Compare commits

...

3 Commits

Author SHA1 Message Date
vikrantgupta25
1798abfc6b chore(docs): regenerate openapi spec with telemetry read scopes 2026-07-07 14:59:00 +05:30
vikrantgupta25
f155f71051 feat(authz): widen telemetry selector segments to 128 bits
64-bit truncation permits chosen-collision attacks at ~2^32 work; 128 bits
pushes this to 2^64. No hashed selector is persisted yet, so the change is
free.
2026-07-07 14:57:08 +05:30
vikrantgupta25
4559a78752 feat(authz): enable FGA for telemetry resources on v5 query_range
Authorize /api/v5/query_range and /preview at the telemetry-resource level,
derived from the request body:

- coretypes: ResourceWithID + ResourceExtractor as the resource-level analogue
  of the id extractors; NewResolvedResourceWithID/NewResolvedResourceWithError;
  telemetryresource selector regex widened to query-type selectors with up to
  two hashed segments (metric name, where clause) or wildcards
- telemetrytypes: QueryRangeResources maps each query to its telemetry
  resource (signal/source aware: audit-logs, meter-metrics) with a hierarchical
  selector id (query_type/<hash(metric)>/<hash(where)>); PrefixSelector expands
  the id into the grant ladder [exact, prefix/*..., *]
- handler: generic TelemetryResourceDef fans out an injected ResourceExtractor;
  fails closed when extraction errors or resolves nothing
- audit: log and skip resolved resources that carry a resolution error
- querier routes: ViewAccess -> CheckResources with telemetry read scopes;
  substitute_vars stays ViewAccess (no telemetry access)
- sqlmigration 099: backfill telemetry read tuples for existing orgs
  (admin: logs/traces/metrics/audit-logs/meter-metrics; editor/viewer:
  logs/traces/metrics)
2026-07-07 14:27:20 +05:30
14 changed files with 648 additions and 12 deletions

View File

@@ -24118,9 +24118,17 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- VIEWER
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Query range
tags:
- querier
@@ -24187,9 +24195,17 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- VIEWER
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Query range preview
tags:
- querier

View File

@@ -5,12 +5,25 @@ import (
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/gorilla/mux"
)
func telemetryReadScopes() []string {
return []string{
coretypes.ResourceTelemetryResourceLogs.Scope(coretypes.VerbRead),
coretypes.ResourceTelemetryResourceTraces.Scope(coretypes.VerbRead),
coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead),
coretypes.ResourceTelemetryResourceAuditLogs.Scope(coretypes.VerbRead),
coretypes.ResourceTelemetryResourceMeterMetrics.Scope(coretypes.VerbRead),
}
}
func (provider *provider) addQuerierRoutes(router *mux.Router) error {
if err := router.Handle("/api/v5/query_range", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.QueryRange), handler.OpenAPIDef{
if err := router.Handle("/api/v5/query_range", handler.New(provider.authzMiddleware.CheckResources(provider.querierHandler.QueryRange, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName), handler.OpenAPIDef{
ID: "QueryRangeV5",
Tags: []string{"querier"},
Summary: "Query range",
@@ -446,12 +459,17 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes(telemetryReadScopes()),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: telemetrytypes.PrefixSelector,
Resources: telemetrytypes.QueryRangeResources,
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v5/query_range/preview", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.QueryRangePreview), handler.OpenAPIDef{
if err := router.Handle("/api/v5/query_range/preview", handler.New(provider.authzMiddleware.CheckResources(provider.querierHandler.QueryRangePreview, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName), handler.OpenAPIDef{
ID: "QueryRangePreviewV5",
Tags: []string{"querier"},
Summary: "Query range preview",
@@ -463,8 +481,13 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes(telemetryReadScopes()),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: telemetrytypes.PrefixSelector,
Resources: telemetrytypes.QueryRangeResources,
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}

View File

@@ -1,6 +1,9 @@
package handler
import "github.com/SigNoz/signoz/pkg/types/coretypes"
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/coretypes"
)
type ResourceDef interface {
// resolveRequest is unexported to seal the interface. It returns a slice so a
@@ -97,3 +100,31 @@ func (def AttachDetachParentChildResourceDef) resolveRequest(ec coretypes.Extrac
),
}
}
type TelemetryResourceDef struct {
Verb coretypes.Verb
Category coretypes.ActionCategory
Selector coretypes.SelectorFunc
Resources coretypes.ResourceExtractor
}
func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
refs, err := def.Resources(ec)
if err != nil {
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(def.Verb, def.Category, err)}
}
if len(refs) == 0 {
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(
def.Verb,
def.Category,
errors.NewInvalidInputf(errors.CodeInvalidInput, "request resolved to no resources"),
)}
}
resolved := make([]coretypes.ResolvedResource, 0, len(refs))
for _, ref := range refs {
resolved = append(resolved, coretypes.NewResolvedResourceWithID(def.Verb, def.Category, ref.Resource, ref.ID, def.Selector))
}
return resolved
}

View File

@@ -118,6 +118,10 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
extractorCtx := coretypes.ExtractorContext{Request: req, ResponseBody: writer.BodyBytes()}
for _, resource := range resolved {
if err := resource.Err(); err != nil {
continue
}
resource.ResolveResponse(extractorCtx)
verb, category := resource.Verb(), resource.Category()

View File

@@ -218,6 +218,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewMigrateSSORoleMappingNamesFactory(sqlstore),
sqlmigration.NewAddMetricReductionRulesFactory(sqlstore, sqlschema),
sqlmigration.NewRemoveOrganizationTuplesFactory(sqlstore),
sqlmigration.NewAddTelemetryTuplesFactory(sqlstore),
)
}

View File

@@ -0,0 +1,144 @@
package sqlmigration
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addTelemetryTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddTelemetryTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_telemetry_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addTelemetryTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addTelemetryTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addTelemetryTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
var orgIDs []string
rows, err := tx.QueryContext(ctx, `SELECT id FROM organizations`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var orgID string
if err := rows.Scan(&orgID); err != nil {
return err
}
orgIDs = append(orgIDs, orgID)
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
tuples := []migrationTuple{
{authtypes.SigNozAdminRoleName, "telemetryresource", "logs", "read"},
{authtypes.SigNozAdminRoleName, "telemetryresource", "traces", "read"},
{authtypes.SigNozAdminRoleName, "telemetryresource", "metrics", "read"},
{authtypes.SigNozAdminRoleName, "telemetryresource", "audit-logs", "read"},
{authtypes.SigNozAdminRoleName, "telemetryresource", "meter-metrics", "read"},
{authtypes.SigNozEditorRoleName, "telemetryresource", "logs", "read"},
{authtypes.SigNozEditorRoleName, "telemetryresource", "traces", "read"},
{authtypes.SigNozEditorRoleName, "telemetryresource", "metrics", "read"},
{authtypes.SigNozViewerRoleName, "telemetryresource", "logs", "read"},
{authtypes.SigNozViewerRoleName, "telemetryresource", "traces", "read"},
{authtypes.SigNozViewerRoleName, "telemetryresource", "metrics", "read"},
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "TUPLE_OPERATION_WRITE", tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
return tx.Commit()
}
func (migration *addTelemetryTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -55,6 +55,13 @@ func OneID(extractor ResourceIDExtractor) ResourceIDsExtractor {
}}
}
type ResourceWithID struct {
Resource Resource
ID string
}
type ResourceExtractor func(ExtractorContext) ([]ResourceWithID, error)
func PathParam(name string) ResourceIDExtractor {
return ResourceIDExtractor{Phase: PhaseRequest, Fn: func(ec ExtractorContext) (string, error) {
if ec.Request == nil {

View File

@@ -23,5 +23,5 @@ var (
TypeRole = Type{valuer.NewString("role"), regexp.MustCompile(`^([a-z-]{1,50}|\*)$`), []Verb{VerbAssignee, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete, VerbAttach, VerbDetach}}
TypeOrganization = Type{valuer.NewString("organization"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbRead, VerbUpdate}}
TypeMetaResource = Type{valuer.NewString("metaresource"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete, VerbAttach, VerbDetach}}
TypeTelemetryResource = Type{valuer.NewString("telemetryresource"), regexp.MustCompile(`^\*$`), []Verb{VerbRead}}
TypeTelemetryResource = Type{valuer.NewString("telemetryresource"), regexp.MustCompile(`^(\*|(builder_query|builder_sub_query|builder_trace_operator|promql|clickhouse_sql)(/([a-f0-9]{32}|\*)){0,2})$`), []Verb{VerbRead}}
)

View File

@@ -30,6 +30,19 @@ func NewResolvedResource(
return resolved
}
func NewResolvedResourceWithID(verb Verb, category ActionCategory, resource Resource, id string, selector SelectorFunc) ResolvedResource {
resolved := &resolvedResource{verb: verb, category: category, resource: resource, selector: selector}
if id != "" {
resolved.ids = []string{id}
}
return resolved
}
func NewResolvedResourceWithError(verb Verb, category ActionCategory, err error) ResolvedResource {
return &resolvedResource{verb: verb, category: category, err: err}
}
func (resolved *resolvedResource) fill(phase ExtractPhase, ec ExtractorContext) {
if !resolved.idExtractor.IsPhase(phase) {
return

View File

@@ -0,0 +1,42 @@
package coretypes
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestTelemetryResourceSelectorRegex(t *testing.T) {
segment := "abcdef0123456789abcdef0123456789"
valid := []string{
"*",
"builder_query",
"promql",
"clickhouse_sql",
"builder_trace_operator",
"builder_sub_query",
"builder_query/" + segment,
"builder_query/*",
"builder_query/" + segment + "/" + segment,
"builder_query/" + segment + "/*",
}
for _, value := range valid {
_, err := TypeTelemetryResource.Selector(value)
assert.NoError(t, err, "expected %q to be a valid telemetry selector", value)
}
invalid := []string{
"",
"builder_formula",
"builder_join",
"trace_operator",
"builder_query/abc",
"builder_query/" + segment + "/" + segment + "/" + segment,
"unknown_type/" + segment,
}
for _, value := range invalid {
_, err := TypeTelemetryResource.Selector(value)
assert.Error(t, err, "expected %q to be rejected as a telemetry selector", value)
}
}

View File

@@ -0,0 +1,93 @@
package telemetrytypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/tidwall/gjson"
)
func QueryRangeResources(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
queries := gjson.GetBytes(ec.RequestBody, "compositeQuery.queries")
if !queries.IsArray() || len(queries.Array()) == 0 {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "composite query has no queries")
}
refs := make([]coretypes.ResourceWithID, 0, len(queries.Array()))
seen := make(map[string]struct{})
for _, query := range queries.Array() {
queryRefs, err := resourcesForQuery(query)
if err != nil {
return nil, err
}
for _, ref := range queryRefs {
key := ref.Resource.Kind().String() + ":" + ref.ID
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
refs = append(refs, ref)
}
}
return refs, nil
}
func resourcesForQuery(query gjson.Result) ([]coretypes.ResourceWithID, error) {
queryType := query.Get("type").String()
switch queryType {
case "builder_query", "builder_sub_query":
return resourcesForBuilderQuery(queryType, query.Get("spec"))
case "builder_trace_operator":
return []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceTraces, ID: queryType}}, nil
case "promql":
return []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: queryType}}, nil
case "clickhouse_sql":
return []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: queryType},
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: queryType},
{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: queryType},
}, nil
case "builder_formula", "builder_join":
return nil, nil
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported query type %q", queryType)
}
}
func resourcesForBuilderQuery(queryType string, spec gjson.Result) ([]coretypes.ResourceWithID, error) {
whereSegment := selectorSegment(spec.Get("filter.expression").String())
source := spec.Get("source").String()
switch spec.Get("signal").String() {
case SignalTraces.StringValue():
return []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceTraces, ID: queryType + "/" + whereSegment}}, nil
case SignalLogs.StringValue():
resource := coretypes.ResourceTelemetryResourceLogs
if source == SourceAudit.StringValue() {
resource = coretypes.ResourceTelemetryResourceAuditLogs
}
return []coretypes.ResourceWithID{{Resource: resource, ID: queryType + "/" + whereSegment}}, nil
case SignalMetrics.StringValue():
resource := coretypes.ResourceTelemetryResourceMetrics
if source == SourceMeter.StringValue() {
resource = coretypes.ResourceTelemetryResourceMeterMetrics
}
refs := make([]coretypes.ResourceWithID, 0, 1)
for _, aggregation := range spec.Get("aggregations").Array() {
refs = append(refs, coretypes.ResourceWithID{
Resource: resource,
ID: queryType + "/" + selectorSegment(aggregation.Get("metricName").String()) + "/" + whereSegment,
})
}
if len(refs) == 0 {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "metrics query has no aggregations")
}
return refs, nil
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported signal %q", spec.Get("signal").String())
}
}

View File

@@ -0,0 +1,133 @@
package telemetrytypes
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestQueryRangeResources(t *testing.T) {
whereSegment := selectorSegment("service.name = 'frontend'")
emptyWhereSegment := selectorSegment("")
metricSegment := selectorSegment("http.server.duration.count")
testCases := []struct {
name string
body string
expected map[string]string
}{
{
name: "traces builder query with filter",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"traces","filter":{"expression":"service.name = 'frontend'"}}}]}}`,
expected: map[string]string{
"traces": "builder_query/" + whereSegment,
},
},
{
name: "logs builder query without filter",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs"}}]}}`,
expected: map[string]string{
"logs": "builder_query/" + emptyWhereSegment,
},
},
{
name: "audit logs",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit"}}]}}`,
expected: map[string]string{
"audit-logs": "builder_query/" + emptyWhereSegment,
},
},
{
name: "metrics builder query",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"metrics","filter":{"expression":"service.name = 'frontend'"},"aggregations":[{"metricName":"http.server.duration.count"}]}}]}}`,
expected: map[string]string{
"metrics": "builder_query/" + metricSegment + "/" + whereSegment,
},
},
{
name: "meter metrics",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"metrics","source":"meter","aggregations":[{"metricName":"http.server.duration.count"}]}}]}}`,
expected: map[string]string{
"meter-metrics": "builder_query/" + metricSegment + "/" + emptyWhereSegment,
},
},
{
name: "promql",
body: `{"compositeQuery":{"queries":[{"type":"promql","spec":{"query":"sum(rate(foo[5m]))"}}]}}`,
expected: map[string]string{
"metrics": "promql",
},
},
{
name: "trace operator",
body: `{"compositeQuery":{"queries":[{"type":"builder_trace_operator","spec":{"expression":"A => B"}}]}}`,
expected: map[string]string{
"traces": "builder_trace_operator",
},
},
{
name: "clickhouse sql fans out to core signals",
body: `{"compositeQuery":{"queries":[{"type":"clickhouse_sql","spec":{"query":"SELECT 1"}}]}}`,
expected: map[string]string{
"logs": "clickhouse_sql",
"traces": "clickhouse_sql",
"metrics": "clickhouse_sql",
},
},
{
name: "duplicate queries deduplicated",
body: `{"compositeQuery":{"queries":[
{"type":"builder_query","spec":{"signal":"traces","filter":{"expression":"service.name = 'frontend'"}}},
{"type":"builder_query","spec":{"signal":"traces","filter":{"expression":"service.name = 'frontend'"}}},
{"type":"builder_formula","spec":{"name":"error_rate","expression":"A / B * 100"}}
]}}`,
expected: map[string]string{
"traces": "builder_query/" + whereSegment,
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
refs, err := QueryRangeResources(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
require.NoError(t, err)
require.Len(t, refs, len(testCase.expected))
actual := make(map[string]string, len(refs))
for _, ref := range refs {
actual[ref.Resource.Kind().String()] = ref.ID
}
assert.Equal(t, testCase.expected, actual)
})
}
}
func TestQueryRangeResourcesErrors(t *testing.T) {
testCases := []struct {
name string
body string
}{
{name: "empty body", body: ``},
{name: "empty object", body: `{}`},
{name: "empty queries", body: `{"compositeQuery":{"queries":[]}}`},
{name: "unknown query type", body: `{"compositeQuery":{"queries":[{"type":"magic","spec":{}}]}}`},
{name: "missing signal", body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{}}]}}`},
{name: "unknown signal", body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"events"}}]}}`},
{name: "metrics without aggregations", body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"metrics"}}]}}`},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
_, err := QueryRangeResources(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
assert.Error(t, err)
})
}
}
func TestQueryRangeResourcesFormulaOnly(t *testing.T) {
refs, err := QueryRangeResources(coretypes.ExtractorContext{RequestBody: []byte(`{"compositeQuery":{"queries":[{"type":"builder_formula","spec":{"expression":"A / B"}}]}}`)})
require.NoError(t, err)
assert.Empty(t, refs)
}

View File

@@ -0,0 +1,51 @@
package telemetrytypes
import (
"context"
"crypto/sha256"
"encoding/hex"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
var errCodeInvalidResourceID = errors.MustNewCode("invalid_resource_id")
var PrefixSelector coretypes.SelectorFunc = func(_ context.Context, resource coretypes.Resource, id string, _ valuer.UUID) ([]coretypes.Selector, error) {
if id == "" {
return nil, errors.Newf(
errors.TypeInvalidInput,
errCodeInvalidResourceID,
"resource id is required for %s",
resource.Kind().String(),
)
}
segments := strings.Split(id, "/")
values := make([]string, 0, len(segments)+1)
values = append(values, id)
for level := len(segments) - 1; level >= 1; level-- {
values = append(values, strings.Join(segments[:level], "/")+"/*")
}
values = append(values, coretypes.WildCardSelectorString)
selectors := make([]coretypes.Selector, 0, len(values))
for _, value := range values {
selector, err := resource.Type().Selector(value)
if err != nil {
return nil, err
}
selectors = append(selectors, selector)
}
return selectors, nil
}
// Must stay stable: grant-time callers rely on producing the same segment for the same input.
func selectorSegment(input string) string {
normalized := strings.Join(strings.Fields(input), " ")
sum := sha256.Sum256([]byte(normalized))
return hex.EncodeToString(sum[:16])
}

View File

@@ -0,0 +1,78 @@
package telemetrytypes
import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSelectorSegment(t *testing.T) {
assert.Len(t, selectorSegment("service.name = 'frontend'"), 32)
assert.Equal(t,
selectorSegment("service.name = 'frontend'"),
selectorSegment("service.name = 'frontend'"),
)
assert.NotEqual(t,
selectorSegment("service.name = 'frontend'"),
selectorSegment("service.name = 'backend'"),
)
assert.Equal(t, selectorSegment(""), selectorSegment(" "))
}
func TestPrefixSelector(t *testing.T) {
ctx := context.Background()
orgID := valuer.GenerateUUID()
metricSegment := selectorSegment("http.server.duration.count")
whereSegment := selectorSegment("service.name = 'frontend'")
testCases := []struct {
name string
id string
expected []string
}{
{
name: "two segments",
id: "builder_query/" + metricSegment + "/" + whereSegment,
expected: []string{
"builder_query/" + metricSegment + "/" + whereSegment,
"builder_query/" + metricSegment + "/*",
"builder_query/*",
"*",
},
},
{
name: "one segment",
id: "builder_query/" + whereSegment,
expected: []string{
"builder_query/" + whereSegment,
"builder_query/*",
"*",
},
},
{
name: "query type only",
id: "promql",
expected: []string{"promql", "*"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
selectors, err := PrefixSelector(ctx, coretypes.ResourceTelemetryResourceMetrics, testCase.id, orgID)
require.NoError(t, err)
values := make([]string, len(selectors))
for idx, selector := range selectors {
values[idx] = selector.String()
}
assert.Equal(t, testCase.expected, values)
})
}
_, err := PrefixSelector(ctx, coretypes.ResourceTelemetryResourceMetrics, "", orgID)
assert.Error(t, err)
}