Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions cmd/backend/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/database-playground/backend-v2/internal/workers"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/posthog/posthog-go"
"github.com/redis/rueidis"
"github.com/vektah/gqlparser/v2/ast"
"go.uber.org/fx"
Expand All @@ -50,6 +51,31 @@ func ApqCache(redisClient rueidis.Client) graphql.Cache[string] {
return apq.NewCache(redisClient, 24*time.Hour)
}

func PostHogClient(lifecycle fx.Lifecycle, cfg config.Config) (posthog.Client, error) {
if cfg.PostHog.APIKey == nil || cfg.PostHog.Host == nil {
slog.Warn("PostHog client is not initialized, because you did not configure a PostHog API key and a host.")
return nil, nil
}

client, err := posthog.NewWithConfig(
*cfg.PostHog.APIKey,
posthog.Config{
Endpoint: *cfg.PostHog.Host,
},
)
if err != nil {
return nil, err
}

lifecycle.Append(fx.StopHook(func() {
if err := client.Close(); err != nil {
slog.Info("failed to close PostHog client", "error", err)
}
}))

return client, nil
}

// GqlgenHandler creates a gqlgen handler.
func GqlgenHandler(
entClient *ent.Client,
Expand Down Expand Up @@ -85,8 +111,8 @@ func UserAccountContext(entClient *ent.Client, storage auth.Storage, eventServic
}

// EventService creates an events.EventService.
func EventService(entClient *ent.Client) *events.EventService {
return events.NewEventService(entClient)
func EventService(entClient *ent.Client, posthogClient posthog.Client) *events.EventService {
return events.NewEventService(entClient, posthogClient)
}

// SubmissionService creates a submission.SubmissionService.
Expand Down
1 change: 1 addition & 0 deletions cmd/backend/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func main() {
EventService,
SubmissionService,
ApqCache,
PostHogClient,
AnnotateService(AuthService),
GqlgenHandler,
fx.Annotate(
Expand Down
10 changes: 10 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,13 @@ Google OAuth 的「已授權的重新導向 URI」應包含 `https://HOST/api/au
## SQL Runner

- `SQL_RUNNER_URI`:[SQL Runner API](https://github.com/database-playground/sqlrunner-v2) 的連線 URL,如 `https://sqlrunner.dbplay.app`。部署說明可參見 [Usage > Starting the service](https://github.com/database-playground/sqlrunner-v2/tree/main?tab=readme-ov-file#starting-the-service)。

## PostHog 設定

PostHog 是一個產品統計平台。這個專案使用 [posthog-go](https://posthog.com/docs/libraries/go) 做後端的 event 寫入。

如果不填寫 API Key 則代表不送出任何統計。

- `POSTHOG_API_KEY`: PostHog 的 API key。可以在 PostHog 的 Settings > Project > General > Project API key 中取得。
- `POSTHOG_HOST`: PostHog API 的主機。可以在 PostHog 的 Settings > Project > General > Web snippet 中的 `api_host` 取得。
- e.g. `https://us.i.posthog.com`
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (
github.com/jackc/pgx/v5 v5.7.6
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.28
github.com/posthog/posthog-go v1.6.10
github.com/redis/rueidis v1.0.66
github.com/samber/lo v1.51.0
github.com/stretchr/testify v1.11.1
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posthog/posthog-go v1.6.10 h1:OA6bkiUg89rI7f5cSXbcrH5+wLinyS6hHplnD92Pu/M=
github.com/posthog/posthog-go v1.6.10/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
Expand Down
2 changes: 1 addition & 1 deletion graph/user.resolvers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func (m *mockAuthStorage) Peek(ctx context.Context, token string) (auth.TokenInf
func NewTestResolver(t *testing.T, entClient *ent.Client, authStorage auth.Storage) *Resolver {
t.Helper()

eventService := events.NewEventService(entClient)
eventService := events.NewEventService(entClient, nil)
sqlrunner := testhelper.NewSQLRunnerClient(t)

submissionService := submission.NewSubmissionService(entClient, eventService, sqlrunner)
Expand Down
2 changes: 1 addition & 1 deletion httpapi/auth/introspect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func setupTestAuthServiceWithDatabase(t *testing.T) (*AuthService, *mockAuthStor

storage := newMockAuthStorageForIntrospect()
cfg := config.Config{}
eventService := events.NewEventService(entClient)
eventService := events.NewEventService(entClient, nil)
useraccount := useraccount.NewContext(entClient, storage, eventService)

authService := NewAuthService(entClient, storage, cfg, useraccount)
Expand Down
2 changes: 1 addition & 1 deletion httpapi/auth/revoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func setupTestAuthService(t *testing.T) (*AuthService, *mockAuthStorage) {
entClient := testhelper.NewEntSqliteClient(t)
storage := newMockAuthStorage()
cfg := config.Config{}
eventService := events.NewEventService(entClient)
eventService := events.NewEventService(entClient, nil)
useraccount := useraccount.NewContext(entClient, storage, eventService)

authService := NewAuthService(entClient, storage, cfg, useraccount)
Expand Down
24 changes: 24 additions & 0 deletions internal/config/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type Config struct {
GAuth GAuthConfig `envPrefix:"GAUTH_"`
Server ServerConfig `envPrefix:"SERVER_"`
SqlRunner SqlRunnerConfig `envPrefix:"SQL_RUNNER_"`
PostHog PostHogConfig `envPrefix:"POSTHOG_"`
}

func (c Config) Validate() error {
Expand All @@ -32,6 +33,12 @@ func (c Config) Validate() error {
if err := c.Server.Validate(); err != nil {
return fmt.Errorf("SERVER: %w", err)
}
if err := c.SqlRunner.Validate(); err != nil {
return fmt.Errorf("SQL_RUNNER: %w", err)
}
if err := c.PostHog.Validate(); err != nil {
return fmt.Errorf("POSTHOG: %w", err)
}

return nil
}
Expand Down Expand Up @@ -147,3 +154,20 @@ func (c SqlRunnerConfig) Validate() error {

return nil
}

type PostHogConfig struct {
APIKey *string `env:"API_KEY"`
Host *string `env:"HOST"`
}

func (c PostHogConfig) Validate() error {
if c.APIKey != nil && *c.APIKey == "" {
return errors.New("POSTHOG_API_KEY cannot be empty")
}

if c.Host != nil && *c.Host == "" {
return errors.New("POSTHOG_HOST cannot be empty")
}

return nil
}
3 changes: 3 additions & 0 deletions internal/events/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,7 @@ const (
EventTypeLogoutAll EventType = "logout_all"

EventTypeSubmitAnswer EventType = "submit_answer"

// Internal usage
EventTypeGrantPoint EventType = "grant_point"
)
22 changes: 18 additions & 4 deletions internal/events/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,27 @@ package events
import (
"context"
"log/slog"
"strconv"
"time"

"github.com/database-playground/backend-v2/ent"
"github.com/posthog/posthog-go"
)

// EventService is the service for triggering events.
type EventService struct {
entClient *ent.Client
entClient *ent.Client
posthogClient posthog.Client

handlers []EventHandler
}

// NewEventService creates a new EventService.
func NewEventService(entClient *ent.Client) *EventService {
func NewEventService(entClient *ent.Client, posthogClient posthog.Client) *EventService {
return &EventService{
entClient: entClient,
handlers: []EventHandler{NewPointsGranter(entClient)},
entClient: entClient,
posthogClient: posthogClient,
handlers: []EventHandler{NewPointsGranter(entClient, posthogClient)},
}
}

Expand All @@ -43,6 +47,16 @@ func (s *EventService) TriggerEvent(ctx context.Context, event Event) {
if err != nil {
slog.Error("failed to trigger event", "error", err)
}

if s.posthogClient != nil {
slog.Debug("sending event to PostHog", "event_type", event.Type, "user_id", event.UserID)
s.posthogClient.Enqueue(posthog.Capture{
DistinctId: strconv.Itoa(event.UserID),
Event: string(event.Type),
Timestamp: time.Now(),
Properties: event.Payload,
})
}
}

// triggerEvent triggers an event synchronously.
Expand Down
84 changes: 52 additions & 32 deletions internal/events/points.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"strconv"
"time"

"github.com/database-playground/backend-v2/ent"
Expand All @@ -12,6 +13,7 @@ import (
"github.com/database-playground/backend-v2/ent/question"
"github.com/database-playground/backend-v2/ent/submission"
"github.com/database-playground/backend-v2/ent/user"
"github.com/posthog/posthog-go"
)

// startOfDay returns the start of the given day (midnight).
Expand Down Expand Up @@ -45,13 +47,15 @@ const (

// PointsGranter determines if the criteria is met to grant points to a user.
type PointsGranter struct {
entClient *ent.Client
entClient *ent.Client
posthogClient posthog.Client
}

// NewPointsGranter creates a new PointsGranter.
func NewPointsGranter(entClient *ent.Client) *PointsGranter {
func NewPointsGranter(entClient *ent.Client, posthogClient posthog.Client) *PointsGranter {
return &PointsGranter{
entClient: entClient,
entClient: entClient,
posthogClient: posthogClient,
}
}

Expand Down Expand Up @@ -171,11 +175,7 @@ func (d *PointsGranter) GrantDailyLoginPoints(ctx context.Context, userID int) (
}

// Grant the "daily login" points to the user.
err = d.entClient.Point.Create().
SetUserID(userID).
SetDescription(PointDescriptionDailyLogin).
SetPoints(PointValueDailyLogin).
Exec(ctx)
err = d.grantPoint(ctx, userID, 0, PointDescriptionDailyLogin, PointValueDailyLogin)
if err != nil {
return false, err
}
Expand Down Expand Up @@ -221,14 +221,11 @@ func (d *PointsGranter) GrantWeeklyLoginPoints(ctx context.Context, userID int)
}

// Grant the "weekly login" points to the user.
err = d.entClient.Point.Create().
SetUserID(userID).
SetDescription(PointDescriptionWeeklyLogin).
SetPoints(PointValueWeeklyLogin).
Exec(ctx)
err = d.grantPoint(ctx, userID, 0, PointDescriptionWeeklyLogin, PointValueWeeklyLogin)
if err != nil {
return false, err
}

return true, nil
}

Expand Down Expand Up @@ -261,11 +258,7 @@ func (d *PointsGranter) GrantFirstAttemptPoints(ctx context.Context, userID int,
}

// Grant the "first attempt" points to the user.
err = d.entClient.Point.Create().
SetUserID(userID).
SetDescription(description).
SetPoints(PointValueFirstAttempt).
Exec(ctx)
err = d.grantPoint(ctx, userID, questionID, description, PointValueFirstAttempt)
if err != nil {
return false, err
}
Expand Down Expand Up @@ -305,11 +298,7 @@ func (d *PointsGranter) GrantDailyAttemptPoints(ctx context.Context, userID int)
}

// Grant the "daily attempt" points to the user.
err = d.entClient.Point.Create().
SetUserID(userID).
SetDescription(PointDescriptionDailyAttempt).
SetPoints(PointValueDailyAttempt).
Exec(ctx)
err = d.grantPoint(ctx, userID, 0, PointDescriptionDailyAttempt, PointValueDailyAttempt)
if err != nil {
return false, err
}
Expand Down Expand Up @@ -347,11 +336,7 @@ func (d *PointsGranter) GrantCorrectAnswerPoints(ctx context.Context, userID int
}

// Grant the "correct answer" points to the user.
err = d.entClient.Point.Create().
SetUserID(userID).
SetDescription(description).
SetPoints(PointValueCorrectAnswer).
Exec(ctx)
err = d.grantPoint(ctx, userID, questionID, description, PointValueCorrectAnswer)
if err != nil {
return false, err
}
Expand Down Expand Up @@ -397,14 +382,49 @@ func (d *PointsGranter) GrantFirstPlacePoints(ctx context.Context, userID int, q
}

// Grant the "first place" points to the user.
err = d.entClient.Point.Create().
err = d.grantPoint(ctx, userID, questionID, description, PointValueFirstPlace)
if err != nil {
return false, err
}

return true, nil
}

func (d *PointsGranter) grantPoint(ctx context.Context, userID int, questionID int, description string, points int) error {
err := d.entClient.Point.Create().
SetUserID(userID).
SetDescription(description).
SetPoints(PointValueFirstPlace).
SetPoints(points).
Exec(ctx)
if err != nil {
return false, err
if d.posthogClient != nil {
d.posthogClient.Enqueue(posthog.NewDefaultException(
time.Now(), strconv.Itoa(userID),
"failed to grant point", err.Error(),
))
}

return err
}

return true, nil
if d.posthogClient != nil {
properties := posthog.NewProperties().
Set("description", description).
Set("points", points)

if questionID != 0 {
properties.Set("questionID", strconv.Itoa(questionID))
}

slog.Debug("sending event to PostHog", "event_type", EventTypeGrantPoint, "user_id", userID)

d.posthogClient.Enqueue(posthog.Capture{
DistinctId: strconv.Itoa(userID),
Event: string(EventTypeGrantPoint),
Timestamp: time.Now(),
Properties: properties,
})
}

return nil
}
Loading