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 <shivanshu1333@gmail.com>

* feat: add tracefunnel module and handler

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* feat: add required types for tracefunnels

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* feat: db operations, module and handler implementation

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* feat: add db migrations

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* chore: add utility functions

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* test: add utility function tests

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* test: add handler tests

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* test: add trace funnel module tests

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* chore: refactor handler and utils

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* chore: add funnel validation while processing funnel steps

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* test: add more tests to utils

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* chore: fix package naming

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* chore: fix naming convention

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* chore: update normalize funnel steps

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* chore: added some improvements

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* fix: optimize funnel creation by combining insert and update operations

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* chore: fix error handling

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* feat: trace funnel state management

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* fix: updated unit tests and mocks

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* fix: review comments

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* fix: minor fixes

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* fix: update funnel migration number

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* fix: review comments and some changes

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* fix: update modules

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

---------

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
This commit is contained in:
Shivanshu Raj Shrivastava 2025-06-02 12:30:49 +05:30 committed by GitHub
parent b6180f6957
commit f9cb9f10be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1924 additions and 8 deletions

View File

@ -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{"*"},

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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{"*"},

View File

@ -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
}

View File

@ -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)
}
})
}

View File

@ -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),
}
}

View File

@ -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)),
}
}

View File

@ -88,6 +88,7 @@ func NewSQLMigrationProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedM
sqlmigration.NewMigratePATToFactorAPIKey(sqlstore),
sqlmigration.NewUpdateApiMonitoringFiltersFactory(sqlstore),
sqlmigration.NewAddKeyOrganizationFactory(sqlstore),
sqlmigration.NewAddTraceFunnelsFactory(sqlstore),
)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}
})
}
}