diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2b5c526..51e7167 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,7 +40,12 @@ jobs: output_name=world_${{ matrix.goos }}_${{ matrix.goarch }} [ ${{ matrix.goos }} = "windows" ] && output_name+=".exe" - env GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build -o $output_name ./cmd/world + ## Get version from tag name + bin_version=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') + ## Handle if event coming from PR, not release/tag + if [ "${{ github.event_name }}" == "pull_request" ]; then bin_version=${{ github.event.pull_request.head.sha }}; fi + + env GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build -ldflags "-X main.AppVersion=$bin_version -X main.SentryDsn=${{ secrets.SENTRY_DSN }} -X main.PosthogApiKey=${{ secrets.POSTHOG_API_KEY }}" -o $output_name ./cmd/world echo "output_name=$output_name" >> $GITHUB_OUTPUT - name: Compress Build Binary uses: a7ul/tar-action@v1.1.3 diff --git a/cmd/world/main.go b/cmd/world/main.go index 809ac28..a74dd58 100644 --- a/cmd/world/main.go +++ b/cmd/world/main.go @@ -1,16 +1,13 @@ package main import ( - "github.com/denisbrodbeck/machineid" - ph "github.com/posthog/posthog-go" - "log" "os" - "pkg.world.dev/world-cli/common/logger" - "pkg.world.dev/world-cli/posthog" - "time" - "github.com/getsentry/sentry-go" + "github.com/rs/zerolog/log" + "pkg.world.dev/world-cli/cmd/world/root" + _ "pkg.world.dev/world-cli/common/logger" + "pkg.world.dev/world-cli/telemetry" ) // This variable will be overridden by ldflags during build @@ -21,11 +18,6 @@ var ( SentryDsn string ) -const ( - postInstallationEvent = "World CLI Installation" - runningEvent = "World CLI Running" -) - func init() { // Set default app version in case not provided by ldflags if AppVersion == "" { @@ -36,60 +28,24 @@ func init() { func main() { // Sentry initialization - if SentryDsn != "" { - err := sentry.Init(sentry.ClientOptions{ - Dsn: SentryDsn, - EnableTracing: true, - TracesSampleRate: 1.0, - ProfilesSampleRate: 1.0, - AttachStacktrace: true, - }) - if err != nil { - log.Fatalf("sentry.Init: %s", err) - } - // Handle panic - defer func() { - err := recover() - if err != nil { - sentry.CurrentHub().Recover(err) - } + telemetry.SentryInit(SentryDsn) + defer telemetry.SentryFlush() - // Flush buffered events before the program terminates. - // Set the timeout to the maximum duration the program can afford to wait. - sentry.Flush(time.Second * 5) - }() - } + // Set logger sentry hook + log.Logger = log.Logger.Hook(telemetry.SentryHook{}) // Posthog Initialization - posthog.Init(PosthogApiKey) - defer posthog.Close() - - // Obtain the machine ID - machineID, err := machineid.ProtectedID("world-cli") - if err != nil { - logger.Error(err) - } - - // Create capture event for posthog - event := ph.Capture{ - DistinctId: machineID, - Timestamp: time.Now(), - Properties: map[string]interface{}{ - "version": AppVersion, - "command": os.Args, - }, - } + telemetry.PosthogInit(PosthogApiKey) + defer telemetry.PosthogClose() // Capture event post installation if len(os.Args) > 1 && os.Args[1] == "post-installation" { - event.Event = postInstallationEvent - posthog.CaptureEvent(event) + telemetry.PosthogCaptureEvent(AppVersion, telemetry.PostInstallationEvent) return } // Capture event running - event.Event = runningEvent - posthog.CaptureEvent(event) + telemetry.PosthogCaptureEvent(AppVersion, telemetry.RunningEvent) root.Execute() } diff --git a/common/logger/init.go b/common/logger/init.go index c949e30..903bd6b 100644 --- a/common/logger/init.go +++ b/common/logger/init.go @@ -46,9 +46,6 @@ func init() { lgr = lgr.With().Caller().Logger() } - // Set hook for sentry capture error message - lgr = lgr.Hook(SentryHook{}) - log.Logger = lgr } diff --git a/common/logger/sentry.go b/common/logger/sentry.go deleted file mode 100644 index db67eb6..0000000 --- a/common/logger/sentry.go +++ /dev/null @@ -1,21 +0,0 @@ -package logger - -import ( - "fmt" - "github.com/getsentry/sentry-go" - "github.com/rs/zerolog" -) - -// SentryHook is a custom hook that implements zerolog.Hook interface -type SentryHook struct{} - -// Run is called for every log event and implements the zerolog.Hook interface -func (h SentryHook) Run(e *zerolog.Event, level zerolog.Level, msg string) { - // Capture error message - sentry.CaptureException(fmt.Errorf(msg)) -} - -// Levels returns the log levels that this hook should be triggered for -func (h SentryHook) Levels() []zerolog.Level { - return []zerolog.Level{zerolog.ErrorLevel, zerolog.FatalLevel} -} diff --git a/posthog/posthog.go b/posthog/posthog.go deleted file mode 100644 index c12ff6b..0000000 --- a/posthog/posthog.go +++ /dev/null @@ -1,38 +0,0 @@ -package posthog - -import ( - ph "github.com/posthog/posthog-go" - - "pkg.world.dev/world-cli/common/logger" -) - -var ( - client ph.Client - initialized bool -) - -// Init Posthog initialization -func Init(posthogApiKey string) { - if posthogApiKey != "" { - client = ph.New(posthogApiKey) - initialized = true - } -} - -func CaptureEvent(capture ph.Capture) { - if initialized { - err := client.Enqueue(capture) - if err != nil { - logger.Error(err) - } - } -} - -func Close() { - if initialized { - err := client.Close() - if err != nil { - logger.Error(err) - } - } -} diff --git a/telemetry/posthog.go b/telemetry/posthog.go new file mode 100644 index 0000000..e081088 --- /dev/null +++ b/telemetry/posthog.go @@ -0,0 +1,132 @@ +package telemetry + +import ( + "os" + "path/filepath" + "time" + + "github.com/denisbrodbeck/machineid" + "github.com/posthog/posthog-go" + "github.com/rs/zerolog/log" +) + +var ( + posthogClient posthog.Client + posthogInitialized bool + lastLoggedTime time.Time +) + +const ( + PostInstallationEvent = "World CLI Installation" + RunningEvent = "World CLI Running" + timestampFile = ".worldcli" +) + +// Init Posthog initialization +func PosthogInit(posthogApiKey string) { + if posthogApiKey != "" { + posthogClient = posthog.New(posthogApiKey) + posthogInitialized = true + + // get last logged time + lastTime, err := getLastLoggedTime() + if err != nil { + log.Err(err).Msg("Cannot get last logged time") + } + + lastLoggedTime = lastTime + + // Update last visited timestamp + err = updateLastLoggedTime(time.Now()) + if err != nil { + log.Err(err).Msg("Cannot update last logged time") + } + } +} + +// getLastLoggedTime reads the last visited timestamp from the file. +func getLastLoggedTime() (time.Time, error) { + filePath, err := getTimestampFilePath() + if err != nil { + return time.Time{}, err + } + + if _, err = os.Stat(filePath); os.IsNotExist(err) { + // Return a zero time if the file does not exist + return time.Time{}, nil + } + + data, err := os.ReadFile(filePath) + if err != nil { + return time.Time{}, err + } + + timestamp, err := time.Parse(time.DateOnly, string(data)) + if err != nil { + return time.Time{}, err + } + + return timestamp, nil +} + +// getTimestampFilePath returns the path to the timestamp file. +func getTimestampFilePath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(homeDir, timestampFile), nil +} + +// updateLastLoggedTime updates the last visited timestamp in the file. +func updateLastLoggedTime(timestamp time.Time) error { + filePath, err := getTimestampFilePath() + if err != nil { + return err + } + + data := []byte(timestamp.Format(time.DateOnly)) + + return os.WriteFile(filePath, data, 0644) +} + +// isSameDay checks if two timestamps are from the same day. +func isSameDay(time1, time2 time.Time) bool { + return time1.Year() == time2.Year() && + time1.Month() == time2.Month() && + time1.Day() == time2.Day() +} + +func PosthogCaptureEvent(context, event string) { + if posthogInitialized && (!isSameDay(lastLoggedTime, time.Now()) || event != RunningEvent) { + // Obtain the machine ID + machineID, err := machineid.ProtectedID("world-cli") + if err != nil { + log.Err(err).Msg("Cannot get machine id") + return + } + + // Capture the event + err = posthogClient.Enqueue(posthog.Capture{ + DistinctId: machineID, + Timestamp: time.Now(), + Event: event, + Properties: map[string]interface{}{ + "context": context, + }, + }) + if err != nil { + log.Err(err).Msg("Cannot capture event") + } + } +} + +func PosthogClose() { + if posthogInitialized { + err := posthogClient.Close() + if err != nil { + log.Err(err).Msg("Cannot close posthog client") + } + posthogInitialized = false + } +} diff --git a/telemetry/sentry.go b/telemetry/sentry.go new file mode 100644 index 0000000..a53fb90 --- /dev/null +++ b/telemetry/sentry.go @@ -0,0 +1,62 @@ +package telemetry + +import ( + "fmt" + "github.com/getsentry/sentry-go" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "time" +) + +var ( + sentryInitialized bool +) + +// SentryInit initialize sentry +func SentryInit(sentryDsn string) { + if sentryDsn != "" { + err := sentry.Init(sentry.ClientOptions{ + Dsn: sentryDsn, + EnableTracing: true, + TracesSampleRate: 1.0, + ProfilesSampleRate: 1.0, + AttachStacktrace: true, + }) + if err != nil { + log.Err(err).Msg("Cannot initialize sentry") + return + } + + sentryInitialized = true + } +} + +func SentryFlush() { + if sentryInitialized { + err := recover() + if err != nil { + sentry.CurrentHub().Recover(err) + } + + // Flush buffered events before the program terminates. + // Set the timeout to the maximum duration the program can afford to wait. + sentry.Flush(time.Second * 5) + sentryInitialized = false + } +} + +// SentryHook is a custom hook that implements zerolog.Hook interface +type SentryHook struct{} + +// Run is called for every log event and implements the zerolog.Hook interface +func (h SentryHook) Run(e *zerolog.Event, level zerolog.Level, msg string) { + if sentryInitialized { + // Capture error message + sentry.CaptureException(fmt.Errorf(msg)) + } +} + +// Levels returns the log levels that this hook should be triggered for +func (h SentryHook) Levels() []zerolog.Level { + return []zerolog.Level{zerolog.ErrorLevel, zerolog.FatalLevel} +}