コンテンツにスキップ

Storefront 商品表示・一覧・検索パフォーマンス トラブルシュート

目的

Storefront の / / /products / /search?q=... / /products/$slug が遅い場合に、推測ではなく staging / production の観測結果から 「home shell」「商品 browse 本体」「認証・カート context」「Header / Navigation の background prefetch」「Fly machine cold start」「CMS / collection chrome」のどこで待っているかを切り分けるための手順です。

この runbook は Issue #580 の対応で得た計測手順を正本化したものです。

合格基準

  • staging の authenticated / で、home-contract-brand-list が commit された時刻を示す window.__ritsubiHomeContractBrandsMetrics.durationMs が 1s 未満であること。 window.__ritsubiHomePageMetrics.durationMs は shell commit の補助値として扱い、 ご契約ブランド一覧の合否には使わない。
  • 公式 smoke は tests/e2e/smoke/home-page-performance.real.spec.ts を使い、 中央値 1s 未満、最大 1.2s 以下を最低ラインとして監視する。
  • staging の authenticated /products で、実操作できる header の初回表示 metric window.__ritsubiHeaderMetrics.durationMs が 1s 未満であること。CMS shell 取得待ちの skeleton ではなく、default header model で描画された site-header を測る。
  • 公式 smoke は tests/e2e/smoke/header-performance.real.spec.ts を使い、 中央値 1s 未満、最大 1.2s 以下を最低ラインとして監視する。
  • staging の authenticated /products で、ブラウザ内 first visible metric window.__ritsubiProductsPageMetrics.durationMs が 1s 以内であること。
  • 公式 smoke は tests/e2e/smoke/products-page-performance.real.spec.ts を使い、 中央値 1s 以下、最大 1.5s 以下を最低ラインとして監視する。
  • staging の authenticated /search?q=... で、ブラウザ内 first visible metric window.__ritsubiProductsPageMetrics.durationMs が 1.2s 以内であること。
  • 公式 smoke は tests/e2e/smoke/products-search-performance.real.spec.ts を使い、 中央値 1.2s 以下、最大 1.5s 以下を最低ラインとして監視する。
  • staging の authenticated /products/$slug で、ブラウザ内 first visible metric window.__ritsubiProductDetailPageMetrics.durationMs が 1s 未満であること。
  • 公式 smoke は tests/e2e/smoke/product-detail-performance.real.spec.ts を使い、 中央値 1s 未満、最大 1.2s 以下を最低ラインとして監視する。
  • 追加の 10 サンプル計測では、外れ値を含めて first visible が 1s 程度に収まることを確認する。
  • 顧客主要リスト系は、staging authenticated route で list 本体または empty state の first visible を測る。/account/favorites/account/orders/cart/quick-order は中央値 1.5s 未満・最大 1.8s 以下、/account/addresses/account/gift は中央値 1s 未満・最大 1.2s 以下を最低ラインとして監視する。

最初に確認すること

  1. 対象環境の health / version / machine 状態を確認する。
just env-status staging
  1. Vendure staging の Fly machine が stopped を含んでいないか確認する。
source apps/vendure-server/scripts/prepare-fly-cli-env.sh
flyctl machine list -a ritsubi-ecommerce-staging --json
  1. stopped machine が残っている場合は、cold start 外れ値を疑う。staging を 1 台 warm 構成にする場合は次を実行する。
source apps/vendure-server/scripts/prepare-fly-cli-env.sh
flyctl scale count app=1 -a ritsubi-ecommerce-staging --yes
flyctl machine list -a ritsubi-ecommerce-staging --json

/products が 500 になる場合

/products / /search / home 周辺でブラウザ console に POST /commerce/shop-api 500Vendure API request failed with status 500 が出る場合、Storefront Worker だけでなく Vendure 側の GraphQL guard を先に見る。 CSP の inline handler 警告や /monitoring の 429 が同時に出ていても、商品表示の 500 とは別症状のことがある。

  1. Storefront / Vendure / Dashboard の現在状態を確認する。
just env-status production
  1. Vendure の live log で HardenPlugin の complexity reject を探す。
fly logs -a ritsubi-ecommerce --no-tail -j \
  | rg 'HardenPlugin|Query complexity|commerce/shop-api|Vendure API request failed'

現行 flyctl logs--since を受け付けない。時刻範囲は Sentry / env-status / deploy release 時刻で先に絞り、CLI では ring buffer を rg で絞る。

次のような行が出る場合は、Vendure は落ちておらず、GraphQL query が上限を超えて fail-closed している。

Query complexity of "anonymous" is 9250, which exceeds the maximum of 2500

production の maxQueryComplexity2500 の strict 運用を正本にする。 正常系 query がこの上限を超える場合は、上限緩和ではなく list fragment / take / query shape を軽量化して戻す。

Storefront の raw GraphQL query については deploy 前に同じ判定を preflight できる。

just storefront-graphql-complexity-preflight staging

この recipe は scripts/ops/storefront-shop-api-operation-registry.mjspreflight: true にした Storefront query を対象環境の Vendure Shop API へ直接投げ、Query is too complexCannot query field を build / deploy 前に fail させる。just cloudflare-deploy-storefront <env>just deploy-storefront-staging では、この preflight が Cloudflare upload 前に自動実行される。 新しい raw Shop API query は registry 未登録だと pnpm run lint:storefront-shop-graphql-boundary で落ちる。

  1. 同じ endpoint で小さい query が通るか確認する。これが 200 なら upstream availability ではなく、特定 query の shape が問題。
curl -sS -D /tmp/shop-api.headers -o /tmp/shop-api.json \
  -H 'content-type: application/json' \
  --data '{"query":"query HealthCollections { collections(options: { take: 1 }) { totalItems } }"}' \
  https://medical.ritsubi.co.jp/commerce/shop-api
sed -n '1,30p' /tmp/shop-api.headers
cat /tmp/shop-api.json
  1. collections(topLevelOnly) のような list multiplier が大きい query を疑う。 2026-05-27 の production incident では、Storefront の browse chrome が top-level collection tree 全体を取って complexity 9000 超になり、HardenPlugin に遮断された。修正方針は HardenPlugin を無効化することではなく、必要な root slug だけを collection(slug: "...") で明示取得すること。
query GetCollectionBrowseRoots {
  brands: collection(slug: "brands") {
    id
    name
    slug
    children {
      id
      name
      slug
    }
  }
  productTypes: collection(slug: "product-types") {
    id
    name
    slug
    children {
      id
      name
      slug
    }
  }
  campaigns: collection(slug: "campaigns") {
    id
    name
    slug
    children {
      id
      name
      slug
    }
  }
}
  1. HardenPlugin の上限を変える場合は、先に query を削る。上限緩和は正規導線が少し 超える場合だけに限定する。正規導線の collection browser は flat / paged query (skip / take + parentId) で 200 になることを確認する。
curl -sS -D /tmp/collections.headers -o /tmp/collections.json \
  -H 'content-type: application/json' \
  --data '{"query":"query GetCollectionsForBrowser($skip: Int!, $take: Int!) { collections(options: { skip: $skip, take: $take, sort: { position: ASC } }) { items { id name slug position description parentId featuredAsset { id preview source } } totalItems } }","variables":{"skip":0,"take":100}}' \
  https://medical.ritsubi.co.jp/commerce/shop-api
sed -n '1,30p' /tmp/collections.headers
cat /tmp/collections.json

古い children { children { ... } } 型の nested collection query は packages/sdk/shop/src/operations/operation-complexity-guard.test.ts で再混入を禁止する。

  1. CSP warning が同時に出ている場合は、HTML shell の inline event handler を見る。 2026-05-27 の incident では Google Fonts link の onload="this.media='all'" が CSP に遮断されていた。CSP を緩める前に apps/storefront/index.html の inline handler を消す。

認証 redirect / service-unavailable の切り分け

/ / /products / /search が商品や home shell へ到達せず、ログイン画面で error=service-unavailable になる場合は、商品 query ではなく edge / browser の session validation が first visible を止めている。

  1. /api/auth-session の full session validation を見る。
curl -sS -w '\nstatus=%{http_code} total=%{time_total}\n' \
  'https://order-staging.ritsubi-platform.com/api/auth-session'
  1. cookie があるのに login へ落ちる場合は、Vendure activeCustomer が返っているかを 確認する。/, /products, /search, /products/$slug は得意先別の表示制御・価格・ 購入条件に依存するため、cookie の存在だけでは通さない。

  2. service-unavailable を消すために protected navigation を fail-open しない。 商品 route も含め、_site 配下は full activeCustomer validation を維持する。

  3. browser route guard は requireAuthenticatedCustomerVite() を確認する。 stale cookie を読めても /api/auth-session の Vendure 検証に失敗したら login redirect に倒す。intent=browse-shell のような軽量判定は使わない。

公式 smoke

staging の home / header / 商品カタログ / 商品検索初回表示パフォーマンスは次でまとめて測る。

just storefront-e2e-real staging \
  'tests/e2e/smoke/home-page-performance.real.spec.ts tests/e2e/smoke/header-performance.real.spec.ts tests/e2e/smoke/products-page-performance.real.spec.ts tests/e2e/smoke/products-search-performance.real.spec.ts tests/e2e/smoke/product-detail-performance.real.spec.ts --reporter=line'

個別に商品カタログ初回表示パフォーマンスを測る場合は次を使う。

env \
  PLAYWRIGHT_BASE_URL=https://order-staging.ritsubi-platform.com \
  PLAYWRIGHT_SKIP_WEBSERVER=true \
  PLAYWRIGHT_HTML_OPEN=never \
  E2E_LOGIN_EMAIL=test1@ritsubi-platform.com \
  E2E_LOGIN_PASSWORD='DevCustomer123!' \
  pnpm -C apps/storefront exec playwright test \
    tests/e2e/smoke/products-page-performance.real.spec.ts \
    --config playwright.config.ts \
    --project=chromium

商品検索パフォーマンスは次で測る。

env \
  PLAYWRIGHT_BASE_URL=https://order-staging.ritsubi-platform.com \
  PLAYWRIGHT_SKIP_WEBSERVER=true \
  PLAYWRIGHT_HTML_OPEN=never \
  E2E_LOGIN_EMAIL=test1@ritsubi-platform.com \
  E2E_LOGIN_PASSWORD='DevCustomer123!' \
  pnpm -C apps/storefront exec playwright test \
    tests/e2e/smoke/products-search-performance.real.spec.ts \
    --config playwright.config.ts \
    --project=chromium

商品詳細パフォーマンスは次で測る。

env \
  PLAYWRIGHT_BASE_URL=https://order-staging.ritsubi-platform.com \
  PLAYWRIGHT_SKIP_WEBSERVER=true \
  PLAYWRIGHT_HTML_OPEN=never \
  E2E_LOGIN_EMAIL=test1@ritsubi-platform.com \
  E2E_LOGIN_PASSWORD='DevCustomer123!' \
  pnpm -C apps/storefront exec playwright test \
    tests/e2e/smoke/product-detail-performance.real.spec.ts \
    --config playwright.config.ts \
    --project=chromium

顧客主要リスト系パフォーマンスは次で測る。/account/payments/account/billing/account/orders への redirect route なので個別計測しない。CMS / 公開一覧系 (/campaigns/announcements/articles) はこの smoke の対象外。

just storefront-e2e-real staging \
  'tests/e2e/smoke/customer-list-performance.real.spec.ts --reporter=line'

成功時はログに次のような値が出る。

[home-page-performance] contractBrandsMetricMs=768,713,698,734,724 medianMs=724 maxMs=768
[product-detail-page-performance] slug=exuviance-professional-cleanser durations=599,589,578,605,592 medianMs=592 maxMs=605
[products-page-performance] durations=974,962,916,928,925 medianMs=928 maxMs=974
[products-search-performance] term="20mL" durations=1180,1081,1207,1203,1148 medianMs=1180 maxMs=1207
[customer-list-performance] id=favorites path=/account/favorites durations=1051,1050,1071,1080,1003 medianMs=1051 maxMs=1080
[customer-list-performance] id=orders path=/account/orders durations=1030,982,977,1053,1001 medianMs=1001 maxMs=1053
[customer-list-performance] id=addresses path=/account/addresses durations=716,757,711,710,733 medianMs=716 maxMs=757
[customer-list-performance] id=gift path=/account/gift durations=986,1024,1018,995,984 medianMs=995 maxMs=1024
[customer-list-performance] id=cart path=/cart durations=986,803,864,850,793 medianMs=850 maxMs=986
[customer-list-performance] id=quick-order path=/quick-order durations=750,729,775,770,706 medianMs=750 maxMs=775

2026-05-31 時点の staging 合格ラインは上記の通り。商品詳細のユーザー要件は first visible 1s 未満なので、product-detail-performance.real.spec.ts は中央値 1s 未満を必須、最大 1.2s 以下を外れ値ガードとして扱う。

追加 10 サンプル計測

公式 smoke が通っても、外れ値確認のため同じ authenticated storage state で 10 サンプルを取る。

pnpm -C apps/storefront exec node --input-type=module <<'EOF'
import { chromium, expect } from '@playwright/test';

const baseURL = 'https://order-staging.ritsubi-platform.com';
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
  baseURL,
  storageState: 'tmp/playwright/storefront/e2e/auth/storage-state.json',
});
const page = await context.newPage();

await page.addInitScript(() => {
  window.sessionStorage.setItem('password-change-skip', '1');
  window.sessionStorage.setItem('auth-bypass-skip', '1');
});

const results = [];
for (let i = 0; i < 10; i += 1) {
  const startedAt = Date.now();
  await page.goto(`/products?perfRun=${startedAt}&sample=${i}`, {
    waitUntil: 'domcontentloaded',
  });
  await expect(page.locator('[data-testid="products-page"]:visible').first()).toBeVisible({
    timeout: 30000,
  });
  await expect(
    page.locator('[data-testid^="products-page-item-"][data-testid$="-title"]').first(),
  ).toBeVisible({ timeout: 30000 });
  await expect
    .poll(async () => page.evaluate(() => window.__ritsubiProductsPageMetrics ?? null), {
      timeout: 30000,
    })
    .not.toBeNull();

  const metric = await page.evaluate(() => window.__ritsubiProductsPageMetrics ?? null);
  const nav = await page.evaluate(() => {
    const [entry] = performance.getEntriesByType('navigation');
    if (!(entry instanceof PerformanceNavigationTiming)) return null;
    return {
      responseStartMs: Math.round(entry.responseStart),
      domContentLoadedMs: Math.round(entry.domContentLoadedEventEnd),
      loadEventEndMs: Math.round(entry.loadEventEnd),
      transferSize: entry.transferSize,
    };
  });
  results.push({
    sample: i,
    wallClockMs: Date.now() - startedAt,
    durationMs: metric?.durationMs,
    nav,
  });
}

await browser.close();

const durations = results
  .map((result) => result.durationMs)
  .filter((value) => typeof value === 'number')
  .sort((left, right) => left - right);
const wall = results.map((result) => result.wallClockMs).sort((left, right) => left - right);

console.log(JSON.stringify({
  url: `${baseURL}/products`,
  count: results.length,
  durationMedianMs: durations[Math.floor(durations.length / 2)],
  durationMaxMs: Math.max(...durations),
  wallClockMedianMs: wall[Math.floor(wall.length / 2)],
  wallClockMaxMs: Math.max(...wall),
  results,
}, null, 2));
EOF

商品詳細の外れ値を追加確認する場合は、同じ storage state で /products/$slug を 10 サンプル取る。

pnpm -C apps/storefront exec node --input-type=module <<'EOF'
import { chromium, expect } from '@playwright/test';

const baseURL = 'https://order-staging.ritsubi-platform.com';
const slug = 'exuviance-professional-cleanser';
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
  baseURL,
  storageState: 'tmp/playwright/storefront/e2e/auth/storage-state.json',
});
const page = await context.newPage();

await page.addInitScript(() => {
  window.sessionStorage.setItem('password-change-skip', '1');
  window.sessionStorage.setItem('auth-bypass-skip', '1');
});

const results = [];
for (let i = 0; i < 10; i += 1) {
  const startedAt = Date.now();
  await page.goto(`/products/${slug}?perfRun=${startedAt}&sample=${i}`, {
    waitUntil: 'domcontentloaded',
  });
  await expect(page.getByTestId('product-detail-page')).toBeVisible({ timeout: 30000 });
  await expect(page.getByTestId('product-detail-page-detail-title')).toBeVisible({
    timeout: 30000,
  });
  await expect
    .poll(async () => page.evaluate(() => window.__ritsubiProductDetailPageMetrics ?? null), {
      timeout: 30000,
    })
    .not.toBeNull();

  const metric = await page.evaluate(() => window.__ritsubiProductDetailPageMetrics ?? null);
  const nav = await page.evaluate(() => {
    const [entry] = performance.getEntriesByType('navigation');
    if (!(entry instanceof PerformanceNavigationTiming)) return null;
    return {
      responseStartMs: Math.round(entry.responseStart),
      domContentLoadedMs: Math.round(entry.domContentLoadedEventEnd),
      loadEventEndMs: Math.round(entry.loadEventEnd),
      transferSize: entry.transferSize,
    };
  });
  results.push({
    sample: i,
    wallClockMs: Date.now() - startedAt,
    durationMs: metric?.durationMs,
    nav,
    relatedProductsCount: metric?.relatedProductsCount ?? null,
    hasWpContent: metric?.hasWpContent ?? null,
  });
}

await browser.close();

const durations = results
  .map((result) => result.durationMs)
  .filter((value) => typeof value === 'number')
  .sort((left, right) => left - right);
const wall = results.map((result) => result.wallClockMs).sort((left, right) => left - right);

console.log(JSON.stringify({
  url: `${baseURL}/products/${slug}`,
  count: results.length,
  durationMedianMs: durations[Math.floor(durations.length / 2)],
  durationMaxMs: Math.max(...durations),
  wallClockMedianMs: wall[Math.floor(wall.length / 2)],
  wallClockMaxMs: Math.max(...wall),
  results,
}, null, 2));
EOF

Cloudflare Worker tail での切り分け

外れ値が残る場合は、staging Worker tail を取りながら同じ計測を再実行する。

mkdir -p tmp/storefront/wrangler-logs
tmp_log="tmp/storefront/wrangler-logs/products-tail-$(date +%s).jsonl"
echo "$tmp_log"

timeout 60s ./scripts/ops/with-cloudflare-auth.sh -- \
  pnpm --dir apps/storefront exec wrangler tail ec-storefront \
    --env staging \
    --format json \
    --sampling-rate 0.99 \
  > "$tmp_log" 2>&1

CLOUDFLARE_API_TOKEN はローカル troubleshooting では注入しない。Cloudflare 操作は wrangler login のユーザー OAuth を正とし、OAuth エラーを共有 token fallback で隠さない。

tail では次を確認する。

  • /products?...wallTimecpuTime
  • cpuTime が小さく wallTime が大きい場合は、Worker CPU ではなく upstream / background data prefetch fanout 待ちを疑う。
  • Slow GraphQL requestoperationName
  • referer/products?... のまま、無関係な account / home などへの prefetch request が並走していないか。

よくある原因と判断

  • /productsresponseStartMs が 1s 超: server render / upstream 待ち。 tail で slow operation を確認する。
  • GetVisibleProductsForBrowseCard が遅い: visible browse 本体。 Vendure 側 visibleProductsForBrowse と process-local cache を確認する。
  • /productsresponseStartMs は速いが first visible だけ 1.5s 以上へ跳ねる: streaming される browse result の外れ値。visibleProductsForBrowse の batch policy / visibility cache を確認する。
  • /products/$slugresponseStartMs は速いが first visible だけ 5s 超へ跳ねる: detail 本体ではなく CMS / recommendation 待ち。detail loader で補助データを直列化していないか確認し、 tail で upstream を切り分ける。
  • /products/$slug が 1s を超える: 先に visibleProductForDetail(slug) が使われているか確認する。 旧 product(slug) + 別途 validateVisibility の 2 段 query に戻すと、detail 本体の upstream 待ちだけで 1s を超えやすい。CMS / recommendation / collection chrome は first visible の正本に含めず、detail 本体と並列または後続で読む。
  • /products/$slug の初回だけ route chunk discovery で遅い: Worker の Link: rel=modulepreloaddist/route-preloads.json を確認する。 apps/storefront/scripts/cloudflare-build.mjsproduct._slug-* などの route chunk を manifest 化し、apps/storefront/worker.js が product detail HTML へ Link header を付ける。
  • /, /products, /search, /account/*, /cart, /quick-order が全体的に 100ms 単位で重い: route 個別 API より先に Storefront Worker の SPA shell 経路を見る。 SPA navigation は存在しない route path を ASSETS に確認してから / へ fallback すると、 全 route で asset miss + shell 取得が二重化する。apps/storefront/worker.jsreadSpaShellAsset() が root shell を Worker isolate 内で再利用し、既知の SPA route は 直接 shared shell から返す状態を正本にする。
  • /products/$slug の SPA loader 開始後に Shop API を待っている: Worker の inline data preload を確認する。product detail HTML では CSP hash 付きの小さい script が visibleProductForDetail fetch を route loader より先に開始し、 window.__ritsubiProductDetailPreload.promise を loader が再利用する。 CSP を unsafe-inline で緩めない。
  • GetActiveOrderContext が同時に遅い: 初回 paint に不要な active order hydration。 /products では初回 commit 後へ遅延させる。
  • /, /products, /searcherror=service-unavailable の login へ落ちる: edge / browser の full session validation。cookie-only 判定へ戻さず、Vendure activeCustomer 検証の遅延・失敗原因を確認する。
  • account / home など無関係 route の prefetch が並走: Header / Navigation の自動 prefetch fanout。 Header の常時表示 Link は prefetch={false} にする。
  • Fly machine に stopped が混在: auto-start / cold start 外れ値。 staging の app machine を warm な 1 台構成に整理する。
  • CMS GraphQL 530 が混在: CMS / announcement / home prefetch。 商品 browse の KPI から切り離し、CMS 側は別 issue として扱う。
  • 顧客主要リストだけが遅い: route shell ではなく list 本体または empty state の first visible を見る。customer-list-performance.real.spec.tsreadySelector が page shell だけを見ていないことを先に確認し、初期表示に不要な full session validation、active order hydration、補助 API、画像 / route chunk discovery、 client-only duplicate fetch を切り分ける。注文作成などの mutation で fixture を作らず、 データが無い場合も empty state を正規の first visible として扱う。
  • 2026-05-31 の staging では protected shell marker、activeCustomer preload、account list の activeOrder 遅延、CMS shell 遅延、gift/order/favorites の route preload に加え、 favorites を backend 側の可視性フィルタ済み正本へ寄せ、client-side ValidateVisibility 直列を撤去した。orders は初期一覧 fragment から lines -> productVariant -> bundleItems/assets/product の深い hydration を外し、 注文カード一覧は注文コード・状態・数量・金額まで、明細は注文詳細 route で取得する。 この状態で /account/favorites/account/orders/account/addresses/account/gift/cart/quick-order は上記の基準内に収まった。

2026-05 staging 調査メモ

  • 2026-05-31 の staging /products?collection=mesoceutical では、画像付き一覧の first visible が durations=2871,2480,1761,2958,2619 (median 2619ms) まで悪化した。根本原因は単一 query の遅さだけではなく、(1) 商品グリッドが collectionIdsWithVisibleProducts を待つ UI 直列、(2) route loader が商品本体 fetch 前に catalog 全体の可視 collection 走査を待つ直列、(3) browse card から activeOrderCommercialState / rich price breakdown が商品数分発火する fanout、 (4) UI は 9 件ページなのに loader が 24 件 hydrate していた過剰取得、(5) HTTP-only cookie 認証のため browser 側 visible-search cache key が作れず同一タブ再計測で cache が効かない、の複合だった。
  • 同 incident の対処後、staging Worker 18eb1b4d-78d7-4df0-96a1-8922efa727d0 で公式 smoke は durations=820,679,726,765,637 medianMs=726 maxMs=820 に収まった。再発時は 「1 query を少し速くする」より先に、下記の通信形が戻っていないか確認する。
  • GetVisibleProductsForBrowseCardGetCollectionIdsWithVisibleProducts 完了後まで開始しない。
  • GetVisibleProductsForBrowseCardtakePRODUCTS_PAGE_SIZE (= 9) を超えている。
  • 商品カード表示直後に ActiveOrderCommercialState が商品数分並ぶ。
  • sessionStorage に ritsubi:visible-search: が作られず、同一タブの連続 browse でも GetVisibleProductsForBrowseCard が毎回 upstream へ出る。
  • /products browse card は first visible を優先する surface であり、同時にカート投入前の pre-cart surface として扱う。単一 variant の価格表示は ProductPrice / ProductPriceSummarydisplayMode="preCart" を使い、上代単価だけを表示する。 activeOrderCommercialState による価格内訳、hover / focus / click の内訳 dialog、 i 詳細は商品一覧では出さない。初期表示で必要なのは画像、商品名、上代税込価格または 上代税込レンジ、詳細導線まで。
  • 一覧上のカート CTA を戻す場合も、pre-cart で価格内訳 fetch を戻してはいけない。発注可否・在庫・ active order mutation は副作用面が大きいため、商品数分 fanout を起こさない shared bulk API または post-first-visible lazy hydration を先に設計する。
  • collectionIdsWithVisibleProducts は sidebar / category chip の正本だが、商品本体 fetch の critical path に置かない。可視でない子孫 collection slug が混ざっても visibleProductsForBrowse が variant policy で fail-closed に絞るため、商品取得は collectionGroups だけで選択 collection / 子孫 slug を解決して開始する。
  • browser 側 visible-search cache は HTTP-only session cookie を直接 key に含められない。 そのため同一タブ限定の http-only-session-tab scope を使い、/api/auth-login / /api/auth-logout へ submit する共通入口で ritsubi:visible-search: prefix を削除する。 localStorage や cross-tab cache に広げると別アカウントの可視商品を誤表示しうるため禁止。
  • /products の tail latency は、visibleProductsForBrowse が sparse な page で requestedTake より大きく 2nd/3rd batch を膨らませると再発しやすい。page-size browse は「次の 1 page + overfetch」までに cap し、回帰 test を packages/plugins/src/rule-engine/visibility/resolvers.spec.ts へ置く。
  • 2026-05-31 以降の /products?collection=mesoceutical は Storefront 初期表示で PRODUCTS_PAGE_SIZE (= 9) だけを visibleProductsForBrowse に渡す。requestedTake: 24requestedTake: 200、全件取得が production log に出た場合は古い bundle / deploy drift または route loader の過剰取得回帰を疑う。
  • ブランド「すべて」は collectionSlugs を使う 1 query が正本。親 + 子 collection を Storefront 側で N 回 visibleProductsForBrowse へ分割すると、child collection 数に比例して first visible が悪化する。
  • Vendure log の visible-products-browse では requestedTake, durationsMs, bottleneckStage, batchCount を見る。L2 hit は visible-products-browse-shared-cache-hit。page-size browse で hydrateProducts が 全 collection 件数分に伸びている場合は、page 商品だけ hydrate する回帰 test を確認する。
  • visibleProductsForBrowse 内の visibility 判定で同一 resolver 内の cache を forceFresh: true で何度も破棄しない。外部 API の validateVisibility は fresh validation を維持するが、browse resolver 内部の validateIds / filterVisibleVariantItems は同一 request/shared context の cache を使う。
  • / の KPI は home first viewport shell の表示で測る。hero / announcement の Promise は先に起動しても初期 shell を待たせず、ranking / brand / footer などの下段 chunk は HomePageDeferredContent として initial commit 後へ回す。
  • responseStartMsdomContentLoadedMs が 100ms 前後でも window.__ritsubiProductsPageMetrics.durationMs だけが跳ねる場合、Worker shell ではなく streaming される browse result 側の待ち時間を疑う。
  • 2026-05-31 の staging では、Storefront Worker が既知の SPA route に対して /products などの path を先に ASSETS へ問い合わせず、root shell を per-binding isolate cache から返すようにした。これにより /products/search、商品詳細、 account / cart / quick-order / checkout 系で共通して発生する asset miss + fallback shell 取得の二重化を避ける。回帰は apps/storefront/src/worker-entry.test.tsreuses the root SPA shell for authenticated browse navigations で検知する。
  • /products/$slug は detail 本体の query 成功後に CMS と recommendation を待つため、 補助データ fetch は直列にしない。apps/storefront/src/lib/products-page-helpers.ts で Promise を先に起動して並列待ちにする。
  • /products/$slug の価格は detail / browse card とも Vendure Shop API の ProductVariant.priceWithTax / currencyCode を正本にする。detail 専用 API でも browse card と同じ価格 source を返し、軽量化のために別の「軽量価格」を作らない。 内訳は同じ意味内容を維持し、必要なら hover 後に追加取得する。価格の本体表示を sessionStorage や Worker cache だけで長時間固定しない。
  • 商品詳細の同一タブ短時間再訪は sessionStorageritsubi:product-detail:${slug} を 30 秒 TTL で吸収する。これは同一 browsing session の重複 navigation を潰すための補助であり、価格・可視性の長期正本ではない。 TTL を延ばす場合は価格変更と可視性変更の stale 表示リスクを先に評価する。
  • local で just deploy-storefront-staging / build-vendure-image が agent worktree を Nx project graph に拾って失敗する場合は、.nxignore.claude/worktrees/** を除外する。
  • Vendure staging deploy が遅い場合、Storefront 反映や Dashboard deploy ではなく scripts/ops/build-vendure-image.sh の Docker image build/push が詰まっていることがある。 just manual-deploy-vendure staging "" "" false でも image_ref が空なら backend image build は残るため、まず pgrep -af 'docker buildx build|build-vendure-image|manual-deploy-vendure' で in-flight process を確認する。
  • local の Vendure image build は registry cache を read-only に使い、local filesystem cache は 既定 BUILDX_LOCAL_FS_CACHE_MODE=min~/.cache/ritsubi/vendure-buildcache が肥大化している 場合、mode=max の cache export I/O が待ち時間を支配し得るため、通常 deploy では mode=min のままにする。中間 layer まで local に残したい調査時だけ BUILDX_LOCAL_FS_CACHE_MODE=max を明示する。
  • CI の Vendure image build は registry.fly.io/<app>:vendure-buildcache と GitHub Actions cache (type=gha,scope=vendure-image) を併用する。R2 / GitHub artifact は BuildKit cache 本体ではなく、vendure-image-build-<env>-<sha> artifact として保存される build log / .summary.json の診断に使う。
  • 実 deploy の image build log は local では ${TMPDIR:-/tmp}/ritsubi-vendure-image-build/<env>-<sha>-<timestamp>.log、CI では vendure-image-build-<env>-<sha> artifact に残る。.summary.jsonslowestSteps を見て、 pnpm installCOPYexporting cachepushing image のどれが支配的かを切り分ける。
  • cart smoke の候補選定は UI-only fallback を全候補へ強制しない。まず API で activeOrder に積める slug を見つけ、UI fallback は API 不可時だけに使う。
  • production へ promoted image を載せる前に、release phase migration が staging 固有 schema を前提にしていないか確認する。今回の FixCustomFieldStorageAndNullabilityDrift20260508142000order.customFieldsPaperconsentrequired が無い production で落ちたため、 column existence check を入れて idempotent にした。
  • Cloudflare Worker を OpenNext から Vite へ移行した環境では、過去の Durable Object class export を消すと wrangler versions uploadcode: 10064 で失敗する。apps/storefront/worker.js に互換 stub export を残し、明示的な delete-class migration を設計するまでは削除しない。
  • wrangler login 済みでも env-status / cloudflare:deploy:* / cloudflare-verify-storefront / cloudflare-deployments-status/accounts/a5e9...Authentication error [code: 10000] / 403 で落ちる場合、OAuth user が対象 account に所属していない可能性がある。staging / production の緊急反映では CLOUDFLARE_ALLOW_TOKEN_FALLBACK=1 just cloudflare-deploy-storefront <env>CLOUDFLARE_ALLOW_TOKEN_FALLBACK=1 just cloudflare-verify-storefront <env> tokenCLOUDFLARE_ALLOW_TOKEN_FALLBACK=1 just cloudflare-deployments-status <env> で Secrets Manager token fallback を明示的に使う。ただし恒久対応は Cloudflare account membership / Wrangler authentication の修正であり、fallback を黙って既定化しない。
  • 同一 commit SHA の Vendure image を staging へ再反映する場合、registry 既存 image を そのまま再利用すると build artifact が更新されない。schema / plugin 変更を同じ SHA で 反映する必要がある時は VENDURE_IMAGE_BUILD_IF_EXISTS=rebuild を使い、反映後に just env-status staging --format json の Vendure buildTime が新しくなったことを確認する。
  • support 導線の canonical slug は CMS の実 publish 状態を正本にする。 production では /support/logistics の published page は /pages/logistics であり、/pages/direct-shipping-guide へ寄せると global error page へ落ちる。
  • production price-normal principal の cart smoke は、/products の先頭に 見える visible listing が smile-*OUT_OF_STOCK SKU 偏重だと browser-only 購入導線を成立させられない。helper 側では stockLevel !== OUT_OF_STOCK の variant を先に選ぶが、それでも cartable product が見つからない場合は code bug ではなく production catalog / visibility / inventory の運用問題として扱う。

2026-05-20 Sentry N+1 (#673) 対応メモ

  • Sentry の b2b-commerce-vendureVisibleProductsForBrowse resolver が N+1 として 6 issue 検出。実体は単一クエリ N+1 ではなく、batch ループの 反復ごとに同形 query (search → validateIds → fallback variant find → detail variant find → productService.findByIds) が連続して走るパターンが Sentry の heuristic に拾われたもの。
  • 一次対応として visibility-shop.resolver.ts の in-process cache を 匿名ユーザーへ拡張した (commit 591a03a89)。CustomerVisibilityService も "anonymous" 軸で context を共有しているため、channel + input + relations が同一なら decision は決定的で correctness は維持される。
  • 二次対応は次の三方向で設計検討中 (本ドキュメント末尾の "#673 残り打ち手" 節を参照)。コミット単位で順に適用し、その都度 staging で window.__ritsubiProductsPageMetrics と Sentry の batch metrics (visible_products_browse.batch_total 等) を確認する。
  • (a) visibility filter を search クエリ層に push down してループ自体を排除
  • (b) cache TTL / LRU の調整による hit rate 改善
  • (c) batch ループの round-trip 短縮 (representative variant validate と fallback fetch の sequencing 最適化)

2026-05-20 Sentry Slow DB Query (#674) / Failed to fetch (#675) 対応メモ

TypeORM の DISTINCT subselect が原因の Slow DB Query

Sentry の Slow DB Query (#674) の半数以上が以下の query 形に集中していた:

SELECT DISTINCT "distinctAlias"."<Entity>_id"
FROM (SELECT ... 多数の LEFT JOIN ...) "distinctAlias"
WHERE "<Entity>"."id" = $1
ORDER BY "<Entity>_id" ASC LIMIT 1

代表 issue:

  • B2B-COMMERCE-VENDURE-Q (count 949): ProductVariant の DISTINCT subselect
  • B2B-COMMERCE-VENDURE-Z (count 273): Product の DISTINCT subselect
  • B2B-COMMERCE-VENDURE-25 (count 395): Collection の DISTINCT subselect

原因: TypeORM の find / findOne で M:N relation (ProductVariant.collectionsCustomer.groups 等) を含めると、行多重を deduplicate するために DISTINCT subselect を伴う query を生成する。単一 ID 検索 (WHERE id = $1 LIMIT 1) の場合、まず全 join を materialize してから DISTINCT で 1 行に絞るため極端に遅い (~300ms+)。

対処: TypeORM 0.3.x の relationLoadStrategy: "query" を指定すると、本体を取得 → 各 relation を別 query で取得という戦略になり DISTINCT subselect が発行されない。

const variant = await repo.findOne({
  where: { id },
  relations: ["product", "collections"], // M:N 含む
  relationLoadStrategy: "query", // ← これを足す
});

適用済み: commit 4d3784dbb

  • packages/plugins/src/rule-engine/commercial/services/commercial-rule.service.ts
  • packages/plugins/src/rule-engine/visibility/customer-visibility.service.ts
  • packages/plugins/src/rule-engine/visibility/visibility-shop.resolver.ts

今後の追加検出パターン: EXPLAIN ANALYZE で query plan が Hash Right Join + HashAggregate (key: <Entity>_id) を含み、distinctAlias 名のサブクエリが見えたら同じ pattern。relationLoadStrategy: "query" を試す。

Sentry SDK 10 の Metrics は experimental flag 必須

Sentry.metrics.count / distribution / gauge は SDK 10 では _experiments.enableMetrics: true を init config に渡さない限り silent no-opapps/vendure-server/src/observability/sentry-config.ts で有効化済み (commit fea099499)。

別の Vendure server を立てる場合、または @sentry/cloudflare 等別 SDK で metrics を使う場合は同じ flag が必要かどうかを確認する。

Storefront fetch retry 戦略 (#675)

vendureFetch (apps/storefront/src/lib/vendure-fetch.ts) は GraphQL query (副作用なし) に限り 1 回までリトライする (NETWORK_RETRY_MAX_ATTEMPTS = 2NETWORK_RETRY_BASE_DELAY_MS = 250 * attempt)。

リトライ対象は error.name === "TypeError" (= ブラウザの fetch network failure) のみ。AbortError や JSON parse 等はリトライしない。リトライ発火は storefront.graphql_network_retry_count tag で観測。

mutation はリトライしない (副作用の二重実行を避けるため)。将来 idempotency key を導入したら mutation も拡張可能。

実装上の注意

  • 顧客可視性に依存する /products は shared HTTP cache で解決しない。
  • Cookie / Authorization に依存する route / fetch は no-store を維持する。
  • 許容される cache は、同一 channel / input 内の短時間 process-local duplicate absorption に限定する。subject 軸は user:<activeUserId>anonymous の二系統で、authenticated 結果が anonymous bucket に 混入しないこと、anonymous 結果が customer-specific visibility を仮定 しないことを resolver 側で保証する。
  • Header / Navigation の prefetch={false} は、クリック後の遷移を止めるものではない。初回表示中の不要な background data prefetch だけを止める。
  • Storefront / Cloudflare の互換性問題を外部ライブラリ patch で回避しない。
  • TypeORM find / findOne で M:N relation を含める場合は 必ず relationLoadStrategy: "query" を指定する。指定漏れは Sentry の Slow DB Query (#674) を量産する。M:N 判定は entity 定義で @ManyToMany を持つ relation (ProductVariant.collectionsCustomer.groupsOrder.channels など) が対象。
  • storefront の GraphQL queryvendureFetch 経由で自動的にネットワーク リトライされる。リトライしてほしくない query は options.captureFailuresfalse にして自前で扱うか、mutation として実装する。

DB インデックス最適化 トラブルシューティング

DB インデックス最適化(2026-02-12 デプロイ)後の商品検索パフォーマンス問題は、以下の手順で診断します。

1. Migration 適用状態の確認

# Staging proxy を起動
just proxy-postgres env=prod port=15433 &

# Migration 実行状態を確認
export PGPASSWORD=$(aws secretsmanager get-secret-value \
  --secret-id b2b-ecommerce/staging/vendure-db-password \
  --region ap-northeast-1 \
  --query SecretString --output text)

psql -h localhost -p 15433 -U postgres -d vendure_staging -c "
  SELECT name FROM migrations
  WHERE name LIKE '%Index%1777%'
  ORDER BY name;
"

# Expected output:
# - Add_visibility_lookup_indexes_1777874700000
# - Add_search_trigram_indexes_1777918200000

2. Index 実装状態の確認

psql -h localhost -p 15433 -U postgres -d vendure_staging <<'EOF'
-- FK lookup indexes
SELECT indexname, tablename
FROM pg_indexes
WHERE indexname LIKE 'IDX_%resourceSetId%'
   OR indexname LIKE 'IDX_%subjectSetId%'
ORDER BY tablename, indexname;

-- Search trigram indexes
SELECT indexname, tablename
FROM pg_indexes
WHERE indexname LIKE 'IDX_search_index_item_enabled%trgm'
ORDER BY indexname;

-- Browse composite index
SELECT indexname, tablename
FROM pg_indexes
WHERE indexname LIKE 'IDX_search_index_item_enabled_channel_language%'
ORDER BY indexname;
EOF

3. EXPLAIN ANALYZE で query plan を確認

問題: 商品検索が遅い場合

psql -h localhost -p 15433 -U postgres -d vendure_staging <<'EOF'
-- Browse query の execution plan を測定
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT s.id, s.productId, s.productName, s.productVariantId, s.priceWithTax
FROM search_index_item s
WHERE s.channelId = 'channel-1'
  AND s.languageCode = 'en'
  AND s.enabled = true
ORDER BY s.createdAt DESC
LIMIT 10;

-- 期待結果: Index Scan (Seq Scan ではない)
-- 確認項目:
-- - Plans[0].Plan.Node Type が "Index Scan" または "Index Only Scan"
-- - Plans[0].Plan."Rows Removed by Filter" が小さい(selectivity が高い)
-- - Plans[0].Execution."Buffers"."Shared Hit" が大きい(キャッシュヒット率が高い)

-- 検索キーワードでの trigram search の plan
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT s.id, s.productId, s.productName
FROM search_index_item s
WHERE s.enabled = true
  AND s.productName ILIKE '%keyword%'
LIMIT 10;

-- 期待結果: Index Scan using IDX_search_index_item_enabled_productName_trgm
EOF

4. Index サイズと効率を測定

psql -h localhost -p 15433 -U postgres -d vendure_staging <<'EOF'
-- FK lookup indexes のサイズ
SELECT
  schemaname, tablename, indexname,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_indexes i
JOIN pg_class c ON c.relname = i.indexname
WHERE (i.indexname LIKE 'IDX_%resourceSetId%'
    OR i.indexname LIKE 'IDX_%subjectSetId%'
    OR i.indexname LIKE 'IDX_search_index_item_enabled%')
ORDER BY pg_relation_size(indexrelid) DESC;

-- 部分インデックスの効率(WHERE enabled=true の削減率)
SELECT
  tablename,
  COUNT(*) AS total_rows,
  (SELECT COUNT(*) FROM search_index_item WHERE enabled = true) AS enabled_rows,
  ROUND(100.0 * (SELECT COUNT(*) FROM search_index_item WHERE enabled = true)
        / COUNT(*), 1) AS coverage_percent
FROM search_index_item
GROUP BY tablename;
EOF

5. よくある問題と対処

問題 原因 確認方法 対応
Migration が実行されていない TypeORM 起動時エラー または migration 順序問題 migrations テーブルで 1777* が存在するか確認 1. Vendure ログを確認 2. Migration lint を再実行 3. Fly restart
Index は存在するが遅い Query plan が Seq Scan を使用 EXPLAIN で Node Type を確認 1. Column statistics を更新: ANALYZE search_index_item 2. Planner threshold を確認
Partial index なのに大きい WHERE enabled=true フィルタが効かない pg_indexes で indexdef に WHERE 句があるか確認 Index 定義を migration で確認、DROP/CREATE で再構築
Trigram search が遅い pg_trgm extension がロードされていない SELECT extname FROM pg_extension WHERE extname='pg_trgm' Extension が存在しない場合は Application startup error と一緒にログに出る

6. Diagnostics SQL スクリプト

完全な診断を実行する場合:

psql -h localhost -p 15433 -U postgres -d vendure_staging \
  -f apps/vendure-server/scripts/products-search-diagnostics.sql

このスクリプトが提供する:

  • Migration drift 検出
  • FK column gap 監査
  • Index 存在確認
  • Partial index 効率測定
  • EXPLAIN テンプレート

7. Rollback 手順

問題が深刻な場合の緊急対応:

# 1. 最新の migration を確認
psql -h localhost -p 15433 -U postgres -d vendure_staging -c \
  "SELECT name FROM migrations ORDER BY id DESC LIMIT 5;"

# 2. Index を個別に削除(最後に追加した順に削除)
psql -h localhost -p 15433 -U postgres -d vendure_staging <<'EOF'
-- Search trigram indexes を削除
DROP INDEX IF EXISTS IDX_search_index_item_enabled_productName_trgm;
DROP INDEX IF EXISTS IDX_search_index_item_enabled_productVariantName_trgm;
-- ... (他の index)

-- FK lookup indexes を削除
DROP INDEX IF EXISTS IDX_resource_set_item_entity_resourceSetId;
-- ... (他の index)
EOF

# 3. Migration レコードを削除(最後に追加した 2 つ)
psql -h localhost -p 15433 -U postgres -d vendure_staging <<'EOF'
DELETE FROM migrations WHERE name LIKE 'Add_search_trigram_indexes%';
DELETE FROM migrations WHERE name LIKE 'Add_visibility_lookup_indexes%';
EOF

# 4. App を再起動(migrations テーブルから削除されたため、TypeORM は再度実行しない)
just deploy-fly staging

パフォーマンス期待値

DB 最適化導入後の期待値:

メトリック 最適化前 最適化後 期待
FK lookup seq_scan 大量 0 100% 削減
Index utilization 3% 95%+ ✅ 圧倒的改善
Browse query cost 1000+ 50-100 ✅ 10-20x 高速化
Partial index size full <20% ✅ Storage 削減

2026-05-28 商品カタログ Vendure API コール削減 (PR: b64b254b9)

superseded: 本節の 48→200 page-size 拡大は、後述「2026-05-28 per-variant pricing N+1 除去 / 単一フェッチ / L2 browse cache」の 単一フェッチ化(take 未指定で全件を 1 レスポンス) に置き換えられた。現行の正は単一フェッチ。以下は経緯として残す。

変更内容

GROUPED_COLLECTION_PRODUCTS_PAGE_SIZE48 → 200 へ拡大し、while ループの HTTP 往復を削減した。あわせて applyProductsCatalogFetchSentryScope を追加し、 API コール数と取得商品数を Sentry タグで観測できるようにした。

  • コミット: b64b254b9 feat(storefront): increase product page size and add catalog fetch observability
  • 変更ファイル:
  • apps/storefront/src/routes/products.tsxGROUPED_COLLECTION_PRODUCTS_PAGE_SIZE: 48 → 200、テレメトリ呼び出し追加
  • apps/storefront/src/lib/observability/products-browse.tsapplyProductsCatalogFetchSentryScope 追加

期待効果

コレクション商品数 変更前(APIコール数) 変更後(APIコール数)
〜100商品 3回(最大 ~600ms) 1回(最大 ~250ms)
〜150商品 4回(最大 ~800ms) 1回(最大 ~350ms)
〜200商品 5回(最大 ~1000ms) 1回(最大 ~400ms)
200商品超 N回 ⌈商品数/200⌉回

Sentry でのコール削減確認方法

デプロイ後、Sentry (b2b-commerce-storefront) の Transaction 詳細で以下のタグを確認する。

storefront.products.catalog_fetch_call_count = 1   ← mesoceutical は期待値 1
storefront.products.catalog_total_products   = <実際の商品数>
storefront.products.page_size                = 200

catalog_fetch_call_count > 1 が引き続き観測される場合は、対象コレクションの 商品数が 200 を超えている(または Worker → Vendure のページサイズが意図通り 渡っていない)ため、以下で実際の商品数を確認する。

# staging の /products?collection=mesoceutical を叩いて totalItems を確認
curl -sS -H 'content-type: application/json' \
  --data '{"query":"query { search(input: { collectionSlug: \"mesoceutical\", groupByProduct: true, take: 1 }) { totalItems } }"}' \
  https://commerce-staging.ritsubi-platform.com/commerce/shop-api \
  | python3 -m json.tool

totalItems が 200 超の場合は GROUPED_COLLECTION_PRODUCTS_PAGE_SIZE のさらなる 拡大を検討する(ただし 400 が Vendure 側の PRODUCTS_BROWSE_MAX = 400 の上限)。

catalog fetch 公式 smoke

staging の /products?collection=mesoceutical のコール削減を計測する。

cd apps/storefront
just storefront-e2e-real staging tests/e2e/smoke/products-page-performance.real.spec.ts --project=chromium

smoke の catalog_fetch_call_count は現時点ではブラウザから直接観測できないため、 Sentry Transaction で確認する。ページ表示速度は window.__ritsubiProductsPageMetrics.durationMs で引き続き計測可能。

2026-04-30 staging の確認結果

  • Storefront Worker version: 85d4fca8-4bab-46d7-8043-f0e477aa0b4a
  • Vendure staging: started 1 / total 1
  • 公式 smoke: 361,319,330,306,333ms、median 330ms、max 361ms
  • 追加 10 サンプル: browser first visible median 256ms、max 985ms

この状態で /products の first visible は 1s 程度の範囲内に収まっている。

2026-05-28 per-collection take=1 廃止・Vendure browse キャッシュのユーザー拡張

背景と問題

2026-05-28 の staging 計測で、初回 first visible metric が 7354ms と判明した。 2026-04-30 計測(330ms)との差異はユーザー認証状態と機能追加の複合。

ボトルネック1: sidebarChromePromise ブロッキング(解消済み: a65bc53dc

ProductsResults コンポーネントが use(sidebarChromePromise) で suspend し、 resolveBrowseGroupsWithVisibleProducts(12 並列 take=1 API コール)の完了まで 商品グリッドを表示できなかった。resolveProductsResultsCollectionGroups(fast path)を新設し、ProductsResults を先行描画できるよう切り替えた。

ボトルネック2: Sidebar・Nav の per-collection take=1 チェック

Sidebar(5 コレクション)と SiteShell Nav(6 コレクション)が resolveBrowseGroupsWithVisibleProducts 経由で各コレクションに GetVisibleProductsByCollectionSlug(take=1) を並列発行していた。

Batch 2 (sidebar): +6499–6771ms(browser responseEnd)
Batch 3 (site-shell): +3807–6109ms(browser responseEnd)

これらは商品グリッドをブロックしないが、sidebar と nav の描画を大幅に遅延させた。

修正: resolveBrowseGroupsWithVisibleProducts を廃止し、 既存の GetVisibleCollectionIds の結果(1回のみ発行)を filterCollectionBrowseGroupsByVisibleIds でそのまま使うように切り替えた。

  • apps/storefront/src/lib/products-page-helpers.ts — Sidebar の Batch 2 削除
  • apps/storefront/src/lib/site-shell-helpers.ts — SiteShell Nav の Batch 3 削除
  • apps/storefront/src/routes/products.tsx — catalog-root redirect の Batch 削除

ボトルネック3: 認証済みユーザーに対する Vendure browse キャッシュ欠落

visibleProductsForBrowse resolver が認証済みユーザー(subject === "user")に 対して 30秒 TTL のキャッシュを完全バイパスしていた。Anonymous ユーザーは2回目以降 が高速だが、認証済みユーザーは毎リクエストでフル DB 検索・variant 可視性チェックを実行。

Node.js headers 受信から browser response body 完了まで 2607ms のダウンロード 遅延が観測された(大きなレスポンスボディ)。

修正: resolveVisibleProductsBrowseCacheDescriptor で customer group IDs を キャッシュキーに含め、認証済みユーザーも同一グループ構成であれば 30秒 TTL の キャッシュを利用できるようにした。

  • packages/plugins/src/rule-engine/visibility/visibility-shop.resolver.ts
  • resolveVisibleProductsBrowseCacheDescriptor: customer group IDs をキーに追加
  • readVisibleProductsBrowseCache / writeVisibleProductsBrowseCache: subject === "user" の早期リターン削除

キャッシュキー構造:

{
  "cacheSubject": "user",
  "channelId": "<channelId>",
  "revision": null,
  "customerGroupIds": ["<groupId1>", "<groupId2>"],
  "input": { "collectionSlug": "...", ... },
  "relations": [...]
}

getCustomer(ctx)WeakMap<RequestContext> per-request キャッシュ付き(追加 DB 往復なし)。グループ構成が同一の顧客間でキャッシュを共有し、 PRODUCTS_BROWSE_CACHE_MAX_ENTRIES = 128 上限で LRU 的に管理。

計測比較

フェーズ 修正前(browser responseEnd) 修正後(予測)
Auth check +517ms +517ms(変わらず)
GetVisibleCollectionIds +925ms +925ms
GetVisibleProductsForBrowse +5479ms(ボディ 2607ms含む) ~500ms(キャッシュHit時)
Sidebar per-collection(Batch2) +6499–6771ms 削除
SiteShell per-collection(Batch3) +3807–6109ms 削除
FIRST PRODUCT VISIBLE 7354ms ~1500ms 予測

トラブルシュート: 修正後も遅い場合

  1. Vendure browse cache が効いていない

Sentry で visible_products_browse.cache.outcome = miss が連続する場合、 customer group 構成が毎リクエストで変わっている可能性がある。

# 対象ユーザーの customer group を確認
source apps/vendure-server/scripts/prepare-fly-cli-env.sh
flyctl ssh console -a ritsubi-ecommerce-staging -C \
  "node -e \"const {DataSource} = require('typeorm'); ...\"" # DB 直接確認
  1. per-collection take=1 が復活している

resolveBrowseGroupsWithVisibleProducts が再度 import・呼び出しされていないか確認。

rg "resolveBrowseGroupsWithVisibleProducts" apps/storefront/src/

このコマンドで visible-browse-collections.ts 本体以外に結果が出る場合は修正が崩れている。

  1. Vendure machine が cold start している
source apps/vendure-server/scripts/prepare-fly-cli-env.sh
flyctl machine list -a ritsubi-ecommerce-staging --json

stopped machine がある場合は最初に確認すること」の手順で warm 化する。

2026-05-28 ブラウザ側 GetCollectionBrowseRoots / GetVisibleCollectionIds キャッシュ欠落

背景と問題

Vendure browse cache(browse products)と browser sessionStorage(visible products)が 機能した後も、measure run の FIRST PRODUCT VISIBLE が 5355–5830ms のままだった。

Fly.io ログで browse cache hit(55–77ms)が確認でき、browser sessionStorage も 機能している(measure run で Vendure に browse call が到達しない)にもかかわらず遅い原因。

ボトルネック4: GetCollectionBrowseRoots のブラウザ未キャッシュ

getCollectionBrowseGroupsForBrowser() は Cloudflare Worker 上で withCmsSharedCache によりキャッシュされるが、SPA(ブラウザ)では typeof window !== "undefined" のとき withCmsSharedCache がキャッシュをスキップし 毎ナビゲーションで Vendure に到達していた。

measure run での計測例:

navStart + 535ms(auth check完了) → GetCollectionBrowseRoots 開始
GetCollectionBrowseRoots: ~1517ms → navStart + 2052ms で完了

修正: fetchCollectionBrowseRootsForBrowser() にブラウザ向け2段キャッシュを追加。

  • sessionStorageritsubi:browse-roots:v1): TTL 300s、前回ナビゲーション からの持ち越し用。公開データのため認証キーなし。
  • module-level singletonbrowseRootsInMemoryCache): TTL 300s、同一SPA セッション内の重複 fetch dedup(TanStack Router loader と site-shell useEffect が同一タブで2回以上呼ぶケースに対応)。

ボトルネック5: GetVisibleCollectionIds のブラウザ未キャッシュ

fetchVisibleCollectionIds() はブラウザ SPA で毎ナビゲーション Vendure に到達し、 loadVisibilityContext(DB 往復含む)を実行していた。

measure run での計測例:

navStart + 535ms(auth check完了) → GetVisibleCollectionIds 開始
GetVisibleCollectionIds: ~3018ms → navStart + 3553ms で完了

use(resultsCollectionGroupsPromise)collectionGroupsPromisevisibleCollectionIdsPromise の両方を await するため、後者の 3018ms が ProductsResults コンポーネントのレンダリングブロックに直結していた。

修正: fetchVisibleCollectionIds() にブラウザ向け2段キャッシュを追加。

  • sessionStorageritsubi:visible-coll-ids:v1:{sessionKey}): TTL 60s、 セッション cookie + vendure token をキーに含めて顧客ごとに独立したキャッシュ。
  • module-level MapvisibleCollectionIdsInMemoryCache): TTL 60s、 同一SPA セッション内の重複 fetch dedup。

ボトルネック6: PRODUCTS_SITE_SHELL_FALLBACK_DELAY_MS が短すぎる

root-layout.tsx の fallback delay が 2500ms のため、first product visible イベントが発火する前にタイムアウトし、site-shell(GetCollectionBrowseRoots ×2、 GetTrackingTagSettings 等)の6件 API コールが products API コールと競合していた。

measure run での計測例:

navStart + ~1490ms(React mount) + 2500ms = ~3990ms で site-shell fallback 発火
→ site-shell の6件 shop-api コールが products 表示より先に発火

修正: PRODUCTS_SITE_SHELL_FALLBACK_DELAY_MS を 2500ms → 10000ms に延長。 browse cache hit 時は FIRST PRODUCT VISIBLE が ~1500ms 以内になるため、 fallback タイマー到達より先に ritsubi:products-first-visible イベントが発火し、 site-shell が products 表示後に非同期ロードされる。

ボトルネック7: productService.findByIds の JOIN 爆発(解消済み: fetchBrowseProductsByIds

Vendure コアの productService.findByIdsrelations 引数を完全無視し、 常に 5 relations(featuredAsset, assets, channels, facetValues, facetValues.facet) を JOIN strategy でロードする実装バグを持つ:

// Vendure core の実装バグ(product.service.js)
.setFindOptions({ relations: (relations && false) || this.relations });
// (relations && false) は常に false → 渡した relations を無視して this.relations を使用

これにより 16 商品の hydration で ManyToMany JOIN(channels × facetValues)が爆発し、 staging で 13.4 秒かかっていた:

"durationsMs": {
  "total": 16642,
  "search": 161,
  "fetchVariants": 2080,
  "validateVisibility": 2080,
  "hydrateProducts": 13362
}

修正: fetchBrowseProductsByIds プライベートメソッドを追加し、productService.findByIds を完全に置き換え:

  • relationLoadStrategy: "query" を使用(JOIN → relation ごとに個別 SELECT)
  • ブラウズカードに必要な 3 relations のみロード: featuredAsset, facetValues, facetValues.facet
  • 不要な assets(OneToMany, 表示不要)と channels(ManyToMany, 高コスト)を除外
  • translationseager: true のため自動ロード(変更なし)
  • channel フィルタリングは leftJoin("product.channels", "channel") + andWhere で維持

期待される改善後のタイムライン(warm cache)

navStart + 0ms    : ナビゲーション開始
navStart + ~535ms : auth-session check 完了
navStart + ~540ms : GetCollectionBrowseRoots → sessionStorage HIT(<10ms)
navStart + ~540ms : GetVisibleCollectionIds  → sessionStorage HIT(<10ms)
navStart + ~540ms : fetchCollectionCatalogSearchResult → sessionStorage HIT(<10ms)
navStart + ~700ms : React レンダリング + components mount
navStart + ~800ms : FIRST PRODUCT VISIBLE
navStart + ~800ms : ritsubi:products-first-visible イベント発火
navStart + ~800ms : getSiteShellModelVite() 開始(non-blocking)

計測比較

フェーズ 修正前(measure run) 修正後(warm cache)
auth-session check +535ms +535ms(変わらず)
GetCollectionBrowseRoots +535ms + 1517ms +535ms + <10ms(HIT)
GetVisibleCollectionIds +535ms + 3018ms +535ms + <10ms(HIT)
fetchCollectionCatalogSearchResult +535ms + ~77ms(HIT) +535ms + ~77ms(HIT)
site-shell fallback 発火 navStart +3990ms products 表示後(+800ms以降)
FIRST PRODUCT VISIBLE 5355–5830ms ~800ms(目標)

トラブルシュート: キャッシュ修正後も遅い場合

  1. sessionStorage キャッシュが機能していない(cold 初回アクセス)

初回アクセス(browser sessionStorage 空)では cold fetch になる。 warmup run → measure run の E2E 2段構成でのみ目標を達成できる。 実ユーザーは2回目以降のナビゲーションから恩恵を受ける。

  1. sessionStorage キャッシュキーが一致しない

GetVisibleCollectionIds のキャッシュキーは session cookie + vendure token。 ユーザーがログインし直すと TTL 内でもキーが変わり cold fetch になる。 ログイン直後の初回ロードは cold fetch を許容する設計。

  1. GetCollectionBrowseRoots sessionStorage キャッシュを確認

ブラウザ DevTools → Application → Session Storage で ritsubi:browse-roots:v1 の存在と expiresAt を確認する。

  1. GetVisibleCollectionIds sessionStorage キャッシュを確認

ブラウザ DevTools → Application → Session Storage で ritsubi:visible-coll-ids:v1: で始まるキーの存在と expiresAt を確認する。

  1. site-shell fallback が products 表示前に発火している

DevTools の Network タブで GetCollectionBrowseRoots が2回発火しているか確認。 1回目が products loader から、2回目が site-shell の fallback から発火するケースは PRODUCTS_SITE_SHELL_FALLBACK_DELAY_MS が足りない(Vendure 障害等で products 表示が遅延)。

  1. auth-session check が毎回ブロッキング

/api/auth-session が毎ナビゲーションで 300–535ms かかる構造は変わっていない。 この遅延は仕様(Vendure session validity を server-side で確認する設計)。 改善候補: session cookie の存在 + TTL 30s の短期 sessionStorage キャッシュ(未実装)。

  • PR #586: DB search optimization indexes と query rewrites
  • Migrations: 1777874700000_add_visibility_lookup_indexes.ts (6 indexes)
  • Migrations: 1777918200000_add_search_trigram_indexes.ts (5 indexes)
  • Status: Staging に自動デプロイ、migrations auto-execute
  • Expected: seq_scan 100% 削減、browse query 5-10x 高速化

#673 残り打ち手 (2026-05-20 設計)

visibleProductsForBrowse の Sentry N+1 検出に対する追加施策。匿名 cache 拡張 (commit 591a03a89) で同時 anonymous 再走は抑制したが、cache miss 時の resolver 内部 N+1 形パターンは残るため、以下の打ち手を順に検討する。

打ち手 (a): Visibility filter の search push-down

プッシュダウンの狙い

batch ループそのものを排除し、search 1 回 + hydrate 1 回の 2-stage 構成に する。現状 resolver は「search で広く取り、可視 variant を絞り込み、 不足したら再 search」というループで visibility filter を application 層に持っているため、低 visibility ratio で iter が膨らむ。

プッシュダウンの設計

  • DefaultSearchPlugin の SearchIndexItem に visibility 判定済みの bucket (customer group id / anonymous flag / policy id) を index 段階で 焼き込む。実装は apps/vendure-server の Search reindex 経路を改修。
  • Shop API の検索クエリでは RequestContext の customer / channel から 対応する bucket を組み立てて where に push する。
  • visibility policy が変わったときの再 index は既存 reindex worker を 再利用するが、policy 変更 hook (AccessPolicyService.update*) に reindex enqueue を追加する。

プッシュダウンの開発コスト

  • 高: index schema 変更、reindex 経路改修、policy 変更時の整合性 運用 (再 index 完了までは古い bucket が残る)。
  • visibility policy の表現力 (時刻条件、customer custom field の 動的評価) を index 段階に落とせない条件があると、application 層の 二段絞り込みが必須になり N+1 が部分的に残る。

プッシュダウンの性能評価

最も効果は大きいが工数も最大。条件 (時刻 / 動的 field) の対応可否を 先に reading で確定させてから着手する。次節 (b)(c) で十分な場合は 保留可能。

打ち手 (b): Cache TTL / LRU の調整

キャッシュ調整の狙い

cache hit rate を上げて batch ループ実行回数自体を減らす。

キャッシュ調整の設計

  • 現状: TTL 15_000ms、entries 128
  • 観測: 同一 channel × 同一 (collection, term, take, skip) の組合せは /products の page 単位で数十種程度に収束する想定。anonymous bucket が 1 channel 1 input で 1 entry を消費するため、128 entries は十分。
  • 提案:
  • TTL を 30s に延長 (in-process なので reindex / policy 変更時の ズレ許容を 30s 以内に留める)。匿名 bucket の正当性は (a) 未導入 時は既に 15s ズレを許容しているのと同質。
  • cache hit / miss / eviction を countMetric で出して staging で hit rate を確認する。hit rate がそもそも低ければ TTL 延長は 効果薄なので (c) を優先する。
  • 既存テストは TTL 値に依存しないため回帰なし。新規 metric は visible_products_browse.cache.{hit,miss,evict} で追加。

キャッシュ調整の運用コスト

  • 低。実装は定数調整と metric 追加のみ。
  • リスクは「policy 変更後の 30s 古い結果」だが、現運用で in-process cache 15s を許容しているので問題なし。

キャッシュ調整の評価

最初に手を付ける。observability の追加で (a) 着手判断にも使える。

打ち手 (c): Representative variant validate と fallback fetch の並列化

キャッシュ調整の狙い

batch 1 iter あたりの round-trip 数を減らす。現状 1 iter は

  1. fulltextSearchService.search
  2. visibilityService.validateIds(代表 variant ids)
  3. (fallback あれば) ProductVariant.find({ product.id IN fallback })
  4. filterVisibleVariantItems(fallbackVariants)
  5. ProductVariant.find({ id IN page visible }) (詳細 hydrate)
  6. productService.findByIds(page products)

の 6 直列。Sentry が見ているのはこの 6 連が iter ごとに繰り返される パターン。

キャッシュ調整の設計

  • 2 と 3 は依存関係がない (どちらも search 結果からの派生で、 fallback 対象は productIds の subset)。Promise.all で並列化し て 1 iter あたり 1 round-trip 削減。
  • 5 と 6 も並列化可能 (両者とも paged の確定 ids に依存)。
  • ただし fallback fetch は「2 の結果次第で skip できる」最適化 経路でもあるため、無差別に並列化すると不可視商品の variant fetch を投機的に行う副作用が出る。productIds 全件が visible representative で覆われた batch ではコスト増になる。
  • 対策: search 結果から「representative visible で覆える ratio」が high の場合 (>= 0.8) は従来通り順次、低い場合だけ並列化する 動的 strategy にする。観測には (b) の cache metric と visible_products_browse.batch_total を併用。

キャッシュ調整の運用コスト

  • 中。ロジック変更は局所 (resolver 内 1 ブロック) だが、副作用 ある投機 fetch を入れるので contract test (代表で覆える batch は fallback fetch しない) を残す必要がある。

キャッシュ調整の評価

(b) で observability を整えた後、cache miss rate と batch 数の 実測を見て採否を決める。実測で 1 request あたり iter 数が常に 1 なら 不要、2 以上が常態なら採用。

進め方

  1. Step 1: (b) を実装し staging に out。visible_products_browse.cache.*visible_products_browse.batch_total を 1 週間観測。
  2. Step 2: hit rate と iter 分布から (c) 採否を判断。採用なら contract test 同梱で実装。
  3. Step 3: (b)(c) を入れても Sentry N+1 が残る、かつ business が /products の P99 短縮を要求する場合のみ (a) に着手。policy 表現の制約を先に reading で確認する。

2026-05-28 Staging Dogfooding 発見問題 (ISSUE-001〜003)

staging dogfoodingで発見・修正した3件の問題を記録する。

ISSUE-001 (Critical): auth ページが 10〜30 秒間空白

症状

/auth/login, /auth/reset-password, /auth/password-reset, /auth/temporary-login, /auth/temporary-setup でページが 10〜30 秒ホワイトスクリーンになる。

根本原因

各 auth route の loader()fetchStoreSettings() をブラウザ側で await するが、 fetch(endpoint, fetchOptions) にタイムアウトがなかった。

WordPress CMS が HTML エラーページを返すと最初のフェッチは即失敗するが、 フォールバックフェッチがブラウザの TCP デフォルトタイムアウト(30秒以上)までハングした。

修正内容

  1. 各 auth route loader に 2000ms Promise.race タイムアウトを追加
  2. auth.login.tsx: withCmsTimeout() ヘルパーで fetchAnnouncementsfetchStoreSettings を 2s でキャップ
  3. auth.reset-password.tsx, auth.password-reset.tsx, auth.temporary-login.tsx, auth.temporary-setup.tsx: 同様に Promise.race タイムアウト追加
  4. CMS(ロゴ・お知らせ)は装飾情報のため、タイムアウト時は null として扱い ページを即座に表示する

  5. apps/storefront/src/lib/cms/client.ts にブラウザ側フェッチ上限追加

  6. ブラウザ側 fetch()signal: AbortSignal.timeout(8000) を設定
  7. ローダーの 2s race が主防御ラインだが、バックグラウンドリクエストが長時間 ハングしないよう 8s で上限を置く

確認方法

# stagingの /auth/login にアクセスし、CMS が遅くても 2s 以内にフォームが表示されることを確認
curl -o /dev/null -w "%{time_total}" https://order-staging.ritsubi-platform.com/auth/login

ISSUE-002 (High): パスワードリセット - 無効トークンで silent fail

症状

/auth/reset-password?token=INVALID でフォームを submit しても、エラーメッセージが 表示されないように見えた。

根本原因

ISSUE-001 の副作用。reset-password ページが blank 状態のままフォームが描画されず、 エラー表示が見えなかった。コード自体のエラーハンドリングは正しく実装されていた。

ResetPasswordFormPasswordResetTokenInvalidError を受け取ると setError("root.server", { message: "リセットトークンが無効です。..." }) を呼び、 FormErrorAlert がエラーメッセージを表示する(既存ユニットテストで検証済み)。

修正内容

ISSUE-001 の修正によりページ表示が 2s 以内になり、エラーハンドリングが正常に機能する。


ISSUE-003 (High): 「お問い合わせフォーム」リンクが循環リダイレクト

症状

ログインページの「お問い合わせフォーム」リンク(/support/contact)をクリックすると、 siteRoute(認証必須)にリダイレクトされ、再度ログインページに戻るループが発生した。

根本原因

apps/storefront/src/router.tsxsupportContactRoute が認証必須の siteRoute の 子として定義されていたため、未認証ユーザーがアクセスすると強制リダイレクトされた。

/support/contact は連絡先・Googleフォームリンクのみを表示する静的ページで、 認証不要な情報のみ含む。

修正内容

supportContactRoutesiteRoute(認証必須)から contentRoute(公開)へ移動。

// router.tsx
const supportContactRoute = createRoute({
  getParentRoute: () => contentRoute,  // siteRoute → contentRoute
  path: "/support/contact",
  ...
});

routeTreesiteRoute.addChildren(...) からも除去し、 contentRoute.addChildren(...) へ追加。

確認方法

# 未認証状態で /support/contact にアクセスし、ページが表示されることを確認
curl -o /dev/null -s -w "%{http_code}" https://order-staging.ritsubi-platform.com/support/contact
# 200 が返ること(302 ではないこと)を確認

2026-05-28 Staging Re-Dogfooding 発見問題 (ISSUE-004)

ISSUE-004 (High): 保護ルート未認証アクセス時に Worker session validation が ~25s ハング

症状

  • 未認証ブラウザまたは stale session cookie を持つブラウザが / などの保護ルートにアクセスすると、 ログインページへのリダイレクトが 25〜26 秒かかる
  • 修正後は 2〜3 秒以内にリダイレクト完了

根本原因

apps/storefront/src/worker-api/handlers/auth.tsvalidateStorefrontSession 関数が fetchVendureGraphql で Vendure に ActiveCustomerForSession クエリを投げる際、 タイムアウトが設定されていなかった。

Vendure が応答に時間を要する場合(cold start、高負荷、ネットワーク遅延など)、 Cloudflare Worker の 30s ウォールタイムに近い時間まで待機してしまう。

影響範囲:

  • session cookie を持つ未認証ユーザー(stale cookie、セッション期限切れ)
  • reset-password フォーム送信後など、Vendure が session cookie を発行した後

修正内容

apps/storefront/src/worker-api/vendure-client.tsfetchVendureGraphqltimeoutMs オプションを追加:

export async function fetchVendureGraphql(
  request: Request,
  env: WorkerEnv,
  body: Record<string, unknown>,
  options: {
    includeRequestCookies?: boolean;
    includeAuthorization?: boolean;
    timeoutMs?: number; // 追加
  } = {},
): Promise<Response> {
  const { timeoutMs, ...fetchOptions } = options;
  return await fetch(
    resolveVendureShopApiUrl(env),
    withRuntimeBodySupport({
      method: "POST",
      headers: buildVendureHeaders(request, env, fetchOptions),
      body: JSON.stringify(body),
      ...(timeoutMs != null ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
    }),
  );
}

validateStorefrontSession の呼び出し側に timeoutMs: 5000 を追加:

const response = await fetchVendureGraphql(
  request,
  env,
  { query: ACTIVE_CUSTOMER_QUERY, operationName: "ActiveCustomerForSession" },
  { includeRequestCookies: true, includeAuthorization: true, timeoutMs: 5000 },
);

タイムアウト時は catch ブロックで { authenticated: false, unavailable: true } を返すため、 ログインページに ?error=service-unavailable 付きで即時リダイレクトされる。 ユーザーには "認証サービスに接続できませんでした" というメッセージが表示される。

確認方法

# stale cookie がある状態で / にアクセスし、2〜3秒以内にリダイレクトされることを確認
curl -o /dev/null -s -w "Total: %{time_total}s  HTTP: %{http_code}  Redirect: %{redirect_url}\n" \
  -H "Cookie: vendure-session=stale-token-xxx" \
  https://order-staging.ritsubi-platform.com/
# Total が 5s 未満、HTTP 302、Redirect が /auth/login?redirect=/ であること

2026-05-28 per-variant pricing N+1 除去 / 単一フェッチ / L2 browse cache

背景(Sentry 実測で原因確定)

ログイン顧客(B2B)のカタログ一覧 (/products?collection=...) の遅延を Sentry 実測で切り分けた。

  • Vendure span.op:db: 7日で count 398,819 / sum ≈ 9.4時間(avg 85ms / p95 320ms)。典型的な N+1 シグネチャ。
  • Storefront span.op:products.visible-search: p50 330ms / p95 2.9s / max 72s(prod 全体)。staging は p50 310 / p95 2,164 / max 20,259 ms。

注: カスタム Sentry.metrics.*visible_products_browse.*)は SDK 設定により no-op の可能性があり、切り分けは span 実測を一次情報にした(#674 節の _experiments.enableMetrics も併せて確認すること)。

根本原因: 価格計算経路の per-variant N+1

RitsubiPriceCalculationStrategy.calculate() が variant ごとに calculateCatalogPrice を呼び、その内部で 1 ページ(最大 ~400 variant)あたり以下が積み上がっていた。

経路 状態(修正前) 1ページあたり query 数
Commercial Rules / 旧対象者条件 request-scoped + 5s TTL でキャッシュ済 各 1
旧商品対象条件 (AccessPolicyService.listResourceSets) 未キャッシュ(2 query 全件 load) ~800 (2×400)
SMILE 単価マスタ (SmilePriceMasterService.findBestPriceForVariant) 未キャッシュ(variant ごと raw query) ~400

ritsubi_smile_price_master には IDX_smile_price_master_lookupproductCode 先頭の複合 index)が既存で、seq scan ではなくクエリ「数」が問題。pricing 経路の variant relation 再取得は browse が collections/facetValues をロード済みのため ensureVariantRelations が skip しており発生しない(調査で確認)。

修正

  1. 旧商品対象条件を request-scoped + 5s shared TTL でキャッシュ化access-policy.service.ts、commit b8f8a93、changeset catalog-browse-pricing-n-plus-one)。旧対象者条件の list と同型。旧商品対象条件系の create/update/delete でキャッシュ失効。→ ~800 query/page → ~2。
  2. SMILE 単価参照を (productCode, customerCode, salesRateClassCode, quantity) キーで request-scoped メモ化smile-price-master.service.ts、同 commit)。specificity 選択ロジックは不変(smile-price-master.priority.test.ts で固定)。
  3. storefront を単一フェッチ化products.tsx、commit 26f8aa3、changeset products-catalog-single-fetch)。下記「2026-05-28 商品カタログ Vendure API コール削減」の 48→200 page-size は本変更で superseded。この時点では backend visibleProductsForBrowse の全件 1 レスポンス化で Storefront 側の while ループ(N 往復)を廃止した。
  4. L2 cross-isolate browse cachevisibility-shop.resolver.tsPR #892、changeset visible-products-browse-shared-cache)。これは「#673 残り打ち手 (b)」の cross-isolate 版。

2026-05-29 update: production の /products?collection=mesoceutical で全件 hydrate が 6s 超へ戻ったため、初期表示は再び page-size browse を正本にした。現行の正は PRODUCTS_INITIAL_PAGE_SIZE (= 24) 分だけ取得し、ブランド和集合は collectionSlugs の 1 query にまとめること。

L2 browse cache の設計(重要)

per-isolate の in-memory Map(L1)は Fly machine ごと / 再起動で消えるため、cold isolate で重い search+visibility pipeline が毎回走り tail(p95/max)を悪化させていた。L2 として Vendure CacheServiceRedisCacheStrategy 済み)を追加。

  • 可視 product/variant の ID のみを保存。Product entity は直列化しない(復元した plain object は applyChannelPriceAndTax を通せず B2B 価格が壊れる)。hit 時は ID から entity を都度ハイドレートし、価格 field resolver を正しい entity 上で動かす。
  • 可視判定は cache key(channel × customerGroupIds × collectionSlug × revision × page)に紐づくため、hit 時の再 validate は不要(L1 と同じ前提)。TTL は L1 と同じ 5 分。
  • additive かつ @Optional: CacheService 未設定環境 / unit test では L2 無効、従来の L1 のみ挙動と完全一致。
  • ログ: L2 hit は event: "visible-products-browse-shared-cache-hit" で出る。

staging 検証結果(Vendure bc23414 deploy 済み)

  • 機能: Shop API 直叩きで visibleProductsForBrowse(mesoceutical) = 26件返却・エラー0(regression なし)。
  • latency: full collection + 価格を proxy 経由 end-to-end で warm ~0.52s / cold ~1.62s

既知の落とし穴(計測時)

  • perf smoke の flake: deploy 直後は cold start で初回 browse が 0 件になり得る。また AUTH_LOGIN_RATE_LIMIT(10 login/60s/IP、wrangler.toml)があるため、smoke を連続実行すると login 失敗認証サービスに接続できませんでした = ISSUE-004 経路)になる。clean な before/after は rate limit 冷却後に 1 回だけ実行する。
  • ローカル drift audit / deploy で @ritsubi/plugins/dist/index.js not found: git worktree 操作の sync-deps hook が packages/plugins/dist を消すことがある。pnpm exec nx run @ritsubi/plugins:build --skip-nx-cache で再生成してから再実行する。
  • L2 hit パスの未検証点(PR #892 マージ前): 認証済み B2B browse で L2 hit の返却商品・価格が L1 と一致することを実スタックで確認すること(pricing 隣接)。

残課題

  • perf smoke fixture の堅牢化(clean 計測)と L2 hit パスの価格一致検証は issue #891 で追跡。

2026-05-28 ホームページ 500 エラー解消 (ISSUE-005: HardenPlugin 複雑度超過)

症状

  • ホームページ (/) が 500 エラー で返る(SWR fallback がなく真っ白)
  • Vendure ログに Query complexity of "anonymous" is 6324, which exceeds the maximum of 2500Query complexity of "anonymous" is 4528, which exceeds the maximum of 2500 が同時刻に出力される
  • HardenPlugin の maxQueryComplexity: 2500 に違反するクエリが複数ホームページ SSR 経路から送出されていた

根本原因

6324: GetProductVariantsBySkusForHometake 未渡し

home-page-helpers.tsfetchRankingVariantsBySkusvendureQuerySsr 呼び出し時に変数 { skus } のみを渡し、 take を渡していなかった。HardenPlugin の complexity plugin はデフォルト take=1000 とみなして計算するため、 complexity = childComplexity + round(ln(childComplexity) * take) が 6324 となり上限を大幅超過した。

// 修正前 (home-page-helpers.ts)
const data = await vendureQuerySsr(
  GET_PRODUCT_VARIANTS_BY_SKUS_FOR_HOME,
  { skus },  // ← take を渡さない → default 1000 → complexity 6324
  ...
);

// 修正後
const data = await vendureQuerySsr(
  GET_PRODUCT_VARIANTS_BY_SKUS_FOR_HOME,
  { skus, take: skus.length },  // ← 実際に必要な件数だけ → complexity ~592
  ...
);

4528: GetHeroCampaignCollectionscollections(options: { topLevelOnly: true })take 未指定

banner-utils.tsHERO_CAMPAIGN_COLLECTIONS_QUERY がヒーローバナーのキャンペーン URL 解決のために collections(options: { topLevelOnly: true }) を呼んでいたが take を指定していなかった。 CollectionListPaginatedList interface を implements しているため、 HardenPlugin は take=1000 でペナルティを加算し complexity 4528 を算出した。

# 修正前
collections(options: { topLevelOnly: true })   # ← take 未指定

# 修正後
collections(options: { topLevelOnly: true, take: 100 })  # ← SHOP_LIST_SAFE_TAKE=100

HardenPlugin complexity 計算式

// apps/vendure-server/node_modules/@vendure/harden-plugin/.../query-complexity-plugin.js
const isPaginatedList = !!namedType.getInterfaces().find((i) => i.name === "PaginatedList");
if (isPaginatedList) {
  const take = args.options?.take ?? args.take ?? 1000; // ← take 未指定は 1000
  result = childComplexity + Math.round(Math.log(childComplexity) * take);
}
  • Math.log は自然対数 (ln)
  • take のデフォルト: 1000(引数未指定時)
  • PaginatedList チェック: TypeScript interface PaginatedListimplements しているかどうか

vendureFetch の operationName 欠落問題

apps/storefront/src/lib/vendure-fetch.tsvendureFetchbody: JSON.stringify({ query: queryStr, variables }) と送り、operationName を含まない。 そのため HardenPlugin の違反ログが全て "anonymous" と表示される。どのクエリが違反しているか ログだけでは判断できないので、staging 上で違反が出たら storefront コードで PaginatedList クエリを take 指定漏れがないか検索すること。

確認方法

# staging ホームページへのリクエストが 200 を返すことを確認
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" -L "https://order.ritsubi-platform.com/"
# → 200 x.xxxs (500 ではない)

# Vendure ログで complexity 違反がないことを確認
fly logs --app ritsubi-ecommerce-staging --no-tail 2>&1 | rg "complexity"
# → 何も出力されないか、今回の修正前のタイムスタンプのみ表示される

修正コミット

  • aead09638fix(storefront): HardenPlugin 複雑度超過を解消 (6324 + 4528)
  • apps/storefront/src/lib/home-page-helpers.ts: take: skus.length 追加
  • apps/storefront/src/lib/cms/content/banner-utils.ts: take: ${SHOP_LIST_SAFE_TAKE} 追加

予防策

storefront に PaginatedList を返す新クエリを追加するとき(products, collections, productVariants など)は 必ず take を変数または定数で指定すること。SHOP_LIST_SAFE_TAKE(= 100)を標準上限として利用する。

list / search / home 用の Shop API operation では、詳細用 fragment (...Product / ...ProductVariant) を spread しないこと。packages/sdk/shop/src/operations/operation-complexity-guard.test.ts が主要 operation の full detail fragment 混入を検出する。