diff --git a/backend/cmd/analytics/main.go b/backend/cmd/analytics/main.go new file mode 100644 index 000000000..8ea792438 --- /dev/null +++ b/backend/cmd/analytics/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "context" + analyticsConfig "openreplay/backend/internal/config/analytics" + "openreplay/backend/pkg/analytics" + "openreplay/backend/pkg/db/postgres/pool" + "openreplay/backend/pkg/logger" + "openreplay/backend/pkg/metrics" + analyticsMetrics "openreplay/backend/pkg/metrics/analytics" + databaseMetrics "openreplay/backend/pkg/metrics/database" + "openreplay/backend/pkg/metrics/web" + "openreplay/backend/pkg/server" + "openreplay/backend/pkg/server/api" +) + +func main() { + ctx := context.Background() + log := logger.New() + cfg := analyticsConfig.New(log) + webMetrics := web.New("analytics") + metrics.New(log, append(webMetrics.List(), append(analyticsMetrics.List(), databaseMetrics.List()...)...)) + + pgConn, err := pool.New(cfg.Postgres.String()) + if err != nil { + log.Fatal(ctx, "can't init postgres connection: %s", err) + } + defer pgConn.Close() + + builder, err := analytics.NewServiceBuilder(log, cfg, webMetrics, pgConn) + if err != nil { + log.Fatal(ctx, "can't init services: %s", err) + } + + router, err := api.NewRouter(&cfg.HTTP, log) + if err != nil { + log.Fatal(ctx, "failed while creating router: %s", err) + } + router.AddHandlers(api.NoPrefix, builder.AnalyticsAPI) + router.AddMiddlewares(builder.Auth.Middleware, builder.RateLimiter.Middleware, builder.AuditTrail.Middleware) + + server.Run(ctx, log, &cfg.HTTP, router) +} diff --git a/backend/internal/config/analytics/config.go b/backend/internal/config/analytics/config.go new file mode 100644 index 000000000..b6ca5ce4c --- /dev/null +++ b/backend/internal/config/analytics/config.go @@ -0,0 +1,29 @@ +package analytics + +import ( + "time" + + "openreplay/backend/internal/config/common" + "openreplay/backend/internal/config/configurator" + "openreplay/backend/internal/config/objectstorage" + "openreplay/backend/internal/config/redis" + "openreplay/backend/pkg/env" + "openreplay/backend/pkg/logger" +) + +type Config struct { + common.Config + common.Postgres + redis.Redis + objectstorage.ObjectsConfig + common.HTTP + FSDir string `env:"FS_DIR,required"` + ProjectExpiration time.Duration `env:"PROJECT_EXPIRATION,default=10m"` + WorkerID uint16 +} + +func New(log logger.Logger) *Config { + cfg := &Config{WorkerID: env.WorkerID()} + configurator.Process(log, cfg) + return cfg +} diff --git a/backend/pkg/analytics/api/dashboard-handlers.go b/backend/pkg/analytics/api/dashboard-handlers.go new file mode 100644 index 000000000..ca8e22ba2 --- /dev/null +++ b/backend/pkg/analytics/api/dashboard-handlers.go @@ -0,0 +1,205 @@ +package api + +import ( + "encoding/json" + "fmt" + "github.com/gorilla/mux" + "net/http" + "openreplay/backend/pkg/server/api" + "openreplay/backend/pkg/server/user" + "strconv" + "time" +) + +func getId(r *http.Request) (int, error) { + vars := mux.Vars(r) + idStr := vars["id"] + if idStr == "" { + return 0, fmt.Errorf("invalid dashboard ID") + } + + id, err := strconv.Atoi(idStr) + if err != nil { + return 0, fmt.Errorf("invalid dashboard ID") + } + + return id, nil +} + +func (e *handlersImpl) createDashboard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + bodyBytes, err := api.ReadBody(e.log, w, r, e.jsonSizeLimit) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize) + return + } + bodySize = len(bodyBytes) + + req := &CreateDashboardRequest{} + if err := json.Unmarshal(bodyBytes, req); err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + resp := &GetDashboardResponse{ + Dashboard: Dashboard{ + DashboardID: 1, + Name: req.Name, + Description: req.Description, + IsPublic: req.IsPublic, + IsPinned: req.IsPinned, + }, + } + + currentUser := r.Context().Value("userData").(*user.User) + e.log.Info(r.Context(), "User ID: ", currentUser.ID) + + e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) +} + +// getDashboards +func (e *handlersImpl) getDashboards(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + //id, err := getId(r) + //if err != nil { + // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + // return + //} + + resp := &GetDashboardsResponse{ + Dashboards: []Dashboard{ + { + DashboardID: 1, + Name: "Dashboard", + Description: "Description", + IsPublic: true, + IsPinned: false, + }, + }, + Total: 1, + } + + e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) +} + +func (e *handlersImpl) getDashboard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + id, err := getId(r) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + resp := &GetDashboardResponse{ + Dashboard: Dashboard{ + DashboardID: id, + Name: "Dashboard", + Description: "Description", + IsPublic: true, + IsPinned: false, + }, + } + + e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) +} + +func (e *handlersImpl) updateDashboard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + //id, err := getId(r) + //if err != nil { + // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + // return + //} + + bodyBytes, err := api.ReadBody(e.log, w, r, e.jsonSizeLimit) + if err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize) + return + } + bodySize = len(bodyBytes) + + req := &UpdateDashboardRequest{} + if err := json.Unmarshal(bodyBytes, req); err != nil { + e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + resp := &GetDashboardResponse{ + Dashboard: Dashboard{ + DashboardID: 1, + Name: req.Name, + Description: req.Description, + IsPublic: req.IsPublic, + IsPinned: req.IsPinned, + }, + } + + e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize) +} + +func (e *handlersImpl) deleteDashboard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + //id, err := getId(r) + //if err != nil { + // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + // return + //} + e.log.Info(r.Context(), "Dashboard deleted") + + e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize) +} + +func (e *handlersImpl) pinDashboard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + //id, err := getId(r) + //if err != nil { + // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + // return + //} + + e.log.Info(r.Context(), "Dashboard pinned") + + e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize) +} + +// add card to dashboard +func (e *handlersImpl) addCardToDashboard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + //id, err := getId(r) + //if err != nil { + // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + // return + //} + + e.log.Info(r.Context(), "Card added to dashboard") + + e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize) +} + +// remove card from dashboard +func (e *handlersImpl) removeCardFromDashboard(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + //id, err := getId(r) + //if err != nil { + // e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + // return + //} + + e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize) +} diff --git a/backend/pkg/analytics/api/handlers.go b/backend/pkg/analytics/api/handlers.go new file mode 100644 index 000000000..05ee6dbfc --- /dev/null +++ b/backend/pkg/analytics/api/handlers.go @@ -0,0 +1,40 @@ +package api + +import ( + config "openreplay/backend/internal/config/analytics" + "openreplay/backend/pkg/analytics/service" + "openreplay/backend/pkg/logger" + "openreplay/backend/pkg/objectstorage" + "openreplay/backend/pkg/server/api" + "openreplay/backend/pkg/server/keys" +) + +type handlersImpl struct { + log logger.Logger + responser *api.Responser + objStorage objectstorage.ObjectStorage + jsonSizeLimit int64 + keys keys.Keys + service service.Service +} + +func (e *handlersImpl) GetAll() []*api.Description { + return []*api.Description{ + {"/v1/analytics/{projectId}/dashboards", e.createDashboard, "POST"}, + {"/v1/analytics/{projectId}/dashboards", e.getDashboards, "GET"}, + {"/v1/analytics/{projectId}/dashboards/{id}", e.getDashboard, "GET"}, + {"/v1/analytics/{projectId}/dashboards/{id}", e.updateDashboard, "PUT"}, + {"/v1/analytics/{projectId}/dashboards/{id}", e.deleteDashboard, "DELETE"}, + } +} + +func NewHandlers(log logger.Logger, cfg *config.Config, responser *api.Responser, objStore objectstorage.ObjectStorage, keys keys.Keys, service service.Service) (api.Handlers, error) { + return &handlersImpl{ + log: log, + responser: responser, + objStorage: objStore, + jsonSizeLimit: cfg.JsonSizeLimit, + keys: keys, + service: service, + }, nil +} diff --git a/backend/pkg/analytics/api/model.go b/backend/pkg/analytics/api/model.go new file mode 100644 index 000000000..3342a4b81 --- /dev/null +++ b/backend/pkg/analytics/api/model.go @@ -0,0 +1,60 @@ +package api + +type Dashboard struct { + DashboardID int `json:"dashboard_id"` + Name string `json:"name"` + Description string `json:"description"` + IsPublic bool `json:"is_public"` + IsPinned bool `json:"is_pinned"` +} + +type CreateDashboardResponse struct { + DashboardID int `json:"dashboard_id"` +} + +type GetDashboardResponse struct { + Dashboard +} + +type GetDashboardsResponse struct { + Dashboards []Dashboard `json:"dashboards"` + Total uint64 `json:"total"` +} + +// REQUESTS + +type CreateDashboardRequest struct { + Name string `json:"name"` + Description string `json:"description"` + IsPublic bool `json:"is_public"` + IsPinned bool `json:"is_pinned"` + Metrics []int `json:"metrics"` +} + +type GetDashboardsRequest struct { + Page uint64 `json:"page"` + Limit uint64 `json:"limit"` + Order string `json:"order"` + Query string `json:"query"` + FilterBy string `json:"filterBy"` +} + +type UpdateDashboardRequest struct { + Name string `json:"name"` + Description string `json:"description"` + IsPublic bool `json:"is_public"` + IsPinned bool `json:"is_pinned"` + Metrics []int `json:"metrics"` +} + +type PinDashboardRequest struct { + IsPinned bool `json:"is_pinned"` +} + +type AddCardToDashboardRequest struct { + CardIDs []int `json:"card_ids"` +} + +type DeleteCardFromDashboardRequest struct { + CardIDs []int `json:"card_ids"` +} diff --git a/backend/pkg/analytics/builder.go b/backend/pkg/analytics/builder.go new file mode 100644 index 000000000..333921e0a --- /dev/null +++ b/backend/pkg/analytics/builder.go @@ -0,0 +1,57 @@ +package analytics + +import ( + "openreplay/backend/pkg/metrics/web" + "openreplay/backend/pkg/server/tracer" + "time" + + "openreplay/backend/internal/config/analytics" + analyticsAPI "openreplay/backend/pkg/analytics/api" + "openreplay/backend/pkg/analytics/service" + "openreplay/backend/pkg/db/postgres/pool" + "openreplay/backend/pkg/logger" + "openreplay/backend/pkg/objectstorage/store" + "openreplay/backend/pkg/server/api" + "openreplay/backend/pkg/server/auth" + "openreplay/backend/pkg/server/keys" + "openreplay/backend/pkg/server/limiter" +) + +type ServicesBuilder struct { + Auth auth.Auth + RateLimiter *limiter.UserRateLimiter + AuditTrail tracer.Tracer + AnalyticsAPI api.Handlers +} + +func NewServiceBuilder(log logger.Logger, cfg *analytics.Config, webMetrics web.Web, pgconn pool.Pool) (*ServicesBuilder, error) { + objStore, err := store.NewStore(&cfg.ObjectsConfig) + if err != nil { + return nil, err + } + + newKeys := keys.NewKeys(log, pgconn) + responser := api.NewResponser(webMetrics) + + audiTrail, err := tracer.NewTracer(log, pgconn) + if err != nil { + return nil, err + } + + analyticsService, err := service.NewService(log, pgconn, objStore) + if err != nil { + return nil, err + } + + handlers, err := analyticsAPI.NewHandlers(log, cfg, responser, objStore, keys.NewKeys(log, pgconn), analyticsService) + if err != nil { + return nil, err + } + + return &ServicesBuilder{ + Auth: auth.NewAuth(log, cfg.JWTSecret, cfg.JWTSpotSecret, pgconn, newKeys), + RateLimiter: limiter.NewUserRateLimiter(10, 30, 1*time.Minute, 5*time.Minute), + AuditTrail: audiTrail, + AnalyticsAPI: handlers, + }, nil +} diff --git a/backend/pkg/analytics/service/analytics.go b/backend/pkg/analytics/service/analytics.go new file mode 100644 index 000000000..ce36b0958 --- /dev/null +++ b/backend/pkg/analytics/service/analytics.go @@ -0,0 +1,34 @@ +package service + +import ( + "errors" + "openreplay/backend/pkg/db/postgres/pool" + "openreplay/backend/pkg/logger" + "openreplay/backend/pkg/objectstorage" +) + +type Service interface { +} + +type serviceImpl struct { + log logger.Logger + conn pool.Pool + storage objectstorage.ObjectStorage +} + +func NewService(log logger.Logger, conn pool.Pool, storage objectstorage.ObjectStorage) (Service, error) { + switch { + case log == nil: + return nil, errors.New("logger is empty") + case conn == nil: + return nil, errors.New("connection pool is empty") + case storage == nil: + return nil, errors.New("object storage is empty") + } + + return &serviceImpl{ + log: log, + conn: conn, + storage: storage, + }, nil +} diff --git a/backend/pkg/metrics/analytics/analytics.go b/backend/pkg/metrics/analytics/analytics.go new file mode 100644 index 000000000..7919e77fb --- /dev/null +++ b/backend/pkg/metrics/analytics/analytics.go @@ -0,0 +1,22 @@ +package analytics + +import ( + "github.com/prometheus/client_golang/prometheus" + + "openreplay/backend/pkg/metrics/common" +) + +var cardCreated = prometheus.NewHistogram( + prometheus.HistogramOpts{ + Namespace: "card", + Name: "created", + Help: "Histogram for tracking card creation", + Buckets: common.DefaultBuckets, + }, +) + +func List() []prometheus.Collector { + return []prometheus.Collector{ + cardCreated, + } +}