diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0137335..f5d0a1d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -81,7 +81,10 @@ jobs: - name: Write .env file uses: appleboy/ssh-action@v0.1.10 env: - ENV_LIST: RCP_JWT_SECRET,RCP_ADMIN_EMAILS,OS_AUTH_URL,OS_USERNAME,OS_PASSWORD,OS_PROJECT_NAME,OS_USER_DOMAIN_NAME,OS_PROJECT_ID,CF_ACCESS_CLIENT_ID,CF_ACCESS_CLIENT_SECRET,GG_OAUTH_CLIENT,GG_OAUTH_SECRET,GG_REDIRECT_URL,DB_DRIVER,DB_DSN,PORT,RCP_NS_PROXY_SOCK,RCP_FUNCTION_BUILDER_SOCKET,RCP_FUNCTION_DATA_DIR,RCP_WEB_CONSOLE_BASE_URL,RCP_WEB_CONSOLE_ALLOWED_ORIGINS,RCP_VM_KNOWN_HOSTS_PATH,RCP_SSH_GW_KNOWN_HOSTS_PATH,FRONTEND_URL,RCP_FRONTEND_BASE_URL,RCP_ALLOWED_FRONTEND_ORIGIN_PATTERN,RCP_AUTH_COOKIE_SAMESITE,RCP_AUTH_COOKIE_SECURE,RCP_ALLOWED_ORIGINS,RCP_ALLOWED_ORIGIN_PATTERN,RCP_DEFAULT_NETWORK_ID,RCP_SSH_GW_NOTIFY_SOCK,RCP_SSH_GW_NOTIFY_SECRET + ENV_LIST: RCP_JWT_SECRET,RCP_ADMIN_EMAILS,OS_AUTH_URL,OS_USERNAME,OS_PASSWORD,OS_PROJECT_NAME,OS_USER_DOMAIN_NAME,OS_PROJECT_ID,CF_ACCESS_CLIENT_ID,CF_ACCESS_CLIENT_SECRET,GG_OAUTH_CLIENT,GG_OAUTH_SECRET,GG_REDIRECT_URL,DB_DRIVER,DB_DSN,PORT,RCP_NS_PROXY_SOCK,RCP_FUNCTION_BUILDER_SOCKET,RCP_FUNCTION_DATA_DIR,RCP_WEB_CONSOLE_BASE_URL,RCP_WEB_CONSOLE_ALLOWED_ORIGINS,RCP_VM_KNOWN_HOSTS_PATH,RCP_SSH_GW_KNOWN_HOSTS_PATH,FRONTEND_URL,RCP_FRONTEND_BASE_URL,RCP_ALLOWED_FRONTEND_ORIGIN_PATTERN,RCP_AUTH_COOKIE_SAMESITE,RCP_AUTH_COOKIE_SECURE,RCP_ALLOWED_ORIGINS,RCP_ALLOWED_ORIGIN_PATTERN,RCP_DEFAULT_NETWORK_ID,RCP_SSH_GW_NOTIFY_SOCK,RCP_SSH_GW_NOTIFY_SECRET,SENTRY_DSN,SENTRY_ENVIRONMENT,SENTRY_RELEASE + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_ENVIRONMENT: production + SENTRY_RELEASE: ${{ github.sha }} RCP_FUNCTION_BUILDER_SOCKET: ${{ secrets.RCP_FUNCTION_BUILDER_SOCKET }} RCP_FUNCTION_DATA_DIR: ${{ secrets.RCP_FUNCTION_DATA_DIR }} FRONTEND_URL: ${{ secrets.FRONTEND_URL }} diff --git a/GUIDELINE.md b/GUIDELINE.md index 072063b..d34a13f 100644 --- a/GUIDELINE.md +++ b/GUIDELINE.md @@ -192,6 +192,9 @@ main.go → infrastructure 클라이언트 생성 → App 조립 → 라우터 | 변수 | 의무 | 설명 | |------|------|------| | `PORT` | optional (기본 `8080`) | HTTP 리스닝 포트 | +| `SENTRY_DSN` | optional | Sentry 오류 수집 DSN. 미설정 시 수집 비활성. 운영 배포는 GitHub Actions Secret에 등록 | +| `SENTRY_ENVIRONMENT` | optional | Sentry 환경 이름. 운영 배포에서는 `production` | +| `SENTRY_RELEASE` | optional | Sentry 릴리스 식별자. 운영 배포에서는 Git commit SHA | | `RCP_JWT_SECRET` | optional (dev 폴백 있음) | JWT 서명 시크릿. 운영에서는 반드시 설정 | | `OS_AUTH_URL` | **필수** | OpenStack Identity 엔드포인트 | | `OS_USERNAME` | **필수** | OpenStack 사용자 이름 | diff --git a/cmd/api/main.go b/cmd/api/main.go index fa4e560..439abef 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -9,6 +9,8 @@ import ( "strings" "time" + "github.com/getsentry/sentry-go" + "github.com/KHU-RETURN/rcp-server/internal/infrastructure/database" "github.com/KHU-RETURN/rcp-server/internal/infrastructure/google" "github.com/KHU-RETURN/rcp-server/internal/infrastructure/openstack" @@ -21,6 +23,24 @@ func main() { if err := godotenv.Load(); err != nil && !errors.Is(err, os.ErrNotExist) { log.Fatalf(".env 로드 실패: %v", err) } + if dsn := strings.TrimSpace(os.Getenv("SENTRY_DSN")); dsn != "" { + off := &sentry.KeyValueCollectionBehavior{Mode: sentry.CollectionOff} + if err := sentry.Init(sentry.ClientOptions{ + Dsn: dsn, + Environment: os.Getenv("SENTRY_ENVIRONMENT"), + Release: os.Getenv("SENTRY_RELEASE"), + DataCollection: &sentry.DataCollection{ + UserInfo: sentry.Set(false), + Cookies: off, + HTTPHeaders: &sentry.HeaderCollectionConfig{Request: off, Response: off}, + HTTPBodies: []sentry.BodyType{}, + QueryParams: off, + }, + }); err != nil { + log.Fatalf("Sentry 초기화 실패: %v", err) + } + defer sentry.Flush(2 * time.Second) + } // 환경변수는 main에서만 읽습니다. jwtSecret := os.Getenv("RCP_JWT_SECRET") diff --git a/go.mod b/go.mod index ecd1ef8..ac4df36 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.26.6 require ( entgo.io/ent v0.14.6 github.com/coder/websocket v1.8.14 + github.com/getsentry/sentry-go v0.49.0 + github.com/getsentry/sentry-go/gin v0.49.0 github.com/gin-gonic/gin v1.12.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 @@ -53,7 +55,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect diff --git a/go.sum b/go.sum index 1632137..701f19d 100644 --- a/go.sum +++ b/go.sum @@ -29,18 +29,25 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/getsentry/sentry-go v0.49.0 h1:Ehejknu1l023Ub7QoRBVLAI7g3Jnhqku4oWx4B4Sh5s= +github.com/getsentry/sentry-go v0.49.0/go.mod h1:nuMJAoCfe1u0Bts2ocyNI+TW8HT84vRMqwA5Qq/SKUI= +github.com/getsentry/sentry-go/gin v0.49.0 h1:PGvRtmaQvDTspf5ltbpyAO3f2B3TfHRIlciPd/Bjxt4= +github.com/getsentry/sentry-go/gin v0.49.0/go.mod h1:jSZV8BST6coCpwWUMWJzFy74t1xoKL0kJPWFPCAr3Yo= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -101,8 +108,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= @@ -116,8 +123,13 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= @@ -169,6 +181,8 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= @@ -188,7 +202,6 @@ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/internal/server/router.go b/internal/server/router.go index c5aad3f..157ff94 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -1,6 +1,11 @@ package server import ( + "fmt" + "net/http" + + "github.com/getsentry/sentry-go" + sentrygin "github.com/getsentry/sentry-go/gin" "github.com/gin-gonic/gin" "github.com/KHU-RETURN/rcp-server/internal/api" @@ -9,6 +14,8 @@ import ( func NewRouter(app *App) *gin.Engine { r := gin.Default() + r.Use(sentrygin.New(sentrygin.Options{Repanic: true})) + r.Use(sentryServerErrorMiddleware()) r.Use(corsMiddleware()) v1 := r.Group(api.BasePath) @@ -43,3 +50,22 @@ func NewRouter(app *App) *gin.Engine { return r } + +func sentryServerErrorMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + if c.Writer.Status() < http.StatusInternalServerError { + return + } + if hub := sentrygin.GetHubFromContext(c); hub != nil { + path := c.FullPath() + if path == "" { + path = "unmatched route" + } + hub.WithScope(func(scope *sentry.Scope) { + scope.SetLevel(sentry.LevelError) + hub.CaptureMessage(fmt.Sprintf("%s %s returned %d", c.Request.Method, path, c.Writer.Status())) + }) + } + } +} diff --git a/internal/server/sentry_test.go b/internal/server/sentry_test.go new file mode 100644 index 0000000..d0a21de --- /dev/null +++ b/internal/server/sentry_test.go @@ -0,0 +1,84 @@ +package server + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/getsentry/sentry-go" + sentrygin "github.com/getsentry/sentry-go/gin" + "github.com/gin-gonic/gin" +) + +type recordingTransport struct { + mu sync.Mutex + events []*sentry.Event +} + +func (*recordingTransport) Configure(sentry.ClientOptions) {} +func (*recordingTransport) Flush(time.Duration) bool { return true } +func (*recordingTransport) FlushWithContext(context.Context) bool { + return true +} +func (*recordingTransport) Close() {} +func (t *recordingTransport) SendEvent(event *sentry.Event) { + t.mu.Lock() + defer t.mu.Unlock() + t.events = append(t.events, event) +} + +func TestSentryMiddlewareCapturesServerErrorsAndPanics(t *testing.T) { + gin.SetMode(gin.TestMode) + transport := &recordingTransport{} + client, err := sentry.NewClient(sentry.ClientOptions{ + Dsn: "https://public@example.com/1", + Transport: transport, + }) + if err != nil { + t.Fatal(err) + } + hub := sentry.NewHub(client, sentry.NewScope()) + + router := gin.New() + router.Use(gin.RecoveryWithWriter(io.Discard), sentrygin.New(sentrygin.Options{Repanic: true}), sentryServerErrorMiddleware()) + router.GET("/items/:id", func(c *gin.Context) { c.Status(http.StatusInternalServerError) }) + router.GET("/panic", func(c *gin.Context) { panic("test panic") }) + router.GET("/ok", func(c *gin.Context) { c.Status(http.StatusOK) }) + + for _, path := range []string{"/items/secret-id?token=secret", "/panic", "/ok"} { + request := httptest.NewRequest(http.MethodGet, path, nil) + request = request.WithContext(sentry.SetHubOnContext(request.Context(), hub)) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + } + client.Flush(2 * time.Second) + + transport.mu.Lock() + defer transport.mu.Unlock() + if len(transport.events) != 2 { + t.Fatalf("captured %d events, want 2", len(transport.events)) + } + var sawStatus, sawPanic bool + for _, event := range transport.events { + if event.Message == "GET /items/:id returned 500" { + sawStatus = true + if event.Level != sentry.LevelError { + t.Fatalf("5xx event level = %q, want error", event.Level) + } + } + if len(event.Exception) > 0 || strings.Contains(event.Message, "test panic") { + sawPanic = true + } + if strings.Contains(event.Message, "secret") { + t.Fatalf("event message contains request parameter: %q", event.Message) + } + } + if !sawStatus || !sawPanic { + t.Fatalf("missing status or panic event: status=%t panic=%t", sawStatus, sawPanic) + } +}