From f9cb9f10be6cdfa1d9f2cc3e8b8e08be4a249365 Mon Sep 17 00:00:00 2001 From: Shivanshu Raj Shrivastava Date: Mon, 2 Jun 2025 12:30:49 +0530 Subject: [PATCH] feat: adds a part of trace funnel feature (APIs, module, handler, store, migrations) implementation (#7763) * feat: adds server and handler changes Signed-off-by: Shivanshu Raj Shrivastava * feat: add tracefunnel module and handler Signed-off-by: Shivanshu Raj Shrivastava * feat: add required types for tracefunnels Signed-off-by: Shivanshu Raj Shrivastava * feat: db operations, module and handler implementation Signed-off-by: Shivanshu Raj Shrivastava * feat: add db migrations Signed-off-by: Shivanshu Raj Shrivastava * chore: add utility functions Signed-off-by: Shivanshu Raj Shrivastava * test: add utility function tests Signed-off-by: Shivanshu Raj Shrivastava * test: add handler tests Signed-off-by: Shivanshu Raj Shrivastava * test: add trace funnel module tests Signed-off-by: Shivanshu Raj Shrivastava * chore: refactor handler and utils Signed-off-by: Shivanshu Raj Shrivastava * chore: add funnel validation while processing funnel steps Signed-off-by: Shivanshu Raj Shrivastava * test: add more tests to utils Signed-off-by: Shivanshu Raj Shrivastava * chore: fix package naming Signed-off-by: Shivanshu Raj Shrivastava * chore: fix naming convention Signed-off-by: Shivanshu Raj Shrivastava * chore: update normalize funnel steps Signed-off-by: Shivanshu Raj Shrivastava * chore: added some improvements Signed-off-by: Shivanshu Raj Shrivastava * fix: optimize funnel creation by combining insert and update operations Signed-off-by: Shivanshu Raj Shrivastava * chore: fix error handling Signed-off-by: Shivanshu Raj Shrivastava * feat: trace funnel state management Signed-off-by: Shivanshu Raj Shrivastava * fix: updated unit tests and mocks Signed-off-by: Shivanshu Raj Shrivastava * fix: review comments Signed-off-by: Shivanshu Raj Shrivastava * fix: minor fixes Signed-off-by: Shivanshu Raj Shrivastava * fix: update funnel migration number Signed-off-by: Shivanshu Raj Shrivastava * fix: review comments and some changes Signed-off-by: Shivanshu Raj Shrivastava * fix: update modules Signed-off-by: Shivanshu Raj Shrivastava --------- Signed-off-by: Shivanshu Raj Shrivastava --- ee/query-service/app/server.go | 1 + .../tracefunnel/impltracefunnel/handler.go | 235 ++++++ .../impltracefunnel/handler_test.go | 173 +++++ .../tracefunnel/impltracefunnel/module.go | 96 +++ .../tracefunnel/impltracefunnel/store.go | 114 +++ pkg/modules/tracefunnel/tracefunnel.go | 38 + .../tracefunneltest/module_test.go | 183 +++++ pkg/query-service/app/http_handler.go | 27 + pkg/query-service/app/server.go | 1 + .../app/traces/v4/query_builder.go | 6 +- .../app/traces/v4/query_builder_test.go | 10 +- pkg/signoz/handler.go | 4 + pkg/signoz/module.go | 4 + pkg/signoz/provider.go | 1 + pkg/sqlmigration/037_add_trace_funnels.go | 89 +++ pkg/types/tracefunneltypes/store.go | 15 + pkg/types/tracefunneltypes/tracefunnel.go | 98 +++ pkg/types/tracefunneltypes/utils.go | 139 ++++ pkg/types/tracefunneltypes/utils_test.go | 698 ++++++++++++++++++ 19 files changed, 1924 insertions(+), 8 deletions(-) create mode 100644 pkg/modules/tracefunnel/impltracefunnel/handler.go create mode 100644 pkg/modules/tracefunnel/impltracefunnel/handler_test.go create mode 100644 pkg/modules/tracefunnel/impltracefunnel/module.go create mode 100644 pkg/modules/tracefunnel/impltracefunnel/store.go create mode 100644 pkg/modules/tracefunnel/tracefunnel.go create mode 100644 pkg/modules/tracefunnel/tracefunneltest/module_test.go create mode 100644 pkg/sqlmigration/037_add_trace_funnels.go create mode 100644 pkg/types/tracefunneltypes/store.go create mode 100644 pkg/types/tracefunneltypes/tracefunnel.go create mode 100644 pkg/types/tracefunneltypes/utils.go create mode 100644 pkg/types/tracefunneltypes/utils_test.go diff --git a/ee/query-service/app/server.go b/ee/query-service/app/server.go index cda311ec34..2b4d3754c6 100644 --- a/ee/query-service/app/server.go +++ b/ee/query-service/app/server.go @@ -298,6 +298,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h apiHandler.RegisterMessagingQueuesRoutes(r, am) apiHandler.RegisterThirdPartyApiRoutes(r, am) apiHandler.MetricExplorerRoutes(r, am) + apiHandler.RegisterTraceFunnelsRoutes(r, am) c := cors.New(cors.Options{ AllowedOrigins: []string{"*"}, diff --git a/pkg/modules/tracefunnel/impltracefunnel/handler.go b/pkg/modules/tracefunnel/impltracefunnel/handler.go new file mode 100644 index 0000000000..09f4dfe269 --- /dev/null +++ b/pkg/modules/tracefunnel/impltracefunnel/handler.go @@ -0,0 +1,235 @@ +package impltracefunnel + +import ( + "encoding/json" + "net/http" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/http/render" + "github.com/SigNoz/signoz/pkg/modules/tracefunnel" + "github.com/SigNoz/signoz/pkg/types/authtypes" + tf "github.com/SigNoz/signoz/pkg/types/tracefunneltypes" + "github.com/SigNoz/signoz/pkg/valuer" + "github.com/gorilla/mux" +) + +type handler struct { + module tracefunnel.Module +} + +func NewHandler(module tracefunnel.Module) tracefunnel.Handler { + return &handler{module: module} +} + +func (handler *handler) New(rw http.ResponseWriter, r *http.Request) { + var req tf.PostableFunnel + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + render.Error(rw, err) + return + } + + claims, err := authtypes.ClaimsFromContext(r.Context()) + if err != nil { + render.Error(rw, err) + return + } + + funnel, err := handler.module.Create(r.Context(), req.Timestamp, req.Name, valuer.MustNewUUID(claims.UserID), valuer.MustNewUUID(claims.OrgID)) + if err != nil { + render.Error(rw, errors.Newf(errors.TypeInvalidInput, + errors.CodeInvalidInput, + "failed to create funnel: %v", err)) + return + } + + response := tf.ConstructFunnelResponse(funnel, &claims) + render.Success(rw, http.StatusOK, response) +} + +func (handler *handler) UpdateSteps(rw http.ResponseWriter, r *http.Request) { + var req tf.PostableFunnel + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + render.Error(rw, err) + return + } + + claims, err := authtypes.ClaimsFromContext(r.Context()) + if err != nil { + render.Error(rw, err) + return + } + + updatedAt, err := tf.ValidateAndConvertTimestamp(req.Timestamp) + if err != nil { + render.Error(rw, err) + return + } + + funnel, err := handler.module.Get(r.Context(), req.FunnelID, valuer.MustNewUUID(claims.OrgID)) + if err != nil { + render.Error(rw, errors.Newf(errors.TypeInvalidInput, + errors.CodeInvalidInput, + "funnel not found: %v", err)) + return + } + + steps, err := tf.ProcessFunnelSteps(req.Steps) + if err != nil { + render.Error(rw, err) + return + } + + funnel.Steps = steps + funnel.UpdatedAt = updatedAt + funnel.UpdatedBy = claims.UserID + + if req.Name != "" { + funnel.Name = req.Name + } + if req.Description != "" { + funnel.Description = req.Description + } + + if err := handler.module.Update(r.Context(), funnel, valuer.MustNewUUID(claims.UserID)); err != nil { + render.Error(rw, errors.Newf(errors.TypeInvalidInput, + errors.CodeInvalidInput, + "failed to update funnel in database: %v", err)) + return + } + + updatedFunnel, err := handler.module.Get(r.Context(), funnel.ID, valuer.MustNewUUID(claims.OrgID)) + if err != nil { + render.Error(rw, errors.Newf(errors.TypeInvalidInput, + errors.CodeInvalidInput, + "failed to get updated funnel: %v", err)) + return + } + + response := tf.ConstructFunnelResponse(updatedFunnel, &claims) + render.Success(rw, http.StatusOK, response) +} + +func (handler *handler) UpdateFunnel(rw http.ResponseWriter, r *http.Request) { + var req tf.PostableFunnel + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + render.Error(rw, err) + return + } + + claims, err := authtypes.ClaimsFromContext(r.Context()) + if err != nil { + render.Error(rw, err) + return + } + + updatedAt, err := tf.ValidateAndConvertTimestamp(req.Timestamp) + if err != nil { + render.Error(rw, err) + return + } + + vars := mux.Vars(r) + funnelID := vars["funnel_id"] + + funnel, err := handler.module.Get(r.Context(), valuer.MustNewUUID(funnelID), valuer.MustNewUUID(claims.OrgID)) + if err != nil { + render.Error(rw, errors.Newf(errors.TypeInvalidInput, + errors.CodeInvalidInput, + "funnel not found: %v", err)) + return + } + + funnel.UpdatedAt = updatedAt + funnel.UpdatedBy = claims.UserID + + if req.Name != "" { + funnel.Name = req.Name + } + if req.Description != "" { + funnel.Description = req.Description + } + + if err := handler.module.Update(r.Context(), funnel, valuer.MustNewUUID(claims.UserID)); err != nil { + render.Error(rw, errors.Newf(errors.TypeInvalidInput, + errors.CodeInvalidInput, + "failed to update funnel in database: %v", err)) + return + } + + updatedFunnel, err := handler.module.Get(r.Context(), funnel.ID, valuer.MustNewUUID(claims.OrgID)) + if err != nil { + render.Error(rw, errors.Newf(errors.TypeInvalidInput, + errors.CodeInvalidInput, + "failed to get updated funnel: %v", err)) + return + } + + response := tf.ConstructFunnelResponse(updatedFunnel, &claims) + render.Success(rw, http.StatusOK, response) +} + +func (handler *handler) List(rw http.ResponseWriter, r *http.Request) { + claims, err := authtypes.ClaimsFromContext(r.Context()) + if err != nil { + render.Error(rw, err) + return + } + + funnels, err := handler.module.List(r.Context(), valuer.MustNewUUID(claims.OrgID)) + if err != nil { + render.Error(rw, errors.Newf(errors.TypeInvalidInput, + errors.CodeInvalidInput, + "failed to list funnels: %v", err)) + return + } + + var response []tf.GettableFunnel + for _, f := range funnels { + response = append(response, tf.ConstructFunnelResponse(f, &claims)) + } + + render.Success(rw, http.StatusOK, response) +} + +func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + funnelID := vars["funnel_id"] + + claims, err := authtypes.ClaimsFromContext(r.Context()) + + if err != nil { + render.Error(rw, err) + return + } + + funnel, err := handler.module.Get(r.Context(), valuer.MustNewUUID(funnelID), valuer.MustNewUUID(claims.OrgID)) + if err != nil { + render.Error(rw, errors.Newf(errors.TypeInvalidInput, + errors.CodeInvalidInput, + "funnel not found: %v", err)) + return + } + response := tf.ConstructFunnelResponse(funnel, &claims) + render.Success(rw, http.StatusOK, response) +} + +func (handler *handler) Delete(rw http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + funnelID := vars["funnel_id"] + + claims, err := authtypes.ClaimsFromContext(r.Context()) + + if err != nil { + render.Error(rw, err) + return + } + + if err := handler.module.Delete(r.Context(), valuer.MustNewUUID(funnelID), valuer.MustNewUUID(claims.OrgID)); err != nil { + render.Error(rw, errors.Newf(errors.TypeInvalidInput, + errors.CodeInvalidInput, + "failed to delete funnel: %v", err)) + return + } + + render.Success(rw, http.StatusOK, nil) +} diff --git a/pkg/modules/tracefunnel/impltracefunnel/handler_test.go b/pkg/modules/tracefunnel/impltracefunnel/handler_test.go new file mode 100644 index 0000000000..06df5d31f4 --- /dev/null +++ b/pkg/modules/tracefunnel/impltracefunnel/handler_test.go @@ -0,0 +1,173 @@ +package impltracefunnel + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/types/authtypes" + traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunneltypes" + "github.com/SigNoz/signoz/pkg/valuer" + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockModule struct { + mock.Mock +} + +func (m *MockModule) Create(ctx context.Context, timestamp int64, name string, userID valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error) { + args := m.Called(ctx, timestamp, name, userID, orgID) + return args.Get(0).(*traceFunnels.StorableFunnel), args.Error(1) +} + +func (m *MockModule) Get(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error) { + args := m.Called(ctx, funnelID, orgID) + return args.Get(0).(*traceFunnels.StorableFunnel), args.Error(1) +} + +func (m *MockModule) Update(ctx context.Context, funnel *traceFunnels.StorableFunnel, userID valuer.UUID) error { + args := m.Called(ctx, funnel, userID) + return args.Error(0) +} + +func (m *MockModule) List(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.StorableFunnel, error) { + args := m.Called(ctx, orgID) + return args.Get(0).([]*traceFunnels.StorableFunnel), args.Error(1) +} + +func (m *MockModule) Delete(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) error { + args := m.Called(ctx, funnelID, orgID) + return args.Error(0) +} + +func (m *MockModule) Save(ctx context.Context, funnel *traceFunnels.StorableFunnel, userID valuer.UUID, orgID valuer.UUID) error { + args := m.Called(ctx, funnel, userID, orgID) + return args.Error(0) +} + +func (m *MockModule) GetFunnelMetadata(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) (int64, int64, string, error) { + args := m.Called(ctx, funnelID, orgID) + return args.Get(0).(int64), args.Get(1).(int64), args.String(2), args.Error(3) +} + +func TestHandler_List(t *testing.T) { + mockModule := new(MockModule) + handler := NewHandler(mockModule) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/trace-funnels/list", nil) + + orgID := valuer.GenerateUUID() + claims := authtypes.Claims{ + OrgID: orgID.String(), + } + req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), claims)) + + rr := httptest.NewRecorder() + + funnel1ID := valuer.GenerateUUID() + funnel2ID := valuer.GenerateUUID() + expectedFunnels := []*traceFunnels.StorableFunnel{ + { + Identifiable: types.Identifiable{ + ID: funnel1ID, + }, + Name: "funnel-1", + OrgID: orgID, + }, + { + Identifiable: types.Identifiable{ + ID: funnel2ID, + }, + Name: "funnel-2", + OrgID: orgID, + }, + } + + mockModule.On("List", req.Context(), orgID).Return(expectedFunnels, nil) + + handler.List(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var response struct { + Status string `json:"status"` + Data []traceFunnels.GettableFunnel `json:"data"` + } + err := json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "success", response.Status) + assert.Len(t, response.Data, 2) + assert.Equal(t, "funnel-1", response.Data[0].FunnelName) + assert.Equal(t, "funnel-2", response.Data[1].FunnelName) + + mockModule.AssertExpectations(t) +} + +func TestHandler_Get(t *testing.T) { + mockModule := new(MockModule) + handler := NewHandler(mockModule) + + funnelID := valuer.GenerateUUID() + orgID := valuer.GenerateUUID() + req := httptest.NewRequest(http.MethodGet, "/api/v1/trace-funnels/"+funnelID.String(), nil) + req = mux.SetURLVars(req, map[string]string{"funnel_id": funnelID.String()}) + req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), authtypes.Claims{ + OrgID: orgID.String(), + })) + + rr := httptest.NewRecorder() + + expectedFunnel := &traceFunnels.StorableFunnel{ + Identifiable: types.Identifiable{ + ID: funnelID, + }, + Name: "test-funnel", + OrgID: orgID, + } + + mockModule.On("Get", req.Context(), funnelID, orgID).Return(expectedFunnel, nil) + + handler.Get(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var response struct { + Status string `json:"status"` + Data traceFunnels.GettableFunnel `json:"data"` + } + err := json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "success", response.Status) + assert.Equal(t, "test-funnel", response.Data.FunnelName) + assert.Equal(t, expectedFunnel.OrgID.String(), response.Data.OrgID) + + mockModule.AssertExpectations(t) +} + +func TestHandler_Delete(t *testing.T) { + mockModule := new(MockModule) + handler := NewHandler(mockModule) + + funnelID := valuer.GenerateUUID() + orgID := valuer.GenerateUUID() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/trace-funnels/"+funnelID.String(), nil) + req = mux.SetURLVars(req, map[string]string{"funnel_id": funnelID.String()}) + req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), authtypes.Claims{ + OrgID: orgID.String(), + })) + + rr := httptest.NewRecorder() + + mockModule.On("Delete", req.Context(), funnelID, orgID).Return(nil) + + handler.Delete(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + mockModule.AssertExpectations(t) +} diff --git a/pkg/modules/tracefunnel/impltracefunnel/module.go b/pkg/modules/tracefunnel/impltracefunnel/module.go new file mode 100644 index 0000000000..b53ffb42c6 --- /dev/null +++ b/pkg/modules/tracefunnel/impltracefunnel/module.go @@ -0,0 +1,96 @@ +package impltracefunnel + +import ( + "context" + "fmt" + "time" + + "github.com/SigNoz/signoz/pkg/modules/tracefunnel" + "github.com/SigNoz/signoz/pkg/types" + traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunneltypes" + "github.com/SigNoz/signoz/pkg/valuer" +) + +type module struct { + store traceFunnels.FunnelStore +} + +func NewModule(store traceFunnels.FunnelStore) tracefunnel.Module { + return &module{ + store: store, + } +} + +func (module *module) Create(ctx context.Context, timestamp int64, name string, userID valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error) { + funnel := &traceFunnels.StorableFunnel{ + Name: name, + OrgID: orgID, + } + funnel.CreatedAt = time.Unix(0, timestamp*1000000) // Convert to nanoseconds + funnel.CreatedBy = userID.String() + + // Set up the user relationship + funnel.CreatedByUser = &types.User{ + Identifiable: types.Identifiable{ + ID: userID, + }, + } + + if funnel.ID.IsZero() { + funnel.ID = valuer.GenerateUUID() + } + + if funnel.CreatedAt.IsZero() { + funnel.CreatedAt = time.Now() + } + if funnel.UpdatedAt.IsZero() { + funnel.UpdatedAt = time.Now() + } + + // Set created_by if CreatedByUser is present + if funnel.CreatedByUser != nil { + funnel.CreatedBy = funnel.CreatedByUser.Identifiable.ID.String() + } + + if err := module.store.Create(ctx, funnel); err != nil { + return nil, fmt.Errorf("failed to create funnel: %v", err) + } + + return funnel, nil +} + +// Get gets a funnel by ID +func (module *module) Get(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error) { + return module.store.Get(ctx, funnelID, orgID) +} + +// Update updates a funnel +func (module *module) Update(ctx context.Context, funnel *traceFunnels.StorableFunnel, userID valuer.UUID) error { + funnel.UpdatedBy = userID.String() + return module.store.Update(ctx, funnel) +} + +// List lists all funnels for an organization +func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.StorableFunnel, error) { + funnels, err := module.store.List(ctx, orgID) + if err != nil { + return nil, fmt.Errorf("failed to list funnels: %v", err) + } + + return funnels, nil +} + +// Delete deletes a funnel +func (module *module) Delete(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) error { + return module.store.Delete(ctx, funnelID, orgID) +} + +// GetFunnelMetadata gets metadata for a funnel +func (module *module) GetFunnelMetadata(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) (int64, int64, string, error) { + funnel, err := module.store.Get(ctx, funnelID, orgID) + if err != nil { + return 0, 0, "", err + } + + return funnel.CreatedAt.UnixNano() / 1000000, funnel.UpdatedAt.UnixNano() / 1000000, funnel.Description, nil +} diff --git a/pkg/modules/tracefunnel/impltracefunnel/store.go b/pkg/modules/tracefunnel/impltracefunnel/store.go new file mode 100644 index 0000000000..f0c2b119b5 --- /dev/null +++ b/pkg/modules/tracefunnel/impltracefunnel/store.go @@ -0,0 +1,114 @@ +package impltracefunnel + +import ( + "context" + "time" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/sqlstore" + traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunneltypes" + "github.com/SigNoz/signoz/pkg/valuer" +) + +type store struct { + sqlstore sqlstore.SQLStore +} + +func NewStore(sqlstore sqlstore.SQLStore) traceFunnels.FunnelStore { + return &store{sqlstore: sqlstore} +} + +func (store *store) Create(ctx context.Context, funnel *traceFunnels.StorableFunnel) error { + // Check if a funnel with the same name already exists in the organization + exists, err := store. + sqlstore. + BunDB(). + NewSelect(). + Model(new(traceFunnels.StorableFunnel)). + Where("name = ? AND org_id = ?", funnel.Name, funnel.OrgID.String()). + Exists(ctx) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to check for existing funnelr") + } + if exists { + return store.sqlstore.WrapAlreadyExistsErrf(nil, traceFunnels.ErrFunnelAlreadyExists, "a funnel with name '%s' already exists in this organization", funnel.Name) + } + + _, err = store. + sqlstore. + BunDB(). + NewInsert(). + Model(funnel). + Exec(ctx) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to create funnels") + } + + return nil +} + +// Get retrieves a funnel by ID +func (store *store) Get(ctx context.Context, uuid valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error) { + funnel := &traceFunnels.StorableFunnel{} + err := store. + sqlstore. + BunDB(). + NewSelect(). + Model(funnel). + Relation("CreatedByUser"). + Where("?TableAlias.id = ? AND ?TableAlias.org_id = ?", uuid.String(), orgID.String()). + Scan(ctx) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to get funnels") + } + return funnel, nil +} + +// Update updates an existing funnel +func (store *store) Update(ctx context.Context, funnel *traceFunnels.StorableFunnel) error { + funnel.UpdatedAt = time.Now() + + _, err := store. + sqlstore. + BunDB(). + NewUpdate(). + Model(funnel). + WherePK(). + Exec(ctx) + if err != nil { + return store.sqlstore.WrapAlreadyExistsErrf(err, traceFunnels.ErrFunnelAlreadyExists, "a funnel with name '%s' already exists in this organization", funnel.Name) + } + return nil +} + +// List retrieves all funnels for a given organization +func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.StorableFunnel, error) { + var funnels []*traceFunnels.StorableFunnel + err := store. + sqlstore. + BunDB(). + NewSelect(). + Model(&funnels). + Relation("CreatedByUser"). + Where("?TableAlias.org_id = ?", orgID.String()). + Scan(ctx) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to list funnels") + } + return funnels, nil +} + +// Delete removes a funnel by ID +func (store *store) Delete(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) error { + _, err := store. + sqlstore. + BunDB(). + NewDelete(). + Model(new(traceFunnels.StorableFunnel)). + Where("id = ? AND org_id = ?", funnelID.String(), orgID.String()). + Exec(ctx) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete funnel") + } + return nil +} diff --git a/pkg/modules/tracefunnel/tracefunnel.go b/pkg/modules/tracefunnel/tracefunnel.go new file mode 100644 index 0000000000..13401ee5a4 --- /dev/null +++ b/pkg/modules/tracefunnel/tracefunnel.go @@ -0,0 +1,38 @@ +package tracefunnel + +import ( + "context" + "github.com/SigNoz/signoz/pkg/valuer" + "net/http" + + traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunneltypes" +) + +// Module defines the interface for trace funnel operations +type Module interface { + Create(ctx context.Context, timestamp int64, name string, userID valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error) + + Get(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error) + + Update(ctx context.Context, funnel *traceFunnels.StorableFunnel, userID valuer.UUID) error + + List(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.StorableFunnel, error) + + Delete(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) error + + GetFunnelMetadata(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) (int64, int64, string, error) +} + +type Handler interface { + New(http.ResponseWriter, *http.Request) + + UpdateSteps(http.ResponseWriter, *http.Request) + + UpdateFunnel(http.ResponseWriter, *http.Request) + + List(http.ResponseWriter, *http.Request) + + Get(http.ResponseWriter, *http.Request) + + Delete(http.ResponseWriter, *http.Request) +} diff --git a/pkg/modules/tracefunnel/tracefunneltest/module_test.go b/pkg/modules/tracefunnel/tracefunneltest/module_test.go new file mode 100644 index 0000000000..0379027669 --- /dev/null +++ b/pkg/modules/tracefunnel/tracefunneltest/module_test.go @@ -0,0 +1,183 @@ +package tracefunneltest + +import ( + "context" + "testing" + "time" + + "github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel" + "github.com/SigNoz/signoz/pkg/types" + traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunneltypes" + "github.com/SigNoz/signoz/pkg/valuer" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockStore struct { + mock.Mock +} + +func (m *MockStore) Create(ctx context.Context, funnel *traceFunnels.StorableFunnel) error { + args := m.Called(ctx, funnel) + return args.Error(0) +} + +func (m *MockStore) Get(ctx context.Context, uuid valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error) { + args := m.Called(ctx, uuid, orgID) + return args.Get(0).(*traceFunnels.StorableFunnel), args.Error(1) +} + +func (m *MockStore) List(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.StorableFunnel, error) { + args := m.Called(ctx, orgID) + return args.Get(0).([]*traceFunnels.StorableFunnel), args.Error(1) +} + +func (m *MockStore) Update(ctx context.Context, funnel *traceFunnels.StorableFunnel) error { + args := m.Called(ctx, funnel) + return args.Error(0) +} + +func (m *MockStore) Delete(ctx context.Context, uuid valuer.UUID, orgID valuer.UUID) error { + args := m.Called(ctx, uuid, orgID) + return args.Error(0) +} + +func TestModule_Create(t *testing.T) { + mockStore := new(MockStore) + module := impltracefunnel.NewModule(mockStore) + + ctx := context.Background() + timestamp := time.Now().UnixMilli() + name := "test-funnel" + userID := valuer.GenerateUUID() + orgID := valuer.GenerateUUID() + + mockStore.On("Create", ctx, mock.MatchedBy(func(f *traceFunnels.StorableFunnel) bool { + return f.Name == name && + f.CreatedBy == userID.String() && + f.OrgID == orgID && + f.CreatedByUser != nil && + f.CreatedByUser.ID == userID && + f.CreatedAt.UnixNano()/1000000 == timestamp + })).Return(nil) + + funnel, err := module.Create(ctx, timestamp, name, userID, orgID) + assert.NoError(t, err) + assert.NotNil(t, funnel) + assert.Equal(t, name, funnel.Name) + assert.Equal(t, userID.String(), funnel.CreatedBy) + assert.Equal(t, orgID, funnel.OrgID) + assert.NotNil(t, funnel.CreatedByUser) + assert.Equal(t, userID, funnel.CreatedByUser.ID) + + mockStore.AssertExpectations(t) +} + +func TestModule_Get(t *testing.T) { + mockStore := new(MockStore) + module := impltracefunnel.NewModule(mockStore) + + ctx := context.Background() + funnelID := valuer.GenerateUUID() + orgID := valuer.GenerateUUID() + expectedFunnel := &traceFunnels.StorableFunnel{ + Name: "test-funnel", + } + + mockStore.On("Get", ctx, funnelID, orgID).Return(expectedFunnel, nil) + + funnel, err := module.Get(ctx, funnelID, orgID) + assert.NoError(t, err) + assert.Equal(t, expectedFunnel, funnel) + + mockStore.AssertExpectations(t) +} + +func TestModule_Update(t *testing.T) { + mockStore := new(MockStore) + module := impltracefunnel.NewModule(mockStore) + + ctx := context.Background() + userID := valuer.GenerateUUID() + funnel := &traceFunnels.StorableFunnel{ + Name: "test-funnel", + } + + mockStore.On("Update", ctx, funnel).Return(nil) + + err := module.Update(ctx, funnel, userID) + assert.NoError(t, err) + assert.Equal(t, userID.String(), funnel.UpdatedBy) + + mockStore.AssertExpectations(t) +} + +func TestModule_List(t *testing.T) { + mockStore := new(MockStore) + module := impltracefunnel.NewModule(mockStore) + + ctx := context.Background() + orgID := valuer.GenerateUUID() + expectedFunnels := []*traceFunnels.StorableFunnel{ + { + Name: "funnel-1", + OrgID: orgID, + }, + { + Name: "funnel-2", + OrgID: orgID, + }, + } + + mockStore.On("List", ctx, orgID).Return(expectedFunnels, nil) + + funnels, err := module.List(ctx, orgID) + assert.NoError(t, err) + assert.Len(t, funnels, 2) + assert.Equal(t, expectedFunnels, funnels) + + mockStore.AssertExpectations(t) +} + +func TestModule_Delete(t *testing.T) { + mockStore := new(MockStore) + module := impltracefunnel.NewModule(mockStore) + + ctx := context.Background() + funnelID := valuer.GenerateUUID() + orgID := valuer.GenerateUUID() + + mockStore.On("Delete", ctx, funnelID, orgID).Return(nil) + + err := module.Delete(ctx, funnelID, orgID) + assert.NoError(t, err) + + mockStore.AssertExpectations(t) +} + +func TestModule_GetFunnelMetadata(t *testing.T) { + mockStore := new(MockStore) + module := impltracefunnel.NewModule(mockStore) + + ctx := context.Background() + funnelID := valuer.GenerateUUID() + orgID := valuer.GenerateUUID() + now := time.Now() + expectedFunnel := &traceFunnels.StorableFunnel{ + Description: "test description", + TimeAuditable: types.TimeAuditable{ + CreatedAt: now, + UpdatedAt: now, + }, + } + + mockStore.On("Get", ctx, funnelID, orgID).Return(expectedFunnel, nil) + + createdAt, updatedAt, description, err := module.GetFunnelMetadata(ctx, funnelID, orgID) + assert.NoError(t, err) + assert.Equal(t, now.UnixNano()/1000000, createdAt) + assert.Equal(t, now.UnixNano()/1000000, updatedAt) + assert.Equal(t, "test description", description) + + mockStore.AssertExpectations(t) +} diff --git a/pkg/query-service/app/http_handler.go b/pkg/query-service/app/http_handler.go index 3712fdfc1d..3badb6c882 100644 --- a/pkg/query-service/app/http_handler.go +++ b/pkg/query-service/app/http_handler.go @@ -5229,3 +5229,30 @@ func (aH *APIHandler) getDomainInfo(w http.ResponseWriter, r *http.Request) { } aH.Respond(w, resp) } + +// RegisterTraceFunnelsRoutes adds trace funnels routes +func (aH *APIHandler) RegisterTraceFunnelsRoutes(router *mux.Router, am *middleware.AuthZ) { + // Main trace funnels router + traceFunnelsRouter := router.PathPrefix("/api/v1/trace-funnels").Subrouter() + + // API endpoints + traceFunnelsRouter.HandleFunc("/new", + am.EditAccess(aH.Signoz.Handlers.TraceFunnel.New)). + Methods(http.MethodPost) + traceFunnelsRouter.HandleFunc("/list", + am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.List)). + Methods(http.MethodGet) + traceFunnelsRouter.HandleFunc("/steps/update", + am.EditAccess(aH.Signoz.Handlers.TraceFunnel.UpdateSteps)). + Methods(http.MethodPut) + + traceFunnelsRouter.HandleFunc("/{funnel_id}", + am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.Get)). + Methods(http.MethodGet) + traceFunnelsRouter.HandleFunc("/{funnel_id}", + am.EditAccess(aH.Signoz.Handlers.TraceFunnel.Delete)). + Methods(http.MethodDelete) + traceFunnelsRouter.HandleFunc("/{funnel_id}", + am.EditAccess(aH.Signoz.Handlers.TraceFunnel.UpdateFunnel)). + Methods(http.MethodPut) +} diff --git a/pkg/query-service/app/server.go b/pkg/query-service/app/server.go index 3f25901a81..3074328f5e 100644 --- a/pkg/query-service/app/server.go +++ b/pkg/query-service/app/server.go @@ -269,6 +269,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server, api.RegisterMessagingQueuesRoutes(r, am) api.RegisterThirdPartyApiRoutes(r, am) api.MetricExplorerRoutes(r, am) + api.RegisterTraceFunnelsRoutes(r, am) c := cors.New(cors.Options{ AllowedOrigins: []string{"*"}, diff --git a/pkg/query-service/app/traces/v4/query_builder.go b/pkg/query-service/app/traces/v4/query_builder.go index 7b2befdd95..69a76de4d1 100644 --- a/pkg/query-service/app/traces/v4/query_builder.go +++ b/pkg/query-service/app/traces/v4/query_builder.go @@ -87,7 +87,7 @@ func existsSubQueryForFixedColumn(key v3.AttributeKey, op v3.FilterOperator) (st } } -func buildTracesFilterQuery(fs *v3.FilterSet) (string, error) { +func BuildTracesFilterQuery(fs *v3.FilterSet) (string, error) { var conditions []string if fs != nil && len(fs.Items) != 0 { @@ -167,7 +167,7 @@ func handleEmptyValuesInGroupBy(groupBy []v3.AttributeKey) (string, error) { Operator: "AND", Items: filterItems, } - return buildTracesFilterQuery(&filterSet) + return BuildTracesFilterQuery(&filterSet) } return "", nil } @@ -248,7 +248,7 @@ func buildTracesQuery(start, end, step int64, mq *v3.BuilderQuery, panelType v3. timeFilter := fmt.Sprintf("(timestamp >= '%d' AND timestamp <= '%d') AND (ts_bucket_start >= %d AND ts_bucket_start <= %d)", tracesStart, tracesEnd, bucketStart, bucketEnd) - filterSubQuery, err := buildTracesFilterQuery(mq.Filters) + filterSubQuery, err := BuildTracesFilterQuery(mq.Filters) if err != nil { return "", err } diff --git a/pkg/query-service/app/traces/v4/query_builder_test.go b/pkg/query-service/app/traces/v4/query_builder_test.go index eff4070b54..8943083162 100644 --- a/pkg/query-service/app/traces/v4/query_builder_test.go +++ b/pkg/query-service/app/traces/v4/query_builder_test.go @@ -211,7 +211,7 @@ func Test_buildTracesFilterQuery(t *testing.T) { want: "", }, { - name: "Test buildTracesFilterQuery in, nin", + name: "Test BuildTracesFilterQuery in, nin", args: args{ fs: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{ {Key: v3.AttributeKey{Key: "method", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: []interface{}{"GET", "POST"}, Operator: v3.FilterOperatorIn}, @@ -226,7 +226,7 @@ func Test_buildTracesFilterQuery(t *testing.T) { wantErr: false, }, { - name: "Test buildTracesFilterQuery not eq, neq, gt, lt, gte, lte", + name: "Test BuildTracesFilterQuery not eq, neq, gt, lt, gte, lte", args: args{ fs: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{ {Key: v3.AttributeKey{Key: "duration", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag}, Value: 102, Operator: v3.FilterOperatorEqual}, @@ -274,13 +274,13 @@ func Test_buildTracesFilterQuery(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := buildTracesFilterQuery(tt.args.fs) + got, err := BuildTracesFilterQuery(tt.args.fs) if (err != nil) != tt.wantErr { - t.Errorf("buildTracesFilterQuery() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("BuildTracesFilterQuery() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { - t.Errorf("buildTracesFilterQuery() = %v, want %v", got, tt.want) + t.Errorf("BuildTracesFilterQuery() = %v, want %v", got, tt.want) } }) } diff --git a/pkg/signoz/handler.go b/pkg/signoz/handler.go index f0d1528ec1..6f5ccdce98 100644 --- a/pkg/signoz/handler.go +++ b/pkg/signoz/handler.go @@ -13,6 +13,8 @@ import ( "github.com/SigNoz/signoz/pkg/modules/quickfilter/implquickfilter" "github.com/SigNoz/signoz/pkg/modules/savedview" "github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview" + "github.com/SigNoz/signoz/pkg/modules/tracefunnel" + "github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel" "github.com/SigNoz/signoz/pkg/modules/user" "github.com/SigNoz/signoz/pkg/modules/user/impluser" ) @@ -25,6 +27,7 @@ type Handlers struct { Apdex apdex.Handler Dashboard dashboard.Handler QuickFilter quickfilter.Handler + TraceFunnel tracefunnel.Handler } func NewHandlers(modules Modules) Handlers { @@ -36,5 +39,6 @@ func NewHandlers(modules Modules) Handlers { Apdex: implapdex.NewHandler(modules.Apdex), Dashboard: impldashboard.NewHandler(modules.Dashboard), QuickFilter: implquickfilter.NewHandler(modules.QuickFilter), + TraceFunnel: impltracefunnel.NewHandler(modules.TraceFunnel), } } diff --git a/pkg/signoz/module.go b/pkg/signoz/module.go index c4f45e8492..33d9be5983 100644 --- a/pkg/signoz/module.go +++ b/pkg/signoz/module.go @@ -16,6 +16,8 @@ import ( "github.com/SigNoz/signoz/pkg/modules/quickfilter/implquickfilter" "github.com/SigNoz/signoz/pkg/modules/savedview" "github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview" + "github.com/SigNoz/signoz/pkg/modules/tracefunnel" + "github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel" "github.com/SigNoz/signoz/pkg/modules/user" "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/sqlstore" @@ -32,6 +34,7 @@ type Modules struct { Apdex apdex.Module Dashboard dashboard.Module QuickFilter quickfilter.Module + TraceFunnel tracefunnel.Module } func NewModules( @@ -54,5 +57,6 @@ func NewModules( Dashboard: impldashboard.NewModule(sqlstore), User: user, QuickFilter: quickfilter, + TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)), } } diff --git a/pkg/signoz/provider.go b/pkg/signoz/provider.go index bfbcdeafe1..6076890333 100644 --- a/pkg/signoz/provider.go +++ b/pkg/signoz/provider.go @@ -88,6 +88,7 @@ func NewSQLMigrationProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedM sqlmigration.NewMigratePATToFactorAPIKey(sqlstore), sqlmigration.NewUpdateApiMonitoringFiltersFactory(sqlstore), sqlmigration.NewAddKeyOrganizationFactory(sqlstore), + sqlmigration.NewAddTraceFunnelsFactory(sqlstore), ) } diff --git a/pkg/sqlmigration/037_add_trace_funnels.go b/pkg/sqlmigration/037_add_trace_funnels.go new file mode 100644 index 0000000000..99a885b1cf --- /dev/null +++ b/pkg/sqlmigration/037_add_trace_funnels.go @@ -0,0 +1,89 @@ +package sqlmigration + +import ( + "context" + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/valuer" + "github.com/uptrace/bun" + "github.com/uptrace/bun/migrate" +) + +// funnel Core Data Structure (funnel and funnelStep) +type funnel struct { + bun.BaseModel `bun:"table:trace_funnel"` + types.Identifiable // funnel id + types.TimeAuditable + types.UserAuditable + Name string `json:"funnel_name" bun:"name,type:text,notnull"` // funnel name + Description string `json:"description" bun:"description,type:text"` // funnel description + OrgID valuer.UUID `json:"org_id" bun:"org_id,type:varchar,notnull"` + Steps []funnelStep `json:"steps" bun:"steps,type:text,notnull"` + Tags string `json:"tags" bun:"tags,type:text"` + CreatedByUser *types.User `json:"user" bun:"rel:belongs-to,join:created_by=id"` +} + +type funnelStep struct { + types.Identifiable + Name string `json:"name,omitempty"` // step name + Description string `json:"description,omitempty"` // step description + Order int64 `json:"step_order"` + ServiceName string `json:"service_name"` + SpanName string `json:"span_name"` + Filters string `json:"filters,omitempty"` + LatencyPointer string `json:"latency_pointer,omitempty"` + LatencyType string `json:"latency_type,omitempty"` + HasErrors bool `json:"has_errors"` +} + +type addTraceFunnels struct { + sqlstore sqlstore.SQLStore +} + +func NewAddTraceFunnelsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] { + return factory.NewProviderFactory(factory.MustNewName("add_trace_funnels"), func(ctx context.Context, providerSettings factory.ProviderSettings, config Config) (SQLMigration, error) { + return newAddTraceFunnels(ctx, providerSettings, config, sqlstore) + }) +} + +func newAddTraceFunnels(_ context.Context, _ factory.ProviderSettings, _ Config, sqlstore sqlstore.SQLStore) (SQLMigration, error) { + return &addTraceFunnels{sqlstore: sqlstore}, nil +} + +func (migration *addTraceFunnels) Register(migrations *migrate.Migrations) error { + if err := migrations.Register(migration.Up, migration.Down); err != nil { + return err + } + return nil +} + +func (migration *addTraceFunnels) Up(ctx context.Context, db *bun.DB) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { + _ = tx.Rollback() + }() + + _, err = tx.NewCreateTable(). + Model(new(funnel)). + ForeignKey(`("org_id") REFERENCES "organizations" ("id") ON DELETE CASCADE`). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + + err = tx.Commit() + if err != nil { + return err + } + + return nil +} + +func (migration *addTraceFunnels) Down(ctx context.Context, db *bun.DB) error { + return nil +} diff --git a/pkg/types/tracefunneltypes/store.go b/pkg/types/tracefunneltypes/store.go new file mode 100644 index 0000000000..ba000410e0 --- /dev/null +++ b/pkg/types/tracefunneltypes/store.go @@ -0,0 +1,15 @@ +package tracefunneltypes + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/valuer" +) + +type FunnelStore interface { + Create(context.Context, *StorableFunnel) error + Get(context.Context, valuer.UUID, valuer.UUID) (*StorableFunnel, error) + List(context.Context, valuer.UUID) ([]*StorableFunnel, error) + Update(context.Context, *StorableFunnel) error + Delete(context.Context, valuer.UUID, valuer.UUID) error +} diff --git a/pkg/types/tracefunneltypes/tracefunnel.go b/pkg/types/tracefunneltypes/tracefunnel.go new file mode 100644 index 0000000000..fdc51d943a --- /dev/null +++ b/pkg/types/tracefunneltypes/tracefunnel.go @@ -0,0 +1,98 @@ +package tracefunneltypes + +import ( + "github.com/SigNoz/signoz/pkg/errors" + v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3" + "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/valuer" + "github.com/uptrace/bun" +) + +var ( + ErrFunnelAlreadyExists = errors.MustNewCode("funnel_already_exists") +) + +// StorableFunnel Core Data Structure (StorableFunnel and FunnelStep) +type StorableFunnel struct { + types.Identifiable + types.TimeAuditable + types.UserAuditable + bun.BaseModel `bun:"table:trace_funnel"` + Name string `json:"funnel_name" bun:"name,type:text,notnull"` + Description string `json:"description" bun:"description,type:text"` + OrgID valuer.UUID `json:"org_id" bun:"org_id,type:varchar,notnull"` + Steps []*FunnelStep `json:"steps" bun:"steps,type:text,notnull"` + Tags string `json:"tags" bun:"tags,type:text"` + CreatedByUser *types.User `json:"user" bun:"rel:belongs-to,join:created_by=id"` +} + +type FunnelStep struct { + ID valuer.UUID `json:"id,omitempty"` + Name string `json:"name,omitempty"` // step name + Description string `json:"description,omitempty"` // step description + Order int64 `json:"step_order"` + ServiceName string `json:"service_name"` + SpanName string `json:"span_name"` + Filters *v3.FilterSet `json:"filters,omitempty"` + LatencyPointer string `json:"latency_pointer,omitempty"` + LatencyType string `json:"latency_type,omitempty"` + HasErrors bool `json:"has_errors"` +} + +// PostableFunnel represents all possible funnel-related requests +type PostableFunnel struct { + FunnelID valuer.UUID `json:"funnel_id,omitempty"` + Name string `json:"funnel_name,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + Description string `json:"description,omitempty"` + Steps []*FunnelStep `json:"steps,omitempty"` + UserID string `json:"user_id,omitempty"` + + // Analytics specific fields + StartTime int64 `json:"start_time,omitempty"` + EndTime int64 `json:"end_time,omitempty"` + StepAOrder int64 `json:"step_a_order,omitempty"` + StepBOrder int64 `json:"step_b_order,omitempty"` +} + +// GettableFunnel represents all possible funnel-related responses +type GettableFunnel struct { + FunnelID string `json:"funnel_id,omitempty"` + FunnelName string `json:"funnel_name,omitempty"` + Description string `json:"description,omitempty"` + CreatedAt int64 `json:"created_at,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + UpdatedAt int64 `json:"updated_at,omitempty"` + UpdatedBy string `json:"updated_by,omitempty"` + OrgID string `json:"org_id,omitempty"` + UserEmail string `json:"user_email,omitempty"` + Funnel *StorableFunnel `json:"funnel,omitempty"` + Steps []*FunnelStep `json:"steps,omitempty"` +} + +// TimeRange represents a time range for analytics +type TimeRange struct { + StartTime int64 `json:"start_time"` + EndTime int64 `json:"end_time"` +} + +// StepTransitionRequest represents a request for step transition analytics +type StepTransitionRequest struct { + TimeRange + StepStart int64 `json:"step_start,omitempty"` + StepEnd int64 `json:"step_end,omitempty"` +} + +// UserInfo represents basic user information +type UserInfo struct { + ID string `json:"id"` + Email string `json:"email"` +} + +type FunnelStepFilter struct { + StepNumber int + ServiceName string + SpanName string + LatencyPointer string // "start" or "end" + CustomFilters *v3.FilterSet +} diff --git a/pkg/types/tracefunneltypes/utils.go b/pkg/types/tracefunneltypes/utils.go new file mode 100644 index 0000000000..60ca4eaf36 --- /dev/null +++ b/pkg/types/tracefunneltypes/utils.go @@ -0,0 +1,139 @@ +package tracefunneltypes + +import ( + "fmt" + "sort" + "time" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/valuer" +) + +// ValidateTimestamp validates a timestamp +func ValidateTimestamp(timestamp int64, fieldName string) error { + if timestamp == 0 { + return fmt.Errorf("%s is required", fieldName) + } + if timestamp < 0 { + return fmt.Errorf("%s must be positive", fieldName) + } + return nil +} + +// ValidateTimestampIsMilliseconds validates that a timestamp is in milliseconds +func ValidateTimestampIsMilliseconds(timestamp int64) bool { + return timestamp >= 1000000000000 && timestamp <= 9999999999999 +} + +func ValidateFunnelSteps(steps []*FunnelStep) error { + if len(steps) < 2 { + return fmt.Errorf("funnel must have at least 2 steps") + } + + for i, step := range steps { + if step.ServiceName == "" { + return fmt.Errorf("step %d: service name is required", i+1) + } + if step.SpanName == "" { + return fmt.Errorf("step %d: span name is required", i+1) + } + if step.Order < 0 { + return fmt.Errorf("step %d: order must be non-negative", i+1) + } + } + + return nil +} + +// NormalizeFunnelSteps normalizes step orders to be sequential starting from 1. +// The function takes a slice of pointers to FunnelStep and returns a new slice with normalized step orders. +// The input slice is left unchanged. If the input slice contains nil pointers, they will be filtered out. +// Returns an empty slice if the input is empty or contains only nil pointers. +func NormalizeFunnelSteps(steps []*FunnelStep) []*FunnelStep { + if len(steps) == 0 { + return []*FunnelStep{} + } + + // Filter out nil pointers and create a new slice + validSteps := make([]*FunnelStep, 0, len(steps)) + for _, step := range steps { + if step != nil { + validSteps = append(validSteps, step) + } + } + + if len(validSteps) == 0 { + return []*FunnelStep{} + } + + // Create a defensive copy of the valid steps + newSteps := make([]*FunnelStep, len(validSteps)) + for i, step := range validSteps { + // Create a copy of each step to avoid modifying the original + stepCopy := *step + newSteps[i] = &stepCopy + } + + sort.Slice(newSteps, func(i, j int) bool { + return newSteps[i].Order < newSteps[j].Order + }) + + for i := range newSteps { + newSteps[i].Order = int64(i + 1) + } + + return newSteps +} + +func ValidateAndConvertTimestamp(timestamp int64) (time.Time, error) { + if err := ValidateTimestamp(timestamp, "timestamp"); err != nil { + return time.Time{}, errors.Newf(errors.TypeInvalidInput, + errors.CodeInvalidInput, + "timestamp is invalid: %v", err) + } + return time.Unix(0, timestamp*1000000), nil // Convert to nanoseconds +} + +func ConstructFunnelResponse(funnel *StorableFunnel, claims *authtypes.Claims) GettableFunnel { + resp := GettableFunnel{ + FunnelName: funnel.Name, + FunnelID: funnel.ID.String(), + Steps: funnel.Steps, + CreatedAt: funnel.CreatedAt.UnixNano() / 1000000, + CreatedBy: funnel.CreatedBy, + OrgID: funnel.OrgID.String(), + UpdatedBy: funnel.UpdatedBy, + UpdatedAt: funnel.UpdatedAt.UnixNano() / 1000000, + Description: funnel.Description, + } + + if funnel.CreatedByUser != nil { + resp.UserEmail = funnel.CreatedByUser.Email + } else if claims != nil { + resp.UserEmail = claims.Email + } + + return resp +} + +func ProcessFunnelSteps(steps []*FunnelStep) ([]*FunnelStep, error) { + // First validate the steps + if err := ValidateFunnelSteps(steps); err != nil { + return nil, errors.Newf(errors.TypeInvalidInput, + errors.CodeInvalidInput, + "invalid funnel steps: %v", err) + } + + // Then process the steps + for i := range steps { + if steps[i].Order < 1 { + steps[i].Order = int64(i + 1) + } + if steps[i].ID.IsZero() { + steps[i].ID = valuer.GenerateUUID() + } + } + + return NormalizeFunnelSteps(steps), nil +} diff --git a/pkg/types/tracefunneltypes/utils_test.go b/pkg/types/tracefunneltypes/utils_test.go new file mode 100644 index 0000000000..cf345d92b5 --- /dev/null +++ b/pkg/types/tracefunneltypes/utils_test.go @@ -0,0 +1,698 @@ +package tracefunneltypes + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/valuer" + "github.com/stretchr/testify/assert" +) + +func TestValidateTimestamp(t *testing.T) { + tests := []struct { + name string + timestamp int64 + fieldName string + expectError bool + }{ + { + name: "valid timestamp", + timestamp: time.Now().UnixMilli(), + fieldName: "timestamp", + expectError: false, + }, + { + name: "zero timestamp", + timestamp: 0, + fieldName: "timestamp", + expectError: true, + }, + { + name: "negative timestamp", + timestamp: -1, + fieldName: "timestamp", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateTimestamp(tt.timestamp, tt.fieldName) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateTimestampIsMilliseconds(t *testing.T) { + tests := []struct { + name string + timestamp int64 + expected bool + }{ + { + name: "valid millisecond timestamp", + timestamp: 1700000000000, // 2023-11-14 12:00:00 UTC + expected: true, + }, + { + name: "too small timestamp", + timestamp: 999999999999, + expected: false, + }, + { + name: "too large timestamp", + timestamp: 10000000000000, + expected: false, + }, + { + name: "second precision timestamp", + timestamp: 1700000000, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ValidateTimestampIsMilliseconds(tt.timestamp) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestValidateFunnelSteps(t *testing.T) { + tests := []struct { + name string + steps []*FunnelStep + expectError bool + }{ + { + name: "valid steps", + steps: []*FunnelStep{ + { + ID: valuer.GenerateUUID(), + Name: "Step 1", + ServiceName: "test-service", + SpanName: "test-span", + Order: 1, + }, + { + ID: valuer.GenerateUUID(), + Name: "Step 2", + ServiceName: "test-service", + SpanName: "test-span-2", + Order: 2, + }, + }, + expectError: false, + }, + { + name: "too few steps", + steps: []*FunnelStep{ + { + ID: valuer.GenerateUUID(), + Name: "Step 1", + ServiceName: "test-service", + SpanName: "test-span", + Order: 1, + }, + }, + expectError: true, + }, + { + name: "missing service name", + steps: []*FunnelStep{ + { + ID: valuer.GenerateUUID(), + Name: "Step 1", + SpanName: "test-span", + Order: 1, + }, + { + ID: valuer.GenerateUUID(), + Name: "Step 2", + ServiceName: "test-service", + SpanName: "test-span-2", + Order: 2, + }, + }, + expectError: true, + }, + { + name: "missing span name", + steps: []*FunnelStep{ + { + ID: valuer.GenerateUUID(), + Name: "Step 1", + ServiceName: "test-service", + Order: 1, + }, + { + ID: valuer.GenerateUUID(), + Name: "Step 2", + ServiceName: "test-service", + SpanName: "test-span-2", + Order: 2, + }, + }, + expectError: true, + }, + { + name: "negative order", + steps: []*FunnelStep{ + { + ID: valuer.GenerateUUID(), + Name: "Step 1", + ServiceName: "test-service", + SpanName: "test-span", + Order: -1, + }, + { + ID: valuer.GenerateUUID(), + Name: "Step 2", + ServiceName: "test-service", + SpanName: "test-span-2", + Order: 2, + }, + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateFunnelSteps(tt.steps) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestNormalizeFunnelSteps(t *testing.T) { + tests := []struct { + name string + steps []*FunnelStep + expected []*FunnelStep + }{ + { + name: "already normalized steps", + steps: []*FunnelStep{ + { + ID: valuer.GenerateUUID(), + Name: "Step 1", + ServiceName: "test-service", + SpanName: "test-span", + Order: 1, + }, + { + ID: valuer.GenerateUUID(), + Name: "Step 2", + ServiceName: "test-service", + SpanName: "test-span-2", + Order: 2, + }, + }, + expected: []*FunnelStep{ + { + Name: "Step 1", + ServiceName: "test-service", + SpanName: "test-span", + Order: 1, + }, + { + Name: "Step 2", + ServiceName: "test-service", + SpanName: "test-span-2", + Order: 2, + }, + }, + }, + { + name: "unordered steps", + steps: []*FunnelStep{ + { + ID: valuer.GenerateUUID(), + Name: "Step 2", + ServiceName: "test-service", + SpanName: "test-span-2", + Order: 2, + }, + { + ID: valuer.GenerateUUID(), + Name: "Step 1", + ServiceName: "test-service", + SpanName: "test-span", + Order: 1, + }, + }, + expected: []*FunnelStep{ + { + Name: "Step 1", + ServiceName: "test-service", + SpanName: "test-span", + Order: 1, + }, + { + Name: "Step 2", + ServiceName: "test-service", + SpanName: "test-span-2", + Order: 2, + }, + }, + }, + { + name: "steps with gaps in order", + steps: []*FunnelStep{ + { + ID: valuer.GenerateUUID(), + Name: "Step 1", + ServiceName: "test-service", + SpanName: "test-span", + Order: 1, + }, + { + ID: valuer.GenerateUUID(), + Name: "Step 3", + ServiceName: "test-service", + SpanName: "test-span-3", + Order: 3, + }, + { + ID: valuer.GenerateUUID(), + Name: "Step 2", + ServiceName: "test-service", + SpanName: "test-span-2", + Order: 2, + }, + }, + expected: []*FunnelStep{ + { + Name: "Step 1", + ServiceName: "test-service", + SpanName: "test-span", + Order: 1, + }, + { + Name: "Step 2", + ServiceName: "test-service", + SpanName: "test-span-2", + Order: 2, + }, + { + Name: "Step 3", + ServiceName: "test-service", + SpanName: "test-span-3", + Order: 3, + }, + }, + }, + { + name: "steps with nil pointers", + steps: []*FunnelStep{ + { + ID: valuer.GenerateUUID(), + Name: "Step 1", + ServiceName: "test-service", + SpanName: "test-span", + Order: 1, + }, + nil, + { + ID: valuer.GenerateUUID(), + Name: "Step 2", + ServiceName: "test-service", + SpanName: "test-span-2", + Order: 2, + }, + }, + expected: []*FunnelStep{ + { + Name: "Step 1", + ServiceName: "test-service", + SpanName: "test-span", + Order: 1, + }, + { + Name: "Step 2", + ServiceName: "test-service", + SpanName: "test-span-2", + Order: 2, + }, + }, + }, + { + name: "empty steps", + steps: []*FunnelStep{}, + expected: []*FunnelStep{}, + }, + { + name: "all nil steps", + steps: []*FunnelStep{nil, nil}, + expected: []*FunnelStep{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NormalizeFunnelSteps(tt.steps) + + // Compare only the relevant fields + assert.Len(t, result, len(tt.expected)) + for i := range result { + assert.Equal(t, tt.expected[i].Name, result[i].Name) + assert.Equal(t, tt.expected[i].ServiceName, result[i].ServiceName) + assert.Equal(t, tt.expected[i].SpanName, result[i].SpanName) + assert.Equal(t, tt.expected[i].Order, result[i].Order) + } + }) + } +} + +func TestGetClaims(t *testing.T) { + tests := []struct { + name string + setup func(*http.Request) + expectError bool + }{ + { + name: "valid claims", + setup: func(r *http.Request) { + claims := authtypes.Claims{ + UserID: "user-123", + OrgID: "org-123", + Email: "test@example.com", + } + *r = *r.WithContext(authtypes.NewContextWithClaims(r.Context(), claims)) + }, + expectError: false, + }, + { + name: "no claims in context", + setup: func(r *http.Request) { + claims := authtypes.Claims{} + *r = *r.WithContext(authtypes.NewContextWithClaims(r.Context(), claims)) + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + tt.setup(req) + + claims, err := authtypes.ClaimsFromContext(req.Context()) + if tt.expectError { + assert.Equal(t, authtypes.Claims{}, claims) + } else { + assert.NoError(t, err) + assert.NotNil(t, claims) + assert.Equal(t, "user-123", claims.UserID) + assert.Equal(t, "org-123", claims.OrgID) + assert.Equal(t, "test@example.com", claims.Email) + } + }) + } +} + +func TestValidateAndConvertTimestamp(t *testing.T) { + tests := []struct { + name string + timestamp int64 + expectError bool + }{ + { + name: "valid timestamp", + timestamp: time.Now().UnixMilli(), + expectError: false, + }, + { + name: "zero timestamp", + timestamp: 0, + expectError: true, + }, + { + name: "negative timestamp", + timestamp: -1, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ValidateAndConvertTimestamp(tt.timestamp) + if tt.expectError { + assert.Error(t, err) + assert.True(t, result.IsZero()) + } else { + assert.NoError(t, err) + assert.False(t, result.IsZero()) + // Verify the conversion from milliseconds to nanoseconds + assert.Equal(t, tt.timestamp*1000000, result.UnixNano()) + } + }) + } +} + +func TestConstructFunnelResponse(t *testing.T) { + now := time.Now() + funnelID := valuer.GenerateUUID() + orgID := valuer.GenerateUUID() + userID := valuer.GenerateUUID() + + tests := []struct { + name string + funnel *StorableFunnel + claims *authtypes.Claims + expected GettableFunnel + }{ + { + name: "with user email from funnel", + funnel: &StorableFunnel{ + Identifiable: types.Identifiable{ + ID: funnelID, + }, + TimeAuditable: types.TimeAuditable{ + CreatedAt: now, + UpdatedAt: now, + }, + UserAuditable: types.UserAuditable{ + CreatedBy: userID.String(), + UpdatedBy: userID.String(), + }, + Name: "test-funnel", + OrgID: orgID, + CreatedByUser: &types.User{ + Identifiable: types.Identifiable{ + ID: userID, + }, + Email: "funnel@example.com", + }, + Steps: []*FunnelStep{ + { + ID: valuer.GenerateUUID(), + Name: "Step 1", + ServiceName: "test-service", + SpanName: "test-span", + Order: 1, + }, + }, + }, + claims: &authtypes.Claims{ + UserID: userID.String(), + OrgID: orgID.String(), + Email: "claims@example.com", + }, + expected: GettableFunnel{ + FunnelName: "test-funnel", + FunnelID: funnelID.String(), + Steps: []*FunnelStep{ + { + Name: "Step 1", + ServiceName: "test-service", + SpanName: "test-span", + Order: 1, + }, + }, + CreatedAt: now.UnixNano() / 1000000, + CreatedBy: userID.String(), + UpdatedAt: now.UnixNano() / 1000000, + UpdatedBy: userID.String(), + OrgID: orgID.String(), + UserEmail: "funnel@example.com", + }, + }, + { + name: "with user email from claims", + funnel: &StorableFunnel{ + Identifiable: types.Identifiable{ + ID: funnelID, + }, + TimeAuditable: types.TimeAuditable{ + CreatedAt: now, + UpdatedAt: now, + }, + UserAuditable: types.UserAuditable{ + CreatedBy: userID.String(), + UpdatedBy: userID.String(), + }, + Name: "test-funnel", + OrgID: orgID, + Steps: []*FunnelStep{ + { + ID: valuer.GenerateUUID(), + Name: "Step 1", + ServiceName: "test-service", + SpanName: "test-span", + Order: 1, + }, + }, + }, + claims: &authtypes.Claims{ + UserID: userID.String(), + OrgID: orgID.String(), + Email: "claims@example.com", + }, + expected: GettableFunnel{ + FunnelName: "test-funnel", + FunnelID: funnelID.String(), + Steps: []*FunnelStep{ + { + Name: "Step 1", + ServiceName: "test-service", + SpanName: "test-span", + Order: 1, + }, + }, + CreatedAt: now.UnixNano() / 1000000, + CreatedBy: userID.String(), + UpdatedAt: now.UnixNano() / 1000000, + UpdatedBy: userID.String(), + OrgID: orgID.String(), + UserEmail: "claims@example.com", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ConstructFunnelResponse(tt.funnel, tt.claims) + + // Compare top-level fields + assert.Equal(t, tt.expected.FunnelName, result.FunnelName) + assert.Equal(t, tt.expected.FunnelID, result.FunnelID) + assert.Equal(t, tt.expected.CreatedAt, result.CreatedAt) + assert.Equal(t, tt.expected.CreatedBy, result.CreatedBy) + assert.Equal(t, tt.expected.UpdatedAt, result.UpdatedAt) + assert.Equal(t, tt.expected.UpdatedBy, result.UpdatedBy) + assert.Equal(t, tt.expected.OrgID, result.OrgID) + assert.Equal(t, tt.expected.UserEmail, result.UserEmail) + + // Compare steps + assert.Len(t, result.Steps, len(tt.expected.Steps)) + for i, step := range result.Steps { + expectedStep := tt.expected.Steps[i] + assert.Equal(t, expectedStep.Name, step.Name) + assert.Equal(t, expectedStep.ServiceName, step.ServiceName) + assert.Equal(t, expectedStep.SpanName, step.SpanName) + assert.Equal(t, expectedStep.Order, step.Order) + } + }) + } +} + +func TestProcessFunnelSteps(t *testing.T) { + tests := []struct { + name string + steps []*FunnelStep + expectError bool + }{ + { + name: "valid steps with missing IDs", + steps: []*FunnelStep{ + { + Name: "Step 1", + ServiceName: "test-service", + SpanName: "test-span", + Order: 0, // Will be normalized to 1 + }, + { + Name: "Step 2", + ServiceName: "test-service", + SpanName: "test-span-2", + Order: 0, // Will be normalized to 2 + }, + }, + expectError: false, + }, + { + name: "invalid steps - missing service name", + steps: []*FunnelStep{ + { + Name: "Step 1", + SpanName: "test-span", + Order: 1, + }, + { + Name: "Step 2", + ServiceName: "test-service", + SpanName: "test-span-2", + Order: 2, + }, + }, + expectError: true, + }, + { + name: "invalid steps - negative order", + steps: []*FunnelStep{ + { + Name: "Step 1", + ServiceName: "test-service", + SpanName: "test-span", + Order: -1, + }, + { + Name: "Step 2", + ServiceName: "test-service", + SpanName: "test-span-2", + Order: 2, + }, + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ProcessFunnelSteps(tt.steps) + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Len(t, result, len(tt.steps)) + + // Verify IDs are generated + for _, step := range result { + assert.False(t, step.ID.IsZero()) + } + + // Verify orders are normalized + for i, step := range result { + assert.Equal(t, int64(i+1), step.Order) + } + } + }) + } +}