Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 受信者向けエンドポイント(認証必須)
Expand Down
6 changes: 3 additions & 3 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,10 @@
<script async src="https://www.googletagmanager.com/gtag/js?id=G-BE16TKNVZ5"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());

gtag('config', 'G-BE16TKNVZ5');
gtag('config', 'G-BE16TKNVZ5', { page_location: location.origin + location.pathname });
</script>
</head>
<body>
Expand Down
202 changes: 202 additions & 0 deletions frontend/src/lib/analytics.ts
Original file line number Diff line number Diff line change
@@ -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" // <img> の 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<ClientErrorParams, "page_path">;

// ---- 制約・サニタイズ ----

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<string, number>();

/** 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<ClientErrorParams, "error_name" | "error_message"> {
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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** (context, resource) ごとに生成済みハンドラをキャッシュし、参照を安定させる。 */
const imageErrorHandlers = new Map<string, ReactEventHandler<HTMLImageElement>>();

/**
* `<img onError>` に渡すハンドラを作る。R2 presigned GET 失敗を image_render として計測する。
* 例: `<img onError={onImageError("gallery-thumb", "thumb")} />`
* GA 送信のみで src は差し替えないため 1 回発火(再レンダーの重複はデデュープで吸収)。
*
* (context, resource) は定数リテラルなので、ハンドラをキャッシュして参照を安定させる。
* レンダーごとのクロージャ生成と、React による onError リスナの張り替え
* (onError は非バブリングで DOM に直付けされる) を避けるため。
*/
export function onImageError(
context: ErrorContext,
resource: ImageResource,
): ReactEventHandler<HTMLImageElement> {
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 回だけ呼ぶ。
* リソースロードエラー(<img> 等)は個別 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 });
});
}
4 changes: 4 additions & 0 deletions frontend/src/main.tsx
Original file line number Diff line number Diff line change
@@ -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(
<StrictMode>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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")}
/>
) : (
<svg
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/GalleryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Button from "../components/ui/Button";
import ConfirmDialog from "../components/ui/ConfirmDialog";
import LoadingSpinner from "../components/ui/LoadingSpinner";
import ScrollToTopButton from "../components/ui/ScrollToTopButton";
import { onImageError } from "../lib/analytics";
import { receiverApi } from "../lib/api";
import { buildZipName, downloadAsZip } from "../lib/zip-download";
import { userAtom } from "../stores/user";
Expand Down Expand Up @@ -664,6 +665,7 @@ export default function GalleryPage() {
className="max-h-full max-w-full rounded-xl object-contain"
loading="lazy"
draggable={false}
onError={onImageError("gallery-thumb", "thumb")}
/>
) : (
<span className="text-2xl text-ink-muted">📷</span>
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/pages/PhotoDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"
}`}
Expand All @@ -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"
}`}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/send/DonePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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}
</div>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/send/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -71,6 +72,7 @@ export default function LandingPage() {
<img
src={receiver.avatar_url}
alt={receiver.display_name}
onError={onImageError("avatar", "avatar")}
className="mx-auto h-20 w-20 rounded-full border-2 border-white object-cover shadow-card"
/>
) : (
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/pages/send/UploadPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
}
}
Expand Down
Loading
Loading