From ca68eee45cb39108d648552ecf861b4fdc3bfee6 Mon Sep 17 00:00:00 2001 From: Gabe Ruttner Date: Fri, 12 Apr 2024 06:16:14 -0700 Subject: [PATCH] feat(api): posthog telemetry (#374) * feat: add posthog dep * feat: posthog analytics * feat: user events * fix: nil tenant * feat: tenant ident * chore: linting * Update pkg/analytics/posthog/posthog.go Co-authored-by: abelanger5 * fix: typo --------- Co-authored-by: abelanger5 --- api/v1/server/handlers/tenants/create.go | 12 ++++ .../server/handlers/tenants/create_invite.go | 6 ++ api/v1/server/handlers/users/accept_invite.go | 7 ++ api/v1/server/handlers/users/create.go | 10 +++ api/v1/server/handlers/users/get_current.go | 14 +++- api/v1/server/handlers/users/reject_invite.go | 7 ++ go.mod | 1 + go.sum | 2 + internal/config/loader/loader.go | 18 +++++ internal/config/server/server.go | 25 +++++++ internal/services/grpc/server.go | 13 +++- pkg/analytics/analytics.go | 12 ++++ pkg/analytics/posthog/posthog.go | 65 +++++++++++++++++++ 13 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 pkg/analytics/analytics.go create mode 100644 pkg/analytics/posthog/posthog.go diff --git a/api/v1/server/handlers/tenants/create.go b/api/v1/server/handlers/tenants/create.go index 2ba7be707..0ac86233e 100644 --- a/api/v1/server/handlers/tenants/create.go +++ b/api/v1/server/handlers/tenants/create.go @@ -58,6 +58,18 @@ func (t *TenantService) TenantCreate(ctx echo.Context, request gen.TenantCreateR return nil, err } + t.config.Analytics.Tenant(tenant.ID, map[string]interface{}{ + "name": tenant.Name, + "slug": tenant.Slug, + }) + + t.config.Analytics.Enqueue( + "tenant:create", + user.ID, + &tenant.ID, + nil, + ) + return gen.TenantCreate200JSONResponse( *transformers.ToTenant(tenant), ), nil diff --git a/api/v1/server/handlers/tenants/create_invite.go b/api/v1/server/handlers/tenants/create_invite.go index 6a23250d6..e7cd34637 100644 --- a/api/v1/server/handlers/tenants/create_invite.go +++ b/api/v1/server/handlers/tenants/create_invite.go @@ -53,6 +53,12 @@ func (t *TenantService) TenantInviteCreate(ctx echo.Context, request gen.TenantI return nil, err } + t.config.Analytics.Enqueue("user-invite:create", + user.ID, + &invite.TenantID, + nil, + ) + return gen.TenantInviteCreate201JSONResponse( *transformers.ToTenantInviteLink(invite), ), nil diff --git a/api/v1/server/handlers/users/accept_invite.go b/api/v1/server/handlers/users/accept_invite.go index 2dbdcf48d..4133e77a2 100644 --- a/api/v1/server/handlers/users/accept_invite.go +++ b/api/v1/server/handlers/users/accept_invite.go @@ -81,5 +81,12 @@ func (u *UserService) TenantInviteAccept(ctx echo.Context, request gen.TenantInv return nil, err } + u.config.Analytics.Enqueue( + "user-invite:reject", + user.ID, + &invite.TenantID, + nil, + ) + return nil, nil } diff --git a/api/v1/server/handlers/users/create.go b/api/v1/server/handlers/users/create.go index 316be375f..fffac2fd8 100644 --- a/api/v1/server/handlers/users/create.go +++ b/api/v1/server/handlers/users/create.go @@ -70,6 +70,16 @@ func (u *UserService) UserCreate(ctx echo.Context, request gen.UserCreateRequest return nil, err } + u.config.Analytics.Enqueue( + "user:create", + user.ID, + nil, + map[string]interface{}{ + "email": request.Body.Email, + "name": request.Body.Name, + }, + ) + return gen.UserCreate200JSONResponse( *transformers.ToUser(user, false), ), nil diff --git a/api/v1/server/handlers/users/get_current.go b/api/v1/server/handlers/users/get_current.go index a0816cc9d..7b43cddae 100644 --- a/api/v1/server/handlers/users/get_current.go +++ b/api/v1/server/handlers/users/get_current.go @@ -25,7 +25,19 @@ func (u *UserService) UserGetCurrent(ctx echo.Context, request gen.UserGetCurren hasPass = true } + transformedUser := transformers.ToUser(user, hasPass) + + u.config.Analytics.Enqueue( + "user:current", + user.ID, + nil, + map[string]interface{}{ + "email": user.Email, + "name": transformedUser.Name, + }, + ) + return gen.UserGetCurrent200JSONResponse( - *transformers.ToUser(user, hasPass), + *transformedUser, ), nil } diff --git a/api/v1/server/handlers/users/reject_invite.go b/api/v1/server/handlers/users/reject_invite.go index 3a8bcff46..43b25f43d 100644 --- a/api/v1/server/handlers/users/reject_invite.go +++ b/api/v1/server/handlers/users/reject_invite.go @@ -62,5 +62,12 @@ func (u *UserService) TenantInviteReject(ctx echo.Context, request gen.TenantInv return nil, err } + u.config.Analytics.Enqueue( + "user-invite:accept", + user.ID, + &invite.TenantID, + nil, + ) + return nil, nil } diff --git a/go.mod b/go.mod index a5d0e4246..83fc3cb7a 100644 --- a/go.mod +++ b/go.mod @@ -73,6 +73,7 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/posthog/posthog-go v0.0.0-20240327112532-87b23fe11103 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect diff --git a/go.sum b/go.sum index 2350d6f91..99c3421b3 100644 --- a/go.sum +++ b/go.sum @@ -212,6 +212,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posthog/posthog-go v0.0.0-20240327112532-87b23fe11103 h1:YEWdfKVtz5Db85b8RLIZ1IY3PLSB1fW49hvK2yIL6JU= +github.com/posthog/posthog-go v0.0.0-20240327112532-87b23fe11103/go.mod h1:QjlpryJtfYLrZF2GUkAhejH4E7WlDbdKkvOi5hLmkdg= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rabbitmq/amqp091-go v1.9.0 h1:qrQtyzB4H8BQgEuJwhmVQqVHB9O4+MNDJCCAcpc3Aoo= github.com/rabbitmq/amqp091-go v1.9.0/go.mod h1:+jPrT9iY2eLjRaMSRHUhc3z14E/l85kv/f+6luSD3pc= diff --git a/internal/config/loader/loader.go b/internal/config/loader/loader.go index d10c6f0ef..5ad19b536 100644 --- a/internal/config/loader/loader.go +++ b/internal/config/loader/loader.go @@ -32,6 +32,8 @@ import ( "github.com/hatchet-dev/hatchet/internal/repository/prisma/db" "github.com/hatchet-dev/hatchet/internal/services/ingestor" "github.com/hatchet-dev/hatchet/internal/validator" + "github.com/hatchet-dev/hatchet/pkg/analytics" + "github.com/hatchet-dev/hatchet/pkg/analytics/posthog" "github.com/hatchet-dev/hatchet/pkg/client" "github.com/hatchet-dev/hatchet/pkg/errors" "github.com/hatchet-dev/hatchet/pkg/errors/sentry" @@ -216,6 +218,21 @@ func GetServerConfigFromConfigfile(dc *database.Config, cf *server.ServerConfigF alerter = errors.NoOpAlerter{} } + var analyticsEmitter analytics.Analytics + + if cf.Analytics.Posthog.Enabled { + analyticsEmitter, err = posthog.NewPosthogAnalytics(&posthog.PosthogAnalyticsOpts{ + ApiKey: cf.Analytics.Posthog.ApiKey, + Endpoint: cf.Analytics.Posthog.Endpoint, + }) + + if err != nil { + return nil, nil, fmt.Errorf("could not create posthog analytics: %w", err) + } + } else { + analyticsEmitter = analytics.NoOpAnalytics{} // TODO + } + auth := server.AuthConfig{ ConfigFile: cf.Auth, } @@ -347,6 +364,7 @@ func GetServerConfigFromConfigfile(dc *database.Config, cf *server.ServerConfigF return cleanup, &server.ServerConfig{ Alerter: alerter, + Analytics: analyticsEmitter, Runtime: cf.Runtime, Auth: auth, Encryption: encryptionSvc, diff --git a/internal/config/server/server.go b/internal/config/server/server.go index 39242c06d..378520652 100644 --- a/internal/config/server/server.go +++ b/internal/config/server/server.go @@ -17,6 +17,7 @@ import ( "github.com/hatchet-dev/hatchet/internal/msgqueue" "github.com/hatchet-dev/hatchet/internal/services/ingestor" "github.com/hatchet-dev/hatchet/internal/validator" + "github.com/hatchet-dev/hatchet/pkg/analytics" "github.com/hatchet-dev/hatchet/pkg/client" "github.com/hatchet-dev/hatchet/pkg/errors" ) @@ -26,6 +27,8 @@ type ServerConfigFile struct { Alerting AlertingConfigFile `mapstructure:"alerting" json:"alerting,omitempty"` + Analytics AnalyticsConfigFile `mapstructure:"analytics" json:"analytics,omitempty"` + Encryption EncryptionConfigFile `mapstructure:"encryption" json:"encryption,omitempty"` Runtime ConfigFileRuntime `mapstructure:"runtime" json:"runtime,omitempty"` @@ -86,6 +89,21 @@ type SentryConfigFile struct { Environment string `mapstructure:"environment" json:"environment,omitempty" default:"development"` } +type AnalyticsConfigFile struct { + Posthog PosthogConfigFile `mapstructure:"posthog" json:"posthog,omitempty"` +} + +type PosthogConfigFile struct { + // Enabled controls whether the Posthog service is enabled for this Hatchet instance. + Enabled bool `mapstructure:"enabled" json:"enabled,omitempty"` + + // APIKey is the API key for the Posthog instance + ApiKey string `mapstructure:"apiKey" json:"apiKey,omitempty"` + + // Endpoint is the endpoint for the Posthog instance + Endpoint string `mapstructure:"endpoint" json:"endpoint,omitempty"` +} + // Encryption options type EncryptionConfigFile struct { // MasterKeyset is the raw master keyset for the instance. This should be a base64-encoded JSON string. You must set @@ -213,6 +231,8 @@ type ServerConfig struct { Alerter errors.Alerter + Analytics analytics.Analytics + Encryption encryption.EncryptionService Runtime ConfigFileRuntime @@ -267,6 +287,11 @@ func BindAllEnv(v *viper.Viper) { _ = v.BindEnv("alerting.sentry.dsn", "SERVER_ALERTING_SENTRY_DSN") _ = v.BindEnv("alerting.sentry.environment", "SERVER_ALERTING_SENTRY_ENVIRONMENT") + // analytics options + _ = v.BindEnv("analytics.posthog.enabled", "SERVER_ANALYTICS_POSTHOG_ENABLED") + _ = v.BindEnv("analytics.posthog.apiKey", "SERVER_ANALYTICS_POSTHOG_API_KEY") + _ = v.BindEnv("analytics.posthog.endpoint", "SERVER_ANALYTICS_POSTHOG_ENDPOINT") + // encryption options _ = v.BindEnv("encryption.masterKeyset", "SERVER_ENCRYPTION_MASTER_KEYSET") _ = v.BindEnv("encryption.masterKeysetFile", "SERVER_ENCRYPTION_MASTER_KEYSET_FILE") diff --git a/internal/services/grpc/server.go b/internal/services/grpc/server.go index 2a78dc317..36c546878 100644 --- a/internal/services/grpc/server.go +++ b/internal/services/grpc/server.go @@ -28,6 +28,7 @@ import ( "github.com/hatchet-dev/hatchet/internal/services/grpc/middleware" "github.com/hatchet-dev/hatchet/internal/services/ingestor" eventcontracts "github.com/hatchet-dev/hatchet/internal/services/ingestor/contracts" + "github.com/hatchet-dev/hatchet/pkg/analytics" "github.com/hatchet-dev/hatchet/pkg/errors" ) @@ -38,6 +39,7 @@ type Server struct { l *zerolog.Logger a errors.Alerter + analytics analytics.Analytics port int bindAddress string @@ -55,6 +57,7 @@ type ServerOpts struct { config *server.ServerConfig l *zerolog.Logger a errors.Alerter + analytics analytics.Analytics port int bindAddress string ingestor ingestor.Ingestor @@ -67,10 +70,11 @@ type ServerOpts struct { func defaultServerOpts() *ServerOpts { logger := logger.NewDefaultLogger("grpc") a := errors.NoOpAlerter{} - + analytics := analytics.NoOpAnalytics{} return &ServerOpts{ l: &logger, a: a, + analytics: analytics, port: 7070, bindAddress: "127.0.0.1", insecure: false, @@ -89,6 +93,12 @@ func WithAlerter(a errors.Alerter) ServerOpt { } } +func WithAnalytics(a analytics.Analytics) ServerOpt { + return func(opts *ServerOpts) { + opts.analytics = a + } +} + func WithBindAddress(bindAddress string) ServerOpt { return func(opts *ServerOpts) { opts.bindAddress = bindAddress @@ -158,6 +168,7 @@ func NewServer(fs ...ServerOpt) (*Server, error) { return &Server{ l: opts.l, a: opts.a, + analytics: opts.analytics, config: opts.config, port: opts.port, bindAddress: opts.bindAddress, diff --git a/pkg/analytics/analytics.go b/pkg/analytics/analytics.go new file mode 100644 index 000000000..8bafc4635 --- /dev/null +++ b/pkg/analytics/analytics.go @@ -0,0 +1,12 @@ +package analytics + +type Analytics interface { + Enqueue(event string, userId string, tenantId *string, data map[string]interface{}) + Tenant(tenantId string, data map[string]interface{}) +} + +type NoOpAnalytics struct{} + +func (a NoOpAnalytics) Enqueue(event string, userId string, tenantId *string, data map[string]interface{}) { +} +func (a NoOpAnalytics) Tenant(tenantId string, data map[string]interface{}) {} diff --git a/pkg/analytics/posthog/posthog.go b/pkg/analytics/posthog/posthog.go new file mode 100644 index 000000000..aac774b52 --- /dev/null +++ b/pkg/analytics/posthog/posthog.go @@ -0,0 +1,65 @@ +package posthog + +import ( + "fmt" + + "github.com/posthog/posthog-go" +) + +type PosthogAnalytics struct { + client *posthog.Client +} + +type PosthogAnalyticsOpts struct { + ApiKey string + Endpoint string +} + +func NewPosthogAnalytics(opts *PosthogAnalyticsOpts) (*PosthogAnalytics, error) { + if opts.ApiKey == "" || opts.Endpoint == "" { + return nil, fmt.Errorf("api key and endpoint are required if posthog is enabled") + } + + phClient, err := posthog.NewWithConfig( + opts.ApiKey, + posthog.Config{ + Endpoint: opts.Endpoint, + }, + ) + + if err != nil { + return nil, fmt.Errorf("failed to create posthog client: %w", err) + } + + return &PosthogAnalytics{ + client: &phClient, + }, nil +} + +func (p *PosthogAnalytics) Enqueue(event string, userId string, tenantId *string, data map[string]interface{}) { + + var group posthog.Groups + + if tenantId != nil { + group = posthog.NewGroups().Set("tenant", *tenantId) + } + + var _ = (*p.client).Enqueue(posthog.Capture{ + DistinctId: userId, + Event: event, + Properties: map[string]interface{}{ + "$set": data, + }, + Groups: group, + }) +} + +func (p *PosthogAnalytics) Tenant(tenantId string, data map[string]interface{}) { + var _ = (*p.client).Enqueue(posthog.GroupIdentify{ + Type: "tenant", + Key: tenantId, + Properties: map[string]interface{}{ + "$set": data, + }, + }) +}