diff --git a/.trunk/configs/cspell.json b/.trunk/configs/cspell.json index 8468436b0..5849d78d3 100644 --- a/.trunk/configs/cspell.json +++ b/.trunk/configs/cspell.json @@ -169,6 +169,7 @@ "sandboxed", "santhosh", "schemagen", + "sentryhttp", "sjson", "somedbtype", "sqlclient", diff --git a/CHANGELOG.md b/CHANGELOG.md index 34ff513f7..c3fe656b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ NOTE: all releases may include dependency updates, not specifically mentioned - feat: integrate try-as library [#912](https://github.com/hypermodeinc/modus/pull/912) - fix: check topic actor status before publishing events [#918](https://github.com/hypermodeinc/modus/pull/918) - feat: update health endpoint [#924](https://github.com/hypermodeinc/modus/pull/924) +- feat: improve sentry and logging [#925](https://github.com/hypermodeinc/modus/pull/925) ## 2025-06-25 - Runtime v0.18.2 diff --git a/runtime/actors/actorsystem.go b/runtime/actors/actorsystem.go index 8dafbe247..98f26a2cc 100644 --- a/runtime/actors/actorsystem.go +++ b/runtime/actors/actorsystem.go @@ -48,15 +48,15 @@ func Initialize(ctx context.Context) { actorSystem, err := goakt.NewActorSystem("modus", opts...) if err != nil { - logger.Fatal(ctx).Err(err).Msg("Failed to create actor system.") + logger.Fatal(ctx, err).Msg("Failed to create actor system.") } if err := startActorSystem(ctx, actorSystem); err != nil { - logger.Fatal(ctx).Err(err).Msg("Failed to start actor system.") + logger.Fatal(ctx, err).Msg("Failed to start actor system.") } if err := actorSystem.Inject(&wasmAgentInfo{}); err != nil { - logger.Fatal(ctx).Err(err).Msg("Failed to inject wasm agent info into actor system.") + logger.Fatal(ctx, err).Msg("Failed to inject wasm agent info into actor system.") } _actorSystem = actorSystem @@ -67,12 +67,12 @@ func Initialize(ctx context.Context) { } func startActorSystem(ctx context.Context, actorSystem goakt.ActorSystem) error { - maxRetries := getIntFromEnv("MODUS_ACTOR_SYSTEM_START_MAX_RETRIES", 5) - retryInterval := getDurationFromEnv("MODUS_ACTOR_SYSTEM_START_RETRY_INTERVAL_SECONDS", 2, time.Second) + maxRetries := utils.GetIntFromEnv("MODUS_ACTOR_SYSTEM_START_MAX_RETRIES", 5) + retryInterval := utils.GetDurationFromEnv("MODUS_ACTOR_SYSTEM_START_RETRY_INTERVAL_SECONDS", 2, time.Second) for i := range maxRetries { if err := actorSystem.Start(ctx); err != nil { - logger.Warn(ctx).Err(err).Int("attempt", i+1).Msgf("Failed to start actor system, retrying in %s...", retryInterval) + logger.Warn(ctx, err).Int("attempt", i+1).Msgf("Failed to start actor system, retrying in %s...", retryInterval) time.Sleep(retryInterval) retryInterval *= 2 // Exponential backoff continue @@ -96,7 +96,7 @@ func loadAgentActors(ctx context.Context, plugin *plugins.Plugin) error { for _, pid := range actors { if a, ok := pid.Actor().(*wasmAgentActor); ok { if err := goakt.Tell(ctx, pid, &messages.RestartAgent{}); err != nil { - logger.Err(ctx, err).Str("agent_id", a.agentId).Msg("Failed to send restart agent message to actor.") + logger.Error(ctx, err).Str("agent_id", a.agentId).Msg("Failed to send restart agent message to actor.") } } } @@ -104,7 +104,7 @@ func loadAgentActors(ctx context.Context, plugin *plugins.Plugin) error { // do this in a goroutine to avoid blocking the cluster engine startup go func() { if err := restoreAgentActors(ctx, plugin.Name()); err != nil { - logger.Err(ctx, err).Msg("Failed to restore agent actors.") + logger.Error(ctx, err).Msg("Failed to restore agent actors.") } }() @@ -133,11 +133,11 @@ func restoreAgentActors(ctx context.Context, pluginName string) error { for _, agent := range agents { actorName := getActorName(agent.Id) if exists, err := _actorSystem.ActorExists(ctx, actorName); err != nil { - logger.Err(ctx, err).Msgf("Failed to check if actor %s exists.", actorName) + logger.Error(ctx, err).Msgf("Failed to check if actor %s exists.", actorName) } else if !exists { err := spawnActorForAgent(ctx, pluginName, agent.Id, agent.Name, false) if err != nil { - logger.Err(ctx, err).Msgf("Failed to spawn actor for agent %s.", agent.Id) + logger.Error(ctx, err).Msgf("Failed to spawn actor for agent %s.", agent.Id) } } } @@ -159,7 +159,7 @@ func beforeShutdown(ctx context.Context) error { if actor.status == AgentStatusRunning { ctx := actor.augmentContext(ctx, pid) if err := actor.suspendAgent(ctx); err != nil { - logger.Err(ctx, err).Str("agent_id", actor.agentId).Msg("Failed to suspend agent actor.") + logger.Error(ctx, err).Str("agent_id", actor.agentId).Msg("Failed to suspend agent actor.") } } } @@ -169,7 +169,7 @@ func beforeShutdown(ctx context.Context) error { for _, pid := range actors { if _, ok := pid.Actor().(*subscriptionActor); ok && pid.IsRunning() { if err := pid.Shutdown(ctx); err != nil { - logger.Err(ctx, err).Msgf("Failed to shutdown actor %s.", pid.Name()) + logger.Error(ctx, err).Msgf("Failed to shutdown actor %s.", pid.Name()) } } } @@ -201,7 +201,7 @@ func Shutdown(ctx context.Context) { } if err := _actorSystem.Stop(ctx); err != nil { - logger.Err(ctx, err).Msg("Failed to shutdown actor system.") + logger.Error(ctx, err).Msg("Failed to shutdown actor system.") } logger.Info(ctx).Msg("Actor system shutdown complete.") diff --git a/runtime/actors/cluster.go b/runtime/actors/cluster.go index 585990f78..ce4a58414 100644 --- a/runtime/actors/cluster.go +++ b/runtime/actors/cluster.go @@ -52,7 +52,7 @@ func clusterOptions(ctx context.Context) []goakt.Option { disco, err := newDiscoveryProvider(ctx, clusterMode, discoveryPort) if err != nil { - logger.Fatal(ctx).Err(err).Msg("Failed to create cluster discovery provider.") + logger.Fatal(ctx, err).Msg("Failed to create cluster discovery provider.") } return []goakt.Option{ @@ -166,9 +166,9 @@ func clusterPorts() (discoveryPort, remotingPort, peersPort int) { // Get default ports dynamically, but use environment variables if set ports := dynaport.Get(3) - discoveryPort = getIntFromEnv("MODUS_CLUSTER_DISCOVERY_PORT", ports[0]) - remotingPort = getIntFromEnv("MODUS_CLUSTER_REMOTING_PORT", ports[1]) - peersPort = getIntFromEnv("MODUS_CLUSTER_PEERS_PORT", ports[2]) + discoveryPort = utils.GetIntFromEnv("MODUS_CLUSTER_DISCOVERY_PORT", ports[0]) + remotingPort = utils.GetIntFromEnv("MODUS_CLUSTER_REMOTING_PORT", ports[1]) + peersPort = utils.GetIntFromEnv("MODUS_CLUSTER_PEERS_PORT", ports[2]) return } @@ -179,7 +179,7 @@ func clusterPorts() (discoveryPort, remotingPort, peersPort int) { // This value is also used for a sleep both on system startup and when spawning a new agent actor, // so it needs to be low enough to not be noticed by the user. func peerSyncInterval() time.Duration { - return getDurationFromEnv("MODUS_CLUSTER_PEER_SYNC_SECONDS", 1, time.Second) + return utils.GetDurationFromEnv("MODUS_CLUSTER_PEER_SYNC_SECONDS", 1, time.Second) } // nodesSyncInterval returns the interval at which the cluster forces a resync of the list of active nodes across the cluster. @@ -189,7 +189,7 @@ func peerSyncInterval() time.Duration { // On each interval, the node will sync its list of nodes with the cluster, and update its local state accordingly. // The default is 10 seconds, which is a reasonable balance between responsiveness and network overhead. func nodesSyncInterval() time.Duration { - return getDurationFromEnv("MODUS_CLUSTER_NODES_SYNC_SECONDS", 10, time.Second) + return utils.GetDurationFromEnv("MODUS_CLUSTER_NODES_SYNC_SECONDS", 10, time.Second) } // partitionCount returns the number of partitions the cluster will use for actor distribution. @@ -199,19 +199,19 @@ func nodesSyncInterval() time.Duration { // We'll use a slightly lower default of 13, which is still a prime number and should work well for most clusters. // The GoAkt default is 271, but this has been found to lead to other errors in practice. func partitionCount() uint64 { - return uint64(getIntFromEnv("MODUS_CLUSTER_PARTITION_COUNT", 13)) + return uint64(utils.GetIntFromEnv("MODUS_CLUSTER_PARTITION_COUNT", 13)) } // readTimeout returns the duration to wait for a cluster read operation before timing out. // The default is 1 second, which should usually not need to be changed. func readTimeout() time.Duration { - return getDurationFromEnv("MODUS_CLUSTER_READ_TIMEOUT_SECONDS", 1, time.Second) + return utils.GetDurationFromEnv("MODUS_CLUSTER_READ_TIMEOUT_SECONDS", 1, time.Second) } // writeTimeout returns the duration to wait for a cluster write operation before timing out. // The default is 1 second, which should usually not need to be changed. func writeTimeout() time.Duration { - return getDurationFromEnv("MODUS_CLUSTER_WRITE_TIMEOUT_SECONDS", 1, time.Second) + return utils.GetDurationFromEnv("MODUS_CLUSTER_WRITE_TIMEOUT_SECONDS", 1, time.Second) } func getPodLabels() map[string]string { diff --git a/runtime/actors/misc.go b/runtime/actors/misc.go index 74cf7bfe7..2edd77818 100644 --- a/runtime/actors/misc.go +++ b/runtime/actors/misc.go @@ -12,11 +12,8 @@ package actors import ( "context" "fmt" - "os" - "strconv" "time" - "github.com/hypermodeinc/modus/runtime/logger" "github.com/hypermodeinc/modus/runtime/utils" goakt "github.com/tochemey/goakt/v3/actor" @@ -60,30 +57,3 @@ func ask(ctx context.Context, actorName string, message proto.Message, timeout t } return nil, fmt.Errorf("failed to get address or PID for actor %s", actorName) } - -// Retrieves an integer value from an environment variable. -func getIntFromEnv(envVar string, defaultValue int) int { - str := os.Getenv(envVar) - if str == "" { - return defaultValue - } - - value, err := strconv.Atoi(str) - if err != nil || value <= 0 { - logger.Warnf("Invalid value for %s. Using %d instead.", envVar, defaultValue) - return defaultValue - } - - return value -} - -// Retrieves a duration value from an environment variable. -func getDurationFromEnv(envVar string, defaultValue int, unit time.Duration) time.Duration { - intVal := getIntFromEnv(envVar, defaultValue) - if intVal <= 0 { - duration := time.Duration(defaultValue) * unit - logger.Warnf("Invalid value for %s. Using %s instead.", envVar, duration) - return duration - } - return time.Duration(intVal) * unit -} diff --git a/runtime/actors/subscriber.go b/runtime/actors/subscriber.go index cb9e5174e..2e690f104 100644 --- a/runtime/actors/subscriber.go +++ b/runtime/actors/subscriber.go @@ -83,11 +83,11 @@ func SubscribeForAgentEvents(ctx context.Context, agentId string, update func(da unsubscribe := &goaktpb.Unsubscribe{Topic: topic} if err := subActor.Tell(ctx, _actorSystem.TopicActor(), unsubscribe); err != nil { - logger.Err(ctx, err).Msg("Failed to unsubscribe from topic") + logger.Error(ctx, err).Msg("Failed to unsubscribe from topic") } if err := subActor.Shutdown(ctx); err != nil { - logger.Err(ctx, err).Msg("Failed to shut down subscription actor") + logger.Error(ctx, err).Msg("Failed to shut down subscription actor") } }() diff --git a/runtime/actors/wasmagent.go b/runtime/actors/wasmagent.go index c20908d8f..051ba2394 100644 --- a/runtime/actors/wasmagent.go +++ b/runtime/actors/wasmagent.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "github.com/getsentry/sentry-go" "github.com/hypermodeinc/modus/runtime/db" "github.com/hypermodeinc/modus/runtime/logger" "github.com/hypermodeinc/modus/runtime/messages" @@ -37,12 +38,16 @@ type wasmAgentActor struct { host wasmhost.WasmHost module wasm.Module buffers utils.OutputBuffers + sentryHub *sentry.Hub initializing bool } func (a *wasmAgentActor) PreStart(ac *goakt.Context) error { ctx := ac.Context() + a.sentryHub = sentry.CurrentHub().Clone() + ctx = sentry.SetHubOnContext(ctx, a.sentryHub) + span, ctx := utils.NewSentrySpanForCurrentFunc(ctx) defer span.Finish() @@ -139,13 +144,14 @@ func (a *wasmAgentActor) Receive(rc *goakt.ReceiveContext) { func (a *wasmAgentActor) PostStop(ac *goakt.Context) error { ctx := ac.Context() + ctx = sentry.SetHubOnContext(ctx, a.sentryHub) span, ctx := utils.NewSentrySpanForCurrentFunc(ctx) defer span.Finish() // suspend the agent if it's not already suspended or terminated if a.status != AgentStatusSuspended && a.status != AgentStatusTerminated { if err := a.suspendAgent(ctx); err != nil { - logger.Err(ctx, err).Msg("Error suspending agent.") + logger.Error(ctx, err).Msg("Error suspending agent.") // don't return on error - we'll still try to deactivate the agent } } @@ -194,7 +200,7 @@ func (a *wasmAgentActor) handleAgentRequest(ctx context.Context, rc *goakt.Recei response.Data = result default: err := fmt.Errorf("unexpected result type: %T", result) - logger.Err(ctx, err).Msg("Error handling message.") + logger.Error(ctx, err).Msg("Error handling message.") return err } } @@ -209,7 +215,7 @@ func (a *wasmAgentActor) handleAgentRequest(ctx context.Context, rc *goakt.Recei // save the state after handling the message to ensure the state is up to date in case of hard termination if err := a.saveState(ctx); err != nil { - logger.Err(ctx, err).Msg("Error saving agent state.") + logger.Error(ctx, err).Msg("Error saving agent state.") } return nil @@ -299,6 +305,9 @@ func (a *wasmAgentActor) augmentContext(ctx context.Context, pid *goakt.PID) con if ctx.Value(pidContextKey{}) == nil { ctx = context.WithValue(ctx, pidContextKey{}, pid) } + if !sentry.HasHubOnContext(ctx) { + ctx = sentry.SetHubOnContext(ctx, a.sentryHub) + } return ctx } diff --git a/runtime/app/app.go b/runtime/app/app.go index 41b0007b3..e7549ef89 100644 --- a/runtime/app/app.go +++ b/runtime/app/app.go @@ -77,7 +77,7 @@ func SetShuttingDown() { } // GetRootSourcePath returns the root path of the source code. -// It is used to trim the paths in stack traces when included in telemetry. +// It is used to trim the paths in stack traces when included in utils. func GetRootSourcePath() string { _, filename, _, ok := runtime.Caller(0) if !ok { diff --git a/runtime/db/db.go b/runtime/db/db.go index 6014c5fcd..e8b9c14fe 100644 --- a/runtime/db/db.go +++ b/runtime/db/db.go @@ -51,7 +51,7 @@ func Stop(ctx context.Context) { func logDbWarningOrError(ctx context.Context, err error, msg string) { if _, ok := err.(*pgconn.ConnectError); ok { - logger.Warn(ctx).Err(err).Msgf("Database connection error. %s", msg) + logger.Warn(ctx, err).Msgf("Database connection error. %s", msg) } else if errors.Is(err, errDbNotConfigured) { if !useModusDB() { logger.Warn(ctx).Msgf("Database has not been configured. %s", msg) @@ -59,7 +59,7 @@ func logDbWarningOrError(ctx context.Context, err error, msg string) { } else { // not really an error, but we log it as such // but user-visible so it doesn't flag in Sentry - logger.Err(ctx, err).Bool("user_visible", true).Msg(msg) + logger.Error(ctx, err).Bool("user_visible", true).Msg(msg) } } @@ -71,7 +71,7 @@ func Initialize(ctx context.Context) { // this will initialize the pool and start the worker _, err := globalRuntimePostgresWriter.GetPool(ctx) if err != nil { - logger.Warn(ctx).Err(err).Msg("Metadata database is not available.") + logger.Warn(ctx, err).Msg("Metadata database is not available.") } go globalRuntimePostgresWriter.worker(ctx) } diff --git a/runtime/db/modusdb.go b/runtime/db/modusdb.go index d608efa25..fa5e3bb90 100644 --- a/runtime/db/modusdb.go +++ b/runtime/db/modusdb.go @@ -40,7 +40,7 @@ func InitModusDb(ctx context.Context) { } if eng, err := modusgraph.NewEngine(modusgraph.NewDefaultConfig(dataDir)); err != nil { - logger.Fatal(ctx).Err(err).Msg("Failed to initialize the local modusGraph database.") + logger.Fatal(ctx, err).Msg("Failed to initialize the local modusGraph database.") } else { GlobalModusDbEngine = eng } @@ -58,7 +58,7 @@ func addToGitIgnore(ctx context.Context, rootPath, contents string) { // if .gitignore file does not exist, create it and add contents to it if _, err := os.Stat(gitIgnorePath); errors.Is(err, os.ErrNotExist) { if err := os.WriteFile(gitIgnorePath, []byte(contents+"\n"), 0644); err != nil { - logger.Err(ctx, err).Msg("Failed to create .gitignore file.") + logger.Error(ctx, err).Msg("Failed to create .gitignore file.") } return } @@ -66,7 +66,7 @@ func addToGitIgnore(ctx context.Context, rootPath, contents string) { // check if contents are already in the .gitignore file file, err := os.Open(gitIgnorePath) if err != nil { - logger.Err(ctx, err).Msg("Failed to open .gitignore file.") + logger.Error(ctx, err).Msg("Failed to open .gitignore file.") return } defer file.Close() @@ -80,11 +80,11 @@ func addToGitIgnore(ctx context.Context, rootPath, contents string) { // contents are not in the file, so append them file, err = os.OpenFile(gitIgnorePath, os.O_APPEND|os.O_WRONLY, 0644) if err != nil { - logger.Err(ctx, err).Msg("Failed to open .gitignore file.") + logger.Error(ctx, err).Msg("Failed to open .gitignore file.") return } defer file.Close() if _, err := file.WriteString("\n" + contents + "\n"); err != nil { - logger.Err(ctx, err).Msg("Failed to append " + contents + " to .gitignore file.") + logger.Error(ctx, err).Msg("Failed to append " + contents + " to .gitignore file.") } } diff --git a/runtime/dgraphclient/dgraph.go b/runtime/dgraphclient/dgraph.go index 56e6b930b..4e7be22c6 100644 --- a/runtime/dgraphclient/dgraph.go +++ b/runtime/dgraphclient/dgraph.go @@ -63,7 +63,7 @@ func (dc *dgraphConnector) execute(ctx context.Context, req *Request) (*Response tx := dc.dgClient.NewReadOnlyTxn() defer func() { if err := tx.Discard(ctx); err != nil { - logger.Warn(ctx).Err(err).Msg("Error discarding transaction.") + logger.Warn(ctx, err).Msg("Error discarding transaction.") return } @@ -86,7 +86,7 @@ func (dc *dgraphConnector) execute(ctx context.Context, req *Request) (*Response tx := dc.dgClient.NewTxn() defer func() { if err := tx.Discard(ctx); err != nil { - logger.Warn(ctx).Err(err).Msg("Error discarding transaction.") + logger.Warn(ctx, err).Msg("Error discarding transaction.") return } diff --git a/runtime/envfiles/envfilemonitor.go b/runtime/envfiles/envfilemonitor.go index 6868be1f4..e4a8fcfe2 100644 --- a/runtime/envfiles/envfilemonitor.go +++ b/runtime/envfiles/envfilemonitor.go @@ -23,7 +23,7 @@ func MonitorEnvFiles(ctx context.Context) { if len(errors) == 0 { err := LoadEnvFiles(ctx) if err != nil { - logger.Err(ctx, err).Msg("Failed to load env files.") + logger.Error(ctx, err).Msg("Failed to load env files.") } } } diff --git a/runtime/envfiles/envfiles.go b/runtime/envfiles/envfiles.go index b735c9e97..11ad737d8 100644 --- a/runtime/envfiles/envfiles.go +++ b/runtime/envfiles/envfiles.go @@ -73,7 +73,7 @@ func LoadEnvFiles(ctx context.Context) error { path := filepath.Join(app.Config().AppPath(), file) if _, err := os.Stat(path); err == nil { if err := godotenv.Load(path); err != nil { - logger.Warn(ctx).Err(err).Msgf("Failed to load %s file.", file) + logger.Warn(ctx, err).Msgf("Failed to load %s file.", file) } envVarsUpdated = true } diff --git a/runtime/go.mod b/runtime/go.mod index 26a71930f..fdb35690d 100644 --- a/runtime/go.mod +++ b/runtime/go.mod @@ -6,7 +6,6 @@ go 1.24.4 require ( github.com/OneOfOne/xxhash v1.2.8 - github.com/archdx/zerolog-sentry v1.8.5 github.com/aws/aws-sdk-go-v2/config v1.29.17 github.com/aws/aws-sdk-go-v2/service/s3 v1.82.0 github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 diff --git a/runtime/go.sum b/runtime/go.sum index 7f7bdd69c..e3e01c8be 100644 --- a/runtime/go.sum +++ b/runtime/go.sum @@ -85,8 +85,6 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= -github.com/archdx/zerolog-sentry v1.8.5 h1:W24e5+yfZiQ83yd9OjBw+o6ERUzyUlCpoBS97gUlwK8= -github.com/archdx/zerolog-sentry v1.8.5/go.mod h1:XrFHGe1CH5DQk/XSySu/IJSi5C9XR6+zpc97zVf/c4c= github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/aws/aws-sdk-go-v2 v1.36.5 h1:0OF9RiEMEdDdZEMqF9MRjevyxAQcf6gY+E7vwBILFj0= diff --git a/runtime/graphql/datasource/planner.go b/runtime/graphql/datasource/planner.go index 1f977ef24..4620fef6c 100644 --- a/runtime/graphql/datasource/planner.go +++ b/runtime/graphql/datasource/planner.go @@ -124,7 +124,7 @@ func (p *modusDataSourcePlanner) EnterField(ref int) { p.template.fieldInfo = f p.template.functionName = config.FieldsToFunctions[f.Name] if err := p.captureInputData(ref); err != nil { - logger.Err(p.ctx, err).Msg("Error capturing input data.") + logger.Error(p.ctx, err).Msg("Error capturing input data.") return } } @@ -250,7 +250,7 @@ func (p *modusDataSourcePlanner) getInputTemplate() (string, error) { func (p *modusDataSourcePlanner) ConfigureFetch() resolve.FetchConfiguration { input, err := p.getInputTemplate() if err != nil { - logger.Error(p.ctx).Err(err).Msg("Error creating input template for Modus data source.") + logger.Error(p.ctx, err).Msg("Error creating input template for Modus data source.") return resolve.FetchConfiguration{} } @@ -272,7 +272,7 @@ func (p *modusDataSourcePlanner) ConfigureFetch() resolve.FetchConfiguration { func (p *modusDataSourcePlanner) ConfigureSubscription() plan.SubscriptionConfiguration { input, err := p.getInputTemplate() if err != nil { - logger.Error(p.ctx).Err(err).Msg("Error creating input template for Modus data source.") + logger.Error(p.ctx, err).Msg("Error creating input template for Modus data source.") return plan.SubscriptionConfiguration{} } diff --git a/runtime/graphql/graphql.go b/runtime/graphql/graphql.go index ad0ff2ce9..e52ced73d 100644 --- a/runtime/graphql/graphql.go +++ b/runtime/graphql/graphql.go @@ -92,7 +92,7 @@ func handleGraphQLRequest(w http.ResponseWriter, r *http.Request) { // NOTE: We only log these in dev, to avoid a bad actor spamming the logs in prod. if app.IsDevEnvironment() { - logger.Warn(ctx).Err(err).Msg(msg) + logger.Warn(ctx, err).Msg(msg) } return } @@ -141,7 +141,7 @@ func handleGraphQLRequest(w http.ResponseWriter, r *http.Request) { resultWriter := gql.NewEngineResultWriter() if operationType, err := gqlRequest.OperationType(); err != nil { msg := "Failed to determine operation type from GraphQL request." - logger.Err(ctx, err).Msg(msg) + logger.Error(ctx, err).Msg(msg) http.Error(w, msg, http.StatusBadRequest) return } else if operationType == gql.OperationTypeSubscription { @@ -187,7 +187,7 @@ func handleGraphQLRequest(w http.ResponseWriter, r *http.Request) { if len(report.InternalErrors) > 0 { // Log internal errors, but don't return them to the client msg := "Failed to execute GraphQL operation." - logger.Err(ctx, err).Msg(msg) + logger.Error(ctx, err).Msg(msg) http.Error(w, msg, http.StatusInternalServerError) return } @@ -214,7 +214,7 @@ func handleGraphQLRequest(w http.ResponseWriter, r *http.Request) { } } else { msg := "Failed to execute GraphQL operation." - logger.Err(ctx, err).Msg(msg) + logger.Error(ctx, err).Msg(msg) http.Error(w, fmt.Sprintf("%s\n%v", msg, err), http.StatusInternalServerError) } return @@ -228,7 +228,7 @@ func handleGraphQLRequest(w http.ResponseWriter, r *http.Request) { if response, err := addOutputToResponse(resultWriter.Bytes(), xsync.ToPlainMap(output)); err != nil { msg := "Failed to add function output to response." - logger.Err(ctx, err).Msg(msg) + logger.Error(ctx, err).Msg(msg) http.Error(w, fmt.Sprintf("%s\n%v", msg, err), http.StatusInternalServerError) } else { diff --git a/runtime/hostfunctions/system.go b/runtime/hostfunctions/system.go index bf0390b4f..7fd530955 100644 --- a/runtime/hostfunctions/system.go +++ b/runtime/hostfunctions/system.go @@ -67,7 +67,7 @@ func GetTimeInZone(ctx context.Context, tz *string) *string { loc, err := timezones.GetLocation(ctx, zoneId) if err != nil { - logger.Err(ctx, err).Str("tz", zoneId).Msg("Failed to get time zone location.") + logger.Error(ctx, err).Str("tz", zoneId).Msg("Failed to get time zone location.") return nil } @@ -86,7 +86,7 @@ func GetTimeZoneData(ctx context.Context, tz, format *string) []byte { } data, err := timezones.GetTimeZoneData(ctx, *tz, *format) if err != nil { - logger.Error(ctx).Err(err).Str("tz", *tz).Msg("Failed to get time zone data.") + logger.Error(ctx, err).Str("tz", *tz).Msg("Failed to get time zone data.") return nil } return data diff --git a/runtime/httpserver/health.go b/runtime/httpserver/health.go index 78cb337ed..c747f7f63 100644 --- a/runtime/httpserver/health.go +++ b/runtime/httpserver/health.go @@ -32,7 +32,7 @@ var healthHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request jsonBytes, err := utils.MakeJsonObject(data, true) if err != nil { - logger.Err(r.Context(), err).Msg("Failed to serialize health check response.") + logger.Error(r.Context(), err).Msg("Failed to serialize health check response.") w.WriteHeader(http.StatusInternalServerError) return } diff --git a/runtime/httpserver/server.go b/runtime/httpserver/server.go index 6c0708bc1..ca350d8cf 100644 --- a/runtime/httpserver/server.go +++ b/runtime/httpserver/server.go @@ -30,6 +30,8 @@ import ( "github.com/hypermodeinc/modus/runtime/metrics" "github.com/hypermodeinc/modus/runtime/middleware" + sentryhttp "github.com/getsentry/sentry-go/http" + "github.com/fatih/color" "github.com/rs/cors" ) @@ -40,6 +42,8 @@ var urlColor = color.New(color.FgHiCyan) var noticeColor = color.New(color.FgGreen, color.Italic) var warningColor = color.New(color.FgYellow) +var sentryHandler = sentryhttp.New(sentryhttp.Options{}) + // ShutdownTimeout is the time to wait for the server to shutdown gracefully. const shutdownTimeout = 5 * time.Second @@ -81,7 +85,7 @@ func startHttpServer(ctx context.Context, mux http.Handler, addresses ...string) err := server.ListenAndServe() app.SetShuttingDown() if err != nil && !errors.Is(err, http.ErrServerClosed) { - logger.Fatal(ctx).Err(err).Msg("HTTP server error. Exiting.") + logger.Fatal(ctx, err).Msg("HTTP server error. Exiting.") } shutdownChan <- true }() @@ -110,7 +114,7 @@ func startHttpServer(ctx context.Context, mux http.Handler, addresses ...string) defer shutdownRelease() server.RegisterOnShutdown(graphql.CancelSubscriptions) if err := server.Shutdown(shutdownCtx); err != nil { - logger.Fatal(ctx).Err(err).Msg("HTTP server shutdown error.") + logger.Fatal(ctx, err).Msg("HTTP server shutdown error.") } } @@ -230,8 +234,14 @@ func GetMainHandler(options ...func(map[string]http.Handler)) http.Handler { return nil }) + // The mux is the main HTTP handler for the server. + var handler http.Handler = mux + + // Add Sentry error handling middleware. + handler = sentryHandler.Handle(handler) + // Restrict the HTTP methods for all handlers to GET and POST. - handler := restrictHttpMethods(mux) + handler = restrictHttpMethods(handler) // Add CORS support to all endpoints. // The default options allow all origins and methods. @@ -240,8 +250,11 @@ func GetMainHandler(options ...func(map[string]http.Handler)) http.Handler { c := cors.New(cors.Options{ AllowedHeaders: []string{"*"}, }) + handler = c.Handler(handler) + + // Any additional middleware can be added here. - return c.Handler(handler) + return handler } func restrictHttpMethods(next http.Handler) http.Handler { diff --git a/runtime/langsupport/executionplan.go b/runtime/langsupport/executionplan.go index 64a38f33d..585e292c0 100644 --- a/runtime/langsupport/executionplan.go +++ b/runtime/langsupport/executionplan.go @@ -80,9 +80,11 @@ func (plan *executionPlan) InvokeFunction(ctx context.Context, wa WasmAdapter, p defer func() { if r := recover(); r != nil { err = utils.ConvertToError(r) + utils.CaptureError(ctx, err) if utils.DebugModeEnabled() { debug.PrintStack() } + } }() diff --git a/runtime/logger/logger.go b/runtime/logger/logger.go index a6fc75d07..2055def01 100644 --- a/runtime/logger/logger.go +++ b/runtime/logger/logger.go @@ -11,7 +11,6 @@ package logger import ( "context" - "io" "os" "sync" "time" @@ -19,16 +18,11 @@ import ( "github.com/hypermodeinc/modus/runtime/app" "github.com/hypermodeinc/modus/runtime/utils" - zls "github.com/archdx/zerolog-sentry" - "github.com/getsentry/sentry-go" "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) -var zlsCloser io.Closer - func Initialize() *zerolog.Logger { - var writer io.Writer if app.Config().UseJsonLogging() { // In JSON mode, we'll log UTC with millisecond precision. // Note that Go uses this specific value for its formatting exemplars. @@ -36,7 +30,7 @@ func Initialize() *zerolog.Logger { zerolog.TimestampFunc = func() time.Time { return time.Now().UTC() } - writer = os.Stderr + log.Logger = log.Logger.Output(os.Stderr) } else { // In console mode, we can use local time and be a bit prettier. // We'll still log with millisecond precision. @@ -69,7 +63,7 @@ func Initialize() *zerolog.Logger { consoleWriter.TimeFormat = "2006-01-02 15:04:05.000 -07:00" } - writer = consoleWriter + log.Logger = log.Logger.Output(consoleWriter) } // Log the runtime version to every log line, except in development. @@ -79,24 +73,9 @@ func Initialize() *zerolog.Logger { Logger() } - // Use zerolog-sentry to route error, fatal, and panic logs to Sentry. - zlsWriter, err := zls.NewWithHub(sentry.CurrentHub(), zls.WithBreadcrumbs()) - if err != nil { - logger := log.Logger.Output(writer) - logger.Fatal().Err(err).Msg("Failed to initialize Sentry logger.") - } - zlsCloser = zlsWriter // so we can close it later, which flushes Sentry events - log.Logger = log.Logger.Output(zerolog.MultiLevelWriter(writer, zlsWriter)) - return &log.Logger } -func Close() { - if zlsCloser != nil { - zlsCloser.Close() - } -} - var adapters []func(context.Context, zerolog.Context) zerolog.Context var mu sync.RWMutex @@ -135,20 +114,67 @@ func Info(ctx context.Context) *zerolog.Event { return Get(ctx).Info() } -func Warn(ctx context.Context) *zerolog.Event { - return Get(ctx).Warn() -} - -func Error(ctx context.Context) *zerolog.Event { - return Get(ctx).Error() +func Warn(ctx context.Context, errs ...error) *zerolog.Event { + switch len(errs) { + case 0: + return Get(ctx).Warn() + case 1: + err := errs[0] + if err == nil { + return Get(ctx).Warn() + } + utils.CaptureWarning(ctx, err) + return Get(ctx).Warn().Err(err) + default: + for _, err := range errs { + if err != nil { + utils.CaptureWarning(ctx, err) + } + } + return Get(ctx).Warn().Errs("errors", errs) + } } -func Err(ctx context.Context, err error) *zerolog.Event { - return Get(ctx).Err(err) +func Error(ctx context.Context, errs ...error) *zerolog.Event { + switch len(errs) { + case 0: + return Get(ctx).Error() + case 1: + err := errs[0] + if err == nil { + return Get(ctx).Error() + } + utils.CaptureError(ctx, err) + return Get(ctx).Err(err) + default: + for _, err := range errs { + if err != nil { + utils.CaptureError(ctx, err) + } + } + return Get(ctx).Error().Errs("errors", errs) + } } -func Fatal(ctx context.Context) *zerolog.Event { - return Get(ctx).Fatal() +func Fatal(ctx context.Context, errs ...error) *zerolog.Event { + switch len(errs) { + case 0: + return Get(ctx).Fatal() + case 1: + err := errs[0] + if err == nil { + return Get(ctx).Fatal() + } + utils.CaptureError(ctx, err) + return Get(ctx).Fatal().Err(err) + default: + for _, err := range errs { + if err != nil { + utils.CaptureError(ctx, err) + } + } + return Get(ctx).Fatal().Errs("errors", errs) + } } func Debugf(msg string, v ...any) { diff --git a/runtime/main.go b/runtime/main.go index da85ee50d..9d3eca6ba 100644 --- a/runtime/main.go +++ b/runtime/main.go @@ -37,12 +37,12 @@ func main() { err := envfiles.LoadEnvFiles(ctx) if err != nil { - log.Warn().Err(err).Msg("Failed to load environment files.") + logger.Warn(ctx, err).Msg("Failed to load environment files.") } // Initialize Sentry (if enabled) - utils.InitSentry() - defer utils.FlushSentryEvents() + utils.InitializeSentry() + defer utils.FinalizeSentry() // Get the main handler for the HTTP server before starting the services, // so it can register the endpoints as the manifest is loaded. diff --git a/runtime/manifestdata/manifestdata.go b/runtime/manifestdata/manifestdata.go index db251df5b..534d0c474 100644 --- a/runtime/manifestdata/manifestdata.go +++ b/runtime/manifestdata/manifestdata.go @@ -44,7 +44,7 @@ func MonitorManifestFile(ctx context.Context) { } if err := loadManifest(ctx); err != nil { - logger.Err(ctx, err).Str("filename", file.Name).Msg("Failed to load manifest file.") + logger.Error(ctx, err).Str("filename", file.Name).Msg("Failed to load manifest file.") return err } @@ -58,7 +58,7 @@ func MonitorManifestFile(ctx context.Context) { if file.Name == manifestFileName { logger.Warn(ctx).Str("filename", file.Name).Msg("Manifest file removed.") if err := unloadManifest(ctx); err != nil { - logger.Err(ctx, err).Str("filename", file.Name).Msg("Failed to unload manifest file.") + logger.Error(ctx, err).Str("filename", file.Name).Msg("Failed to unload manifest file.") return err } } diff --git a/runtime/middleware/authKeys.go b/runtime/middleware/authKeys.go index c2f5d7811..4b59f2efe 100644 --- a/runtime/middleware/authKeys.go +++ b/runtime/middleware/authKeys.go @@ -71,7 +71,7 @@ func getJwksRefreshMinutes(ctx context.Context) int { } refreshTime, err := strconv.Atoi(refreshTimeStr) if err != nil { - logger.Warn(ctx).Err(err).Msg("Invalid MODUS_JWKS_REFRESH_MINUTES value. Using default value of 1440 minutes.") + logger.Warn(ctx, err).Msg("Invalid MODUS_JWKS_REFRESH_MINUTES value. Using default value of 1440 minutes.") return 1440 } return refreshTime @@ -90,7 +90,7 @@ func (ak *AuthKeys) worker(ctx context.Context) { if keysStr != "" { keys, err := jwksEndpointsJsonToKeys(ctx, keysStr) if err != nil { - logger.Warn(ctx).Err(err).Msg("Auth JWKS public keys deserializing error") + logger.Warn(ctx, err).Msg("Auth JWKS public keys deserializing error") } else { ak.setJwksPublicKeys(keys) } diff --git a/runtime/middleware/jwt.go b/runtime/middleware/jwt.go index 2860ce68c..4770a7fa2 100644 --- a/runtime/middleware/jwt.go +++ b/runtime/middleware/jwt.go @@ -47,9 +47,9 @@ func initKeys(ctx context.Context) { keys, err := publicPemKeysJsonToKeys(publicPemKeysJson) if err != nil { if app.IsDevEnvironment() { - logger.Fatal(ctx).Err(err).Msg("Auth PEM public keys deserializing error") + logger.Fatal(ctx, err).Msg("Auth PEM public keys deserializing error") } - logger.Error(ctx).Err(err).Msg("Auth PEM public keys deserializing error") + logger.Error(ctx, err).Msg("Auth PEM public keys deserializing error") return } globalAuthKeys.setPemPublicKeys(keys) @@ -58,9 +58,9 @@ func initKeys(ctx context.Context) { keys, err := jwksEndpointsJsonToKeys(ctx, jwksEndpointsJson) if err != nil { if app.IsDevEnvironment() { - logger.Fatal(ctx).Err(err).Msg("Auth JWKS public keys deserializing error") + logger.Fatal(ctx, err).Msg("Auth JWKS public keys deserializing error") } - logger.Error(ctx).Err(err).Msg("Auth JWKS public keys deserializing error") + logger.Error(ctx, err).Msg("Auth JWKS public keys deserializing error") return } globalAuthKeys.setJwksPublicKeys(keys) @@ -94,7 +94,7 @@ func HandleJWT(next http.Handler) http.Handler { } token, _, err := jwtParser.ParseUnverified(tokenStr, jwt.MapClaims{}) if err != nil { - logger.Warn(ctx).Err(err).Msg("Error parsing JWT token. Continuing since running in development") + logger.Warn(ctx, err).Msg("Error parsing JWT token. Continuing since running in development") next.ServeHTTP(w, r) return } @@ -160,7 +160,7 @@ func HandleJWT(next http.Handler) http.Handler { func addClaimsToContext(ctx context.Context, claims jwt.MapClaims) context.Context { claimsJson, err := utils.JsonSerialize(claims) if err != nil { - logger.Error(ctx).Err(err).Msg("JWT claims serialization error") + logger.Error(ctx, err).Msg("JWT claims serialization error") return ctx } return context.WithValue(ctx, jwtClaims, string(claimsJson)) diff --git a/runtime/pluginmanager/loader.go b/runtime/pluginmanager/loader.go index 400ac26b5..0f82b4801 100644 --- a/runtime/pluginmanager/loader.go +++ b/runtime/pluginmanager/loader.go @@ -26,7 +26,7 @@ func monitorPlugins(ctx context.Context) { loadPluginFile := func(fi storage.FileInfo) error { err := loadPlugin(ctx, fi.Name) if err != nil { - logger.Err(ctx, err). + logger.Error(ctx, err). Str("filename", fi.Name). Msg("Failed to load plugin.") } @@ -39,7 +39,7 @@ func monitorPlugins(ctx context.Context) { sm.Removed = func(fi storage.FileInfo) error { err := unloadPlugin(ctx, fi.Name) if err != nil { - logger.Err(ctx, err). + logger.Error(ctx, err). Str("filename", fi.Name). Msg("Failed to unload plugin.") } diff --git a/runtime/secrets/kubernetes.go b/runtime/secrets/kubernetes.go index d05bb4075..a84821a6d 100644 --- a/runtime/secrets/kubernetes.go +++ b/runtime/secrets/kubernetes.go @@ -54,7 +54,7 @@ func (sp *kubernetesSecretsProvider) initialize(ctx context.Context) { cli, cache, err := newK8sClientForSecret(sp.secretNamespace, sp.secretName) if err != nil { - logger.Fatal(ctx).Err(err).Msg("Failed to initialize Kubernetes client.") + logger.Fatal(ctx, err).Msg("Failed to initialize Kubernetes client.") } sp.k8sClient = cli @@ -67,7 +67,7 @@ func (sp *kubernetesSecretsProvider) initialize(ctx context.Context) { // to call the API server everytime. go func() { if err := cache.Start(ctx); err != nil { - logger.Fatal(ctx).Err(err).Msg("Failed to start Kubernetes client cache.") + logger.Fatal(ctx, err).Msg("Failed to start Kubernetes client cache.") } logger.Info(ctx).Msg("Kubernetes client cache stopped.") }() diff --git a/runtime/services/services.go b/runtime/services/services.go index 58abf6b65..ecc1aea4f 100644 --- a/runtime/services/services.go +++ b/runtime/services/services.go @@ -80,7 +80,6 @@ func Stop(ctx context.Context) { sqlclient.Shutdown() dgraphclient.ShutdownConns() neo4jclient.CloseDrivers(ctx) - logger.Close() db.Stop(ctx) db.CloseModusDb(ctx) diff --git a/runtime/sqlclient/mysql.go b/runtime/sqlclient/mysql.go index af8d4a3ec..ecfa02f8c 100644 --- a/runtime/sqlclient/mysql.go +++ b/runtime/sqlclient/mysql.go @@ -42,7 +42,7 @@ func (ds *mysqlDS) query(ctx context.Context, stmt string, params []any, execOnl } defer func() { if err := tx.Rollback(); err != nil && err != sql.ErrTxDone { - logger.Warn(ctx).Err(err).Msg("Error rolling back transaction.") + logger.Warn(ctx, err).Msg("Error rolling back transaction.") return } }() diff --git a/runtime/sqlclient/postgresql.go b/runtime/sqlclient/postgresql.go index 92fe1a0c3..037ec9f4b 100644 --- a/runtime/sqlclient/postgresql.go +++ b/runtime/sqlclient/postgresql.go @@ -36,7 +36,7 @@ func (ds *postgresqlDS) query(ctx context.Context, stmt string, params []any, ex } defer func() { if err := tx.Rollback(ctx); err != nil && err != pgx.ErrTxClosed { - logger.Warn(ctx).Err(err).Msg("Error rolling back transaction.") + logger.Warn(ctx, err).Msg("Error rolling back transaction.") return } }() diff --git a/runtime/storage/awsstorage.go b/runtime/storage/awsstorage.go index 4013898ab..6e793fa70 100644 --- a/runtime/storage/awsstorage.go +++ b/runtime/storage/awsstorage.go @@ -44,13 +44,13 @@ func (stg *awsStorageProvider) initialize(ctx context.Context) { cfg, err := config.LoadDefaultConfig(ctx) if err != nil { - logger.Fatal(ctx).Err(err).Msg("Failed to load AWS configuration. Exiting.") + logger.Fatal(ctx, err).Msg("Failed to load AWS configuration. Exiting.") } client := sts.NewFromConfig(cfg) identity, err := client.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) if err != nil { - logger.Fatal(ctx).Err(err).Msg("Failed to get AWS caller identity. Exiting.") + logger.Fatal(ctx, err).Msg("Failed to get AWS caller identity. Exiting.") } stg.s3Client = s3.NewFromConfig(cfg) diff --git a/runtime/storage/localstorage.go b/runtime/storage/localstorage.go index a9d55378f..2a766390b 100644 --- a/runtime/storage/localstorage.go +++ b/runtime/storage/localstorage.go @@ -40,7 +40,7 @@ func (stg *localStorageProvider) initialize(ctx context.Context) { Msg("Creating app directory.") err := os.MkdirAll(stg.appPath, 0755) if err != nil { - logger.Fatal(ctx).Err(err). + logger.Fatal(ctx, err). Msg("Failed to create local app directory. Exiting.") } } else { diff --git a/runtime/storage/storagemonitor.go b/runtime/storage/storagemonitor.go index 728d4423a..153ada933 100644 --- a/runtime/storage/storagemonitor.go +++ b/runtime/storage/storagemonitor.go @@ -54,7 +54,7 @@ func (sm *StorageMonitor) Start(ctx context.Context) { if err != nil { // Don't stop watching. We'll just try again on the next cycle. if !loggedError { - logger.Err(ctx, err).Msgf("Failed to list %s files.", sm.patterns) + logger.Error(ctx, err).Msgf("Failed to list %s files.", sm.patterns) loggedError = true } continue diff --git a/runtime/utils/sentry.go b/runtime/utils/sentry.go index 4b983482e..59ceea8fe 100644 --- a/runtime/utils/sentry.go +++ b/runtime/utils/sentry.go @@ -11,7 +11,6 @@ package utils import ( "context" - "log" "os" "runtime" "strings" @@ -20,13 +19,14 @@ import ( "github.com/hypermodeinc/modus/runtime/app" "github.com/getsentry/sentry-go" + "github.com/rs/zerolog/log" ) var sentryInitialized bool var rootSourcePath = app.GetRootSourcePath() -func InitSentry() { +func InitializeSentry() { // Don't initialize Sentry when running in debug mode. if DebugModeEnabled() { @@ -69,18 +69,18 @@ func InitSentry() { // That way we can trace performance issues in the runtime itself, and let Sentry correlate them with // any errors that may have occurred. EnableTracing: true, - TracesSampleRate: 1.0, + TracesSampleRate: GetFloatFromEnv("SENTRY_TRACES_SAMPLE_RATE", 0.2), }) if err != nil { - // We don't have our logger yet, so just log to stderr. - log.Fatalf("sentry.Init: %s", err) + log.Fatal().Err(err).Msg("Failed to initialize Sentry.") } sentryInitialized = true } -func FlushSentryEvents() { +func FinalizeSentry() { if sentryInitialized { + sentry.Recover() sentry.Flush(5 * time.Second) } } @@ -99,11 +99,11 @@ func NewSentrySpan(ctx context.Context, funcName string) (*sentry.Span, context. if tx := sentry.TransactionFromContext(ctx); tx == nil { tx = sentry.StartTransaction(ctx, funcName, sentry.WithOpName("function")) return tx, tx.Context() - } else { - span := sentry.StartSpan(ctx, "function") - span.Description = funcName - return span, span.Context() } + + span := sentry.StartSpan(ctx, "function") + span.Description = funcName + return span, span.Context() } func getFuncName(skip int) string { @@ -159,3 +159,28 @@ func sentryAddExtras(event *sentry.Event) { event.Extra["namespace"] = ns } } + +func CaptureError(ctx context.Context, err error) { + if !sentryInitialized || err == nil { + return + } + _ = sentryHub(ctx).CaptureException(err) +} + +func CaptureWarning(ctx context.Context, err error) { + if !sentryInitialized || err == nil { + return + } + + hub := sentryHub(ctx) + event := hub.Client().EventFromException(err, sentry.LevelWarning) + _ = hub.CaptureEvent(event) +} + +func sentryHub(ctx context.Context) *sentry.Hub { + if hub := sentry.GetHubFromContext(ctx); hub != nil { + return hub + } + + return sentry.CurrentHub() +} diff --git a/runtime/utils/strings.go b/runtime/utils/strings.go index 0f82e20fd..8fed09618 100644 --- a/runtime/utils/strings.go +++ b/runtime/utils/strings.go @@ -10,6 +10,7 @@ package utils import ( + "strings" "unicode/utf16" "unicode/utf8" "unsafe" @@ -74,3 +75,11 @@ func SanitizeUTF8(s []byte) []byte { } return b } + +func TrimStringBefore(s string, sep string) string { + parts := strings.SplitN(s, sep, 2) + if len(parts) == 2 { + return parts[1] + } + return s +} diff --git a/runtime/utils/utils.go b/runtime/utils/utils.go index d0c6d9912..0cbba90bd 100644 --- a/runtime/utils/utils.go +++ b/runtime/utils/utils.go @@ -16,9 +16,11 @@ import ( "reflect" "strconv" "strings" + "time" "unsafe" "github.com/google/uuid" + "github.com/rs/zerolog/log" ) func NilIf[T any](condition bool, val T) *T { @@ -46,14 +48,6 @@ func TraceModeEnabled() bool { return EnvVarFlagEnabled("MODUS_TRACE") } -func TrimStringBefore(s string, sep string) string { - parts := strings.SplitN(s, sep, 2) - if len(parts) == 2 { - return parts[1] - } - return s -} - func GenerateUUIDv7() string { return uuid.Must(uuid.NewV7()).String() } @@ -143,3 +137,46 @@ func GetStructFieldValue(rs reflect.Value, fieldName string, caseInsensitive boo return rf.Interface(), nil } + +// Retrieves an integer value from an environment variable. +func GetIntFromEnv(envVar string, defaultValue int) int { + str := os.Getenv(envVar) + if str == "" { + return defaultValue + } + + value, err := strconv.Atoi(str) + if err != nil { + log.Warn().Msgf("Invalid value for %s. Using %d instead.", envVar, defaultValue) + return defaultValue + } + + return value +} + +// Retrieves a decimal value from an environment variable. +func GetFloatFromEnv(envVar string, defaultValue float64) float64 { + str := os.Getenv(envVar) + if str == "" { + return defaultValue + } + + value, err := strconv.ParseFloat(str, 64) + if err != nil { + log.Warn().Msgf("Invalid value for %s. Using %f instead.", envVar, defaultValue) + return defaultValue + } + + return value +} + +// Retrieves a duration value from an environment variable. +func GetDurationFromEnv(envVar string, defaultValue int, unit time.Duration) time.Duration { + intVal := GetIntFromEnv(envVar, defaultValue) + if intVal < 0 { + duration := time.Duration(defaultValue) * unit + log.Warn().Msgf("Invalid value for %s. Using %s instead.", envVar, duration) + return duration + } + return time.Duration(intVal) * unit +} diff --git a/runtime/wasmhost/fncall.go b/runtime/wasmhost/fncall.go index b21598355..d416ba0bc 100644 --- a/runtime/wasmhost/fncall.go +++ b/runtime/wasmhost/fncall.go @@ -87,7 +87,7 @@ func (host *wasmHost) CallFunction(ctx context.Context, fnInfo functions.Functio mod, err := host.GetModuleInstance(ctx, plugin, buffers) if err != nil { - logger.Err(ctx, err).Msg("Error getting module instance.") + logger.Error(ctx, err).Msg("Error getting module instance.") return nil, err } defer mod.Close(ctx) @@ -168,7 +168,7 @@ func (host *wasmHost) CallFunctionInModule(ctx context.Context, mod wasm.Module, Msg("Function execution was canceled.") } else if errors.Is(err, utils.ErrUserError) { // If we specifically wrapped an error with ErrUserError, then we want to log it as a user-visible error. - logger.Err(ctx, err). + logger.Error(ctx, err). Str("function", fnName). Dur("duration_ms", duration). Bool("user_visible", true). @@ -180,7 +180,7 @@ func (host *wasmHost) CallFunctionInModule(ctx context.Context, mod wasm.Module, } // NOTE: Errors of this type should not be user-visible, as they were caused by some Runtime issue, not the user's code. // This will also ensure the error is reported to Sentry. - logger.Err(ctx, err). + logger.Error(ctx, err). Str("function", fnName). Dur("duration_ms", duration). Msg("Error while executing function.") diff --git a/runtime/wasmhost/hostfns.go b/runtime/wasmhost/hostfns.go index 89bd797c0..6e8dacc01 100644 --- a/runtime/wasmhost/hostfns.go +++ b/runtime/wasmhost/hostfns.go @@ -208,7 +208,9 @@ func (host *wasmHost) newHostFunction(modName, funcName string, fn any, opts ... // Log any panics that occur in the host function defer func() { if r := recover(); r != nil { - logger.Err(ctx, utils.ConvertToError(r)).Str("host_function", fullName).Msg("Panic in host function.") + err := utils.ConvertToError(r) + logger.Error(ctx, err).Str("host_function", fullName).Msg("Panic in host function.") + utils.CaptureError(ctx, err) if utils.DebugModeEnabled() { debug.PrintStack() } @@ -248,7 +250,7 @@ func (host *wasmHost) newHostFunction(modName, funcName string, fn any, opts ... params = append(params, rvParam.Interface()) } if err := decodeParams(ctx, wa, plan, stack, params); err != nil { - logger.Err(ctx, err).Str("host_function", fullName).Any("data", params).Msg("Error decoding input parameters.") + logger.Error(ctx, err).Str("host_function", fullName).Any("data", params).Msg("Error decoding input parameters.") return } @@ -316,7 +318,7 @@ func (host *wasmHost) newHostFunction(modName, funcName string, fn any, opts ... // Encode the results (if there are any) and write them to the stack if len(results) > 0 { if err := encodeResults(ctx, wa, plan, stack, results); err != nil { - logger.Err(ctx, err).Str("host_function", fullName).Any("data", results).Msg("Error encoding results.") + logger.Error(ctx, err).Str("host_function", fullName).Any("data", results).Msg("Error encoding results.") } } }) @@ -512,7 +514,7 @@ func callHostFunction(ctx context.Context, fn func() error, msgs hfMessages) { } } else if err != nil { if msgs.msgError != "" { - l := logger.Err(ctx, err).Bool("user_visible", true).Dur("duration_ms", duration) + l := logger.Error(ctx, err).Bool("user_visible", true).Dur("duration_ms", duration) if msgs.msgDetail != "" { l.Str("detail", msgs.msgDetail) } diff --git a/runtime/wasmhost/wasmhost.go b/runtime/wasmhost/wasmhost.go index 90c51fae7..47da2f715 100644 --- a/runtime/wasmhost/wasmhost.go +++ b/runtime/wasmhost/wasmhost.go @@ -54,7 +54,7 @@ func NewWasmHost(ctx context.Context, registrations ...func(WasmHost) error) Was wasi.MustInstantiate(ctx, runtime) if err := instantiateEnvHostFunctions(ctx, runtime); err != nil { - logger.Fatal(ctx).Err(err).Msg("Failed to instantiate env host functions.") + logger.Fatal(ctx, err).Msg("Failed to instantiate env host functions.") return nil } @@ -65,13 +65,13 @@ func NewWasmHost(ctx context.Context, registrations ...func(WasmHost) error) Was for _, reg := range registrations { if err := reg(host); err != nil { - logger.Fatal(ctx).Err(err).Msg("Failed to apply a registration to the WASM host.") + logger.Fatal(ctx, err).Msg("Failed to apply a registration to the WASM host.") return nil } } if err := host.instantiateHostFunctions(ctx); err != nil { - logger.Fatal(ctx).Err(err).Msg("Failed to instantiate host functions.") + logger.Fatal(ctx, err).Msg("Failed to instantiate host functions.") return nil } @@ -94,7 +94,7 @@ func TryGetWasmHost(ctx context.Context) (WasmHost, bool) { func (host *wasmHost) Close(ctx context.Context) { if err := host.runtime.Close(ctx); err != nil { - logger.Err(ctx, err).Msg("Failed to cleanly close the WASM runtime.") + logger.Error(ctx, err).Msg("Failed to cleanly close the WASM runtime.") } }