Compare commits

...

3 Commits

Author SHA1 Message Date
Naman Verma
f2cd1ba01b fix: add rank column to tag relations to keep note of tag ordering in dashboards 2026-07-21 10:25:07 +05:30
Naman Verma
65b7df618a Merge branch 'main' into nv/tags-ordering 2026-07-21 07:55:21 +05:30
Naman Verma
354cd2540d fix: always sort dashboard tags in alphabetical order 2026-07-20 15:09:34 +05:30
8 changed files with 226 additions and 31 deletions

View File

@@ -45,17 +45,17 @@ func (m *module) createMany(ctx context.Context, orgID valuer.UUID, kind coretyp
return []*tagtypes.Tag{}, nil
}
toCreate, matched, err := m.resolve(ctx, orgID, kind, postable)
ordered, toCreate, err := m.resolve(ctx, orgID, kind, postable)
if err != nil {
return nil, err
}
created, err := m.store.CreateOrGet(ctx, toCreate)
if err != nil {
// CreateOrGet fills new rows' IDs in place; ordered shares those pointers.
if _, err := m.store.CreateOrGet(ctx, toCreate); err != nil {
return nil, err
}
return append(matched, created...), nil
return ordered, nil
}
func (m *module) syncLinksForResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, tagIDs []valuer.UUID) error {

View File

@@ -13,10 +13,9 @@ import (
// the existing tags for an org. Lookup is case-insensitive on both key and
// value (matching the storage uniqueness rule); when an existing row matches,
// its display casing is reused. Inputs are deduped on (LOWER(key), LOWER(value));
// the first input's casing wins on collisions. Returns:
// - toCreate: new Tag rows the caller should insert (with pre-generated IDs)
// - matched: existing rows the caller's input already pointed to. They
// already carry authoritative IDs from the store.
// the first input's casing wins on collisions. Returns the resolved tags in
// request order (deduped) plus the new subset to insert; the new tags share
// pointers with the ordered slice, so their IDs populate in place on insert.
func (m *module) resolve(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, postable []tagtypes.PostableTag) ([]*tagtypes.Tag, []*tagtypes.Tag, error) {
if len(postable) == 0 {
return nil, nil, nil
@@ -34,8 +33,8 @@ func (m *module) resolve(ctx context.Context, orgID valuer.UUID, kind coretypes.
}
seenInRequestAlready := make(map[string]struct{}, len(postable)) // postable can have the same tag multiple times
ordered := make([]*tagtypes.Tag, 0, len(postable))
toCreate := make([]*tagtypes.Tag, 0)
matched := make([]*tagtypes.Tag, 0)
for _, p := range postable {
key, value, err := tagtypes.ValidatePostableTag(p)
@@ -49,11 +48,13 @@ func (m *module) resolve(ctx context.Context, orgID valuer.UUID, kind coretypes.
seenInRequestAlready[lookup] = struct{}{}
if existingTag, ok := lowercaseTagsMap[lookup]; ok {
matched = append(matched, existingTag)
ordered = append(ordered, existingTag)
continue
}
toCreate = append(toCreate, tagtypes.NewTag(orgID, kind, key, value))
newTag := tagtypes.NewTag(orgID, kind, key, value)
ordered = append(ordered, newTag)
toCreate = append(toCreate, newTag)
}
return toCreate, matched, nil
return ordered, toCreate, nil
}

View File

@@ -20,14 +20,14 @@ func TestModule_Resolve(t *testing.T) {
store := tagtypestest.NewStore()
m := &module{store: store}
toCreate, matched, err := m.resolve(context.Background(), valuer.GenerateUUID(), testKind, nil)
ordered, toCreate, err := m.resolve(context.Background(), valuer.GenerateUUID(), testKind, nil)
require.NoError(t, err)
assert.Empty(t, ordered)
assert.Empty(t, toCreate)
assert.Empty(t, matched)
assert.Zero(t, store.ListCallCount, "should not hit store when input is empty")
})
t.Run("creates missing pairs and reuses existing", func(t *testing.T) {
t.Run("creates missing pairs and reuses existing, in request order", func(t *testing.T) {
orgID := valuer.GenerateUUID()
dbTag := tagtypes.NewTag(orgID, testKind, "team", "Pulse")
dbTag2 := tagtypes.NewTag(orgID, testKind, "Database", "redis")
@@ -35,22 +35,28 @@ func TestModule_Resolve(t *testing.T) {
store.Tags = []*tagtypes.Tag{dbTag, dbTag2}
m := &module{store: store}
toCreate, matched, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
ordered, toCreate, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
{Key: "team", Value: "events"}, // new
{Key: "DATABASE", Value: "REDIS"}, // case-only conflict
{Key: "Brand", Value: "New"}, // new
})
require.NoError(t, err)
// ordered mirrors the request: new, existing (reused pointer), new.
require.Len(t, ordered, 3)
assert.Equal(t, "team", ordered[0].Key)
assert.Equal(t, "events", ordered[0].Value)
assert.Same(t, dbTag2, ordered[1], "case-only conflict reuses the existing pointer with its authoritative ID")
assert.Equal(t, "Brand", ordered[2].Key)
assert.Equal(t, "New", ordered[2].Value)
createdLowerKVs := []string{}
for _, tg := range toCreate {
createdLowerKVs = append(createdLowerKVs, strings.ToLower(tg.Key)+"\x00"+strings.ToLower(tg.Value))
}
assert.ElementsMatch(t, []string{"team\x00events", "brand\x00new"}, createdLowerKVs,
"only the two missing pairs should be returned for insertion")
require.Len(t, matched, 1, "DATABASE:REDIS should hit the existing 'Database:redis' tag")
assert.Same(t, dbTag2, matched[0], "matched should return the existing pointer with its authoritative ID")
assert.Same(t, ordered[0], toCreate[0], "toCreate shares pointers with ordered so inserted IDs propagate")
})
t.Run("dedupes inputs that map to the same lower(key)+lower(value)", func(t *testing.T) {
@@ -58,14 +64,14 @@ func TestModule_Resolve(t *testing.T) {
store := tagtypestest.NewStore()
m := &module{store: store}
toCreate, matched, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
ordered, toCreate, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
{Key: "Foo", Value: "Bar"},
{Key: "foo", Value: "bar"},
{Key: "FOO", Value: "BAR"},
})
require.NoError(t, err)
require.Empty(t, matched)
require.Len(t, ordered, 1, "duplicate inputs must collapse into a single tag")
require.Len(t, toCreate, 1, "duplicate inputs must collapse into a single insert")
assert.Equal(t, "Foo", toCreate[0].Key, "first input's casing wins")
assert.Equal(t, "Bar", toCreate[0].Value, "first input's casing wins")
@@ -78,15 +84,15 @@ func TestModule_Resolve(t *testing.T) {
store.Tags = []*tagtypes.Tag{dbTag}
m := &module{store: store}
toCreate, matched, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
ordered, toCreate, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
{Key: "team", Value: "PULSE"},
})
require.NoError(t, err)
assert.Empty(t, toCreate)
require.Len(t, matched, 1)
assert.Equal(t, "Team", matched[0].Key)
assert.Equal(t, "Pulse", matched[0].Value)
require.Len(t, ordered, 1)
assert.Equal(t, "Team", ordered[0].Key)
assert.Equal(t, "Pulse", ordered[0].Value)
})
t.Run("propagates validation error from any input", func(t *testing.T) {

View File

@@ -44,6 +44,7 @@ func (s *store) ListByResource(ctx context.Context, orgID valuer.UUID, kind core
Where("tr.kind = ?", kind).
Where("tr.resource_id = ?", resourceID).
Where("tag.org_id = ?", orgID).
OrderExpr("tr.rank ASC").
Scan(ctx)
if err != nil {
return nil, err
@@ -71,6 +72,7 @@ func (s *store) ListByResources(ctx context.Context, orgID valuer.UUID, kind cor
Where("tr.kind = ?", kind).
Where("tr.resource_id IN (?)", bun.In(resourceIDs)).
Where("tag.org_id = ?", orgID).
OrderExpr("tr.rank ASC").
Scan(ctx)
if err != nil {
return nil, err
@@ -112,11 +114,14 @@ func (s *store) CreateRelations(ctx context.Context, relations []*tagtypes.TagRe
if len(relations) == 0 {
return nil
}
// On re-link (same tag, same resource) overwrite rank with the incoming
// position, so a reordered tag set persists its new order.
_, err := s.sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(&relations).
On("CONFLICT (kind, resource_id, tag_id) DO NOTHING").
On("CONFLICT (kind, resource_id, tag_id) DO UPDATE").
Set("rank = EXCLUDED.rank").
Exec(ctx)
return err
}

View File

@@ -221,6 +221,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddRoleTransactionGroupsFactory(sqlstore, sqlschema),
sqlmigration.NewAddTagUniqueIndexFactory(sqlstore, sqlschema),
sqlmigration.NewAddTelemetryTuplesFactory(sqlstore),
sqlmigration.NewAddTagRelationRankFactory(sqlstore, sqlschema),
)
}

View File

@@ -0,0 +1,74 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addTagRelationRank struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddTagRelationRankFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_tag_relation_rank"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addTagRelationRank{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *addTagRelationRank) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addTagRelationRank) Up(ctx context.Context, db *bun.DB) error {
// tag_relation has an FK to tag; disable FK enforcement for SQLite's
// recreate-table fallback.
if err := migration.sqlschema.ToggleFKEnforcement(ctx, db, false); err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
table, uniqueConstraints, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("tag_relation"))
if err != nil {
return err
}
// rank records a tag's position within its resource. Existing rows backfill to 0.
rankColumn := &sqlschema.Column{
Name: sqlschema.ColumnName("rank"),
DataType: sqlschema.DataTypeInteger,
Nullable: false,
}
sqls := migration.sqlschema.Operator().AddColumn(table, uniqueConstraints, rankColumn, 0)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return migration.sqlschema.ToggleFKEnforcement(ctx, db, true)
}
func (migration *addTagRelationRank) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -16,23 +16,27 @@ type TagRelation struct {
Kind coretypes.Kind `json:"kind" required:"true" bun:"kind,type:text,notnull"`
ResourceID valuer.UUID `json:"resourceId" required:"true" bun:"resource_id,type:text,notnull"`
TagID valuer.UUID `json:"tagId" required:"true" bun:"tag_id,type:text,notnull"`
CreatedAt time.Time `json:"createdAt" bun:"created_at,notnull"`
// Rank is the tag's position within its resource; reads order by it.
Rank int `json:"rank" bun:"rank,notnull"`
CreatedAt time.Time `json:"createdAt" bun:"created_at,notnull"`
}
func NewTagRelation(kind coretypes.Kind, resourceID valuer.UUID, tagID valuer.UUID) *TagRelation {
func NewTagRelation(kind coretypes.Kind, resourceID valuer.UUID, tagID valuer.UUID, rank int) *TagRelation {
return &TagRelation{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
Kind: kind,
ResourceID: resourceID,
TagID: tagID,
Rank: rank,
CreatedAt: time.Now(),
}
}
// NewTagRelations ranks each tag by its position so reads preserve order.
func NewTagRelations(kind coretypes.Kind, resourceID valuer.UUID, tagIDs []valuer.UUID) []*TagRelation {
relations := make([]*TagRelation, 0, len(tagIDs))
for _, tagID := range tagIDs {
relations = append(relations, NewTagRelation(kind, resourceID, tagID))
for rank, tagID := range tagIDs {
relations = append(relations, NewTagRelation(kind, resourceID, tagID, rank))
}
return relations
}

View File

@@ -570,7 +570,11 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
assert alpha["schemaVersion"] == "v6"
assert alpha["source"] == "user"
assert alpha["locked"] is False
assert {"key": "team", "value": "pulse"} in alpha["tags"]
# tags round-trip in the order sent — no reordering, no drift
assert alpha["tags"] == [
{"key": "team", "value": "pulse"},
{"key": "env", "value": "prod"},
]
# ── stage 3: list everything ─────────────────────────────────────────────
response = requests.get(
@@ -590,6 +594,13 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
"Epsilon Metrics",
"Zeta Overview",
}
# per-dashboard tags also round-trip in the order sent
delta = next(d for d in body["data"]["dashboards"] if d["spec"]["display"]["name"] == "Delta Storage")
assert delta["tags"] == [
{"key": "team", "value": "storage"},
{"key": "env", "value": "dev"},
{"key": "tier", "value": "critical"},
]
# top-level tags = org-wide distinct tag set, sorted case-insensitively
# by (key, value). Asserting the exact list (not a set) locks in the sort.
assert body["data"]["tags"] == [
@@ -1013,6 +1024,99 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
assert response.json()["data"]["id"] == clone["id"]
def test_dashboard_v2_tag_order_round_trips(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Tags must round-trip in the order the client sent them across every write
path — create, update, patch — so GitOps/Terraform state never drifts."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def tags_of(response: requests.Response) -> list[dict]:
return response.json()["data"]["tags"]
# ── create with tags in a deliberate order; "env" repeats with two values ─
created_order = [
{"key": "team", "value": "pulse"},
{"key": "env", "value": "prod"},
{"key": "env", "value": "staging"},
{"key": "tier", "value": "critical"},
]
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}}, "tags": created_order},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
dashboard_id = response.json()["data"]["id"]
assert tags_of(response) == created_order
# ── get echoes the same order ────────────────────────────────────────────
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert tags_of(response) == created_order
# ── update reordered; the two "env" values land at non-adjacent positions ─
reordered = [
{"key": "tier", "value": "critical"},
{"key": "env", "value": "staging"},
{"key": "team", "value": "pulse"},
{"key": "env", "value": "prod"},
]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}}, "tags": reordered},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert tags_of(response) == reordered
# ── patch appends a new tag (a third "env" value); it lands at the end ────
response = requests.patch(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
json=[{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "dev"}}],
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert tags_of(response) == reordered + [{"key": "env", "value": "dev"}]
# ── update reorders everything and adds another new tag → all in the new order ─
# "env" now carries three interleaved values
new_order = [
{"key": "env", "value": "prod"},
{"key": "team", "value": "pulse"},
{"key": "env", "value": "dev"},
{"key": "tier", "value": "critical"},
{"key": "env", "value": "staging"},
{"key": "team", "value": "storage"},
]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}}, "tags": new_order},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert tags_of(response) == new_order
# ── and the final order persists on a fresh read ─────────────────────────
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert tags_of(response) == new_order
def test_dashboard_v2_pin_limit(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument