Compare commits

..

1 Commits

Author SHA1 Message Date
srikanthccv
2345d26192 feat(volume-control): rules API + UI for aggregating away metric labels 2026-06-23 09:24:35 +05:30
89 changed files with 4730 additions and 3652 deletions

View File

@@ -29,6 +29,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
pkgimplmetricreductionrule "github.com/SigNoz/signoz/pkg/modules/metricreductionrule/implmetricreductionrule"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/retention"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
@@ -119,6 +121,9 @@ 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, _ factory.ProviderSettings, _ int) metricreductionrule.Module {
return pkgimplmetricreductionrule.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]] {
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, nil, nil))
},

View File

@@ -24,6 +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"
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"
@@ -46,6 +47,7 @@ import (
pkgcloudintegration "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/retention"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
@@ -182,6 +184,9 @@ 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, licensing licensing.Licensing, ps factory.ProviderSettings, threads int) metricreductionrule.Module {
return eeimplmetricreductionrule.NewModule(sqlStore, ts, dashboardModule, queryParser, licensing, 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

@@ -5022,6 +5022,169 @@ components:
required:
- rules
type: object
MetricreductionruletypesAffectedAsset:
properties:
id:
type: string
impactedLabels:
items:
type: string
nullable: true
type: array
name:
type: string
type:
$ref: '#/components/schemas/MetricreductionruletypesAssetType'
widget:
type: string
required:
- type
- id
- name
- impactedLabels
type: object
MetricreductionruletypesAssetType:
enum:
- dashboard
- alert_rule
type: string
MetricreductionruletypesGettableReductionRule:
properties:
active:
type: boolean
effectiveFrom:
format: date-time
type: string
ingestedSeries:
minimum: 0
type: integer
labels:
items:
type: string
nullable: true
type: array
matchType:
$ref: '#/components/schemas/MetricreductionruletypesMatchType'
metricName:
type: string
reducedSeries:
minimum: 0
type: integer
reductionPercent:
format: double
type: number
updatedAt:
format: date-time
type: string
updatedBy:
type: string
required:
- metricName
- matchType
- labels
- effectiveFrom
- updatedAt
- updatedBy
- active
- ingestedSeries
- reducedSeries
- reductionPercent
type: object
MetricreductionruletypesGettableReductionRulePreview:
properties:
affectedAssets:
items:
$ref: '#/components/schemas/MetricreductionruletypesAffectedAsset'
nullable: true
type: array
droppedLabels:
items:
type: string
nullable: true
type: array
effectiveFrom:
format: date-time
type: string
ingestedSeries:
minimum: 0
type: integer
reducedSeries:
minimum: 0
type: integer
reductionPercent:
format: double
type: number
required:
- ingestedSeries
- reducedSeries
- reductionPercent
- droppedLabels
- affectedAssets
- effectiveFrom
type: object
MetricreductionruletypesGettableReductionRules:
properties:
rules:
items:
$ref: '#/components/schemas/MetricreductionruletypesGettableReductionRule'
nullable: true
type: array
total:
type: integer
required:
- rules
- total
type: object
MetricreductionruletypesMatchType:
enum:
- drop
- keep
type: string
MetricreductionruletypesOrder:
enum:
- asc
- desc
type: string
MetricreductionruletypesPostableReductionRule:
properties:
labels:
items:
type: string
nullable: true
type: array
matchType:
$ref: '#/components/schemas/MetricreductionruletypesMatchType'
required:
- matchType
- labels
type: object
MetricreductionruletypesPostableReductionRulePreview:
properties:
labels:
items:
type: string
nullable: true
type: array
lookbackMs:
format: int64
type: integer
matchType:
$ref: '#/components/schemas/MetricreductionruletypesMatchType'
metricName:
type: string
required:
- metricName
- matchType
- labels
type: object
MetricreductionruletypesReductionRuleOrderBy:
enum:
- metricname
- ingestedvolume
- reducedvolume
- reduction
- lastupdated
type: string
MetricsexplorertypesInspectMetricsRequest:
properties:
end:
@@ -5138,6 +5301,10 @@ components:
type: string
dashboardName:
type: string
groupBy:
items:
type: string
type: array
widgetId:
type: string
widgetName:
@@ -15897,6 +16064,229 @@ paths:
summary: Update metric metadata
tags:
- metrics
/api/v2/metrics/{metric_name}/reduction_rule:
delete:
deprecated: false
description: Removes the volume-control (label reduction) rule for a specified
metric, reverting it to full fidelity. Admin only; enterprise feature.
operationId: DeleteMetricReductionRule
parameters:
- in: path
name: metric_name
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"451":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unavailable For Legal Reasons
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Delete a metric reduction rule
tags:
- metrics
get:
deprecated: false
description: Returns the active volume-control (label reduction) rule for a
specified metric. Enterprise feature.
operationId: GetMetricReductionRule
parameters:
- in: path
name: metric_name
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/MetricreductionruletypesGettableReductionRule'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"451":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unavailable For Legal Reasons
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get a metric reduction rule
tags:
- metrics
put:
deprecated: false
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.
operationId: UpsertMetricReductionRule
parameters:
- in: path
name: metric_name
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MetricreductionruletypesPostableReductionRule'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/MetricreductionruletypesGettableReductionRule'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"451":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unavailable For Legal Reasons
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create or update a metric reduction rule
tags:
- metrics
/api/v2/metrics/inspect:
post:
deprecated: false
@@ -16002,6 +16392,158 @@ paths:
summary: Check if non-SigNoz metrics have been received
tags:
- metrics
/api/v2/metrics/reduction_rules:
get:
deprecated: false
description: Returns active metric volume-control (label reduction) rules, sorted
and paginated server-side. Enterprise feature.
operationId: ListMetricReductionRules
parameters:
- in: query
name: orderBy
schema:
$ref: '#/components/schemas/MetricreductionruletypesReductionRuleOrderBy'
- in: query
name: order
schema:
$ref: '#/components/schemas/MetricreductionruletypesOrder'
- in: query
name: offset
schema:
type: integer
- in: query
name: limit
schema:
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/MetricreductionruletypesGettableReductionRules'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"451":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unavailable For Legal Reasons
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List metric reduction rules
tags:
- metrics
/api/v2/metrics/reduction_rules/preview:
post:
deprecated: false
description: Estimates the series reduction and related-asset impact of a candidate
volume-control rule without persisting it. Enterprise feature.
operationId: PreviewMetricReductionRule
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MetricreductionruletypesPostableReductionRulePreview'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/MetricreductionruletypesGettableReductionRulePreview'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"451":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unavailable For Legal Reasons
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Preview a metric reduction rule
tags:
- metrics
/api/v2/metrics/stats:
post:
deprecated: false

View File

@@ -0,0 +1,332 @@
package implmetricreductionrule
import (
"context"
"time"
sqlbuilder "github.com/huandu/go-sqlbuilder"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"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
timeseriesTable = telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4TableName
)
type clickhouse struct {
telemetryStore telemetrystore.TelemetryStore
threads int
}
func newClickhouse(telemetryStore telemetrystore.TelemetryStore, threads int) *clickhouse {
return &clickhouse{telemetryStore: telemetryStore, threads: threads}
}
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 (c *clickhouse) Sync(ctx context.Context, metricName string, labels []string, matchType string, effectiveFromMs int64, deleted bool, updatedAt time.Time) error {
ctx = c.withThreads(ctx)
ib := sqlbuilder.NewInsertBuilder()
ib.InsertInto(reductionRulesTable)
ib.Cols("metric_name", "labels", "match_type", "effective_from_unix_milli", "deleted", "updated_at")
ib.Values(metricName, labels, matchType, effectiveFromMs, deleted, updatedAt)
query, args := ib.BuildWithFlavor(sqlbuilder.ClickHouse)
if err := c.telemetryStore.ClickhouseDB().Exec(ctx, query, args...); err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "failed to sync reduction rule to clickhouse")
}
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)
sb := sqlbuilder.NewSelectBuilder()
sb.Select("attr_name")
sb.Distinct()
sb.From(metadataTable)
sb.Where(
sb.E("metric_name", metricName),
"NOT startsWith(attr_name, '__')",
sb.GE("last_reported_unix_milli", startMs),
sb.LE("first_reported_unix_milli", endMs),
)
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 fetch metric attribute keys")
}
defer rows.Close()
keys := make([]string, 0)
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan attribute key")
}
keys = append(keys, key)
}
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()
reducedExpr := "1"
if len(keptLabels) > 0 {
reducedExpr = "countDistinct(("
for i, label := range keptLabels {
if i > 0 {
reducedExpr += ", "
}
reducedExpr += "JSONExtractString(labels, " + sb.Var(label) + ")"
}
reducedExpr += "))"
}
sb.Select("countDistinct(fingerprint)", reducedExpr)
sb.From(table)
conds := []string{
sb.E("metric_name", metricName),
sb.GE("unix_milli", startMs),
sb.LT("unix_milli", endMs),
}
if originalOnly {
conds = append(conds, sb.E("is_reduced", false))
}
sb.Where(conds...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var current, reduced uint64
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&current, &reduced); err != nil {
return 0, 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to estimate reduction impact")
}
if len(keptLabels) == 0 && current == 0 {
reduced = 0
}
if reduced > current {
reduced = current
}
return current, reduced, nil
}
type volumeRow struct {
MetricName string
Ingested uint64
Reduced uint64
}
func (c *clickhouse) VolumeByMetric(ctx context.Context, metricNames []string, startMs, endMs int64) (map[string]volumeRow, error) {
if len(metricNames) == 0 {
return map[string]volumeRow{}, nil
}
ctx = c.withThreads(ctx)
ingestedTable, originalOnly := c.originalSeriesSource(ctx)
ingested, err := c.countSeries(ctx, ingestedTable, originalOnly, metricNames, 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, startMs, endMs)
if err != nil {
return nil, err
}
}
out := make(map[string]volumeRow, len(metricNames))
for metricName, count := range ingested {
out[metricName] = volumeRow{MetricName: metricName, Ingested: count, Reduced: out[metricName].Reduced}
}
for metricName, count := range reduced {
row := out[metricName]
row.MetricName = metricName
row.Reduced = count
out[metricName] = row
}
return out, nil
}
func (c *clickhouse) countSeries(ctx context.Context, table string, originalOnly bool, metricNames []string, startMs, endMs int64) (map[string]uint64, error) {
startMs = floorToTimeSeriesBucket(startMs)
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("metric_name", "countDistinct(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))
}
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 metric 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()
}
func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, 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:
orderExpr = "reduced"
case metricreductionruletypes.OrderByReduction:
orderExpr = "if(ingested = 0, 0, (toFloat64(ingested) - toFloat64(reduced)) / toFloat64(ingested))"
}
direction := "ASC"
if order == metricreductionruletypes.OrderDesc {
direction = "DESC"
}
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", reducedSelect)
sb.From("(SELECT arrayJoin(" + sb.Var(metricNames) + ") AS metric_name) AS base")
sb.JoinWithOption(
sqlbuilder.LeftJoin,
"(SELECT metric_name, countDistinct(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)+" GROUP BY metric_name) AS i",
"base.metric_name = i.metric_name",
)
if reducedPresent {
reducedTable := telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4ReducedTableName
sb.JoinWithOption(
sqlbuilder.LeftJoin,
"(SELECT metric_name, countDistinct(fingerprint) AS cnt FROM "+reducedTable+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" GROUP BY metric_name) AS d",
"base.metric_name = d.metric_name",
)
}
sb.OrderBy(orderExpr + " " + direction)
if limit > 0 {
sb.Limit(limit).Offset(offset)
}
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 rank reduction rules by volume")
}
defer rows.Close()
out := make([]volumeRow, 0, len(metricNames))
for rows.Next() {
var row volumeRow
if err := rows.Scan(&row.MetricName, &row.Ingested, &row.Reduced); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan volume row")
}
out = append(out, row)
}
return out, rows.Err()
}

View File

@@ -0,0 +1,390 @@
package implmetricreductionrule
import (
"context"
"log/slog"
"sort"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
effectiveFromMargin = 5 * time.Minute
defaultPreviewLookback = 24 * time.Hour
)
var protectedLabels = map[string]struct{}{
"le": {},
"quantile": {},
"__name__": {},
"__temporality__": {},
"deployment.environment": {},
}
func isProtected(label string) bool {
_, ok := protectedLabels[label]
return ok
}
type module struct {
store metricreductionruletypes.Store
ch *clickhouse
dashboard dashboard.Module
ruleStore ruletypes.RuleStore
licensing licensing.Licensing
logger *slog.Logger
}
func NewModule(sqlStore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, dashboardModule dashboard.Module, queryParser queryparser.QueryParser, licensing licensing.Licensing, 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,
logger: scoped.Logger(),
}
}
func (m *module) checkLicense(ctx context.Context, orgID valuer.UUID) error {
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())
}
return nil
}
func (m *module) List(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams) (*metricreductionruletypes.GettableReductionRules, error) {
if err := m.checkLicense(ctx, orgID); err != nil {
return nil, err
}
if err := params.Validate(); err != nil {
return nil, err
}
now := time.Now()
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
endMs := now.UnixMilli()
switch params.OrderBy {
case metricreductionruletypes.OrderByMetricName, metricreductionruletypes.OrderByLastUpdated:
return m.listSortedByColumn(ctx, orgID, params, startMs, endMs)
default:
return m.listSortedByVolume(ctx, orgID, params, startMs, endMs)
}
}
func (m *module) listSortedByColumn(ctx context.Context, orgID valuer.UUID, params *metricreductionruletypes.ListReductionRulesParams, startMs, endMs int64) (*metricreductionruletypes.GettableReductionRules, error) {
domainRules, total, err := m.store.List(ctx, orgID, params)
if err != nil {
return nil, err
}
metricNames := make([]string, len(domainRules))
for i, rule := range domainRules {
metricNames[i] = rule.MetricName
}
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, startMs, endMs)
if err != nil {
return nil, err
}
rules := make([]metricreductionruletypes.GettableReductionRule, 0, len(domainRules))
for _, rule := range domainRules {
rules = append(rules, withVolume(toGettableReductionRule(rule), volumes[rule.MetricName]))
}
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
}
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{})
if err != nil {
return nil, err
}
if total == 0 {
return &metricreductionruletypes.GettableReductionRules{Rules: []metricreductionruletypes.GettableReductionRule{}, Total: 0}, nil
}
metricNames := make([]string, len(allRules))
ruleByMetric := make(map[string]*metricreductionruletypes.StorableReductionRule, len(allRules))
for i, rule := range allRules {
metricNames[i] = rule.MetricName
ruleByMetric[rule.MetricName] = rule
}
ranked, err := m.ch.RankByVolume(ctx, metricNames, params.OrderBy, params.Order, startMs, endMs, params.Offset, params.Limit)
if err != nil {
return nil, err
}
rules := make([]metricreductionruletypes.GettableReductionRule, 0, len(ranked))
for _, row := range ranked {
rule, ok := ruleByMetric[row.MetricName]
if !ok {
continue
}
rules = append(rules, withVolume(toGettableReductionRule(rule), row))
}
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
}
func (m *module) Get(ctx context.Context, orgID valuer.UUID, metricName string) (*metricreductionruletypes.GettableReductionRule, error) {
if err := m.checkLicense(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) {
if err := m.checkLicense(ctx, orgID); err != nil {
return nil, err
}
if err := req.Validate(); err != nil {
return nil, err
}
if err := m.validateMetricForReduction(ctx, req.MetricName); err != nil {
return nil, err
}
if req.MatchType == metricreductionruletypes.MatchTypeDrop {
for _, label := range req.Labels {
if isProtected(label) {
return nil, errors.Newf(errors.TypeInvalidInput, metricreductionruletypes.ErrCodeMetricReductionRuleProtectedLabel,
"label %q is protected and cannot be dropped", label)
}
}
}
now := time.Now()
rule := metricreductionruletypes.NewReductionRule(orgID, req.MetricName, req.MatchType, req.Labels, now.Add(effectiveFromMargin), userEmail)
if err := m.store.RunInTx(ctx, func(ctx context.Context) error {
if err := m.store.Upsert(ctx, rule); err != nil {
return err
}
return m.ch.Sync(ctx, rule.MetricName, rule.Labels, rule.MatchType.StringValue(), rule.EffectiveFrom.UnixMilli(), false, rule.UpdatedAt)
}); err != nil {
return nil, err
}
gettable := toGettableReductionRule(rule)
return &gettable, nil
}
func (m *module) Delete(ctx context.Context, orgID valuer.UUID, metricName string) error {
if err := m.checkLicense(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)
})
}
func (m *module) Preview(ctx context.Context, orgID valuer.UUID, req *metricreductionruletypes.PostableReductionRulePreview) (*metricreductionruletypes.GettableReductionRulePreview, error) {
if err := m.checkLicense(ctx, orgID); err != nil {
return nil, err
}
if err := req.Validate(); err != nil {
return nil, err
}
if err := m.validateMetricForReduction(ctx, req.MetricName); err != nil {
return nil, err
}
lookback := time.Duration(req.LookbackMs) * time.Millisecond
if lookback <= 0 {
lookback = defaultPreviewLookback
}
now := time.Now()
current, reduced, reductionPercent, dropped, err := m.estimateVolume(ctx, req.MetricName, req.MatchType, req.Labels, now.Add(-lookback).UnixMilli(), now.UnixMilli())
if err != nil {
return nil, err
}
return &metricreductionruletypes.GettableReductionRulePreview{
IngestedSeries: current,
ReducedSeries: reduced,
ReductionPercent: reductionPercent,
DroppedLabels: dropped,
AffectedAssets: m.relatedAssetImpact(ctx, orgID, req.MetricName, dropped),
EffectiveFrom: now.Add(effectiveFromMargin),
}, nil
}
func (m *module) validateMetricForReduction(ctx context.Context, metricName string) error {
exists, err := m.ch.MetricExists(ctx, metricName)
if err != nil {
return err
}
if !exists {
return errors.NewNotFoundf(errors.CodeNotFound, "metric not found: %q", metricName)
}
isExpHist, err := m.ch.IsExponentialHistogram(ctx, metricName)
if err != nil {
return err
}
if isExpHist {
return errors.Newf(errors.TypeInvalidInput, metricreductionruletypes.ErrCodeMetricReductionRuleUnsupportedMetricType,
"exponential histogram metrics cannot be reduced in v1")
}
return nil
}
func (m *module) relatedAssetImpact(ctx context.Context, orgID valuer.UUID, metricName string, dropped []string) []metricreductionruletypes.AffectedAsset {
affected := make([]metricreductionruletypes.AffectedAsset, 0)
droppedSet := make(map[string]struct{}, len(dropped))
for _, label := range dropped {
droppedSet[label] = struct{}{}
}
if dashboards, err := m.dashboard.GetByMetricNames(ctx, orgID, []string{metricName}); err != nil {
m.logger.WarnContext(ctx, "failed to fetch related dashboards for reduction preview", slog.String("metric_name", metricName), errors.Attr(err))
} else {
for _, item := range dashboards[metricName] {
var groupBy []string
if gb := item["group_by"]; gb != "" {
groupBy = strings.Split(gb, ",")
}
affected = append(affected, metricreductionruletypes.AffectedAsset{
Type: metricreductionruletypes.AssetTypeDashboard,
ID: item["dashboard_id"],
Name: item["dashboard_name"],
Widget: item["widget_name"],
ImpactedLabels: intersectLabels(groupBy, droppedSet),
})
}
}
if alerts, err := m.ruleStore.GetStoredRulesByMetricName(ctx, orgID.String(), metricName); err != nil {
m.logger.WarnContext(ctx, "failed to fetch related alerts for reduction preview", slog.String("metric_name", metricName), errors.Attr(err))
} else {
for _, a := range alerts {
affected = append(affected, metricreductionruletypes.AffectedAsset{
Type: metricreductionruletypes.AssetTypeAlert,
ID: a.AlertID,
Name: a.AlertName,
})
}
}
return affected
}
func toGettableReductionRule(rule *metricreductionruletypes.StorableReductionRule) metricreductionruletypes.GettableReductionRule {
return metricreductionruletypes.GettableReductionRule{
MetricName: rule.MetricName,
MatchType: rule.MatchType,
Labels: rule.Labels,
EffectiveFrom: rule.EffectiveFrom,
UpdatedAt: rule.UpdatedAt,
UpdatedBy: rule.UpdatedBy,
Active: !rule.EffectiveFrom.After(time.Now()),
}
}
func withVolume(rule metricreductionruletypes.GettableReductionRule, volume volumeRow) metricreductionruletypes.GettableReductionRule {
rule.IngestedSeries = volume.Ingested
rule.ReducedSeries = volume.Reduced
if volume.Ingested > 0 && volume.Reduced <= volume.Ingested {
rule.ReductionPercent = (1 - float64(volume.Reduced)/float64(volume.Ingested)) * 100
}
return rule
}
func intersectLabels(keys []string, droppedSet map[string]struct{}) []string {
var out []string
for _, key := range keys {
if _, ok := droppedSet[key]; ok {
out = append(out, key)
}
}
return out
}
func resolveDroppedKept(matchType metricreductionruletypes.MatchType, ruleLabels, keys []string) (dropped, kept []string) {
ruleSet := make(map[string]struct{}, len(ruleLabels))
for _, l := range ruleLabels {
ruleSet[l] = struct{}{}
}
for _, k := range keys {
if isProtected(k) {
kept = append(kept, k)
continue
}
_, listed := ruleSet[k]
drop := listed
if matchType == metricreductionruletypes.MatchTypeKeep {
drop = !listed
}
if drop {
dropped = append(dropped, k)
} else {
kept = append(kept, k)
}
}
sort.Strings(dropped)
sort.Strings(kept)
return dropped, kept
}
func (m *module) estimateVolume(ctx context.Context, metricName string, matchType metricreductionruletypes.MatchType, labels []string, startMs, endMs int64) (current uint64, reduced uint64, reductionPercent float64, dropped []string, err error) {
keys, err := m.ch.AttributeKeys(ctx, metricName, startMs, endMs)
if err != nil {
return 0, 0, 0, nil, err
}
dropped, kept := resolveDroppedKept(matchType, labels, keys)
current, reduced, err = m.ch.EstimateCardinality(ctx, metricName, kept, startMs, endMs)
if err != nil {
return 0, 0, 0, nil, err
}
if current > 0 && reduced <= current {
reductionPercent = (1 - float64(reduced)/float64(current)) * 100
}
return current, reduced, reductionPercent, dropped, nil
}

View File

@@ -0,0 +1,102 @@
package implmetricreductionrule
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type store struct {
sqlstore sqlstore.SQLStore
}
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.StorableReductionRule, int, error) {
column := "metric_name"
if params.OrderBy == metricreductionruletypes.OrderByLastUpdated {
column = "updated_at"
}
direction := "ASC"
if params.Order == metricreductionruletypes.OrderDesc {
direction = "DESC"
}
rules := make([]*metricreductionruletypes.StorableReductionRule, 0)
query := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&rules).
Where("org_id = ?", orgID).
Order(column + " " + direction)
if params.Limit > 0 {
query = query.Limit(params.Limit).Offset(params.Offset)
}
total, err := query.ScanAndCount(ctx)
if err != nil {
return nil, 0, err
}
return rules, total, nil
}
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().
Model(rule).
Where("org_id = ?", orgID).
Where("metric_name = ?", metricName).
Scan(ctx)
if err != nil {
return nil, s.sqlstore.WrapNotFoundErrf(err, metricreductionruletypes.ErrCodeMetricReductionRuleNotFound, "no reduction rule found for metric %q", metricName)
}
return rule, nil
}
func (s *store) Upsert(ctx context.Context, rule *metricreductionruletypes.StorableReductionRule) error {
_, err := s.sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(rule).
On("CONFLICT (org_id, metric_name) DO UPDATE").
Set("match_type = EXCLUDED.match_type").
Set("labels = EXCLUDED.labels").
Set("effective_from = EXCLUDED.effective_from").
Set("updated_at = EXCLUDED.updated_at").
Set("updated_by = EXCLUDED.updated_by").
Exec(ctx)
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) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
return s.sqlstore.RunInTxCtx(ctx, nil, cb)
}

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

@@ -43,7 +43,7 @@
"@dnd-kit/modifiers": "7.0.0",
"@dnd-kit/sortable": "8.0.0",
"@dnd-kit/utilities": "3.2.2",
"@grafana/data": "^11.6.15",
"@grafana/data": "^11.6.14",
"@monaco-editor/react": "^4.7.0",
"@sentry/react": "10.57.0",
"@sentry/vite-plugin": "5.3.0",
@@ -79,7 +79,7 @@
"event-source-polyfill": "1.0.31",
"eventemitter3": "5.0.1",
"history": "4.10.1",
"http-proxy-middleware": "4.1.1",
"http-proxy-middleware": "4.0.0",
"http-status-codes": "2.3.0",
"i18next": "^21.6.12",
"i18next-browser-languagedetector": "^6.1.3",
@@ -231,17 +231,16 @@
"xml2js": "0.5.0",
"phin": "^3.7.1",
"body-parser": "1.20.3",
"http-proxy-middleware": "4.1.1",
"http-proxy-middleware": "4.0.0",
"cross-spawn": "7.0.5",
"cookie": "^0.7.1",
"serialize-javascript": "6.0.2",
"prismjs": "1.30.0",
"got": "11.8.5",
"form-data": "4.0.6",
"form-data": "4.0.4",
"brace-expansion": "^2.0.2",
"on-headers": "^1.1.0",
"js-cookie": "^3.0.7",
"tmp": "0.2.7",
"tmp": "0.2.4",
"vite": "npm:rolldown-vite@7.3.1"
}
}

View File

@@ -12,17 +12,16 @@ overrides:
xml2js: 0.5.0
phin: ^3.7.1
body-parser: 1.20.3
http-proxy-middleware: 4.1.1
http-proxy-middleware: 4.0.0
cross-spawn: 7.0.5
cookie: ^0.7.1
serialize-javascript: 6.0.2
prismjs: 1.30.0
got: 11.8.5
form-data: 4.0.6
form-data: 4.0.4
brace-expansion: ^2.0.2
on-headers: ^1.1.0
js-cookie: ^3.0.7
tmp: 0.2.7
tmp: 0.2.4
vite: npm:rolldown-vite@7.3.1
importers:
@@ -57,8 +56,8 @@ importers:
specifier: 3.2.2
version: 3.2.2(react@18.2.0)
'@grafana/data':
specifier: ^11.6.15
version: 11.6.15(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
specifier: ^11.6.14
version: 11.6.14(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@monaco-editor/react':
specifier: ^4.7.0
version: 4.7.0(monaco-editor@0.55.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -165,8 +164,8 @@ importers:
specifier: 4.10.1
version: 4.10.1
http-proxy-middleware:
specifier: 4.1.1
version: 4.1.1
specifier: 4.0.0
version: 4.0.0
http-status-codes:
specifier: 2.3.0
version: 2.3.0
@@ -1637,14 +1636,14 @@ packages:
'@gerrit0/mini-shiki@3.23.0':
resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==}
'@grafana/data@11.6.15':
resolution: {integrity: sha512-q2Zbjr0N9iEGY/zKHm4Z4X5x64806E17W58y7mnvwc0MlbyGPPVulcp/rWA2Nd190mZeafZQPer9u+MaO+0HUQ==}
'@grafana/data@11.6.14':
resolution: {integrity: sha512-Nsjq1A9m6LbsKsKvOgvAk9Wq7RGjy0V4N9d5YsSnzMwCiw/ov2wblR2bcDpy95uF8KaDTIR2Gf40nJaOYksPMA==}
peerDependencies:
react: ^18.0.0
react-dom: ^18.0.0
'@grafana/schema@11.6.15':
resolution: {integrity: sha512-MPIvGAp9uzkswnH6e+Fmzu+WBTqWMgbv93/8iu56gb+sjCB2LciZLz4KvrPFdw32bWCGSMAGqsML9mgmeJZtGQ==}
'@grafana/schema@11.6.14':
resolution: {integrity: sha512-YTqgYekb7kiu5NEoQxKF8czJ6QIARmMkCi9cNcynHqYpcDLOv5pg5Q0QtKgiiqHjlYoEeCV6iejdB4hXxzB+VA==}
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
@@ -5168,8 +5167,8 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
form-data@4.0.6:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
form-data@4.0.4:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
format@0.2.2:
@@ -5382,10 +5381,6 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
hast-util-from-parse5@8.0.1:
resolution: {integrity: sha512-Er/Iixbc7IEa7r/XLtuG52zoqn/b3Xng/w6aZQ0xGVxzhw5xUFxcRqdPzP6yFi/4HBYRaifaI5fQ1RH8n0ZeOQ==}
@@ -5461,8 +5456,8 @@ packages:
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
engines: {node: '>= 6'}
http-proxy-middleware@4.1.1:
resolution: {integrity: sha512-KX5ZofGXLFXqFAkQoOWZ+rTtaLTut7m0gyL+QzJrdejtIZ+F4bPPDoe7reISg2+v0CAz5OfVwEJEhty7X+e57g==}
http-proxy-middleware@4.0.0:
resolution: {integrity: sha512-wuHwaUtmC0XzJNHqRp41zXtt5ojpHbusXGhq6781VvnjWUYPu7opmOF3eomGNujT07kEOnHWZyV9UZzKimVCKA==}
engines: {node: ^22.15.0 || ^24.0.0 || >=26.0.0}
http-status-codes@2.3.0:
@@ -5472,8 +5467,8 @@ packages:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
httpxy@0.5.3:
resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==}
httpxy@0.5.1:
resolution: {integrity: sha512-JPhqYiixe1A1I+MXDewWDZqeudBGU8Q9jCHYN8ML+779RQzLjTi78HBvWz4jMxUD6h2/vUL12g4q/mFM0OUw1A==}
human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
@@ -6046,8 +6041,8 @@ packages:
js-base64@3.7.5:
resolution: {integrity: sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==}
js-cookie@3.0.8:
resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==}
js-cookie@2.2.1:
resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==}
js-levenshtein@1.1.6:
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
@@ -8399,8 +8394,8 @@ packages:
resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==}
engines: {node: ^20.0.0 || >=22.0.0}
tmp@0.2.7:
resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==}
tmp@0.2.4:
resolution: {integrity: sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==}
engines: {node: '>=14.14'}
tmpl@1.0.5:
@@ -10323,10 +10318,10 @@ snapshots:
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@grafana/data@11.6.15(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
'@grafana/data@11.6.14(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@braintree/sanitize-url': 7.0.1
'@grafana/schema': 11.6.15
'@grafana/schema': 11.6.14
'@types/d3-interpolate': 3.0.1
'@types/string-hash': 1.1.3
d3-interpolate: 3.0.1
@@ -10352,7 +10347,7 @@ snapshots:
uplot: 1.6.31
xss: 1.0.14
'@grafana/schema@11.6.15':
'@grafana/schema@11.6.14':
dependencies:
tslib: 2.8.1
@@ -12891,7 +12886,7 @@ snapshots:
axios@1.16.0:
dependencies:
follow-redirects: 1.16.0
form-data: 4.0.6
form-data: 4.0.4
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
@@ -13838,7 +13833,7 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.4
hasown: 2.0.2
es-toolkit@1.46.1: {}
@@ -14036,7 +14031,7 @@ snapshots:
dependencies:
chardet: 0.7.0
iconv-lite: 0.4.24
tmp: 0.2.7
tmp: 0.2.4
fast-deep-equal@3.1.3: {}
@@ -14169,12 +14164,12 @@ snapshots:
cross-spawn: 7.0.5
signal-exit: 4.1.0
form-data@4.0.6:
form-data@4.0.4:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.4
hasown: 2.0.2
mime-types: 2.1.35
format@0.2.2: {}
@@ -14253,7 +14248,7 @@ snapshots:
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.4
hasown: 2.0.2
math-intrinsics: 1.1.0
get-nonce@1.0.1: {}
@@ -14391,10 +14386,6 @@ snapshots:
dependencies:
function-bind: 1.1.2
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
hast-util-from-parse5@8.0.1:
dependencies:
'@types/hast': 3.0.4
@@ -14515,10 +14506,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
http-proxy-middleware@4.1.1:
http-proxy-middleware@4.0.0:
dependencies:
debug: 4.3.4(supports-color@5.5.0)
httpxy: 0.5.3
httpxy: 0.5.1
is-glob: 4.0.3
is-plain-obj: 4.1.0
micromatch: 4.0.8
@@ -14534,7 +14525,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
httpxy@0.5.3: {}
httpxy@0.5.1: {}
human-signals@2.1.0: {}
@@ -15348,7 +15339,7 @@ snapshots:
js-base64@3.7.5: {}
js-cookie@3.0.8: {}
js-cookie@2.2.1: {}
js-levenshtein@1.1.6: {}
@@ -15376,7 +15367,7 @@ snapshots:
decimal.js: 10.6.0
domexception: 4.0.0
escodegen: 2.1.0
form-data: 4.0.6
form-data: 4.0.4
html-encoding-sniffer: 3.0.0
http-proxy-agent: 5.0.0
https-proxy-agent: 5.0.1
@@ -17345,7 +17336,7 @@ snapshots:
copy-to-clipboard: 3.3.3
fast-deep-equal: 3.1.3
fast-shallow-equal: 1.0.0
js-cookie: 3.0.8
js-cookie: 2.2.1
nano-css: 5.6.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
@@ -17364,7 +17355,7 @@ snapshots:
copy-to-clipboard: 3.3.3
fast-deep-equal: 3.1.3
fast-shallow-equal: 1.0.0
js-cookie: 3.0.8
js-cookie: 2.2.1
nano-css: 5.6.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
@@ -18112,7 +18103,7 @@ snapshots:
tinypool@2.1.0: {}
tmp@0.2.7: {}
tmp@0.2.4: {}
tmpl@1.0.5: {}

View File

@@ -330,10 +330,3 @@ export const AIAssistantPage = Loadable(
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
),
);
export const LLMObservabilityAttributeMappingPage = Loadable(
() =>
import(
/* webpackChunkName: "LLM Observability Attribute Mapping Page" */ 'pages/LLMObservabilityAttributeMapping'
),
);

View File

@@ -22,7 +22,6 @@ import {
IntegrationsDetailsPage,
LicensePage,
ListAllALertsPage,
LLMObservabilityAttributeMappingPage,
LiveLogs,
Login,
Logs,
@@ -470,6 +469,13 @@ const routes: AppRoutes[] = [
key: 'METRICS_EXPLORER_VIEWS',
isPrivate: true,
},
{
path: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
exact: true,
component: MetricsExplorer,
key: 'METRICS_EXPLORER_VOLUME_CONTROL',
isPrivate: true,
},
{
path: ROUTES.METER,
@@ -506,13 +512,6 @@ const routes: AppRoutes[] = [
key: 'AI_ASSISTANT',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
exact: true,
component: LLMObservabilityAttributeMappingPage,
key: 'LLM_OBSERVABILITY_ATTRIBUTE_MAPPING',
isPrivate: true,
},
];
export const SUPPORT_ROUTE: AppRoutes = {

View File

@@ -18,6 +18,7 @@ import type {
} from 'react-query';
import type {
DeleteMetricReductionRulePathParameters,
GetMetricAlerts200,
GetMetricAlertsPathParameters,
GetMetricAttributes200,
@@ -29,18 +30,27 @@ import type {
GetMetricHighlightsPathParameters,
GetMetricMetadata200,
GetMetricMetadataPathParameters,
GetMetricReductionRule200,
GetMetricReductionRulePathParameters,
GetMetricsOnboardingStatus200,
GetMetricsStats200,
GetMetricsTreemap200,
InspectMetrics200,
ListMetricReductionRules200,
ListMetricReductionRulesParams,
ListMetrics200,
ListMetricsParams,
MetricreductionruletypesPostableReductionRuleDTO,
MetricreductionruletypesPostableReductionRulePreviewDTO,
MetricsexplorertypesInspectMetricsRequestDTO,
MetricsexplorertypesStatsRequestDTO,
MetricsexplorertypesTreemapRequestDTO,
MetricsexplorertypesUpdateMetricMetadataRequestDTO,
PreviewMetricReductionRule200,
RenderErrorResponseDTO,
UpdateMetricMetadataPathParameters,
UpsertMetricReductionRule200,
UpsertMetricReductionRulePathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
@@ -771,6 +781,292 @@ export const useUpdateMetricMetadata = <
> => {
return useMutation(getUpdateMetricMetadataMutationOptions(options));
};
/**
* Removes the volume-control (label reduction) rule for a specified metric, reverting it to full fidelity. Admin only; enterprise feature.
* @summary Delete a metric reduction rule
*/
export const deleteMetricReductionRule = (
{ metricName }: DeleteMetricReductionRulePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/metrics/${metricName}/reduction_rule`,
method: 'DELETE',
signal,
});
};
export const getDeleteMetricReductionRuleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteMetricReductionRule>>,
TError,
{ pathParams: DeleteMetricReductionRulePathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteMetricReductionRule>>,
TError,
{ pathParams: DeleteMetricReductionRulePathParameters },
TContext
> => {
const mutationKey = ['deleteMetricReductionRule'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteMetricReductionRule>>,
{ pathParams: DeleteMetricReductionRulePathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteMetricReductionRule(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteMetricReductionRuleMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteMetricReductionRule>>
>;
export type DeleteMetricReductionRuleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete a metric reduction rule
*/
export const useDeleteMetricReductionRule = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteMetricReductionRule>>,
TError,
{ pathParams: DeleteMetricReductionRulePathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteMetricReductionRule>>,
TError,
{ pathParams: DeleteMetricReductionRulePathParameters },
TContext
> => {
return useMutation(getDeleteMetricReductionRuleMutationOptions(options));
};
/**
* Returns the active volume-control (label reduction) rule for a specified metric. Enterprise feature.
* @summary Get a metric reduction rule
*/
export const getMetricReductionRule = (
{ metricName }: GetMetricReductionRulePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricReductionRule200>({
url: `/api/v2/metrics/${metricName}/reduction_rule`,
method: 'GET',
signal,
});
};
export const getGetMetricReductionRuleQueryKey = ({
metricName,
}: GetMetricReductionRulePathParameters) => {
return [`/api/v2/metrics/${metricName}/reduction_rule`] as const;
};
export const getGetMetricReductionRuleQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricReductionRule>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricReductionRulePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRule>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricReductionRuleQueryKey({ metricName });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricReductionRule>>
> = ({ signal }) => getMetricReductionRule({ metricName }, signal);
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRule>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricReductionRuleQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricReductionRule>>
>;
export type GetMetricReductionRuleQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get a metric reduction rule
*/
export function useGetMetricReductionRule<
TData = Awaited<ReturnType<typeof getMetricReductionRule>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ metricName }: GetMetricReductionRulePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricReductionRule>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricReductionRuleQueryOptions(
{ metricName },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get a metric reduction rule
*/
export const invalidateGetMetricReductionRule = async (
queryClient: QueryClient,
{ metricName }: GetMetricReductionRulePathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricReductionRuleQueryKey({ metricName }) },
options,
);
return queryClient;
};
/**
* 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.
* @summary Create or update a metric reduction rule
*/
export const upsertMetricReductionRule = (
{ metricName }: UpsertMetricReductionRulePathParameters,
metricreductionruletypesPostableReductionRuleDTO?: BodyType<MetricreductionruletypesPostableReductionRuleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpsertMetricReductionRule200>({
url: `/api/v2/metrics/${metricName}/reduction_rule`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: metricreductionruletypesPostableReductionRuleDTO,
signal,
});
};
export const getUpsertMetricReductionRuleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof upsertMetricReductionRule>>,
TError,
{
pathParams: UpsertMetricReductionRulePathParameters;
data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof upsertMetricReductionRule>>,
TError,
{
pathParams: UpsertMetricReductionRulePathParameters;
data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO>;
},
TContext
> => {
const mutationKey = ['upsertMetricReductionRule'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof upsertMetricReductionRule>>,
{
pathParams: UpsertMetricReductionRulePathParameters;
data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return upsertMetricReductionRule(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpsertMetricReductionRuleMutationResult = NonNullable<
Awaited<ReturnType<typeof upsertMetricReductionRule>>
>;
export type UpsertMetricReductionRuleMutationBody =
| BodyType<MetricreductionruletypesPostableReductionRuleDTO>
| undefined;
export type UpsertMetricReductionRuleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create or update a metric reduction rule
*/
export const useUpsertMetricReductionRule = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof upsertMetricReductionRule>>,
TError,
{
pathParams: UpsertMetricReductionRulePathParameters;
data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof upsertMetricReductionRule>>,
TError,
{
pathParams: UpsertMetricReductionRulePathParameters;
data?: BodyType<MetricreductionruletypesPostableReductionRuleDTO>;
},
TContext
> => {
return useMutation(getUpsertMetricReductionRuleMutationOptions(options));
};
/**
* Returns raw time series data points for a metric within a time range (max 30 minutes). Each series includes labels and timestamp/value pairs.
* @summary Inspect raw metric data points
@@ -940,6 +1236,192 @@ export const invalidateGetMetricsOnboardingStatus = async (
return queryClient;
};
/**
* Returns active metric volume-control (label reduction) rules, sorted and paginated server-side. Enterprise feature.
* @summary List metric reduction rules
*/
export const listMetricReductionRules = (
params?: ListMetricReductionRulesParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListMetricReductionRules200>({
url: `/api/v2/metrics/reduction_rules`,
method: 'GET',
params,
signal,
});
};
export const getListMetricReductionRulesQueryKey = (
params?: ListMetricReductionRulesParams,
) => {
return [
`/api/v2/metrics/reduction_rules`,
...(params ? [params] : []),
] as const;
};
export const getListMetricReductionRulesQueryOptions = <
TData = Awaited<ReturnType<typeof listMetricReductionRules>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListMetricReductionRulesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listMetricReductionRules>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListMetricReductionRulesQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listMetricReductionRules>>
> = ({ signal }) => listMetricReductionRules(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listMetricReductionRules>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListMetricReductionRulesQueryResult = NonNullable<
Awaited<ReturnType<typeof listMetricReductionRules>>
>;
export type ListMetricReductionRulesQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary List metric reduction rules
*/
export function useListMetricReductionRules<
TData = Awaited<ReturnType<typeof listMetricReductionRules>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListMetricReductionRulesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listMetricReductionRules>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListMetricReductionRulesQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List metric reduction rules
*/
export const invalidateListMetricReductionRules = async (
queryClient: QueryClient,
params?: ListMetricReductionRulesParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListMetricReductionRulesQueryKey(params) },
options,
);
return queryClient;
};
/**
* Estimates the series reduction and related-asset impact of a candidate volume-control rule without persisting it. Enterprise feature.
* @summary Preview a metric reduction rule
*/
export const previewMetricReductionRule = (
metricreductionruletypesPostableReductionRulePreviewDTO?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<PreviewMetricReductionRule200>({
url: `/api/v2/metrics/reduction_rules/preview`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: metricreductionruletypesPostableReductionRulePreviewDTO,
signal,
});
};
export const getPreviewMetricReductionRuleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof previewMetricReductionRule>>,
TError,
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof previewMetricReductionRule>>,
TError,
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
TContext
> => {
const mutationKey = ['previewMetricReductionRule'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof previewMetricReductionRule>>,
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> }
> = (props) => {
const { data } = props ?? {};
return previewMetricReductionRule(data);
};
return { mutationFn, ...mutationOptions };
};
export type PreviewMetricReductionRuleMutationResult = NonNullable<
Awaited<ReturnType<typeof previewMetricReductionRule>>
>;
export type PreviewMetricReductionRuleMutationBody =
| BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO>
| undefined;
export type PreviewMetricReductionRuleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Preview a metric reduction rule
*/
export const usePreviewMetricReductionRule = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof previewMetricReductionRule>>,
TError,
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof previewMetricReductionRule>>,
TError,
{ data?: BodyType<MetricreductionruletypesPostableReductionRulePreviewDTO> },
TContext
> => {
return useMutation(getPreviewMetricReductionRuleMutationOptions(options));
};
/**
* This endpoint provides list of metrics with their number of samples and timeseries for the given time range
* @summary Get metrics statistics

View File

@@ -6577,6 +6577,157 @@ export interface LlmpricingruletypesUpdatableLLMPricingRulesDTO {
rules: LlmpricingruletypesUpdatableLLMPricingRuleDTO[] | null;
}
export enum MetricreductionruletypesAssetTypeDTO {
dashboard = 'dashboard',
alert_rule = 'alert_rule',
}
export interface MetricreductionruletypesAffectedAssetDTO {
/**
* @type string
*/
id: string;
/**
* @type array,null
*/
impactedLabels: string[] | null;
/**
* @type string
*/
name: string;
type: MetricreductionruletypesAssetTypeDTO;
/**
* @type string
*/
widget?: string;
}
export enum MetricreductionruletypesMatchTypeDTO {
drop = 'drop',
keep = 'keep',
}
export interface MetricreductionruletypesGettableReductionRuleDTO {
/**
* @type boolean
*/
active: boolean;
/**
* @type string
* @format date-time
*/
effectiveFrom: string;
/**
* @type integer
* @minimum 0
*/
ingestedSeries: number;
/**
* @type array,null
*/
labels: string[] | null;
matchType: MetricreductionruletypesMatchTypeDTO;
/**
* @type string
*/
metricName: string;
/**
* @type integer
* @minimum 0
*/
reducedSeries: number;
/**
* @type number
* @format double
*/
reductionPercent: number;
/**
* @type string
* @format date-time
*/
updatedAt: string;
/**
* @type string
*/
updatedBy: string;
}
export interface MetricreductionruletypesGettableReductionRulePreviewDTO {
/**
* @type array,null
*/
affectedAssets: MetricreductionruletypesAffectedAssetDTO[] | null;
/**
* @type array,null
*/
droppedLabels: string[] | null;
/**
* @type string
* @format date-time
*/
effectiveFrom: string;
/**
* @type integer
* @minimum 0
*/
ingestedSeries: number;
/**
* @type integer
* @minimum 0
*/
reducedSeries: number;
/**
* @type number
* @format double
*/
reductionPercent: number;
}
export interface MetricreductionruletypesGettableReductionRulesDTO {
/**
* @type array,null
*/
rules: MetricreductionruletypesGettableReductionRuleDTO[] | null;
/**
* @type integer
*/
total: number;
}
export enum MetricreductionruletypesOrderDTO {
asc = 'asc',
desc = 'desc',
}
export interface MetricreductionruletypesPostableReductionRuleDTO {
/**
* @type array,null
*/
labels: string[] | null;
matchType: MetricreductionruletypesMatchTypeDTO;
}
export interface MetricreductionruletypesPostableReductionRulePreviewDTO {
/**
* @type array,null
*/
labels: string[] | null;
/**
* @type integer
* @format int64
*/
lookbackMs?: number;
matchType: MetricreductionruletypesMatchTypeDTO;
/**
* @type string
*/
metricName: string;
}
export enum MetricreductionruletypesReductionRuleOrderByDTO {
metricname = 'metricname',
ingestedvolume = 'ingestedvolume',
reducedvolume = 'reducedvolume',
reduction = 'reduction',
lastupdated = 'lastupdated',
}
export interface MetricsexplorertypesInspectMetricsRequestDTO {
/**
* @type integer
@@ -6738,6 +6889,10 @@ export interface MetricsexplorertypesMetricDashboardDTO {
* @type string
*/
dashboardName: string;
/**
* @type array
*/
groupBy?: string[];
/**
* @type string
*/
@@ -8135,44 +8290,6 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
items: SpantypesSpanMapperGroupDTO[];
}
export type SpantypesSpanMapperTestSpanDTOAttributesAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMapperTestSpanDTOAttributes =
SpantypesSpanMapperTestSpanDTOAttributesAnyOf | null;
export type SpantypesSpanMapperTestSpanDTOResourceAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMapperTestSpanDTOResource =
SpantypesSpanMapperTestSpanDTOResourceAnyOf | null;
export interface SpantypesSpanMapperTestSpanDTO {
/**
* @type object,null
*/
attributes?: SpantypesSpanMapperTestSpanDTOAttributes;
/**
* @type object,null
*/
resource?: SpantypesSpanMapperTestSpanDTOResource;
}
export interface SpantypesGettableSpanMapperTestDTO {
/**
* @type array,null
*/
spans: SpantypesSpanMapperTestSpanDTO[] | null;
}
export enum SpantypesSpanAggregationTypeDTO {
span_count = 'span_count',
execution_time_percentage = 'execution_time_percentage',
@@ -8468,33 +8585,6 @@ export interface SpantypesPostableSpanMapperGroupDTO {
name: string;
}
export interface SpantypesPostableSpanMapperTestGroupDTO {
condition: SpantypesSpanMapperGroupConditionDTO | null;
/**
* @type boolean
*/
enabled?: boolean;
/**
* @type array,null
*/
mappers?: SpantypesPostableSpanMapperDTO[] | null;
/**
* @type string
*/
name: string;
}
export interface SpantypesPostableSpanMapperTestDTO {
/**
* @type array,null
*/
groups: SpantypesPostableSpanMapperTestGroupDTO[] | null;
/**
* @type array,null
*/
spans: SpantypesSpanMapperTestSpanDTO[] | null;
}
export interface SpantypesSpanAggregationDTO {
aggregation: SpantypesSpanAggregationTypeDTO;
field: TelemetrytypesTelemetryFieldKeyDTO;
@@ -9863,14 +9953,6 @@ export type UpdateSpanMapperPathParameters = {
groupId: string;
mapperId: string;
};
export type TestSpanMappers200 = {
data: SpantypesGettableSpanMapperTestDTO;
/**
* @type string
*/
status: string;
};
export type GetStats200Data = { [key: string]: unknown };
export type GetStats200 = {
@@ -10389,6 +10471,31 @@ export type GetMetricMetadata200 = {
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;
/**
@@ -10405,6 +10512,43 @@ export type GetMetricsOnboardingStatus200 = {
status: string;
};
export type ListMetricReductionRulesParams = {
/**
* @description undefined
*/
orderBy?: MetricreductionruletypesReductionRuleOrderByDTO;
/**
* @description undefined
*/
order?: MetricreductionruletypesOrderDTO;
/**
* @type integer
* @description undefined
*/
offset?: number;
/**
* @type integer
* @description undefined
*/
limit?: number;
};
export type ListMetricReductionRules200 = {
data: MetricreductionruletypesGettableReductionRulesDTO;
/**
* @type string
*/
status: string;
};
export type PreviewMetricReductionRule200 = {
data: MetricreductionruletypesGettableReductionRulePreviewDTO;
/**
* @type string
*/
status: string;
};
export type GetMetricsStats200 = {
data: MetricsexplorertypesStatsResponseDTO;
/**

View File

@@ -30,10 +30,8 @@ import type {
RenderErrorResponseDTO,
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesPostableSpanMapperTestDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
TestSpanMappers200,
UpdateSpanMapperGroupPathParameters,
UpdateSpanMapperPathParameters,
} from '../sigNoz.schemas';
@@ -782,86 +780,3 @@ export const useUpdateSpanMapper = <
> => {
return useMutation(getUpdateSpanMapperMutationOptions(options));
};
/**
* Tests how span mappers would transform sample spans
* @summary Test span mappers against sample spans
*/
export const testSpanMappers = (
spantypesPostableSpanMapperTestDTO?: BodyType<SpantypesPostableSpanMapperTestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<TestSpanMappers200>({
url: `/api/v1/span_mapper_groups/test`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: spantypesPostableSpanMapperTestDTO,
signal,
});
};
export const getTestSpanMappersMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
> => {
const mutationKey = ['testSpanMappers'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof testSpanMappers>>,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> }
> = (props) => {
const { data } = props ?? {};
return testSpanMappers(data);
};
return { mutationFn, ...mutationOptions };
};
export type TestSpanMappersMutationResult = NonNullable<
Awaited<ReturnType<typeof testSpanMappers>>
>;
export type TestSpanMappersMutationBody =
| BodyType<SpantypesPostableSpanMapperTestDTO>
| undefined;
export type TestSpanMappersMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Test span mappers against sample spans
*/
export const useTestSpanMappers = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
> => {
return useMutation(getTestSpanMappersMutationOptions(options));
};

View File

@@ -77,6 +77,7 @@ const ROUTES = {
METRICS_EXPLORER: '/metrics-explorer/summary',
METRICS_EXPLORER_EXPLORER: '/metrics-explorer/explorer',
METRICS_EXPLORER_VIEWS: '/metrics-explorer/views',
METRICS_EXPLORER_VOLUME_CONTROL: '/metrics-explorer/volume-control',
API_MONITORING_BASE: '/api-monitoring',
API_MONITORING: '/api-monitoring/explorer',
METRICS_EXPLORER_BASE: '/metrics-explorer',
@@ -91,8 +92,6 @@ const ROUTES = {
AI_ASSISTANT_BASE: '/ai-assistant',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING:
'/llm-observability/settings/attribute-mapping',
} as const;
export default ROUTES;

View File

@@ -1,56 +0,0 @@
import { Button } from '@signozhq/ui/button';
import styles from './LLMObservabilityAttributeMapping.module.scss';
interface AttributeMappingHeaderProps {
isDirty: boolean;
isSaving: boolean;
onDiscard: () => void;
onSave: () => void;
}
function AttributeMappingHeader({
isDirty,
isSaving,
onDiscard,
onSave,
}: AttributeMappingHeaderProps): JSX.Element {
return (
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<h1 className={styles.title}>Attribute Mapping</h1>
<p className={styles.description}>
Configure source-to-target attribute remapping for LLM traces
</p>
</div>
<div className={styles.pageHeaderActions}>
{isDirty && (
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
Unsaved changes
</span>
)}
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={!isDirty || isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={!isDirty || isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
</header>
);
}
export default AttributeMappingHeader;

View File

@@ -1,41 +0,0 @@
import styles from './LLMObservabilityAttributeMapping.module.scss';
import MapperGroupsTable from './MapperGroupsTable';
import { DraftGroup } from './types';
import { AttributeMappingStore } from './useAttributeMappingStore';
interface AttributeMappingsTabProps {
store: AttributeMappingStore;
onEditGroup: (group: DraftGroup) => void;
onAddGroup: () => void;
}
// "Attribute mappings" tab: the mapping-groups listing, its error state and
// footer summary. The store is owned by the container (the header's save/
// discard share it), so it's passed in rather than created here.
function AttributeMappingsTab({
store,
onEditGroup,
onAddGroup,
}: AttributeMappingsTabProps): JSX.Element {
return (
<div data-testid="attribute-mappings-tab">
{store.isError && (
<div className={styles.pageError} role="alert">
Failed to load mapping groups. Please try again.
</div>
)}
<MapperGroupsTable
store={store}
onEditGroup={onEditGroup}
onAddGroup={onAddGroup}
/>
<footer className={styles.pageFooter}>
Showing {store.groups.length} group{store.groups.length === 1 ? '' : 's'}
</footer>
</div>
);
}
export default AttributeMappingsTab;

View File

@@ -1,93 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { Plus, X } from '@signozhq/icons';
import styles from './GroupFormDrawer.module.scss';
import KeySearchInput from './KeySearchInput';
import { FieldContextValue } from './types';
interface ConditionKeyListProps {
label: string;
labelHint?: string;
keys: string[];
placeholder: string;
addLabel: string;
testIdPrefix: string;
fieldContext: FieldContextValue;
onChange: (keys: string[]) => void;
}
// Editor for one list of condition keys (the group's span-attribute or
// resource gating keys). Substring "contains" match, order irrelevant.
function ConditionKeyList({
label,
labelHint,
keys,
placeholder,
addLabel,
testIdPrefix,
fieldContext,
onChange,
}: ConditionKeyListProps): JSX.Element {
const updateKey = (index: number, value: string): void => {
onChange(keys.map((key, i) => (i === index ? value : key)));
};
const addKey = (): void => {
onChange([...keys, '']);
};
const removeKey = (index: number): void => {
onChange(keys.filter((_, i) => i !== index));
};
return (
<div className={styles.groupFormField}>
<span className={styles.groupFormLabel}>
{label}
{labelHint && (
<span className={styles.groupFormLabelHint}> {labelHint}</span>
)}
</span>
{keys.length > 0 && (
<div className={styles.groupFormKeys}>
{keys.map((key, index) => (
// eslint-disable-next-line react/no-array-index-key
<div className={styles.groupFormKey} key={index}>
<KeySearchInput
className={styles.groupFormKeyInput}
placeholder={placeholder}
value={key}
fieldContext={fieldContext}
onChange={(next): void => updateKey(index, next)}
testId={`${testIdPrefix}-${index}`}
/>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Remove key"
onClick={(): void => removeKey(index)}
testId={`${testIdPrefix}-remove-${index}`}
>
<X size={14} />
</Button>
</div>
))}
</div>
)}
<Button
variant="dashed"
color="secondary"
prefix={<Plus size={14} />}
onClick={addKey}
testId={`${testIdPrefix}-add`}
>
{addLabel}
</Button>
</div>
);
}
export default ConditionKeyList;

View File

@@ -1,76 +0,0 @@
.groupForm {
display: flex;
flex-direction: column;
gap: var(--spacing-10);
padding: var(--spacing-2) 0;
}
.groupFormField {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.groupFormFieldRow {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.groupFormLabel {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
}
.groupFormLabelHint {
font-weight: var(--font-weight-normal);
text-transform: none;
letter-spacing: normal;
}
.groupFormHint {
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}
.groupFormKeys {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.groupFormKey {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.groupFormKeyInput {
flex: 1;
font-family: 'Geist Mono', monospace;
}
.groupFormError {
padding: var(--spacing-5) var(--spacing-6);
border-radius: var(--radius-2);
background: var(--callout-error-background);
color: var(--callout-error-title);
font-size: var(--periscope-font-size-base);
}
.groupFormFooter {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: var(--spacing-4);
}
.groupFormFooterActions {
display: flex;
gap: var(--spacing-4);
margin-left: auto;
}

View File

@@ -1,147 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import { Trash2 } from '@signozhq/icons';
import ConditionKeyList from './ConditionKeyList';
import styles from './GroupFormDrawer.module.scss';
import { FieldContext, GroupDraft, MapperDraftMode } from './types';
import { isGroupDraftValid } from './utils';
interface GroupFormDrawerProps {
isOpen: boolean;
mode: MapperDraftMode;
draft: GroupDraft;
setDraft: (next: GroupDraft) => void;
onClose: () => void;
onSave: () => void;
onDelete: () => void;
isSaving: boolean;
isDeleting: boolean;
saveError: string | null;
}
function GroupFormDrawer({
isOpen,
mode,
draft,
setDraft,
onClose,
onSave,
onDelete,
isSaving,
isDeleting,
saveError,
}: GroupFormDrawerProps): JSX.Element {
const isEdit = mode === 'edit';
const isValid = isGroupDraftValid(draft);
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
title={isEdit ? 'Edit group' : 'New group'}
subTitle="A group gates which spans its mappings run on"
width="wide"
testId="group-form-drawer"
footer={
<div className={styles.groupFormFooter}>
{isEdit && (
<Button
variant="ghost"
color="destructive"
prefix={<Trash2 size={14} />}
onClick={onDelete}
disabled={isDeleting}
testId="group-form-delete"
>
{isDeleting ? 'Deleting…' : 'Delete'}
</Button>
)}
<div className={styles.groupFormFooterActions}>
<Button
variant="ghost"
color="secondary"
onClick={onClose}
testId="group-form-cancel"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
disabled={!isValid || isSaving}
testId="group-form-save"
>
{/* eslint-disable-next-line no-nested-ternary */}
{isSaving ? 'Saving…' : isEdit ? 'Save group' : 'Create group'}
</Button>
</div>
</div>
}
>
<div className={styles.groupForm}>
<div className={styles.groupFormField}>
<span className={styles.groupFormLabel}>Group name</span>
<Input
placeholder="e.g. OpenAI gateway"
value={draft.name}
onChange={(event): void =>
setDraft({ ...draft, name: event.target.value })
}
testId="group-form-name"
/>
</div>
<div className={`${styles.groupFormField} ${styles.groupFormFieldRow}`}>
<span className={styles.groupFormLabel}>Enabled</span>
<Switch
value={draft.enabled}
onChange={(checked): void => setDraft({ ...draft, enabled: checked })}
testId="group-form-enabled"
/>
</div>
<ConditionKeyList
label="Condition · span attribute keys"
labelHint="· runs when a span attribute key contains any of these"
keys={draft.attributes}
placeholder="e.g. gen_ai."
addLabel="Add attribute key"
testIdPrefix="group-form-attribute"
fieldContext={FieldContext.attribute}
onChange={(attributes): void => setDraft({ ...draft, attributes })}
/>
<ConditionKeyList
label="Condition · resource keys"
labelHint="· or when a resource key contains any of these"
keys={draft.resource}
placeholder="e.g. service.name"
addLabel="Add resource key"
testIdPrefix="group-form-resource"
fieldContext={FieldContext.resource}
onChange={(resource): void => setDraft({ ...draft, resource })}
/>
<span className={styles.groupFormHint}>
Leave both empty to run this group on every span.
</span>
{saveError && (
<div className={styles.groupFormError} role="alert">
{saveError}
</div>
)}
</div>
</DrawerWrapper>
);
}
export default GroupFormDrawer;

View File

@@ -1,51 +0,0 @@
.keySearch {
position: relative;
flex: 1;
min-width: 0;
}
.keySearchDropdown {
position: absolute;
top: calc(100% + var(--spacing-2));
left: 0;
z-index: 20;
min-width: 100%;
width: max-content;
max-width: 420px;
max-height: 240px;
overflow-x: hidden;
overflow-y: auto;
padding: var(--spacing-2);
border: 1px solid var(--l2-border);
border-radius: 6px;
background: var(--l2-background);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.keySearchDropdownEmpty {
padding: var(--spacing-4) var(--spacing-5);
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}
.keySearchOption {
display: block;
width: 100%;
max-width: 100%;
padding: var(--spacing-3) var(--spacing-5);
border: none;
border-radius: 3px;
background: transparent;
color: var(--l1-foreground);
font-family: 'Geist Mono', monospace;
font-size: var(--font-size-xs);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
&:hover {
background: var(--l2-background-hover);
}
}

View File

@@ -1,117 +0,0 @@
import { useMemo, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { useGetFieldsKeys } from 'api/generated/services/fields';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import Spinner from 'components/Spinner';
import useDebounce from 'hooks/useDebounce';
import styles from './KeySearchInput.module.scss';
import { FieldContext, FieldContextValue } from './types';
const SUGGESTION_LIMIT = 50;
const DEBOUNCE_MS = 300;
interface KeySearchInputProps {
value: string;
fieldContext: FieldContextValue;
placeholder?: string;
className?: string;
disabled?: boolean;
testId?: string;
onChange: (value: string) => void;
}
// Maps the mapper's attribute/resource context to the fields-endpoint context.
function toFieldsContext(
context: FieldContextValue,
): TelemetrytypesFieldContextDTO {
return context === FieldContext.resource
? TelemetrytypesFieldContextDTO.resource
: TelemetrytypesFieldContextDTO.attribute;
}
// Free-text input with span/resource key suggestions from /api/v1/fields/keys
// (signal=traces). Typing keeps the custom value; suggestions are assistive.
function KeySearchInput({
value,
fieldContext,
placeholder,
className,
disabled,
testId,
onChange,
}: KeySearchInputProps): JSX.Element {
const [isOpen, setIsOpen] = useState(false);
const debouncedSearch = useDebounce(value, DEBOUNCE_MS);
const { data, isFetching } = useGetFieldsKeys(
{
signal: TelemetrytypesSignalDTO.traces,
fieldContext: toFieldsContext(fieldContext),
searchText: debouncedSearch,
limit: SUGGESTION_LIMIT,
},
{ query: { enabled: isOpen && !disabled, keepPreviousData: true } },
);
const suggestions = useMemo(() => {
const keys = data?.data?.keys ?? {};
return Object.keys(keys)
.filter((key) => key !== value)
.slice(0, SUGGESTION_LIMIT);
}, [data, value]);
return (
<div className={cx(styles.keySearch, className)}>
<Input
placeholder={placeholder}
value={value}
disabled={disabled}
autoComplete="off"
onChange={(event): void => {
onChange(event.target.value);
setIsOpen(true);
}}
onFocus={(): void => setIsOpen(true)}
onBlur={(): void => setIsOpen(false)}
testId={testId}
/>
{isOpen && suggestions.length > 0 && (
<div
className={styles.keySearchDropdown}
data-testid={`${testId}-dropdown`}
>
{suggestions.map((name) => (
<button
type="button"
key={name}
className={styles.keySearchOption}
// onMouseDown (not onClick) so selection runs before the input blur.
onMouseDown={(event): void => {
event.preventDefault();
onChange(name);
setIsOpen(false);
}}
data-testid={`${testId}-option-${name}`}
>
{name}
</button>
))}
</div>
)}
{isOpen && isFetching && suggestions.length === 0 && (
<div
className={cx(styles.keySearchDropdown, styles.keySearchDropdownEmpty)}
>
<Spinner size="small" height="auto" />
</div>
)}
</div>
);
}
export default KeySearchInput;

View File

@@ -1,161 +0,0 @@
.llmObservabilityAttributeMapping {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
}
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.pageHeaderTitle {
display: flex;
flex-direction: column;
}
.title {
margin: 0;
font-size: var(--periscope-font-size-large);
font-weight: var(--font-weight-semibold);
}
.description {
margin: var(--spacing-2) 0 0;
font-size: var(--periscope-font-size-base);
color: var(--l3-foreground);
}
.pageHeaderActions {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.unsavedChanges {
font-size: var(--periscope-font-size-base);
color: var(--accent-amber);
}
.tableEmpty {
padding: var(--spacing-12) var(--spacing-6);
text-align: center;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}
.muted {
color: var(--l3-foreground);
}
.pageError {
padding: var(--padding-3) var(--padding-4);
border-radius: var(--radius-2);
background: var(--callout-error-background);
color: var(--callout-error-title);
font-size: var(--periscope-font-size-base);
}
.pageFooter {
margin-top: var(--spacing-8);
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}
.rowActions {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.addRow {
align-self: flex-start;
}
.groupsTableWrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.groupsTableNameCell {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.groupsTableName {
font-weight: var(--font-weight-semibold);
}
.groupsTableFilters {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
// Allow this column to shrink within the cell so long keys wrap
// instead of forcing the cell wider than its allotted width.
min-width: 0;
}
.groupsTableFilter {
display: flex;
align-items: flex-start;
gap: var(--spacing-4);
font-size: var(--periscope-font-size-base);
min-width: 0;
// Keep the context badge at full width; only the key text flexes.
> *:first-child {
flex-shrink: 0;
}
}
.groupsTableFilterKey {
min-width: 0;
font-family: 'Geist Mono', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mappersTableWrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: var(--margin-1) 0;
}
.mappersTableTarget {
font-family: 'Geist Mono', monospace;
font-weight: var(--font-weight-semibold);
}
.mappersTableSources {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-3);
}
.mappersTableSourceChip {
display: inline-flex;
align-items: center;
max-width: 220px;
padding: 2px var(--padding-2);
border: 1px solid var(--l2-border);
border-radius: 6px;
background: var(--l3-background);
font-family: 'Geist Mono', monospace;
font-size: var(--font-size-xs);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mappersTableSourceMore {
font-size: var(--font-size-xs);
white-space: nowrap;
}

View File

@@ -1,88 +0,0 @@
import { useCallback } from 'react';
import { Tabs } from '@signozhq/ui/tabs';
import AttributeMappingHeader from './AttributeMappingHeader';
import AttributeMappingsTab from './AttributeMappingsTab';
import GroupFormDrawer from './GroupFormDrawer';
import styles from './LLMObservabilityAttributeMapping.module.scss';
import TestTab from './TestTab';
import { useAttributeMappingStore } from './useAttributeMappingStore';
import { useGroupFormDrawer } from './useGroupFormDrawer';
function LLMObservabilityAttributeMapping(): JSX.Element {
const store = useAttributeMappingStore();
const groupDrawer = useGroupFormDrawer();
const handleGroupSave = useCallback((): void => {
store.upsertGroup(groupDrawer.draft);
groupDrawer.close();
}, [store, groupDrawer]);
const handleGroupDelete = useCallback((): void => {
if (groupDrawer.draft.id) {
store.removeGroup(groupDrawer.draft.id);
}
groupDrawer.close();
}, [store, groupDrawer]);
const tabItems = [
{
key: 'attribute-mappings',
label: 'Attribute mappings',
children: (
<AttributeMappingsTab
store={store}
onEditGroup={groupDrawer.openForEdit}
onAddGroup={groupDrawer.openForAdd}
/>
),
},
{
key: 'test',
label: 'Test',
children: <TestTab store={store} />,
},
];
return (
<div
className={styles.llmObservabilityAttributeMapping}
data-testid="llm-observability-attribute-mapping-page"
>
<AttributeMappingHeader
isDirty={store.isDirty}
isSaving={store.isSaving}
onDiscard={store.discard}
onSave={store.save}
/>
{store.saveError && (
<div className={styles.pageError} role="alert">
{store.saveError}
</div>
)}
<Tabs
testId="attribute-mapping-tabs"
defaultValue="attribute-mappings"
items={tabItems}
/>
{groupDrawer.isOpen && (
<GroupFormDrawer
isOpen={groupDrawer.isOpen}
mode={groupDrawer.mode}
draft={groupDrawer.draft}
setDraft={groupDrawer.setDraft}
onClose={groupDrawer.close}
onSave={handleGroupSave}
onDelete={handleGroupDelete}
isSaving={false}
isDeleting={false}
saveError={null}
/>
)}
</div>
);
}
export default LLMObservabilityAttributeMapping;

View File

@@ -1,104 +0,0 @@
.form {
display: flex;
flex-direction: column;
gap: 20px;
padding: 4px 0;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bg-vanilla-400);
}
.labelHint {
font-weight: 400;
text-transform: none;
letter-spacing: normal;
}
.hint {
font-size: 12px;
color: var(--bg-vanilla-400);
}
.fieldContext {
width: 200px;
}
.sources {
display: flex;
flex-direction: column;
gap: 8px;
}
.source {
display: flex;
align-items: center;
gap: 6px;
}
.sourceHandle {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
background: transparent;
color: var(--bg-vanilla-400);
cursor: grab;
touch-action: none;
user-select: none;
&:active {
cursor: grabbing;
}
}
.sourceIndex {
width: 20px;
text-align: center;
font-size: 12px;
color: var(--bg-vanilla-400);
}
.sourceInput {
flex: 1;
min-width: 0;
font-family: 'Geist Mono', monospace;
}
.sourceSelect {
flex: 0 0 auto;
width: 120px;
}
.error {
padding: 10px 12px;
border-radius: 4px;
background: var(--callout-error-background);
color: var(--callout-error-title);
font-size: 13px;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 8px;
}
.footerActions {
display: flex;
gap: 8px;
margin-left: auto;
}

View File

@@ -1,257 +0,0 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { SelectSimple } from '@signozhq/ui/select';
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
arrayMove,
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { Plus, Trash2 } from '@signozhq/icons';
import { v4 as uuid } from 'uuid';
import KeySearchInput from './KeySearchInput';
import styles from './MapperFormDrawer.module.scss';
import SourceAttributeRow from './SourceAttributeRow';
import {
FieldContext,
FieldContextValue,
MapperDraft,
MapperDraftMode,
SourceConfig,
} from './types';
import { createEmptySource, isMapperDraftValid } from './utils';
const FIELD_CONTEXT_OPTIONS = [
{ value: FieldContext.attribute, label: 'Span attribute' },
{ value: FieldContext.resource, label: 'Resource' },
];
interface MapperFormDrawerProps {
isOpen: boolean;
mode: MapperDraftMode;
draft: MapperDraft;
setDraft: (next: MapperDraft) => void;
onClose: () => void;
onSave: () => void;
onDelete: () => void;
isSaving: boolean;
isDeleting: boolean;
saveError: string | null;
}
function MapperFormDrawer({
isOpen,
mode,
draft,
setDraft,
onClose,
onSave,
onDelete,
isSaving,
isDeleting,
saveError,
}: MapperFormDrawerProps): JSX.Element {
const isEdit = mode === 'edit';
const isValid = isMapperDraftValid(draft);
// Stable per-row ids for the sortable list. These are UI-only (never sent to
// the API and excluded from the draft), so dnd-kit can track rows reliably
// even though sources are stored as a plain array. Re-seeded each time the
// drawer opens; kept in lockstep with the sources array on add/remove/drag.
const [rowIds, setRowIds] = useState<string[]>(() =>
draft.sources.map(() => uuid()),
);
const sourceIds = draft.sources.map(
(_, index) => rowIds[index] ?? `pending-${index}`,
);
// 5px activation distance so clicking into the input never starts a drag.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const updateSource = (index: number, patch: Partial<SourceConfig>): void => {
const sources = draft.sources.map((source, i) =>
i === index ? { ...source, ...patch } : source,
);
setDraft({ ...draft, sources });
};
const addSource = (): void => {
setDraft({ ...draft, sources: [...draft.sources, createEmptySource()] });
setRowIds((prev) => [...prev, uuid()]);
};
const removeSource = (index: number): void => {
const sources = draft.sources.filter((_, i) => i !== index);
if (sources.length === 0) {
setDraft({ ...draft, sources: [createEmptySource()] });
setRowIds([uuid()]);
return;
}
setDraft({ ...draft, sources });
setRowIds((prev) => prev.filter((_, i) => i !== index));
};
const handleDragEnd = (event: DragEndEvent): void => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const from = sourceIds.indexOf(String(active.id));
const to = sourceIds.indexOf(String(over.id));
if (from === -1 || to === -1) {
return;
}
setDraft({ ...draft, sources: arrayMove(draft.sources, from, to) });
setRowIds((prev) => arrayMove(prev, from, to));
};
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
title={isEdit ? 'Edit mapping' : 'New custom mapping'}
subTitle="Map source attributes onto a canonical target attribute"
width="wide"
testId="mapper-form-drawer"
footer={
<div className={styles.footer}>
{isEdit && (
<Button
variant="ghost"
color="destructive"
prefix={<Trash2 size={14} />}
onClick={onDelete}
disabled={isDeleting}
testId="mapper-form-delete"
>
{isDeleting ? 'Deleting…' : 'Delete'}
</Button>
)}
<div className={styles.footerActions}>
<Button
variant="ghost"
color="secondary"
onClick={onClose}
testId="mapper-form-cancel"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
disabled={!isValid || isSaving}
testId="mapper-form-save"
>
{/* eslint-disable-next-line no-nested-ternary */}
{isSaving ? 'Saving…' : isEdit ? 'Save mapping' : 'Create mapping'}
</Button>
</div>
</div>
}
>
<div className={styles.form}>
<div className={styles.field}>
<span className={styles.label}>Target attribute</span>
<KeySearchInput
placeholder="e.g. gen_ai.content.prompt"
value={draft.name}
fieldContext={draft.fieldContext}
disabled={isEdit}
onChange={(name): void => setDraft({ ...draft, name })}
testId="mapper-form-target"
/>
{isEdit && (
<span className={styles.hint}>
The target attribute can&apos;t be changed after creation.
</span>
)}
</div>
<div className={styles.field}>
<span className={styles.label}>Write target to</span>
<SelectSimple
className={styles.fieldContext}
items={FIELD_CONTEXT_OPTIONS}
value={draft.fieldContext}
withPortal={false}
onChange={(next): void =>
setDraft({ ...draft, fieldContext: next as FieldContextValue })
}
testId="mapper-form-field-context"
/>
<span className={styles.hint}>
Where the standardized attribute is written.
</span>
</div>
<div className={styles.field}>
<span className={styles.label}>
Source attributes
<span className={styles.labelHint}>
{' '}
· priority: top bottom · drag to reorder
</span>
</span>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext items={sourceIds} strategy={verticalListSortingStrategy}>
<div className={styles.sources}>
{draft.sources.map((source, index) => (
<SourceAttributeRow
key={sourceIds[index]}
id={sourceIds[index]}
index={index}
value={source}
canRemove={draft.sources.length > 1}
onChange={updateSource}
onRemove={removeSource}
/>
))}
</div>
</SortableContext>
</DndContext>
<Button
variant="dashed"
color="secondary"
prefix={<Plus size={14} />}
onClick={addSource}
testId="mapper-form-add-source"
>
Add another source
</Button>
</div>
{saveError && (
<div className={styles.error} role="alert">
{saveError}
</div>
)}
</div>
</DrawerWrapper>
);
}
export default MapperFormDrawer;

View File

@@ -1,80 +0,0 @@
import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Plus } from '@signozhq/icons';
import TanStackTable from 'components/TanStackTableView';
import styles from './LLMObservabilityAttributeMapping.module.scss';
import { getMapperGroupsColumns } from './mapperGroups.config';
import MappersTable from './MappersTable';
import { DraftGroup } from './types';
import { AttributeMappingStore } from './useAttributeMappingStore';
const SKELETON_ROW_COUNT = 5;
interface MapperGroupsTableProps {
store: AttributeMappingStore;
onEditGroup: (group: DraftGroup) => void;
onAddGroup: () => void;
}
// Top-level listing of mapping groups. Each row expands to reveal its mappers,
// which MappersTable fetches lazily on first open. Built on the shared
// TanStackTable with virtual scroll disabled — this is a small, content-height
// list, and nested expanded tables need to grow with their content rather than
// live inside a fixed-height viewport. Row actions (edit/delete/toggle) live in
// the column config; the add-group affordance sits below the table.
function MapperGroupsTable({
store,
onEditGroup,
onAddGroup,
}: MapperGroupsTableProps): JSX.Element {
const columns = useMemo(
() =>
getMapperGroupsColumns({
onEditGroup,
onRemoveGroup: store.removeGroup,
onToggleGroup: store.toggleGroup,
}),
[onEditGroup, store.removeGroup, store.toggleGroup],
);
const isEmpty = !store.isLoading && store.groups.length === 0;
return (
<div className={styles.groupsTableWrapper}>
{isEmpty ? (
<div className={styles.tableEmpty} data-testid="mapper-groups-empty">
No mapping groups yet.
</div>
) : (
<TanStackTable<DraftGroup>
data={store.groups}
columns={columns}
isLoading={store.isLoading}
skeletonRowCount={SKELETON_ROW_COUNT}
getRowKey={(row): string => row.localId}
getRowCanExpand={(): boolean => true}
renderExpandedRow={(row): JSX.Element => (
<MappersTable group={row} store={store} />
)}
disableVirtualScroll
testId="mapper-groups-table"
/>
)}
<Button
variant="ghost"
color="primary"
prefix={<Plus size={14} />}
className={styles.addRow}
onClick={onAddGroup}
testId="add-group-row"
disabled={store.isLoading}
>
Add a new group
</Button>
</div>
);
}
export default MapperGroupsTable;

View File

@@ -1,138 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Plus } from '@signozhq/icons';
import { useListSpanMappers } from 'api/generated/services/spanmapper';
import TanStackTable from 'components/TanStackTableView';
import styles from './LLMObservabilityAttributeMapping.module.scss';
import MapperFormDrawer from './MapperFormDrawer';
import { getMappersColumns } from './mappers.config';
import { DraftGroup, DraftMapper, Mapper } from './types';
import { AttributeMappingStore } from './useAttributeMappingStore';
import { useMapperFormDrawer } from './useMapperFormDrawer';
const SKELETON_ROW_COUNT = 3;
interface MappersTableProps {
group: DraftGroup;
store: AttributeMappingStore;
}
// Nested table of a group's mappers, rendered inside the group's expanded row.
// This component only mounts when its group row is expanded, so the fetch is
// lazy by construction — a group's mappers load on first open, are folded into
// the store's draft so they're editable, and are then cached by react-query.
// New (unsaved) groups have no serverId, so skip the fetch.
function MappersTable({ group, store }: MappersTableProps): JSX.Element {
const drawer = useMapperFormDrawer();
const { hydrateGroupMappers, upsertMapper, removeMapper, toggleMapper } = store;
const hasServerId = group.serverId !== null;
const { data, isLoading, isError } = useListSpanMappers(
{ groupId: group.serverId ?? '' },
{ query: { enabled: hasServerId } },
);
useEffect(() => {
const items = data?.data?.items;
if (group.serverId && items) {
// The generated schema mis-types this list response with the groups DTO;
// the runtime payload is mappers.
hydrateGroupMappers(group.serverId, items as unknown as Mapper[]);
}
}, [group.serverId, data, hydrateGroupMappers]);
const isLoadingMappers = hasServerId && isLoading;
const isErrorMappers = hasServerId && isError;
const handleSave = useCallback((): void => {
upsertMapper(group.localId, drawer.draft);
drawer.close();
}, [upsertMapper, group.localId, drawer]);
const handleDelete = useCallback((): void => {
if (drawer.draft.id) {
removeMapper(group.localId, drawer.draft.id);
}
drawer.close();
}, [removeMapper, group.localId, drawer]);
const columns = useMemo(
() =>
getMappersColumns({
onEdit: drawer.openForEdit,
onRemove: (localId): void => removeMapper(group.localId, localId),
onToggle: (localId, enabled): void =>
toggleMapper(group.localId, localId, enabled),
}),
[drawer.openForEdit, removeMapper, toggleMapper, group.localId],
);
let content: JSX.Element;
if (isErrorMappers) {
content = (
<div
className={styles.tableEmpty}
data-testid={`mappers-error-${group.localId}`}
>
Failed to load mappings. Please try again.
</div>
);
} else if (!isLoadingMappers && group.mappers.length === 0) {
content = (
<div
className={styles.tableEmpty}
data-testid={`mappers-empty-${group.localId}`}
>
No mappings in this group yet.
</div>
);
} else {
content = (
<TanStackTable<DraftMapper>
data={group.mappers}
columns={columns}
isLoading={isLoadingMappers}
skeletonRowCount={SKELETON_ROW_COUNT}
getRowKey={(row): string => row.localId}
disableVirtualScroll
testId={`mappers-table-${group.localId}`}
/>
);
}
return (
<div className={styles.mappersTableWrapper}>
{content}
<Button
variant="ghost"
color="primary"
size="sm"
prefix={<Plus size={14} />}
className={styles.addRow}
onClick={drawer.openForAdd}
testId={`add-mapper-${group.localId}`}
>
Add mapping
</Button>
{drawer.isOpen && (
<MapperFormDrawer
isOpen={drawer.isOpen}
mode={drawer.mode}
draft={drawer.draft}
setDraft={drawer.setDraft}
onClose={drawer.close}
onSave={handleSave}
onDelete={handleDelete}
isSaving={false}
isDeleting={false}
saveError={null}
/>
)}
</div>
);
}
export default MappersTable;

View File

@@ -1,116 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { SelectSimple } from '@signozhq/ui/select';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, X } from '@signozhq/icons';
import KeySearchInput from './KeySearchInput';
import styles from './MapperFormDrawer.module.scss';
import {
FieldContext,
FieldContextValue,
MapperOperation,
MapperOperationValue,
SourceConfig,
} from './types';
const CONTEXT_OPTIONS = [
{ value: FieldContext.attribute, label: 'Attribute' },
{ value: FieldContext.resource, label: 'Resource' },
];
const OPERATION_OPTIONS = [
{ value: MapperOperation.move, label: 'Move' },
{ value: MapperOperation.copy, label: 'Copy' },
];
interface SourceAttributeRowProps {
id: string;
index: number;
value: SourceConfig;
canRemove: boolean;
onChange: (index: number, patch: Partial<SourceConfig>) => void;
onRemove: (index: number) => void;
}
// A single draggable source row. Order = priority (top wins). Each source can
// be read from a span attribute or the resource, and moved (delete source) or
// copied (keep source). Only the grip is a drag handle.
function SourceAttributeRow({
id,
index,
value,
canRemove,
onChange,
onRemove,
}: SourceAttributeRowProps): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
return (
<div className={styles.source} ref={setNodeRef} style={style}>
<div
className={styles.sourceHandle}
data-testid={`mapper-form-source-handle-${index}`}
{...attributes}
{...listeners}
>
<GripVertical size={14} />
</div>
<span className={styles.sourceIndex}>{index + 1}</span>
<KeySearchInput
className={styles.sourceInput}
placeholder="Source attribute key"
value={value.key}
fieldContext={value.context}
onChange={(key): void => onChange(index, { key })}
testId={`mapper-form-source-${index}`}
/>
<SelectSimple
className={styles.sourceSelect}
items={CONTEXT_OPTIONS}
value={value.context}
withPortal={false}
onChange={(next): void =>
onChange(index, { context: next as FieldContextValue })
}
testId={`mapper-form-source-context-${index}`}
/>
<SelectSimple
className={styles.sourceSelect}
items={OPERATION_OPTIONS}
value={value.operation}
withPortal={false}
onChange={(next): void =>
onChange(index, { operation: next as MapperOperationValue })
}
testId={`mapper-form-source-operation-${index}`}
/>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Remove source"
disabled={!canRemove}
onClick={(): void => onRemove(index)}
testId={`mapper-form-source-remove-${index}`}
>
<X size={14} />
</Button>
</div>
);
}
export default SourceAttributeRow;

View File

@@ -1,88 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { SpantypesSpanMapperTestSpanDTO } from 'api/generated/services/sigNoz.schemas';
import { useMemo } from 'react';
import { AttrChangeStatus, diffSpanAttributes } from './testPayload';
import styles from './TestTab.module.scss';
interface TestResultProps {
index: number;
span: SpantypesSpanMapperTestSpanDTO;
inputAttributes: Record<string, unknown>;
}
const STATUS_BADGE: Partial<
Record<
AttrChangeStatus,
{ color: 'success' | 'robin' | 'sienna'; label: string }
>
> = {
added: { color: 'success', label: 'populated' },
changed: { color: 'robin', label: 'remapped' },
removed: { color: 'sienna', label: 'moved out' },
};
// Only added/removed rows carry a background treatment; the rest render plain.
// Kept as an explicit map so we never do dynamic `styles[...]` access.
const ROW_CLASS: Partial<Record<AttrChangeStatus, string>> = {
added: styles.added,
removed: styles.removed,
};
function formatValue(value: unknown): string {
if (typeof value === 'string') {
return value;
}
return JSON.stringify(value);
}
// Renders one transformed span as a key/value list, highlighting which target
// attributes the mappers populated and which source keys a move consumed.
function TestResult({
index,
span,
inputAttributes,
}: TestResultProps): JSX.Element {
const entries = useMemo(
() => diffSpanAttributes(inputAttributes, span.attributes ?? {}),
[inputAttributes, span.attributes],
);
return (
<div className={styles.resultCard} data-testid={`test-result-${index}`}>
<div className={styles.resultTitle}>Resulting attributes</div>
{entries.length === 0 ? (
<div className={styles.resultEmpty}>This span has no attributes.</div>
) : (
<div className={styles.attrRows}>
{entries.map((entry) => {
const badge = STATUS_BADGE[entry.status];
return (
<div
key={entry.key}
className={`${styles.attrRow} ${ROW_CLASS[entry.status] ?? ''}`}
>
<span className={styles.attrKey} title={entry.key}>
{entry.key}
</span>
<span className={styles.attrValue} title={formatValue(entry.value)}>
{formatValue(entry.value)}
</span>
{badge ? (
<Badge color={badge.color} variant="outline">
{badge.label}
</Badge>
) : (
<span />
)}
</div>
);
})}
</div>
)}
</div>
);
}
export default TestResult;

View File

@@ -1,129 +0,0 @@
.testTab {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
max-width: 860px;
}
.heading {
display: flex;
align-items: center;
gap: var(--spacing-3);
margin: 0;
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-semibold);
}
.description {
margin: 0;
font-size: var(--periscope-font-size-base);
color: var(--l3-foreground);
}
.editor {
width: 100%;
min-height: 260px;
padding: var(--padding-4);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l1-background);
color: var(--l1-foreground);
font-family: 'Geist Mono', monospace;
font-size: var(--font-size-xs);
line-height: 1.6;
resize: vertical;
&:focus {
outline: none;
border-color: var(--robin-500);
}
}
.actions {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.error {
padding: var(--padding-3) var(--padding-4);
border-radius: var(--radius-2);
background: var(--callout-error-background);
color: var(--callout-error-title);
font-size: var(--periscope-font-size-base);
}
.results {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.resultCard {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--padding-4);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l1-background);
}
.resultTitle {
display: flex;
align-items: center;
gap: var(--spacing-3);
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-semibold);
}
.resultEmpty {
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}
.attrRows {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.attrRow {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) auto;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-2) var(--padding-3);
border-radius: var(--radius-2);
font-family: 'Geist Mono', monospace;
font-size: var(--font-size-xs);
&.added {
background: var(--callout-success-background);
}
&.removed {
opacity: 0.65;
.attrKey,
.attrValue {
text-decoration: line-through;
}
}
}
.attrKey,
.attrValue {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.attrKey {
font-weight: var(--font-weight-medium);
}
.attrValue {
color: var(--l3-foreground);
}

View File

@@ -1,80 +0,0 @@
import { Play } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import styles from './TestTab.module.scss';
import TestResult from './TestResult';
import { AttributeMappingStore } from './useAttributeMappingStore';
import { useTestSpanMapper } from './useTestSpanMapper';
interface TestTabProps {
store: AttributeMappingStore;
}
// "Test" tab: paste a sample span, run it through the working draft's mappers,
// and see which target attributes get populated. Only groups whose mappers
// changed are sent in full — unchanged groups are backfilled server-side.
function TestTab({ store }: TestTabProps): JSX.Element {
const { input, setInput, run, isRunning, result, testedAttributes, error } =
useTestSpanMapper(store.snapshot, store.groups);
return (
<div className={styles.testTab} data-testid="test-tab">
<h3 className={styles.heading}>Test with sample span</h3>
<p className={styles.description}>
Paste a JSON span object to see which target attributes get populated and
which source key matched.
</p>
<textarea
className={styles.editor}
data-testid="test-span-input"
value={input}
onChange={(event): void => setInput(event.target.value)}
spellCheck={false}
aria-label="Sample span JSON"
/>
<div className={styles.actions}>
<Button
testId="run-test-button"
variant="solid"
color="primary"
prefix={<Play size={14} />}
onClick={run}
loading={isRunning}
disabled={isRunning}
>
Run Test
</Button>
</div>
{error && (
<div className={styles.error} role="alert" data-testid="test-error">
{error}
</div>
)}
{result && (
<div className={styles.results} data-testid="test-results">
{result.length === 0 ? (
<div className={styles.resultEmpty}>
No spans returned. The mappers produced no output for this input.
</div>
) : (
result.map((span, index) => (
<TestResult
// eslint-disable-next-line react/no-array-index-key
key={index}
index={index}
span={span}
inputAttributes={testedAttributes ?? {}}
/>
))
)}
</div>
)}
</div>
);
}
export default TestTab;

View File

@@ -1,128 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Switch } from '@signozhq/ui/switch';
import { ChevronDown, ChevronRight, Pencil, Trash2 } from '@signozhq/icons';
import type { TableColumnDef } from 'components/TanStackTableView';
import styles from './LLMObservabilityAttributeMapping.module.scss';
import type { DraftGroup } from './types';
import { conditionFiltersFromGroup } from './utils';
interface ColumnsConfig {
onEditGroup: (group: DraftGroup) => void;
onRemoveGroup: (localId: string) => void;
onToggleGroup: (localId: string, enabled: boolean) => void;
}
// Column definitions for the mapping-groups TanStackTable. Sorting is off across
// the board — the groups list API returns the full set unordered, so there's no
// server-side ordering to back a sortable header yet.
export function getMapperGroupsColumns({
onEditGroup,
onRemoveGroup,
onToggleGroup,
}: ColumnsConfig): TableColumnDef<DraftGroup>[] {
return [
{
id: 'name',
header: 'Group name',
accessorFn: (row): string => row.name,
width: { min: 240, default: '100%' },
enableMove: false,
enableRemove: false,
cell: ({ row, isExpanded, toggleExpanded }): JSX.Element => (
<div className={styles.groupsTableNameCell}>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label={isExpanded ? 'Collapse group' : 'Expand group'}
onClick={(): void => toggleExpanded()}
testId={`group-expand-${row.localId}`}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</Button>
<span
className={styles.groupsTableName}
data-testid={`group-name-${row.localId}`}
>
{row.name}
</span>
</div>
),
},
{
id: 'filters',
header: 'Filters',
width: { min: 200, default: '100%' },
enableMove: false,
cell: ({ row }): JSX.Element => {
const filters = conditionFiltersFromGroup(row);
if (filters.length === 0) {
return <span className={styles.muted}>No condition · always runs</span>;
}
return (
<div
className={styles.groupsTableFilters}
data-testid={`group-filters-${row.localId}`}
>
{filters.map((filter) => (
<div
className={styles.groupsTableFilter}
key={`${filter.context}:${filter.key}`}
>
<Badge
color={filter.context === 'resource' ? 'amber' : 'robin'}
variant="outline"
>
{filter.context}
</Badge>
<span className={styles.groupsTableFilterKey}>
contains {filter.key}
</span>
</div>
))}
</div>
);
},
},
{
id: 'actions',
header: 'Actions',
// Compact, right-aligned action cluster — opt out of the "last column
// fills 100%" rule so the spare width flows into Group name / Filters.
width: { fixed: '160px', ignoreLastColumnFill: true },
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element => (
<div className={styles.rowActions}>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Edit group"
onClick={(): void => onEditGroup(row)}
testId={`group-edit-${row.localId}`}
>
<Pencil size={14} />
</Button>
<Button
variant="ghost"
color="destructive"
size="icon"
aria-label="Delete group"
onClick={(): void => onRemoveGroup(row.localId)}
testId={`group-delete-${row.localId}`}
>
<Trash2 size={14} />
</Button>
<Switch
value={row.enabled}
onChange={(checked): void => onToggleGroup(row.localId, checked)}
testId={`group-enabled-${row.localId}`}
/>
</div>
),
},
];
}

View File

@@ -1,132 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Switch } from '@signozhq/ui/switch';
import { Pencil, Trash2 } from '@signozhq/icons';
import type { TableColumnDef } from 'components/TanStackTableView';
import cx from 'classnames';
import styles from './LLMObservabilityAttributeMapping.module.scss';
import { DraftMapper, FieldContext } from './types';
const MAX_VISIBLE_SOURCES = 3;
interface ColumnsConfig {
onEdit: (mapper: DraftMapper) => void;
onRemove: (localId: string) => void;
onToggle: (localId: string, enabled: boolean) => void;
}
// Column definitions for the per-group mappers TanStackTable (rendered inside an
// expanded group row). Sorting is off — priority order is positional (top wins).
export function getMappersColumns({
onEdit,
onRemove,
onToggle,
}: ColumnsConfig): TableColumnDef<DraftMapper>[] {
return [
{
id: 'target',
header: 'Target',
accessorFn: (row): string => row.name,
width: { min: 200, default: '100%' },
enableMove: false,
cell: ({ row }): JSX.Element => (
<span
className={styles.mappersTableTarget}
data-testid={`mapper-target-${row.localId}`}
>
{row.name}
</span>
),
},
{
id: 'sources',
header: 'Sources',
width: { min: 220, default: '100%' },
enableMove: false,
cell: ({ row }): JSX.Element => {
// Skeleton placeholder rows reach the cell before real data, so
// `sources` can be undefined — default to empty.
const sources = row.sources ?? [];
if (sources.length === 0) {
return <span className={styles.muted}></span>;
}
const visible = sources.slice(0, MAX_VISIBLE_SOURCES);
const remaining = sources.length - visible.length;
return (
<div
className={styles.mappersTableSources}
data-testid={`mapper-sources-${row.localId}`}
>
{visible.map((source) => (
<span
className={styles.mappersTableSourceChip}
key={`${source.context}:${source.key}`}
title={source.key}
>
{source.key}
</span>
))}
{remaining > 0 && (
<span className={cx(styles.mappersTableSourceMore, styles.muted)}>
+{remaining} more
</span>
)}
</div>
);
},
},
{
id: 'writesTo',
header: 'Writes to',
width: { min: 130 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Badge
color={row.fieldContext === FieldContext.resource ? 'amber' : 'robin'}
variant="outline"
>
{row.fieldContext}
</Badge>
),
},
{
id: 'actions',
header: 'Actions',
// Compact, right-aligned action cluster — opt out of the "last column
// fills 100%" rule so the spare width flows into Target / Sources.
width: { fixed: '160px', ignoreLastColumnFill: true },
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element => (
<div className={styles.rowActions}>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Edit mapping"
onClick={(): void => onEdit(row)}
testId={`mapper-edit-${row.localId}`}
>
<Pencil size={14} />
</Button>
<Button
variant="ghost"
color="destructive"
size="icon"
aria-label="Delete mapping"
onClick={(): void => onRemove(row.localId)}
testId={`mapper-delete-${row.localId}`}
>
<Trash2 size={14} />
</Button>
<Switch
value={row.enabled}
onChange={(checked): void => onToggle(row.localId, checked)}
testId={`mapper-enabled-${row.localId}`}
/>
</div>
),
},
];
}

View File

@@ -1,190 +0,0 @@
import {
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DraftGroup, DraftMapper, SourceConfig } from './types';
import {
buildPostableGroup,
buildPostableMapper,
buildUpdatableGroup,
buildUpdatableMapper,
} from './utils';
// Thin persistence surface the store wires to the generated mutations.
// createGroup returns the new server id so its mappers can be created under it.
export interface SaveMutations {
createGroup: (data: SpantypesPostableSpanMapperGroupDTO) => Promise<string>;
updateGroup: (
groupId: string,
data: SpantypesUpdatableSpanMapperGroupDTO,
) => Promise<void>;
deleteGroup: (groupId: string) => Promise<void>;
createMapper: (
groupId: string,
data: SpantypesPostableSpanMapperDTO,
) => Promise<void>;
updateMapper: (
groupId: string,
mapperId: string,
data: SpantypesUpdatableSpanMapperDTO,
) => Promise<void>;
deleteMapper: (groupId: string, mapperId: string) => Promise<void>;
}
function arraysEqual(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index]);
}
function sourcesEqual(a: SourceConfig[], b: SourceConfig[]): boolean {
return (
a.length === b.length &&
a.every(
(source, index) =>
source.key === b[index].key &&
source.context === b[index].context &&
source.operation === b[index].operation,
)
);
}
function groupChanged(snapshot: DraftGroup, draft: DraftGroup): boolean {
return (
snapshot.name !== draft.name ||
snapshot.enabled !== draft.enabled ||
!arraysEqual(snapshot.attributes, draft.attributes) ||
!arraysEqual(snapshot.resource, draft.resource)
);
}
function mapperChanged(snapshot: DraftMapper, draft: DraftMapper): boolean {
return (
snapshot.enabled !== draft.enabled ||
snapshot.fieldContext !== draft.fieldContext ||
!sourcesEqual(snapshot.sources, draft.sources)
);
}
function groupDraftOf(
node: DraftGroup,
): Parameters<typeof buildPostableGroup>[0] {
return {
id: node.serverId,
name: node.name,
attributes: node.attributes,
resource: node.resource,
enabled: node.enabled,
};
}
function mapperDraftOf(
node: DraftMapper,
): Parameters<typeof buildPostableMapper>[0] {
return {
id: node.serverId,
name: node.name,
fieldContext: node.fieldContext,
sources: node.sources,
enabled: node.enabled,
};
}
async function persistMappers(
groupServerId: string,
snapshotMappers: DraftMapper[],
draftMappers: DraftMapper[],
m: SaveMutations,
): Promise<void> {
const snapById = new Map(
snapshotMappers
.filter((mapper) => mapper.serverId)
.map((mapper) => [mapper.serverId as string, mapper]),
);
const draftServerIds = new Set(
draftMappers
.filter((mapper) => mapper.serverId)
.map((mapper) => mapper.serverId as string),
);
// Deleted mappers.
await Promise.all(
snapshotMappers
.filter((mapper) => mapper.serverId && !draftServerIds.has(mapper.serverId))
.map((mapper) => m.deleteMapper(groupServerId, mapper.serverId as string)),
);
// Created + updated mappers (sequential to keep ordering deterministic).
for (const mapper of draftMappers) {
if (!mapper.serverId) {
// eslint-disable-next-line no-await-in-loop
await m.createMapper(
groupServerId,
buildPostableMapper(mapperDraftOf(mapper)),
);
} else {
const snap = snapById.get(mapper.serverId);
if (!snap || mapperChanged(snap, mapper)) {
// eslint-disable-next-line no-await-in-loop
await m.updateMapper(
groupServerId,
mapper.serverId,
buildUpdatableMapper(mapperDraftOf(mapper)),
);
}
}
}
}
// Diffs the staged tree against the server snapshot and issues the minimal set
// of create/update/delete calls to reconcile them.
export async function persistDraft(
snapshot: DraftGroup[],
draft: DraftGroup[],
m: SaveMutations,
): Promise<void> {
const snapById = new Map(
snapshot
.filter((group) => group.serverId)
.map((group) => [group.serverId as string, group]),
);
const draftServerIds = new Set(
draft
.filter((group) => group.serverId)
.map((group) => group.serverId as string),
);
// Apply additive work (creates/updates) before deletes, so a failure here
// leaves at worst an incomplete set of additions rather than groups that
// were deleted with no replacement persisted. Deletes are irreversible
// (they cascade mappers server-side), so we do them last, once everything
// else has succeeded.
for (const group of draft) {
if (!group.serverId) {
// eslint-disable-next-line no-await-in-loop
const newId = await m.createGroup(buildPostableGroup(groupDraftOf(group)));
// eslint-disable-next-line no-await-in-loop
await persistMappers(newId, [], group.mappers, m);
continue;
}
const snap = snapById.get(group.serverId);
if (!snap || groupChanged(snap, group)) {
// eslint-disable-next-line no-await-in-loop
await m.updateGroup(
group.serverId,
buildUpdatableGroup(groupDraftOf(group)),
);
}
// eslint-disable-next-line no-await-in-loop
await persistMappers(group.serverId, snap?.mappers ?? [], group.mappers, m);
}
// Deleted groups (cascades mappers server-side).
await Promise.all(
snapshot
.filter((group) => group.serverId && !draftServerIds.has(group.serverId))
.map((group) => m.deleteGroup(group.serverId as string)),
);
}

View File

@@ -1,135 +0,0 @@
import {
SpantypesPostableSpanMapperTestDTO,
SpantypesPostableSpanMapperTestGroupDTO,
SpantypesSpanMapperTestSpanDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DraftGroup } from './types';
import {
buildPostableGroup,
buildPostableMapper,
groupDraftFromNode,
mapperDraftFromNode,
} from './utils';
// A group's mappers must be sent in full when the group is new (the backend has
// no saved group of that name to backfill from) or when its mapper set has
// diverged from the saved baseline. Otherwise we omit them and let the backend
// load the saved mappers by group name — which also covers groups whose rows
// were never expanded (their draft carries no mappers yet).
function shouldSendMappers(
snap: DraftGroup | undefined,
group: DraftGroup,
): boolean {
if (!snap) {
return true;
}
return JSON.stringify(snap.mappers) !== JSON.stringify(group.mappers);
}
// Builds the `groups` portion of the test request from the working draft,
// sending each group's name/enabled/condition from the current draft and its
// `mappers` only when they changed (null otherwise → backend backfills).
export function buildTestGroups(
snapshot: DraftGroup[],
draft: DraftGroup[],
): SpantypesPostableSpanMapperTestGroupDTO[] {
const snapById = new Map(
snapshot
.filter((group) => group.serverId)
.map((group) => [group.serverId as string, group]),
);
return draft.map((group) => {
const base = buildPostableGroup(groupDraftFromNode(group));
const snap = group.serverId ? snapById.get(group.serverId) : undefined;
return {
...base,
mappers: shouldSendMappers(snap, group)
? group.mappers.map((mapper) =>
buildPostableMapper(mapperDraftFromNode(mapper)),
)
: null,
};
});
}
// Parses the pasted JSON into a single test span. The pasted object is treated
// as the span's attribute map (matching the sample shown to the user); resource
// is left empty. Throws a friendly error on anything that isn't a JSON object.
export function parseSpanInput(input: string): SpantypesSpanMapperTestSpanDTO {
const trimmed = input.trim();
if (!trimmed) {
throw new Error('Paste a JSON span object to run the test.');
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
throw new Error(
'Invalid JSON — check for trailing commas or missing quotes.',
);
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Span must be a JSON object of attribute key-value pairs.');
}
return { attributes: parsed as Record<string, unknown>, resource: {} };
}
export function buildTestRequest(
snapshot: DraftGroup[],
draft: DraftGroup[],
input: string,
): SpantypesPostableSpanMapperTestDTO {
return {
groups: buildTestGroups(snapshot, draft),
spans: [parseSpanInput(input)],
};
}
export type AttrChangeStatus = 'added' | 'changed' | 'unchanged' | 'removed';
export interface AttrDiffEntry {
key: string;
value: unknown;
status: AttrChangeStatus;
}
function valuesEqual(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
// Diffs the span attributes a user pasted against the attributes the mappers
// produced, so the UI can highlight which target keys got populated ('added'),
// which source keys were consumed by a move ('removed'), and what stayed.
// Added keys (the populated targets) sort first as the primary signal.
export function diffSpanAttributes(
inputAttributes: Record<string, unknown>,
resultAttributes: Record<string, unknown>,
): AttrDiffEntry[] {
const added: AttrDiffEntry[] = [];
const changed: AttrDiffEntry[] = [];
const unchanged: AttrDiffEntry[] = [];
const removed: AttrDiffEntry[] = [];
Object.entries(resultAttributes).forEach(([key, value]) => {
if (!(key in inputAttributes)) {
added.push({ key, value, status: 'added' });
} else if (!valuesEqual(inputAttributes[key], value)) {
changed.push({ key, value, status: 'changed' });
} else {
unchanged.push({ key, value, status: 'unchanged' });
}
});
Object.entries(inputAttributes).forEach(([key, value]) => {
if (!(key in resultAttributes)) {
removed.push({ key, value, status: 'removed' });
}
});
return [...added, ...changed, ...unchanged, ...removed];
}

View File

@@ -1,84 +0,0 @@
import {
SpantypesFieldContextDTO,
SpantypesSpanMapperConfigDTO,
SpantypesSpanMapperDTO,
SpantypesSpanMapperGroupConditionDTO,
SpantypesSpanMapperGroupDTO,
SpantypesSpanMapperOperationDTO,
SpantypesSpanMapperSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
export type MapperGroup = SpantypesSpanMapperGroupDTO;
export type MapperGroupCondition =
NonNullable<SpantypesSpanMapperGroupConditionDTO>;
export type Mapper = SpantypesSpanMapperDTO;
export type MapperConfig = SpantypesSpanMapperConfigDTO;
export type MapperSource = SpantypesSpanMapperSourceDTO;
export const FieldContext = SpantypesFieldContextDTO;
export const MapperOperation = SpantypesSpanMapperOperationDTO;
export type FieldContextValue = SpantypesFieldContextDTO;
export type MapperOperationValue = SpantypesSpanMapperOperationDTO;
// A single human-readable condition clause shown in the group's Filters column.
export interface ConditionFilter {
context: 'attribute' | 'resource';
key: string;
}
export type MapperDraftMode = 'add' | 'edit';
// One source candidate. `context` is where the key is read from (span
// attribute or resource); `operation` is move (delete source) or copy (keep).
// Priority is implicit in list order (top wins), derived on save.
export interface SourceConfig {
key: string;
context: SpantypesFieldContextDTO;
operation: SpantypesSpanMapperOperationDTO;
}
// Editable form state for a mapper. `sources` is ordered highest priority
// first; `fieldContext` is where the standardized target is written.
export interface MapperDraft {
id: string | null;
name: string;
fieldContext: SpantypesFieldContextDTO;
sources: SourceConfig[];
enabled: boolean;
}
// Editable form state for a group. The group runs when a span carries a
// span-attribute key matching `attributes` OR a resource key matching
// `resource` (plain substring match).
export interface GroupDraft {
id: string | null;
name: string;
attributes: string[];
resource: string[];
enabled: boolean;
}
// Working-copy node for a mapper. `localId` is a stable client key (the server
// id once persisted, or a temporary id for not-yet-saved rows). `serverId` is
// null until the row has been persisted.
export interface DraftMapper {
localId: string;
serverId: string | null;
name: string;
fieldContext: SpantypesFieldContextDTO;
sources: SourceConfig[];
enabled: boolean;
}
// Working-copy node for a group, holding its mappers inline so the whole tree
// can be staged locally and diffed against the server snapshot on save.
export interface DraftGroup {
localId: string;
serverId: string | null;
name: string;
attributes: string[];
resource: string[];
enabled: boolean;
mappers: DraftMapper[];
}

View File

@@ -1,318 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
useCreateSpanMapper,
useCreateSpanMapperGroup,
useDeleteSpanMapper,
useDeleteSpanMapperGroup,
useListSpanMapperGroups,
useUpdateSpanMapper,
useUpdateSpanMapperGroup,
} from 'api/generated/services/spanmapper';
import { persistDraft, SaveMutations } from './saveDraft';
import {
DraftGroup,
GroupDraft,
Mapper,
MapperDraft,
MapperGroup,
} from './types';
import {
buildDraftGroup,
buildDraftMapper,
nodeFromGroupDraft,
nodeFromMapperDraft,
} from './utils';
const GROUPS_KEY_PREFIX = '/api/v1/span_mapper_groups';
function clone(groups: DraftGroup[]): DraftGroup[] {
return JSON.parse(JSON.stringify(groups)) as DraftGroup[];
}
export interface AttributeMappingStore {
groups: DraftGroup[];
// The last-saved server baseline the working `groups` are diffed against.
// Exposed so the Test tab can send only the groups whose mappers changed.
snapshot: DraftGroup[];
isLoading: boolean;
isError: boolean;
isDirty: boolean;
isSaving: boolean;
saveError: string | null;
upsertGroup: (draft: GroupDraft) => void;
removeGroup: (localId: string) => void;
toggleGroup: (localId: string, enabled: boolean) => void;
hydrateGroupMappers: (groupServerId: string, mappers: Mapper[]) => void;
upsertMapper: (groupLocalId: string, draft: MapperDraft) => void;
removeMapper: (groupLocalId: string, mapperLocalId: string) => void;
toggleMapper: (
groupLocalId: string,
mapperLocalId: string,
enabled: boolean,
) => void;
save: () => Promise<void>;
discard: () => void;
}
export function useAttributeMappingStore(): AttributeMappingStore {
const queryClient = useQueryClient();
const groupsQuery = useListSpanMapperGroups();
const serverGroups: MapperGroup[] = useMemo(
() => groupsQuery.data?.data?.items ?? [],
[groupsQuery.data],
);
const ready = !groupsQuery.isLoading;
// A group's mappers are fetched lazily when its row is first expanded (see
// MappersTable -> hydrateGroupMappers) and cached here, keyed by group server
// id. Page load stays a single groups request — never an N+1 fan-out across
// every group.
const [loadedMappers, setLoadedMappers] = useState<Record<string, Mapper[]>>(
{},
);
const loadedRef = useRef<Set<string>>(new Set());
const snapshot = useMemo<DraftGroup[]>(() => {
if (!ready) {
return [];
}
return serverGroups.map((group) =>
buildDraftGroup(group, loadedMappers[group.id] ?? []),
);
}, [ready, serverGroups, loadedMappers]);
const [draft, setDraft] = useState<DraftGroup[] | null>(null);
// Initialise the working copy once data is ready (and re-init after a save
// clears it). Never clobbers in-flight edits — only runs when draft is null.
useEffect(() => {
if (ready && draft === null) {
setDraft(clone(snapshot));
}
}, [ready, draft, snapshot]);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { mutateAsync: createGroup } = useCreateSpanMapperGroup();
const { mutateAsync: updateGroup } = useUpdateSpanMapperGroup();
const { mutateAsync: deleteGroup } = useDeleteSpanMapperGroup();
const { mutateAsync: createMapper } = useCreateSpanMapper();
const { mutateAsync: updateMapper } = useUpdateSpanMapper();
const { mutateAsync: deleteMapper } = useDeleteSpanMapper();
const mutations: SaveMutations = useMemo(
() => ({
createGroup: async (data): Promise<string> => {
const result = await createGroup({ data });
return result.data.id;
},
updateGroup: async (groupId, data): Promise<void> => {
await updateGroup({ pathParams: { groupId }, data });
},
deleteGroup: async (groupId): Promise<void> => {
await deleteGroup({ pathParams: { groupId } });
},
createMapper: async (groupId, data): Promise<void> => {
await createMapper({ pathParams: { groupId }, data });
},
updateMapper: async (groupId, mapperId, data): Promise<void> => {
await updateMapper({ pathParams: { groupId, mapperId }, data });
},
deleteMapper: async (groupId, mapperId): Promise<void> => {
await deleteMapper({ pathParams: { groupId, mapperId } });
},
}),
[
createGroup,
updateGroup,
deleteGroup,
createMapper,
updateMapper,
deleteMapper,
],
);
const upsertGroup = useCallback((groupDraft: GroupDraft): void => {
setDraft((prev) => {
const groups = prev ?? [];
if (groupDraft.id) {
return groups.map((group) =>
group.localId === groupDraft.id
? nodeFromGroupDraft(groupDraft, group)
: group,
);
}
return [...groups, nodeFromGroupDraft(groupDraft)];
});
}, []);
const removeGroup = useCallback((localId: string): void => {
setDraft((prev) => (prev ?? []).filter((group) => group.localId !== localId));
}, []);
const toggleGroup = useCallback((localId: string, enabled: boolean): void => {
setDraft((prev) =>
(prev ?? []).map((group) =>
group.localId === localId ? { ...group, enabled } : group,
),
);
}, []);
// Fold a group's freshly-fetched server mappers into both the snapshot
// baseline and the working draft, exactly once per group (the guard skips
// re-expands). The "Add mapping" affordance renders while the fetch is still
// in flight, so a user can stage a new mapper (serverId === null) before the
// server list arrives — those unsaved rows are preserved, with the server
// mappers folded in ahead of them, so a racing add isn't clobbered.
const hydrateGroupMappers = useCallback(
(groupServerId: string, mappers: Mapper[]): void => {
if (loadedRef.current.has(groupServerId)) {
return;
}
loadedRef.current.add(groupServerId);
setLoadedMappers((prev) => ({ ...prev, [groupServerId]: mappers }));
setDraft((prev) =>
prev === null
? prev
: prev.map((group) =>
group.serverId === groupServerId
? {
...group,
mappers: [
...mappers.map(buildDraftMapper),
...group.mappers.filter((mapper) => mapper.serverId === null),
],
}
: group,
),
);
},
[],
);
const upsertMapper = useCallback(
(groupLocalId: string, mapperDraft: MapperDraft): void => {
setDraft((prev) =>
(prev ?? []).map((group) => {
if (group.localId !== groupLocalId) {
return group;
}
if (mapperDraft.id) {
return {
...group,
mappers: group.mappers.map((mapper) =>
mapper.localId === mapperDraft.id
? nodeFromMapperDraft(mapperDraft, mapper)
: mapper,
),
};
}
return {
...group,
mappers: [...group.mappers, nodeFromMapperDraft(mapperDraft)],
};
}),
);
},
[],
);
const removeMapper = useCallback(
(groupLocalId: string, mapperLocalId: string): void => {
setDraft((prev) =>
(prev ?? []).map((group) =>
group.localId === groupLocalId
? {
...group,
mappers: group.mappers.filter(
(mapper) => mapper.localId !== mapperLocalId,
),
}
: group,
),
);
},
[],
);
const toggleMapper = useCallback(
(groupLocalId: string, mapperLocalId: string, enabled: boolean): void => {
setDraft((prev) =>
(prev ?? []).map((group) =>
group.localId === groupLocalId
? {
...group,
mappers: group.mappers.map((mapper) =>
mapper.localId === mapperLocalId ? { ...mapper, enabled } : mapper,
),
}
: group,
),
);
},
[],
);
const discard = useCallback((): void => {
setSaveError(null);
setDraft(clone(snapshot));
}, [snapshot]);
const save = useCallback(async (): Promise<void> => {
if (!draft) {
return;
}
setIsSaving(true);
setSaveError(null);
try {
await persistDraft(snapshot, draft, mutations);
await queryClient.invalidateQueries({
predicate: (query) =>
typeof query.queryKey?.[0] === 'string' &&
(query.queryKey[0] as string).startsWith(GROUPS_KEY_PREFIX),
});
// Drop lazily-loaded mappers and re-initialise the working copy from the
// freshly-fetched server data; expanded rows re-hydrate on next render.
loadedRef.current = new Set();
setLoadedMappers({});
setDraft(null);
toast.success('Attribute mapping changes saved');
} catch (error) {
const message = error instanceof Error ? error.message : 'Save failed';
setSaveError(message);
toast.error(`Failed to save changes: ${message}`);
} finally {
setIsSaving(false);
}
}, [draft, snapshot, mutations, queryClient]);
const isDirty = useMemo(
() => draft !== null && JSON.stringify(draft) !== JSON.stringify(snapshot),
[draft, snapshot],
);
return {
groups: draft ?? [],
snapshot,
isLoading: !ready || draft === null,
isError: groupsQuery.isError,
isDirty,
isSaving,
saveError,
upsertGroup,
removeGroup,
toggleGroup,
hydrateGroupMappers,
upsertMapper,
removeMapper,
toggleMapper,
save,
discard,
};
}

View File

@@ -1,40 +0,0 @@
import { useCallback, useState } from 'react';
import { DraftGroup, GroupDraft, MapperDraftMode } from './types';
import { EMPTY_GROUP_DRAFT, groupDraftFromNode } from './utils';
interface UseGroupFormDrawer {
isOpen: boolean;
mode: MapperDraftMode;
draft: GroupDraft;
setDraft: (next: GroupDraft) => void;
openForAdd: () => void;
openForEdit: (group: DraftGroup) => void;
close: () => void;
}
// Form state for the group drawer. Persistence is staged through the store,
// so this hook only owns open/draft/mode.
export function useGroupFormDrawer(): UseGroupFormDrawer {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [mode, setMode] = useState<MapperDraftMode>('add');
const [draft, setDraft] = useState<GroupDraft>(EMPTY_GROUP_DRAFT);
const openForAdd = useCallback((): void => {
setMode('add');
setDraft(EMPTY_GROUP_DRAFT);
setIsOpen(true);
}, []);
const openForEdit = useCallback((group: DraftGroup): void => {
setMode('edit');
setDraft(groupDraftFromNode(group));
setIsOpen(true);
}, []);
const close = useCallback((): void => {
setIsOpen(false);
}, []);
return { isOpen, mode, draft, setDraft, openForAdd, openForEdit, close };
}

View File

@@ -1,40 +0,0 @@
import { useCallback, useState } from 'react';
import { DraftMapper, MapperDraft, MapperDraftMode } from './types';
import { EMPTY_MAPPER_DRAFT, mapperDraftFromNode } from './utils';
interface UseMapperFormDrawerResult {
isOpen: boolean;
mode: MapperDraftMode;
draft: MapperDraft;
setDraft: (next: MapperDraft) => void;
openForAdd: () => void;
openForEdit: (mapper: DraftMapper) => void;
close: () => void;
}
// Form state for the mapper drawer. Persistence is staged through the store,
// so this hook only owns open/draft/mode.
export function useMapperFormDrawer(): UseMapperFormDrawerResult {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [mode, setMode] = useState<MapperDraftMode>('add');
const [draft, setDraft] = useState<MapperDraft>(EMPTY_MAPPER_DRAFT);
const openForAdd = useCallback((): void => {
setMode('add');
setDraft(EMPTY_MAPPER_DRAFT);
setIsOpen(true);
}, []);
const openForEdit = useCallback((mapper: DraftMapper): void => {
setMode('edit');
setDraft(mapperDraftFromNode(mapper));
setIsOpen(true);
}, []);
const close = useCallback((): void => {
setIsOpen(false);
}, []);
return { isOpen, mode, draft, setDraft, openForAdd, openForEdit, close };
}

View File

@@ -1,109 +0,0 @@
import { useCallback, useState } from 'react';
import {
RenderErrorResponseDTO,
SpantypesSpanMapperTestSpanDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useTestSpanMappers } from 'api/generated/services/spanmapper';
import { AxiosError } from 'axios';
import { buildTestRequest } from './testPayload';
import { DraftGroup } from './types';
// Pre-filled sample so the tab is runnable on first open (mirrors the design).
export const SAMPLE_SPAN_JSON = `{
"my_company.llm.input": "What is quantum computing?",
"llm.input_messages": "What is quantum computing?",
"gen_ai.request.model": "gpt-4",
"gen_ai.usage.total_tokens": 1250,
"gen_ai.content.completion": "Quantum computing leverages..."
}`;
function apiErrorMessage(error: unknown): string {
const axiosError = error as AxiosError<RenderErrorResponseDTO>;
return (
axiosError?.response?.data?.error?.message ??
(error instanceof Error ? error.message : 'Test failed. Please try again.')
);
}
export interface UseTestSpanMapper {
input: string;
setInput: (value: string) => void;
run: () => void;
reset: () => void;
isRunning: boolean;
result: SpantypesSpanMapperTestSpanDTO[] | null;
// The attributes that were actually submitted with the last successful run,
// so the result diff stays stable even if the textarea is edited afterwards.
testedAttributes: Record<string, unknown> | null;
error: string | null;
}
// Owns the Test tab's local state: the pasted span JSON, the parsed/built
// request, and the result/error of the test mutation. Builds the request from
// the working draft (sending only changed groups' mappers — see testPayload).
export function useTestSpanMapper(
snapshot: DraftGroup[],
draft: DraftGroup[],
): UseTestSpanMapper {
const [input, setInput] = useState<string>(SAMPLE_SPAN_JSON);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<SpantypesSpanMapperTestSpanDTO[] | null>(
null,
);
const [testedAttributes, setTestedAttributes] = useState<Record<
string,
unknown
> | null>(null);
const { mutate, isLoading } = useTestSpanMappers();
const run = useCallback((): void => {
setError(null);
let body;
try {
body = buildTestRequest(snapshot, draft, input);
} catch (parseError) {
setResult(null);
setError(apiErrorMessage(parseError));
return;
}
const submittedAttributes = (body.spans?.[0]?.attributes ?? {}) as Record<
string,
unknown
>;
mutate(
{ data: body },
{
onSuccess: (response) => {
setTestedAttributes(submittedAttributes);
setResult(response.data?.spans ?? []);
},
onError: (mutationError) => {
setResult(null);
setError(apiErrorMessage(mutationError));
},
},
);
}, [snapshot, draft, input, mutate]);
const reset = useCallback((): void => {
setError(null);
setResult(null);
setTestedAttributes(null);
}, []);
return {
input,
setInput,
run,
reset,
isRunning: isLoading,
result,
testedAttributes,
error,
};
}

View File

@@ -1,264 +0,0 @@
import {
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { v4 as uuid } from 'uuid';
import {
ConditionFilter,
DraftGroup,
DraftMapper,
FieldContext,
GroupDraft,
Mapper,
MapperDraft,
MapperGroup,
MapperOperation,
SourceConfig,
} from './types';
// Client-side id for not-yet-persisted rows. Prefixed so it never collides
// with a server UUID and is easy to spot in logs.
export function genLocalId(prefix: 'group' | 'mapper'): string {
return `local-${prefix}-${uuid()}`;
}
// Trimmed, de-duplicated, non-empty keys preserving input order.
export function cleanKeys(keys: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
keys.forEach((raw) => {
const key = raw.trim();
if (key && !seen.has(key)) {
seen.add(key);
result.push(key);
}
});
return result;
}
// Display clauses for a group's condition keys (span attribute keys first,
// then resource keys).
export function conditionFiltersFromGroup(group: {
attributes?: string[];
resource?: string[];
}): ConditionFilter[] {
// TanStackTable renders skeleton placeholder rows through the cells on first
// render, so these arrays can be undefined before real data lands — default
// to empty rather than crashing the cell.
return [
...(group.attributes ?? []).map((key) => ({
context: 'attribute' as const,
key,
})),
...(group.resource ?? []).map((key) => ({
context: 'resource' as const,
key,
})),
];
}
// Source configs for a mapper, highest priority first (first match wins at
// evaluation time).
export function getMapperSources(mapper: Mapper): SourceConfig[] {
const sources = mapper.config?.sources ?? [];
return [...sources]
.sort((a, b) => b.priority - a.priority)
.map((source) => ({
key: source.key,
context: source.context,
operation: source.operation,
}));
}
export function createEmptySource(): SourceConfig {
return {
key: '',
context: FieldContext.attribute,
operation: MapperOperation.copy,
};
}
export const EMPTY_MAPPER_DRAFT: MapperDraft = {
id: null,
name: '',
fieldContext: FieldContext.attribute,
sources: [createEmptySource()],
enabled: true,
};
// Trimmed, de-duplicated (by context+key), non-empty sources in priority order,
// preserving each source's context and operation. A key identifies a different
// source per context (span attribute vs resource), so both can coexist; only an
// exact (context, key) repeat is collapsed, keeping the higher-priority row.
export function getCleanSources(draft: MapperDraft): SourceConfig[] {
const seen = new Set<string>();
const result: SourceConfig[] = [];
draft.sources.forEach((source) => {
const key = source.key.trim();
const dedupeKey = `${source.context}:${key}`;
if (key && !seen.has(dedupeKey)) {
seen.add(dedupeKey);
result.push({ ...source, key });
}
});
return result;
}
export function isMapperDraftValid(draft: MapperDraft): boolean {
return draft.name.trim().length > 0 && getCleanSources(draft).length > 0;
}
// Priority is derived from list order so the first row wins.
function buildSources(
draft: MapperDraft,
): SpantypesPostableSpanMapperDTO['config']['sources'] {
const sources = getCleanSources(draft);
return sources.map((source, index) => ({
key: source.key,
context: source.context,
operation: source.operation,
priority: sources.length - index,
}));
}
export function buildPostableMapper(
draft: MapperDraft,
): SpantypesPostableSpanMapperDTO {
return {
name: draft.name.trim(),
fieldContext: draft.fieldContext,
enabled: draft.enabled,
config: { sources: buildSources(draft) },
};
}
// The target name is immutable on update (UpdatableSpanMapper has no name).
export function buildUpdatableMapper(
draft: MapperDraft,
): SpantypesUpdatableSpanMapperDTO {
return {
fieldContext: draft.fieldContext,
enabled: draft.enabled,
config: { sources: buildSources(draft) },
};
}
export const EMPTY_GROUP_DRAFT: GroupDraft = {
id: null,
name: '',
attributes: [''],
resource: [],
enabled: true,
};
export function isGroupDraftValid(draft: GroupDraft): boolean {
return draft.name.trim().length > 0;
}
export function buildPostableGroup(
draft: GroupDraft,
): SpantypesPostableSpanMapperGroupDTO {
return {
name: draft.name.trim(),
enabled: draft.enabled,
condition: {
attributes: cleanKeys(draft.attributes),
resource: cleanKeys(draft.resource),
},
};
}
// A full group payload is also a valid partial-update payload (all updatable
// fields are present), so we reuse the postable builder.
export function buildUpdatableGroup(
draft: GroupDraft,
): SpantypesUpdatableSpanMapperGroupDTO {
return buildPostableGroup(draft);
}
// ---- working-copy (draft tree) helpers ----
export function buildDraftMapper(mapper: Mapper): DraftMapper {
return {
localId: mapper.id,
serverId: mapper.id,
name: mapper.name,
fieldContext: mapper.fieldContext,
sources: getMapperSources(mapper),
enabled: mapper.enabled,
};
}
export function buildDraftGroup(
group: MapperGroup,
mappers: Mapper[],
): DraftGroup {
return {
localId: group.id,
serverId: group.id,
name: group.name,
attributes: group.condition?.attributes ?? [],
resource: group.condition?.resource ?? [],
enabled: group.enabled,
mappers: mappers.map(buildDraftMapper),
};
}
// DraftGroup -> editable form state (id carries the localId).
export function groupDraftFromNode(group: DraftGroup): GroupDraft {
return {
id: group.localId,
name: group.name,
attributes: group.attributes.length > 0 ? group.attributes : [''],
resource: group.resource,
enabled: group.enabled,
};
}
// DraftMapper -> editable form state (id carries the localId).
export function mapperDraftFromNode(mapper: DraftMapper): MapperDraft {
return {
id: mapper.localId,
name: mapper.name,
fieldContext: mapper.fieldContext,
sources:
mapper.sources.length > 0
? mapper.sources.map((source) => ({ ...source }))
: [createEmptySource()],
enabled: mapper.enabled,
};
}
// Form state -> working-copy node. Reuses cleanKeys/getCleanSourceKeys so the
// staged tree already holds normalized values.
export function nodeFromGroupDraft(
draft: GroupDraft,
existing?: DraftGroup,
): DraftGroup {
return {
localId: existing?.localId ?? genLocalId('group'),
serverId: existing?.serverId ?? null,
name: draft.name.trim(),
attributes: cleanKeys(draft.attributes),
resource: cleanKeys(draft.resource),
enabled: draft.enabled,
mappers: existing?.mappers ?? [],
};
}
export function nodeFromMapperDraft(
draft: MapperDraft,
existing?: DraftMapper,
): DraftMapper {
return {
localId: existing?.localId ?? genLocalId('mapper'),
serverId: existing?.serverId ?? null,
name: draft.name.trim(),
fieldContext: draft.fieldContext,
sources: getCleanSources(draft),
enabled: draft.enabled,
};
}

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

@@ -21,6 +21,7 @@ import AllAttributes from './AllAttributes';
import DashboardsAndAlertsPopover from './DashboardsAndAlertsPopover';
import Highlights from './Highlights';
import Metadata from './Metadata';
import VolumeControlSection from './VolumeControl/VolumeControlSection';
import { MetricDetailsProps } from './types';
import { getMetricDetailsQuery } from './utils';
@@ -190,6 +191,7 @@ function MetricDetails({
isLoadingMetricMetadata={isLoadingMetricMetadata}
refetchMetricMetadata={refetchMetricMetadata}
/>
<VolumeControlSection metricName={metricName} />
<AllAttributes
metricName={metricName}
metricType={metadata?.type}

View File

@@ -0,0 +1,64 @@
import { Typography } from '@signozhq/ui/typography';
import { Spin } from 'antd';
import { MetricreductionruletypesGettableReductionRulePreviewDTO } from 'api/generated/services/sigNoz.schemas';
import { formatCompact } from './configUtils';
import { RuleMode } from './types';
import styles from './VolumeControlConfig.module.scss';
interface ImpactPanelProps {
mode: RuleMode;
preview?: MetricreductionruletypesGettableReductionRulePreviewDTO;
isLoading: boolean;
}
function ImpactPanel({
mode,
preview,
isLoading,
}: ImpactPanelProps): JSX.Element {
if (mode === 'all') {
return (
<div className={styles.impact} data-testid="volume-control-impact">
<Typography.Text className={styles.impactNote}>
All attributes remain queryable, no reduction.
</Typography.Text>
</div>
);
}
return (
<div className={styles.impact} data-testid="volume-control-impact">
{isLoading && <Spin size="small" />}
{!isLoading && preview && (
<div className={styles.meters}>
<div className={styles.meter}>
<span className={styles.meterLabel}>Ingested series</span>
<span className={styles.meterValue}>
{formatCompact(preview.ingestedSeries)}
</span>
</div>
<div className={styles.meter}>
<span className={styles.meterLabel}>Reduced series</span>
<span className={styles.meterValue}>
{formatCompact(preview.reducedSeries)}
</span>
</div>
<div className={styles.meter}>
<span className={styles.meterLabel}>Reduction</span>
<span className={`${styles.meterValue} ${styles.meterValueGood}`}>
{Math.round(preview.reductionPercent)}%
</span>
</div>
</div>
)}
{!isLoading && !preview && (
<Typography.Text className={styles.impactNote}>
Select attributes to preview the impact.
</Typography.Text>
)}
</div>
);
}
export default ImpactPanel;

View File

@@ -0,0 +1,47 @@
import { Typography } from '@signozhq/ui/typography';
import { Select } from 'antd';
import { popupContainer } from 'utils/selectPopupContainer';
import { RuleMode } from './types';
import styles from './VolumeControlConfig.module.scss';
interface LabelSelectorProps {
mode: RuleMode;
options: string[];
value: string[];
onChange: (labels: string[]) => void;
loading?: boolean;
}
function LabelSelector({
mode,
options,
value,
onChange,
loading,
}: LabelSelectorProps): JSX.Element {
const helpText =
mode === 'include'
? 'Only the selected attributes will remain queryable.'
: 'The selected attributes will be aggregated away; all others stay queryable.';
return (
<div className={styles.field} data-testid="volume-control-label-selector">
<Typography.Text className={styles.fieldLabel}>Attributes</Typography.Text>
<Typography.Text className={styles.fieldHint}>{helpText}</Typography.Text>
<Select
mode="multiple"
className={styles.labelSelect}
placeholder="Select attributes"
value={value}
onChange={onChange}
loading={loading}
options={options.map((key) => ({ label: key, value: key }))}
getPopupContainer={popupContainer}
data-testid="volume-control-label-select"
/>
</div>
);
}
export default LabelSelector;

View File

@@ -0,0 +1,60 @@
import { Typography } from '@signozhq/ui/typography';
import { RuleMode } from './types';
import styles from './VolumeControlConfig.module.scss';
interface ModeOption {
mode: RuleMode;
title: string;
description: string;
}
const MODE_OPTIONS: ModeOption[] = [
{
mode: 'all',
title: 'Allow all attributes',
description: 'All attributes stay queryable. Removes any existing rule.',
},
{
mode: 'include',
title: 'Include attributes',
description: 'Allowlist: only the selected attributes stay queryable.',
},
{
mode: 'exclude',
title: 'Exclude attributes',
description: 'Blocklist: the selected attributes are aggregated away.',
},
];
interface ModeSelectorProps {
mode: RuleMode;
onChange: (mode: RuleMode) => void;
}
function ModeSelector({ mode, onChange }: ModeSelectorProps): JSX.Element {
return (
<div className={styles.modeCards} data-testid="volume-control-mode-selector">
{MODE_OPTIONS.map((option) => (
<button
type="button"
key={option.mode}
className={`${styles.modeCard} ${
mode === option.mode ? styles.modeCardActive : ''
}`}
onClick={(): void => onChange(option.mode)}
data-testid={`volume-control-mode-${option.mode}`}
>
<Typography.Text className={styles.modeTitle}>
{option.title}
</Typography.Text>
<Typography.Text className={styles.modeDesc}>
{option.description}
</Typography.Text>
</button>
))}
</div>
);
}
export default ModeSelector;

View File

@@ -0,0 +1,51 @@
import { Info } from '@signozhq/icons';
import { MetricreductionruletypesAffectedAssetDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './VolumeControlConfig.module.scss';
interface RelatedAssetsWarningProps {
affectedAssets?: MetricreductionruletypesAffectedAssetDTO[] | null;
}
function RelatedAssetsWarning({
affectedAssets,
}: RelatedAssetsWarningProps): JSX.Element | null {
const impacted = (affectedAssets ?? []).filter(
(asset) => (asset.impactedLabels ?? []).length > 0,
);
if (impacted.length === 0) {
return null;
}
const impactedLabels = Array.from(
new Set(impacted.flatMap((asset) => asset.impactedLabels ?? [])),
);
return (
<div className={styles.warning} data-testid="volume-control-warning">
<Info size={14} />
<div>
<div className={styles.warningTitle}>
This rule affects {impacted.length} related asset
{impacted.length > 1 ? 's' : ''}.
</div>
{impactedLabels.length > 0 && (
<div className={styles.warningDetail}>
{impactedLabels.join(', ')} will no longer be queryable; affected panels
fall back to aggregated data once the rule applies.
</div>
)}
<ul className={styles.assetList}>
{impacted.map((asset) => (
<li key={`${asset.type}-${asset.id}`}>
{asset.name}
{asset.widget ? ` · ${asset.widget}` : ''}
</li>
))}
</ul>
</div>
</div>
);
}
export default RelatedAssetsWarning;

View File

@@ -0,0 +1,172 @@
.body {
display: flex;
flex-direction: column;
gap: 20px;
padding: 4px 2px;
}
.title {
display: flex;
align-items: center;
gap: 10px;
}
.admin-tag {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.03em;
color: var(--bg-amber-400, #ffd778);
border: 1px solid var(--bg-amber-500, #ffcc56);
border-radius: 99px;
padding: 1px 8px;
}
/* mode cards */
.mode-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.mode-card {
display: flex;
flex-direction: column;
gap: 4px;
text-align: left;
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 6px;
background: var(--bg-ink-300, #16181d);
padding: 12px;
cursor: pointer;
transition:
border-color 0.12s ease,
background 0.12s ease;
}
.mode-card:hover {
border-color: var(--bg-slate-200, #2c3140);
}
.mode-card-active {
border-color: var(--bg-robin-500, #4e74f8);
background: rgba(78, 116, 248, 0.08);
}
.mode-title {
font-size: 12.5px;
font-weight: 600;
color: var(--bg-vanilla-100, #fff);
}
.mode-desc {
font-size: 11px;
color: var(--bg-vanilla-400, #c0c1c3);
line-height: 1.45;
}
/* label selector */
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-size: 12.5px;
font-weight: 600;
color: var(--bg-vanilla-100, #fff);
}
.field-hint {
font-size: 11px;
color: var(--bg-vanilla-400, #c0c1c3);
}
.label-select {
width: 100%;
margin-top: 4px;
}
/* impact panel */
.impact {
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 6px;
background: var(--bg-ink-300, #16181d);
padding: 14px;
}
.impact-note {
font-size: 12px;
color: var(--bg-vanilla-400, #c0c1c3);
}
.meters {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.meter {
display: flex;
flex-direction: column;
gap: 5px;
}
.meter-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--bg-vanilla-400, #c0c1c3);
}
.meter-value {
font-family: 'Geist Mono', monospace;
font-size: 18px;
color: var(--bg-vanilla-100, #fff);
}
.meter-value-good {
color: var(--bg-forest-400, #50e7a7);
}
/* related-asset warning */
.warning {
display: flex;
gap: 10px;
padding: 11px 13px;
border-radius: 6px;
background: rgba(255, 204, 86, 0.07);
border: 1px solid rgba(255, 204, 86, 0.3);
color: var(--bg-amber-400, #ffd778);
}
.warning-title {
font-size: 12px;
font-weight: 600;
color: var(--bg-vanilla-100, #fff);
}
.warning-detail {
font-size: 11.5px;
color: var(--bg-vanilla-300, #e9e9e9);
margin-top: 4px;
}
.asset-list {
margin: 6px 0 0;
padding-left: 16px;
font-size: 11.5px;
color: var(--bg-vanilla-300, #e9e9e9);
}
/* footer */
.footer {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.footer-spacer {
flex: 1;
}

View File

@@ -0,0 +1,115 @@
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { MetricreductionruletypesGettableReductionRuleDTO } from 'api/generated/services/sigNoz.schemas';
import ImpactPanel from './ImpactPanel';
import LabelSelector from './LabelSelector';
import ModeSelector from './ModeSelector';
import RelatedAssetsWarning from './RelatedAssetsWarning';
import { useVolumeControlConfig } from './useVolumeControlConfig';
import styles from './VolumeControlConfig.module.scss';
interface VolumeControlConfigDrawerProps {
metricName: string;
existingRule: MetricreductionruletypesGettableReductionRuleDTO | null;
open: boolean;
onClose: () => void;
}
function VolumeControlConfigDrawer({
metricName,
existingRule,
open,
onClose,
}: VolumeControlConfigDrawerProps): JSX.Element {
const {
mode,
setMode,
labels,
setLabels,
attributeKeys,
isLoadingAttributes,
preview,
isPreviewLoading,
save,
remove,
isSaving,
isRemoving,
hasExistingRule,
isSaveDisabled,
} = useVolumeControlConfig({ metricName, existingRule, open, onClose });
const footer = (
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
onClick={onClose}
data-testid="volume-control-cancel"
>
Cancel
</Button>
<div className={styles.footerSpacer} />
{hasExistingRule && (
<Button
variant="ghost"
color="destructive"
onClick={remove}
loading={isRemoving}
data-testid="volume-control-remove"
>
Remove rule
</Button>
)}
<Button
variant="solid"
color="primary"
onClick={save}
disabled={isSaveDisabled}
loading={isSaving}
data-testid="volume-control-save"
>
Save rule
</Button>
</div>
);
return (
<DrawerWrapper
open={open}
onOpenChange={(next: boolean): void => {
if (!next) {
onClose();
}
}}
title={`Manage attributes · ${metricName}`}
direction="right"
showCloseButton
width="wide"
footer={footer}
showOverlay={false}
className="volume-control-config-drawer"
style={{ zIndex: 1100 }}
>
<div className={styles.body} data-testid="volume-control-config-drawer">
<div className={styles.title}>
<span className={styles.adminTag}>Admin only</span>
</div>
<ModeSelector mode={mode} onChange={setMode} />
{mode !== 'all' && (
<LabelSelector
mode={mode}
options={attributeKeys}
value={labels}
onChange={setLabels}
loading={isLoadingAttributes}
/>
)}
<ImpactPanel mode={mode} preview={preview} isLoading={isPreviewLoading} />
<RelatedAssetsWarning affectedAssets={preview?.affectedAssets} />
</div>
</DrawerWrapper>
);
}
export default VolumeControlConfigDrawer;

View File

@@ -0,0 +1,114 @@
.section {
margin-top: 16px;
}
.header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
color: var(--bg-vanilla-400, #c0c1c3);
}
.title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.card {
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 6px;
padding: 12px 14px;
background: var(--bg-ink-300, #16181d);
}
.card-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex: 0 0 auto;
}
.status-active {
background: var(--bg-forest-500, #25e192);
}
.status-pending {
background: var(--bg-amber-500, #ffcc56);
}
.card-title {
font-weight: 600;
}
.mode {
display: block;
font-size: 12px;
color: var(--bg-vanilla-400, #c0c1c3);
margin-bottom: 8px;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
font-family: 'Geist Mono', monospace;
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
background: var(--bg-ink-200, #23262e);
border: 1px solid var(--bg-slate-400, #1d212d);
color: var(--bg-vanilla-100, #fff);
}
.empty {
display: flex;
flex-direction: column;
align-items: flex-start;
border: 1px dashed var(--bg-slate-300, #242834);
border-radius: 6px;
padding: 14px;
}
.empty-text {
font-size: 12px;
color: var(--bg-vanilla-400, #c0c1c3);
}
.setup-button {
margin-top: 12px;
}
.edit-button {
margin-left: auto;
}
.pending-banner {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 9px 12px;
margin-bottom: 10px;
border-radius: 6px;
background: rgba(35, 196, 248, 0.07);
border: 1px solid rgba(35, 196, 248, 0.25);
color: var(--bg-aqua-400, #4bcff9);
}
.pending-text {
font-size: 11.5px;
color: var(--bg-vanilla-300, #e9e9e9);
line-height: 1.45;
}

View File

@@ -0,0 +1,136 @@
import { Gauge, Info } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { Skeleton } from 'antd';
import { useGetMetricReductionRule } from 'api/generated/services/metrics';
import { useVolumeControlFeatureGate } from 'hooks/metricsExplorer/useVolumeControlFeatureGate';
import { useState } from 'react';
import { getLabelVerb, getMatchTypeLabel } from './utils';
import VolumeControlConfigDrawer from './VolumeControlConfigDrawer';
import styles from './VolumeControlSection.module.scss';
interface VolumeControlSectionProps {
metricName: string;
}
function VolumeControlSection({
metricName,
}: VolumeControlSectionProps): JSX.Element | null {
const { isVolumeControlEnabled, canManageVolumeControl } =
useVolumeControlFeatureGate();
const [isConfigOpen, setIsConfigOpen] = useState(false);
const { data, isLoading, error } = useGetMetricReductionRule(
{ metricName },
{
query: {
enabled: isVolumeControlEnabled && !!metricName,
retry: false,
},
},
);
if (!isVolumeControlEnabled) {
return null;
}
const rule = data?.data;
const hasRule = !!rule && !error;
const openConfig = (): void => setIsConfigOpen(true);
const closeConfig = (): void => setIsConfigOpen(false);
return (
<div className={styles.section} data-testid="volume-control-section">
<div className={styles.header}>
<Gauge size={14} />
<Typography.Text className={styles.title}>Volume control</Typography.Text>
</div>
{isLoading && <Skeleton active title={false} paragraph={{ rows: 2 }} />}
{!isLoading && hasRule && rule && !rule.active && (
<div
className={styles.pendingBanner}
data-testid="volume-control-pending-banner"
>
<Info size={13} />
<Typography.Text className={styles.pendingText}>
This metric&apos;s configuration was recently updated. Reduced volumes
will take effect within a few minutes.
</Typography.Text>
</div>
)}
{!isLoading && hasRule && rule && (
<div className={styles.card} data-testid="volume-control-active">
<div className={styles.cardRow}>
<span
className={`${styles.statusDot} ${
rule.active ? styles.statusActive : styles.statusPending
}`}
/>
<Typography.Text className={styles.cardTitle}>
{rule.active
? 'Aggregation rule active'
: 'Aggregation rule pending activation'}
</Typography.Text>
{canManageVolumeControl && (
<Button
variant="ghost"
color="secondary"
className={styles.editButton}
onClick={openConfig}
data-testid="volume-control-edit"
>
Edit
</Button>
)}
</div>
<Typography.Text className={styles.mode}>
{getMatchTypeLabel(rule.matchType)}
</Typography.Text>
<div className={styles.chips}>
{(rule.labels ?? []).map((label) => (
<span className={styles.chip} key={label}>
{getLabelVerb(rule.matchType)} {label}
</span>
))}
</div>
</div>
)}
{!isLoading && !hasRule && (
<div className={styles.empty} data-testid="volume-control-empty">
<Typography.Text className={styles.emptyText}>
No volume control rule. All series are retained. Aggregate away
high-cardinality attributes to reduce cost.
</Typography.Text>
{canManageVolumeControl && (
<Button
variant="solid"
color="primary"
className={styles.setupButton}
onClick={openConfig}
data-testid="volume-control-setup"
>
Set up volume control
</Button>
)}
</div>
)}
{canManageVolumeControl && isConfigOpen && (
<VolumeControlConfigDrawer
metricName={metricName}
existingRule={rule ?? null}
open={isConfigOpen}
onClose={closeConfig}
/>
)}
</div>
);
}
export default VolumeControlSection;

View File

@@ -0,0 +1,42 @@
import {
MetricreductionruletypesMatchTypeDTO,
MetricreductionruletypesGettableReductionRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { RuleMode } from './types';
export function modeFromRule(
rule: MetricreductionruletypesGettableReductionRuleDTO | null | undefined,
): { mode: RuleMode; labels: string[] } {
if (!rule) {
return { mode: 'all', labels: [] };
}
return {
mode:
rule.matchType === MetricreductionruletypesMatchTypeDTO.keep
? 'include'
: 'exclude',
labels: rule.labels ?? [],
};
}
export function matchTypeForMode(
mode: RuleMode,
): MetricreductionruletypesMatchTypeDTO {
return mode === 'include'
? MetricreductionruletypesMatchTypeDTO.keep
: MetricreductionruletypesMatchTypeDTO.drop;
}
export function formatCompact(value: number): string {
if (value >= 1e9) {
return `${(value / 1e9).toFixed(1)}B`;
}
if (value >= 1e6) {
return `${(value / 1e6).toFixed(1)}M`;
}
if (value >= 1e3) {
return `${(value / 1e3).toFixed(1)}K`;
}
return `${value}`;
}

View File

@@ -0,0 +1 @@
export type RuleMode = 'all' | 'include' | 'exclude';

View File

@@ -0,0 +1,182 @@
import {
invalidateGetMetricReductionRule,
invalidateListMetricReductionRules,
invalidateListMetrics,
useDeleteMetricReductionRule,
useGetMetricAttributes,
usePreviewMetricReductionRule,
useUpsertMetricReductionRule,
} from 'api/generated/services/metrics';
import {
MetricreductionruletypesGettableReductionRulePreviewDTO,
MetricreductionruletypesGettableReductionRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useNotifications } from 'hooks/useNotifications';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { matchTypeForMode, modeFromRule } from './configUtils';
import { RuleMode } from './types';
interface UseVolumeControlConfigParams {
metricName: string;
existingRule: MetricreductionruletypesGettableReductionRuleDTO | null;
open: boolean;
onClose: () => void;
}
export interface UseVolumeControlConfigResult {
mode: RuleMode;
setMode: (mode: RuleMode) => void;
labels: string[];
setLabels: (labels: string[]) => void;
attributeKeys: string[];
isLoadingAttributes: boolean;
preview?: MetricreductionruletypesGettableReductionRulePreviewDTO;
isPreviewLoading: boolean;
save: () => void;
remove: () => void;
isSaving: boolean;
isRemoving: boolean;
hasExistingRule: boolean;
isSaveDisabled: boolean;
}
const PREVIEW_DEBOUNCE_MS = 400;
const SAVE_ERROR_MESSAGE = 'Failed to save volume control rule';
const REMOVE_ERROR_MESSAGE = 'Failed to remove volume control rule';
export function useVolumeControlConfig({
metricName,
existingRule,
open,
onClose,
}: UseVolumeControlConfigParams): UseVolumeControlConfigResult {
const { notifications } = useNotifications();
const queryClient = useQueryClient();
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const initial = useMemo(() => modeFromRule(existingRule), [existingRule]);
const [mode, setMode] = useState<RuleMode>(initial.mode);
const [labels, setLabels] = useState<string[]>(initial.labels);
const attributesQuery = useGetMetricAttributes(
{ metricName },
{
start: minTime ? Math.floor(minTime / 1000000) : undefined,
end: maxTime ? Math.floor(maxTime / 1000000) : undefined,
},
{ query: { enabled: open && !!metricName } },
);
const attributeKeys = useMemo(
() => (attributesQuery.data?.data.attributes ?? []).map((attr) => attr.key),
[attributesQuery.data],
);
const previewMutation = usePreviewMetricReductionRule();
const { mutate: previewMutate, reset: previewReset } = previewMutation;
const [isPreviewPending, setIsPreviewPending] = useState(false);
useEffect(() => {
if (!open || mode === 'all' || labels.length === 0) {
previewReset();
setIsPreviewPending(false);
return undefined;
}
setIsPreviewPending(true);
const timer = setTimeout(() => {
previewMutate(
{ data: { metricName, matchType: matchTypeForMode(mode), labels } },
{ onSettled: () => setIsPreviewPending(false) },
);
}, PREVIEW_DEBOUNCE_MS);
return (): void => clearTimeout(timer);
}, [open, mode, labels, metricName, previewMutate, previewReset]);
const upsertMutation = useUpsertMetricReductionRule();
const deleteMutation = useDeleteMetricReductionRule();
const invalidate = useCallback((): void => {
void invalidateGetMetricReductionRule(queryClient, { metricName });
void invalidateListMetricReductionRules(queryClient);
void invalidateListMetrics(queryClient);
}, [queryClient, metricName]);
const removeRule = useCallback((): void => {
deleteMutation.mutate(
{ pathParams: { metricName } },
{
onSuccess: () => {
notifications.success({ message: 'Volume control rule removed' });
invalidate();
onClose();
},
onError: (error) =>
notifications.error({
message: error.response?.data?.error?.message ?? REMOVE_ERROR_MESSAGE,
}),
},
);
}, [deleteMutation, metricName, notifications, invalidate, onClose]);
const save = useCallback((): void => {
if (mode === 'all') {
if (!existingRule) {
onClose();
return;
}
removeRule();
return;
}
upsertMutation.mutate(
{
pathParams: { metricName },
data: { matchType: matchTypeForMode(mode), labels },
},
{
onSuccess: () => {
notifications.success({ message: 'Volume control rule saved' });
invalidate();
onClose();
},
onError: (error) =>
notifications.error({
message: error.response?.data?.error?.message ?? SAVE_ERROR_MESSAGE,
}),
},
);
}, [
mode,
labels,
metricName,
existingRule,
upsertMutation,
removeRule,
notifications,
invalidate,
onClose,
]);
return {
mode,
setMode,
labels,
setLabels,
attributeKeys,
isLoadingAttributes: attributesQuery.isLoading,
preview: previewMutation.data?.data,
isPreviewLoading: isPreviewPending,
save,
remove: removeRule,
isSaving: upsertMutation.isLoading || deleteMutation.isLoading,
isRemoving: deleteMutation.isLoading,
hasExistingRule: !!existingRule,
isSaveDisabled: mode !== 'all' && labels.length === 0,
};
}

View File

@@ -0,0 +1,19 @@
import { MetricreductionruletypesMatchTypeDTO } from 'api/generated/services/sigNoz.schemas';
export function isKeepMode(
matchType: MetricreductionruletypesMatchTypeDTO,
): boolean {
return matchType === MetricreductionruletypesMatchTypeDTO.keep;
}
export function getMatchTypeLabel(
matchType: MetricreductionruletypesMatchTypeDTO,
): string {
return isKeepMode(matchType) ? 'Include attributes' : 'Exclude attributes';
}
export function getLabelVerb(
matchType: MetricreductionruletypesMatchTypeDTO,
): string {
return isKeepMode(matchType) ? 'include' : 'exclude';
}

View File

@@ -77,6 +77,14 @@ jest.mock(
},
);
jest.mock(
'container/MetricsExplorer/MetricDetails/VolumeControl/VolumeControlSection',
() =>
function MockVolumeControlSection(): JSX.Element {
return <div data-testid="volume-control-section-mock">Volume Control</div>;
},
);
const useGetMetricMetadataMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricMetadata',

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

@@ -0,0 +1,27 @@
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
height: 20px;
padding: 0 8px;
border-radius: 99px;
font-size: 11px;
font-weight: 600;
}
.active {
color: var(--bg-forest-400, #50e7a7);
background: rgba(37, 225, 146, 0.1);
border: 1px solid rgba(37, 225, 146, 0.22);
}
.pending {
color: var(--bg-amber-400, #ffd778);
background: rgba(255, 204, 86, 0.1);
border: 1px solid rgba(255, 204, 86, 0.25);
}
.none {
color: var(--bg-vanilla-400, #c0c1c3);
font-size: 12px;
}

View File

@@ -0,0 +1,29 @@
import { Gauge } from '@signozhq/icons';
import { MetricreductionruletypesGettableReductionRuleDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './VolumeControlBadge.module.scss';
interface VolumeControlBadgeProps {
rule?: MetricreductionruletypesGettableReductionRuleDTO;
}
function VolumeControlBadge({ rule }: VolumeControlBadgeProps): JSX.Element {
if (!rule) {
return (
<span className={styles.none} data-testid="vc-badge-none">
</span>
);
}
return (
<span
className={`${styles.badge} ${rule.active ? styles.active : styles.pending}`}
data-testid="vc-badge-active"
>
<Gauge size={11} />
{rule.active ? 'Active' : 'Pending'}
</span>
);
}
export default VolumeControlBadge;

View File

@@ -0,0 +1,95 @@
.tab {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 0;
}
.header {
display: flex;
flex-direction: column;
gap: 4px;
}
.title-row {
display: flex;
align-items: center;
gap: 8px;
color: var(--bg-robin-400, #7190f9);
}
.title {
margin: 0 !important;
}
.subtitle {
font-size: 13px;
color: var(--bg-vanilla-400, #c0c1c3);
max-width: 680px;
}
.stats {
display: flex;
gap: 12px;
}
.stat {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 160px;
padding: 12px 16px;
border: 1px solid var(--bg-slate-400, #1d212d);
border-radius: 6px;
background: var(--bg-ink-400, #121317);
}
.stat-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--bg-vanilla-400, #c0c1c3);
}
.stat-value {
font-family: 'Geist Mono', monospace;
font-size: 22px;
font-weight: 600;
color: var(--bg-vanilla-100, #fff);
}
.metric-name {
font-family: 'Geist Mono', monospace;
font-size: 12.5px;
color: var(--bg-vanilla-100, #fff);
}
.attributes {
font-family: 'Geist Mono', monospace;
font-size: 12px;
color: var(--bg-vanilla-300, #e9e9e9);
}
.muted {
font-size: 12px;
color: var(--bg-vanilla-400, #c0c1c3);
}
.reduction {
font-family: 'Geist Mono', monospace;
font-size: 12px;
font-weight: 600;
color: var(--bg-forest-400, #50e7a7);
}
.empty {
padding: 32px 16px;
text-align: center;
color: var(--bg-vanilla-400, #c0c1c3);
}
.unavailable {
padding: 48px 16px;
text-align: center;
color: var(--bg-vanilla-400, #c0c1c3);
}

View File

@@ -0,0 +1,275 @@
import { Gauge } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { Table } from 'antd';
import type { TableColumnsType, TableProps } from 'antd';
import { useListMetricReductionRules } from 'api/generated/services/metrics';
import {
ListMetricReductionRulesParams,
MetricreductionruletypesGettableReductionRuleDTO,
MetricreductionruletypesOrderDTO,
MetricreductionruletypesReductionRuleOrderByDTO,
} from 'api/generated/services/sigNoz.schemas';
import dayjs from 'dayjs';
import { useVolumeControlFeatureGate } from 'hooks/metricsExplorer/useVolumeControlFeatureGate';
import { useCallback, useMemo, useState } from 'react';
import { formatCompact } from '../MetricDetails/VolumeControl/configUtils';
import {
getLabelVerb,
getMatchTypeLabel,
} from '../MetricDetails/VolumeControl/utils';
import VolumeControlConfigDrawer from '../MetricDetails/VolumeControl/VolumeControlConfigDrawer';
import VolumeControlBadge from './VolumeControlBadge';
import styles from './VolumeControlTab.module.scss';
const OrderBy = MetricreductionruletypesReductionRuleOrderByDTO;
const SortOrder = MetricreductionruletypesOrderDTO;
const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_PARAMS: Required<ListMetricReductionRulesParams> = {
orderBy: OrderBy.reduction,
order: SortOrder.desc,
offset: 0,
limit: DEFAULT_PAGE_SIZE,
};
function VolumeControlTab(): JSX.Element {
const { isVolumeControlEnabled, canManageVolumeControl } =
useVolumeControlFeatureGate();
const [selectedRule, setSelectedRule] =
useState<MetricreductionruletypesGettableReductionRuleDTO | null>(null);
const [params, setParams] =
useState<Required<ListMetricReductionRulesParams>>(DEFAULT_PARAMS);
const { data, isLoading } = useListMetricReductionRules(params, {
query: { enabled: isVolumeControlEnabled },
});
const rules = data?.data.rules ?? [];
const total = data?.data.total ?? 0;
const sortOrderFor = useCallback(
(
key: MetricreductionruletypesReductionRuleOrderByDTO,
): 'ascend' | 'descend' | undefined => {
if (params.orderBy !== key) {
return undefined;
}
return params.order === SortOrder.desc ? 'descend' : 'ascend';
},
[params],
);
const columns: TableColumnsType<MetricreductionruletypesGettableReductionRuleDTO> =
useMemo(
() => [
{
title: 'METRIC',
dataIndex: 'metricName',
key: OrderBy.metricname,
sorter: true,
sortOrder: sortOrderFor(OrderBy.metricname),
render: (metricName: string): JSX.Element => (
<span className={styles.metricName}>{metricName}</span>
),
},
{
title: 'STATUS',
key: 'status',
width: 130,
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => <VolumeControlBadge rule={rule} />,
},
{
title: 'MODE',
key: 'mode',
width: 160,
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => <span>{getMatchTypeLabel(rule.matchType)}</span>,
},
{
title: 'ATTRIBUTES',
key: 'attributes',
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => (
<span className={styles.attributes}>
{getLabelVerb(rule.matchType)} {(rule.labels ?? []).join(', ') || '—'}
</span>
),
},
{
title: 'INGESTED',
key: OrderBy.ingestedvolume,
width: 130,
sorter: true,
sortOrder: sortOrderFor(OrderBy.ingestedvolume),
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => (
<span className={styles.muted}>{formatCompact(rule.ingestedSeries)}</span>
),
},
{
title: 'REDUCED',
key: OrderBy.reducedvolume,
width: 130,
sorter: true,
sortOrder: sortOrderFor(OrderBy.reducedvolume),
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => <span>{formatCompact(rule.reducedSeries)}</span>,
},
{
title: 'CHANGE',
key: OrderBy.reduction,
width: 110,
sorter: true,
sortOrder: sortOrderFor(OrderBy.reduction),
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => {
if (rule.reductionPercent <= 0) {
return <span className={styles.muted}></span>;
}
return (
<span className={styles.reduction}>
{Math.round(rule.reductionPercent)}%
</span>
);
},
},
{
title: 'LAST CONFIGURED',
key: OrderBy.lastupdated,
width: 240,
sorter: true,
sortOrder: sortOrderFor(OrderBy.lastupdated),
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => (
<span className={styles.muted}>
{dayjs(rule.updatedAt).format('MMM D, YYYY · h:mm A')}
{rule.updatedBy ? ` · ${rule.updatedBy}` : ''}
</span>
),
},
...(canManageVolumeControl
? ([
{
title: '',
key: 'action',
width: 110,
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => (
<Button
variant="ghost"
color="secondary"
onClick={(): void => setSelectedRule(rule)}
data-testid={`vc-manage-${rule.metricName}`}
>
Manage
</Button>
),
},
] as TableColumnsType<MetricreductionruletypesGettableReductionRuleDTO>)
: []),
],
[canManageVolumeControl, sortOrderFor],
);
const handleTableChange: TableProps<MetricreductionruletypesGettableReductionRuleDTO>['onChange'] =
(pagination, _filters, sorter): void => {
const active = Array.isArray(sorter) ? sorter[0] : sorter;
const pageSize = pagination.pageSize ?? DEFAULT_PAGE_SIZE;
const current = pagination.current ?? 1;
setParams({
orderBy: active?.order
? (active.columnKey as MetricreductionruletypesReductionRuleOrderByDTO)
: DEFAULT_PARAMS.orderBy,
order: active?.order === 'descend' ? SortOrder.desc : SortOrder.asc,
limit: pageSize,
offset: (current - 1) * pageSize,
});
};
if (!isVolumeControlEnabled) {
return (
<div className={styles.unavailable} data-testid="volume-control-unavailable">
<Typography.Text>
Volume control is available on enterprise and cloud plans.
</Typography.Text>
</div>
);
}
return (
<div className={styles.tab} data-testid="volume-control-tab">
<div className={styles.header}>
<div className={styles.titleRow}>
<Gauge size={18} />
<Typography.Title level={4} className={styles.title}>
Volume Control
</Typography.Title>
</div>
<Typography.Text className={styles.subtitle}>
Aggregate away high-cardinality attributes to reduce stored metric volume
and cost.
</Typography.Text>
</div>
<div className={styles.stats}>
<div className={styles.stat}>
<span className={styles.statLabel}>Active rules</span>
<span className={styles.statValue}>{total}</span>
</div>
</div>
<Table<MetricreductionruletypesGettableReductionRuleDTO>
rowKey="metricName"
loading={isLoading}
dataSource={rules}
columns={columns}
onChange={handleTableChange}
pagination={{
current: Math.floor(params.offset / params.limit) + 1,
pageSize: params.limit,
total,
showSizeChanger: false,
}}
locale={{
emptyText: (
<div className={styles.empty} data-testid="volume-control-tab-empty">
No volume control rules yet. Open a metric and set one up to start
reducing its series volume.
</div>
),
}}
/>
{canManageVolumeControl && selectedRule && (
<VolumeControlConfigDrawer
metricName={selectedRule.metricName}
existingRule={selectedRule}
open={!!selectedRule}
onClose={(): void => setSelectedRule(null)}
/>
)}
</div>
);
}
export default VolumeControlTab;

View File

@@ -206,7 +206,6 @@ export const routesToSkip = [
ROUTES.METER,
ROUTES.METER_EXPLORER_VIEWS,
ROUTES.SOMETHING_WENT_WRONG,
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];

View File

@@ -0,0 +1,23 @@
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
interface VolumeControlFeatureGate {
isVolumeControlEnabled: boolean;
canManageVolumeControl: boolean;
isLoading: boolean;
}
export function useVolumeControlFeatureGate(): VolumeControlFeatureGate {
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const { user, isFetchingActiveLicense, activeLicense } = useAppContext();
const isVolumeControlEnabled = isCloudUser || isEnterpriseSelfHostedUser;
const isAdmin = user?.role === USER_ROLES.ADMIN;
return {
isVolumeControlEnabled,
canManageVolumeControl: isVolumeControlEnabled && isAdmin,
isLoading: isFetchingActiveLicense && !activeLicense,
};
}

View File

@@ -1,7 +0,0 @@
import LLMObservabilityAttributeMapping from 'container/LLMObservabilityAttributeMapping/LLMObservabilityAttributeMapping';
function LLMObservabilityAttributeMappingPage(): JSX.Element {
return <LLMObservabilityAttributeMapping />;
}
export default LLMObservabilityAttributeMappingPage;

View File

@@ -3,19 +3,29 @@ import { useLocation } from 'react-use';
import RouteTab from 'components/RouteTab';
import { TabRoutes } from 'components/RouteTab/types';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useVolumeControlFeatureGate } from 'hooks/metricsExplorer/useVolumeControlFeatureGate';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import history from 'lib/history';
import { DataSource } from 'types/common/queryBuilder';
import { Explorer, Summary, Views } from './constants';
import { Explorer, Summary, Views, VolumeControl } from './constants';
import './MetricsExplorerPage.styles.scss';
function MetricsExplorerPage(): JSX.Element {
const { pathname } = useLocation();
const { isVolumeControlEnabled } = useVolumeControlFeatureGate();
const routes: TabRoutes[] = [Summary, Explorer, Views];
const routes: TabRoutes[] = useMemo(
() => [
Summary,
...(isVolumeControlEnabled ? [VolumeControl] : []),
Explorer,
Views,
],
[isVolumeControlEnabled],
);
const { updateAllQueriesOperators } = useQueryBuilder();

View File

@@ -2,7 +2,8 @@ import { TabRoutes } from 'components/RouteTab/types';
import ROUTES from 'constants/routes';
import ExplorerPage from 'container/MetricsExplorer/Explorer';
import SummaryPage from 'container/MetricsExplorer/Summary';
import { BarChart, Compass, TowerControl } from '@signozhq/icons';
import VolumeControlTab from 'container/MetricsExplorer/VolumeControlTab/VolumeControlTab';
import { BarChart, Compass, Gauge, TowerControl } from '@signozhq/icons';
import SaveView from 'pages/SaveView';
export const Summary: TabRoutes = {
@@ -37,3 +38,14 @@ export const Views: TabRoutes = {
route: ROUTES.METRICS_EXPLORER_VIEWS,
key: ROUTES.METRICS_EXPLORER_VIEWS,
};
export const VolumeControl: TabRoutes = {
Component: VolumeControlTab,
name: (
<div className="tab-item">
<Gauge size={16} /> Volume Control
</div>
),
route: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
key: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
};

View File

@@ -122,6 +122,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
METRICS_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
METRICS_EXPLORER_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
METRICS_EXPLORER_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
METRICS_EXPLORER_VOLUME_CONTROL: ['ADMIN', 'EDITOR', 'VIEWER'],
API_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'],
WORKSPACE_ACCESS_RESTRICTED: ['ADMIN', 'EDITOR', 'VIEWER'],
METRICS_EXPLORER_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -136,5 +137,4 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
AI_ASSISTANT_ICON_PREVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
MCP_SERVER: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_ASSISTANT_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING: ['ADMIN', 'EDITOR', 'VIEWER'],
};

View File

@@ -0,0 +1,104 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
"github.com/gorilla/mux"
)
func (provider *provider) addMetricReductionRuleRoutes(router *mux.Router) error {
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, sorted and paginated server-side. Enterprise feature.",
RequestQuery: new(metricreductionruletypes.ListReductionRulesParams),
Response: new(metricreductionruletypes.GettableReductionRules),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusUnauthorized, 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/reduction_rules/preview", handler.New(
provider.authzMiddleware.AdminAccess(provider.metricReductionRuleHandler.Preview),
handler.OpenAPIDef{
ID: "PreviewMetricReductionRule",
Tags: []string{"metrics"},
Summary: "Preview a metric reduction rule",
Description: "Estimates the series reduction and related-asset impact of a candidate volume-control rule without persisting it. Enterprise feature.",
Request: new(metricreductionruletypes.PostableReductionRulePreview),
RequestContentType: "application/json",
Response: new(metricreductionruletypes.GettableReductionRulePreview),
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.MethodPost).GetError(); err != nil {
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. Enterprise feature.",
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

@@ -18,6 +18,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/fields"
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/preference"
@@ -39,39 +40,40 @@ import (
)
type provider struct {
config apiserver.Config
settings factory.ScopedProviderSettings
router *mux.Router
authzMiddleware *middleware.AuthZ
authzService authz.AuthZ
orgHandler organization.Handler
userHandler user.Handler
sessionHandler session.Handler
authDomainHandler authdomain.Handler
preferenceHandler preference.Handler
globalHandler global.Handler
promoteHandler promote.Handler
flaggerHandler flagger.Handler
dashboardModule dashboard.Module
dashboardHandler dashboard.Handler
metricsExplorerHandler metricsexplorer.Handler
infraMonitoringHandler inframonitoring.Handler
gatewayHandler gateway.Handler
fieldsHandler fields.Handler
authzHandler authz.Handler
rawDataExportHandler rawdataexport.Handler
zeusHandler zeus.Handler
querierHandler querier.Handler
serviceAccountHandler serviceaccount.Handler
factoryHandler factory.Handler
cloudIntegrationHandler cloudintegration.Handler
ruleStateHistoryHandler rulestatehistory.Handler
spanMapperHandler spanmapper.Handler
alertmanagerHandler alertmanager.Handler
traceDetailHandler tracedetail.Handler
rulerHandler ruler.Handler
llmPricingRuleHandler llmpricingrule.Handler
statsHandler statsreporter.Handler
config apiserver.Config
settings factory.ScopedProviderSettings
router *mux.Router
authzMiddleware *middleware.AuthZ
authzService authz.AuthZ
orgHandler organization.Handler
userHandler user.Handler
sessionHandler session.Handler
authDomainHandler authdomain.Handler
preferenceHandler preference.Handler
globalHandler global.Handler
promoteHandler promote.Handler
flaggerHandler flagger.Handler
dashboardModule dashboard.Module
dashboardHandler dashboard.Handler
metricsExplorerHandler metricsexplorer.Handler
metricReductionRuleHandler metricreductionrule.Handler
infraMonitoringHandler inframonitoring.Handler
gatewayHandler gateway.Handler
fieldsHandler fields.Handler
authzHandler authz.Handler
rawDataExportHandler rawdataexport.Handler
zeusHandler zeus.Handler
querierHandler querier.Handler
serviceAccountHandler serviceaccount.Handler
factoryHandler factory.Handler
cloudIntegrationHandler cloudintegration.Handler
ruleStateHistoryHandler rulestatehistory.Handler
spanMapperHandler spanmapper.Handler
alertmanagerHandler alertmanager.Handler
traceDetailHandler tracedetail.Handler
rulerHandler ruler.Handler
llmPricingRuleHandler llmpricingrule.Handler
statsHandler statsreporter.Handler
}
func NewFactory(
@@ -88,6 +90,7 @@ func NewFactory(
dashboardModule dashboard.Module,
dashboardHandler dashboard.Handler,
metricsExplorerHandler metricsexplorer.Handler,
metricReductionRuleHandler metricreductionrule.Handler,
infraMonitoringHandler inframonitoring.Handler,
gatewayHandler gateway.Handler,
fieldsHandler fields.Handler,
@@ -124,6 +127,7 @@ func NewFactory(
dashboardModule,
dashboardHandler,
metricsExplorerHandler,
metricReductionRuleHandler,
infraMonitoringHandler,
gatewayHandler,
fieldsHandler,
@@ -162,6 +166,7 @@ func newProvider(
dashboardModule dashboard.Module,
dashboardHandler dashboard.Handler,
metricsExplorerHandler metricsexplorer.Handler,
metricReductionRuleHandler metricreductionrule.Handler,
infraMonitoringHandler inframonitoring.Handler,
gatewayHandler gateway.Handler,
fieldsHandler fields.Handler,
@@ -184,38 +189,39 @@ func newProvider(
router := mux.NewRouter().UseEncodedPath()
provider := &provider{
config: config,
settings: settings,
router: router,
orgHandler: orgHandler,
userHandler: userHandler,
authzService: authzService,
sessionHandler: sessionHandler,
authDomainHandler: authDomainHandler,
preferenceHandler: preferenceHandler,
globalHandler: globalHandler,
promoteHandler: promoteHandler,
flaggerHandler: flaggerHandler,
dashboardModule: dashboardModule,
dashboardHandler: dashboardHandler,
metricsExplorerHandler: metricsExplorerHandler,
infraMonitoringHandler: infraMonitoringHandler,
gatewayHandler: gatewayHandler,
fieldsHandler: fieldsHandler,
authzHandler: authzHandler,
rawDataExportHandler: rawDataExportHandler,
zeusHandler: zeusHandler,
querierHandler: querierHandler,
serviceAccountHandler: serviceAccountHandler,
factoryHandler: factoryHandler,
cloudIntegrationHandler: cloudIntegrationHandler,
ruleStateHistoryHandler: ruleStateHistoryHandler,
spanMapperHandler: spanMapperHandler,
alertmanagerHandler: alertmanagerHandler,
traceDetailHandler: traceDetailHandler,
rulerHandler: rulerHandler,
llmPricingRuleHandler: llmPricingRuleHandler,
statsHandler: statsHandler,
config: config,
settings: settings,
router: router,
orgHandler: orgHandler,
userHandler: userHandler,
authzService: authzService,
sessionHandler: sessionHandler,
authDomainHandler: authDomainHandler,
preferenceHandler: preferenceHandler,
globalHandler: globalHandler,
promoteHandler: promoteHandler,
flaggerHandler: flaggerHandler,
dashboardModule: dashboardModule,
dashboardHandler: dashboardHandler,
metricsExplorerHandler: metricsExplorerHandler,
metricReductionRuleHandler: metricReductionRuleHandler,
infraMonitoringHandler: infraMonitoringHandler,
gatewayHandler: gatewayHandler,
fieldsHandler: fieldsHandler,
authzHandler: authzHandler,
rawDataExportHandler: rawDataExportHandler,
zeusHandler: zeusHandler,
querierHandler: querierHandler,
serviceAccountHandler: serviceAccountHandler,
factoryHandler: factoryHandler,
cloudIntegrationHandler: cloudIntegrationHandler,
ruleStateHistoryHandler: ruleStateHistoryHandler,
spanMapperHandler: spanMapperHandler,
alertmanagerHandler: alertmanagerHandler,
traceDetailHandler: traceDetailHandler,
rulerHandler: rulerHandler,
llmPricingRuleHandler: llmPricingRuleHandler,
statsHandler: statsHandler,
}
provider.authzMiddleware = middleware.NewAuthZ(settings.Logger(), orgGetter, authzService)
@@ -272,6 +278,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addMetricReductionRuleRoutes(router); err != nil {
return err
}
if err := provider.addInfraMonitoringRoutes(router); err != nil {
return err
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"log/slog"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/errors"
@@ -207,12 +208,14 @@ 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)
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], ","),
})
}
}
@@ -306,6 +309,68 @@ 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
}
// 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

@@ -0,0 +1,156 @@
package implmetricreductionrule
import (
"net/http"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
type handler struct {
module metricreductionrule.Module
}
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 (h *handler) List(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
}
var params metricreductionruletypes.ListReductionRulesParams
if err := binding.Query.BindQuery(r.URL.Query(), &params); err != nil {
render.Error(rw, err)
return
}
out, err := h.module.List(r.Context(), valuer.MustNewUUID(claims.OrgID), &params)
if err != nil {
render.Error(rw, err)
return
}
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
if err := in.Validate(); err != nil {
render.Error(rw, err)
return
}
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 {
render.Error(rw, err)
return
}
var in metricreductionruletypes.PostableReductionRulePreview
if err := binding.JSON.BindBody(r.Body, &in); err != nil {
render.Error(rw, err)
return
}
if err := in.Validate(); err != nil {
render.Error(rw, err)
return
}
out, err := h.module.Preview(r.Context(), valuer.MustNewUUID(claims.OrgID), &in)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, out)
}

View File

@@ -0,0 +1,41 @@
package implmetricreductionrule
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type module struct{}
func NewModule() metricreductionrule.Module {
return &module{}
}
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()
}
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) Preview(_ context.Context, _ valuer.UUID, _ *metricreductionruletypes.PostableReductionRulePreview) (*metricreductionruletypes.GettableReductionRulePreview, error) {
return nil, errUnsupported()
}

View File

@@ -0,0 +1,25 @@
package metricreductionrule
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
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
Preview(ctx context.Context, orgID valuer.UUID, req *metricreductionruletypes.PostableReductionRulePreview) (*metricreductionruletypes.GettableReductionRulePreview, error)
}
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)
Preview(rw http.ResponseWriter, r *http.Request)
}

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

@@ -24,6 +24,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/inframonitoring/implinframonitoring"
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule/impllmpricingrule"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule/implmetricreductionrule"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
@@ -64,6 +66,7 @@ type Handlers struct {
SpanPercentile spanpercentile.Handler
Services services.Handler
MetricsExplorer metricsexplorer.Handler
MetricReductionRule metricreductionrule.Handler
InfraMonitoring inframonitoring.Handler
Global global.Handler
FlaggerHandler flagger.Handler
@@ -110,6 +113,7 @@ func NewHandlers(
RawDataExport: implrawdataexport.NewHandler(modules.RawDataExport),
Services: implservices.NewHandler(modules.Services),
MetricsExplorer: implmetricsexplorer.NewHandler(modules.MetricsExplorer),
MetricReductionRule: implmetricreductionrule.NewHandler(modules.MetricReductionRule),
InfraMonitoring: implinframonitoring.NewHandler(modules.InfraMonitoring),
SpanPercentile: implspanpercentile.NewHandler(modules.SpanPercentile),
Global: signozglobal.NewHandler(global),

View File

@@ -59,7 +59,7 @@ func TestNewHandlers(t *testing.T) {
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, retentionGetter, flagger, tagModule)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, retentionGetter, flagger, tagModule, nil)
querierHandler := querier.NewHandler(providerSettings, nil, nil)
registryHandler := factory.NewHandler(nil)

View File

@@ -21,6 +21,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule/impllmpricingrule"
"github.com/SigNoz/signoz/pkg/modules/logspipeline"
"github.com/SigNoz/signoz/pkg/modules/logspipeline/impllogspipeline"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/organization"
@@ -66,33 +67,34 @@ import (
)
type Modules struct {
OrgGetter organization.Getter
OrgSetter organization.Setter
Preference preference.Module
UserSetter user.Setter
UserGetter user.Getter
RetentionGetter retention.Getter
SavedView savedview.Module
Apdex apdex.Module
Dashboard dashboard.Module
QuickFilter quickfilter.Module
TraceFunnel tracefunnel.Module
RawDataExport rawdataexport.Module
AuthDomain authdomain.Module
Session session.Module
Services services.Module
SpanPercentile spanpercentile.Module
MetricsExplorer metricsexplorer.Module
InfraMonitoring inframonitoring.Module
Promote promote.Module
ServiceAccount serviceaccount.Module
CloudIntegration cloudintegration.Module
LogsPipeline logspipeline.Module
RuleStateHistory rulestatehistory.Module
TraceDetail tracedetail.Module
SpanMapper spanmapper.Module
LLMPricingRule llmpricingrule.Module
Tag tag.Module
OrgGetter organization.Getter
OrgSetter organization.Setter
Preference preference.Module
UserSetter user.Setter
UserGetter user.Getter
RetentionGetter retention.Getter
SavedView savedview.Module
Apdex apdex.Module
Dashboard dashboard.Module
QuickFilter quickfilter.Module
TraceFunnel tracefunnel.Module
RawDataExport rawdataexport.Module
AuthDomain authdomain.Module
Session session.Module
Services services.Module
SpanPercentile spanpercentile.Module
MetricsExplorer metricsexplorer.Module
MetricReductionRule metricreductionrule.Module
InfraMonitoring inframonitoring.Module
Promote promote.Module
ServiceAccount serviceaccount.Module
CloudIntegration cloudintegration.Module
LogsPipeline logspipeline.Module
RuleStateHistory rulestatehistory.Module
TraceDetail tracedetail.Module
SpanMapper spanmapper.Module
LLMPricingRule llmpricingrule.Module
Tag tag.Module
}
func NewModules(
@@ -119,6 +121,7 @@ func NewModules(
retentionGetter retention.Getter,
fl flagger.Flagger,
tagModule tag.Module,
metricReductionRule metricreductionrule.Module,
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
@@ -130,32 +133,33 @@ func NewModules(
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
return Modules{
OrgGetter: orgGetter,
OrgSetter: orgSetter,
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
SavedView: implsavedview.NewModule(sqlstore),
Apdex: implapdex.NewModule(sqlstore),
Dashboard: dashboard,
UserSetter: userSetter,
UserGetter: userGetter,
RetentionGetter: retentionGetter,
QuickFilter: quickfilter,
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier),
AuthDomain: implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs),
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs), tokenizer, orgGetter),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, providerSettings, config.MetricsExplorer),
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, providerSettings, config.InfraMonitoring),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
ServiceAccount: serviceAccount,
LogsPipeline: impllogspipeline.NewModule(sqlstore),
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger)),
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore)),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore)),
Tag: tagModule,
OrgGetter: orgGetter,
OrgSetter: orgSetter,
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
SavedView: implsavedview.NewModule(sqlstore),
Apdex: implapdex.NewModule(sqlstore),
Dashboard: dashboard,
UserSetter: userSetter,
UserGetter: userGetter,
RetentionGetter: retentionGetter,
QuickFilter: quickfilter,
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier),
AuthDomain: implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs),
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs), tokenizer, orgGetter),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, providerSettings, config.MetricsExplorer),
MetricReductionRule: metricReductionRule,
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, providerSettings, config.InfraMonitoring),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
ServiceAccount: serviceAccount,
LogsPipeline: impllogspipeline.NewModule(sqlstore),
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger)),
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore)),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore)),
Tag: tagModule,
}
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule/implmetricreductionrule"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
@@ -63,7 +64,7 @@ func TestNewModules(t *testing.T) {
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule())
reflectVal := reflect.ValueOf(modules)
for i := 0; i < reflectVal.NumField(); i++ {

View File

@@ -23,6 +23,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/fields"
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/preference"
@@ -68,6 +69,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ dashboard.Module }{},
struct{ dashboard.Handler }{},
struct{ metricsexplorer.Handler }{},
struct{ metricreductionrule.Handler }{},
struct{ inframonitoring.Handler }{},
struct{ gateway.Handler }{},
struct{ fields.Handler }{},

View File

@@ -215,6 +215,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewRecreateUserDashboardPreferenceFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateRecurrenceBoundsFactory(sqlstore),
sqlmigration.NewAddDashboardViewFactory(sqlstore, sqlschema),
sqlmigration.NewAddMetricReductionRulesFactory(sqlstore, sqlschema),
)
}
@@ -295,6 +296,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
modules.Dashboard,
handlers.Dashboard,
handlers.MetricsExplorer,
handlers.MetricReductionRule,
handlers.InfraMonitoring,
handlers.GatewayHandler,
handlers.Fields,

View File

@@ -26,6 +26,7 @@ import (
"github.com/SigNoz/signoz/pkg/meterreporter"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/retention"
@@ -114,6 +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, 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
@@ -464,8 +466,10 @@ func New(
return nil, err
}
metricReductionRuleModule := metricReductionRuleModuleCallback(sqlstore, telemetrystore, dashboard, queryParser, licensing, 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)
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)
// Initialize ruler from the variant-specific provider factories
rulerInstance, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Ruler, rulerProviderFactories(cache, alertmanager, sqlstore, telemetrystore, telemetryMetadataStore, prometheus, orgGetter, modules.RuleStateHistory, querier, queryParser), "signoz")

View File

@@ -0,0 +1,90 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addMetricReductionRules struct {
sqlschema sqlschema.SQLSchema
sqlstore sqlstore.SQLStore
}
func NewAddMetricReductionRulesFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_metric_reduction_rule"), func(_ context.Context, _ factory.ProviderSettings, _ Config) (SQLMigration, error) {
return &addMetricReductionRules{
sqlschema: sqlschema,
sqlstore: sqlstore,
}, nil
})
}
func (migration *addMetricReductionRules) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *addMetricReductionRules) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
sqls := [][]byte{}
tableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "metric_reduction_rule",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "metric_name", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "match_type", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "labels", DataType: sqlschema.DataTypeText, Nullable: false, Default: "'[]'"},
{Name: "effective_from", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "created_by", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "updated_by", DataType: sqlschema.DataTypeText, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
ColumnNames: []sqlschema.ColumnName{"id"},
},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("org_id"),
ReferencedTableName: sqlschema.TableName("organizations"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
sqls = append(sqls, tableSQLs...)
indexSQLs := migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{
TableName: "metric_reduction_rule",
ColumnNames: []sqlschema.ColumnName{"org_id", "metric_name"},
})
sqls = append(sqls, indexSQLs...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addMetricReductionRules) Down(context.Context, *bun.DB) error {
return nil
}

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},
Warnings: []string{"key `k8s.statefulset.name` not found on metric signoz_calls_total"},
},

View File

@@ -9,27 +9,34 @@ 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"
AttributesMetadataTableName = "distributed_metadata"
AttributesMetadataLocalTableName = "metadata"
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"
// Reduction tables written by the collector; see signoz-otel-collector#839.
TimeseriesV4BufferTableName = "distributed_time_series_v4_buffer"
TimeseriesV4BufferLocalTableName = "time_series_v4_buffer"
TimeseriesV4ReducedTableName = "distributed_time_series_v4_reduced"
TimeseriesV4ReducedLocalTableName = "time_series_v4_reduced"
AttributesMetadataTableName = "distributed_metadata"
AttributesMetadataLocalTableName = "metadata"
ReductionRulesTableName = "distributed_metric_reduction_rules"
ReductionRulesLocalTableName = "metric_reduction_rules"
)
var (

View File

@@ -0,0 +1,232 @@
package metricreductionruletypes
import (
"database/sql/driver"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
var (
ErrCodeMetricReductionRuleUnsupported = errors.MustNewCode("metric_reduction_rule_unsupported")
ErrCodeMetricReductionRuleNotFound = errors.MustNewCode("metric_reduction_rule_not_found")
ErrCodeMetricReductionRuleProtectedLabel = errors.MustNewCode("metric_reduction_rule_protected_label")
ErrCodeMetricReductionRuleUnsupportedMetricType = errors.MustNewCode("metric_reduction_rule_unsupported_metric_type")
)
type MatchType struct {
valuer.String
}
var (
MatchTypeDrop = MatchType{valuer.NewString("drop")}
MatchTypeKeep = MatchType{valuer.NewString("keep")}
)
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("metricName")}
OrderByIngestedVolume = ReductionRuleOrderBy{valuer.NewString("ingestedVolume")}
OrderByReducedVolume = ReductionRuleOrderBy{valuer.NewString("reducedVolume")}
OrderByReduction = ReductionRuleOrderBy{valuer.NewString("reduction")}
OrderByLastUpdated = ReductionRuleOrderBy{valuer.NewString("lastUpdated")}
)
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
func (l LabelList) Value() (driver.Value, error) {
if l == nil {
return "[]", nil
}
b, err := json.Marshal(l)
if err != nil {
return nil, err
}
return string(b), nil
}
func (l *LabelList) Scan(src any) error {
var raw []byte
switch v := src.(type) {
case string:
raw = []byte(v)
case []byte:
raw = v
case nil:
*l = nil
return nil
default:
return errors.NewInternalf(errors.CodeInternal, "metricreductionruletypes: cannot scan %T into LabelList", src)
}
return json.Unmarshal(raw, l)
}
type StorableReductionRule struct {
bun.BaseModel `bun:"table:metric_reduction_rule" json:"-"`
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID valuer.UUID `bun:"org_id,type:text,notnull"`
MetricName string `bun:"metric_name,type:text,notnull"`
MatchType MatchType `bun:"match_type,type:text,notnull"`
Labels LabelList `bun:"labels,type:text,notnull,default:'[]'"`
EffectiveFrom time.Time `bun:"effective_from,notnull"`
}
func NewReductionRule(orgID valuer.UUID, metricName string, matchType MatchType, labels []string, effectiveFrom time.Time, by string) *StorableReductionRule {
now := time.Now()
return &StorableReductionRule{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
UserAuditable: types.UserAuditable{CreatedBy: by, UpdatedBy: by},
OrgID: orgID,
MetricName: metricName,
MatchType: matchType,
Labels: LabelList(labels),
EffectiveFrom: effectiveFrom,
}
}
type GettableReductionRule struct {
MetricName string `json:"metricName" required:"true"`
MatchType MatchType `json:"matchType" required:"true"`
Labels []string `json:"labels" required:"true" nullable:"true"`
EffectiveFrom time.Time `json:"effectiveFrom" required:"true"`
UpdatedAt time.Time `json:"updatedAt" required:"true"`
UpdatedBy string `json:"updatedBy" required:"true"`
Active bool `json:"active" required:"true"`
IngestedSeries uint64 `json:"ingestedSeries" required:"true"`
ReducedSeries uint64 `json:"reducedSeries" required:"true"`
ReductionPercent float64 `json:"reductionPercent" required:"true"`
}
type GettableReductionRules struct {
Rules []GettableReductionRule `json:"rules" required:"true" nullable:"true"`
Total int `json:"total" required:"true"`
}
type ListReductionRulesParams struct {
OrderBy ReductionRuleOrderBy `query:"orderBy,default=reduction" json:"orderBy"`
Order Order `query:"order,default=desc" json:"order"`
Offset int `query:"offset" json:"offset"`
Limit int `query:"limit" json:"limit"`
}
func (p *ListReductionRulesParams) Validate() error {
if p.Limit <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be greater than 0")
}
if p.Offset < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset must not be negative")
}
return nil
}
type PostableReductionRule struct {
MetricName string `json:"-"`
MatchType MatchType `json:"matchType" required:"true"`
Labels []string `json:"labels" required:"true" nullable:"true"`
}
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())
}
if len(req.Labels) == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"labels must not be empty; to allow all attributes, delete the rule instead")
}
return nil
}
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 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"`
ImpactedLabels []string `json:"impactedLabels" required:"true" nullable:"true"`
}
type GettableReductionRulePreview struct {
IngestedSeries uint64 `json:"ingestedSeries" 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

@@ -0,0 +1,24 @@
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

@@ -0,0 +1,15 @@
package metricreductionruletypes
import (
"context"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Store interface {
List(ctx context.Context, orgID valuer.UUID, params *ListReductionRulesParams) ([]*StorableReductionRule, int, error)
Get(ctx context.Context, orgID valuer.UUID, metricName string) (*StorableReductionRule, error)
Upsert(ctx context.Context, rule *StorableReductionRule) error
Delete(ctx context.Context, orgID valuer.UUID, metricName string) 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.