diff --git a/ee/query-service/app/api/api.go b/ee/query-service/app/api/api.go index 991e6519ff..96b1e089e5 100644 --- a/ee/query-service/app/api/api.go +++ b/ee/query-service/app/api/api.go @@ -13,6 +13,8 @@ import ( "github.com/SigNoz/signoz/pkg/alertmanager" "github.com/SigNoz/signoz/pkg/apis/fields" "github.com/SigNoz/signoz/pkg/http/middleware" + "github.com/SigNoz/signoz/pkg/modules/quickfilter" + quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" baseapp "github.com/SigNoz/signoz/pkg/query-service/app" "github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations" "github.com/SigNoz/signoz/pkg/query-service/app/integrations" @@ -55,6 +57,8 @@ type APIHandler struct { // NewAPIHandler returns an APIHandler func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler, error) { + quickfiltermodule := quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(signoz.SQLStore)) + quickFilter := quickfilter.NewAPI(quickfiltermodule) baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{ Reader: opts.DataConnector, PreferSpanMetrics: opts.PreferSpanMetrics, @@ -69,6 +73,8 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler, AlertmanagerAPI: alertmanager.NewAPI(signoz.Alertmanager), FieldsAPI: fields.NewAPI(signoz.TelemetryStore), Signoz: signoz, + QuickFilters: quickFilter, + QuickFilterModule: quickfiltermodule, }) if err != nil { diff --git a/ee/query-service/app/api/auth.go b/ee/query-service/app/api/auth.go index 8e4c0fc069..3f33d7d187 100644 --- a/ee/query-service/app/api/auth.go +++ b/ee/query-service/app/api/auth.go @@ -134,7 +134,7 @@ func (ah *APIHandler) registerUser(w http.ResponseWriter, r *http.Request) { return } - _, registerError := baseauth.Register(ctx, req, ah.Signoz.Alertmanager, ah.Signoz.Modules.Organization) + _, registerError := baseauth.Register(ctx, req, ah.Signoz.Alertmanager, ah.Signoz.Modules.Organization, ah.QuickFilterModule) if !registerError.IsNil() { RespondError(w, apierr, nil) return diff --git a/pkg/modules/quickfilter/api.go b/pkg/modules/quickfilter/api.go new file mode 100644 index 0000000000..add2c4c3b6 --- /dev/null +++ b/pkg/modules/quickfilter/api.go @@ -0,0 +1,87 @@ +package quickfilter + +import ( + "encoding/json" + "github.com/SigNoz/signoz/pkg/http/render" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/types/quickfiltertypes" + "github.com/SigNoz/signoz/pkg/valuer" + "github.com/gorilla/mux" + "net/http" +) + +type API interface { + GetQuickFilters(http.ResponseWriter, *http.Request) + UpdateQuickFilters(http.ResponseWriter, *http.Request) + GetSignalFilters(http.ResponseWriter, *http.Request) +} + +type quickFiltersAPI struct { + usecase Usecase +} + +func NewAPI(usecase Usecase) API { + return &quickFiltersAPI{usecase: usecase} +} + +func (q *quickFiltersAPI) GetQuickFilters(rw http.ResponseWriter, r *http.Request) { + claims, err := authtypes.ClaimsFromContext(r.Context()) + if err != nil { + render.Error(rw, err) + return + } + + filters, err := q.usecase.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID)) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusOK, filters) +} + +func (q *quickFiltersAPI) UpdateQuickFilters(rw http.ResponseWriter, r *http.Request) { + claims, err := authtypes.ClaimsFromContext(r.Context()) + if err != nil { + render.Error(rw, err) + return + } + + var req quickfiltertypes.UpdatableQuickFilters + decodeErr := json.NewDecoder(r.Body).Decode(&req) + if decodeErr != nil { + render.Error(rw, decodeErr) + return + } + + err = q.usecase.UpdateQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), req.Signal, req.Filters) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusNoContent, nil) +} + +func (q *quickFiltersAPI) GetSignalFilters(rw http.ResponseWriter, r *http.Request) { + claims, err := authtypes.ClaimsFromContext(r.Context()) + if err != nil { + render.Error(rw, err) + return + } + + signal := mux.Vars(r)["signal"] + validatedSignal, err := quickfiltertypes.NewSignal(signal) + if err != nil { + render.Error(rw, err) + return + } + + filters, err := q.usecase.GetSignalFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), validatedSignal) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusOK, filters) +} diff --git a/pkg/modules/quickfilter/core/core.go b/pkg/modules/quickfilter/core/core.go new file mode 100644 index 0000000000..261ea0eac2 --- /dev/null +++ b/pkg/modules/quickfilter/core/core.go @@ -0,0 +1,116 @@ +package core + +import ( + "context" + "encoding/json" + "fmt" + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/modules/quickfilter" + v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3" + "github.com/SigNoz/signoz/pkg/types/quickfiltertypes" + "github.com/SigNoz/signoz/pkg/valuer" +) + +type usecase struct { + store quickfiltertypes.QuickFilterStore +} + +// NewQuickFilters creates a new quick filters usecase +func NewQuickFilters(store quickfiltertypes.QuickFilterStore) quickfilter.Usecase { + return &usecase{store: store} +} + +// GetQuickFilters returns all quick filters for an organization +func (u *usecase) GetQuickFilters(ctx context.Context, orgID valuer.UUID) ([]*quickfiltertypes.SignalFilters, error) { + storedFilters, err := u.store.Get(ctx, orgID) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error fetching organization filters") + } + + result := make([]*quickfiltertypes.SignalFilters, 0, len(storedFilters)) + for _, storedFilter := range storedFilters { + signalFilter, err := quickfiltertypes.NewSignalFilterFromStorableQuickFilter(storedFilter) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error processing filter for signal: %s", storedFilter.Signal) + } + result = append(result, signalFilter) + } + + return result, nil +} + +// GetSignalFilters returns quick filters for a specific signal in an organization +func (u *usecase) GetSignalFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal) (*quickfiltertypes.SignalFilters, error) { + storedFilter, err := u.store.GetBySignal(ctx, orgID, signal.StringValue()) + if err != nil { + return nil, err + } + + // If no filter exists for this signal, return empty filters with the requested signal + if storedFilter == nil { + return &quickfiltertypes.SignalFilters{ + Signal: signal, + Filters: []v3.AttributeKey{}, + }, nil + } + + // Convert stored filter to signal filter + signalFilter, err := quickfiltertypes.NewSignalFilterFromStorableQuickFilter(storedFilter) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error processing filter for signal: %s", storedFilter.Signal) + } + + return signalFilter, nil +} + +// UpdateQuickFilters updates quick filters for a specific signal in an organization +func (u *usecase) UpdateQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []v3.AttributeKey) error { + // Validate each filter + for _, filter := range filters { + if err := filter.Validate(); err != nil { + return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid filter: %v", err) + } + } + + // Marshal filters to JSON + filterJSON, err := json.Marshal(filters) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error marshalling filters") + } + + // Check if filter exists + existingFilter, err := u.store.GetBySignal(ctx, orgID, signal.StringValue()) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error checking existing filters") + } + + var filter *quickfiltertypes.StorableQuickFilter + if existingFilter != nil { + // Update in place + if err := existingFilter.Update(filterJSON); err != nil { + return errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "error updating existing filter") + } + filter = existingFilter + } else { + // Create new + filter, err = quickfiltertypes.NewStorableQuickFilter(orgID, signal, filterJSON) + if err != nil { + return errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "error creating new filter") + } + } + + // Persist filter + if err := u.store.Upsert(ctx, filter); err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, fmt.Sprintf("error upserting filter for signal: %s", signal.StringValue())) + } + + return nil +} + +func (u *usecase) SetDefaultConfig(ctx context.Context, orgID valuer.UUID) error { + storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(orgID) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error creating default quick filters") + } + return u.store.Create(ctx, storableQuickFilters) +} diff --git a/pkg/modules/quickfilter/core/store.go b/pkg/modules/quickfilter/core/store.go new file mode 100644 index 0000000000..6ef3627bf0 --- /dev/null +++ b/pkg/modules/quickfilter/core/store.go @@ -0,0 +1,86 @@ +package core + +import ( + "context" + "database/sql" + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/types/quickfiltertypes" + "github.com/SigNoz/signoz/pkg/valuer" +) + +type store struct { + store sqlstore.SQLStore +} + +// NewStore creates a new SQLite store for quick filters +func NewStore(db sqlstore.SQLStore) quickfiltertypes.QuickFilterStore { + return &store{store: db} +} + +// GetQuickFilters retrieves all filters for an organization +func (s *store) Get(ctx context.Context, orgID valuer.UUID) ([]*quickfiltertypes.StorableQuickFilter, error) { + filters := make([]*quickfiltertypes.StorableQuickFilter, 0) + + err := s.store. + BunDB(). + NewSelect(). + Model(&filters). + Where("org_id = ?", orgID). + Order("signal ASC"). + Scan(ctx) + + if err != nil { + return filters, err + } + + return filters, nil +} + +// GetSignalFilters retrieves filters for a specific signal in an organization +func (s *store) GetBySignal(ctx context.Context, orgID valuer.UUID, signal string) (*quickfiltertypes.StorableQuickFilter, error) { + filter := new(quickfiltertypes.StorableQuickFilter) + + err := s.store. + BunDB(). + NewSelect(). + Model(filter). + Where("org_id = ?", orgID). + Where("signal = ?", signal). + Scan(ctx) + + if err != nil { + if err == sql.ErrNoRows { + return nil, s.store.WrapNotFoundErrf(err, errors.CodeNotFound, "No rows found for org_id: "+orgID.StringValue()+" signal: "+signal) + } + return nil, err + } + + return filter, nil +} + +// UpsertQuickFilter inserts or updates filters for an organization and signal +func (s *store) Upsert(ctx context.Context, filter *quickfiltertypes.StorableQuickFilter) error { + _, err := s.store. + BunDB(). + NewInsert(). + Model(filter). + On("CONFLICT (id) DO UPDATE"). + Set("filter = EXCLUDED.filter"). + Set("updated_at = EXCLUDED.updated_at"). + Exec(ctx) + + return err +} + +func (s *store) Create(ctx context.Context, filters []*quickfiltertypes.StorableQuickFilter) error { + // Using SQLite-specific conflict resolution + _, err := s.store. + BunDB(). + NewInsert(). + Model(&filters). + On("CONFLICT (org_id, signal) DO NOTHING"). + Exec(ctx) + + return err +} diff --git a/pkg/modules/quickfilter/usecase.go b/pkg/modules/quickfilter/usecase.go new file mode 100644 index 0000000000..19a1b622b0 --- /dev/null +++ b/pkg/modules/quickfilter/usecase.go @@ -0,0 +1,15 @@ +package quickfilter + +import ( + "context" + v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3" + "github.com/SigNoz/signoz/pkg/types/quickfiltertypes" + "github.com/SigNoz/signoz/pkg/valuer" +) + +type Usecase interface { + GetQuickFilters(ctx context.Context, orgID valuer.UUID) ([]*quickfiltertypes.SignalFilters, error) + UpdateQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []v3.AttributeKey) error + GetSignalFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal) (*quickfiltertypes.SignalFilters, error) + SetDefaultConfig(ctx context.Context, orgID valuer.UUID) error +} diff --git a/pkg/query-service/app/http_handler.go b/pkg/query-service/app/http_handler.go index 7c9e696051..f7df58f2ca 100644 --- a/pkg/query-service/app/http_handler.go +++ b/pkg/query-service/app/http_handler.go @@ -23,6 +23,7 @@ import ( errorsV2 "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/http/middleware" "github.com/SigNoz/signoz/pkg/http/render" + "github.com/SigNoz/signoz/pkg/modules/quickfilter" "github.com/SigNoz/signoz/pkg/query-service/app/integrations" "github.com/SigNoz/signoz/pkg/query-service/app/metricsexplorer" "github.com/SigNoz/signoz/pkg/signoz" @@ -141,6 +142,10 @@ type APIHandler struct { FieldsAPI *fields.API Signoz *signoz.SigNoz + + QuickFilters quickfilter.API + + QuickFilterModule quickfilter.Usecase } type APIHandlerOpts struct { @@ -181,6 +186,10 @@ type APIHandlerOpts struct { FieldsAPI *fields.API Signoz *signoz.SigNoz + + QuickFilters quickfilter.API + + QuickFilterModule quickfilter.Usecase } // NewAPIHandler returns an APIHandler @@ -214,6 +223,7 @@ func NewAPIHandler(opts APIHandlerOpts) (*APIHandler, error) { jobsRepo := inframetrics.NewJobsRepo(opts.Reader, querierv2) pvcsRepo := inframetrics.NewPvcsRepo(opts.Reader, querierv2) summaryService := metricsexplorer.NewSummaryService(opts.Reader, opts.RuleManager) + //quickFilterModule := quickfilter.NewAPI(opts.QuickFilterModule) aH := &APIHandler{ reader: opts.Reader, @@ -243,6 +253,8 @@ func NewAPIHandler(opts APIHandlerOpts) (*APIHandler, error) { AlertmanagerAPI: opts.AlertmanagerAPI, Signoz: opts.Signoz, FieldsAPI: opts.FieldsAPI, + QuickFilters: opts.QuickFilters, + QuickFilterModule: opts.QuickFilterModule, } logsQueryBuilder := logsv4.PrepareLogsQuery @@ -564,6 +576,12 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) { router.HandleFunc("/api/v1/org/preferences/{preferenceId}", am.AdminAccess(aH.Signoz.Handlers.Preference.GetOrg)).Methods(http.MethodGet) router.HandleFunc("/api/v1/org/preferences/{preferenceId}", am.AdminAccess(aH.Signoz.Handlers.Preference.UpdateOrg)).Methods(http.MethodPut) + // Quick Filters + router.HandleFunc("/api/v1/orgs/me/filters", am.AdminAccess(aH.QuickFilters.GetQuickFilters)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/orgs/me/filters/{signal}", am.AdminAccess(aH.QuickFilters.GetSignalFilters)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/orgs/me/filters", am.AdminAccess(aH.QuickFilters.UpdateQuickFilters)).Methods(http.MethodPut) + + // === Authentication APIs === router.HandleFunc("/api/v1/invite", am.AdminAccess(aH.inviteUser)).Methods(http.MethodPost) router.HandleFunc("/api/v1/invite/bulk", am.AdminAccess(aH.inviteUsers)).Methods(http.MethodPost) router.HandleFunc("/api/v1/invite/{token}", am.OpenAccess(aH.getInvite)).Methods(http.MethodGet) @@ -2082,7 +2100,7 @@ func (aH *APIHandler) registerUser(w http.ResponseWriter, r *http.Request) { return } - _, apiErr := auth.Register(context.Background(), req, aH.Signoz.Alertmanager, aH.Signoz.Modules.Organization) + _, apiErr := auth.Register(context.Background(), req, aH.Signoz.Alertmanager, aH.Signoz.Modules.Organization, aH.QuickFilterModule) if apiErr != nil { RespondError(w, apiErr, nil) return diff --git a/pkg/query-service/app/server.go b/pkg/query-service/app/server.go index 1b0d544273..80a26d87f7 100644 --- a/pkg/query-service/app/server.go +++ b/pkg/query-service/app/server.go @@ -14,6 +14,8 @@ import ( "github.com/SigNoz/signoz/pkg/alertmanager" "github.com/SigNoz/signoz/pkg/apis/fields" "github.com/SigNoz/signoz/pkg/http/middleware" + "github.com/SigNoz/signoz/pkg/modules/quickfilter" + quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" "github.com/SigNoz/signoz/pkg/prometheus" "github.com/SigNoz/signoz/pkg/query-service/agentConf" "github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader" @@ -156,6 +158,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { } telemetry.GetInstance().SetReader(reader) + quickFilterModule := quickfilter.NewAPI(quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(serverOptions.SigNoz.SQLStore))) apiHandler, err := NewAPIHandler(APIHandlerOpts{ Reader: reader, PreferSpanMetrics: serverOptions.PreferSpanMetrics, @@ -171,6 +174,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { AlertmanagerAPI: alertmanager.NewAPI(serverOptions.SigNoz.Alertmanager), FieldsAPI: fields.NewAPI(serverOptions.SigNoz.TelemetryStore), Signoz: serverOptions.SigNoz, + QuickFilters: quickFilterModule, }) if err != nil { return nil, err diff --git a/pkg/query-service/auth/auth.go b/pkg/query-service/auth/auth.go index 4af12fb1af..a09f982bb7 100644 --- a/pkg/query-service/auth/auth.go +++ b/pkg/query-service/auth/auth.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "github.com/SigNoz/signoz/pkg/modules/quickfilter" "os" "text/template" "time" @@ -529,7 +530,7 @@ func RegisterInvitedUser(ctx context.Context, req *RegisterRequest, nopassword b // Register registers a new user. For the first register request, it doesn't need an invite token // and also the first registration is an enforced ADMIN registration. Every subsequent request will // need an invite token to go through. -func Register(ctx context.Context, req *RegisterRequest, alertmanager alertmanager.Alertmanager, organizationModule organization.Module) (*types.User, *model.ApiError) { +func Register(ctx context.Context, req *RegisterRequest, alertmanager alertmanager.Alertmanager, organizationModule organization.Module, quickfiltermodule quickfilter.Usecase) (*types.User, *model.ApiError) { users, err := dao.DB().GetUsers(ctx) if err != nil { return nil, model.InternalError(fmt.Errorf("failed to get user count")) @@ -545,7 +546,9 @@ func Register(ctx context.Context, req *RegisterRequest, alertmanager alertmanag if err := alertmanager.SetDefaultConfig(ctx, user.OrgID); err != nil { return nil, model.InternalError(err) } - + if err := quickfiltermodule.SetDefaultConfig(ctx, valuer.MustNewUUID(user.OrgID)); err != nil { + return nil, model.InternalError(err) + } return user, nil default: return RegisterInvitedUser(ctx, req, false) diff --git a/pkg/query-service/tests/integration/filter_suggestions_test.go b/pkg/query-service/tests/integration/filter_suggestions_test.go index 2f0b22db14..bb7987d360 100644 --- a/pkg/query-service/tests/integration/filter_suggestions_test.go +++ b/pkg/query-service/tests/integration/filter_suggestions_test.go @@ -4,6 +4,8 @@ import ( "encoding/base64" "encoding/json" "fmt" + "github.com/SigNoz/signoz/pkg/modules/quickfilter" + quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" "net/http" "slices" "strings" @@ -299,6 +301,7 @@ func NewFilterSuggestionsTestBed(t *testing.T) *FilterSuggestionsTestBed { mockClickhouse.MatchExpectationsInOrder(false) modules := signoz.NewModules(testDB) + quickFilterModule := quickfilter.NewAPI(quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(testDB))) apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{ Reader: reader, @@ -309,6 +312,7 @@ func NewFilterSuggestionsTestBed(t *testing.T) *FilterSuggestionsTestBed { Modules: modules, Handlers: signoz.NewHandlers(modules), }, + QuickFilters: quickFilterModule, }) if err != nil { t.Fatalf("could not create a new ApiHandler: %v", err) diff --git a/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go b/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go index a15c860e0d..77a5478bc3 100644 --- a/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go +++ b/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go @@ -3,6 +3,8 @@ package tests import ( "encoding/json" "fmt" + "github.com/SigNoz/signoz/pkg/modules/quickfilter" + quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" "net/http" "strings" "testing" @@ -363,6 +365,7 @@ func NewCloudIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *CloudI modules := signoz.NewModules(testDB) handlers := signoz.NewHandlers(modules) + quickFilterModule := quickfilter.NewAPI(quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(testDB))) apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{ Reader: reader, @@ -374,6 +377,7 @@ func NewCloudIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *CloudI Modules: modules, Handlers: handlers, }, + QuickFilters: quickFilterModule, }) if err != nil { t.Fatalf("could not create a new ApiHandler: %v", err) diff --git a/pkg/query-service/tests/integration/signoz_integrations_test.go b/pkg/query-service/tests/integration/signoz_integrations_test.go index 06e3184833..3c02027130 100644 --- a/pkg/query-service/tests/integration/signoz_integrations_test.go +++ b/pkg/query-service/tests/integration/signoz_integrations_test.go @@ -3,6 +3,8 @@ package tests import ( "encoding/json" "fmt" + "github.com/SigNoz/signoz/pkg/modules/quickfilter" + quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" "net/http" "slices" "testing" @@ -569,6 +571,7 @@ func NewIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *Integration modules := signoz.NewModules(testDB) handlers := signoz.NewHandlers(modules) + quickFilterModule := quickfilter.NewAPI(quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(testDB))) apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{ Reader: reader, @@ -581,6 +584,7 @@ func NewIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *Integration Modules: modules, Handlers: handlers, }, + QuickFilters: quickFilterModule, }) if err != nil { t.Fatalf("could not create a new ApiHandler: %v", err) diff --git a/pkg/signoz/provider.go b/pkg/signoz/provider.go index 1e9f1878a2..9b35c4cf5c 100644 --- a/pkg/signoz/provider.go +++ b/pkg/signoz/provider.go @@ -74,6 +74,7 @@ func NewSQLMigrationProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedM sqlmigration.NewUpdateIntegrationsFactory(sqlstore), sqlmigration.NewUpdateOrganizationsFactory(sqlstore), sqlmigration.NewDropGroupsFactory(sqlstore), + sqlmigration.NewCreateQuickFiltersFactory(sqlstore), ) } diff --git a/pkg/sqlmigration/030_create_quick_filters.go b/pkg/sqlmigration/030_create_quick_filters.go new file mode 100644 index 0000000000..61fe1a7852 --- /dev/null +++ b/pkg/sqlmigration/030_create_quick_filters.go @@ -0,0 +1,106 @@ +package sqlmigration + +import ( + "context" + "database/sql" + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/types/quickfiltertypes" + "github.com/SigNoz/signoz/pkg/valuer" + "github.com/uptrace/bun" + "github.com/uptrace/bun/migrate" +) + +type createQuickFilters struct { + store sqlstore.SQLStore +} + +type quickFilter struct { + bun.BaseModel `bun:"table:quick_filter"` + types.Identifiable + OrgID string `bun:"org_id,notnull,unique:org_id_signal,type:text"` + Filter string `bun:"filter,notnull,type:text"` + Signal string `bun:"signal,notnull,unique:org_id_signal,type:text"` + types.TimeAuditable + types.UserAuditable +} + +func NewCreateQuickFiltersFactory(store sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] { + return factory.NewProviderFactory(factory.MustNewName("create_quick_filters"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) { + return &createQuickFilters{store: store}, nil + }) +} + +func (m *createQuickFilters) Register(migrations *migrate.Migrations) error { + return migrations.Register(m.Up, m.Down) +} + +func (m *createQuickFilters) Up(ctx context.Context, db *bun.DB) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + // Create table if not exists + _, err = tx.NewCreateTable(). + Model((*quickFilter)(nil)). + IfNotExists(). + ForeignKey(`("org_id") REFERENCES "organizations" ("id") ON DELETE CASCADE ON UPDATE CASCADE`). + Exec(ctx) + if err != nil { + return err + } + + // Get default organization ID + var defaultOrg valuer.UUID + err = tx.NewSelect().Table("organizations").Column("id").Limit(1).Scan(ctx, &defaultOrg) + if err != nil { + if err == sql.ErrNoRows { + // No organizations found, nothing to insert, commit and return + err := tx.Commit() + if err != nil { + return err + } + return nil + } + return err + } + + // Get the default quick filters + storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(defaultOrg) + if err != nil { + return err + } + + // For SQLite, insert each filter individually with proper conflict handling + for _, filter := range storableQuickFilters { + // Check if the record already exists + exists, err := tx.NewSelect(). + Model((*quickFilter)(nil)). + Where("org_id = ? AND signal = ?", filter.OrgID, filter.Signal). + Exists(ctx) + if err != nil { + return err + } + + // Only insert if it doesn't exist + if !exists { + _, err = tx.NewInsert(). + Model(&filter). + Exec(ctx) + + if err != nil { + return err + } + } + } + + // Commit the transaction + return tx.Commit() +} + +func (m *createQuickFilters) Down(ctx context.Context, db *bun.DB) error { + return nil +} diff --git a/pkg/types/quickfiltertypes/filter.go b/pkg/types/quickfiltertypes/filter.go new file mode 100644 index 0000000000..3e4dd873be --- /dev/null +++ b/pkg/types/quickfiltertypes/filter.go @@ -0,0 +1,234 @@ +package quickfiltertypes + +import ( + "encoding/json" + "fmt" + "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" + "time" +) + +type Signal struct { + valuer.String +} + +func (enum *Signal) UnmarshalJSON(data []byte) error { + var str string + if err := json.Unmarshal(data, &str); err != nil { + return err + } + + signal, err := NewSignal(str) + if err != nil { + return err + } + + *enum = signal + return nil +} + +var ( + SignalTraces = Signal{valuer.NewString("traces")} + SignalLogs = Signal{valuer.NewString("logs")} + SignalApiMonitoring = Signal{valuer.NewString("api_monitoring")} + SignalExceptions = Signal{valuer.NewString("exceptions")} +) + +// NewSignal creates a Signal from a string +func NewSignal(s string) (Signal, error) { + switch s { + case "traces": + return SignalTraces, nil + case "logs": + return SignalLogs, nil + case "api_monitoring": + return SignalApiMonitoring, nil + case "exceptions": + return SignalExceptions, nil + default: + return Signal{}, errors.New(errors.TypeInternal, errors.CodeInternal, "invalid signal: "+s) + } +} + +type StorableQuickFilter struct { + bun.BaseModel `bun:"table:quick_filter"` + types.Identifiable + OrgID valuer.UUID `bun:"org_id,type:text,notnull"` + Filter string `bun:"filter,type:text,notnull"` + Signal Signal `bun:"signal,type:text,notnull"` + types.TimeAuditable +} + +type SignalFilters struct { + Signal Signal `json:"signal"` + Filters []v3.AttributeKey `json:"filters"` +} + +type UpdatableQuickFilters struct { + Signal Signal `json:"signal"` + Filters []v3.AttributeKey `json:"filters"` +} + +// NewStorableQuickFilter creates a new StorableQuickFilter after validation +func NewStorableQuickFilter(orgID valuer.UUID, signal Signal, filterJSON []byte) (*StorableQuickFilter, error) { + if orgID.StringValue() == "" { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgID is required") + } + + if _, err := NewSignal(signal.StringValue()); err != nil { + return nil, err + } + + var filters []v3.AttributeKey + if err := json.Unmarshal(filterJSON, &filters); err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid filter JSON") + } + + now := time.Now() + return &StorableQuickFilter{ + Identifiable: types.Identifiable{ + ID: valuer.GenerateUUID(), + }, + OrgID: orgID, + Signal: signal, + Filter: string(filterJSON), + TimeAuditable: types.TimeAuditable{ + CreatedAt: now, + UpdatedAt: now, + }, + }, nil +} + +// Update updates an existing StorableQuickFilter with new filter data after validation +func (quickfilter *StorableQuickFilter) Update(filterJSON []byte) error { + var filters []v3.AttributeKey + if err := json.Unmarshal(filterJSON, &filters); err != nil { + return errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid filter JSON") + } + + quickfilter.Filter = string(filterJSON) + quickfilter.UpdatedAt = time.Now() + return nil +} + +// NewSignalFilterFromStorableQuickFilter converts a StorableQuickFilter to a SignalFilters object +func NewSignalFilterFromStorableQuickFilter(storableQuickFilter *StorableQuickFilter) (*SignalFilters, error) { + if storableQuickFilter == nil { + return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "storableQuickFilter cannot be nil") + } + + var filters []v3.AttributeKey + if storableQuickFilter.Filter != "" { + err := json.Unmarshal([]byte(storableQuickFilter.Filter), &filters) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error unmarshalling filters") + } + } + + return &SignalFilters{ + Signal: storableQuickFilter.Signal, + Filters: filters, + }, nil +} + +// NewDefaultQuickFilter generates default filters for all supported signals +func NewDefaultQuickFilter(orgID valuer.UUID) ([]*StorableQuickFilter, error) { + tracesFilters := []map[string]interface{}{ + {"key": "duration_nano", "dataType": "float64", "type": "tag"}, + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "hasError", "dataType": "bool", "type": "tag"}, + {"key": "serviceName", "dataType": "string", "type": "tag"}, + {"key": "name", "dataType": "string", "type": "resource"}, + {"key": "rpcMethod", "dataType": "string", "type": "tag"}, + {"key": "responseStatusCode", "dataType": "string", "type": "resource"}, + {"key": "httpHost", "dataType": "string", "type": "tag"}, + {"key": "httpMethod", "dataType": "string", "type": "tag"}, + {"key": "httpRoute", "dataType": "string", "type": "tag"}, + {"key": "httpUrl", "dataType": "string", "type": "tag"}, + {"key": "traceID", "dataType": "string", "type": "tag"}, + } + + logsFilters := []map[string]interface{}{ + {"key": "severity_text", "dataType": "string", "type": "resource"}, + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "serviceName", "dataType": "string", "type": "tag"}, + {"key": "host.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.cluster.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.deployment.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.namespace.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.pod.name", "dataType": "string", "type": "resource"}, + } + + apiMonitoringFilters := []map[string]interface{}{ + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "serviceName", "dataType": "string", "type": "tag"}, + {"key": "rpcMethod", "dataType": "string", "type": "tag"}, + } + + exceptionsFilters := []map[string]interface{}{ + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "serviceName", "dataType": "string", "type": "tag"}, + {"key": "host.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.cluster.name", "dataType": "string", "type": "tag"}, + {"key": "k8s.deployment.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.namespace.name", "dataType": "string", "type": "tag"}, + {"key": "k8s.pod.name", "dataType": "string", "type": "tag"}, + } + + tracesJSON, err := json.Marshal(tracesFilters) + if err != nil { + return nil, fmt.Errorf("failed to marshal traces filters: %w", err) + } + + logsJSON, err := json.Marshal(logsFilters) + if err != nil { + return nil, fmt.Errorf("failed to marshal logs filters: %w", err) + } + + apiMonitoringJSON, err := json.Marshal(apiMonitoringFilters) + if err != nil { + return nil, fmt.Errorf("failed to marshal API monitoring filters: %w", err) + } + exceptionsJSON, err := json.Marshal(exceptionsFilters) + if err != nil { + return nil, fmt.Errorf("failed to marshal exceptions filters: %w", err) + } + + return []*StorableQuickFilter{ + { + Identifiable: types.Identifiable{ + ID: valuer.GenerateUUID(), + }, + OrgID: orgID, + Filter: string(tracesJSON), + Signal: SignalTraces, + }, + { + Identifiable: types.Identifiable{ + ID: valuer.GenerateUUID(), + }, + OrgID: orgID, + Filter: string(logsJSON), + Signal: SignalLogs, + }, + { + Identifiable: types.Identifiable{ + ID: valuer.GenerateUUID(), + }, + OrgID: orgID, + Filter: string(apiMonitoringJSON), + Signal: SignalApiMonitoring, + }, + { + Identifiable: types.Identifiable{ + ID: valuer.GenerateUUID(), + }, + OrgID: orgID, + Filter: string(exceptionsJSON), + Signal: SignalExceptions, + }, + }, nil +} diff --git a/pkg/types/quickfiltertypes/store.go b/pkg/types/quickfiltertypes/store.go new file mode 100644 index 0000000000..bae60af14f --- /dev/null +++ b/pkg/types/quickfiltertypes/store.go @@ -0,0 +1,18 @@ +package quickfiltertypes + +import ( + "context" + "github.com/SigNoz/signoz/pkg/valuer" +) + +type QuickFilterStore interface { + // Get retrieves all filters for an organization + Get(ctx context.Context, orgID valuer.UUID) ([]*StorableQuickFilter, error) + + // GetBySignal retrieves filters for a specific signal in an organization + GetBySignal(ctx context.Context, orgID valuer.UUID, signal string) (*StorableQuickFilter, error) + + // Upsert inserts or updates filters for an organization and signal + Upsert(ctx context.Context, filter *StorableQuickFilter) error + Create(ctx context.Context, filter []*StorableQuickFilter) error +}