From 88fb21159ca8e583ce115fab4859ed0c313bc8df Mon Sep 17 00:00:00 2001 From: Thomas Henry Thirlwall Date: Wed, 24 Jun 2026 15:26:43 -0500 Subject: [PATCH 1/2] feat(profiling): add label-discovery endpoint Add POST /profiles/labels returning a map of allowlisted label key to distinct values from profiling_samples.labels, filtered by service, type, and time range. The allowlist currently exposes only "endpoint". The repository method mirrors the existing metric tag discovery (DiscoverTagValues): one DISTINCT query per allowlisted key. The shared allowlist loop lives in a build-tag-agnostic file so only the dialect-specific query is split across the pgch and sqlite builds. Co-Authored-By: Claude Opus 4.8 --- backend/app/controllers/profile.controller.go | 33 ++++++++ backend/app/controllers/routes.go | 1 + .../app/repositories/profile.repository.go | 24 ++++++ .../repositories/profile.repository_labels.go | 26 +++++++ .../repositories/profile.repository_sqlite.go | 26 +++++++ .../app/repositories/profile_labels_test.go | 78 +++++++++++++++++++ 6 files changed, 188 insertions(+) create mode 100644 backend/app/repositories/profile.repository_labels.go create mode 100644 backend/app/repositories/profile_labels_test.go 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..3d547862 100644 --- a/backend/app/repositories/profile.repository_sqlite.go +++ b/backend/app/repositories/profile.repository_sqlite.go @@ -288,6 +288,32 @@ 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) { + type labelValueRow struct { + Value string `lit:"v"` + } + lit.RegisterModel[labelValueRow](db.Driver) + + 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) + } +} From 9ae34015f805b161e0071ff8f92536f142ee1eaa Mon Sep 17 00:00:00 2001 From: Thomas Henry Thirlwall Date: Wed, 24 Jun 2026 15:40:19 -0500 Subject: [PATCH 2/2] fix(profiling): register labelValueRow once at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit distinctLabelValues called lit.RegisterModel on every request, writing lit's unsynchronized global model map while concurrent lit queries read it — a potential "concurrent map writes" fatal crash under parallel SQLite-mode requests. Register the result model once in init() via models.ExtensionModelRegistrations, matching profileGroupRow. Co-Authored-By: Claude Opus 4.8 --- backend/app/repositories/profile.repository_sqlite.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/app/repositories/profile.repository_sqlite.go b/backend/app/repositories/profile.repository_sqlite.go index 3d547862..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) }) } @@ -289,11 +294,6 @@ func (r *profileRepository) GetFlameGraph(ctx context.Context, projectId uuid.UU } func (r *profileRepository) distinctLabelValues(ctx context.Context, projectId uuid.UUID, service, profileType, key string, from, to time.Time) ([]string, error) { - type labelValueRow struct { - Value string `lit:"v"` - } - lit.RegisterModel[labelValueRow](db.Driver) - results, err := lit.SelectNamed[labelValueRow](db.TelemetryDB, `SELECT DISTINCT json_extract(labels, '$.' || :key) AS v FROM profiling_samples