Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions backend/app/controllers/profile.controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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{}
1 change: 1 addition & 0 deletions backend/app/controllers/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions backend/app/repositories/profile.repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions backend/app/repositories/profile.repository_labels.go
Original file line number Diff line number Diff line change
@@ -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
}
26 changes: 26 additions & 0 deletions backend/app/repositories/profile.repository_sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}

Expand Down Expand Up @@ -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 ""
Expand Down
78 changes: 78 additions & 0 deletions backend/app/repositories/profile_labels_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading