Compare commits

..

6 Commits

Author SHA1 Message Date
srikanthccv
9f7b00dbd9 chore: add missing files 2026-06-25 00:38:24 +05:30
srikanthccv
db850d7fe5 chore: temp commit 2026-06-25 00:36:48 +05:30
srikanthccv
20953d801f Merge origin/main into issue-5340
Resolve pkg/telemetrymetrics/tables.go: adopt main's buffer/reduced table
constants (including the local-table variants) and keep this branch's
reduction-rules table constants. Update the metric-reduction ClickHouse
queries to main's renamed samples-reduced constants
(SamplesV4ReducedLast/SumTableName).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:25:31 +05:30
srikanthccv
efe021c141 chore: remove unneeded doc 2026-06-24 08:44:32 +05:30
srikanthccv
eb51ec3139 chore: temp commit 2026-06-24 08:08:34 +05:30
srikanthccv
2345d26192 feat(volume-control): rules API + UI for aggregating away metric labels 2026-06-23 09:24:35 +05:30
83 changed files with 3697 additions and 4428 deletions

View File

@@ -140,20 +140,3 @@ jobs:
run: |
go run cmd/enterprise/*.go generate config web-settings
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in web settings schema. Run go run cmd/enterprise/*.go generate config web-settings locally and commit."; exit 1)
transaction-groups:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v4
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: generate-transaction-groups
run: |
go run cmd/enterprise/*.go generate config transaction-groups
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in transaction groups schema. Run go run cmd/enterprise/*.go generate config transaction-groups locally and commit."; exit 1)

View File

@@ -121,7 +121,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(_ sqlstore.SQLStore, _ dashboard.Module, _ global.Global, _ zeus.Zeus, _ gateway.Gateway, _ licensing.Licensing, _ serviceaccount.Module, _ cloudintegration.Config) (cloudintegration.Module, error) {
return implcloudintegration.NewModule(), nil
},
func(_ sqlstore.SQLStore, _ telemetrystore.TelemetryStore, _ dashboard.Module, _ queryparser.QueryParser, _ licensing.Licensing, _ flagger.Flagger, _ telemetrytypes.MetadataStore, _ factory.ProviderSettings, _ int) metricreductionrule.Module {
func(_ sqlstore.SQLStore, _ telemetrystore.TelemetryStore, _ dashboard.Module, _ queryparser.QueryParser, _ licensing.Licensing, _ flagger.Flagger, _ factory.ProviderSettings, _ int) metricreductionrule.Module {
return implmetricreductionrule.NewModule()
},
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {

View File

@@ -24,7 +24,7 @@ import (
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration/implcloudprovider"
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
eeimplmetricreductionrule "github.com/SigNoz/signoz/ee/modules/metricreductionrule/implmetricreductionrule"
"github.com/SigNoz/signoz/ee/modules/metricreductionrule/implmetricreductionrule"
eequerier "github.com/SigNoz/signoz/ee/querier"
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
eerules "github.com/SigNoz/signoz/ee/query-service/rules"
@@ -184,8 +184,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), dashboardModule, global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)
},
func(sqlStore sqlstore.SQLStore, ts telemetrystore.TelemetryStore, dashboardModule dashboard.Module, queryParser queryparser.QueryParser, lic licensing.Licensing, flgr pkgflagger.Flagger, ms telemetrytypes.MetadataStore, ps factory.ProviderSettings, threads int) metricreductionrule.Module {
return eeimplmetricreductionrule.NewModule(sqlStore, ts, dashboardModule, queryParser, lic, flgr, ms, ps, threads)
func(sqlStore sqlstore.SQLStore, ts telemetrystore.TelemetryStore, dashboardModule dashboard.Module, queryParser queryparser.QueryParser, licensing licensing.Licensing, flagger pkgflagger.Flagger, ps factory.ProviderSettings, threads int) metricreductionrule.Module {
return implmetricreductionrule.NewModule(sqlStore, ts, dashboardModule, queryParser, licensing, flagger, ps, threads)
},
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, eerules.PrepareTaskFunc, eerules.TestNotification))

View File

@@ -6,15 +6,12 @@ import (
"reflect"
"strings"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/web"
"github.com/spf13/cobra"
"github.com/swaggest/jsonschema-go"
)
const webSettingsSchemaPath = "frontend/src/schemas/generated/webSettings.schema.json"
const transactionGroupsSchemaPath = "frontend/src/schemas/generated/transactionGroups.schema.json"
const webSettingsSchemaPath = "docs/config/web-settings.json"
func registerGenerateConfig(parentCmd *cobra.Command) {
configCmd := &cobra.Command{
@@ -30,14 +27,6 @@ func registerGenerateConfig(parentCmd *cobra.Command) {
},
})
configCmd.AddCommand(&cobra.Command{
Use: "transaction-groups",
Short: "Generate JSON Schema for transaction groups",
RunE: func(currCmd *cobra.Command, args []string) error {
return generateTransactionGroups()
},
})
parentCmd.AddCommand(configCmd)
}
@@ -63,7 +52,6 @@ func generateWebSettings() error {
return err
}
schema.WithTitle("WebSettings")
data, err := json.MarshalIndent(schema, "", " ")
if err != nil {
return err
@@ -71,31 +59,3 @@ func generateWebSettings() error {
return os.WriteFile(webSettingsSchemaPath, append(data, '\n'), 0o600)
}
func generateTransactionGroups() error {
falseVal := false
noAdditional := jsonschema.SchemaOrBool{TypeBoolean: &falseVal}
reflector := jsonschema.Reflector{}
reflector.DefaultOptions = append(reflector.DefaultOptions,
jsonschema.InterceptSchema(func(params jsonschema.InterceptSchemaParams) (bool, error) {
if params.Value.Kind() == reflect.Struct {
params.Schema.AdditionalProperties = &noAdditional
}
return false, nil
}),
)
schema, err := reflector.Reflect(authtypes.TransactionGroups{})
if err != nil {
return err
}
schema.WithTitle("TransactionGroups")
data, err := json.MarshalIndent(schema, "", " ")
if err != nil {
return err
}
return os.WriteFile(transactionGroupsSchemaPath, append(data, '\n'), 0o600)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
{
"title": "WebSettings",
"required": [
"posthog",
"appcues",

View File

@@ -12,16 +12,15 @@ import (
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
)
var (
reductionRulesTable = telemetrymetrics.DBName + "." + telemetrymetrics.ReductionRulesTableName
metadataTable = telemetrymetrics.DBName + "." + telemetrymetrics.AttributesMetadataTableName
bufferSeriesTable = telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4BufferTableName
timeseriesTable = telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4TableName
)
const timeSeriesBucketMilli = int64(time.Hour / time.Millisecond)
type volumeRow struct {
MetricName string
Ingested uint64
@@ -47,18 +46,23 @@ func (c *clickhouse) withThreads(ctx context.Context) context.Context {
return ctxtypes.SetClickhouseMaxThreads(ctx, c.threads)
}
const timeSeriesBucketMilli = int64(time.Hour / time.Millisecond)
// floorToTimeSeriesBucket rounds the start down to the hour, since unix_milli is hour-bucketed.
func floorToTimeSeriesBucket(ms int64) int64 {
return ms - (ms % timeSeriesBucketMilli)
}
func strictEffectiveFrom(sb *sqlbuilder.SelectBuilder, metricNames []string, effectiveFrom map[string]int64) string {
// effectiveFromGate restricts ingested rows to on/after each metric's effective_from (floored to the
// hour) so a rule's pre-activation history isn't counted as reduced. A missing entry gates at 0.
func effectiveFromGate(sb *sqlbuilder.SelectBuilder, metricNames []string, effectiveFrom map[string]int64) string {
names := make([]any, 0, len(metricNames))
froms := make([]any, 0, len(metricNames))
floors := make([]any, 0, len(metricNames))
for _, name := range metricNames {
names = append(names, name)
froms = append(froms, effectiveFrom[name])
floors = append(floors, floorToTimeSeriesBucket(effectiveFrom[name]))
}
return "unix_milli >= transform(metric_name, " + sb.Var(names) + ", " + sb.Var(froms) + ", 0)"
return "unix_milli >= transform(metric_name, " + sb.Var(names) + ", " + sb.Var(floors) + ", 0)"
}
func (c *clickhouse) Sync(ctx context.Context, metricName string, labels []string, matchType string, effectiveFromMs int64, deleted bool, updatedAt time.Time) error {
@@ -76,6 +80,38 @@ func (c *clickhouse) Sync(ctx context.Context, metricName string, labels []strin
return nil
}
func (c *clickhouse) MetricExists(ctx context.Context, metricName string) (bool, error) {
ctx = c.withThreads(ctx)
sb := sqlbuilder.NewSelectBuilder()
sb.Select("count(*) > 0")
sb.From(metadataTable)
sb.Where(sb.E("metric_name", metricName))
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var exists bool
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&exists); err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "failed to check metric existence")
}
return exists, nil
}
func (c *clickhouse) IsExponentialHistogram(ctx context.Context, metricName string) (bool, error) {
ctx = c.withThreads(ctx)
sb := sqlbuilder.NewSelectBuilder()
sb.Select("count(*) > 0")
sb.From(timeseriesTable)
sb.Where(sb.E("metric_name", metricName), sb.E("type", metrictypes.ExpHistogramType.StringValue()))
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var isExpHist bool
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&isExpHist); err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "failed to check metric type")
}
return isExpHist, nil
}
func (c *clickhouse) AttributeKeys(ctx context.Context, metricName string, startMs, endMs int64) ([]string, error) {
ctx = c.withThreads(ctx)
@@ -108,8 +144,25 @@ func (c *clickhouse) AttributeKeys(ctx context.Context, metricName string, start
return keys, rows.Err()
}
func (c *clickhouse) tableExists(ctx context.Context, distributedTableName string) bool {
var exists bool
query := "SELECT count() > 0 FROM system.tables WHERE database = ? AND name = ?"
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, telemetrymetrics.DBName, distributedTableName).Scan(&exists); err != nil {
return false
}
return exists
}
func (c *clickhouse) originalSeriesSource(ctx context.Context) (table string, originalOnly bool) {
if c.tableExists(ctx, telemetrymetrics.TimeseriesV4BufferTableName) {
return telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4BufferTableName, true
}
return telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4TableName, false
}
func (c *clickhouse) EstimateCardinality(ctx context.Context, metricName string, keptLabels []string, startMs, endMs int64) (uint64, uint64, error) {
ctx = c.withThreads(ctx)
table, originalOnly := c.originalSeriesSource(ctx)
startMs = floorToTimeSeriesBucket(startMs)
sb := sqlbuilder.NewSelectBuilder()
@@ -127,12 +180,14 @@ func (c *clickhouse) EstimateCardinality(ctx context.Context, metricName string,
}
sb.Select("uniq(fingerprint)", reducedExpr)
sb.From(bufferSeriesTable)
sb.From(table)
conds := []string{
sb.E("metric_name", metricName),
sb.GE("unix_milli", startMs),
sb.LT("unix_milli", endMs),
sb.E("is_reduced", false),
}
if originalOnly {
conds = append(conds, sb.E("is_reduced", false))
}
sb.Where(conds...)
@@ -150,21 +205,24 @@ func (c *clickhouse) EstimateCardinality(ctx context.Context, metricName string,
return current, reduced, nil
}
// VolumeByMetric returns ingested vs reduced series counts per metric.
func (c *clickhouse) VolumeByMetric(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]volumeRow, error) {
if len(metricNames) == 0 {
return map[string]volumeRow{}, nil
}
ctx = c.withThreads(ctx)
ingested, err := c.ingestedSeriesCount(ctx, metricNames, effectiveFrom, startMs, endMs)
ingestedTable, originalOnly := c.originalSeriesSource(ctx)
ingested, err := c.countSeries(ctx, ingestedTable, originalOnly, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
reduced, err := c.reducedSeriesCount(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
reduced := ingested
if c.tableExists(ctx, telemetrymetrics.TimeseriesV4ReducedTableName) {
reduced, err = c.countSeries(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.TimeseriesV4ReducedTableName, false, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
}
out := make(map[string]volumeRow, len(metricNames))
@@ -180,9 +238,8 @@ func (c *clickhouse) VolumeByMetric(ctx context.Context, metricNames []string, e
return out, nil
}
// ingestedSeriesCount counts distinct raw fingerprints per metric from the samples buffer over the
// window.
func (c *clickhouse) ingestedSeriesCount(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
func (c *clickhouse) countSeries(ctx context.Context, table string, originalOnly bool, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
startMs = floorToTimeSeriesBucket(startMs)
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
@@ -190,71 +247,17 @@ func (c *clickhouse) ingestedSeriesCount(ctx context.Context, metricNames []stri
sb := sqlbuilder.NewSelectBuilder()
sb.Select("metric_name", "uniq(fingerprint)")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
conds := []string{
sb.In("metric_name", names...),
sb.GE("unix_milli", startMs),
sb.LT("unix_milli", endMs),
}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("metric_name")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to count ingested series")
}
defer rows.Close()
out := make(map[string]uint64, len(metricNames))
for rows.Next() {
var (
metricName string
count uint64
)
if err := rows.Scan(&metricName, &count); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan series count")
}
out[metricName] = count
}
return out, rows.Err()
}
// reducedSeriesCount counts distinct reduced_fingerprints per metric, summed across the two 60s
// reduced sample tables.
func (c *clickhouse) reducedSeriesCount(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
out := make(map[string]uint64, len(metricNames))
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
counts, err := c.reducedSeriesCountForTable(ctx, telemetrymetrics.DBName+"."+table, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
for metricName, count := range counts {
out[metricName] += count
}
}
return out, nil
}
func (c *clickhouse) reducedSeriesCountForTable(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("metric_name", "uniq(reduced_fingerprint)")
sb.From(table)
conds := []string{
sb.In("metric_name", names...),
sb.GE("unix_milli", startMs),
sb.LT("unix_milli", endMs),
}
if originalOnly {
conds = append(conds, sb.E("is_reduced", false))
}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
conds = append(conds, effectiveFromGate(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("metric_name")
@@ -262,7 +265,7 @@ func (c *clickhouse) reducedSeriesCountForTable(ctx context.Context, table strin
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to count reduced series")
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to count metric series")
}
defer rows.Close()
@@ -280,15 +283,16 @@ func (c *clickhouse) reducedSeriesCountForTable(ctx context.Context, table strin
return out, rows.Err()
}
// RankByVolume ranks metrics by ingested/reduced series volume. Like VolumeByMetric, the counts read
// the samples tables with a strict effective_from gate; the reduced count sums distinct
// reduced_fingerprints across the two 60s reduced sample tables.
func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, orderBy metricreductionruletypes.ReductionRuleOrderBy, order metricreductionruletypes.Order, startMs, endMs int64, offset, limit int) ([]volumeRow, error) {
if len(metricNames) == 0 {
return []volumeRow{}, nil
}
ctx = c.withThreads(ctx)
ingestedTable, originalOnly := c.originalSeriesSource(ctx)
reducedPresent := c.tableExists(ctx, telemetrymetrics.TimeseriesV4ReducedTableName)
startMs = floorToTimeSeriesBucket(startMs)
orderExpr := "ingested"
switch orderBy {
case metricreductionruletypes.OrderByReducedVolume:
@@ -301,29 +305,31 @@ func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, eff
direction = "DESC"
}
ingestedTable := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName
reducedLast := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedLastTableName
reducedSum := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedSumTableName
ingestedFilter := ""
if originalOnly {
ingestedFilter = "is_reduced = false AND "
}
reducedSelect := "ifNull(i.cnt, 0) AS reduced"
if reducedPresent {
reducedSelect = "ifNull(d.cnt, 0) AS reduced"
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("base.metric_name AS metric_name", "ifNull(i.cnt, 0) AS ingested", "ifNull(d.cnt, 0) AS reduced")
sb.Select("base.metric_name AS metric_name", "ifNull(i.cnt, 0) AS ingested", reducedSelect)
sb.From("(SELECT arrayJoin(" + sb.Var(metricNames) + ") AS metric_name) AS base")
sb.JoinWithOption(
sqlbuilder.LeftJoin,
"(SELECT metric_name, uniq(fingerprint) AS cnt FROM "+ingestedTable+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name) AS i",
"(SELECT metric_name, uniq(fingerprint) AS cnt FROM "+ingestedTable+" WHERE has("+sb.Var(metricNames)+", metric_name) AND "+ingestedFilter+"unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+effectiveFromGate(sb, metricNames, effectiveFrom)+" GROUP BY metric_name) AS i",
"base.metric_name = i.metric_name",
)
// Reduced series are spread across two type-specific tables; union the per-table distinct
// reduced_fingerprints and sum per metric (a metric only lands in the table matching its type).
sb.JoinWithOption(
sqlbuilder.LeftJoin,
"(SELECT metric_name, sum(cnt) AS cnt FROM ("+
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt FROM "+reducedLast+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
" UNION ALL "+
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt FROM "+reducedSum+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
") GROUP BY metric_name) AS d",
"base.metric_name = d.metric_name",
)
if reducedPresent {
reducedTable := telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4ReducedTableName
sb.JoinWithOption(
sqlbuilder.LeftJoin,
"(SELECT metric_name, uniq(fingerprint) AS cnt FROM "+reducedTable+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+effectiveFromGate(sb, metricNames, effectiveFrom)+" GROUP BY metric_name) AS d",
"base.metric_name = d.metric_name",
)
}
sb.OrderBy(orderExpr + " " + direction)
if limit > 0 {
sb.Limit(limit).Offset(offset)
@@ -351,6 +357,11 @@ func (c *clickhouse) SampleVolume(ctx context.Context, metricNames []string, eff
if len(metricNames) == 0 {
return 0, 0, nil
}
if !c.tableExists(ctx, telemetrymetrics.SamplesV4BufferTableName) ||
!c.tableExists(ctx, telemetrymetrics.SamplesV4ReducedLastTableName) ||
!c.tableExists(ctx, telemetrymetrics.SamplesV4ReducedSumTableName) {
return 0, 0, nil
}
ctx = c.withThreads(ctx)
ingested, err := c.countRawSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, metricNames, effectiveFrom, startMs, endMs)
@@ -379,9 +390,9 @@ func (c *clickhouse) countRawSamples(ctx context.Context, table string, metricNa
sb := sqlbuilder.NewSelectBuilder()
sb.Select("count()")
sb.From(table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
conds := []string{sb.In("metric_name", names...), sb.E("reduced_fingerprint", 0), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
conds = append(conds, effectiveFromGate(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
@@ -405,7 +416,7 @@ func (c *clickhouse) countReducedSamples(ctx context.Context, table string, metr
sb.From(table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
conds = append(conds, effectiveFromGate(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
@@ -417,52 +428,33 @@ func (c *clickhouse) countReducedSamples(ctx context.Context, table string, metr
return count, nil
}
// SeriesTimeseries returns ingested vs reduced series per 60s bucket from the samples tables, gated
// to each metric's strict effective_from (see strictEffectiveFrom).
func (c *clickhouse) SeriesTimeseries(ctx context.Context, allMetrics, reducedMetrics []string, effectiveFrom map[string]int64, startMs, endMs int64) ([]volumePoint, error) {
if len(allMetrics) == 0 {
// SeriesTimeseries returns ingested vs reduced series per hourly bucket; ingested is gated to each
// metric's effective_from (see effectiveFromGate).
func (c *clickhouse) SeriesTimeseries(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) ([]volumePoint, error) {
if len(metricNames) == 0 {
return []volumePoint{}, nil
}
ctx = c.withThreads(ctx)
startMs = floorToTimeSeriesBucket(startMs)
ingested, err := c.ingestedSeriesByBucket(ctx, allMetrics, effectiveFrom, startMs, endMs)
ingestedTable, originalOnly := c.originalSeriesSource(ctx)
ingested, err := c.seriesByBucket(ctx, ingestedTable, originalOnly, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
retained := make(map[int64]uint64)
if len(reducedMetrics) > 0 {
reduced, err := c.reducedSeriesByBucket(ctx, reducedMetrics, effectiveFrom, startMs, endMs)
reduced := ingested
if c.tableExists(ctx, telemetrymetrics.TimeseriesV4ReducedTableName) {
reduced, err = c.seriesByBucket(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.TimeseriesV4ReducedTableName, false, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
for ts, count := range reduced {
retained[ts] += count
}
}
reducedSet := make(map[string]struct{}, len(reducedMetrics))
for _, name := range reducedMetrics {
reducedSet[name] = struct{}{}
}
nonReduced := make([]string, 0, len(allMetrics))
for _, name := range allMetrics {
if _, ok := reducedSet[name]; !ok {
nonReduced = append(nonReduced, name)
}
}
if len(nonReduced) > 0 {
nonReducedIngested, err := c.ingestedSeriesByBucket(ctx, nonReduced, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
for ts, count := range nonReducedIngested {
retained[ts] += count
}
}
return mergeVolumePoints(ingested, retained), nil
return mergeVolumePoints(ingested, reduced), nil
}
// mergeVolumePoints unions two per-bucket maps into a single time-ordered series of points.
func mergeVolumePoints(ingested, reduced map[int64]uint64) []volumePoint {
buckets := make(map[int64]struct{}, len(ingested))
for ts := range ingested {
@@ -488,61 +480,25 @@ func mergeVolumePoints(ingested, reduced map[int64]uint64) []volumePoint {
return points
}
// ingestedSeriesByBucket counts distinct raw fingerprints per hourly bucket from the samples buffer.
func (c *clickhouse) ingestedSeriesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
func (c *clickhouse) seriesByBucket(ctx context.Context, table string, originalOnly bool, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
bucketExpr := "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalHour(1)))) * 1000 AS bucket"
sb.Select(bucketExpr, "uniq(fingerprint)")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
sb.Select("unix_milli", "uniq(fingerprint)")
sb.From(table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if originalOnly {
conds = append(conds, sb.E("is_reduced", false))
}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
conds = append(conds, effectiveFromGate(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("bucket")
sb.GroupBy("unix_milli")
return c.scanBuckets(ctx, sb)
}
// reducedSeriesByBucket counts distinct reduced_fingerprints per hourly bucket, summed across the two
// reduced sample tables (a metric only lands in the table matching its type, so per-bucket sums are
// exact).
func (c *clickhouse) reducedSeriesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
out := make(map[int64]uint64)
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
bucketExpr := "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalHour(1)))) * 1000 AS bucket"
sb.Select(bucketExpr, "uniq(reduced_fingerprint)")
sb.From(telemetrymetrics.DBName + "." + table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("bucket")
counts, err := c.scanBuckets(ctx, sb)
if err != nil {
return nil, err
}
for ts, count := range counts {
out[ts] += count
}
}
return out, nil
}
func (c *clickhouse) scanBuckets(ctx context.Context, sb *sqlbuilder.SelectBuilder) (map[int64]uint64, error) {
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {

View File

@@ -19,7 +19,6 @@ import (
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -28,44 +27,43 @@ import (
const (
// effectiveFromMargin delays effective_from so the collector picks up the synced rule before it
// goes live; it must be >= the collector's rule-refresh interval (see signoz-otel-collector#839).
// goes live; it must be >= the collector's rule-refresh interval (signoz-otel-collector#839).
effectiveFromMargin = 5 * time.Minute
defaultPreviewLookback = 24 * time.Hour
// pricePerMillionSamplesUSD is the metrics list price (samples are the billed unit).
pricePerMillionSamplesUSD = 0.1
monthDuration = 30 * 24 * time.Hour
)
type module struct {
store metricreductionruletypes.Store
ch *clickhouse
dashboard dashboard.Module
ruleStore ruletypes.RuleStore
licensing licensing.Licensing
flagger flagger.Flagger
metadataStore telemetrytypes.MetadataStore
logger *slog.Logger
store metricreductionruletypes.Store
ch *clickhouse
dashboard dashboard.Module
ruleStore ruletypes.RuleStore
licensing licensing.Licensing
flagger flagger.Flagger
logger *slog.Logger
}
func NewModule(sqlStore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, dashboardModule dashboard.Module, queryParser queryparser.QueryParser, licensing licensing.Licensing, flagger flagger.Flagger, metadataStore telemetrytypes.MetadataStore, providerSettings factory.ProviderSettings, threads int) metricreductionrule.Module {
func NewModule(sqlStore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, dashboardModule dashboard.Module, queryParser queryparser.QueryParser, licensing licensing.Licensing, flagger flagger.Flagger, providerSettings factory.ProviderSettings, threads int) metricreductionrule.Module {
scoped := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/modules/metricreductionrule/implmetricreductionrule")
return &module{
store: NewStore(sqlStore),
ch: newClickhouse(telemetryStore, threads),
dashboard: dashboardModule,
ruleStore: sqlrulestore.NewRuleStore(sqlStore, queryParser, providerSettings),
licensing: licensing,
flagger: flagger,
metadataStore: metadataStore,
logger: scoped.Logger(),
store: NewStore(sqlStore),
ch: newClickhouse(telemetryStore, threads),
dashboard: dashboardModule,
ruleStore: sqlrulestore.NewRuleStore(sqlStore, queryParser, providerSettings),
licensing: licensing,
flagger: flagger,
logger: scoped.Logger(),
}
}
func (m *module) checkAccess(ctx context.Context, orgID valuer.UUID) error {
return nil
if !m.flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableMetricsReduction, featuretypes.NewFlaggerEvaluationContext(orgID)) {
return errors.Newf(errors.TypeUnsupported, metricreductionruletypes.ErrCodeMetricReductionRuleUnsupported, "metric volume control is not enabled")
}
return nil
if _, err := m.licensing.GetActive(ctx, orgID); err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "metric volume control requires a valid license").WithAdditional(err.Error())
}
@@ -118,7 +116,7 @@ func (m *module) listSortedByColumn(ctx context.Context, orgID valuer.UUID, para
}
func (m *module) listSortedByVolume(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams, startMs, endMs int64) (*metricreductionruletypes.GettableReductionRules, error) {
allRules, total, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{Search: params.Search, MetricName: params.MetricName})
allRules, total, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{Search: params.Search})
if err != nil {
return nil, err
}
@@ -128,7 +126,7 @@ func (m *module) listSortedByVolume(ctx context.Context, orgID valuer.UUID, para
metricNames := make([]string, len(allRules))
effectiveFrom := make(map[string]int64, len(allRules))
ruleByMetric := make(map[string]*metricreductionruletypes.ReductionRule, len(allRules))
ruleByMetric := make(map[string]*metricreductionruletypes.StorableReductionRule, len(allRules))
for i, rule := range allRules {
metricNames[i] = rule.MetricName
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
@@ -152,21 +150,64 @@ func (m *module) listSortedByVolume(ctx context.Context, orgID valuer.UUID, para
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
}
func (m *module) Create(ctx context.Context, orgID valuer.UUID, userEmail string, req *metricreductionruletypes.PostableReductionRule) (*metricreductionruletypes.GettableReductionRule, error) {
func (m *module) Get(ctx context.Context, orgID valuer.UUID, metricName string) (*metricreductionruletypes.GettableReductionRule, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
if metricName == "" {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName is required")
}
rule, err := m.store.Get(ctx, orgID, metricName)
if err != nil {
return nil, err
}
now := time.Now()
current, reduced, reductionPercent, _, err := m.estimateVolume(ctx, rule.MetricName, rule.MatchType, rule.Labels, now.Add(-defaultPreviewLookback).UnixMilli(), now.UnixMilli())
if err != nil {
return nil, err
}
gettable := toGettableReductionRule(rule)
gettable.IngestedSeries = current
gettable.ReducedSeries = reduced
gettable.ReductionPercent = reductionPercent
return &gettable, nil
}
func (m *module) Upsert(ctx context.Context, orgID valuer.UUID, userEmail string, req *metricreductionruletypes.PostableReductionRule) (*metricreductionruletypes.GettableReductionRule, error) {
return m.writeRule(ctx, orgID, userEmail, req, false)
}
// writeRule validates, persists (create inserts, else upserts), and syncs the rule to ClickHouse.
func (m *module) writeRule(ctx context.Context, orgID valuer.UUID, userEmail string, req *metricreductionruletypes.PostableReductionRule, create bool) (*metricreductionruletypes.GettableReductionRule, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
if err := req.Validate(); err != nil {
return nil, err
}
if err := m.validateMetricForReduction(ctx, orgID, req.MetricName); err != nil {
return nil, err
// Reducibility is only checked when creating; an existing rule stays editable even if its metric stopped reporting.
needsMetricCheck := create
if !create {
if _, err := m.store.Get(ctx, orgID, req.MetricName); err != nil {
needsMetricCheck = true
}
}
if needsMetricCheck {
if err := m.validateMetricForReduction(ctx, req.MetricName); err != nil {
return nil, err
}
}
now := time.Now()
rule := metricreductionruletypes.NewReductionRule(orgID, req.MetricName, req.MatchType, req.Labels, now.Add(effectiveFromMargin), userEmail)
persist := m.store.Upsert
if create {
persist = m.store.Create
}
if err := m.store.RunInTx(ctx, func(ctx context.Context) error {
if err := m.store.Create(ctx, rule); err != nil {
if err := persist(ctx, rule); err != nil {
return err
}
return m.ch.Sync(ctx, rule.MetricName, rule.Labels, rule.MatchType.StringValue(), rule.EffectiveFrom.UnixMilli(), false, rule.UpdatedAt)
@@ -178,6 +219,29 @@ func (m *module) Create(ctx context.Context, orgID valuer.UUID, userEmail string
return &gettable, nil
}
func (m *module) Delete(ctx context.Context, orgID valuer.UUID, metricName string) error {
if err := m.checkAccess(ctx, orgID); err != nil {
return err
}
if metricName == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName is required")
}
now := time.Now()
effectiveFromMs := now.Add(effectiveFromMargin).UnixMilli()
return m.store.RunInTx(ctx, func(ctx context.Context) error {
if err := m.store.Delete(ctx, orgID, metricName); err != nil {
return err
}
return m.ch.Sync(ctx, metricName, []string{}, metricreductionruletypes.MatchTypeDrop.StringValue(), effectiveFromMs, true, now)
})
}
// Create inserts a new rule (Terraform/operators), returning AlreadyExists if the metric already has one.
func (m *module) Create(ctx context.Context, orgID valuer.UUID, userEmail string, req *metricreductionruletypes.PostableReductionRule) (*metricreductionruletypes.GettableReductionRule, error) {
return m.writeRule(ctx, orgID, userEmail, req, true)
}
func (m *module) GetByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*metricreductionruletypes.GettableReductionRule, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
@@ -190,7 +254,7 @@ func (m *module) GetByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
return &gettable, nil
}
func (m *module) UpdateByID(ctx context.Context, orgID valuer.UUID, userEmail string, id valuer.UUID, req *metricreductionruletypes.UpdatableReductionRule) (*metricreductionruletypes.GettableReductionRule, error) {
func (m *module) UpdateByID(ctx context.Context, orgID valuer.UUID, userEmail string, id valuer.UUID, req *metricreductionruletypes.PostableReductionRule) (*metricreductionruletypes.GettableReductionRule, error) {
if err := m.checkAccess(ctx, orgID); err != nil {
return nil, err
}
@@ -198,10 +262,13 @@ func (m *module) UpdateByID(ctx context.Context, orgID valuer.UUID, userEmail st
if err != nil {
return nil, err
}
// metricName is immutable; an id always addresses the same metric.
req.MetricName = existing.MetricName
if err := req.Validate(); err != nil {
return nil, err
}
// Update in place to preserve the id and create-audit; the metric isn't re-validated so a rule
// stays editable even if its metric stopped reporting.
now := time.Now()
existing.MatchType = req.MatchType
existing.Labels = metricreductionruletypes.LabelList(req.Labels)
@@ -248,7 +315,7 @@ func (m *module) Preview(ctx context.Context, orgID valuer.UUID, req *metricredu
if err := req.Validate(); err != nil {
return nil, err
}
if err := m.validateMetricForReduction(ctx, orgID, req.MetricName); err != nil {
if err := m.validateMetricForReduction(ctx, req.MetricName); err != nil {
return nil, err
}
lookback := time.Duration(req.LookbackMs) * time.Millisecond
@@ -273,13 +340,13 @@ func (m *module) Preview(ctx context.Context, orgID valuer.UUID, req *metricredu
}
return &metricreductionruletypes.GettableReductionRulePreview{
IngestedSeries: current,
CurrentRetainedSeries: currentReduced,
RetainedSeries: reduced,
ReductionPercent: reductionPercent,
DroppedLabels: dropped,
AffectedAssets: m.relatedAssetImpact(ctx, orgID, req.MetricName, dropped),
EffectiveFrom: now.Add(effectiveFromMargin),
IngestedSeries: current,
CurrentReducedSeries: currentReduced,
ReducedSeries: reduced,
ReductionPercent: reductionPercent,
DroppedLabels: dropped,
AffectedAssets: m.relatedAssetImpact(ctx, orgID, req.MetricName, dropped),
EffectiveFrom: now.Add(effectiveFromMargin),
}, nil
}
@@ -311,27 +378,20 @@ func (m *module) Stats(ctx context.Context, orgID valuer.UUID) (*metricreduction
if err != nil {
return nil, err
}
var ingestedSeries, retainedSeries uint64
reducedMetricNames := make([]string, 0, len(volumes))
reducedEffectiveFrom := make(map[string]int64, len(volumes))
for name, volume := range volumes {
var ingestedSeries, reducedSeries uint64
for _, volume := range volumes {
ingestedSeries += volume.Ingested
retained := effectiveRetained(volume.Ingested, volume.Reduced)
retainedSeries += retained
if retained < volume.Ingested {
reducedMetricNames = append(reducedMetricNames, name)
reducedEffectiveFrom[name] = effectiveFrom[name]
}
reducedSeries += volume.Reduced
}
ingestedSamples, reducedSamples, err := m.ch.SampleVolume(ctx, reducedMetricNames, reducedEffectiveFrom, startMs, endMs)
ingestedSamples, reducedSamples, err := m.ch.SampleVolume(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
return &metricreductionruletypes.GettableReductionRuleStats{
IngestedSeries: ingestedSeries,
RetainedSeries: retainedSeries,
ReducedSeries: reducedSeries,
EstimatedMonthlySavingsUsd: monthlySavingsUSD(ingestedSamples, reducedSamples, startMs, endMs),
}, nil
}
@@ -367,18 +427,7 @@ func (m *module) Timeseries(ctx context.Context, orgID valuer.UUID) (*querybuild
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
}
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
reducedNames := make([]string, 0, len(volumes))
for name, volume := range volumes {
if effectiveRetained(volume.Ingested, volume.Reduced) < volume.Ingested {
reducedNames = append(reducedNames, name)
}
}
points, err := m.ch.SeriesTimeseries(ctx, metricNames, reducedNames, effectiveFrom, startMs, endMs)
points, err := m.ch.SeriesTimeseries(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
@@ -404,7 +453,7 @@ func buildVolumeTimeseries(points []volumePoint) *querybuildertypesv5.QueryRange
{
Series: []*querybuildertypesv5.TimeSeries{
{Labels: []*querybuildertypesv5.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "series"}, Value: "ingested"}}, Values: ingested},
{Labels: []*querybuildertypesv5.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "series"}, Value: "retained"}}, Values: reduced},
{Labels: []*querybuildertypesv5.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "series"}, Value: "reduced"}}, Values: reduced},
},
},
},
@@ -414,23 +463,20 @@ func buildVolumeTimeseries(points []volumePoint) *querybuildertypesv5.QueryRange
}
}
func (m *module) validateMetricForReduction(ctx context.Context, orgID valuer.UUID, metricName string) error {
lastSeen, err := m.metadataStore.FetchLastSeenInfoMulti(ctx, metricName)
func (m *module) validateMetricForReduction(ctx context.Context, metricName string) error {
exists, err := m.ch.MetricExists(ctx, metricName)
if err != nil {
return err
}
if lastSeen[metricName] == 0 {
if !exists {
return errors.NewNotFoundf(errors.CodeNotFound, "metric not found: %q", metricName)
}
now := time.Now()
startTs := uint64(now.Add(-defaultPreviewLookback).UnixMilli())
endTs := uint64(now.UnixMilli())
_, types, _, err := m.metadataStore.FetchTemporalityAndTypeMulti(ctx, orgID, startTs, endTs, metricName)
isExpHist, err := m.ch.IsExponentialHistogram(ctx, metricName)
if err != nil {
return err
}
if types[metricName] == metrictypes.ExpHistogramType {
if isExpHist {
return errors.Newf(errors.TypeInvalidInput, metricreductionruletypes.ErrCodeMetricReductionRuleUnsupportedMetricType,
"exponential histogram metrics cannot be reduced in v1")
}
@@ -453,7 +499,8 @@ func (m *module) relatedAssetImpact(ctx context.Context, orgID valuer.UUID, metr
Type: metricreductionruletypes.AssetTypeDashboard,
ID: item["dashboard_id"],
Name: item["dashboard_name"],
Widget: &metricreductionruletypes.AffectedWidget{ID: item["widget_id"], Name: item["widget_name"]},
Widget: item["widget_name"],
WidgetID: item["widget_id"],
ImpactedLabels: intersectLabels(usedLabels, droppedSet),
})
}
@@ -474,7 +521,7 @@ func (m *module) relatedAssetImpact(ctx context.Context, orgID valuer.UUID, metr
return affected
}
func toGettableReductionRule(rule *metricreductionruletypes.ReductionRule) metricreductionruletypes.GettableReductionRule {
func toGettableReductionRule(rule *metricreductionruletypes.StorableReductionRule) metricreductionruletypes.GettableReductionRule {
return metricreductionruletypes.GettableReductionRule{
Identifiable: rule.Identifiable,
TimeAuditable: rule.TimeAuditable,
@@ -487,18 +534,11 @@ func toGettableReductionRule(rule *metricreductionruletypes.ReductionRule) metri
}
}
func effectiveRetained(ingested, reduced uint64) uint64 {
if reduced == 0 || reduced > ingested {
return ingested
}
return reduced
}
func withVolume(rule metricreductionruletypes.GettableReductionRule, volume volumeRow) metricreductionruletypes.GettableReductionRule {
rule.IngestedSeries = volume.Ingested
rule.RetainedSeries = effectiveRetained(volume.Ingested, volume.Reduced)
if volume.Ingested > 0 {
rule.ReductionPercent = (1 - float64(rule.RetainedSeries)/float64(volume.Ingested)) * 100
rule.ReducedSeries = volume.Reduced
if volume.Ingested > 0 && volume.Reduced <= volume.Ingested {
rule.ReductionPercent = (1 - float64(volume.Reduced)/float64(volume.Ingested)) * 100
}
return rule
}
@@ -519,6 +559,7 @@ func intersectLabels(keys []string, droppedSet map[string]struct{}) []string {
return out
}
// splitCSV splits a comma-joined label list, returning nil for the empty string.
func splitCSV(s string) []string {
if s == "" {
return nil

View File

@@ -17,7 +17,7 @@ func NewStore(sqlstore sqlstore.SQLStore) metricreductionruletypes.Store {
return &store{sqlstore: sqlstore}
}
func (s *store) List(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams) ([]*metricreductionruletypes.ReductionRule, int, error) {
func (s *store) List(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams) ([]*metricreductionruletypes.StorableReductionRule, int, error) {
column := "metric_name"
if params.OrderBy == metricreductionruletypes.OrderByLastUpdated {
column = "updated_at"
@@ -27,7 +27,7 @@ func (s *store) List(ctx context.Context, orgID valuer.UUID, params *metricreduc
direction = "DESC"
}
rules := make([]*metricreductionruletypes.ReductionRule, 0)
rules := make([]*metricreductionruletypes.StorableReductionRule, 0)
query := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
@@ -37,9 +37,6 @@ func (s *store) List(ctx context.Context, orgID valuer.UUID, params *metricreduc
if params.Search != "" {
query = query.Where("metric_name LIKE ?", "%"+params.Search+"%")
}
if params.MetricName != "" {
query = query.Where("metric_name = ?", params.MetricName)
}
if params.Limit > 0 {
query = query.Limit(params.Limit).Offset(params.Offset)
}
@@ -51,8 +48,8 @@ func (s *store) List(ctx context.Context, orgID valuer.UUID, params *metricreduc
return rules, total, nil
}
func (s *store) Get(ctx context.Context, orgID valuer.UUID, metricName string) (*metricreductionruletypes.ReductionRule, error) {
rule := new(metricreductionruletypes.ReductionRule)
func (s *store) Get(ctx context.Context, orgID valuer.UUID, metricName string) (*metricreductionruletypes.StorableReductionRule, error) {
rule := new(metricreductionruletypes.StorableReductionRule)
err := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
@@ -66,8 +63,8 @@ func (s *store) Get(ctx context.Context, orgID valuer.UUID, metricName string) (
return rule, nil
}
func (s *store) GetByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*metricreductionruletypes.ReductionRule, error) {
rule := new(metricreductionruletypes.ReductionRule)
func (s *store) GetByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*metricreductionruletypes.StorableReductionRule, error) {
rule := new(metricreductionruletypes.StorableReductionRule)
err := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
@@ -81,7 +78,7 @@ func (s *store) GetByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
return rule, nil
}
func (s *store) Create(ctx context.Context, rule *metricreductionruletypes.ReductionRule) error {
func (s *store) Create(ctx context.Context, rule *metricreductionruletypes.StorableReductionRule) error {
res, err := s.sqlstore.
BunDBCtx(ctx).
NewInsert().
@@ -103,7 +100,7 @@ func (s *store) Create(ctx context.Context, rule *metricreductionruletypes.Reduc
return nil
}
func (s *store) Upsert(ctx context.Context, rule *metricreductionruletypes.ReductionRule) error {
func (s *store) Upsert(ctx context.Context, rule *metricreductionruletypes.StorableReductionRule) error {
_, err := s.sqlstore.
BunDBCtx(ctx).
NewInsert().
@@ -118,11 +115,33 @@ func (s *store) Upsert(ctx context.Context, rule *metricreductionruletypes.Reduc
return err
}
func (s *store) Delete(ctx context.Context, orgID valuer.UUID, metricName string) error {
res, err := s.sqlstore.
BunDBCtx(ctx).
NewDelete().
Model((*metricreductionruletypes.StorableReductionRule)(nil)).
Where("org_id = ?", orgID).
Where("metric_name = ?", metricName).
Exec(ctx)
if err != nil {
return err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return errors.Newf(errors.TypeNotFound, metricreductionruletypes.ErrCodeMetricReductionRuleNotFound, "no reduction rule found for metric %q", metricName)
}
return nil
}
func (s *store) DeleteByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
res, err := s.sqlstore.
BunDBCtx(ctx).
NewDelete().
Model((*metricreductionruletypes.ReductionRule)(nil)).
Model((*metricreductionruletypes.StorableReductionRule)(nil)).
Where("org_id = ?", orgID).
Where("id = ?", id).
Exec(ctx)

View File

@@ -0,0 +1,170 @@
package implmetricreductionrule
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestStore(t *testing.T) sqlstore.SQLStore {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
Provider: "sqlite",
Connection: sqlstore.ConnectionConfig{
MaxOpenConns: 1,
MaxConnLifetime: 0,
},
Sqlite: sqlstore.SqliteConfig{
Path: dbPath,
Mode: "wal",
BusyTimeout: 5 * time.Second,
TransactionMode: "deferred",
},
})
require.NoError(t, err)
_, err = store.BunDB().NewCreateTable().
Model((*metricreductionruletypes.StorableReductionRule)(nil)).
IfNotExists().
Exec(context.Background())
require.NoError(t, err)
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_metric_reduction_rule_org_metric ON metric_reduction_rule (org_id, metric_name)`)
require.NoError(t, err)
return store
}
func newRule(orgID valuer.UUID, metricName string, matchType metricreductionruletypes.MatchType, labels []string, by string) *metricreductionruletypes.StorableReductionRule {
return metricreductionruletypes.NewReductionRule(orgID, metricName, matchType, labels, time.Now(), by)
}
func TestStore_UpsertGetListDelete(t *testing.T) {
ctx := context.Background()
s := NewStore(newTestStore(t))
orgID := valuer.GenerateUUID()
empty, _, err := s.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
require.NoError(t, err)
assert.Empty(t, empty)
require.NoError(t, s.Upsert(ctx, newRule(orgID, "http_requests_total", metricreductionruletypes.MatchTypeDrop, []string{"pod", "container"}, "creator@x.com")))
got, err := s.Get(ctx, orgID, "http_requests_total")
require.NoError(t, err)
assert.Equal(t, metricreductionruletypes.MatchTypeDrop, got.MatchType)
assert.Equal(t, []string{"pod", "container"}, []string(got.Labels))
assert.Equal(t, "creator@x.com", got.CreatedBy)
list, _, err := s.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
require.NoError(t, err)
require.Len(t, list, 1)
}
func TestStore_UpsertReplacesAndPreservesCreator(t *testing.T) {
ctx := context.Background()
s := NewStore(newTestStore(t))
orgID := valuer.GenerateUUID()
require.NoError(t, s.Upsert(ctx, newRule(orgID, "cpu_usage", metricreductionruletypes.MatchTypeDrop, []string{"pod"}, "creator@x.com")))
require.NoError(t, s.Upsert(ctx, newRule(orgID, "cpu_usage", metricreductionruletypes.MatchTypeKeep, []string{"le"}, "editor@x.com")))
list, _, err := s.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
require.NoError(t, err)
require.Len(t, list, 1, "upsert on the same (org, metric) replaces, it does not duplicate")
got, err := s.Get(ctx, orgID, "cpu_usage")
require.NoError(t, err)
assert.Equal(t, metricreductionruletypes.MatchTypeKeep, got.MatchType)
assert.Equal(t, []string{"le"}, []string(got.Labels))
assert.Equal(t, "creator@x.com", got.CreatedBy, "created_by is preserved on update")
assert.Equal(t, "editor@x.com", got.UpdatedBy, "updated_by reflects the latest editor")
}
func TestStore_DeleteMissingRuleErrors(t *testing.T) {
ctx := context.Background()
s := NewStore(newTestStore(t))
orgID := valuer.GenerateUUID()
require.NoError(t, s.Upsert(ctx, newRule(orgID, "mem_usage", metricreductionruletypes.MatchTypeDrop, []string{"pod"}, "creator@x.com")))
require.NoError(t, s.Delete(ctx, orgID, "mem_usage"))
_, err := s.Get(ctx, orgID, "mem_usage")
require.Error(t, err)
require.Error(t, s.Delete(ctx, orgID, "mem_usage"), "deleting a non-existent rule returns an error")
}
func TestStore_ScopedByOrg(t *testing.T) {
ctx := context.Background()
s := NewStore(newTestStore(t))
orgA := valuer.GenerateUUID()
orgB := valuer.GenerateUUID()
require.NoError(t, s.Upsert(ctx, newRule(orgA, "shared_metric", metricreductionruletypes.MatchTypeDrop, []string{"pod"}, "a@x.com")))
_, err := s.Get(ctx, orgB, "shared_metric")
require.Error(t, err, "a rule in org A must not be visible to org B")
list, _, err := s.List(ctx, orgB, &metricreductionruletypes.ListReductionRulesParams{})
require.NoError(t, err)
assert.Empty(t, list)
}
func TestStore_ListSortsAndPaginates(t *testing.T) {
ctx := context.Background()
s := NewStore(newTestStore(t))
orgID := valuer.GenerateUUID()
for _, name := range []string{"c_metric", "a_metric", "b_metric"} {
require.NoError(t, s.Upsert(ctx, newRule(orgID, name, metricreductionruletypes.MatchTypeDrop, []string{"pod"}, "x@x.com")))
}
page, total, err := s.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{
OrderBy: metricreductionruletypes.OrderByMetricName,
Order: metricreductionruletypes.OrderAsc,
Offset: 0,
Limit: 2,
})
require.NoError(t, err)
assert.Equal(t, 3, total, "total reflects all rows, not the page size")
require.Len(t, page, 2)
assert.Equal(t, "a_metric", page[0].MetricName)
assert.Equal(t, "b_metric", page[1].MetricName)
page, _, err = s.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{
OrderBy: metricreductionruletypes.OrderByMetricName,
Order: metricreductionruletypes.OrderDesc,
Offset: 2,
Limit: 2,
})
require.NoError(t, err)
require.Len(t, page, 1)
assert.Equal(t, "a_metric", page[0].MetricName, "desc order with offset 2 lands on the smallest name")
}
func TestStore_RunInTxRollsBackOnError(t *testing.T) {
ctx := context.Background()
s := NewStore(newTestStore(t))
orgID := valuer.GenerateUUID()
err := s.RunInTx(ctx, func(ctx context.Context) error {
if err := s.Upsert(ctx, newRule(orgID, "rolled_back", metricreductionruletypes.MatchTypeDrop, []string{"pod"}, "creator@x.com")); err != nil {
return err
}
return assert.AnError
})
require.ErrorIs(t, err, assert.AnError)
_, err = s.Get(ctx, orgID, "rolled_back")
require.Error(t, err, "the upsert must not persist when the transaction callback fails")
}

View File

@@ -25,7 +25,7 @@
"test": "jest",
"test:changedsince": "jest --changedSince=main --coverage --silent",
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh",
"generate:config:web-settings": "json2ts ./src/schemas/generated/webSettings.schema.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM frontend/src/schemas/generated/webSettings.schema.json */'"
"generate:config:web-settings": "json2ts ../docs/config/web-settings.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM docs/config/web-settings.json */'"
},
"engines": {
"node": ">=22.0.0",

File diff suppressed because it is too large Load Diff

View File

@@ -2094,45 +2094,6 @@ export interface AuthtypesGettableTokenDTO {
tokenType?: string;
}
export enum CoretypesKindDTO {
anonymous = 'anonymous',
organization = 'organization',
role = 'role',
serviceaccount = 'serviceaccount',
user = 'user',
'notification-channel' = 'notification-channel',
'route-policy' = 'route-policy',
'apdex-setting' = 'apdex-setting',
'auth-domain' = 'auth-domain',
session = 'session',
'cloud-integration' = 'cloud-integration',
'cloud-integration-service' = 'cloud-integration-service',
integration = 'integration',
dashboard = 'dashboard',
'public-dashboard' = 'public-dashboard',
'ingestion-key' = 'ingestion-key',
'ingestion-limit' = 'ingestion-limit',
pipeline = 'pipeline',
'user-preference' = 'user-preference',
'org-preference' = 'org-preference',
'quick-filter' = 'quick-filter',
'ttl-setting' = 'ttl-setting',
rule = 'rule',
'planned-maintenance' = 'planned-maintenance',
'saved-view' = 'saved-view',
'trace-funnel' = 'trace-funnel',
'factor-password' = 'factor-password',
'factor-api-key' = 'factor-api-key',
license = 'license',
subscription = 'subscription',
logs = 'logs',
traces = 'traces',
metrics = 'metrics',
'audit-logs' = 'audit-logs',
'meter-metrics' = 'meter-metrics',
'logs-field' = 'logs-field',
'traces-field' = 'traces-field',
}
export enum CoretypesTypeDTO {
user = 'user',
serviceaccount = 'serviceaccount',
@@ -2143,7 +2104,10 @@ export enum CoretypesTypeDTO {
telemetryresource = 'telemetryresource',
}
export interface CoretypesResourceRefDTO {
kind: CoretypesKindDTO;
/**
* @type string
*/
kind: string;
type: CoretypesTypeDTO;
}
@@ -2279,12 +2243,12 @@ export interface AuthtypesPostableRoleDTO {
/**
* @type string
*/
description?: string;
description: string;
/**
* @type string
*/
name: string;
transactionGroups?: AuthtypesTransactionGroupsDTO;
transactionGroups: AuthtypesTransactionGroupsDTO;
}
export interface AuthtypesPostableRotateTokenDTO {
@@ -6693,17 +6657,6 @@ export enum MetricreductionruletypesAssetTypeDTO {
dashboard = 'dashboard',
alert_rule = 'alert_rule',
}
export interface MetricreductionruletypesAffectedWidgetDTO {
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
}
export interface MetricreductionruletypesAffectedAssetDTO {
/**
* @type string
@@ -6718,7 +6671,14 @@ export interface MetricreductionruletypesAffectedAssetDTO {
*/
name: string;
type: MetricreductionruletypesAssetTypeDTO;
widget?: MetricreductionruletypesAffectedWidgetDTO;
/**
* @type string
*/
widget?: string;
/**
* @type string
*/
widgetId?: string;
}
export enum MetricreductionruletypesMatchTypeDTO {
@@ -6762,16 +6722,16 @@ export interface MetricreductionruletypesGettableReductionRuleDTO {
* @type string
*/
metricName: string;
/**
* @type integer
* @minimum 0
*/
reducedSeries: number;
/**
* @type number
* @format double
*/
reductionPercent: number;
/**
* @type integer
* @minimum 0
*/
retainedSeries: number;
/**
* @type string
* @format date-time
@@ -6792,7 +6752,7 @@ export interface MetricreductionruletypesGettableReductionRulePreviewDTO {
* @type integer
* @minimum 0
*/
currentRetainedSeries: number;
currentReducedSeries: number;
/**
* @type array,null
*/
@@ -6807,16 +6767,16 @@ export interface MetricreductionruletypesGettableReductionRulePreviewDTO {
* @minimum 0
*/
ingestedSeries: number;
/**
* @type integer
* @minimum 0
*/
reducedSeries: number;
/**
* @type number
* @format double
*/
reductionPercent: number;
/**
* @type integer
* @minimum 0
*/
retainedSeries: number;
}
export interface MetricreductionruletypesGettableReductionRuleStatsDTO {
@@ -6834,7 +6794,7 @@ export interface MetricreductionruletypesGettableReductionRuleStatsDTO {
* @type integer
* @minimum 0
*/
retainedSeries: number;
reducedSeries: number;
}
export interface MetricreductionruletypesGettableReductionRulesDTO {
@@ -6861,7 +6821,7 @@ export interface MetricreductionruletypesPostableReductionRuleDTO {
/**
* @type string
*/
metricName: string;
metricName?: string;
}
export interface MetricreductionruletypesPostableReductionRulePreviewDTO {
@@ -6888,14 +6848,6 @@ export enum MetricreductionruletypesReductionRuleOrderByDTO {
reduction = 'reduction',
last_updated = 'last_updated',
}
export interface MetricreductionruletypesUpdatableReductionRuleDTO {
/**
* @type array,null
*/
labels: string[] | null;
matchType: MetricreductionruletypesMatchTypeDTO;
}
export interface MetricsexplorertypesInspectMetricsRequestDTO {
/**
* @type integer
@@ -7057,6 +7009,10 @@ export interface MetricsexplorertypesMetricDashboardDTO {
* @type string
*/
dashboardName: string;
/**
* @type array
*/
groupBy?: string[];
/**
* @type string
*/
@@ -10541,6 +10497,154 @@ export type Livez200 = {
status: string;
};
export type ListMetricsParams = {
/**
* @type integer,null
* @description undefined
*/
start?: number | null;
/**
* @type integer,null
* @description undefined
*/
end?: number | null;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type string
* @description undefined
*/
searchText?: string;
/**
* @type string
* @description undefined
*/
source?: string;
};
export type ListMetrics200 = {
data: MetricsexplorertypesListMetricsResponseDTO;
/**
* @type string
*/
status: string;
};
export type GetMetricAlertsPathParameters = {
metricName: string;
};
export type GetMetricAlerts200 = {
data: MetricsexplorertypesMetricAlertsResponseDTO;
/**
* @type string
*/
status: string;
};
export type GetMetricAttributesPathParameters = {
metricName: string;
};
export type GetMetricAttributesParams = {
/**
* @type integer,null
* @description undefined
*/
start?: number | null;
/**
* @type integer,null
* @description undefined
*/
end?: number | null;
};
export type GetMetricAttributes200 = {
data: MetricsexplorertypesMetricAttributesResponseDTO;
/**
* @type string
*/
status: string;
};
export type GetMetricDashboardsPathParameters = {
metricName: string;
};
export type GetMetricDashboards200 = {
data: MetricsexplorertypesMetricDashboardsResponseDTO;
/**
* @type string
*/
status: string;
};
export type GetMetricHighlightsPathParameters = {
metricName: string;
};
export type GetMetricHighlights200 = {
data: MetricsexplorertypesMetricHighlightsResponseDTO;
/**
* @type string
*/
status: string;
};
export type GetMetricMetadataPathParameters = {
metricName: string;
};
export type GetMetricMetadata200 = {
data: MetricsexplorertypesMetricMetadataDTO;
/**
* @type string
*/
status: string;
};
export type UpdateMetricMetadataPathParameters = {
metricName: string;
};
export type DeleteMetricReductionRulePathParameters = {
metricName: string;
};
export type GetMetricReductionRulePathParameters = {
metricName: string;
};
export type GetMetricReductionRule200 = {
data: MetricreductionruletypesGettableReductionRuleDTO;
/**
* @type string
*/
status: string;
};
export type UpsertMetricReductionRulePathParameters = {
metricName: string;
};
export type UpsertMetricReductionRule200 = {
data: MetricreductionruletypesGettableReductionRuleDTO;
/**
* @type string
*/
status: string;
};
export type InspectMetrics200 = {
data: MetricsexplorertypesInspectMetricsResponseDTO;
/**
* @type string
*/
status: string;
};
export type GetMetricsOnboardingStatus200 = {
data: MetricsexplorertypesMetricsOnboardingResponseDTO;
/**
* @type string
*/
status: string;
};
export type ListMetricReductionRulesParams = {
/**
* @description undefined
@@ -10555,11 +10659,6 @@ export type ListMetricReductionRulesParams = {
* @description undefined
*/
search?: string;
/**
* @type string
* @description undefined
*/
metricName?: string;
/**
* @type integer
* @description undefined
@@ -10637,148 +10736,6 @@ export type GetMetricReductionRuleTimeseries200 = {
status: string;
};
export type ListMetricsParams = {
/**
* @type integer,null
* @description undefined
*/
start?: number | null;
/**
* @type integer,null
* @description undefined
*/
end?: number | null;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type string
* @description undefined
*/
searchText?: string;
/**
* @type string
* @description undefined
*/
source?: string;
};
export type ListMetrics200 = {
data: MetricsexplorertypesListMetricsResponseDTO;
/**
* @type string
*/
status: string;
};
export type GetMetricAlertsParams = {
/**
* @type string
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
*/
metricName: string;
};
export type GetMetricAlerts200 = {
data: MetricsexplorertypesMetricAlertsResponseDTO;
/**
* @type string
*/
status: string;
};
export type GetMetricAttributesParams = {
/**
* @type string
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
*/
metricName: string;
/**
* @type integer,null
* @description Start of the time range as a Unix timestamp in milliseconds.
*/
start?: number | null;
/**
* @type integer,null
* @description End of the time range as a Unix timestamp in milliseconds.
*/
end?: number | null;
};
export type GetMetricAttributes200 = {
data: MetricsexplorertypesMetricAttributesResponseDTO;
/**
* @type string
*/
status: string;
};
export type GetMetricDashboardsParams = {
/**
* @type string
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
*/
metricName: string;
};
export type GetMetricDashboards200 = {
data: MetricsexplorertypesMetricDashboardsResponseDTO;
/**
* @type string
*/
status: string;
};
export type GetMetricHighlightsParams = {
/**
* @type string
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
*/
metricName: string;
};
export type GetMetricHighlights200 = {
data: MetricsexplorertypesMetricHighlightsResponseDTO;
/**
* @type string
*/
status: string;
};
export type InspectMetrics200 = {
data: MetricsexplorertypesInspectMetricsResponseDTO;
/**
* @type string
*/
status: string;
};
export type GetMetricMetadataParams = {
/**
* @type string
* @description The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies).
*/
metricName: string;
};
export type GetMetricMetadata200 = {
data: MetricsexplorertypesMetricMetadataDTO;
/**
* @type string
*/
status: string;
};
export type GetMetricsOnboardingStatus200 = {
data: MetricsexplorertypesMetricsOnboardingResponseDTO;
/**
* @type string
*/
status: string;
};
export type GetMetricsStats200 = {
data: MetricsexplorertypesStatsResponseDTO;
/**

View File

@@ -191,6 +191,9 @@ function TimeSeries({
if (metrics[0] && yAxisUnit) {
updateMetricMetadata(
{
pathParams: {
metricName: metricNames[0],
},
data: buildUpdateMetricYAxisUnitPayload(
metricNames[0],
metrics[0],

View File

@@ -48,14 +48,18 @@ function AllAttributes({
isLoading: isLoadingAttributes,
isError: isErrorAttributes,
refetch: refetchAttributes,
} = useGetMetricAttributes({
metricName,
start: minTime ? Math.floor(minTime / 1000000) : undefined,
end: maxTime ? Math.floor(maxTime / 1000000) : undefined,
});
} = useGetMetricAttributes(
{
metricName,
},
{
start: minTime ? Math.floor(minTime / 1000000) : undefined,
end: maxTime ? Math.floor(maxTime / 1000000) : undefined,
},
);
const attributes = useMemo(
() => attributesData?.data.attributes ?? [],
() => attributesData?.data?.attributes ?? [],
[attributesData],
);

View File

@@ -237,6 +237,9 @@ function Metadata({
const handleSave = useCallback(() => {
updateMetricMetadata(
{
pathParams: {
metricName,
},
data: transformUpdateMetricMetadataRequest(metricName, metricMetadataState),
},
{

View File

@@ -516,6 +516,11 @@
--tooltip-z-index: 1000;
}
// Lift the volume-control config drawer above the MetricDetails drawer (z-index 1000).
.volume-control-config-drawer {
z-index: 1100 !important;
}
@keyframes fade-in-out {
0% {
opacity: 0;

View File

@@ -57,7 +57,7 @@ function MetricDetails({
);
const metadata = useMemo(() => {
if (!metricMetadataResponse) {
if (!metricMetadataResponse?.data) {
return null;
}
const { type, description, unit, temporality, isMonotonic } =

View File

@@ -27,8 +27,9 @@ function ImpactPanel({
);
}
const current = preview?.currentRetainedSeries ?? 0;
const proposed = preview?.retainedSeries ?? 0;
// "Current" is what the metric keeps today (its rule, or raw if none); reduction is current -> proposed.
const current = preview?.currentReducedSeries ?? 0;
const proposed = preview?.reducedSeries ?? 0;
const deltaPct = current > 0 ? (1 - proposed / current) * 100 : 0;
const reductionLabel = `${deltaPct >= 0 ? '' : '+'}${Math.round(
Math.abs(deltaPct),

View File

@@ -3,7 +3,6 @@ import {
MetricreductionruletypesAffectedAssetDTO,
MetricreductionruletypesAssetTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import styles from './VolumeControlConfig.module.scss';
@@ -17,10 +16,8 @@ function assetHref(
return undefined;
}
if (asset.type === AssetType.dashboard) {
const base = ROUTES.DASHBOARD.replace(':dashboardId', asset.id);
return asset.widget?.id
? `${base}?${QueryParams.expandedWidgetId}=${asset.widget.id}`
: base;
const base = `/dashboard/${asset.id}`;
return asset.widgetId ? `${base}/${asset.widgetId}` : base;
}
if (asset.type === AssetType.alert_rule) {
return `${ROUTES.EDIT_ALERTS}?ruleId=${asset.id}`;
@@ -35,6 +32,8 @@ interface RelatedAssetsWarningProps {
function RelatedAssetsWarning({
affectedAssets,
}: RelatedAssetsWarningProps): JSX.Element | null {
// Dashboards are flagged only when they use a dropped label (group-by or filter); alerts are
// flagged whenever they reference the metric, since we don't yet resolve their impacted labels.
const impacted = (affectedAssets ?? []).filter(
(asset) =>
asset.type === AssetType.alert_rule ||
@@ -65,11 +64,9 @@ function RelatedAssetsWarning({
<ul className={styles.assetList}>
{impacted.map((asset) => {
const href = assetHref(asset);
const label = `${asset.name}${
asset.widget ? ` · ${asset.widget.name}` : ''
}`;
const label = `${asset.name}${asset.widget ? ` · ${asset.widget}` : ''}`;
return (
<li key={`${asset.type}-${asset.id}-${asset.widget?.id ?? ''}`}>
<li key={`${asset.type}-${asset.id}-${asset.widgetId ?? ''}`}>
{href ? (
<a href={href} target="_blank" rel="noopener noreferrer">
{label}

View File

@@ -2,7 +2,7 @@ import { Gauge, Info } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { Skeleton } from 'antd';
import { useListMetricReductionRules } from 'api/generated/services/metrics';
import { useGetMetricReductionRule } from 'api/generated/services/metrics';
import { useVolumeControlFeatureGate } from 'hooks/metricsExplorer/useVolumeControlFeatureGate';
import { useState } from 'react';
@@ -21,7 +21,7 @@ function VolumeControlSection({
useVolumeControlFeatureGate();
const [isConfigOpen, setIsConfigOpen] = useState(false);
const { data, isLoading, error } = useListMetricReductionRules(
const { data, isLoading, error } = useGetMetricReductionRule(
{ metricName },
{
query: {
@@ -35,7 +35,7 @@ function VolumeControlSection({
return null;
}
const rule = data?.data.rules?.[0];
const rule = data?.data;
const hasRule = !!rule && !error;
const openConfig = (): void => setIsConfigOpen(true);
@@ -57,8 +57,8 @@ function VolumeControlSection({
>
<Info size={13} />
<Typography.Text className={styles.pendingText}>
This metric&apos;s configuration was recently updated. Volume changes will
take effect within a few minutes.
This metric&apos;s configuration was recently updated. Reduced volumes
will take effect within a few minutes.
</Typography.Text>
</div>
)}

View File

@@ -1,15 +1,15 @@
import {
invalidateGetMetricReductionRule,
invalidateListMetricReductionRules,
invalidateListMetrics,
useCreateMetricReductionRule,
useDeleteMetricReductionRuleByID,
useDeleteMetricReductionRule,
useGetMetricAttributes,
usePreviewMetricReductionRule,
useUpdateMetricReductionRuleByID,
useUpsertMetricReductionRule,
} from 'api/generated/services/metrics';
import {
MetricreductionruletypesGettableReductionRuleDTO,
MetricreductionruletypesGettableReductionRulePreviewDTO,
MetricreductionruletypesGettableReductionRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useNotifications } from 'hooks/useNotifications';
import { useCallback, useEffect, useMemo, useState } from 'react';
@@ -66,11 +66,9 @@ export function useVolumeControlConfig({
const [mode, setMode] = useState<RuleMode>(initial.mode);
const [labels, setLabels] = useState<string[]>(initial.labels);
const existingRuleId = existingRule?.id;
const attributesQuery = useGetMetricAttributes(
{ metricName },
{
metricName,
start: minTime ? Math.floor(minTime / 1000000) : undefined,
end: maxTime ? Math.floor(maxTime / 1000000) : undefined,
},
@@ -101,22 +99,18 @@ export function useVolumeControlConfig({
return (): void => clearTimeout(timer);
}, [open, mode, labels, metricName, previewMutate, previewReset]);
const createMutation = useCreateMetricReductionRule();
const updateMutation = useUpdateMetricReductionRuleByID();
const deleteMutation = useDeleteMetricReductionRuleByID();
const upsertMutation = useUpsertMetricReductionRule();
const deleteMutation = useDeleteMetricReductionRule();
const invalidate = useCallback((): void => {
void invalidateGetMetricReductionRule(queryClient, { metricName });
void invalidateListMetricReductionRules(queryClient);
void invalidateListMetrics(queryClient);
}, [queryClient]);
}, [queryClient, metricName]);
const removeRule = useCallback((): void => {
if (!existingRuleId) {
onClose();
return;
}
deleteMutation.mutate(
{ pathParams: { id: existingRuleId } },
{ pathParams: { metricName } },
{
onSuccess: () => {
notifications.success({ message: 'Volume control rule removed' });
@@ -129,47 +123,28 @@ export function useVolumeControlConfig({
}),
},
);
}, [deleteMutation, existingRuleId, notifications, invalidate, onClose]);
}, [deleteMutation, metricName, notifications, invalidate, onClose]);
const save = useCallback((): void => {
if (mode === 'all') {
if (!existingRuleId) {
if (!existingRule) {
onClose();
return;
}
removeRule();
return;
}
const onSuccess = (): void => {
notifications.success({ message: 'Volume control rule saved' });
invalidate();
onClose();
};
if (existingRuleId) {
updateMutation.mutate(
{
pathParams: { id: existingRuleId },
data: { matchType: matchTypeForMode(mode), labels },
},
{
onSuccess,
onError: (error) =>
notifications.error({
message: error.response?.data?.error?.message ?? SAVE_ERROR_MESSAGE,
}),
},
);
return;
}
createMutation.mutate(
upsertMutation.mutate(
{
data: { metricName, matchType: matchTypeForMode(mode), labels },
pathParams: { metricName },
data: { matchType: matchTypeForMode(mode), labels },
},
{
onSuccess,
onSuccess: () => {
notifications.success({ message: 'Volume control rule saved' });
invalidate();
onClose();
},
onError: (error) =>
notifications.error({
message: error.response?.data?.error?.message ?? SAVE_ERROR_MESSAGE,
@@ -180,9 +155,8 @@ export function useVolumeControlConfig({
mode,
labels,
metricName,
existingRuleId,
createMutation,
updateMutation,
existingRule,
upsertMutation,
removeRule,
notifications,
invalidate,
@@ -200,12 +174,9 @@ export function useVolumeControlConfig({
isPreviewLoading: isPreviewPending,
save,
remove: removeRule,
isSaving:
createMutation.isLoading ||
updateMutation.isLoading ||
deleteMutation.isLoading,
isSaving: upsertMutation.isLoading || deleteMutation.isLoading,
isRemoving: deleteMutation.isLoading,
hasExistingRule: !!existingRuleId,
hasExistingRule: !!existingRule,
isSaveDisabled: mode !== 'all' && labels.length === 0,
};
}

View File

@@ -195,12 +195,14 @@ describe('Metadata', () => {
expect(mockUseUpdateMetricMetadata).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
metricName: MOCK_METRIC_NAME,
type: MetrictypesTypeDTO.sum,
temporality: MetrictypesTemporalityDTO.cumulative,
unit: 'By',
isMonotonic: true,
}),
pathParams: {
metricName: MOCK_METRIC_NAME,
},
}),
expect.objectContaining({
onSuccess: expect.any(Function),

View File

@@ -1,6 +1,5 @@
import { Color } from '@signozhq/design-tokens';
import { Tooltip } from 'antd';
import { TableColumnType as ColumnType } from 'antd';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import {
MetricsexplorertypesStatDTO,
MetricsexplorertypesTreemapEntryDTO,

View File

@@ -15,9 +15,10 @@ import { useMemo, useRef } from 'react';
import { buildVolumeChartPayload } from './utils';
import styles from './VolumeControlTab.module.scss';
// Kept volume is neutral; saved volume gets the positive/green tone used for reduction elsewhere.
const COLOR_MAPPING: Record<string, string> = {
Ingested: Color.BG_ROBIN_500,
Retained: Color.BG_FOREST_500,
'Reduced (kept)': Color.BG_ROBIN_500,
Saved: Color.BG_FOREST_500,
};
interface VolumeControlChartProps {
@@ -66,7 +67,7 @@ function VolumeControlChart({ enabled }: VolumeControlChartProps): JSX.Element {
return (
<div className={styles.chart} data-testid="volume-control-chart">
<Typography.Text className={styles.chartTitle}>
Series volume over time · ingested vs retained
Series volume over time · kept vs saved
</Typography.Text>
<div className={styles.chartBody} ref={graphRef}>
{dimensions.width > 0 && (
@@ -75,6 +76,7 @@ function VolumeControlChart({ enabled }: VolumeControlChartProps): JSX.Element {
data={chartData}
width={dimensions.width}
height={dimensions.height}
isStackedBarChart
yAxisUnit="short"
timezone={timezone}
legendConfig={{ position: LegendPosition.BOTTOM }}

View File

@@ -35,11 +35,7 @@ const OrderBy = MetricreductionruletypesReductionRuleOrderByDTO;
const SortOrder = MetricreductionruletypesOrderDTO;
const DEFAULT_PAGE_SIZE = 10;
type VolumeControlTableParams = Required<
Omit<ListMetricReductionRulesParams, 'metricName'>
>;
const DEFAULT_PARAMS: VolumeControlTableParams = {
const DEFAULT_PARAMS: Required<ListMetricReductionRulesParams> = {
orderBy: OrderBy.reduction,
order: SortOrder.desc,
search: '',
@@ -52,7 +48,8 @@ function VolumeControlTab(): JSX.Element {
useVolumeControlFeatureGate();
const [selectedRule, setSelectedRule] =
useState<MetricreductionruletypesGettableReductionRuleDTO | null>(null);
const [params, setParams] = useState<VolumeControlTableParams>(DEFAULT_PARAMS);
const [params, setParams] =
useState<Required<ListMetricReductionRulesParams>>(DEFAULT_PARAMS);
const [searchInput, setSearchInput] = useState('');
const debouncedSearch = useDebounce(searchInput, 400);
@@ -74,7 +71,7 @@ function VolumeControlTab(): JSX.Element {
const stats = statsData?.data;
const overallReduction =
stats && stats.ingestedSeries > 0
? Math.round((1 - stats.retainedSeries / stats.ingestedSeries) * 100)
? Math.round((1 - stats.reducedSeries / stats.ingestedSeries) * 100)
: 0;
const rules = data?.data.rules ?? [];
@@ -149,7 +146,7 @@ function VolumeControlTab(): JSX.Element {
),
},
{
title: 'RETAINED',
title: 'REDUCED',
key: OrderBy.reduced_volume,
width: 130,
sorter: true,
@@ -157,7 +154,7 @@ function VolumeControlTab(): JSX.Element {
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => <span>{formatCompact(rule.retainedSeries)}</span>,
): JSX.Element => <span>{formatCompact(rule.reducedSeries)}</span>,
},
{
title: 'CHANGE',
@@ -275,9 +272,9 @@ function VolumeControlTab(): JSX.Element {
</span>
</div>
<div className={styles.stat}>
<span className={styles.statLabel}>Retained series</span>
<span className={styles.statLabel}>Reduced series</span>
<span className={styles.statValue}>
{formatCompact(stats?.retainedSeries ?? 0)}
{formatCompact(stats?.reducedSeries ?? 0)}
{overallReduction > 0 && (
<span className={styles.statDelta}>{overallReduction}%</span>
)}

View File

@@ -2,7 +2,6 @@ import {
Querybuildertypesv5QueryRangeResponseDTO,
Querybuildertypesv5TimeSeriesDataDTO,
Querybuildertypesv5TimeSeriesDTO,
Querybuildertypesv5TimeSeriesValueDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
@@ -16,15 +15,13 @@ function findSeries(
);
}
function toChartValues(
points: Querybuildertypesv5TimeSeriesValueDTO[],
): [number, string][] {
return points.map((point) => [
Math.floor((point.timestamp ?? 0) / 1000),
String(point.value ?? 0),
]);
// query-range chart values are [unixSeconds, valueString]; our points carry unix millis.
function toSeconds(timestampMs: number): number {
return Math.floor(timestampMs / 1000);
}
// buildVolumeChartPayload adapts the v5 query-range timeseries into the chart payload as a kept + saved
// part-to-whole stack (kept + saved = original ingested volume).
export function buildVolumeChartPayload(
response?: Querybuildertypesv5QueryRangeResponseDTO,
): SuccessResponse<MetricRangePayloadProps> {
@@ -34,7 +31,23 @@ export function buildVolumeChartPayload(
const series = result?.aggregations?.[0]?.series;
const ingested = findSeries(series, 'ingested')?.values ?? [];
const retained = findSeries(series, 'retained')?.values ?? [];
const reduced = findSeries(series, 'reduced')?.values ?? [];
const reducedByTs = new Map<number, number>();
reduced.forEach((point) =>
reducedByTs.set(point.timestamp ?? 0, point.value ?? 0),
);
const keptValues: [number, string][] = reduced.map((point) => [
toSeconds(point.timestamp ?? 0),
String(point.value ?? 0),
]);
const savedValues: [number, string][] = ingested.map((point) => {
const saved = Math.max(
0,
(point.value ?? 0) - (reducedByTs.get(point.timestamp ?? 0) ?? 0),
);
return [toSeconds(point.timestamp ?? 0), String(saved)];
});
return {
statusCode: 200,
@@ -45,16 +58,16 @@ export function buildVolumeChartPayload(
resultType: 'matrix',
result: [
{
queryName: 'ingested',
legend: 'Ingested',
queryName: 'reduced',
legend: 'Reduced (kept)',
metric: {},
values: toChartValues(ingested),
values: keptValues,
},
{
queryName: 'retained',
legend: 'Retained',
queryName: 'saved',
legend: 'Saved',
metric: {},
values: toChartValues(retained),
values: savedValues,
},
],
newResult: { data: { result: [], resultType: 'matrix' } },

View File

@@ -1,5 +1,4 @@
import type {
CoretypesKindDTO,
CoretypesObjectGroupDTO,
CoretypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
@@ -57,7 +56,7 @@ const baseAuthzResources: AuthzResources = {
// API payload resource refs — only kind+type, no allowedVerbs (matches CoretypesResourceRefDTO shape)
const dashboardResourceRef = {
kind: 'dashboard' as CoretypesKindDTO,
kind: 'dashboard',
type: 'metaresource' as CoretypesTypeDTO,
};
const alertResourceRef = {

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { Badge } from '@signozhq/ui/badge';
import type {
CoretypesKindDTO,
CoretypesObjectGroupDTO,
CoretypesResourceRefDTO,
CoretypesTypeDTO,
@@ -148,7 +147,7 @@ export function buildPatchPayload({
continue;
}
const resourceDef: CoretypesResourceRefDTO = {
kind: found.kind as CoretypesKindDTO,
kind: found.kind,
type: found.type as CoretypesTypeDTO,
};

View File

@@ -21,8 +21,8 @@ export function useVolumeControlFeatureGate(): VolumeControlFeatureGate {
);
const isVolumeControlEnabled =
(isMetricsReductionEnabled && (isCloudUser || isEnterpriseSelfHostedUser)) ||
true;
isMetricsReductionEnabled &&
(isCloudUser || isEnterpriseSelfHostedUser || true);
const isAdmin = user?.role === USER_ROLES.ADMIN || true;
return {

View File

@@ -2,13 +2,13 @@ import {
AuthtypesTransactionDTO,
CoretypesTypeDTO,
AuthtypesRelationDTO,
CoretypesKindDTO,
} from '../../api/generated/services/sigNoz.schemas';
import permissionsType from './permissions.type';
import {
AuthZObject,
AuthZRelation,
BrandedPermission,
ResourceName,
ResourcesForRelation,
ResourceType,
} from './types';
@@ -87,7 +87,7 @@ export function permissionToTransactionDto(
relation: relation as AuthtypesRelationDTO,
object: {
resource: {
kind: resourceName as CoretypesKindDTO,
kind: resourceName as ResourceName,
type: type as CoretypesTypeDTO,
},
selector: selector || '*',

View File

@@ -1,117 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
interface UseInlineOverflowCountOptions {
itemCount: number;
/** Horizontal gap between items, in px. */
gap?: number;
/** Width kept free at the end of the line for a trailing "+N" trigger, in px. */
reserveWidth?: number;
/** Pause measuring (e.g. while expanded) without unmounting. */
enabled?: boolean;
}
interface UseInlineOverflowCountResult {
containerRef: React.RefObject<HTMLDivElement>;
visibleCount: number;
overflowCount: number;
}
/**
* Measures how many of a container's children (each marked
* `data-overflow-item="true"`) fit on a single line, reserving `reserveWidth`
* for a trailing "+N" trigger. Item widths are cached, so children hidden with
* `display: none` still count toward the fit; measuring pauses while `enabled`
* is false.
*/
export function useInlineOverflowCount({
itemCount,
gap = 8,
reserveWidth = 0,
enabled = true,
}: UseInlineOverflowCountOptions): UseInlineOverflowCountResult {
const containerRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(itemCount);
const itemWidthsRef = useRef<number[]>([]);
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
useEffect(() => {
itemWidthsRef.current = [];
setVisibleCount(itemCount);
}, [itemCount]);
const measure = useCallback((): void => {
const container = containerRef.current;
if (!container || !enabledRef.current) {
return;
}
const itemElements = Array.from(container.children).filter(
(itemElement): itemElement is HTMLElement =>
itemElement instanceof HTMLElement &&
itemElement.dataset.overflowItem === 'true',
);
if (itemElements.length === 0) {
setVisibleCount(0);
return;
}
itemElements.forEach((itemElement, index) => {
if (itemElement.offsetWidth > 0) {
itemWidthsRef.current[index] = itemElement.offsetWidth;
}
});
const cachedWidths: number[] = [];
for (let index = 0; index < itemElements.length; index += 1) {
const cachedWidth = itemWidthsRef.current[index];
if (cachedWidth == null) {
// Width not cached yet — reveal everything for one frame so it gets
// measured, then the next pass collapses accurately.
setVisibleCount(itemElements.length);
return;
}
cachedWidths.push(cachedWidth);
}
const containerWidth = container.clientWidth;
const totalWidth = cachedWidths.reduce(
(runningTotal, itemWidth, index) =>
runningTotal + itemWidth + (index > 0 ? gap : 0),
0,
);
if (totalWidth <= containerWidth) {
setVisibleCount(itemElements.length);
return;
}
const availableWidth = containerWidth - reserveWidth;
let usedWidth = 0;
let fitCount = 0;
for (let index = 0; index < cachedWidths.length; index += 1) {
const itemWidthWithGap = cachedWidths[index] + (index > 0 ? gap : 0);
if (usedWidth + itemWidthWithGap > availableWidth && fitCount > 0) {
break;
}
usedWidth += itemWidthWithGap;
fitCount += 1;
}
setVisibleCount(Math.max(1, fitCount));
}, [gap, reserveWidth]);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return undefined;
}
const observer = new ResizeObserver(() => measure());
observer.observe(container);
Array.from(container.children).forEach((child) => observer.observe(child));
measure();
return (): void => observer.disconnect();
}, [measure, itemCount, enabled]);
return {
containerRef,
visibleCount,
overflowCount: Math.max(0, itemCount - visibleCount),
};
}

View File

@@ -1,7 +1,11 @@
.dashboardActionsContainer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.dashboardActionsSecondary {
display: flex;
gap: 12px;
}

View File

@@ -1,42 +1,32 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { useTranslation } from 'react-i18next';
import { generatePath } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import {
Braces,
ClipboardCopy,
Configure,
Copy,
Ellipsis,
FileJson,
Fullscreen,
Grid3X3,
LockKeyhole,
PenLine,
Plus,
SquareStack,
Trash2,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import { toast } from '@signozhq/ui/sonner';
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useDeleteDashboard } from 'hooks/dashboard/useDeleteDashboard';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import DashboardSettings from '../../DashboardSettings';
import { useAddSection } from '../../PanelsAndSectionsLayout/Section/hooks/useAddSection';
import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitleModal';
import JsonEditorDrawer from '../JsonEditorDrawer/JsonEditorDrawer';
import SettingsDrawer from '../SettingsDrawer';
import styles from './DashboardActions.module.scss';
import { useDashboardStore } from '../../store/useDashboardStore';
@@ -65,31 +55,14 @@ function DashboardActions({
const canEdit = useDashboardStore((s) => s.isEditable);
const { user } = useAppContext();
const { t } = useTranslation(['dashboard', 'common']);
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
useState<boolean>(false);
const [isJsonEditorOpen, setIsJsonEditorOpen] = useState<boolean>(false);
const [isCloning, setIsCloning] = useState<boolean>(false);
const [isNewSectionOpen, setIsNewSectionOpen] = useState<boolean>(false);
const [state, setCopy] = useCopyToClipboard();
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
const { addSection, isSaving: isAddingSection } = useAddSection({
layouts: dashboard.spec.layouts,
});
const handleCreateSection = useCallback(
async (title: string): Promise<void> => {
await addSection(title);
setIsNewSectionOpen(false);
},
[addSection],
);
useEffect(() => {
if (state.error) {
toast.error(t('something_went_wrong', { ns: 'common' }));
@@ -116,24 +89,6 @@ function DashboardActions({
URL.revokeObjectURL(url);
}, [dashboardDataJSON, title]);
const handleClone = useCallback(async (): Promise<void> => {
if (!dashboard.id) {
return;
}
try {
setIsCloning(true);
const response = await cloneDashboardV2({ id: dashboard.id });
toast.success('Dashboard cloned');
safeNavigate(
generatePath(ROUTES.DASHBOARD, { dashboardId: response.data.id }),
);
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsCloning(false);
}
}, [dashboard.id, safeNavigate, showErrorModal]);
const handleConfirmDelete = useCallback((): void => {
deleteDashboardMutation.mutate(undefined, {
onSuccess: () => {
@@ -144,24 +99,17 @@ function DashboardActions({
}, [deleteDashboardMutation]);
const menuItems = useMemo<MenuItem[]>(() => {
const dashboardGroup: MenuItem[] = [];
const editGroup: MenuItem[] = [];
if (canEdit) {
dashboardGroup.push({
editGroup.push({
key: 'rename',
label: 'Rename',
icon: <PenLine size={14} />,
onClick: onOpenRename,
});
}
dashboardGroup.push({
key: 'clone',
label: 'Clone dashboard',
icon: <Copy size={14} />,
disabled: isCloning,
onClick: (): void => void handleClone(),
});
if (isAuthor || user.role === USER_ROLES.ADMIN) {
dashboardGroup.push({
editGroup.push({
key: 'lock',
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
icon: <LockKeyhole size={14} />,
@@ -169,14 +117,14 @@ function DashboardActions({
onClick: onLockToggle,
});
}
dashboardGroup.push({
editGroup.push({
key: 'fullscreen',
label: 'Full screen',
icon: <Fullscreen size={14} />,
onClick: handle.enter,
});
const dataGroup: MenuItem[] = [
const exportGroup: MenuItem[] = [
{
key: 'export',
label: 'Export JSON',
@@ -191,35 +139,7 @@ function DashboardActions({
},
];
const layoutGroup: MenuItem[] = [];
if (canEdit) {
layoutGroup.push({
key: 'new-section',
label: 'New section',
icon: <SquareStack size={14} />,
onClick: (): void => setIsNewSectionOpen(true),
});
}
const items: MenuItem[] = [
{
type: 'group',
key: 'group-dashboard',
label: 'Dashboard',
children: dashboardGroup,
},
{ type: 'group', key: 'group-data', label: 'Data', children: dataGroup },
];
if (layoutGroup.length > 0) {
items.push({
type: 'group',
key: 'group-layout',
label: 'Layout',
children: layoutGroup,
});
}
items.push(
{ type: 'divider', key: 'divider-danger' },
const dangerGroup: MenuItem[] = [
{
key: 'delete',
label: 'Delete dashboard',
@@ -227,85 +147,74 @@ function DashboardActions({
danger: true,
onClick: (): void => setIsDeleteOpen(true),
},
);
return items;
];
return [editGroup, exportGroup, dangerGroup]
.filter((group) => group.length > 0)
.flatMap((group, index) =>
index > 0 ? [{ type: 'divider' } as MenuItem, ...group] : group,
);
}, [
canEdit,
isCloning,
isDashboardLocked,
isAuthor,
user.role,
isDashboardLocked,
dashboard.createdBy,
onOpenRename,
handleClone,
onLockToggle,
handle.enter,
exportJSON,
setCopy,
dashboardDataJSON,
canEdit,
]);
return (
<div className={styles.dashboardActionsContainer}>
<DropdownMenuSimple menu={{ items: menuItems }}>
<Button
variant="solid"
color="secondary"
size="md"
prefix={<Grid3X3 size="md" />}
testId="options"
>
Actions
</Button>
</DropdownMenuSimple>
{canEdit && (
<>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
<div className={styles.dashboardActionsSecondary}>
<DropdownMenuSimple menu={{ items: menuItems }}>
<Button
variant="solid"
color="secondary"
prefix={<Configure size="md" />}
testId="show-drawer"
onClick={(): void => setIsSettingsDrawerOpen(true)}
size="icon"
prefix={<Ellipsis size="md" />}
testId="options"
/>
</DropdownMenuSimple>
{canEdit && (
<>
<Button
variant="solid"
color="secondary"
prefix={<Configure size="md" />}
testId="show-drawer"
onClick={(): void => setIsSettingsDrawerOpen(true)}
size="md"
>
Configure
</Button>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={(): void => setIsSettingsDrawerOpen(false)}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
</>
)}
{!isDashboardLocked && (
<Button
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
size="md"
>
Configure
New Panel
</Button>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={(): void => setIsSettingsDrawerOpen(false)}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
</>
)}
<Button
variant="solid"
color="secondary"
prefix={<Braces size="md" />}
testId="edit-json"
onClick={(): void => setIsJsonEditorOpen(true)}
size="md"
>
Edit as JSON
</Button>
{!isDashboardLocked && (
<Button
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
size="md"
>
New Panel
</Button>
)}
<JsonEditorDrawer
dashboard={dashboard}
isOpen={isJsonEditorOpen}
onClose={(): void => setIsJsonEditorOpen(false)}
/>
)}
</div>
<ConfirmDeleteDialog
open={isDeleteOpen}
title={`Delete dashboard"?`}
@@ -314,15 +223,6 @@ function DashboardActions({
onConfirm={handleConfirmDelete}
onClose={(): void => setIsDeleteOpen(false)}
/>
<SectionTitleModal
open={isNewSectionOpen}
heading="New section"
okText="Create section"
initialValue=""
isSaving={isAddingSection}
onClose={(): void => setIsNewSectionOpen(false)}
onSubmit={handleCreateSection}
/>
</div>
);
}

View File

@@ -1,9 +1,19 @@
.dashboardInfo {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
width: 40%;
@media (min-width: 1280px) {
width: 30%;
}
}
.dashboardTitleContainer {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
width: 100%;
}
.dashboardImage {
@@ -11,8 +21,9 @@
}
.dashboardTitle {
flex: 0 1 auto;
flex: 1;
min-width: 0;
max-width: fit-content;
color: var(--l1-foreground);
font-size: 18px;
font-weight: 500;
@@ -26,19 +37,6 @@
cursor: text !important;
}
.descriptionIcon {
flex-shrink: 0;
color: var(--l2-foreground);
cursor: help;
}
.divider {
flex-shrink: 0;
width: 1px;
height: 18px;
background: var(--l2-border);
}
.dashboardTitleEditor {
display: flex;
align-items: center;
@@ -56,13 +54,8 @@
flex-shrink: 0;
}
/* Flexes into the remaining space and clips so the ResizeObserver can measure
how many tags fit before collapsing the rest into a `+N` badge. */
.dashboardTags {
display: flex;
flex: 1 1 0;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
flex-wrap: wrap;
gap: 8px;
}

View File

@@ -1,5 +1,5 @@
import { KeyboardEvent } from 'react';
import { Check, Globe, LockKeyhole, SolidInfoCircle, X } from '@signozhq/icons';
import { Check, Globe, LockKeyhole, X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
@@ -9,7 +9,6 @@ import cx from 'classnames';
import { isEmpty } from 'lodash-es';
import styles from './DashboardInfo.module.scss';
import { useVisibleTagCount } from './useVisibleTagCount';
import { useDashboardStore } from '../../store/useDashboardStore';
interface DashboardInfoProps {
@@ -46,11 +45,6 @@ function DashboardInfo({
const hasTags = tags.length > 0;
const hasDescription = !isEmpty(description);
const { containerRef, visibleCount } = useVisibleTagCount(tags);
const needsOverflow = tags.length > visibleCount;
const visibleTags = needsOverflow ? tags.slice(0, visibleCount) : tags;
const remainingTags = needsOverflow ? tags.slice(visibleCount) : [];
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
event.preventDefault();
@@ -62,106 +56,83 @@ function DashboardInfo({
return (
<div className={styles.dashboardInfo}>
<img src={image} alt={title} className={styles.dashboardImage} />
<div className={styles.dashboardTitleContainer}>
<img src={image} alt={title} className={styles.dashboardImage} />
{isEditing ? (
<div className={styles.dashboardTitleEditor}>
<Input
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={120}
className={styles.dashboardTitleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button
type="button"
variant="outlined"
color="primary"
size="icon"
className={styles.dashboardTitleActionButton}
aria-label="Save title"
testId="dashboard-title-save"
onClick={onCommit}
>
<Check size={14} />
</Button>
<Button
type="button"
variant="outlined"
color="secondary"
size="icon"
className={styles.dashboardTitleActionButton}
aria-label="Cancel title edit"
testId="dashboard-title-cancel"
onClick={onCancel}
>
<X size={14} />
</Button>
</div>
) : (
<TooltipSimple title={title}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.dashboardTitleHover]: canEdit,
})}
data-testid="dashboard-title"
onClick={canEdit ? onStartEdit : undefined}
>
{title}
</Typography.Text>
</TooltipSimple>
)}
{isEditing ? (
<div className={styles.dashboardTitleEditor}>
<Input
autoFocus
value={draft}
testId="dashboard-title-input"
maxLength={120}
className={styles.dashboardTitleInput}
onChange={(e): void => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
/>
<Button
type="button"
variant="outlined"
color="primary"
size="icon"
className={styles.dashboardTitleActionButton}
aria-label="Save title"
testId="dashboard-title-save"
onClick={onCommit}
>
<Check size={14} />
</Button>
<Button
type="button"
variant="outlined"
color="secondary"
size="icon"
className={styles.dashboardTitleActionButton}
aria-label="Cancel title edit"
testId="dashboard-title-cancel"
onClick={onCancel}
>
<X size={14} />
</Button>
{isPublicDashboard && (
<TooltipSimple title="This dashboard is publicly accessible">
<Globe size={14} />
</TooltipSimple>
)}
{isDashboardLocked && (
<TooltipSimple title="This dashboard is locked">
<LockKeyhole size={14} />
</TooltipSimple>
)}
</div>
{hasTags && (
<div className={styles.dashboardTags}>
{tags.map((tag) => (
<Badge key={tag} color="warning" variant="outline">
{tag}
</Badge>
))}
</div>
) : (
<TooltipSimple title={title}>
<Typography.Text
className={cx(styles.dashboardTitle, {
[styles.dashboardTitleHover]: canEdit,
})}
data-testid="dashboard-title"
onClick={canEdit ? onStartEdit : undefined}
>
{title}
</Typography.Text>
</TooltipSimple>
)}
{hasDescription && (
<TooltipSimple title={description}>
<SolidInfoCircle
className={styles.descriptionIcon}
size={14}
data-testid="dashboard-description-info"
/>
</TooltipSimple>
)}
{isPublicDashboard && (
<TooltipSimple title="This dashboard is publicly accessible">
<Globe size={14} />
</TooltipSimple>
)}
{isDashboardLocked && (
<TooltipSimple title="This dashboard is locked">
<LockKeyhole size={14} />
</TooltipSimple>
)}
{hasTags && (
<>
<span className={styles.divider} />
<div
ref={containerRef}
className={styles.dashboardTags}
data-testid="dashboard-tags"
>
{visibleTags.map((tag) => (
<Badge key={tag} color="warning" variant="outline">
{tag}
</Badge>
))}
{remainingTags.length > 0 && (
<TooltipSimple title={remainingTags.join(', ')}>
<Badge
color="warning"
variant="outline"
data-testid="dashboard-tags-overflow"
>
+{remainingTags.length}
</Badge>
</TooltipSimple>
)}
</div>
</>
<Typography.Text color="muted">{description}</Typography.Text>
)}
</div>
);

View File

@@ -1,62 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
BADGE_GAP,
estimateBadgeWidth,
OVERFLOW_BADGE_WIDTH,
} from 'components/Alerts/LabelColumn/utils';
interface Result {
containerRef: React.RefObject<HTMLDivElement>;
visibleCount: number;
}
/**
* Measures how many tags fit in the container and returns the visible count,
* reserving room for the `+N` overflow badge. Reuses the badge-width estimation
* from the alerts LabelColumn so dashboards and alerts overflow identically.
*/
export function useVisibleTagCount(tags: string[]): Result {
const containerRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(tags.length);
const calculateVisible = useCallback(
(width: number): number => {
if (width <= 0) {
return 1;
}
const availableWidth = width - OVERFLOW_BADGE_WIDTH - BADGE_GAP;
let usedWidth = 0;
let count = 0;
for (const tag of tags) {
const badgeWidth = estimateBadgeWidth(tag) + BADGE_GAP;
if (usedWidth + badgeWidth > availableWidth && count > 0) {
break;
}
usedWidth += badgeWidth;
count += 1;
}
return Math.max(1, count);
},
[tags],
);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return undefined;
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0) {
setVisibleCount(calculateVisible(entry.contentRect.width));
}
});
observer.observe(container);
if (container.clientWidth > 0) {
setVisibleCount(calculateVisible(container.clientWidth));
}
return (): void => observer.disconnect();
}, [calculateVisible]);
return { containerRef, visibleCount };
}

View File

@@ -5,9 +5,7 @@
color: var(--l2-foreground);
background-color: var(--l1-background);
padding: 16px;
box-shadow:
0 1px 0 0 var(--l2-border),
0 6px 12px -10px var(--l2-border);
box-shadow: 0 2px 2px 0px var(--l2-border);
}
.dashboardPageToolbarSubContainer {
@@ -18,22 +16,5 @@
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
width: 100%;
}
.toolbarRow2 {
width: 100%;
margin-top: 12px;
&::after {
display: block;
clear: both;
content: '';
}
}
.timeCluster {
float: right;
margin: 0 0 0 16px;
}

View File

@@ -1,72 +0,0 @@
.root {
:global(.ant-drawer-wrapper-body) {
border-left: 1px solid var(--l1-border);
background: var(--l2-background);
}
:global(.ant-drawer-header) {
height: 48px;
border-bottom: 1px solid var(--l1-border);
:global(.ant-drawer-title) {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
}
}
:global(.ant-drawer-body) {
padding: 0;
display: flex;
min-height: 0;
}
:global(.ant-drawer-footer) {
padding: 12px 16px;
border-top: 1px solid var(--l1-border);
}
}
.body {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
flex-direction: column;
}
.editor {
flex: 1;
min-height: 0;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.validation {
min-width: 0;
overflow: hidden;
font-family: 'Space Mono', monospace;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.validationValid {
color: var(--bg-forest-400);
}
.validationInvalid {
color: var(--bg-cherry-400);
}
.footerActions {
display: flex;
flex-shrink: 0;
gap: 8px;
}

View File

@@ -1,141 +0,0 @@
import { KeyboardEvent, useCallback } from 'react';
import MEditor from '@monaco-editor/react';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { Drawer } from 'antd';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
import { defineJsonEditorTheme, JSON_EDITOR_THEME } from './editorTheme';
import styles from './JsonEditorDrawer.module.scss';
import JsonEditorToolbar from './JsonEditorToolbar';
import { useJsonEditor } from './useJsonEditor';
interface JsonEditorDrawerProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
isOpen: boolean;
onClose: () => void;
}
function JsonEditorDrawer({
dashboard,
isOpen,
onClose,
}: JsonEditorDrawerProps): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const { draft, setDraft, validity, isDirty, isSaving, format, reset, apply } =
useJsonEditor({ dashboard, isOpen, onApplied: onClose });
const onCopy = useCallback((): void => {
copyToClipboard(draft);
toast.success('JSON copied to clipboard');
}, [copyToClipboard, draft]);
const onDownload = useCallback((): void => {
const blob = new Blob([draft], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${dashboard.name || 'dashboard'}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, [draft, dashboard.name]);
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
void apply();
}
},
[apply],
);
const applyDisabled = !isDirty || !validity.valid || isSaving;
const validationText = validity.valid
? `Valid JSON · ${validity.lineCount} lines`
: `Line ${validity.errorLine ?? '?'} · ${validity.message ?? 'Invalid JSON'}`;
return (
<Drawer
title="Dashboard JSON"
placement="right"
width={660}
onClose={onClose}
open={isOpen}
rootClassName={styles.root}
footer={
<div className={styles.footer}>
<Typography.Text
className={cx(styles.validation, {
[styles.validationValid]: validity.valid,
[styles.validationInvalid]: !validity.valid,
})}
data-testid="json-editor-validation"
>
{validationText}
</Typography.Text>
<div className={styles.footerActions}>
<Button
variant="outlined"
color="secondary"
size="md"
testId="json-editor-cancel"
onClick={onClose}
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="md"
testId="json-editor-apply"
disabled={applyDisabled}
onClick={(): void => void apply()}
>
Apply changes
</Button>
</div>
</div>
}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div className={styles.body} onKeyDown={onKeyDown}>
<JsonEditorToolbar
isDirty={isDirty}
onFormat={format}
onCopy={onCopy}
onDownload={onDownload}
onReset={reset}
/>
<div className={styles.editor}>
<MEditor
language="json"
height="100%"
value={draft}
onChange={(value): void => setDraft(value ?? '')}
options={{
scrollbar: { alwaysConsumeMouseWheel: false },
minimap: { enabled: false },
fontSize: 13,
fontFamily: 'Space Mono',
}}
theme="vs-dark"
onMount={(editor, monaco): void => {
defineJsonEditorTheme(monaco, editor.getContainerDomNode());
monaco.editor.setTheme(JSON_EDITOR_THEME);
void document.fonts.ready.then(() => monaco.editor.remeasureFonts());
}}
/>
</div>
</div>
</Drawer>
);
}
export default JsonEditorDrawer;

View File

@@ -1,12 +0,0 @@
.toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border-bottom: 1px solid var(--l1-border);
background: var(--l1-background);
}
.spacer {
flex: 1;
}

View File

@@ -1,69 +0,0 @@
import { AlignLeft, Copy, Download, RotateCcw } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import styles from './JsonEditorToolbar.module.scss';
interface JsonEditorToolbarProps {
isDirty: boolean;
onFormat: () => void;
onCopy: () => void;
onDownload: () => void;
onReset: () => void;
}
function JsonEditorToolbar({
isDirty,
onFormat,
onCopy,
onDownload,
onReset,
}: JsonEditorToolbarProps): JSX.Element {
return (
<div className={styles.toolbar}>
<Button
variant="ghost"
color="secondary"
size="sm"
prefix={<AlignLeft size={14} />}
testId="json-editor-format"
onClick={onFormat}
>
Format
</Button>
<Button
variant="ghost"
color="secondary"
size="sm"
prefix={<Copy size={14} />}
testId="json-editor-copy"
onClick={onCopy}
>
Copy
</Button>
<Button
variant="ghost"
color="secondary"
size="sm"
prefix={<Download size={14} />}
testId="json-editor-download"
onClick={onDownload}
>
Download
</Button>
<div className={styles.spacer} />
<Button
variant="ghost"
color="secondary"
size="sm"
prefix={<RotateCcw size={14} />}
testId="json-editor-reset"
disabled={!isDirty}
onClick={onReset}
>
Reset
</Button>
</div>
);
}
export default JsonEditorToolbar;

View File

@@ -1,165 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import JsonEditorDrawer from '../JsonEditorDrawer';
import { useJsonEditor } from '../useJsonEditor';
jest.mock('../useJsonEditor', () => ({ useJsonEditor: jest.fn() }));
jest.mock('@monaco-editor/react', () => ({
__esModule: true,
default: ({
value,
onChange,
}: {
value: string;
onChange: (next?: string) => void;
}): JSX.Element => (
<textarea
aria-label="json-editor"
data-testid="monaco"
value={value}
onChange={(e): void => onChange(e.target.value)}
/>
),
}));
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
jest.mock('react-use', () => ({
useCopyToClipboard: (): [unknown, jest.Mock] => [{}, jest.fn()],
}));
const mockUseJsonEditor = useJsonEditor as jest.Mock;
const dashboard = {
id: 'dash-1',
name: 'My dashboard',
} as unknown as DashboardtypesGettableDashboardV2DTO;
function hookValue(
overrides: Partial<ReturnType<typeof useJsonEditor>> = {},
): ReturnType<typeof useJsonEditor> {
return {
draft: '{\n "a": 1\n}',
setDraft: jest.fn(),
validity: { valid: true, lineCount: 3 },
isDirty: true,
isSaving: false,
format: jest.fn(),
reset: jest.fn(),
apply: jest.fn().mockResolvedValue(undefined),
...overrides,
};
}
describe('JsonEditorDrawer', () => {
beforeEach(() => jest.clearAllMocks());
it('renders the toolbar, editor and footer actions when open', () => {
mockUseJsonEditor.mockReturnValue(hookValue());
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
expect(screen.getByTestId('json-editor-format')).toBeInTheDocument();
expect(screen.getByTestId('json-editor-copy')).toBeInTheDocument();
expect(screen.getByTestId('json-editor-download')).toBeInTheDocument();
expect(screen.getByTestId('json-editor-reset')).toBeInTheDocument();
expect(screen.getByTestId('json-editor-apply')).toBeInTheDocument();
expect(screen.getByTestId('monaco')).toBeInTheDocument();
});
it('shows a valid status with the line count', () => {
mockUseJsonEditor.mockReturnValue(
hookValue({ validity: { valid: true, lineCount: 12 } }),
);
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
expect(screen.getByTestId('json-editor-validation')).toHaveTextContent(
'Valid JSON · 12 lines',
);
});
it('shows the error line and message when invalid', () => {
mockUseJsonEditor.mockReturnValue(
hookValue({
validity: {
valid: false,
lineCount: 4,
errorLine: 3,
message: 'Unexpected token',
},
}),
);
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
expect(screen.getByTestId('json-editor-validation')).toHaveTextContent(
'Line 3 · Unexpected token',
);
});
it('disables Apply when not dirty, invalid, or saving', () => {
mockUseJsonEditor.mockReturnValue(hookValue({ isDirty: false }));
const { rerender } = render(
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
);
expect(screen.getByTestId('json-editor-apply')).toBeDisabled();
mockUseJsonEditor.mockReturnValue(
hookValue({ validity: { valid: false, lineCount: 1 } }),
);
rerender(
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
);
expect(screen.getByTestId('json-editor-apply')).toBeDisabled();
mockUseJsonEditor.mockReturnValue(hookValue({ isSaving: true }));
rerender(
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />,
);
expect(screen.getByTestId('json-editor-apply')).toBeDisabled();
});
it('wires toolbar and footer buttons to the hook callbacks', () => {
const value = hookValue();
mockUseJsonEditor.mockReturnValue(value);
const onClose = jest.fn();
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={onClose} />);
fireEvent.click(screen.getByTestId('json-editor-format'));
expect(value.format).toHaveBeenCalled();
fireEvent.click(screen.getByTestId('json-editor-reset'));
expect(value.reset).toHaveBeenCalled();
fireEvent.click(screen.getByTestId('json-editor-apply'));
expect(value.apply).toHaveBeenCalled();
fireEvent.click(screen.getByTestId('json-editor-cancel'));
expect(onClose).toHaveBeenCalled();
});
it('forwards editor changes to setDraft', () => {
const value = hookValue();
mockUseJsonEditor.mockReturnValue(value);
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
fireEvent.change(screen.getByTestId('monaco'), {
target: { value: '{"b":2}' },
});
expect(value.setDraft).toHaveBeenCalledWith('{"b":2}');
});
it('applies on Cmd/Ctrl+Enter', () => {
const value = hookValue();
mockUseJsonEditor.mockReturnValue(value);
render(<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />);
fireEvent.keyDown(screen.getByTestId('monaco'), {
key: 'Enter',
metaKey: true,
});
expect(value.apply).toHaveBeenCalled();
});
});

View File

@@ -1,179 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { updateDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { toast } from '@signozhq/ui/sonner';
import { useJsonEditor } from '../useJsonEditor';
const mockRefetch = jest.fn();
const mockShowErrorModal = jest.fn();
jest.mock('../../../store/useDashboardStore', () => ({
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1', refetch: mockRefetch }),
}));
jest.mock('providers/ErrorModalProvider', () => ({
useErrorModal: (): { showErrorModal: jest.Mock } => ({
showErrorModal: mockShowErrorModal,
}),
}));
jest.mock('api/generated/services/dashboard', () => ({
updateDashboardV2: jest.fn(),
}));
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const mockUpdate = updateDashboardV2 as jest.Mock;
const mockToastSuccess = toast.success as jest.Mock;
const dashboard = {
id: 'dash-1',
name: 'My dashboard',
schemaVersion: 'v6',
image: 'icon.png',
tags: [{ key: 'env', value: 'prod' }],
spec: {
display: { name: 'My dashboard' },
panels: {},
layouts: [],
variables: [],
},
} as unknown as DashboardtypesGettableDashboardV2DTO;
const serialized = JSON.stringify(dashboard, null, 2);
describe('useJsonEditor', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUpdate.mockResolvedValue({});
});
it('seeds the draft from the dashboard and reports valid, non-dirty state', () => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
expect(result.current.draft).toBe(serialized);
expect(result.current.isDirty).toBe(false);
expect(result.current.validity.valid).toBe(true);
expect(result.current.validity.lineCount).toBe(serialized.split('\n').length);
});
it('flags invalid JSON with a line number and marks the draft dirty', () => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
act(() => result.current.setDraft('{\n "name": ,\n}'));
expect(result.current.validity.valid).toBe(false);
expect(result.current.validity.message).toBeDefined();
expect(result.current.isDirty).toBe(true);
});
it('format() pretty-prints valid JSON and leaves invalid JSON untouched', () => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
act(() => result.current.setDraft('{"a":1}'));
act(() => result.current.format());
expect(result.current.draft).toBe('{\n "a": 1\n}');
act(() => result.current.setDraft('{bad'));
act(() => result.current.format());
expect(result.current.draft).toBe('{bad');
});
it('reset() restores the last-applied text', () => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
act(() => result.current.setDraft('edited'));
expect(result.current.isDirty).toBe(true);
act(() => result.current.reset());
expect(result.current.draft).toBe(serialized);
expect(result.current.isDirty).toBe(false);
});
it('apply() is a no-op when the draft is unchanged or invalid', async () => {
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
await act(async () => {
await result.current.apply();
});
expect(mockUpdate).not.toHaveBeenCalled();
act(() => result.current.setDraft('{bad'));
await act(async () => {
await result.current.apply();
});
expect(mockUpdate).not.toHaveBeenCalled();
});
it('apply() PUTs the narrowed body, toasts, refetches and calls onApplied', async () => {
const onApplied = jest.fn();
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied }),
);
const next = { ...dashboard, name: 'Renamed' };
act(() => result.current.setDraft(JSON.stringify(next)));
await act(async () => {
await result.current.apply();
});
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate).toHaveBeenCalledWith(
{ id: 'dash-1' },
expect.objectContaining({
name: 'Renamed',
schemaVersion: 'v6',
spec: next.spec,
tags: next.tags,
}),
);
expect(mockToastSuccess).toHaveBeenCalled();
expect(mockRefetch).toHaveBeenCalled();
expect(onApplied).toHaveBeenCalled();
});
it('apply() surfaces errors through the error modal', async () => {
mockUpdate.mockRejectedValueOnce(new Error('boom'));
const { result } = renderHook(() =>
useJsonEditor({ dashboard, isOpen: true, onApplied: jest.fn() }),
);
act(() =>
result.current.setDraft(JSON.stringify({ ...dashboard, name: 'X' })),
);
await act(async () => {
await result.current.apply();
});
expect(mockShowErrorModal).toHaveBeenCalled();
});
it('re-seeds the draft when the drawer re-opens', () => {
const onApplied = jest.fn();
const { result, rerender } = renderHook(
(props: { isOpen: boolean }) =>
useJsonEditor({ dashboard, isOpen: props.isOpen, onApplied }),
{ initialProps: { isOpen: false } },
);
act(() => result.current.setDraft('stale edit'));
expect(result.current.draft).toBe('stale edit');
rerender({ isOpen: true });
expect(result.current.draft).toBe(serialized);
});
});

View File

@@ -1,26 +0,0 @@
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesDashboardSpecDTO,
DashboardtypesUpdatableDashboardV2DTO,
TagtypesPostableTagDTO,
} from 'api/generated/services/sigNoz.schemas';
/**
* Narrow a parsed (full Gettable-shaped) dashboard JSON down to the PUT-updatable
* body. The editor shows the whole dashboard for readability, but the update
* endpoint only accepts `{ name, schemaVersion, image, tags, spec }` — the
* server owns `id`, `locked`, timestamps, etc., so we drop them here.
*/
export function dashboardToUpdatable(
parsed: Record<string, unknown>,
): DashboardtypesUpdatableDashboardV2DTO {
const dashboard = parsed as Partial<DashboardtypesGettableDashboardV2DTO>;
return {
name: dashboard.name ?? '',
schemaVersion: dashboard.schemaVersion ?? 'v6',
image: dashboard.image,
tags: (dashboard.tags as TagtypesPostableTagDTO[] | null | undefined) ?? null,
spec: dashboard.spec as DashboardtypesDashboardSpecDTO,
};
}

View File

@@ -1,47 +0,0 @@
import type { Monaco } from '@monaco-editor/react';
export const JSON_EDITOR_THEME = 'signoz-json';
function token(el: HTMLElement, name: string): string {
return getComputedStyle(el).getPropertyValue(name).trim().replace('#', '');
}
function isDark(hex: string): boolean {
if (hex.length < 6) {
return true;
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return 0.299 * r + 0.587 * g + 0.114 * b < 128;
}
export function defineJsonEditorTheme(monaco: Monaco, el: HTMLElement): void {
const background = token(el, '--l1-background');
const foreground = token(el, '--l1-foreground');
const keyColor = token(el, '--bg-vanilla-400');
const valueColor = token(el, '--bg-robin-400');
const rules: { token: string; foreground: string }[] = [];
if (keyColor) {
rules.push({ token: 'string.key.json', foreground: keyColor });
}
if (valueColor) {
rules.push({ token: 'string.value.json', foreground: valueColor });
}
const colors: Record<string, string> = {};
if (background) {
colors['editor.background'] = `#${background}`;
}
if (foreground) {
colors['editor.foreground'] = `#${foreground}`;
}
monaco.editor.defineTheme(JSON_EDITOR_THEME, {
base: isDark(background) ? 'vs-dark' : 'vs',
inherit: true,
rules,
colors,
});
}

View File

@@ -1,148 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { updateDashboardV2 } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { dashboardToUpdatable } from './dashboardToUpdatable';
import { useDashboardStore } from '../../store/useDashboardStore';
export interface JsonValidity {
valid: boolean;
lineCount: number;
/** 1-based line of the parse error, when known. */
errorLine?: number;
message?: string;
}
interface Params {
dashboard: DashboardtypesGettableDashboardV2DTO;
isOpen: boolean;
onApplied: () => void;
}
interface Result {
draft: string;
setDraft: (next: string) => void;
validity: JsonValidity;
isDirty: boolean;
isSaving: boolean;
format: () => void;
reset: () => void;
apply: () => Promise<void>;
}
const serialize = (dashboard: DashboardtypesGettableDashboardV2DTO): string =>
JSON.stringify(dashboard, null, 2);
/** Derive a 1-based line number from a `JSON.parse` "position N" error message. */
function errorLineFromMessage(
source: string,
message: string,
): number | undefined {
const match = /position (\d+)/.exec(message);
if (!match) {
return undefined;
}
const position = Number(match[1]);
return source.slice(0, position).split('\n').length;
}
/**
* Editor state for the dashboard JSON drawer: tracks the editable `draft`
* against the last-applied text, exposes live validation, and applies changes
* via the full-document update endpoint.
*/
export function useJsonEditor({
dashboard,
isOpen,
onApplied,
}: Params): Result {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
const [appliedText, setAppliedText] = useState<string>(() =>
serialize(dashboard),
);
const [draft, setDraft] = useState<string>(appliedText);
const [isSaving, setIsSaving] = useState(false);
// Re-seed the editor from the current dashboard each time the drawer opens so
// it always reflects the latest persisted state (e.g. after a refetch).
useEffect(() => {
if (isOpen) {
const next = serialize(dashboard);
setAppliedText(next);
setDraft(next);
}
}, [isOpen, dashboard]);
const validity = useMemo<JsonValidity>(() => {
const lineCount = draft.split('\n').length;
try {
JSON.parse(draft);
return { valid: true, lineCount };
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid JSON';
return {
valid: false,
lineCount,
errorLine: errorLineFromMessage(draft, message),
message,
};
}
}, [draft]);
const isDirty = draft !== appliedText;
const format = useCallback((): void => {
try {
setDraft(JSON.stringify(JSON.parse(draft), null, 2));
} catch {
// Leave the draft untouched when it can't be parsed.
}
}, [draft]);
const reset = useCallback((): void => {
setDraft(appliedText);
}, [appliedText]);
const apply = useCallback(async (): Promise<void> => {
if (!validity.valid || !isDirty) {
return;
}
try {
setIsSaving(true);
const parsed = JSON.parse(draft) as Record<string, unknown>;
await updateDashboardV2({ id: dashboardId }, dashboardToUpdatable(parsed));
toast.success('Dashboard updated');
refetch();
onApplied();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
}, [
dashboardId,
validity.valid,
isDirty,
draft,
refetch,
onApplied,
showErrorModal,
]);
return {
draft,
setDraft,
validity,
isDirty,
isSaving,
format,
reset,
apply,
};
}

View File

@@ -12,7 +12,6 @@ import type {
DashboardtypesJSONPatchOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useAppContext } from 'providers/App/App';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import { useErrorModal } from 'providers/ErrorModalProvider';
@@ -140,15 +139,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
/>
</div>
{/* Row 2: the time selector floats top-right (declared first so the
variables bar's content wraps around it); the variables bar
collapses to one line and, when expanded, wraps full-width under it. */}
<div className={styles.toolbarRow2}>
<div className={styles.timeCluster}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
<VariablesBar dashboard={dashboard} />
</div>
<VariablesBar dashboard={dashboard} />
</section>
);
}

View File

@@ -2,30 +2,21 @@ import { useEffect, useState } from 'react';
import { Modal } from 'antd';
import { Input } from '@signozhq/ui/input';
interface SectionTitleModalProps {
interface RenameSectionModalProps {
open: boolean;
/** Modal heading, e.g. "Rename section" / "New section". */
heading: string;
/** Confirm button label, e.g. "Rename" / "Create section". */
okText: string;
initialValue: string;
isSaving: boolean;
placeholder?: string;
onClose: () => void;
onSubmit: (title: string) => void;
}
/** Title-entry modal shared by section create and rename. */
function SectionTitleModal({
function RenameSectionModal({
open,
heading,
okText,
initialValue,
isSaving,
placeholder = 'Section name',
onClose,
onSubmit,
}: SectionTitleModalProps): JSX.Element {
}: RenameSectionModalProps): JSX.Element {
const [value, setValue] = useState<string>(initialValue);
// Reseed the field each time the modal opens.
@@ -45,19 +36,19 @@ function SectionTitleModal({
return (
<Modal
open={open}
title={heading}
title="Rename section"
onCancel={onClose}
onOk={submit}
okText={okText}
okText="Rename"
okButtonProps={{ disabled: isSaving || !value.trim() }}
destroyOnClose
>
<Input
testId="section-title-input"
testId="rename-section-input"
autoFocus
value={value}
maxLength={120}
placeholder={placeholder}
placeholder="Section name"
onChange={(e): void => setValue(e.target.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
@@ -70,4 +61,4 @@ function SectionTitleModal({
);
}
export default SectionTitleModal;
export default RenameSectionModal;

View File

@@ -13,7 +13,7 @@ import { useDashboardStore } from '../../../store/useDashboardStore';
import { useDeleteSection } from '../hooks/useDeleteSection';
import { useRenameSection } from '../hooks/useRenameSection';
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
import SectionTitleModal from '../SectionTitleModal';
import RenameSectionModal from '../RenameSectionModal';
import SectionGrid from '../SectionGrid/SectionGrid';
import SectionHeader, {
type SectionDragHandle,
@@ -146,10 +146,8 @@ function Section({
)}
</div>
))}
<SectionTitleModal
<RenameSectionModal
open={isRenaming}
heading="Rename section"
okText="Rename"
initialValue={section.title}
isSaving={isSaving}
onClose={(): void => setIsRenaming(false)}

View File

@@ -12,27 +12,6 @@ import {
} from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
const SECTION_SELECTOR = '[data-testid^="dashboard-section-"]';
/**
* Waits (via rAF) for the refetch to render the appended section, then scrolls
* it into view. Polls because `refetch` resolves before React commits the new
* section to the DOM; bails after ~40 frames.
*/
function scrollToNewSection(prevCount: number, attempts = 40): void {
const sections = document.querySelectorAll(SECTION_SELECTOR);
if (sections.length > prevCount) {
sections[sections.length - 1]?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
return;
}
if (attempts > 0) {
requestAnimationFrame(() => scrollToNewSection(prevCount, attempts - 1));
}
}
interface Params {
layouts: DashboardtypesLayoutDTO[] | undefined | null;
}
@@ -63,12 +42,10 @@ export function useAddSection({ layouts }: Params): Result {
!layouts || layouts.length === 0
? reorderLayoutsOp([newGridLayout(trimmed)])
: addSectionOp(trimmed);
const prevSectionCount = document.querySelectorAll(SECTION_SELECTOR).length;
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, [op]);
refetch();
scrollToNewSection(prevSectionCount);
} catch (error) {
showErrorModal(error as APIError);
} finally {

View File

@@ -101,7 +101,7 @@ function VariableSelector({
${variable.name}
{variable.description ? (
<Tooltip title={variable.description}>
<SolidInfoCircle className={styles.infoIcon} size={14} />
<SolidInfoCircle className={styles.infoIcon} size="md" />
</Tooltip>
) : null}
</Typography.Text>

View File

@@ -1,55 +1,12 @@
/* Mirrors the V1 dashboard variable bar: each variable is a connected pill —
a robin `$name` segment joined to a value segment. */
/* Sits inside the already-padded sticky toolbar section, so it only needs a top
gap from the tags — horizontal/bottom padding comes from the toolbar. */
.bar {
min-width: 0;
}
.strip {
display: flow-root;
}
.stripExpanded {
display: flex;
flex-wrap: wrap;
gap: 8px;
gap: 12px;
padding-top: 12px;
overflow: visible;
clear: both;
.variableSlot,
.moreButton {
margin: 0;
}
@media (prefers-reduced-motion: no-preference) {
animation: variablesExpandIn 200ms ease-out;
}
}
@keyframes variablesExpandIn {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.variableSlot {
display: inline-flex;
align-items: center;
margin-right: 8px;
vertical-align: top;
}
.variableSlotHidden {
display: none;
}
.moreButton {
display: inline-flex;
vertical-align: top;
}
.variableItem {
@@ -64,7 +21,7 @@
align-items: center;
gap: 4px;
padding: 6px 6px 6px 8px;
border: 1px solid var(--l3-border);
border: 1px solid var(--l1-border);
border-radius: 2px 0 0 2px;
background: var(--l3-background);
color: var(--bg-robin-300);
@@ -76,10 +33,8 @@
}
.infoIcon {
display: inline-flex;
margin-left: 2px;
margin-left: 4px;
color: var(--l2-foreground);
vertical-align: middle;
}
.variableValue {
@@ -87,7 +42,7 @@
min-width: 120px;
height: 32px;
align-items: center;
border: 1px solid var(--l3-border);
border: 1px solid var(--l1-border);
border-left: none;
border-radius: 0 2px 2px 0;
background: var(--l2-background);
@@ -100,6 +55,8 @@
}
}
/* Inner control fills the value segment; the segment provides the frame, so the
control itself is borderless/transparent. */
.control {
width: 100%;
min-width: 120px;

View File

@@ -1,9 +1,4 @@
import { useState } from 'react';
import { ChevronLeft } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import cx from 'classnames';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useInlineOverflowCount } from 'hooks/useInlineOverflowCount';
import { useVariableSelection } from './useVariableSelection';
import VariableSelector from './VariableSelector';
@@ -16,76 +11,33 @@ interface VariablesBarProps {
/**
* Runtime variable selector bar shown above the panels. Renders one control per
* dashboard variable; selections live in the store + URL (never the spec).
*
* The pills sit on the line left of the floated time selector and collapse the
* overflow behind a `+N` trigger. Expanding lets the bar wrap onto full-width
* lines that flow underneath the time selector. Every selector stays mounted
* either way so auto-selection and option fetching keep driving the panels.
*/
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
const { variables, dependencyData, selection, setSelection } =
useVariableSelection(dashboard);
const [expanded, setExpanded] = useState(false);
const { containerRef, visibleCount, overflowCount } = useInlineOverflowCount({
itemCount: variables.length,
gap: 8,
reserveWidth: 48,
enabled: !expanded,
});
if (variables.length === 0) {
return null;
}
const hasOverflow = overflowCount > 0;
return (
<div className={styles.bar} data-testid="dashboard-variables-bar">
<div
ref={containerRef}
className={cx(styles.strip, { [styles.stripExpanded]: expanded })}
>
{variables.map((variable, index) => (
<div
key={variable.name}
data-overflow-item="true"
className={cx(styles.variableSlot, {
[styles.variableSlotHidden]:
!expanded && hasOverflow && index >= visibleCount,
})}
>
<VariableSelector
variable={variable}
variables={variables}
parents={dependencyData.parentGraph[variable.name] ?? []}
selections={selection}
selection={
selection[variable.name] ?? {
value: variable.multiSelect ? [] : '',
allSelected: false,
}
}
onChange={(next): void => setSelection(variable.name, next)}
/>
</div>
))}
{hasOverflow && (
<span className={styles.moreButton}>
<Button
variant="outlined"
color="secondary"
size="md"
prefix={expanded ? <ChevronLeft size={14} /> : undefined}
aria-expanded={expanded}
testId="dashboard-variables-more"
onClick={(): void => setExpanded((prev) => !prev)}
>
{expanded ? 'Less' : `+${overflowCount}`}
</Button>
</span>
)}
</div>
{variables.map((variable) => (
<VariableSelector
key={variable.name}
variable={variable}
variables={variables}
parents={dependencyData.parentGraph[variable.name] ?? []}
selections={selection}
selection={
selection[variable.name] ?? {
value: variable.multiSelect ? [] : '',
allSelected: false,
}
}
onChange={(next): void => setSelection(variable.name, next)}
/>
))}
</div>
);
}

View File

@@ -73,7 +73,7 @@ function ValueSelector({
return (
<CustomSelect
className={styles.control}
className={styles.select}
data-testid={testId}
options={optionData}
value={

View File

@@ -48,15 +48,8 @@ export const createVariableSelectionSlice: StateCreator<
},
});
/**
* Stable empty map for dashboards with no stored selections. Returning an inline
* `{}` here would hand zustand's useSyncExternalStore a new reference every call,
* which it reads as a changed snapshot → infinite re-render loop.
*/
const EMPTY_SELECTION_MAP: VariableSelectionMap = {};
/** Selector: the selection map for a dashboard (empty if none). */
export const selectVariableValues =
(dashboardId: string) =>
(state: DashboardStore): VariableSelectionMap =>
state.variableValues[dashboardId] ?? EMPTY_SELECTION_MAP;
state.variableValues[dashboardId] ?? {};

View File

@@ -1,133 +0,0 @@
{
"title": "TransactionGroups",
"items": {
"$ref": "#/definitions/AuthtypesTransactionGroup"
},
"definitions": {
"AuthtypesRelation": {
"additionalProperties": false,
"enum": [
"create",
"read",
"update",
"delete",
"list",
"assignee",
"attach",
"detach"
],
"type": "string"
},
"AuthtypesTransactionGroup": {
"required": [
"relation",
"objectGroup"
],
"properties": {
"objectGroup": {
"$ref": "#/definitions/CoretypesObjectGroup"
},
"relation": {
"$ref": "#/definitions/AuthtypesRelation"
}
},
"type": "object"
},
"CoretypesKind": {
"additionalProperties": false,
"enum": [
"anonymous",
"organization",
"role",
"serviceaccount",
"user",
"notification-channel",
"route-policy",
"apdex-setting",
"auth-domain",
"session",
"cloud-integration",
"cloud-integration-service",
"integration",
"dashboard",
"public-dashboard",
"ingestion-key",
"ingestion-limit",
"pipeline",
"user-preference",
"org-preference",
"quick-filter",
"ttl-setting",
"rule",
"planned-maintenance",
"saved-view",
"trace-funnel",
"factor-password",
"factor-api-key",
"license",
"subscription",
"logs",
"traces",
"metrics",
"audit-logs",
"meter-metrics",
"logs-field",
"traces-field"
],
"type": "string"
},
"CoretypesObjectGroup": {
"required": [
"resource",
"selectors"
],
"additionalProperties": false,
"properties": {
"resource": {
"$ref": "#/definitions/CoretypesResourceRef"
},
"selectors": {
"items": {
"$ref": "#/definitions/CoretypesSelector"
},
"type": "array"
}
},
"type": "object"
},
"CoretypesResourceRef": {
"required": [
"type",
"kind"
],
"additionalProperties": false,
"properties": {
"kind": {
"$ref": "#/definitions/CoretypesKind"
},
"type": {
"$ref": "#/definitions/CoretypesType"
}
},
"type": "object"
},
"CoretypesSelector": {
"additionalProperties": false,
"type": "string"
},
"CoretypesType": {
"additionalProperties": false,
"enum": [
"user",
"serviceaccount",
"anonymous",
"role",
"organization",
"metaresource",
"telemetryresource"
],
"type": "string"
}
},
"type": "array"
}

View File

@@ -1,4 +1,4 @@
/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM frontend/src/schemas/generated/webSettings.schema.json */
/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM docs/config/web-settings.json */
export interface WebSettings {
appcues: Appcues;

View File

@@ -11,13 +11,13 @@ import (
)
func (provider *provider) addMetricReductionRuleRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/metric_reduction_rules", handler.New(
if err := router.Handle("/api/v2/metrics/reduction_rules", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricReductionRuleHandler.List),
handler.OpenAPIDef{
ID: "ListMetricReductionRules",
Tags: []string{"metrics"},
Summary: "List metric reduction rules",
Description: "Returns active metric volume-control (label reduction) rules.",
Description: "Returns active metric volume-control (label reduction) rules, sorted and paginated server-side.",
RequestQuery: new(metricreductionruletypes.ListReductionRulesParams),
Response: new(metricreductionruletypes.GettableReductionRules),
ResponseContentType: "application/json",
@@ -29,32 +29,13 @@ func (provider *provider) addMetricReductionRuleRoutes(router *mux.Router) error
return err
}
if err := router.Handle("/api/v2/metric_reduction_rules", handler.New(
provider.authzMiddleware.AdminAccess(provider.metricReductionRuleHandler.Create),
handler.OpenAPIDef{
ID: "CreateMetricReductionRule",
Tags: []string{"metrics"},
Summary: "Create a metric reduction rule",
Description: "Creates a volume-control rule for a metric and returns it with its id; fails if the metric already has a rule.",
Request: new(metricreductionruletypes.PostableReductionRule),
RequestContentType: "application/json",
Response: new(metricreductionruletypes.GettableReductionRule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusConflict, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons, http.StatusInternalServerError},
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metric_reduction_rules/stats", handler.New(
if err := router.Handle("/api/v2/metrics/reduction_rules/stats", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricReductionRuleHandler.Stats),
handler.OpenAPIDef{
ID: "GetMetricReductionRuleStats",
Tags: []string{"metrics"},
Summary: "Metric reduction stats",
Description: "Returns total ingested vs retained series and the estimated monthly savings across all volume-control rules.",
Description: "Returns total ingested vs reduced series and the estimated monthly savings across all volume-control rules.",
Response: new(metricreductionruletypes.GettableReductionRuleStats),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
@@ -65,13 +46,13 @@ func (provider *provider) addMetricReductionRuleRoutes(router *mux.Router) error
return err
}
if err := router.Handle("/api/v2/metric_reduction_rules/timeseries", handler.New(
if err := router.Handle("/api/v2/metrics/reduction_rules/timeseries", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricReductionRuleHandler.Timeseries),
handler.OpenAPIDef{
ID: "GetMetricReductionRuleTimeseries",
Tags: []string{"metrics"},
Summary: "Metric reduction volume over time",
Description: "Returns ingested vs retained series over time across all volume-control rules (hourly buckets), in the query-range time-series response shape.",
Description: "Returns ingested vs reduced series over time across all volume-control rules (hourly buckets), in the query-range time-series response shape.",
Response: new(querybuildertypesv5.QueryRangeResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
@@ -82,7 +63,7 @@ func (provider *provider) addMetricReductionRuleRoutes(router *mux.Router) error
return err
}
if err := router.Handle("/api/v2/metric_reduction_rules/preview", handler.New(
if err := router.Handle("/api/v2/metrics/reduction_rules/preview", handler.New(
provider.authzMiddleware.AdminAccess(provider.metricReductionRuleHandler.Preview),
handler.OpenAPIDef{
ID: "PreviewMetricReductionRule",
@@ -101,7 +82,26 @@ func (provider *provider) addMetricReductionRuleRoutes(router *mux.Router) error
return err
}
if err := router.Handle("/api/v2/metric_reduction_rules/{id}", handler.New(
if err := router.Handle("/api/v2/metrics/reduction_rules", handler.New(
provider.authzMiddleware.AdminAccess(provider.metricReductionRuleHandler.Create),
handler.OpenAPIDef{
ID: "CreateMetricReductionRule",
Tags: []string{"metrics"},
Summary: "Create a metric reduction rule",
Description: "Creates a volume-control rule for a metric and returns it with its id; fails if the metric already has a rule. Intended for Terraform/operators.",
Request: new(metricreductionruletypes.PostableReductionRule),
RequestContentType: "application/json",
Response: new(metricreductionruletypes.GettableReductionRule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusConflict, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons, http.StatusInternalServerError},
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/reduction_rules/{id}", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricReductionRuleHandler.GetByID),
handler.OpenAPIDef{
ID: "GetMetricReductionRuleByID",
@@ -118,14 +118,14 @@ func (provider *provider) addMetricReductionRuleRoutes(router *mux.Router) error
return err
}
if err := router.Handle("/api/v2/metric_reduction_rules/{id}", handler.New(
if err := router.Handle("/api/v2/metrics/reduction_rules/{id}", handler.New(
provider.authzMiddleware.AdminAccess(provider.metricReductionRuleHandler.UpdateByID),
handler.OpenAPIDef{
ID: "UpdateMetricReductionRuleByID",
Tags: []string{"metrics"},
Summary: "Update a metric reduction rule by id",
Description: "Updates the match type and labels of a volume-control rule by its id; the metric name is immutable.",
Request: new(metricreductionruletypes.UpdatableReductionRule),
Request: new(metricreductionruletypes.PostableReductionRule),
RequestContentType: "application/json",
Response: new(metricreductionruletypes.GettableReductionRule),
ResponseContentType: "application/json",
@@ -137,7 +137,7 @@ func (provider *provider) addMetricReductionRuleRoutes(router *mux.Router) error
return err
}
if err := router.Handle("/api/v2/metric_reduction_rules/{id}", handler.New(
if err := router.Handle("/api/v2/metrics/reduction_rules/{id}", handler.New(
provider.authzMiddleware.AdminAccess(provider.metricReductionRuleHandler.DeleteByID),
handler.OpenAPIDef{
ID: "DeleteMetricReductionRuleByID",
@@ -152,5 +152,58 @@ func (provider *provider) addMetricReductionRuleRoutes(router *mux.Router) error
return err
}
if err := router.Handle("/api/v2/metrics/{metric_name}/reduction_rule", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricReductionRuleHandler.Get),
handler.OpenAPIDef{
ID: "GetMetricReductionRule",
Tags: []string{"metrics"},
Summary: "Get a metric reduction rule",
Description: "Returns the active volume-control (label reduction) rule for a specified metric.",
Response: new(metricreductionruletypes.GettableReductionRule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons, http.StatusInternalServerError},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/{metric_name}/reduction_rule", handler.New(
provider.authzMiddleware.AdminAccess(provider.metricReductionRuleHandler.Upsert),
handler.OpenAPIDef{
ID: "UpsertMetricReductionRule",
Tags: []string{"metrics"},
Summary: "Create or update a metric reduction rule",
Description: "Creates or updates the volume-control (label reduction) rule for a specified metric. The rule takes effect after a short activation delay. Admin only; enterprise feature.",
Request: new(metricreductionruletypes.PostableReductionRule),
RequestContentType: "application/json",
Response: new(metricreductionruletypes.GettableReductionRule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons, http.StatusInternalServerError},
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/{metric_name}/reduction_rule", handler.New(
provider.authzMiddleware.AdminAccess(provider.metricReductionRuleHandler.Delete),
handler.OpenAPIDef{
ID: "DeleteMetricReductionRule",
Tags: []string{"metrics"},
Summary: "Delete a metric reduction rule",
Description: "Removes the volume-control (label reduction) rule for a specified metric, reverting it to full fidelity. Admin only; enterprise feature.",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons, http.StatusInternalServerError},
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -68,7 +68,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metrics/attributes", handler.New(
if err := router.Handle("/api/v2/metrics/{metric_name}/attributes", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricAttributes),
handler.OpenAPIDef{
ID: "GetMetricAttributes",
@@ -88,7 +88,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metrics/metadata", handler.New(
if err := router.Handle("/api/v2/metrics/{metric_name}/metadata", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricMetadata),
handler.OpenAPIDef{
ID: "GetMetricMetadata",
@@ -96,7 +96,6 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
Summary: "Get metric metadata",
Description: "This endpoint returns metadata information like metric description, unit, type, temporality, monotonicity for a specified metric",
Request: nil,
RequestQuery: new(metricsexplorertypes.MetricNameQuery),
RequestContentType: "",
Response: new(metricsexplorertypes.MetricMetadata),
ResponseContentType: "application/json",
@@ -108,7 +107,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metrics/metadata", handler.New(
if err := router.Handle("/api/v2/metrics/{metric_name}/metadata", handler.New(
provider.authzMiddleware.EditAccess(provider.metricsExplorerHandler.UpdateMetricMetadata),
handler.OpenAPIDef{
ID: "UpdateMetricMetadata",
@@ -127,7 +126,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metrics/highlights", handler.New(
if err := router.Handle("/api/v2/metrics/{metric_name}/highlights", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricHighlights),
handler.OpenAPIDef{
ID: "GetMetricHighlights",
@@ -135,7 +134,6 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
Summary: "Get metric highlights",
Description: "This endpoint returns highlights like number of datapoints, totaltimeseries, active time series, last received time for a specified metric",
Request: nil,
RequestQuery: new(metricsexplorertypes.MetricNameQuery),
RequestContentType: "",
Response: new(metricsexplorertypes.MetricHighlightsResponse),
ResponseContentType: "application/json",
@@ -147,7 +145,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metrics/alerts", handler.New(
if err := router.Handle("/api/v2/metrics/{metric_name}/alerts", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricAlerts),
handler.OpenAPIDef{
ID: "GetMetricAlerts",
@@ -155,7 +153,6 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
Summary: "Get metric alerts",
Description: "This endpoint returns associated alerts for a specified metric",
Request: nil,
RequestQuery: new(metricsexplorertypes.MetricNameQuery),
RequestContentType: "",
Response: new(metricsexplorertypes.MetricAlertsResponse),
ResponseContentType: "application/json",
@@ -167,7 +164,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metrics/dashboards", handler.New(
if err := router.Handle("/api/v2/metrics/{metric_name}/dashboards", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricDashboards),
handler.OpenAPIDef{
ID: "GetMetricDashboards",
@@ -175,7 +172,6 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
Summary: "Get metric dashboards",
Description: "This endpoint returns associated dashboards for a specified metric",
Request: nil,
RequestQuery: new(metricsexplorertypes.MetricNameQuery),
RequestContentType: "",
Response: new(metricsexplorertypes.MetricDashboardsResponse),
ResponseContentType: "application/json",

View File

@@ -4,6 +4,7 @@ import (
"context"
"log/slog"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/errors"
@@ -11,6 +12,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
@@ -207,12 +209,16 @@ func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, m
module.checkPromQLQueriesForMetricNames(ctx, query, metricNames, foundMetrics)
// Add widget to results for all found metrics
groupByByMetric := module.collectBuilderGroupBy(query, metricNames)
filterByMetric := module.collectBuilderFilterKeys(query, metricNames)
for metricName := range foundMetrics {
result[metricName] = append(result[metricName], map[string]string{
"dashboard_id": dashboard.ID,
"widget_name": widgetTitle,
"widget_id": widgetID,
"dashboard_name": dashTitle,
"group_by": strings.Join(groupByByMetric[metricName], ","),
"filter_by": strings.Join(filterByMetric[metricName], ","),
})
}
}
@@ -306,6 +312,137 @@ func (module *module) checkBuilderQueriesForMetricNames(query map[string]interfa
}
}
// collectBuilderGroupBy returns, per metric, the group-by attribute keys used alongside it in the
// builder queries of a widget.
func (module *module) collectBuilderGroupBy(query map[string]interface{}, metricNames []string) map[string][]string {
out := make(map[string][]string)
builder, ok := query["builder"].(map[string]interface{})
if !ok {
return out
}
queryData, ok := builder["queryData"].([]interface{})
if !ok {
return out
}
for _, qd := range queryData {
data, ok := qd.(map[string]interface{})
if !ok {
continue
}
if dataSource, ok := data["dataSource"].(string); !ok || dataSource != "metrics" {
continue
}
aggregations, ok := data["aggregations"].([]interface{})
if !ok {
continue
}
groupByKeys := groupByKeysFromData(data["groupBy"])
if len(groupByKeys) == 0 {
continue
}
for _, agg := range aggregations {
aggMap, ok := agg.(map[string]interface{})
if !ok {
continue
}
metricName, ok := aggMap["metricName"].(string)
if !ok || metricName == "" || !slices.Contains(metricNames, metricName) {
continue
}
out[metricName] = append(out[metricName], groupByKeys...)
}
}
return out
}
// groupByKeysFromData extracts attribute names from a builder query's groupBy (JSON []GroupByKey).
func groupByKeysFromData(v interface{}) []string {
arr, ok := v.([]interface{})
if !ok {
return nil
}
var keys []string
for _, item := range arr {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
if name, ok := m["name"].(string); ok && name != "" {
keys = append(keys, name)
}
}
return keys
}
// collectBuilderFilterKeys returns, per metric, the attribute keys referenced in builder-query filter
// expressions: a panel that filters on a dropped label breaks just like one that groups by it.
func (module *module) collectBuilderFilterKeys(query map[string]interface{}, metricNames []string) map[string][]string {
out := make(map[string][]string)
builder, ok := query["builder"].(map[string]interface{})
if !ok {
return out
}
queryData, ok := builder["queryData"].([]interface{})
if !ok {
return out
}
for _, qd := range queryData {
data, ok := qd.(map[string]interface{})
if !ok {
continue
}
if dataSource, ok := data["dataSource"].(string); !ok || dataSource != "metrics" {
continue
}
aggregations, ok := data["aggregations"].([]interface{})
if !ok {
continue
}
filterKeys := filterKeysFromData(data["filter"])
if len(filterKeys) == 0 {
continue
}
for _, agg := range aggregations {
aggMap, ok := agg.(map[string]interface{})
if !ok {
continue
}
metricName, ok := aggMap["metricName"].(string)
if !ok || metricName == "" || !slices.Contains(metricNames, metricName) {
continue
}
out[metricName] = append(out[metricName], filterKeys...)
}
}
return out
}
// filterKeysFromData extracts the attribute keys from a builder query's filter (JSON {expression})
// using the filter-query grammar.
func filterKeysFromData(v interface{}) []string {
m, ok := v.(map[string]interface{})
if !ok {
return nil
}
expr, ok := m["expression"].(string)
if !ok || expr == "" {
return nil
}
seen := make(map[string]struct{})
var keys []string
for _, sel := range querybuilder.QueryStringToKeysSelectors(expr) {
if sel == nil || sel.Name == "" {
continue
}
if _, dup := seen[sel.Name]; dup {
continue
}
seen[sel.Name] = struct{}{}
keys = append(keys, sel.Name)
}
return keys
}
// checkClickHouseQueriesForMetricNames checks clickhouse_sql[] array for metric names in query strings.
func (module *module) checkClickHouseQueriesForMetricNames(ctx context.Context, query map[string]interface{}, metricNames []string, foundMetrics map[string]bool) {
clickhouseSQL, ok := query["clickhouse_sql"].([]interface{})

View File

@@ -21,6 +21,14 @@ func NewHandler(module metricreductionrule.Module) metricreductionrule.Handler {
return &handler{module: module}
}
func metricNameFromPath(r *http.Request) (string, error) {
metricName := mux.Vars(r)["metric_name"]
if metricName == "" {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "metric_name is required in URL path")
}
return metricName, nil
}
func idFromPath(r *http.Request) (valuer.UUID, error) {
id, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
@@ -51,6 +59,78 @@ func (h *handler) List(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, out)
}
func (h *handler) Get(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
}
metricName, err := metricNameFromPath(r)
if err != nil {
render.Error(rw, err)
return
}
out, err := h.module.Get(r.Context(), valuer.MustNewUUID(claims.OrgID), metricName)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, out)
}
func (h *handler) Upsert(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
}
metricName, err := metricNameFromPath(r)
if err != nil {
render.Error(rw, err)
return
}
var in metricreductionruletypes.PostableReductionRule
if err := binding.JSON.BindBody(r.Body, &in); err != nil {
render.Error(rw, err)
return
}
in.MetricName = metricName
out, err := h.module.Upsert(r.Context(), valuer.MustNewUUID(claims.OrgID), claims.Email, &in)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, out)
}
func (h *handler) Delete(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
}
metricName, err := metricNameFromPath(r)
if err != nil {
render.Error(rw, err)
return
}
if err := h.module.Delete(r.Context(), valuer.MustNewUUID(claims.OrgID), metricName); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (h *handler) Preview(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
@@ -161,7 +241,7 @@ func (h *handler) UpdateByID(rw http.ResponseWriter, r *http.Request) {
return
}
var in metricreductionruletypes.UpdatableReductionRule
var in metricreductionruletypes.PostableReductionRule
if err := binding.JSON.BindBody(r.Body, &in); err != nil {
render.Error(rw, err)
return

View File

@@ -16,37 +16,51 @@ func NewModule() metricreductionrule.Module {
return &module{}
}
var errUnsupported = errors.New(errors.TypeUnsupported, metricreductionruletypes.ErrCodeMetricReductionRuleUnsupported,
"metric volume control is an enterprise feature")
func errUnsupported() error {
return errors.Newf(errors.TypeUnsupported, metricreductionruletypes.ErrCodeMetricReductionRuleUnsupported,
"metric volume control is an enterprise feature")
}
func (m *module) List(_ context.Context, _ valuer.UUID, _ *metricreductionruletypes.ListReductionRulesParams) (*metricreductionruletypes.GettableReductionRules, error) {
return nil, errUnsupported
return nil, errUnsupported()
}
func (m *module) Get(_ context.Context, _ valuer.UUID, _ string) (*metricreductionruletypes.GettableReductionRule, error) {
return nil, errUnsupported()
}
func (m *module) Upsert(_ context.Context, _ valuer.UUID, _ string, _ *metricreductionruletypes.PostableReductionRule) (*metricreductionruletypes.GettableReductionRule, error) {
return nil, errUnsupported()
}
func (m *module) Delete(_ context.Context, _ valuer.UUID, _ string) error {
return errUnsupported()
}
func (m *module) Create(_ context.Context, _ valuer.UUID, _ string, _ *metricreductionruletypes.PostableReductionRule) (*metricreductionruletypes.GettableReductionRule, error) {
return nil, errUnsupported
return nil, errUnsupported()
}
func (m *module) GetByID(_ context.Context, _ valuer.UUID, _ valuer.UUID) (*metricreductionruletypes.GettableReductionRule, error) {
return nil, errUnsupported
return nil, errUnsupported()
}
func (m *module) UpdateByID(_ context.Context, _ valuer.UUID, _ string, _ valuer.UUID, _ *metricreductionruletypes.UpdatableReductionRule) (*metricreductionruletypes.GettableReductionRule, error) {
return nil, errUnsupported
func (m *module) UpdateByID(_ context.Context, _ valuer.UUID, _ string, _ valuer.UUID, _ *metricreductionruletypes.PostableReductionRule) (*metricreductionruletypes.GettableReductionRule, error) {
return nil, errUnsupported()
}
func (m *module) DeleteByID(_ context.Context, _ valuer.UUID, _ valuer.UUID) error {
return errUnsupported
return errUnsupported()
}
func (m *module) Preview(_ context.Context, _ valuer.UUID, _ *metricreductionruletypes.PostableReductionRulePreview) (*metricreductionruletypes.GettableReductionRulePreview, error) {
return nil, errUnsupported
return nil, errUnsupported()
}
func (m *module) Stats(_ context.Context, _ valuer.UUID) (*metricreductionruletypes.GettableReductionRuleStats, error) {
return nil, errUnsupported
return nil, errUnsupported()
}
func (m *module) Timeseries(_ context.Context, _ valuer.UUID) (*querybuildertypesv5.QueryRangeResponse, error) {
return nil, errUnsupported
return nil, errUnsupported()
}

View File

@@ -11,9 +11,12 @@ import (
type Module interface {
List(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams) (*metricreductionruletypes.GettableReductionRules, error)
Get(ctx context.Context, orgID valuer.UUID, metricName string) (*metricreductionruletypes.GettableReductionRule, error)
Upsert(ctx context.Context, orgID valuer.UUID, userEmail string, req *metricreductionruletypes.PostableReductionRule) (*metricreductionruletypes.GettableReductionRule, error)
Delete(ctx context.Context, orgID valuer.UUID, metricName string) error
Create(ctx context.Context, orgID valuer.UUID, userEmail string, req *metricreductionruletypes.PostableReductionRule) (*metricreductionruletypes.GettableReductionRule, error)
GetByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*metricreductionruletypes.GettableReductionRule, error)
UpdateByID(ctx context.Context, orgID valuer.UUID, userEmail string, id valuer.UUID, req *metricreductionruletypes.UpdatableReductionRule) (*metricreductionruletypes.GettableReductionRule, error)
UpdateByID(ctx context.Context, orgID valuer.UUID, userEmail string, id valuer.UUID, req *metricreductionruletypes.PostableReductionRule) (*metricreductionruletypes.GettableReductionRule, error)
DeleteByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
Preview(ctx context.Context, orgID valuer.UUID, req *metricreductionruletypes.PostableReductionRulePreview) (*metricreductionruletypes.GettableReductionRulePreview, error)
Stats(ctx context.Context, orgID valuer.UUID) (*metricreductionruletypes.GettableReductionRuleStats, error)
@@ -22,6 +25,9 @@ type Module interface {
type Handler interface {
List(rw http.ResponseWriter, r *http.Request)
Get(rw http.ResponseWriter, r *http.Request)
Upsert(rw http.ResponseWriter, r *http.Request)
Delete(rw http.ResponseWriter, r *http.Request)
Create(rw http.ResponseWriter, r *http.Request)
GetByID(rw http.ResponseWriter, r *http.Request)
UpdateByID(rw http.ResponseWriter, r *http.Request)

View File

@@ -11,8 +11,17 @@ import (
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/metricsexplorertypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func extractMetricName(req *http.Request) (string, error) {
metricName := mux.Vars(req)["metric_name"]
if metricName == "" {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "metric_name is required in URL path")
}
return metricName, nil
}
type handler struct {
module metricsexplorer.Module
}
@@ -107,17 +116,23 @@ func (h *handler) UpdateMetricMetadata(rw http.ResponseWriter, req *http.Request
return
}
// Extract metric_name from URL path
vars := mux.Vars(req)
metricName := vars["metric_name"]
if metricName == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "metric_name is required in URL path"))
return
}
var in metricsexplorertypes.UpdateMetricMetadataRequest
if err := binding.JSON.BindBody(req.Body, &in); err != nil {
render.Error(rw, err)
return
}
if in.MetricName == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName is required"))
return
}
// Set metric name from URL path
in.MetricName = metricName
orgID := valuer.MustNewUUID(claims.OrgID)
err = h.module.UpdateMetricMetadata(req.Context(), orgID, &in)
@@ -136,16 +151,11 @@ func (h *handler) GetMetricMetadata(rw http.ResponseWriter, req *http.Request) {
return
}
var in metricsexplorertypes.MetricNameQuery
if err := binding.Query.BindQuery(req.URL.Query(), &in); err != nil {
metricName, err := extractMetricName(req)
if err != nil {
render.Error(rw, err)
return
}
if err := in.Validate(); err != nil {
render.Error(rw, err)
return
}
metricName := in.MetricName
orgID := valuer.MustNewUUID(claims.OrgID)
@@ -171,24 +181,20 @@ func (h *handler) GetMetricAlerts(rw http.ResponseWriter, req *http.Request) {
return
}
var in metricsexplorertypes.MetricNameQuery
if err := binding.Query.BindQuery(req.URL.Query(), &in); err != nil {
render.Error(rw, err)
return
}
if err := in.Validate(); err != nil {
metricName, err := extractMetricName(req)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
if err := h.checkMetricExists(req.Context(), orgID, in.MetricName); err != nil {
if err := h.checkMetricExists(req.Context(), orgID, metricName); err != nil {
render.Error(rw, err)
return
}
out, err := h.module.GetMetricAlerts(req.Context(), orgID, in.MetricName)
out, err := h.module.GetMetricAlerts(req.Context(), orgID, metricName)
if err != nil {
render.Error(rw, err)
return
@@ -203,24 +209,20 @@ func (h *handler) GetMetricDashboards(rw http.ResponseWriter, req *http.Request)
return
}
var in metricsexplorertypes.MetricNameQuery
if err := binding.Query.BindQuery(req.URL.Query(), &in); err != nil {
render.Error(rw, err)
return
}
if err := in.Validate(); err != nil {
metricName, err := extractMetricName(req)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
if err := h.checkMetricExists(req.Context(), orgID, in.MetricName); err != nil {
if err := h.checkMetricExists(req.Context(), orgID, metricName); err != nil {
render.Error(rw, err)
return
}
out, err := h.module.GetMetricDashboards(req.Context(), orgID, in.MetricName)
out, err := h.module.GetMetricDashboards(req.Context(), orgID, metricName)
if err != nil {
render.Error(rw, err)
return
@@ -235,24 +237,20 @@ func (h *handler) GetMetricHighlights(rw http.ResponseWriter, req *http.Request)
return
}
var in metricsexplorertypes.MetricNameQuery
if err := binding.Query.BindQuery(req.URL.Query(), &in); err != nil {
render.Error(rw, err)
return
}
if err := in.Validate(); err != nil {
metricName, err := extractMetricName(req)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
if err := h.checkMetricExists(req.Context(), orgID, in.MetricName); err != nil {
if err := h.checkMetricExists(req.Context(), orgID, metricName); err != nil {
render.Error(rw, err)
return
}
highlights, err := h.module.GetMetricHighlights(req.Context(), orgID, in.MetricName)
highlights, err := h.module.GetMetricHighlights(req.Context(), orgID, metricName)
if err != nil {
render.Error(rw, err)
return
@@ -267,12 +265,20 @@ func (h *handler) GetMetricAttributes(rw http.ResponseWriter, req *http.Request)
return
}
metricName, err := extractMetricName(req)
if err != nil {
render.Error(rw, err)
return
}
var in metricsexplorertypes.MetricAttributesRequest
if err := binding.Query.BindQuery(req.URL.Query(), &in); err != nil {
render.Error(rw, err)
return
}
in.MetricName = metricName
if err := in.Validate(); err != nil {
render.Error(rw, err)
return
@@ -280,7 +286,7 @@ func (h *handler) GetMetricAttributes(rw http.ResponseWriter, req *http.Request)
orgID := valuer.MustNewUUID(claims.OrgID)
if err := h.checkMetricExists(req.Context(), orgID, in.MetricName); err != nil {
if err := h.checkMetricExists(req.Context(), orgID, metricName); err != nil {
render.Error(rw, err)
return
}

View File

@@ -377,11 +377,16 @@ func (m *module) GetMetricDashboards(ctx context.Context, orgID valuer.UUID, met
if dashboardList, ok := data[metricName]; ok {
dashboards = make([]metricsexplorertypes.MetricDashboard, 0, len(dashboardList))
for _, item := range dashboardList {
var groupBy []string
if gb := item["group_by"]; gb != "" {
groupBy = strings.Split(gb, ",")
}
dashboards = append(dashboards, metricsexplorertypes.MetricDashboard{
DashboardName: item["dashboard_name"],
DashboardID: item["dashboard_id"],
WidgetID: item["widget_id"],
WidgetName: item["widget_name"],
GroupBy: groupBy,
})
}
}

View File

@@ -114,10 +114,6 @@ func validateAndApplyDefaultExportLimits(queries []qbtypes.QueryEnvelope) error
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit cannot be more than %d", MaxExportRowCountLimit)
}
queries[idx].SetLimit(limit)
if queries[idx].GetOffset() < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset must be non-negative")
}
}
return nil
}

View File

@@ -70,13 +70,12 @@ func exportRawDataForSingleQuery(querier querier.Querier, ctx context.Context, o
queries := rangeRequest.CompositeQuery.Queries
rowCountLimit := queries[queryIndex].GetLimit()
startingOffset := queries[queryIndex].GetOffset()
rowCount := 0
for rowCount < rowCountLimit {
chunkSize := min(ChunkSize, rowCountLimit-rowCount)
queries[queryIndex].SetLimit(chunkSize)
queries[queryIndex].SetOffset(startingOffset + rowCount)
queries[queryIndex].SetOffset(rowCount)
response, err := querier.QueryRange(ctx, orgID, rangeRequest)
if err != nil {

View File

@@ -1687,6 +1687,15 @@ func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
metricsReduction := aH.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureEnableMetricsReduction, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureEnableMetricsReduction.String()),
Active: metricsReduction,
Usage: 0,
UsageLimit: -1,
Route: "",
})
if constants.IsDotMetricsEnabled {
for idx, feature := range featureSet {
if feature.Name == licensetypes.DotMetricsEnabled {

View File

@@ -115,7 +115,7 @@ func New(
meterReporterProviderFactories func(context.Context, factory.ProviderSettings, flagger.Flagger, licensing.Licensing, telemetrystore.TelemetryStore, retention.Getter, organization.Getter, zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string),
querierHandlerCallback func(factory.ProviderSettings, querier.Querier, analytics.Analytics) querier.Handler,
cloudIntegrationCallback func(sqlstore.SQLStore, dashboard.Module, global.Global, zeus.Zeus, gateway.Gateway, licensing.Licensing, serviceaccount.Module, cloudintegration.Config) (cloudintegration.Module, error),
metricReductionRuleModuleCallback func(sqlstore.SQLStore, telemetrystore.TelemetryStore, dashboard.Module, queryparser.QueryParser, licensing.Licensing, flagger.Flagger, telemetrytypes.MetadataStore, factory.ProviderSettings, int) metricreductionrule.Module,
metricReductionRuleModuleCallback func(sqlstore.SQLStore, telemetrystore.TelemetryStore, dashboard.Module, queryparser.QueryParser, licensing.Licensing, flagger.Flagger, factory.ProviderSettings, int) metricreductionrule.Module,
rulerProviderFactories func(cache.Cache, alertmanager.Alertmanager, sqlstore.SQLStore, telemetrystore.TelemetryStore, telemetrytypes.MetadataStore, prometheus.Prometheus, organization.Getter, rulestatehistory.Module, querier.Querier, queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]],
) (*SigNoz, error) {
// Initialize instrumentation
@@ -466,7 +466,7 @@ func New(
return nil, err
}
metricReductionRuleModule := metricReductionRuleModuleCallback(sqlstore, telemetrystore, dashboard, queryParser, licensing, flagger, telemetryMetadataStore, providerSettings, config.MetricsExplorer.TelemetryStore.Threads)
metricReductionRuleModule := metricReductionRuleModuleCallback(sqlstore, telemetrystore, dashboard, queryParser, licensing, flagger, providerSettings, config.MetricsExplorer.TelemetryStore.Threads)
// Initialize all modules
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule)

View File

@@ -244,7 +244,7 @@ func TestStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `k8s.statefulset.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `k8s.statefulset.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'k8s.statefulset.name') AS `k8s.statefulset.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? AND JSONExtractString(labels, 'k8s.statefulset.name') = ? GROUP BY fingerprint, `k8s.statefulset.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `k8s.statefulset.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `k8s.statefulset.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `k8s.statefulset.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `k8s.statefulset.name`, ts",
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `k8s.statefulset.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `k8s.statefulset.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'k8s.statefulset.name') AS `k8s.statefulset.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? AND JSONExtractString(labels, 'k8s.statefulset.name') = ? GROUP BY fingerprint, `k8s.statefulset.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `k8s.statefulset.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `k8s.statefulset.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `k8s.statefulset.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `k8s.statefulset.name`, ts",
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", false, "my-statefulset", "signoz_calls_total", uint64(1747947360000), uint64(1747983420000), 0},
},
expectedErr: nil,

View File

@@ -9,25 +9,25 @@ import (
)
const (
DBName = "signoz_metrics"
UpdatedMetadataTableName = "distributed_updated_metadata"
UpdatedMetadataLocalTableName = "updated_metadata"
SamplesV4TableName = "distributed_samples_v4"
SamplesV4LocalTableName = "samples_v4"
SamplesV4Agg5mTableName = "distributed_samples_v4_agg_5m"
SamplesV4Agg5mLocalTableName = "samples_v4_agg_5m"
SamplesV4Agg30mTableName = "distributed_samples_v4_agg_30m"
SamplesV4Agg30mLocalTableName = "samples_v4_agg_30m"
ExpHistogramTableName = "distributed_exp_hist"
ExpHistogramLocalTableName = "exp_hist"
TimeseriesV4TableName = "distributed_time_series_v4"
TimeseriesV4LocalTableName = "time_series_v4"
TimeseriesV46hrsTableName = "distributed_time_series_v4_6hrs"
TimeseriesV46hrsLocalTableName = "time_series_v4_6hrs"
TimeseriesV41dayTableName = "distributed_time_series_v4_1day"
TimeseriesV41dayLocalTableName = "time_series_v4_1day"
TimeseriesV41weekTableName = "distributed_time_series_v4_1week"
TimeseriesV41weekLocalTableName = "time_series_v4_1week"
DBName = "signoz_metrics"
UpdatedMetadataTableName = "distributed_updated_metadata"
UpdatedMetadataLocalTableName = "updated_metadata"
SamplesV4TableName = "distributed_samples_v4"
SamplesV4LocalTableName = "samples_v4"
SamplesV4Agg5mTableName = "distributed_samples_v4_agg_5m"
SamplesV4Agg5mLocalTableName = "samples_v4_agg_5m"
SamplesV4Agg30mTableName = "distributed_samples_v4_agg_30m"
SamplesV4Agg30mLocalTableName = "samples_v4_agg_30m"
ExpHistogramTableName = "distributed_exp_hist"
ExpHistogramLocalTableName = "exp_hist"
TimeseriesV4TableName = "distributed_time_series_v4"
TimeseriesV4LocalTableName = "time_series_v4"
TimeseriesV46hrsTableName = "distributed_time_series_v4_6hrs"
TimeseriesV46hrsLocalTableName = "time_series_v4_6hrs"
TimeseriesV41dayTableName = "distributed_time_series_v4_1day"
TimeseriesV41dayLocalTableName = "time_series_v4_1day"
TimeseriesV41weekTableName = "distributed_time_series_v4_1week"
TimeseriesV41weekLocalTableName = "time_series_v4_1week"
AttributesMetadataTableName = "distributed_metadata"
AttributesMetadataLocalTableName = "metadata"
@@ -42,7 +42,8 @@ const (
TimeseriesV4ReducedTableName = "distributed_time_series_v4_reduced"
TimeseriesV4ReducedLocalTableName = "time_series_v4_reduced"
ReductionRulesTableName = "distributed_metric_reduction_rules"
ReductionRulesTableName = "distributed_metric_reduction_rules"
ReductionRulesLocalTableName = "metric_reduction_rules"
)
var (

View File

@@ -80,8 +80,8 @@ type RoleWithTransactionGroups struct {
type PostableRole struct {
Name string `json:"name" required:"true"`
Description string `json:"description" required:"false"`
TransactionGroups TransactionGroups `json:"transactionGroups" required:"false" nullable:"false"`
Description string `json:"description" required:"true"`
TransactionGroups TransactionGroups `json:"transactionGroups" required:"true" nullable:"false"`
}
type UpdatableRole struct {
@@ -167,40 +167,32 @@ func (role *Role) ErrIfManaged() error {
}
func (role *PostableRole) UnmarshalJSON(data []byte) error {
shadow := struct {
Name string `json:"name"`
Description string `json:"description"`
TransactionGroups *json.RawMessage `json:"transactionGroups"`
}{}
type Alias PostableRole
var temp Alias
if err := json.Unmarshal(data, &shadow); err != nil {
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
if shadow.Name == "" {
if temp.Name == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "name is missing from the request")
}
if match := roleNameRegex.MatchString(shadow.Name); !match {
if match := roleNameRegex.MatchString(temp.Name); !match {
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "name must contain only lowercase letters (a-z) and hyphens (-), and be at most 50 characters long.")
}
if strings.HasPrefix(shadow.Name, managedRolePrefix) {
if strings.HasPrefix(temp.Name, managedRolePrefix) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "role name cannot start with %q as it is reserved for SigNoz managed roles.", managedRolePrefix)
}
var transactionGroups TransactionGroups
if shadow.TransactionGroups != nil {
var err error
transactionGroups, err = NewTransactionGroups(*shadow.TransactionGroups)
if err != nil {
return err
}
if temp.TransactionGroups == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "transactionGroups is required").WithAdditional("send an empty array to create a role with no transaction groups")
}
role.Name = shadow.Name
role.Description = shadow.Description
role.TransactionGroups = transactionGroups
role.Name = temp.Name
role.Description = temp.Description
role.TransactionGroups = temp.TransactionGroups
return nil
}
@@ -214,6 +206,9 @@ func (role *UpdatableRole) UnmarshalJSON(data []byte) error {
return err
}
// A pointer distinguishes an omitted/null description from an explicit empty string: the field
// must be sent (update reconciles to exactly what is given), but an empty string is allowed so a
// caller can deliberately clear the description.
if shadow.Description == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "description is required").WithAdditional("send an empty string to clear the description")
}

View File

@@ -3,22 +3,10 @@ package authtypes
import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type rawTransactionGroup struct {
Relation string `json:"relation"`
ObjectGroup struct {
Resource struct {
Type string `json:"type"`
Kind string `json:"kind"`
} `json:"resource"`
Selectors []string `json:"selectors"`
} `json:"objectGroup"`
}
type Transaction struct {
ID valuer.UUID `json:"-"`
Relation Relation `json:"relation" required:"true"`
@@ -51,23 +39,16 @@ func NewTransaction(relation Relation, object coretypes.Object) (*Transaction, e
return &Transaction{ID: valuer.GenerateUUID(), Relation: relation, Object: object}, nil
}
func NewTransactionGroups(data []byte) (TransactionGroups, error) {
var rawGroups []rawTransactionGroup
if err := json.Unmarshal(data, &rawGroups); err != nil {
return nil, errors.New(errors.TypeInvalidInput, ErrCodeRoleInvalidInput, "transactionGroups must be an array of {relation, objectGroup} objects")
func NewTransactionGroup(relation Relation, objectGroup coretypes.ObjectGroup) (*TransactionGroup, error) {
if err := coretypes.ErrIfVerbNotValidForResource(relation.Verb, objectGroup.Resource); err != nil {
return nil, err
}
groups := make(TransactionGroups, 0, len(rawGroups))
for index, rawGroup := range rawGroups {
group, err := newTransactionGroup(rawGroup, index)
if err != nil {
return nil, err
}
groups = append(groups, group)
if _, err := coretypes.NewObjectsFromObjectGroup(objectGroup); err != nil {
return nil, err
}
return groups, nil
return &TransactionGroup{Relation: relation, ObjectGroup: objectGroup}, nil
}
func NewGettableTransaction(results []*TransactionWithAuthorization) []*GettableTransaction {
@@ -107,6 +88,26 @@ func (transaction *Transaction) UnmarshalJSON(data []byte) error {
return nil
}
func (transactionGroup *TransactionGroup) UnmarshalJSON(data []byte) error {
var shadow = struct {
Relation Relation
ObjectGroup coretypes.ObjectGroup
}{}
err := json.Unmarshal(data, &shadow)
if err != nil {
return err
}
group, err := NewTransactionGroup(shadow.Relation, shadow.ObjectGroup)
if err != nil {
return err
}
*transactionGroup = *group
return nil
}
func (transaction *Transaction) TransactionKey() string {
return transaction.Relation.StringValue() + ":" + transaction.Object.Resource.Type.StringValue() + ":" + transaction.Object.Resource.Kind.String()
}
@@ -155,39 +156,3 @@ func (groups TransactionGroups) selectorSet() map[string]struct{} {
func (group *TransactionGroup) selectorKey(selector coretypes.Selector) string {
return group.Relation.StringValue() + "|" + group.ObjectGroup.Resource.String() + "|" + selector.String()
}
func newTransactionGroup(raw rawTransactionGroup, index int) (*TransactionGroup, error) {
verb, err := coretypes.NewVerb(raw.Relation)
if err != nil {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "transactionGroups[%d].relation: %s", index, err.Error())
}
resourceType, err := coretypes.NewType(raw.ObjectGroup.Resource.Type)
if err != nil {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "transactionGroups[%d].objectGroup.resource.type: %s", index, err.Error())
}
kind, err := coretypes.NewKind(raw.ObjectGroup.Resource.Kind)
if err != nil {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "transactionGroups[%d].objectGroup.resource.kind: %s", index, err.Error())
}
resourceRef := coretypes.ResourceRef{Type: resourceType, Kind: kind}
if err := coretypes.ErrIfVerbNotValidForResource(verb, resourceRef); err != nil {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "transactionGroups[%d]: %s", index, err.Error())
}
selectors := make([]coretypes.Selector, 0, len(raw.ObjectGroup.Selectors))
for selectorIndex, rawSelector := range raw.ObjectGroup.Selectors {
selector, err := resourceType.Selector(rawSelector)
if err != nil {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "transactionGroups[%d].objectGroup.selectors[%d]: %s", index, selectorIndex, err.Error())
}
selectors = append(selectors, selector)
}
return &TransactionGroup{
Relation: Relation{Verb: verb},
ObjectGroup: coretypes.ObjectGroup{Resource: resourceRef, Selectors: selectors},
}, nil
}

View File

@@ -39,48 +39,6 @@ func MustNewKind(str string) Kind {
return kind
}
func (name Kind) Enum() []any {
return []any{
KindAnonymous,
KindOrganization,
KindRole,
KindServiceAccount,
KindUser,
KindNotificationChannel,
KindRoutePolicy,
KindApdexSetting,
KindAuthDomain,
KindSession,
KindCloudIntegration,
KindCloudIntegrationService,
KindIntegration,
KindDashboard,
KindPublicDashboard,
KindIngestionKey,
KindIngestionLimit,
KindPipeline,
KindUserPreference,
KindOrgPreference,
KindQuickFilter,
KindTTLSetting,
KindRule,
KindPlannedMaintenance,
KindSavedView,
KindTraceFunnel,
KindFactorPassword,
KindFactorAPIKey,
KindLicense,
KindSubscription,
KindLogs,
KindTraces,
KindMetrics,
KindAuditLogs,
KindMeterMetrics,
KindLogsField,
KindTracesField,
}
}
func (name Kind) String() string {
return name.val
}

View File

@@ -1,59 +0,0 @@
package metricreductionruletypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Order struct {
valuer.String
}
var (
OrderAsc = Order{valuer.NewString("asc")}
OrderDesc = Order{valuer.NewString("desc")}
)
func (Order) Enum() []any {
return []any{OrderAsc, OrderDesc}
}
type ReductionRuleOrderBy struct {
valuer.String
}
var (
OrderByMetricName = ReductionRuleOrderBy{valuer.NewString("metric")}
OrderByIngestedVolume = ReductionRuleOrderBy{valuer.NewString("ingested_volume")}
OrderByReducedVolume = ReductionRuleOrderBy{valuer.NewString("reduced_volume")}
OrderByReduction = ReductionRuleOrderBy{valuer.NewString("reduction")}
OrderByLastUpdated = ReductionRuleOrderBy{valuer.NewString("last_updated")}
)
func (ReductionRuleOrderBy) Enum() []any {
return []any{OrderByMetricName, OrderByIngestedVolume, OrderByReducedVolume, OrderByReduction, OrderByLastUpdated}
}
type ListReductionRulesParams struct {
OrderBy ReductionRuleOrderBy `query:"orderBy,default=reduction" json:"orderBy"`
Order Order `query:"order,default=desc" json:"order"`
Search string `query:"search" json:"search"`
MetricName string `query:"metricName" json:"metricName,omitempty"`
Offset int `query:"offset" json:"offset"`
Limit int `query:"limit,default=10" json:"limit"`
}
const maxReductionRulesPageSize = 1000
func (p *ListReductionRulesParams) Validate() error {
if p.Limit <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be greater than 0")
}
if p.Limit > maxReductionRulesPageSize {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must not exceed %d", maxReductionRulesPageSize)
}
if p.Offset < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset must not be negative")
}
return nil
}

View File

@@ -1,24 +0,0 @@
package metricreductionruletypes_test
import (
"testing"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestListReductionRulesParamsSortDefaults(t *testing.T) {
var params metricreductionruletypes.ListReductionRulesParams
require.NoError(t, binding.Query.BindQuery(map[string][]string{"limit": {"10"}}, &params))
assert.Equal(t, metricreductionruletypes.OrderByReduction, params.OrderBy, "orderBy defaults to reduction")
assert.Equal(t, metricreductionruletypes.OrderDesc, params.Order, "order defaults to desc")
}
func TestListReductionRulesParamsValidate(t *testing.T) {
require.Error(t, (&metricreductionruletypes.ListReductionRulesParams{Limit: 0}).Validate(), "limit must be set")
require.Error(t, (&metricreductionruletypes.ListReductionRulesParams{Limit: 10, Offset: -1}).Validate(), "offset must not be negative")
require.NoError(t, (&metricreductionruletypes.ListReductionRulesParams{Limit: 10}).Validate())
}

View File

@@ -1,68 +0,0 @@
package metricreductionruletypes
import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
type AssetType struct {
valuer.String
}
var (
AssetTypeDashboard = AssetType{valuer.NewString("dashboard")}
AssetTypeAlert = AssetType{valuer.NewString("alert_rule")}
)
func (AssetType) Enum() []any {
return []any{AssetTypeDashboard, AssetTypeAlert}
}
type PostableReductionRulePreview struct {
MetricName string `json:"metricName" required:"true"`
MatchType MatchType `json:"matchType" required:"true"`
Labels []string `json:"labels" required:"true" nullable:"true"`
LookbackMs int64 `json:"lookbackMs,omitempty"`
}
func (req *PostableReductionRulePreview) Validate() error {
if req == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
}
if req.MetricName == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName is required")
}
if req.MatchType != MatchTypeDrop && req.MatchType != MatchTypeKeep {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"matchType must be one of %q or %q", MatchTypeDrop.StringValue(), MatchTypeKeep.StringValue())
}
if len(req.Labels) == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "labels must not be empty")
}
return nil
}
type AffectedWidget struct {
ID string `json:"id" required:"true"`
Name string `json:"name" required:"true"`
}
type AffectedAsset struct {
Type AssetType `json:"type" required:"true"`
ID string `json:"id" required:"true"`
Name string `json:"name" required:"true"`
Widget *AffectedWidget `json:"widget,omitempty"`
ImpactedLabels []string `json:"impactedLabels" required:"true" nullable:"true"`
}
type GettableReductionRulePreview struct {
IngestedSeries uint64 `json:"ingestedSeries" required:"true"`
CurrentRetainedSeries uint64 `json:"currentRetainedSeries" required:"true"`
RetainedSeries uint64 `json:"retainedSeries" required:"true"`
ReductionPercent float64 `json:"reductionPercent" required:"true"`
DroppedLabels []string `json:"droppedLabels" required:"true" nullable:"true"`
AffectedAssets []AffectedAsset `json:"affectedAssets" required:"true" nullable:"true"`
EffectiveFrom time.Time `json:"effectiveFrom" required:"true"`
}

View File

@@ -32,6 +32,48 @@ func (MatchType) Enum() []any {
return []any{MatchTypeDrop, MatchTypeKeep}
}
type AssetType struct {
valuer.String
}
var (
AssetTypeDashboard = AssetType{valuer.NewString("dashboard")}
AssetTypeAlert = AssetType{valuer.NewString("alert_rule")}
)
func (AssetType) Enum() []any {
return []any{AssetTypeDashboard, AssetTypeAlert}
}
type Order struct {
valuer.String
}
var (
OrderAsc = Order{valuer.NewString("asc")}
OrderDesc = Order{valuer.NewString("desc")}
)
func (Order) Enum() []any {
return []any{OrderAsc, OrderDesc}
}
type ReductionRuleOrderBy struct {
valuer.String
}
var (
OrderByMetricName = ReductionRuleOrderBy{valuer.NewString("metric")}
OrderByIngestedVolume = ReductionRuleOrderBy{valuer.NewString("ingested_volume")}
OrderByReducedVolume = ReductionRuleOrderBy{valuer.NewString("reduced_volume")}
OrderByReduction = ReductionRuleOrderBy{valuer.NewString("reduction")}
OrderByLastUpdated = ReductionRuleOrderBy{valuer.NewString("last_updated")}
)
func (ReductionRuleOrderBy) Enum() []any {
return []any{OrderByMetricName, OrderByIngestedVolume, OrderByReducedVolume, OrderByReduction, OrderByLastUpdated}
}
// LabelList is a []string persisted as a single JSON text column.
type LabelList []string
@@ -62,7 +104,7 @@ func (l *LabelList) Scan(src any) error {
return json.Unmarshal(raw, l)
}
type ReductionRule struct {
type StorableReductionRule struct {
bun.BaseModel `bun:"table:metric_reduction_rule" json:"-"`
types.Identifiable
@@ -76,9 +118,9 @@ type ReductionRule struct {
EffectiveFrom time.Time `bun:"effective_from,notnull"`
}
func NewReductionRule(orgID valuer.UUID, metricName string, matchType MatchType, labels []string, effectiveFrom time.Time, by string) *ReductionRule {
func NewReductionRule(orgID valuer.UUID, metricName string, matchType MatchType, labels []string, effectiveFrom time.Time, by string) *StorableReductionRule {
now := time.Now()
return &ReductionRule{
return &StorableReductionRule{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
UserAuditable: types.UserAuditable{CreatedBy: by, UpdatedBy: by},
@@ -101,7 +143,7 @@ type GettableReductionRule struct {
EffectiveFrom time.Time `json:"effectiveFrom" required:"true"`
Active bool `json:"active" required:"true"`
IngestedSeries uint64 `json:"ingestedSeries" required:"true"`
RetainedSeries uint64 `json:"retainedSeries" required:"true"`
ReducedSeries uint64 `json:"reducedSeries" required:"true"`
ReductionPercent float64 `json:"reductionPercent" required:"true"`
}
@@ -110,14 +152,39 @@ type GettableReductionRules struct {
Total int `json:"total" required:"true"`
}
type UpdatableReductionRule struct {
MatchType MatchType `json:"matchType" required:"true"`
Labels []string `json:"labels" required:"true" nullable:"true"`
type GettableReductionRuleStats struct {
IngestedSeries uint64 `json:"ingestedSeries" required:"true"`
ReducedSeries uint64 `json:"reducedSeries" required:"true"`
EstimatedMonthlySavingsUsd float64 `json:"estimatedMonthlySavingsUsd" required:"true"`
}
type ListReductionRulesParams struct {
OrderBy ReductionRuleOrderBy `query:"orderBy,default=reduction" json:"orderBy"`
Order Order `query:"order,default=desc" json:"order"`
Search string `query:"search" json:"search"`
Offset int `query:"offset" json:"offset"`
Limit int `query:"limit,default=10" json:"limit"`
}
const maxReductionRulesPageSize = 1000
func (p *ListReductionRulesParams) Validate() error {
if p.Limit <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be greater than 0")
}
if p.Limit > maxReductionRulesPageSize {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must not exceed %d", maxReductionRulesPageSize)
}
if p.Offset < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset must not be negative")
}
return nil
}
type PostableReductionRule struct {
MetricName string `json:"metricName" required:"true"`
UpdatableReductionRule
MetricName string `json:"metricName"`
MatchType MatchType `json:"matchType" required:"true"`
Labels []string `json:"labels" required:"true" nullable:"true"`
}
var protectedLabels = map[string]struct{}{
@@ -134,10 +201,13 @@ func IsProtectedLabel(label string) bool {
return ok
}
func (req *UpdatableReductionRule) Validate() error {
func (req *PostableReductionRule) Validate() error {
if req == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
}
if req.MetricName == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName is required")
}
if req.MatchType != MatchTypeDrop && req.MatchType != MatchTypeKeep {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"matchType must be one of %q or %q", MatchTypeDrop.StringValue(), MatchTypeKeep.StringValue())
@@ -157,12 +227,45 @@ func (req *UpdatableReductionRule) Validate() error {
return nil
}
func (req *PostableReductionRule) Validate() error {
type PostableReductionRulePreview struct {
MetricName string `json:"metricName" required:"true"`
MatchType MatchType `json:"matchType" required:"true"`
Labels []string `json:"labels" required:"true" nullable:"true"`
LookbackMs int64 `json:"lookbackMs,omitempty"`
}
func (req *PostableReductionRulePreview) Validate() error {
if req == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
}
if req.MetricName == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName is required")
}
return req.UpdatableReductionRule.Validate()
if req.MatchType != MatchTypeDrop && req.MatchType != MatchTypeKeep {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"matchType must be one of %q or %q", MatchTypeDrop.StringValue(), MatchTypeKeep.StringValue())
}
if len(req.Labels) == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "labels must not be empty")
}
return nil
}
type AffectedAsset struct {
Type AssetType `json:"type" required:"true"`
ID string `json:"id" required:"true"`
Name string `json:"name" required:"true"`
Widget string `json:"widget,omitempty"`
WidgetID string `json:"widgetId,omitempty"`
ImpactedLabels []string `json:"impactedLabels" required:"true" nullable:"true"`
}
type GettableReductionRulePreview struct {
IngestedSeries uint64 `json:"ingestedSeries" required:"true"`
CurrentReducedSeries uint64 `json:"currentReducedSeries" required:"true"`
ReducedSeries uint64 `json:"reducedSeries" required:"true"`
ReductionPercent float64 `json:"reductionPercent" required:"true"`
DroppedLabels []string `json:"droppedLabels" required:"true" nullable:"true"`
AffectedAssets []AffectedAsset `json:"affectedAssets" required:"true" nullable:"true"`
EffectiveFrom time.Time `json:"effectiveFrom" required:"true"`
}

View File

@@ -3,22 +3,39 @@ package metricreductionruletypes_test
import (
"testing"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUpdatableReductionRuleValidate(t *testing.T) {
func TestListReductionRulesParamsSortDefaults(t *testing.T) {
var params metricreductionruletypes.ListReductionRulesParams
require.NoError(t, binding.Query.BindQuery(map[string][]string{"limit": {"10"}}, &params))
assert.Equal(t, metricreductionruletypes.OrderByReduction, params.OrderBy, "orderBy defaults to reduction")
assert.Equal(t, metricreductionruletypes.OrderDesc, params.Order, "order defaults to desc")
}
func TestListReductionRulesParamsValidate(t *testing.T) {
require.Error(t, (&metricreductionruletypes.ListReductionRulesParams{Limit: 0}).Validate(), "limit must be set")
require.Error(t, (&metricreductionruletypes.ListReductionRulesParams{Limit: 10, Offset: -1}).Validate(), "offset must not be negative")
require.NoError(t, (&metricreductionruletypes.ListReductionRulesParams{Limit: 10}).Validate())
}
func TestPostableReductionRuleValidate(t *testing.T) {
cases := []struct {
name string
req *metricreductionruletypes.UpdatableReductionRule
req *metricreductionruletypes.PostableReductionRule
wantErr bool
}{
{"nil", nil, true},
{"invalid match type", &metricreductionruletypes.UpdatableReductionRule{Labels: []string{"host"}}, true},
{"empty labels", &metricreductionruletypes.UpdatableReductionRule{MatchType: metricreductionruletypes.MatchTypeDrop}, true},
{"drop protected label", &metricreductionruletypes.UpdatableReductionRule{MatchType: metricreductionruletypes.MatchTypeDrop, Labels: []string{"host", "le"}}, true},
{"keep protected label is allowed", &metricreductionruletypes.UpdatableReductionRule{MatchType: metricreductionruletypes.MatchTypeKeep, Labels: []string{"le"}}, false},
{"valid drop", &metricreductionruletypes.UpdatableReductionRule{MatchType: metricreductionruletypes.MatchTypeDrop, Labels: []string{"host"}}, false},
{"empty metric name", &metricreductionruletypes.PostableReductionRule{MatchType: metricreductionruletypes.MatchTypeDrop, Labels: []string{"host"}}, true},
{"invalid match type", &metricreductionruletypes.PostableReductionRule{MetricName: "m", Labels: []string{"host"}}, true},
{"empty labels", &metricreductionruletypes.PostableReductionRule{MetricName: "m", MatchType: metricreductionruletypes.MatchTypeDrop}, true},
{"drop protected label", &metricreductionruletypes.PostableReductionRule{MetricName: "m", MatchType: metricreductionruletypes.MatchTypeDrop, Labels: []string{"host", "le"}}, true},
{"keep protected label is allowed", &metricreductionruletypes.PostableReductionRule{MetricName: "m", MatchType: metricreductionruletypes.MatchTypeKeep, Labels: []string{"le"}}, false},
{"valid drop", &metricreductionruletypes.PostableReductionRule{MetricName: "m", MatchType: metricreductionruletypes.MatchTypeDrop, Labels: []string{"host"}}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -30,11 +47,3 @@ func TestUpdatableReductionRuleValidate(t *testing.T) {
})
}
}
func TestPostableReductionRuleValidate(t *testing.T) {
valid := metricreductionruletypes.UpdatableReductionRule{MatchType: metricreductionruletypes.MatchTypeKeep, Labels: []string{"host"}}
require.Error(t, (*metricreductionruletypes.PostableReductionRule)(nil).Validate(), "nil request")
require.Error(t, (&metricreductionruletypes.PostableReductionRule{UpdatableReductionRule: valid}).Validate(), "metricName required")
require.NoError(t, (&metricreductionruletypes.PostableReductionRule{MetricName: "m", UpdatableReductionRule: valid}).Validate())
}

View File

@@ -1,8 +0,0 @@
package metricreductionruletypes
// GettableReductionRuleStats is the aggregate volume-control summary across all of an org's rules.
type GettableReductionRuleStats struct {
IngestedSeries uint64 `json:"ingestedSeries" required:"true"`
RetainedSeries uint64 `json:"retainedSeries" required:"true"`
EstimatedMonthlySavingsUsd float64 `json:"estimatedMonthlySavingsUsd" required:"true"`
}

View File

@@ -7,11 +7,12 @@ import (
)
type Store interface {
List(ctx context.Context, orgID valuer.UUID, params *ListReductionRulesParams) ([]*ReductionRule, int, error)
Get(ctx context.Context, orgID valuer.UUID, metricName string) (*ReductionRule, error)
GetByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*ReductionRule, error)
Create(ctx context.Context, rule *ReductionRule) error
Upsert(ctx context.Context, rule *ReductionRule) error
List(ctx context.Context, orgID valuer.UUID, params *ListReductionRulesParams) ([]*StorableReductionRule, int, error)
Get(ctx context.Context, orgID valuer.UUID, metricName string) (*StorableReductionRule, error)
GetByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*StorableReductionRule, error)
Create(ctx context.Context, rule *StorableReductionRule) error
Upsert(ctx context.Context, rule *StorableReductionRule) error
Delete(ctx context.Context, orgID valuer.UUID, metricName string) error
DeleteByID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
RunInTx(ctx context.Context, cb func(ctx context.Context) error) error
}

View File

@@ -241,10 +241,11 @@ type MetricAlertsResponse struct {
// MetricDashboard represents a dashboard/widget referencing a metric.
type MetricDashboard struct {
DashboardName string `json:"dashboardName" required:"true"`
DashboardID string `json:"dashboardId" required:"true"`
WidgetID string `json:"widgetId" required:"true"`
WidgetName string `json:"widgetName" required:"true"`
DashboardName string `json:"dashboardName" required:"true"`
DashboardID string `json:"dashboardId" required:"true"`
WidgetID string `json:"widgetId" required:"true"`
WidgetName string `json:"widgetName" required:"true"`
GroupBy []string `json:"groupBy,omitempty"`
}
// MetricDashboardsResponse represents the response for metric dashboards endpoint.
@@ -260,27 +261,11 @@ type MetricHighlightsResponse struct {
ActiveTimeSeries uint64 `json:"activeTimeSeries" required:"true"`
}
// MetricNameQuery represents the query parameters for endpoints that take a metric name.
type MetricNameQuery struct {
MetricName string `query:"metricName" required:"true" description:"The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies)."`
}
// Validate ensures MetricNameQuery contains acceptable values.
func (q *MetricNameQuery) Validate() error {
if q == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
}
if q.MetricName == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName is required")
}
return nil
}
// MetricAttributesRequest represents the query parameters for the metric attributes endpoint.
type MetricAttributesRequest struct {
MetricName string `query:"metricName" required:"true" description:"The name of the metric. May contain slashes (e.g. cloud-provider metrics like run.googleapis.com/request_latencies)."`
Start *int64 `query:"start" description:"Start of the time range as a Unix timestamp in milliseconds."`
End *int64 `query:"end" description:"End of the time range as a Unix timestamp in milliseconds."`
MetricName string `json:"-"`
Start *int64 `query:"start"`
End *int64 `query:"end"`
}
// Validate ensures MetricAttributesRequest contains acceptable values.
@@ -289,10 +274,6 @@ func (req *MetricAttributesRequest) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
}
if req.MetricName == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName is required")
}
if req.Start != nil && req.End != nil {
if *req.Start >= *req.End {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "start (%d) must be less than end (%d)", *req.Start, *req.End)