diff --git a/backend/app/controllers/profile.controller.go b/backend/app/controllers/profile.controller.go index d8f55490..58f3940b 100644 --- a/backend/app/controllers/profile.controller.go +++ b/backend/app/controllers/profile.controller.go @@ -38,6 +38,13 @@ type ProfileFlameGraphRequest struct { Labels map[string]string `json:"labels"` } +type ProfileLabelsRequest struct { + FromDate time.Time `json:"fromDate"` + ToDate time.Time `json:"toDate"` + ServiceName string `json:"serviceName"` + Type string `json:"type"` +} + type ProfileSeriesPoint struct { Timestamp time.Time `json:"timestamp"` Value float64 `json:"value"` @@ -129,4 +136,30 @@ func (p profileController) GetFlameGraph(c *gin.Context) { c.JSON(http.StatusOK, services.FoldFlameGraph(rows)) } +func (p profileController) DiscoverLabels(c *gin.Context) { + projectId, err := middleware.GetProjectId(c) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) + return + } + + var request ProfileLabelsRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if request.ServiceName == "" || request.Type == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "serviceName and type are required"}) + return + } + + labels, err := repositories.ProfileRepository.DiscoverLabels(c, projectId, request.ServiceName, request.Type, request.FromDate, request.ToDate) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("error discovering profile labels: %w", err)) + return + } + + c.JSON(http.StatusOK, labels) +} + var ProfileController = profileController{} diff --git a/backend/app/controllers/routes.go b/backend/app/controllers/routes.go index 9df49653..642d5a8b 100644 --- a/backend/app/controllers/routes.go +++ b/backend/app/controllers/routes.go @@ -99,6 +99,7 @@ func RegisterControllers(router *gin.RouterGroup) { router.POST("/profiles/grouped", middleware.UseAppAuth, middleware.RequireProjectAccess, ProfileController.FindGroupedByService) router.POST("/profiles/series", middleware.UseAppAuth, middleware.RequireProjectAccess, ProfileController.GetSeries) router.POST("/profiles/flamegraph", middleware.UseAppAuth, middleware.RequireProjectAccess, ProfileController.GetFlameGraph) + router.POST("/profiles/labels", middleware.UseAppAuth, middleware.RequireProjectAccess, ProfileController.DiscoverLabels) router.POST("/ai-traces/grouped", middleware.UseAppAuth, middleware.RequireProjectAccess, AiTraceController.FindGroupedByTraceName) router.POST("/ai-traces/trace", middleware.UseAppAuth, middleware.RequireProjectAccess, AiTraceController.FindByTraceName) diff --git a/backend/app/repositories/profile.repository.go b/backend/app/repositories/profile.repository.go index 290f7891..ac09317e 100644 --- a/backend/app/repositories/profile.repository.go +++ b/backend/app/repositories/profile.repository.go @@ -227,6 +227,30 @@ func (r *profileRepository) GetFlameGraph(ctx context.Context, projectId uuid.UU return out, nil } +func (r *profileRepository) distinctLabelValues(ctx context.Context, projectId uuid.UUID, service, profileType, key string, from, to time.Time) ([]string, error) { + rows, err := chdb.Conn.Query(ctx, + `SELECT DISTINCT labels[?] AS v + FROM profiling_samples + WHERE project_id = ? AND type = ? AND service_name = ? AND start_time >= ? AND start_time <= ? + AND labels[?] != '' + ORDER BY v ASC`, + key, projectId, profileType, service, from, to, key) + if err != nil { + return nil, err + } + defer rows.Close() + + var values []string + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + return nil, err + } + values = append(values, v) + } + return values, nil +} + func chLabelFilter(qualifier string, filters map[string]string) (string, []interface{}) { if len(filters) == 0 { return "", nil diff --git a/backend/app/repositories/profile.repository_labels.go b/backend/app/repositories/profile.repository_labels.go new file mode 100644 index 00000000..32f6eeed --- /dev/null +++ b/backend/app/repositories/profile.repository_labels.go @@ -0,0 +1,26 @@ +package repositories + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +const profileLabelEndpoint = "endpoint" + +var allowedProfileLabels = []string{profileLabelEndpoint} + +func (r *profileRepository) DiscoverLabels(ctx context.Context, projectId uuid.UUID, service, profileType string, from, to time.Time) (map[string][]string, error) { + out := map[string][]string{} + for _, key := range allowedProfileLabels { + values, err := r.distinctLabelValues(ctx, projectId, service, profileType, key, from, to) + if err != nil { + return nil, err + } + if len(values) > 0 { + out[key] = values + } + } + return out, nil +} diff --git a/backend/app/repositories/profile.repository_sqlite.go b/backend/app/repositories/profile.repository_sqlite.go index ae338447..3d94985a 100644 --- a/backend/app/repositories/profile.repository_sqlite.go +++ b/backend/app/repositories/profile.repository_sqlite.go @@ -25,9 +25,14 @@ type profileGroupRow struct { LastSeen SQLiteTime `lit:"last_seen"` } +type labelValueRow struct { + Value string `lit:"v"` +} + func init() { models.ExtensionModelRegistrations = append(models.ExtensionModelRegistrations, func(driver lit.Driver) { lit.RegisterModel[profileGroupRow](driver) + lit.RegisterModel[labelValueRow](driver) }) } @@ -288,6 +293,27 @@ func (r *profileRepository) GetFlameGraph(ctx context.Context, projectId uuid.UU return out, sqlRows.Err() } +func (r *profileRepository) distinctLabelValues(ctx context.Context, projectId uuid.UUID, service, profileType, key string, from, to time.Time) ([]string, error) { + results, err := lit.SelectNamed[labelValueRow](db.TelemetryDB, + `SELECT DISTINCT json_extract(labels, '$.' || :key) AS v + FROM profiling_samples + WHERE project_id = :project_id AND type = :type AND service_name = :service + AND start_time >= :from AND start_time <= :to + AND json_extract(labels, '$.' || :key) IS NOT NULL + AND json_extract(labels, '$.' || :key) != '' + ORDER BY v ASC`, + lit.P{"project_id": projectId, "type": profileType, "service": service, "key": key, "from": NewSQLiteTime(from), "to": NewSQLiteTime(to)}) + if err != nil { + return nil, err + } + + values := make([]string, 0, len(results)) + for _, row := range results { + values = append(values, row.Value) + } + return values, nil +} + func sqliteLabelFilter(qualifier string, filters map[string]string, params lit.P) string { if len(filters) == 0 { return "" diff --git a/backend/app/repositories/profile_labels_test.go b/backend/app/repositories/profile_labels_test.go new file mode 100644 index 00000000..8a8aab32 --- /dev/null +++ b/backend/app/repositories/profile_labels_test.go @@ -0,0 +1,78 @@ +//go:build !pgch + +package repositories + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/tracewayapp/traceway/backend/app/models" + "github.com/tracewayapp/traceway/backend/app/profiling" +) + +func TestProfileRepository_DiscoverLabels(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + projectId := uuid.New() + base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + from := base.Add(-time.Hour) + to := base.Add(time.Hour) + + hashX := profiling.HashFrames([]string{"a"}) + sample := func(start time.Time, service, profileType string, labels map[string]string) models.ProfileSample { + return models.ProfileSample{ + ProjectId: projectId, ProfileId: uuid.New(), ServiceName: service, + Type: profileType, Start: start, End: start, StackHash: hashX, Value: 1, Labels: labels, + } + } + mustInsertSamples(t, ctx, + sample(base, "checkout", profiling.TypeCPUNanos, map[string]string{"endpoint": "/a"}), + sample(base, "checkout", profiling.TypeCPUNanos, map[string]string{"endpoint": "/b"}), + sample(base, "checkout", profiling.TypeCPUNanos, map[string]string{"endpoint": "/a"}), + sample(base, "checkout", profiling.TypeCPUNanos, map[string]string{"endpoint": "", "region": "us"}), + sample(base.Add(-2*time.Hour), "checkout", profiling.TypeCPUNanos, map[string]string{"endpoint": "/before"}), + sample(base, "billing", profiling.TypeCPUNanos, map[string]string{"endpoint": "/other-service"}), + sample(base, "checkout", profiling.TypeHeapInuseSpace, map[string]string{"endpoint": "/other-type"}), + ) + + labels, err := ProfileRepository.DiscoverLabels(ctx, projectId, "checkout", profiling.TypeCPUNanos, from, to) + if err != nil { + t.Fatalf("DiscoverLabels: %v", err) + } + + endpoints := labels["endpoint"] + if len(endpoints) != 2 || endpoints[0] != "/a" || endpoints[1] != "/b" { + t.Errorf("endpoint values = %v, want [/a /b] (deduped, sorted, time/service/type filtered)", endpoints) + } + if _, ok := labels["region"]; ok { + t.Errorf("non-allowlisted label key region must not be returned, got %v", labels) + } +} + +func TestProfileRepository_DiscoverLabels_NoValuesOmitsKey(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + projectId := uuid.New() + base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + from := base.Add(-time.Hour) + to := base.Add(time.Hour) + + hashX := profiling.HashFrames([]string{"a"}) + mustInsertSamples(t, ctx, + models.ProfileSample{ + ProjectId: projectId, ProfileId: uuid.New(), ServiceName: "checkout", + Type: profiling.TypeCPUNanos, Start: base, End: base, StackHash: hashX, Value: 1, + Labels: map[string]string{"endpoint": ""}, + }, + ) + + labels, err := ProfileRepository.DiscoverLabels(ctx, projectId, "checkout", profiling.TypeCPUNanos, from, to) + if err != nil { + t.Fatalf("DiscoverLabels: %v", err) + } + if _, ok := labels["endpoint"]; ok { + t.Errorf("a label key with no non-empty values must be omitted, got %v", labels) + } +}