Skip to content
Draft
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: 11 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,14 @@ LOG_LEVEL=debug

# PWA設定
# アプリケーション名(マニフェストとメタデータに使用)
NEXT_PUBLIC_APP_NAME=Sonory
NEXT_PUBLIC_APP_NAME=Sonory

# オブザーバビリティ(Sentry)
# 1. https://sentry.io でプロジェクト作成(web: Next.js / api: Cloudflare Workers)
# 2. 以下をローカル .env または Cloudflare / wrangler secret に設定(チャット等で共有しない)
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_DSN=
# ソースマップ自動アップロード(任意・CI 向け)
SENTRY_ORG=
SENTRY_PROJECT=
SENTRY_AUTH_TOKEN=
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
"dependencies": {
"@hono/zod-openapi": "^1.4.0",
"@hono/zod-validator": "^0.8.0",
"@sentry/cloudflare": "^10.56.0",
"@sentry/hono": "^10.56.0",
"@sonory/shared-types": "file:../../packages/shared-types",
"@sonory/utils": "file:../../packages/utils",
"@supabase/supabase-js": "^2.108.0",
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { OpenAPIHono } from "@hono/zod-openapi"
import { sentry } from "@sentry/hono/cloudflare"
import { logger as honoLogger } from "hono/logger"
import { requestId } from "hono/request-id"
import { timing } from "hono/timing"
Expand All @@ -10,13 +11,19 @@ import { healthRoutes } from "./routes/health"
import pinsRoutes from "./routes/pins"
import type { Env } from "./types/env"
import { logger } from "./utils/logger"
import { createSentryOptions } from "./utils/sentryOptions"

/**
* OpenAPIHono アプリケーションを構築する。
*/
export function createApp(): OpenAPIHono<{ Bindings: Env }> {
const app = new OpenAPIHono<{ Bindings: Env }>()

app.use(
"*",
sentry(app, (env: Env) => createSentryOptions(env)),
)

app.use("*", requestId())
app.use("*", timing())
app.use("*", honoLogger())
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/middleware/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { APIError } from "@sonory/shared-types"
import { ERROR_CODES } from "@sonory/shared-types"
import type { Context, Next } from "hono"
import { HTTPException } from "hono/http-exception"
import type { Env } from "../types/env"
import { captureException } from "../utils/telemetry"

// ERROR_CODESを再エクスポート
export { ERROR_CODES }
Expand Down Expand Up @@ -85,6 +87,16 @@ export const errorHandler = async (c: Context, next: Next) => {

// その他のエラー
console.error("Unhandled error:", error)

const env = (c.env ?? {}) as Env
if (env.SENTRY_DSN) {
await captureException(error, {
requestId,
path: c.req.path,
method: c.req.method,
})
}

const apiError: APIError = {
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "An unexpected error occurred",
Expand Down
10 changes: 5 additions & 5 deletions apps/api/src/middleware/monitoring.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type { Context, Next } from "hono"
import type { Env } from "../index"
import { logger } from "../utils/logger"
import { captureException } from "../utils/telemetry"

/**
* モニタリング・ログ基盤ミドルウェア
*
* @description
* Cloudflare Workers環境でのログ・メトリクス・エラー追跡を提供
* Phase 1: 基本的なログ機能を実装
* Phase 2以降: Sentry連携やCloudflare Analytics統合を追加予定
* Phase 2: Sentry連携(@sentry/hono)済み
*/

/**
Expand Down Expand Up @@ -38,10 +39,9 @@ export async function errorTracking(

logger.error("Unhandled error occurred", errorDetails)

// TODO Phase 2以降: Sentryに送信
// if (c.env.SENTRY_DSN) {
// await sendToSentry(errorDetails)
// }
if (c.env.SENTRY_DSN) {
await captureException(error, errorDetails)
}

// エラーを再スロー(エラーハンドラーミドルウェアで処理)
throw error
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/types/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ export interface Env {
SUPABASE_SERVICE_KEY?: string
PYTHON_AUDIO_ANALYZER_URL: string
PYTHON_AUDIO_ANALYZER_TIMEOUT: string
/** Sentry DSN(`wrangler secret put SENTRY_DSN` で設定) */
SENTRY_DSN?: string
}
17 changes: 17 additions & 0 deletions apps/api/src/utils/sentryOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { CloudflareOptions } from "@sentry/cloudflare"
import type { Env } from "../types/env"

/**
* Workers 環境向け Sentry オプションを生成する。
*/
export function createSentryOptions(env: Env): CloudflareOptions {
const isProduction = env.ENVIRONMENT === "production"

return {
dsn: env.SENTRY_DSN,
enabled: Boolean(env.SENTRY_DSN),
environment: env.ENVIRONMENT,
tracesSampleRate: isProduction ? 0.1 : 1,
sendDefaultPii: false,
}
}
30 changes: 30 additions & 0 deletions apps/api/src/utils/telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as Sentry from "@sentry/cloudflare"
import type { LogContext } from "@sonory/utils"

/**
* Workers 向けのエラー転送。
*/
export async function captureException(
error: unknown,
context?: LogContext,
): Promise<void> {
if (context === undefined) {
Sentry.captureException(error)
return
}

Sentry.captureException(error, { extra: { ...context } })
}

/**
* 構造化メトリクスを Workers Analytics Engine へ送るためのプレースホルダー。
*/
export function recordMetric(
name: string,
value: number,
tags?: LogContext,
): void {
void name
void value
void tags
}
2 changes: 1 addition & 1 deletion apps/api/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"lib": ["ES2022"],
"types": ["./worker-configuration.d.ts", "node"],
"jsx": "react-jsx",
"moduleResolution": "node",
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"noPropertyAccessFromIndexSignature": false,
"moduleDetection": "force",
Expand Down
9 changes: 9 additions & 0 deletions apps/web/instrumentation-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as Sentry from "@sentry/nextjs"

import { getSentryOptions, isSentryEnabled } from "./sentry.shared.config"

if (isSentryEnabled()) {
Sentry.init(getSentryOptions())
}

export const onRouterTransitionStart = Sentry.captureRouterTransitionStart
16 changes: 16 additions & 0 deletions apps/web/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as Sentry from "@sentry/nextjs"

/**
* Next.js サーバー・Edge ランタイム向け Sentry 登録フック。
*/
export async function register(): Promise<void> {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./sentry.server.config")
}

if (process.env.NEXT_RUNTIME === "edge") {
await import("./sentry.edge.config")
}
}

export const onRequestError = Sentry.captureRequestError
10 changes: 9 additions & 1 deletion apps/web/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { withSentryConfig } from "@sentry/nextjs"
import type { NextConfig } from "next"

const withPWA = require("@ducanh2912/next-pwa").default({
Expand Down Expand Up @@ -43,4 +44,11 @@ const nextConfig: NextConfig = {
},
}

export default withPWA(nextConfig)
const configWithPwa = withPWA(nextConfig)

export default withSentryConfig(configWithPwa, {
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
silent: !process.env.CI,
})
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
"@ducanh2912/next-pwa": "^10.2.9",
"@mapbox/mapbox-gl-geocoder": "^5.1.2",
"@opennextjs/cloudflare": "^1.19.11",
"@sentry/nextjs": "^10.56.0",
"@sonory/shared-types": "*",
"@sonory/utils": "*",
"@supabase/supabase-js": "^2.108.0",
"@tanstack/react-query": "^5.101.0",
"framer-motion": "^12.40.0",
Expand Down
7 changes: 7 additions & 0 deletions apps/web/sentry.edge.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as Sentry from "@sentry/nextjs"

import { getSentryOptions, isSentryEnabled } from "./sentry.shared.config"

if (isSentryEnabled()) {
Sentry.init(getSentryOptions())
}
7 changes: 7 additions & 0 deletions apps/web/sentry.server.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as Sentry from "@sentry/nextjs"

import { getSentryOptions, isSentryEnabled } from "./sentry.shared.config"

if (isSentryEnabled()) {
Sentry.init(getSentryOptions())
}
28 changes: 28 additions & 0 deletions apps/web/sentry.shared.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { BrowserOptions, EdgeOptions, NodeOptions } from "@sentry/nextjs"

const sentryDsn = process.env.NEXT_PUBLIC_SENTRY_DSN

/**
* Sentry が有効かどうかを判定する。
*
* @description
* DSN 未設定時は SDK を初期化せず、ローカル開発への影響を避ける。
*/
export function isSentryEnabled(): boolean {
return Boolean(sentryDsn)
}

/**
* Sentry 共通オプションを返す。
*/
export function getSentryOptions(): BrowserOptions | NodeOptions | EdgeOptions {
const isProduction = process.env.NODE_ENV === "production"

return {
dsn: sentryDsn,
enabled: isSentryEnabled(),
environment: process.env.NODE_ENV,
tracesSampleRate: isProduction ? 0.1 : 1,
sendDefaultPii: false,
}
}
35 changes: 35 additions & 0 deletions apps/web/src/app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"use client"

import * as Sentry from "@sentry/nextjs"
import { useEffect } from "react"

interface GlobalErrorProps {
readonly error: Error & { digest?: string }
readonly reset: () => void
}

/**
* アプリ全体の未処理エラーを捕捉し Sentry へ送信する。
*/
export default function GlobalError({
error,
reset,
}: GlobalErrorProps): React.JSX.Element {
useEffect(() => {
Sentry.captureException(error)
}, [error])

return (
<html lang="ja">
<body>
<main>
<h1>問題が発生しました</h1>
<p>しばらくしてからもう一度お試しください。</p>
<button type="button" onClick={() => reset()}>
再試行
</button>
</main>
</body>
</html>
)
}
50 changes: 50 additions & 0 deletions apps/web/src/lib/telemetry/createClientLogger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { createLogger, type Logger } from "@sonory/utils"

import { createConsoleSink } from "./sinks/consoleSink"
import { createSentrySink } from "./sinks/sentrySink"

const isDevelopment = process.env.NODE_ENV === "development"

/**
* ブラウザ向けロガーを生成する。
*
* @description
* - `debug` / `info`: 開発環境のみコンソール出力
* - `warn` / `error`: 本番では Sentry へ転送(DSN 設定時)
* - 位置情報・音声 URL など PII は context に載せないこと
*
* @param namespace - ログの発生元(例: `"SoundPinMarkers"`)
* @returns 名前空間付きロガー
*
* @example
* ```ts
* const log = createClientLogger("recording")
* log.debug("Recorder started")
* log.error("Upload failed", { code: "NETWORK_ERROR" })
* ```
*/
export function createClientLogger(namespace: string): Logger {
const consoleSink = createConsoleSink()
const sentrySink = createSentrySink(namespace)

return createLogger({
namespace,
minLevel: isDevelopment ? "debug" : "warn",
sinks: {
debug: isDevelopment ? consoleSink.debug : undefined,
info: isDevelopment ? consoleSink.info : sentrySink.info,
warn: (message, context) => {
if (isDevelopment) {
consoleSink.warn(message, context)
}
sentrySink.warn(message, context)
},
error: (message, context) => {
if (isDevelopment) {
consoleSink.error(message, context)
}
sentrySink.error(message, context)
},
},
})
}
1 change: 1 addition & 0 deletions apps/web/src/lib/telemetry/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { createClientLogger } from "./createClientLogger"
26 changes: 26 additions & 0 deletions apps/web/src/lib/telemetry/sinks/consoleSink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { LogContext, LogLevel } from "@sonory/utils"

/**
* 開発環境向けのコンソール出力シンク。
*/
export function createConsoleSink(): Readonly<
Record<LogLevel, (message: string, context?: LogContext) => void>
> {
const format = (message: string, context?: LogContext): string =>
context === undefined ? message : `${message} ${JSON.stringify(context)}`

return {
debug: (message, context) => {
console.debug(format(message, context))
},
info: (message, context) => {
console.info(format(message, context))
},
warn: (message, context) => {
console.warn(format(message, context))
},
error: (message, context) => {
console.error(format(message, context))
},
}
}
Loading
Loading