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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ CRON_SECRET=your_secure_cron_secret_here
# ===========================================
# 外部サービス設定 (オプション)
# ===========================================
# Sentry
# Web project (Next.js browser/server/edge)
# NEXT_PUBLIC_SENTRY_DSN=https://<public_key>@o<org>.ingest.sentry.io/<project_id>
# Workers project (Cloudflare Workers / scheduled worker)
# SENTRY_WORKER_DSN=https://<public_key>@o<org>.ingest.sentry.io/<project_id>
#
# Build / sourcemap upload
# SENTRY_AUTH_TOKEN=your_sentry_auth_token_here
# SENTRY_ORG=your_sentry_org_slug_here
# SENTRY_PROJECT_WEB=your_sentry_web_project_slug_here
# SENTRY_PROJECT_WORKERS=your_sentry_workers_project_slug_here

# ニコニコ動画のセンシティブコンテンツアクセス用
# NICO_COOKIES=sensitive_material_status=accept

Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/deploy-worker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ jobs:
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT_WORKERS }}
run: |
if [ "${{ inputs.wrangler_config }}" != "wrangler.toml" ]; then
wrangler deploy ${{ inputs.worker_file }} \
Expand All @@ -56,4 +59,4 @@ jobs:
wrangler tail ${{ inputs.worker_name }} --format=pretty &
TAIL_PID=$!
sleep 10
kill $TAIL_PID || true
kill $TAIL_PID || true
126 changes: 126 additions & 0 deletions __tests__/unit/sentry-shared.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import {
buildSafeTags,
normalizeTransactionName,
sanitizeUrlForSentry,
scrubBreadcrumb,
scrubEvent,
} from '@/lib/sentry/shared'

describe('sentry shared helpers', () => {
it('redacts sensitive query parameters while preserving safe ones', () => {
expect(
sanitizeUrlForSentry(
'https://nico-rank.com/api/ranking?genre=game&tag=secret-tag&page=2#hash',
),
).toBe('/api/ranking?genre=game&tag=%5Bredacted%5D&page=2')
})

it('normalizes dynamic transaction paths', () => {
expect(
normalizeTransactionName(
'GET https://nico-rank.com/api/thumbnail/sm9?token=secret',
),
).toBe('GET /api/thumbnail/:videoId?token=%5Bredacted%5D')
})

it('scrubs request and breadcrumb payloads', () => {
const event = scrubEvent({
request: {
url: 'https://nico-rank.com/mylists/abc123?memo=private&genre=all',
headers: {
authorization: 'secret',
},
data: {
body: 'secret',
},
cookies: 'a=b',
},
breadcrumbs: [
{
message: 'Authorization header leaked',
data: {
url: 'https://nico-rank.com/api/ranking?tag=hidden&genre=all',
headers: {
cookie: 'secret',
},
response: {
body: 'secret',
},
},
},
],
user: {
id: 'user-1',
},
transaction: 'GET https://nico-rank.com/mylists/abc123?title=secret',
contexts: {
response: {
status_code: 500,
},
trace: {
data: {
url: 'https://nico-rank.com/api/ranking?tag=hidden',
'http.request.body.size': 100,
'http.response.body.size': 200,
},
},
},
})

expect(event.request).toEqual({
url: '/mylists/:id?memo=%5Bredacted%5D&genre=all',
})
expect(event.breadcrumbs).toEqual([
{
message: '[redacted]',
data: {
url: '/api/ranking?tag=%5Bredacted%5D&genre=all',
},
},
])
expect(event.transaction).toBe('GET /mylists/:id?title=%5Bredacted%5D')
expect(event.user).toBeUndefined()
expect(event.contexts).toEqual({
trace: {
data: {
url: '/api/ranking?tag=%5Bredacted%5D',
},
},
})
})

it('keeps only non-empty safe tags', () => {
expect(
buildSafeTags({
runtime: 'browser',
is_preview: false,
count: 3,
empty: '',
missing: undefined,
nullable: null,
}),
).toEqual({
runtime: 'browser',
is_preview: 'false',
count: '3',
})
})

it('scrubs standalone breadcrumbs', () => {
expect(
scrubBreadcrumb({
data: {
from: 'https://nico-rank.com/api/ranking?tag=foo',
to: 'https://nico-rank.com/mylists/123?memo=secret',
input: 'hidden',
},
}),
).toEqual({
data: {
from: '/api/ranking?tag=%5Bredacted%5D',
to: '/mylists/:id?memo=%5Bredacted%5D',
},
})
})
})
57 changes: 57 additions & 0 deletions app/admin/ng-settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import type { NGList } from '@/types/ng-list'
import { createEmptyNGList, migrateLegacyNGList } from '@/lib/ng-list-migration'
import { DerivedNGList } from './components/DerivedNGList'
import { captureWebException } from '@/lib/sentry/capture'

// 常時ライトモード適用のためのラッパー
function LightModeWrapper({ children }: { children: React.ReactNode }) {
Expand Down Expand Up @@ -63,6 +64,19 @@ export default function NGSettingsPage() {
signal: controller.signal
})
if (!response.ok) {
captureWebException(new Error(`NG list load failed: ${response.status}`), {
tags: {
runtime: 'browser',
surface: 'admin-ng-settings',
endpoint_family: '/api/admin/ng-list',
action: 'load',
},
contexts: {
request: {
status: response.status,
},
},
})
console.error('Failed to fetch NG list:', response.status, response.statusText)
if (response.status === 401) {
alert('認証エラー: ページをリロードして再度ログインしてください')
Expand All @@ -79,6 +93,14 @@ export default function NGSettingsPage() {
if (error.name === 'AbortError') {
return
}
captureWebException(error, {
tags: {
runtime: 'browser',
surface: 'admin-ng-settings',
endpoint_family: '/api/admin/ng-list',
action: 'load',
},
})
console.error('Error fetching NG list:', error)
alert('NGリストの取得に失敗しました')
} finally {
Expand Down Expand Up @@ -135,6 +157,25 @@ export default function NGSettingsPage() {
})
})
if (!response.ok) {
captureWebException(new Error(`NG list save failed: ${response.status}`), {
tags: {
runtime: 'browser',
surface: 'admin-ng-settings',
endpoint_family: '/api/admin/ng-list',
action: 'save',
},
contexts: {
ng_list: {
videoIdCount: ngList.videoIds.length,
authorIdCount: ngList.authorIds.length,
videoTitleCount: ngList.videoTitles.exact.length + ngList.videoTitles.partial.length,
authorNameCount: ngList.authorNames.exact.length + ngList.authorNames.partial.length,
},
request: {
status: response.status,
},
},
})
console.error('Failed to save NG list:', response.status, response.statusText)
if (response.status === 401) {
alert('認証エラー: ページをリロードして再度ログインしてください')
Expand All @@ -151,6 +192,22 @@ export default function NGSettingsPage() {
if (error.name === 'AbortError') {
return
}
captureWebException(error, {
tags: {
runtime: 'browser',
surface: 'admin-ng-settings',
endpoint_family: '/api/admin/ng-list',
action: 'save',
},
contexts: {
ng_list: {
videoIdCount: ngList.videoIds.length,
authorIdCount: ngList.authorIds.length,
videoTitleCount: ngList.videoTitles.exact.length + ngList.videoTitles.partial.length,
authorNameCount: ngList.authorNames.exact.length + ngList.authorNames.partial.length,
},
},
})
console.error('Error saving NG list:', error)
alert('保存に失敗しました')
} finally {
Expand Down
22 changes: 22 additions & 0 deletions app/api/admin/ng-list/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { getNGListManual, setNGListManual } from '@/lib/ng-list-server'
import { captureWebException } from '@/lib/sentry/capture'

export const dynamic = 'force-dynamic'

Expand Down Expand Up @@ -33,6 +34,14 @@ export async function GET(request: NextRequest) {
return withNoStore(NextResponse.json(ngList))
} catch (error) {
console.error('Failed to fetch NG list:', error)
captureWebException(error, {
tags: {
runtime: 'next-node',
surface: 'admin-ng-list',
endpoint_family: '/api/admin/ng-list',
action: 'get',
},
})
return withNoStore(NextResponse.json({ error: 'Failed to fetch NG list' }, { status: 500 }))
}
}
Expand All @@ -59,6 +68,19 @@ export async function POST(request: NextRequest) {

return withNoStore(NextResponse.json({ success: true }))
} catch (error) {
captureWebException(error, {
tags: {
runtime: 'next-node',
surface: 'admin-ng-list',
endpoint_family: '/api/admin/ng-list',
action: 'post',
},
contexts: {
ng_list: {
note: 'update-failed',
},
},
})
return withNoStore(NextResponse.json({ error: 'Failed to update NG list' }, { status: 500 }))
}
}
8 changes: 8 additions & 0 deletions app/api/admin/video-info/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { DERIVED_NG_VIDEO_INFO_MAP_KEY } from '@/lib/admin-ng-constants'
import { captureWebException } from '@/lib/sentry/capture'

export const runtime = 'nodejs'

Expand Down Expand Up @@ -149,6 +150,13 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ videos: videoMap })
} catch (error) {
console.error('Error fetching video info:', error)
captureWebException(error, {
tags: {
runtime: 'next-node',
surface: 'admin-video-info',
endpoint_family: '/api/admin/video-info',
},
})
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
32 changes: 32 additions & 0 deletions app/api/cron/fetch/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { CACHED_GENRES } from '@/types/ranking-config'
import { setRankingToKV, type KVRankingData } from '@/lib/cloudflare-kv'
import { buildRankingData } from '@/lib/pipeline/run-update'
import { createServerNgFilter } from '@/lib/pipeline/ng-filter'
import { captureWebException } from '@/lib/sentry/capture'
// import { mockRankingData } from '@/lib/mock-data' // モックデータは使用しない
import type { RankingData, RankingItem } from '@/types/ranking'

Expand Down Expand Up @@ -95,6 +96,14 @@ export async function POST(request: Request) {
}
} catch (error) {
console.error('[KV Kill Switch] Failed to check kill switch status:', error)
captureWebException(error, {
tags: {
runtime: 'next-node',
surface: 'cron-fetch',
endpoint_family: '/api/cron/fetch',
upstream_kind: 'kill-switch',
},
})
// キルスイッチチェックが失敗しても処理は続行
}

Expand Down Expand Up @@ -207,6 +216,14 @@ export async function POST(request: Request) {
}
} catch (monitorError) {
console.error('[KV Monitor] Failed to check write count:', monitorError)
captureWebException(monitorError, {
tags: {
runtime: 'next-node',
surface: 'cron-fetch',
endpoint_family: '/api/cron/fetch',
upstream_kind: 'kv-monitor',
},
})
// モニタリングが失敗しても処理は続行(安全のため)
}

Expand All @@ -215,6 +232,14 @@ export async function POST(request: Request) {
await setRankingToKV(kvData)
// Cloudflare KV書き込み成功(ログは出力しない - ESLintエラー回避)
} catch (cfError) {
captureWebException(cfError, {
tags: {
runtime: 'next-node',
surface: 'cron-fetch',
endpoint_family: '/api/cron/fetch',
upstream_kind: 'cloudflare-kv',
},
})
// Cloudflare KVへの書き込みに失敗しても、Vercel KVへの書き込みは成功しているので処理は続行
// エラーは記録するが、全体としては成功とする
}
Expand All @@ -230,6 +255,13 @@ export async function POST(request: Request) {
isMock: !allSuccess && totalItems === 100, // モックデータを使用した場合
})
} catch (error) {
captureWebException(error, {
tags: {
runtime: 'next-node',
surface: 'cron-fetch',
endpoint_family: '/api/cron/fetch',
},
})
return NextResponse.json(
{ error: 'Failed to fetch ranking' },
{ status: 500 },
Expand Down
Loading
Loading