diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 721da3c..a79275b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -176,6 +176,7 @@ jobs: GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} GH_PAT: ${{ secrets.GH_PAT }} GCP_BUCKET: ${{ secrets.GCP_BUCKET }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} # Binary signing DEVELOPER_ID_APP: ${{ secrets.DEVELOPER_ID_APP }} @@ -346,6 +347,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} GH_PAT: ${{ secrets.GH_PAT }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} GCP_BUCKET: ${{ secrets.GCP_BUCKET }} CERT_THUMBPRINT: ${{ secrets.CERT_THUMBPRINT }} TIMESTAMP_SERVER: ${{ secrets.TIMESTAMP_SERVER }} @@ -463,6 +465,7 @@ jobs: GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} GH_PAT: ${{ secrets.GH_PAT }} GCP_BUCKET: ${{ secrets.GCP_BUCKET }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} # Copy files to latest/linux folder # Copies all uploaded files to stable latest/ paths with simplified naming diff --git a/.gitignore b/.gitignore index 83f2d2d..5cf50b0 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,4 @@ Thumbs.db .env.local .env.*.local -.thunder.json \ No newline at end of file +.thunder.json.env diff --git a/.goreleaser.macos.yaml b/.goreleaser.macos.yaml index 14d3433..8ab1017 100644 --- a/.goreleaser.macos.yaml +++ b/.goreleaser.macos.yaml @@ -23,6 +23,7 @@ builds: - -X github.com/Thunder-Compute/thunder-cli/internal/version.BuildVersion={{ .Version }} - -X github.com/Thunder-Compute/thunder-cli/internal/version.BuildCommit={{ .Commit }} - -X github.com/Thunder-Compute/thunder-cli/internal/version.BuildDate={{ .Date }} + - -X github.com/Thunder-Compute/thunder-cli/internal/version.SentryDSN={{ .Env.SENTRY_DSN }} # Post-build hook: Signs binaries, creates .pkg installer, signs .pkg, and notarizes hooks: post: diff --git a/.goreleaser.ubuntu.yaml b/.goreleaser.ubuntu.yaml index a925c84..d904e3a 100644 --- a/.goreleaser.ubuntu.yaml +++ b/.goreleaser.ubuntu.yaml @@ -23,6 +23,7 @@ builds: - -X github.com/Thunder-Compute/thunder-cli/internal/version.BuildVersion={{ .Version }} - -X github.com/Thunder-Compute/thunder-cli/internal/version.BuildCommit={{ .Commit }} - -X github.com/Thunder-Compute/thunder-cli/internal/version.BuildDate={{ .Date }} + - -X github.com/Thunder-Compute/thunder-cli/internal/version.SentryDSN={{ .Env.SENTRY_DSN }} # Linux package configuration (.deb, .rpm, .apk) # These packages are automatically included in GitHub releases - DO NOT add to release.extra_files diff --git a/.goreleaser.windows.yaml b/.goreleaser.windows.yaml index 3bc57ef..397dcc5 100644 --- a/.goreleaser.windows.yaml +++ b/.goreleaser.windows.yaml @@ -24,6 +24,7 @@ builds: - -X github.com/Thunder-Compute/thunder-cli/internal/version.BuildVersion={{ .Version }} - -X github.com/Thunder-Compute/thunder-cli/internal/version.BuildCommit={{ .Commit }} - -X github.com/Thunder-Compute/thunder-cli/internal/version.BuildDate={{ .Date }} + - -X github.com/Thunder-Compute/thunder-cli/internal/version.SentryDSN={{ .Env.SENTRY_DSN }} hooks: post: - >- diff --git a/api/client.go b/api/client.go index 88414bd..c854560 100644 --- a/api/client.go +++ b/api/client.go @@ -9,6 +9,9 @@ import ( "net/http" "sort" "time" + + "github.com/Thunder-Compute/thunder-cli/sentry" + sentrygo "github.com/getsentry/sentry-go" ) type Client struct { @@ -41,6 +44,8 @@ func (c *Client) setHeaders(req *http.Request) { } func (c *Client) ValidateToken(ctx context.Context) error { + sentry.AddBreadcrumb("api", "validate_token", nil, sentry.LevelInfo) + req, err := http.NewRequest("GET", c.baseURL+"/v1/auth/validate", nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) @@ -50,17 +55,39 @@ func (c *Client) ValidateToken(ctx context.Context) error { resp, err := c.do(ctx, req) if err != nil { + sentry.CaptureError(err, &sentry.EventOptions{ + Tags: sentry.NewTags(). + Set("api_method", "ValidateToken"). + Set("api_url", c.baseURL), + Level: ptr(sentry.LevelError), + }) return fmt.Errorf("failed to validate token: %w", err) } defer resp.Body.Close() if resp.StatusCode == 401 { - return fmt.Errorf("authentication failed: invalid token") + err := fmt.Errorf("authentication failed: invalid token") + sentry.CaptureError(err, &sentry.EventOptions{ + Tags: sentry.NewTags(). + Set("api_method", "ValidateToken"). + Set("status_code", "401"), + Level: ptr(sentry.LevelWarning), + }) + return err } if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("token validation failed with status %d: %s", resp.StatusCode, string(body)) + err := fmt.Errorf("token validation failed with status %d: %s", resp.StatusCode, string(body)) + sentry.CaptureError(err, &sentry.EventOptions{ + Tags: sentry.NewTags(). + Set("api_method", "ValidateToken"). + Set("status_code", fmt.Sprintf("%d", resp.StatusCode)), + Extra: sentry.NewExtra(). + Set("response_body", string(body)), + Level: ptr(getLogLevelForStatus(resp.StatusCode)), + }) + return err } _, _ = io.ReadAll(resp.Body) @@ -68,6 +95,8 @@ func (c *Client) ValidateToken(ctx context.Context) error { } func (c *Client) ListInstancesWithIPUpdateCtx(ctx context.Context) ([]Instance, error) { + sentry.AddBreadcrumb("api", "list_instances", nil, sentry.LevelInfo) + req, err := http.NewRequest("GET", c.baseURL+"/instances/list?update_ips=true", nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) @@ -77,17 +106,37 @@ func (c *Client) ListInstancesWithIPUpdateCtx(ctx context.Context) ([]Instance, resp, err := c.do(ctx, req) if err != nil { + sentry.CaptureError(err, &sentry.EventOptions{ + Tags: sentry.NewTags(). + Set("api_method", "ListInstances"). + Set("api_url", c.baseURL), + Level: ptr(sentry.LevelError), + }) return nil, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() if resp.StatusCode == 401 { - return nil, fmt.Errorf("authentication failed: invalid token") + err := fmt.Errorf("authentication failed: invalid token") + sentry.CaptureError(err, &sentry.EventOptions{ + Tags: sentry.NewTags(). + Set("api_method", "ListInstances"). + Set("status_code", "401"), + Level: ptr(sentry.LevelWarning), + }) + return nil, err } if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + err := fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + sentry.CaptureError(err, &sentry.EventOptions{ + Tags: sentry.NewTags(). + Set("api_method", "ListInstances"). + Set("status_code", fmt.Sprintf("%d", resp.StatusCode)), + Level: ptr(getLogLevelForStatus(resp.StatusCode)), + }) + return nil, err } body, err := io.ReadAll(resp.Body) @@ -532,3 +581,22 @@ func (c *Client) DeleteSnapshot(snapshotID string) error { return nil } + +// Helper functions for Sentry integration + +// ptr is a helper to create a pointer to a value +func ptr[T any](v T) *T { + return &v +} + +// getLogLevelForStatus determines the appropriate Sentry level for HTTP status codes +func getLogLevelForStatus(statusCode int) sentrygo.Level { + switch { + case statusCode >= 500: + return sentrygo.LevelError + case statusCode >= 400: + return sentrygo.LevelWarning + default: + return sentrygo.LevelInfo + } +} diff --git a/cmd/connect.go b/cmd/connect.go index 79f4581..37e8ce1 100644 --- a/cmd/connect.go +++ b/cmd/connect.go @@ -16,6 +16,7 @@ import ( "github.com/spf13/cobra" "github.com/Thunder-Compute/thunder-cli/api" + "github.com/Thunder-Compute/thunder-cli/sentry" "github.com/Thunder-Compute/thunder-cli/tui" helpmenus "github.com/Thunder-Compute/thunder-cli/tui/help-menus" "github.com/Thunder-Compute/thunder-cli/utils" @@ -77,16 +78,12 @@ func defaultConnectOptions(token, baseURL string) *connectOptions { var connectCmd = &cobra.Command{ Use: "connect [instance_id]", Short: "Establish an SSH connection to a Thunder Compute instance", - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { var instanceID string if len(args) > 0 { instanceID = args[0] } - - if err := runConnect(instanceID, tunnelPorts, debugMode); err != nil { - PrintError(err) - os.Exit(1) - } + return runConnect(instanceID, tunnelPorts, debugMode) }, } @@ -107,6 +104,11 @@ func runConnect(instanceID string, tunnelPortsStr []string, debug bool) error { // runConnectWithOptions accepts options for testing. If opts is nil, default options are used. func runConnectWithOptions(instanceID string, tunnelPortsStr []string, debug bool, opts *connectOptions) error { + sentry.AddBreadcrumb("connect", "starting connection", map[string]interface{}{ + "instance_id": instanceID, + "has_tunnels": len(tunnelPortsStr) > 0, + }, sentry.LevelInfo) + configLoader := resolveConfigLoader(opts) config, err := configLoader() if err != nil { @@ -124,6 +126,8 @@ func runConnectWithOptions(instanceID string, tunnelPortsStr []string, debug boo client := resolveConnectClient(opts, config.Token, config.APIURL) + sentry.AddBreadcrumb("connect", "fetching instances", nil, sentry.LevelInfo) + busy := tui.NewBusyModel("Fetching instances...") bp := tea.NewProgram(busy, tea.WithOutput(os.Stdout)) busyDone := make(chan struct{}) @@ -177,6 +181,10 @@ func runConnectWithOptions(instanceID string, tunnelPortsStr []string, debug boo instanceID = foundInstance.ID } + sentry.AddBreadcrumb("connect", "instance selected", map[string]interface{}{ + "instance_id": instanceID, + }, sentry.LevelInfo) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() @@ -312,6 +320,14 @@ func runConnectWithOptions(instanceID string, tunnelPortsStr []string, debug boo port = 22 } + sentry.AddBreadcrumb("connect", "instance validated", map[string]interface{}{ + "instance_id": instanceID, + "instance_name": instance.Name, + "instance_ip": instance.IP, + "instance_port": port, + "instance_mode": instance.Mode, + }, sentry.LevelInfo) + phaseTimings["instance_validation"] = time.Since(phase2Start) tui.SendPhaseUpdate(p, 1, tui.PhaseCompleted, fmt.Sprintf("Found: %s (%s)", instance.Name, instance.IP), phaseTimings["instance_validation"]) @@ -320,25 +336,43 @@ func runConnectWithOptions(instanceID string, tunnelPortsStr []string, debug boo keyFile := utils.GetKeyFile(instance.UUID) newKeyCreated := false + keyExists := utils.KeyExists(instance.UUID) + + sentry.AddBreadcrumb("connect", "checking SSH keys", map[string]interface{}{ + "key_exists": keyExists, + "key_file": keyFile, + }, sentry.LevelInfo) + if checkCancelled() { return nil } - if !utils.KeyExists(instance.UUID) { + if !keyExists { + sentry.AddBreadcrumb("connect", "generating new SSH key", map[string]interface{}{ + "instance_id": instanceID, + }, sentry.LevelInfo) + tui.SendPhaseUpdate(p, 2, tui.PhaseInProgress, "Generating new SSH key...", 0) keyResp, err := client.AddSSHKeyCtx(ctx, instanceID) if checkCancelled() { return nil } if err != nil { + sentry.AddBreadcrumb("connect", "SSH key generation failed", map[string]interface{}{ + "error": err.Error(), + }, sentry.LevelError) shutdownTUI() return fmt.Errorf("failed to add SSH key: %w", err) } if err := utils.SavePrivateKey(instance.UUID, keyResp.Key); err != nil { + sentry.AddBreadcrumb("connect", "SSH key save failed", map[string]interface{}{ + "error": err.Error(), + }, sentry.LevelError) shutdownTUI() return fmt.Errorf("failed to save private key: %w", err) } newKeyCreated = true + sentry.AddBreadcrumb("connect", "SSH key created successfully", nil, sentry.LevelInfo) } phaseTimings["ssh_key_management"] = time.Since(phase3Start) @@ -347,6 +381,11 @@ func runConnectWithOptions(instanceID string, tunnelPortsStr []string, debug boo phase4Start := time.Now() tui.SendPhaseUpdate(p, 3, tui.PhaseInProgress, fmt.Sprintf("Waiting for SSH service on %s:%d...", instance.IP, port), 0) + sentry.AddBreadcrumb("connect", "waiting for SSH port", map[string]interface{}{ + "ip": instance.IP, + "port": port, + }, sentry.LevelInfo) + if checkCancelled() { return nil } @@ -354,6 +393,11 @@ func runConnectWithOptions(instanceID string, tunnelPortsStr []string, debug boo if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return nil } + sentry.AddBreadcrumb("connect", "SSH port not available", map[string]interface{}{ + "ip": instance.IP, + "port": port, + "error": err.Error(), + }, sentry.LevelError) shutdownTUI() return fmt.Errorf("SSH service not available: %w", err) } @@ -390,6 +434,12 @@ func runConnectWithOptions(instanceID string, tunnelPortsStr []string, debug boo return nil } + sentry.AddBreadcrumb("connect", "establishing SSH connection", map[string]interface{}{ + "ip": instance.IP, + "port": port, + "new_key_created": newKeyCreated, + }, sentry.LevelInfo) + // Use different connection strategies for new keys vs reconnections if newKeyCreated { // New key: expect auth failures while key propagates, use longer timeout @@ -408,6 +458,13 @@ func runConnectWithOptions(instanceID string, tunnelPortsStr []string, debug boo // Handle persistent auth failure (likely deleted ~/.ssh on instance) or other auth errors needsKeyRegeneration := err != nil && !newKeyCreated && (errors.Is(err, utils.ErrPersistentAuthFailure) || utils.IsAuthError(err) || utils.IsKeyParseError(err)) if needsKeyRegeneration { + sentry.AddBreadcrumb("connect", "SSH auth failed, regenerating key", map[string]interface{}{ + "error": err.Error(), + "is_persistent_auth_fail": errors.Is(err, utils.ErrPersistentAuthFailure), + "is_auth_error": utils.IsAuthError(err), + "is_key_parse_error": utils.IsKeyParseError(err), + }, sentry.LevelWarning) + if errors.Is(err, utils.ErrPersistentAuthFailure) { tui.SendPhaseUpdate(p, 3, tui.PhaseWarning, "SSH keys on instance appear to be missing. Reconfiguring access...", 0) } else { @@ -419,16 +476,23 @@ func runConnectWithOptions(instanceID string, tunnelPortsStr []string, debug boo return nil } if keyErr != nil { + sentry.AddBreadcrumb("connect", "key regeneration failed", map[string]interface{}{ + "error": keyErr.Error(), + }, sentry.LevelError) shutdownTUI() return fmt.Errorf("failed to generate new SSH key: %w", keyErr) } if saveErr := utils.SavePrivateKey(instance.UUID, keyResp.Key); saveErr != nil { + sentry.AddBreadcrumb("connect", "key save failed after regeneration", map[string]interface{}{ + "error": saveErr.Error(), + }, sentry.LevelError) shutdownTUI() return fmt.Errorf("failed to save new private key: %w", saveErr) } keyFile = utils.GetKeyFile(instance.UUID) + sentry.AddBreadcrumb("connect", "key regenerated, retrying connection", nil, sentry.LevelInfo) tui.SendPhaseUpdate(p, 3, tui.PhaseInProgress, fmt.Sprintf("Retrying connection with new key to %s:%d...", instance.IP, port), 0) @@ -451,14 +515,26 @@ func runConnectWithOptions(instanceID string, tunnelPortsStr []string, debug boo return nil } if err != nil { + sentry.AddBreadcrumb("connect", "SSH connection failed after key regeneration", map[string]interface{}{ + "error": err.Error(), + }, sentry.LevelError) shutdownTUI() return fmt.Errorf("failed to establish SSH connection after key regeneration: %w", err) } } else if err != nil { + sentry.AddBreadcrumb("connect", "SSH connection failed", map[string]interface{}{ + "error": err.Error(), + "error_type": string(utils.ClassifySSHError(err)), + "is_auth_error": utils.IsAuthError(err), + }, sentry.LevelError) shutdownTUI() return fmt.Errorf("failed to establish SSH connection: %w", err) } + sentry.AddBreadcrumb("connect", "SSH connection established", map[string]interface{}{ + "duration_ms": time.Since(phase4Start).Milliseconds(), + }, sentry.LevelInfo) + phaseTimings["ssh_connection"] = time.Since(phase4Start) tui.SendPhaseComplete(p, 3, phaseTimings["ssh_connection"]) @@ -469,16 +545,26 @@ func runConnectWithOptions(instanceID string, tunnelPortsStr []string, debug boo return nil } + sentry.AddBreadcrumb("connect", "setting up token", map[string]interface{}{ + "mode": instance.Mode, + }, sentry.LevelInfo) + // Set up token on the instance (binary is now managed by the instance itself) if instance.Mode == "production" { tui.SendPhaseUpdate(p, 4, tui.PhaseInProgress, "Production mode detected, setting up token...", 0) if err := utils.RemoveThunderVirtualization(sshClient, config.Token); err != nil { + sentry.AddBreadcrumb("connect", "token setup failed (production)", map[string]interface{}{ + "error": err.Error(), + }, sentry.LevelError) shutdownTUI() return fmt.Errorf("failed to set up token: %w", err) } } else { tui.SendPhaseUpdate(p, 4, tui.PhaseInProgress, "Setting up token...", 0) if err := utils.SetupToken(sshClient, config.Token); err != nil { + sentry.AddBreadcrumb("connect", "token setup failed (prototyping)", map[string]interface{}{ + "error": err.Error(), + }, sentry.LevelError) shutdownTUI() return fmt.Errorf("failed to set up token: %w", err) } @@ -495,6 +581,12 @@ func runConnectWithOptions(instanceID string, tunnelPortsStr []string, debug boo templatePorts := utils.GetTemplateOpenPorts(instance.Template) _ = utils.UpdateSSHConfig(instanceID, instance.IP, port, instance.UUID, tunnelPorts, templatePorts) + sentry.AddBreadcrumb("connect", "connection setup complete", map[string]interface{}{ + "instance_id": instanceID, + "tunnel_count": len(tunnelPorts), + "total_time_ms": time.Since(phase1Start).Milliseconds(), + }, sentry.LevelInfo) + tui.SendConnectComplete(p) if checkCancelled() { diff --git a/cmd/create.go b/cmd/create.go index 34f0693..0b52470 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -27,11 +27,8 @@ var ( var createCmd = &cobra.Command{ Use: "create", Short: "Create a new Thunder Compute GPU instance", - Run: func(cmd *cobra.Command, args []string) { - if err := runCreate(cmd); err != nil { - PrintError(err) - os.Exit(1) - } + RunE: func(cmd *cobra.Command, args []string) error { + return runCreate(cmd) }, } diff --git a/cmd/delete.go b/cmd/delete.go index c38c5bb..db4a956 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -20,11 +20,8 @@ var deleteCmd = &cobra.Command{ Use: "delete [instance_id]", Short: "Delete a Thunder Compute instance", Args: cobra.MaximumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { - if err := runDelete(args); err != nil { - PrintError(err) - os.Exit(1) - } + RunE: func(cmd *cobra.Command, args []string) error { + return runDelete(args) }, } diff --git a/cmd/login.go b/cmd/login.go index dc03f17..b29abda 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -232,11 +232,8 @@ var loginToken string var loginCmd = &cobra.Command{ Use: "login", Short: "Authenticate with Thunder Compute", - Run: func(cmd *cobra.Command, args []string) { - if err := runLogin(); err != nil { - PrintError(err) - os.Exit(1) - } + RunE: func(cmd *cobra.Command, args []string) error { + return runLogin() }, } @@ -573,11 +570,8 @@ var logoutCmd = &cobra.Command{ Use: "logout", Short: "Log out from Thunder Compute", Long: `Log out from Thunder Compute and remove saved authentication credentials.`, - Run: func(cmd *cobra.Command, args []string) { - if err := runLogout(); err != nil { - PrintError(err) - os.Exit(1) - } + RunE: func(cmd *cobra.Command, args []string) error { + return runLogout() }, } diff --git a/cmd/modify.go b/cmd/modify.go index 69646eb..4af2f57 100644 --- a/cmd/modify.go +++ b/cmd/modify.go @@ -21,11 +21,8 @@ var modifyCmd = &cobra.Command{ Use: "modify [instance_index_or_id]", Short: "Modify a Thunder Compute instance configuration", Args: cobra.MaximumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { - if err := runModify(cmd, args); err != nil { - PrintError(err) - os.Exit(1) - } + RunE: func(cmd *cobra.Command, args []string) error { + return runModify(cmd, args) }, } diff --git a/cmd/root.go b/cmd/root.go index 6ffce3a..da9e419 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,6 +11,7 @@ import ( "github.com/Thunder-Compute/thunder-cli/internal/autoupdate" "github.com/Thunder-Compute/thunder-cli/internal/updatepolicy" "github.com/Thunder-Compute/thunder-cli/internal/version" + "github.com/Thunder-Compute/thunder-cli/sentry" "github.com/Thunder-Compute/thunder-cli/tui" helpmenus "github.com/Thunder-Compute/thunder-cli/tui/help-menus" "github.com/spf13/cobra" @@ -31,8 +32,9 @@ var rootCmd = &cobra.Command{ // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { - err := rootCmd.Execute() + cmd, err := rootCmd.ExecuteC() if err != nil { + CaptureCommandError(cmd, err) PrintError(err) os.Exit(1) } @@ -50,6 +52,13 @@ func init() { helpmenus.RenderRootHelp(cmd) }) + // Wrap all subcommands with Sentry after they're registered + cobra.OnInitialize(func() { + for _, cmd := range rootCmd.Commands() { + WrapCommandWithSentry(cmd) + } + }) + completionCmd := &cobra.Command{ Use: "completion [shell]", Short: "Generate the autocompletion script for tnr for the specified shell", @@ -125,6 +134,13 @@ func checkIfUpdateNeeded(cmd *cobra.Command) { policyResult, err := updatepolicy.Check(ctx, version.BuildVersion, false) if err != nil { + // Capture update check failures to Sentry + sentry.CaptureError(err, &sentry.EventOptions{ + Tags: sentry.NewTags(). + Set("operation", "update_check"). + Set("version", version.BuildVersion), + Level: ptr(sentry.LevelWarning), + }) fmt.Fprintf(os.Stderr, "Warning: update check failed: %v\n", err) return } @@ -171,6 +187,14 @@ func handleMandatoryUpdate(parentCtx context.Context, res updatepolicy.Result, m defer cancel() if err := runSelfUpdate(updateCtx, res); err != nil { + // Capture update failures to Sentry + sentry.CaptureError(err, &sentry.EventOptions{ + Tags: sentry.NewTags(). + Set("operation", "mandatory_update"). + Set("current_version", res.CurrentVersion). + Set("target_version", res.LatestVersion), + Level: ptr(sentry.LevelError), + }) if manual { fmt.Fprintf(os.Stderr, "Update failed: %v\n", err) } else { @@ -243,6 +267,14 @@ func handleOptionalUpdate(parentCtx context.Context, res updatepolicy.Result) { if updateErr == nil { fmt.Println("Update finished! You can now re-run your command.") } else { + // Capture optional update failures to Sentry + sentry.CaptureError(updateErr, &sentry.EventOptions{ + Tags: sentry.NewTags(). + Set("operation", "optional_update"). + Set("current_version", res.CurrentVersion). + Set("target_version", res.LatestVersion), + Level: ptr(sentry.LevelWarning), + }) fmt.Fprintf(os.Stderr, "Warning: optional update failed: %v\n", updateErr) fmt.Printf("You can download the latest version from GitHub: https://github.com/Thunder-Compute/thunder-cli/releases/tag/%s and reinstall the CLI.\n", releaseTag(res)) } diff --git a/cmd/scp.go b/cmd/scp.go index 2464f30..3c087f8 100644 --- a/cmd/scp.go +++ b/cmd/scp.go @@ -11,6 +11,7 @@ import ( "time" "github.com/Thunder-Compute/thunder-cli/api" + "github.com/Thunder-Compute/thunder-cli/sentry" "github.com/Thunder-Compute/thunder-cli/tui" helpmenus "github.com/Thunder-Compute/thunder-cli/tui/help-menus" "github.com/Thunder-Compute/thunder-cli/utils" @@ -24,19 +25,15 @@ var scpCmd = &cobra.Command{ Use: "scp [source...] [destination]", Short: "Securely copy files between local machine and Thunder Compute instances", Args: cobra.MinimumNArgs(2), - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { if len(args) < 2 { - PrintError(fmt.Errorf("requires at least a source and destination")) - os.Exit(1) + return fmt.Errorf("requires at least a source and destination") } sources := args[:len(args)-1] destination := args[len(args)-1] - if err := runSCP(sources, destination); err != nil { - PrintError(err) - os.Exit(1) - } + return runSCP(sources, destination) }, } @@ -105,6 +102,11 @@ func isValidInstanceID(s string) bool { } func runSCP(sources []string, destination string) error { + sentry.AddBreadcrumb("scp", "starting SCP operation", map[string]interface{}{ + "source_count": len(sources), + "destination": destination, + }, sentry.LevelInfo) + config, err := LoadConfig() if err != nil { return fmt.Errorf("not authenticated. Please run 'tnr login' first") @@ -118,6 +120,10 @@ func runSCP(sources []string, destination string) error { for i, src := range sources { parsed, err := parsePath(src) if err != nil { + sentry.AddBreadcrumb("scp", "path parsing failed", map[string]interface{}{ + "path": src, + "error": err.Error(), + }, sentry.LevelError) return fmt.Errorf("failed to parse source path '%s': %w", src, err) } sourcePaths[i] = parsed @@ -125,14 +131,26 @@ func runSCP(sources []string, destination string) error { destPath, err := parsePath(destination) if err != nil { + sentry.AddBreadcrumb("scp", "destination path parsing failed", map[string]interface{}{ + "path": destination, + "error": err.Error(), + }, sentry.LevelError) return fmt.Errorf("failed to parse destination path '%s': %w", destination, err) } direction, instanceID, err := determineTransferDirection(sourcePaths, destPath) if err != nil { + sentry.AddBreadcrumb("scp", "transfer direction error", map[string]interface{}{ + "error": err.Error(), + }, sentry.LevelError) return err } + sentry.AddBreadcrumb("scp", "transfer direction determined", map[string]interface{}{ + "direction": direction, + "instance_id": instanceID, + }, sentry.LevelInfo) + if len(sourcePaths) > 1 { if direction == "upload" { if !strings.HasSuffix(destPath.Path, "/") { @@ -181,8 +199,13 @@ func runSCP(sources []string, destination string) error { p.Send(tui.SCPPhaseMsg{Phase: tui.SCPPhaseConnecting}) + sentry.AddBreadcrumb("scp", "fetching instances", nil, sentry.LevelInfo) + instances, err := client.ListInstances() if err != nil { + sentry.AddBreadcrumb("scp", "failed to list instances", map[string]interface{}{ + "error": err.Error(), + }, sentry.LevelError) p.Send(tui.SCPErrorMsg{Err: fmt.Errorf("failed to list instances: %w", err)}) <-tuiDone return fmt.Errorf("failed to list instances: %w", err) @@ -197,30 +220,57 @@ func runSCP(sources []string, destination string) error { } if targetInstance == nil { + sentry.AddBreadcrumb("scp", "instance not found", map[string]interface{}{ + "instance_id": instanceID, + }, sentry.LevelError) p.Send(tui.SCPErrorMsg{Err: fmt.Errorf("instance '%s' not found", instanceID)}) <-tuiDone return fmt.Errorf("instance '%s' not found", instanceID) } if targetInstance.Status != "RUNNING" { + sentry.AddBreadcrumb("scp", "instance not running", map[string]interface{}{ + "instance_id": instanceID, + "status": targetInstance.Status, + }, sentry.LevelError) p.Send(tui.SCPErrorMsg{Err: fmt.Errorf("instance '%s' is not running (status: %s)", instanceID, targetInstance.Status)}) <-tuiDone return fmt.Errorf("instance '%s' is not running (status: %s)", instanceID, targetInstance.Status) } + sentry.AddBreadcrumb("scp", "instance validated", map[string]interface{}{ + "instance_id": targetInstance.ID, + "instance_name": targetInstance.Name, + "instance_ip": targetInstance.IP, + }, sentry.LevelInfo) + instanceName := fmt.Sprintf("%s (%s)", targetInstance.Name, targetInstance.ID) p.Send(tui.SCPInstanceNameMsg{InstanceName: instanceName}) keyFile := utils.GetKeyFile(targetInstance.UUID) - if !utils.KeyExists(targetInstance.UUID) { + keyExists := utils.KeyExists(targetInstance.UUID) + + sentry.AddBreadcrumb("scp", "checking SSH keys", map[string]interface{}{ + "key_exists": keyExists, + }, sentry.LevelInfo) + + if !keyExists { + sentry.AddBreadcrumb("scp", "generating SSH key", nil, sentry.LevelInfo) + keyResp, err := client.AddSSHKey(targetInstance.ID) if err != nil { + sentry.AddBreadcrumb("scp", "SSH key generation failed", map[string]interface{}{ + "error": err.Error(), + }, sentry.LevelError) p.Send(tui.SCPErrorMsg{Err: fmt.Errorf("failed to add SSH key: %w", err)}) <-tuiDone return fmt.Errorf("failed to add SSH key: %w", err) } if err := utils.SavePrivateKey(targetInstance.UUID, keyResp.Key); err != nil { + sentry.AddBreadcrumb("scp", "SSH key save failed", map[string]interface{}{ + "error": err.Error(), + }, sentry.LevelError) p.Send(tui.SCPErrorMsg{Err: fmt.Errorf("failed to save private key: %w", err)}) <-tuiDone return fmt.Errorf("failed to save private key: %w", err) @@ -246,6 +296,11 @@ func runSCP(sources []string, destination string) error { return nil } + sentry.AddBreadcrumb("scp", "establishing SSH connection", map[string]interface{}{ + "ip": targetInstance.IP, + "port": targetInstance.Port, + }, sentry.LevelInfo) + sshClient, err := utils.RobustSSHConnectCtx(cancelCtx, targetInstance.IP, keyFile, targetInstance.Port, 60) if checkCancelled() { <-tuiDone @@ -253,12 +308,20 @@ func runSCP(sources []string, destination string) error { return nil } if err != nil { + sentry.AddBreadcrumb("scp", "SSH connection failed", map[string]interface{}{ + "ip": targetInstance.IP, + "port": targetInstance.Port, + "error": err.Error(), + "error_type": string(utils.ClassifySSHError(err)), + }, sentry.LevelError) p.Send(tui.SCPErrorMsg{Err: fmt.Errorf("SSH connection failed: %w", err)}) <-tuiDone return fmt.Errorf("SSH connection failed: %w", err) } defer sshClient.Close() + sentry.AddBreadcrumb("scp", "SSH connection established", nil, sentry.LevelInfo) + if checkCancelled() { <-tuiDone PrintWarningSimple("User cancelled scp process") @@ -287,6 +350,13 @@ func runSCP(sources []string, destination string) error { startTime := time.Now() p.Send(tui.SCPPhaseMsg{Phase: tui.SCPPhaseTransferring}) + sentry.AddBreadcrumb("scp", "starting file transfer", map[string]interface{}{ + "direction": direction, + "file_count": len(sourcePaths), + "instance_id": instanceID, + "instance_ip": targetInstance.IP, + }, sentry.LevelInfo) + filesTransferred := 0 var totalBytes int64 @@ -341,6 +411,11 @@ func runSCP(sources []string, destination string) error { return nil } if err != nil { + sentry.AddBreadcrumb("scp", "upload failed", map[string]interface{}{ + "local_path": localPath, + "remote_path": remotePath, + "error": err.Error(), + }, sentry.LevelError) p.Send(tui.SCPErrorMsg{Err: err}) <-tuiDone return fmt.Errorf("upload failed: %w", err) @@ -412,6 +487,11 @@ func runSCP(sources []string, destination string) error { return nil } if err != nil { + sentry.AddBreadcrumb("scp", "download failed", map[string]interface{}{ + "remote_path": remotePath, + "local_path": localPath, + "error": err.Error(), + }, sentry.LevelError) p.Send(tui.SCPErrorMsg{Err: err}) <-tuiDone return fmt.Errorf("download failed: %w", err) @@ -431,6 +511,13 @@ func runSCP(sources []string, destination string) error { duration := time.Since(startTime) + sentry.AddBreadcrumb("scp", "transfer complete", map[string]interface{}{ + "direction": direction, + "files_transferred": filesTransferred, + "bytes_transferred": totalBytes, + "duration_ms": duration.Milliseconds(), + }, sentry.LevelInfo) + p.Send(tui.SCPCompleteMsg{ FilesTransferred: filesTransferred, BytesTransferred: totalBytes, diff --git a/cmd/sentry_middleware.go b/cmd/sentry_middleware.go new file mode 100644 index 0000000..1ae6b52 --- /dev/null +++ b/cmd/sentry_middleware.go @@ -0,0 +1,124 @@ +package cmd + +import ( + "errors" + "time" + + "github.com/Thunder-Compute/thunder-cli/internal/version" + "github.com/Thunder-Compute/thunder-cli/sentry" + "github.com/Thunder-Compute/thunder-cli/tui" + sentrygo "github.com/getsentry/sentry-go" + "github.com/spf13/cobra" +) + +// WrapCommandWithSentry wraps a cobra.Command's Run function +// to automatically capture panics to Sentry +func WrapCommandWithSentry(cmd *cobra.Command) { + if cmd.Run == nil { + return + } + + originalRun := cmd.Run + cmd.Run = func(c *cobra.Command, args []string) { + defer sentry.CapturePanic(&sentry.EventOptions{ + Tags: sentry.NewTags(). + Set("command", cmd.Name()). + Set("version", version.BuildVersion), + }) + + originalRun(c, args) + } +} + +// CaptureCommandError captures errors from command execution +// It intelligently filters out user cancellations and categorizes errors +func CaptureCommandError(cmd *cobra.Command, err error) { + if err == nil { + return + } + + // Don't capture user cancellations + var cancellationErr *tui.CancellationError + if errors.As(err, &cancellationErr) { + return + } + + // Capture the error with context + eventID := sentry.CaptureError(err, &sentry.EventOptions{ + Tags: sentry.NewTags(). + Set("command", cmd.Name()). + Set("version", version.BuildVersion), + Extra: sentry.NewExtra(). + Set("args", cmd.Flags().Args()), + Level: ptr(getLogLevelForError(err)), + }) + + if eventID != nil { + // Flush to ensure the event is sent before the process exits + // (os.Exit skips deferred functions like sentry.Shutdown) + sentry.Flush(2 * time.Second) + // fmt.Printf("\nError report sent to Sentry (event: %s, command: %s, type: %s)\n", *eventID, cmd.Name(), getErrorType(err)) + } + // else { + // fmt.Printf("\nSentry not initialized (DSN not set in build) - error not reported\n") + // } +} + +// ptr is a helper to create a pointer to a value +func ptr[T any](v T) *T { + return &v +} + +// getLogLevelForError determines the appropriate Sentry level for an error +func getLogLevelForError(err error) sentrygo.Level { + errStr := err.Error() + + // Authentication and critical errors + if containsAny(errStr, []string{"authentication", "401", "403", "unauthorized"}) { + return sentrygo.LevelError + } + + // Network and timeout errors (less critical) + if containsAny(errStr, []string{"timeout", "connection refused", "no route to host"}) { + return sentrygo.LevelWarning + } + + // Not found errors (informational) + if containsAny(errStr, []string{"not found", "404"}) { + return sentrygo.LevelWarning + } + + // Server errors (critical) + if containsAny(errStr, []string{"500", "502", "503", "504"}) { + return sentrygo.LevelError + } + + // Default to error level + return sentrygo.LevelError +} + +// containsAny checks if the string contains any of the substrings +func containsAny(s string, substrings []string) bool { + for _, substr := range substrings { + if contains(s, substr) { + return true + } + } + return false +} + +// contains is a simple substring check (case-insensitive) +func contains(s, substr string) bool { + // Simple case-sensitive check for now + // Could be enhanced with strings.ToLower for case-insensitive + return len(s) >= len(substr) && indexSubstring(s, substr) >= 0 +} + +func indexSubstring(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/cmd/snapshot_create.go b/cmd/snapshot_create.go index 85268bc..58399b4 100644 --- a/cmd/snapshot_create.go +++ b/cmd/snapshot_create.go @@ -22,11 +22,8 @@ var ( var snapshotCreateCmd = &cobra.Command{ Use: "create", Short: "Create a snapshot from an instance", - Run: func(cmd *cobra.Command, args []string) { - if err := runSnapshotCreate(cmd); err != nil { - PrintError(err) - os.Exit(1) - } + RunE: func(cmd *cobra.Command, args []string) error { + return runSnapshotCreate(cmd) }, } diff --git a/cmd/snapshot_delete.go b/cmd/snapshot_delete.go index e52f47d..319f28e 100644 --- a/cmd/snapshot_delete.go +++ b/cmd/snapshot_delete.go @@ -16,11 +16,8 @@ var snapshotDeleteCmd = &cobra.Command{ Use: "delete [snapshot_name]", Short: "Delete a snapshot", Args: cobra.MaximumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { - if err := runSnapshotDelete(args); err != nil { - PrintError(err) - os.Exit(1) - } + RunE: func(cmd *cobra.Command, args []string) error { + return runSnapshotDelete(args) }, } diff --git a/cmd/snapshot_list.go b/cmd/snapshot_list.go index 53b5e4b..ad5d0ce 100644 --- a/cmd/snapshot_list.go +++ b/cmd/snapshot_list.go @@ -19,11 +19,8 @@ var snapshotListCmd = &cobra.Command{ Use: "list", Aliases: []string{"ls"}, Short: "List all snapshots", - Run: func(cmd *cobra.Command, args []string) { - if err := runSnapshotList(); err != nil { - PrintError(err) - os.Exit(1) - } + RunE: func(cmd *cobra.Command, args []string) error { + return runSnapshotList() }, } diff --git a/cmd/status.go b/cmd/status.go index a1ccdfe..a71badd 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -18,11 +18,8 @@ var noWait bool var statusCmd = &cobra.Command{ Use: "status", Short: "List and monitor Thunder Compute instances", - Run: func(cmd *cobra.Command, args []string) { - if err := RunStatus(); err != nil { - PrintError(err) - os.Exit(1) - } + RunE: func(cmd *cobra.Command, args []string) error { + return RunStatus() }, } diff --git a/cmd/update.go b/cmd/update.go index 5f131cf..3316b5e 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -20,11 +20,8 @@ var updateCmd = &cobra.Command{ Annotations: map[string]string{ "skipUpdateCheck": "true", }, - Run: func(cmd *cobra.Command, args []string) { - if err := runUpdateCommand(); err != nil { - PrintError(err) - os.Exit(1) - } + RunE: func(cmd *cobra.Command, args []string) error { + return runUpdateCommand() }, } diff --git a/go.mod b/go.mod index 7582df1..040c7b4 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/getsentry/sentry-go v0.41.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index 01d036e..7fa5d6a 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/getsentry/sentry-go v0.41.0 h1:q/dQZOlEIb4lhxQSjJhQqtRr3vwrJ6Ahe1C9zv+ryRo= +github.com/getsentry/sentry-go v0.41.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -72,5 +74,6 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/version/version.go b/internal/version/version.go index 8f184d4..a97c700 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -4,4 +4,5 @@ var ( BuildVersion = "dev" BuildCommit = "none" BuildDate = "unknown" + SentryDSN = "" ) diff --git a/main.go b/main.go index 776dd7c..3b7ec93 100644 --- a/main.go +++ b/main.go @@ -4,9 +4,18 @@ Copyright © 2025 NAME HERE package main import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "runtime" + "github.com/Thunder-Compute/thunder-cli/cmd" "github.com/Thunder-Compute/thunder-cli/internal/autoupdate" "github.com/Thunder-Compute/thunder-cli/internal/console" + "github.com/Thunder-Compute/thunder-cli/internal/version" + "github.com/Thunder-Compute/thunder-cli/sentry" + sentrygo "github.com/getsentry/sentry-go" ) func main() { @@ -18,5 +27,117 @@ func main() { } console.Init() + + _ = initSentry() + defer sentry.Shutdown() + + // Wrap execution with panic recovery + defer sentry.CapturePanic(&sentry.EventOptions{ + Tags: sentry.NewTags(). + Set("command", "root"). + Set("version", version.BuildVersion), + }) + cmd.Execute() } + +func initSentry() error { + // DSN is injected at build time - if empty, Sentry is disabled + if version.SentryDSN == "" { + return nil + } + + // Load config for user context only + cfg, _ := cmd.LoadConfig() + + // Get configuration values + environment := getEnvironment() + sampleRate := getSampleRate() + tracesSampleRate := getTracesSampleRate() + release := fmt.Sprintf("thunder-cli@%s", version.BuildVersion) + + // Initialize Sentry with build-injected DSN + err := sentry.Init(sentry.Config{ + DSN: version.SentryDSN, + Environment: environment, + Release: release, + Debug: false, // Never debug in production + SampleRate: sampleRate, + TracesSampleRate: tracesSampleRate, + EnableProfiling: false, + ServiceName: "thunder-cli", + InstanceID: getInstanceID(), + FilteredErrors: getFilteredErrors(), + }, nil) + + if err != nil { + return fmt.Errorf("failed to initialize Sentry: %w", err) + } + + // Set user context (privacy-safe) + if cfg != nil && cfg.Token != "" { + setUserContext(cfg.Token) + } + + // Set global context tags + sentrygo.ConfigureScope(func(scope *sentrygo.Scope) { + scope.SetTag("os", runtime.GOOS) + scope.SetTag("arch", runtime.GOARCH) + scope.SetTag("go_version", runtime.Version()) + scope.SetTag("build_commit", version.BuildCommit) + if cfg != nil { + scope.SetTag("api_url", cfg.APIURL) + } + }) + + return nil +} + +func getEnvironment() string { + // Check if running from development build + if version.BuildVersion == "dev" { + return "dev" + } + + return "production" +} + +func getSampleRate() float64 { + return 1.0 // Capture all errors +} + +func getTracesSampleRate() float64 { + return 0.1 // Sample 10% of traces +} + +func getInstanceID() string { + // Try various environment variables for instance ID + if id := os.Getenv("HOSTNAME"); id != "" { + return id + } + if id := os.Getenv("COMPUTERNAME"); id != "" { + return id + } + return "unknown" +} + +func getFilteredErrors() []string { + return []string{ + "user cancelled", + "context canceled", + "operation cancelled by user", + "authentication cancelled", + } +} + +func setUserContext(token string) { + // Hash the token to create anonymous but unique user ID + hash := sha256.Sum256([]byte(token)) + userID := hex.EncodeToString(hash[:8]) + + sentrygo.ConfigureScope(func(scope *sentrygo.Scope) { + scope.SetUser(sentrygo.User{ + ID: userID, // Hashed, not personally identifiable + }) + }) +} diff --git a/sentry/options.go b/sentry/options.go new file mode 100644 index 0000000..18073f3 --- /dev/null +++ b/sentry/options.go @@ -0,0 +1,57 @@ +package sentry + +import "github.com/getsentry/sentry-go" + +// EventOptions holds optional settings for capturing events +type EventOptions struct { + Tags *Tags + Extra *Extra + Level *sentry.Level + Fingerprint []string +} + +// Tags is a builder for event tags +type Tags struct { + tags map[string]string +} + +// NewTags creates a new Tags builder +func NewTags() *Tags { + return &Tags{ + tags: make(map[string]string), + } +} + +// Set adds a tag key-value pair +func (t *Tags) Set(key, value string) *Tags { + t.tags[key] = value + return t +} + +// ToMap returns the tags as a map +func (t *Tags) ToMap() map[string]string { + return t.tags +} + +// Extra is a builder for extra event data +type Extra struct { + data map[string]interface{} +} + +// NewExtra creates a new Extra builder +func NewExtra() *Extra { + return &Extra{ + data: make(map[string]interface{}), + } +} + +// Set adds an extra data key-value pair +func (e *Extra) Set(key string, value interface{}) *Extra { + e.data[key] = value + return e +} + +// ToMap returns the extra data as a map +func (e *Extra) ToMap() map[string]interface{} { + return e.data +} diff --git a/sentry/sentry.go b/sentry/sentry.go new file mode 100644 index 0000000..5054132 --- /dev/null +++ b/sentry/sentry.go @@ -0,0 +1,230 @@ +package sentry + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/getsentry/sentry-go" +) + +// Config holds Sentry configuration options +type Config struct { + DSN string + Environment string // "dev" or "production" + Release string // e.g., "thunder-cli-v1.0.0" + Debug bool + SampleRate float64 // 0.0 to 1.0 + TracesSampleRate float64 // 0.0 to 1.0 + EnableProfiling bool + FilteredErrors []string // Error messages to filter out + + // Enrichment data + ServiceName string + InstanceID string +} + +// Init initializes Sentry with the provided configuration +func Init(cfg Config, logProvider interface{}) error { + if cfg.DSN == "" { + return nil + } + + err := sentry.Init(sentry.ClientOptions{ + Dsn: cfg.DSN, + Environment: cfg.Environment, + Release: cfg.Release, + Debug: cfg.Debug, + AttachStacktrace: true, + SampleRate: cfg.SampleRate, + TracesSampleRate: cfg.TracesSampleRate, + EnableTracing: cfg.TracesSampleRate > 0, + + // BeforeSend hook for filtering and enrichment + BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { + // Filter out specified errors + if event.Message != "" { + for _, filtered := range cfg.FilteredErrors { + if strings.Contains(event.Message, filtered) { + return nil // Drop event + } + } + } + + // Check exception messages too + for _, exception := range event.Exception { + for _, filtered := range cfg.FilteredErrors { + if strings.Contains(exception.Value, filtered) { + return nil + } + } + } + + // Enrich with service metadata + if event.Extra == nil { + event.Extra = make(map[string]interface{}) + } + event.Extra["service_name"] = cfg.ServiceName + event.Extra["instance_id"] = cfg.InstanceID + + return event + }, + }) + + if err != nil { + return fmt.Errorf("failed to initialize Sentry: %w", err) + } + + // Set global tags + sentry.ConfigureScope(func(scope *sentry.Scope) { + scope.SetTag("service", cfg.ServiceName) + scope.SetTag("environment", cfg.Environment) + if cfg.InstanceID != "" { + scope.SetTag("instance_id", cfg.InstanceID) + } + }) + + return nil +} + +// Flush flushes buffered events with timeout +func Flush(timeout time.Duration) bool { + return sentry.Flush(timeout) +} + +// Shutdown gracefully shuts down Sentry +func Shutdown() { + sentry.Flush(5 * time.Second) +} + +// CaptureError captures an error with typed options +func CaptureError(err error, opts *EventOptions) *sentry.EventID { + if err == nil { + return nil + } + + var eventID *sentry.EventID + sentry.WithScope(func(scope *sentry.Scope) { + if opts != nil { + // Apply tags + if opts.Tags != nil { + for k, v := range opts.Tags.ToMap() { + scope.SetTag(k, v) + } + } + + // Apply extra data + if opts.Extra != nil { + for k, v := range opts.Extra.ToMap() { + scope.SetExtra(k, v) + } + } + + // Apply level + if opts.Level != nil { + scope.SetLevel(*opts.Level) + } + + // Apply fingerprint + if opts.Fingerprint != nil { + scope.SetFingerprint(opts.Fingerprint) + } + } + + eventID = sentry.CaptureException(err) + }) + return eventID +} + +// AddBreadcrumb adds a breadcrumb for context tracking +// Breadcrumbs are global and attach to all subsequent events in the same scope +func AddBreadcrumb(category string, message string, data map[string]interface{}, level Level) { + sentry.AddBreadcrumb(&sentry.Breadcrumb{ + Type: "default", + Category: category, + Message: message, + Data: data, + Level: sentry.Level(level), + Timestamp: time.Now(), + }) +} + +// Level is a Sentry severity level (re-exported for convenience) +type Level = sentry.Level + +// Sentry level constants +const ( + LevelDebug = sentry.LevelDebug + LevelInfo = sentry.LevelInfo + LevelWarning = sentry.LevelWarning + LevelError = sentry.LevelError + LevelFatal = sentry.LevelFatal +) + +// CapturePanic should be used in a defer statement to capture and report panics. +// It recovers from panic, reports to Sentry, flushes, and re-panics. +// Example: defer sentry.CapturePanic(&sentry.EventOptions{...}) +func CapturePanic(opts *EventOptions) { + if r := recover(); r != nil { + sentry.WithScope(func(scope *sentry.Scope) { + scope.SetLevel(sentry.LevelFatal) + + if opts != nil { + // Apply tags + if opts.Tags != nil { + for k, v := range opts.Tags.ToMap() { + scope.SetTag(k, v) + } + } + + // Apply extra data + if opts.Extra != nil { + for k, v := range opts.Extra.ToMap() { + scope.SetExtra(k, v) + } + } + } + + sentry.CurrentHub().Recover(r) + }) + sentry.Flush(5 * time.Second) + panic(r) // Re-panic after capturing + } +} + +// GetEnvironment returns the environment string based on debug flag +func GetEnvironment(debug bool) string { + if debug { + return "dev" + } + env := os.Getenv("ENVIRONMENT") + if env != "" { + return env + } + return "production" +} + +// GetRelease returns a release string for the service +func GetRelease(serviceName string) string { + version := os.Getenv("SERVICE_VERSION") + if version == "" { + version = "unknown" + } + return fmt.Sprintf("%s-%s", serviceName, version) +} + +// GetInstanceID returns an instance identifier +func GetInstanceID() string { + // Try various environment variables for instance ID + if id := os.Getenv("HOSTNAME"); id != "" { + return id + } + if id := os.Getenv("AWS_LAMBDA_FUNCTION_NAME"); id != "" { + return id + } + if id := os.Getenv("POD_NAME"); id != "" { + return id + } + return "unknown" +}