mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-21 13:40:38 +01:00
Compare commits
3 Commits
main
...
nv/tags-or
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2cd1ba01b | ||
|
|
65b7df618a | ||
|
|
354cd2540d |
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -221,6 +221,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddRoleTransactionGroupsFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddTagUniqueIndexFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddTelemetryTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddTagRelationRankFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
74
pkg/sqlmigration/102_add_tag_relation_rank.go
Normal file
74
pkg/sqlmigration/102_add_tag_relation_rank.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user