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
121 changed files with 5034 additions and 3300 deletions

7
.github/CODEOWNERS vendored
View File

@@ -189,13 +189,6 @@ go.mod @therealpandey
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
## Notification Channels
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
/frontend/src/pages/ChannelsNew/ @SigNoz/pulse-frontend
/frontend/src/container/AllAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/CreateAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/EditAlertChannels/ @SigNoz/pulse-frontend
## OpenAPI Schema - Generated
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv

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

@@ -647,12 +647,8 @@ components:
type: string
name:
type: string
transactionGroups:
$ref: '#/components/schemas/AuthtypesTransactionGroups'
required:
- name
- description
- transactionGroups
type: object
AuthtypesPostableRotateToken:
properties:
@@ -707,34 +703,6 @@ components:
useRoleAttribute:
type: boolean
type: object
AuthtypesRoleWithTransactionGroups:
properties:
createdAt:
format: date-time
type: string
description:
type: string
id:
type: string
name:
type: string
orgId:
type: string
transactionGroups:
$ref: '#/components/schemas/AuthtypesTransactionGroups'
type:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- description
- type
- orgId
- transactionGroups
type: object
AuthtypesSamlConfig:
properties:
attributeMapping:
@@ -768,35 +736,11 @@ components:
- relation
- object
type: object
AuthtypesTransactionGroup:
properties:
objectGroup:
$ref: '#/components/schemas/CoretypesObjectGroup'
relation:
$ref: '#/components/schemas/AuthtypesRelation'
required:
- relation
- objectGroup
type: object
AuthtypesTransactionGroups:
items:
$ref: '#/components/schemas/AuthtypesTransactionGroup'
type: array
AuthtypesUpdatableAuthDomain:
properties:
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
type: object
AuthtypesUpdatableRole:
properties:
description:
type: string
transactionGroups:
$ref: '#/components/schemas/AuthtypesTransactionGroups'
required:
- description
- transactionGroups
type: object
AuthtypesUserRole:
properties:
createdAt:
@@ -5078,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:
@@ -5194,6 +5301,10 @@ components:
type: string
dashboardName:
type: string
groupBy:
items:
type: string
type: array
widgetId:
type: string
widgetName:
@@ -5597,22 +5708,6 @@ components:
nullable: true
type: array
type: object
Querybuildertypesv5EstimateEntry:
properties:
database:
type: string
marks:
format: int64
type: integer
parts:
format: int64
type: integer
rows:
format: int64
type: integer
table:
type: string
type: object
Querybuildertypesv5ExecStats:
description: Execution statistics for the query, including rows scanned, bytes
scanned, and duration.
@@ -5680,25 +5775,6 @@ components:
- anomaly
- fillzero
type: string
Querybuildertypesv5Granules:
properties:
initial:
format: int64
type: integer
reads:
items:
$ref: '#/components/schemas/Querybuildertypesv5MergeTreeRead'
type: array
selected:
format: int64
type: integer
skipScore:
format: double
type: number
skipped:
format: int64
type: integer
type: object
Querybuildertypesv5GroupByKey:
properties:
description:
@@ -5721,31 +5797,6 @@ components:
expression:
type: string
type: object
Querybuildertypesv5IndexStep:
properties:
condition:
type: string
initialGranules:
format: int64
type: integer
initialParts:
format: int64
type: integer
keys:
items:
type: string
type: array
name:
type: string
selectedGranules:
format: int64
type: integer
selectedParts:
format: int64
type: integer
type:
type: string
type: object
Querybuildertypesv5Label:
properties:
key:
@@ -5769,16 +5820,6 @@ components:
expression:
type: string
type: object
Querybuildertypesv5MergeTreeRead:
properties:
steps:
items:
$ref: '#/components/schemas/Querybuildertypesv5IndexStep'
nullable: true
type: array
table:
type: string
type: object
Querybuildertypesv5MetricAggregation:
properties:
comparisonSpaceAggregationParam:
@@ -5823,20 +5864,6 @@ components:
- asc
- desc
type: string
Querybuildertypesv5PreviewStatement:
properties:
db.statement.args:
items: {}
type: array
db.statement.query:
type: string
estimate:
items:
$ref: '#/components/schemas/Querybuildertypesv5EstimateEntry'
type: array
granules:
$ref: '#/components/schemas/Querybuildertypesv5Granules'
type: object
Querybuildertypesv5PromQuery:
properties:
disabled:
@@ -6159,39 +6186,6 @@ components:
type:
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
type: object
Querybuildertypesv5QueryPreview:
properties:
error: {}
magnitudeScore:
nullable: true
type: number
selectivityScore:
nullable: true
type: number
statements:
items:
$ref: '#/components/schemas/Querybuildertypesv5PreviewStatement'
type: array
valid:
type: boolean
warnings:
items:
type: string
type: array
type: object
Querybuildertypesv5QueryRangePreviewResponse:
description: Response from the v5 query range preview (dry-run) endpoint. For
each query in the composite query, returns the underlying ClickHouse statement(s)
it renders to without executing them (one per PromQL metric selector; exactly
one for builder/ClickHouse/trace-operator queries), with the optional EXPLAIN
ESTIMATE and granule analysis attached per statement when requested.
properties:
compositeQuery:
additionalProperties:
$ref: '#/components/schemas/Querybuildertypesv5QueryPreview'
nullable: true
type: object
type: object
Querybuildertypesv5QueryRangeRequest:
description: Request body for the v5 query range endpoint. Supports builder
queries (traces, logs, metrics), formulas, joins, trace operators, PromQL,
@@ -10426,15 +10420,6 @@ paths:
name: limit
schema:
type: integer
- in: query
name: q
schema:
type: string
- in: query
name: isOverride
schema:
nullable: true
type: boolean
responses:
"200":
content:
@@ -11240,7 +11225,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesRoleWithTransactionGroups'
$ref: '#/components/schemas/AuthtypesRole'
status:
type: string
required:
@@ -11275,7 +11260,7 @@ paths:
tags:
- role
patch:
deprecated: true
deprecated: false
description: This endpoint patches a role
operationId: PatchRole
parameters:
@@ -11336,68 +11321,6 @@ paths:
summary: Patch role
tags:
- role
put:
deprecated: false
description: This endpoint updates a role
operationId: UpdateRole
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesUpdatableRole'
responses:
"204":
description: No Content
"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:
- role:update
- tokenizer:
- role:update
summary: Update role
tags:
- role
/api/v1/roles/{id}/relations/{relation}/objects:
get:
deprecated: false
@@ -11477,7 +11400,7 @@ paths:
tags:
- role
patch:
deprecated: true
deprecated: false
description: Patches the objects connected to the specified role via a given
relation type
operationId: PatchObjects
@@ -16141,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
@@ -16246,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
@@ -22258,78 +22556,6 @@ paths:
summary: Query range
tags:
- querier
/api/v5/query_range/preview:
post:
deprecated: false
description: 'Validate a composite query without executing it. Accepts the same
payload as the query range endpoint. By default (verbose=true) returns, for
each query, the rendered underlying ClickHouse statement(s) with each statement''s
EXPLAIN ESTIMATE (per-table parts/rows/marks) and granule index analysis (candidate/surviving
granules, skip score, and the per-index pruning funnel), plus two top-level
scores: selectivityScore (0-100 granule-skip selectivity; higher is better)
and magnitudeScore (0-100 absolute scan cost; higher is cheaper). Pass ?verbose=false
for the lightweight per-query verdict (valid/error/warnings) with no rendered
SQL and no ClickHouse round trips. Intended for agentic/dry-run consumption:
per-query errors are reported in the response rather than failing the whole
request.'
operationId: QueryRangePreviewV5
parameters:
- in: query
name: verbose
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeRequest'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/Querybuildertypesv5QueryRangePreviewResponse'
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
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Query range preview
tags:
- querier
/api/v5/substitute_vars:
post:
deprecated: false

View File

@@ -179,36 +179,13 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
return provider.Write(ctx, tuples, nil)
}
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.RoleWithTransactionGroups) error {
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
existingRole, err := provider.GetByOrgIDAndName(ctx, orgID, role.Name)
if err != nil && !errors.Asc(err, authtypes.ErrCodeRoleNotFound) {
return err
}
if existingRole != nil {
return errors.Newf(errors.TypeAlreadyExists, authtypes.ErrCodeRoleAlreadyExists, "role with name: %s already exists", existingRole.Name)
}
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
if err != nil {
return err
}
err = provider.Write(ctx, tuples, nil)
if err != nil {
return err
}
if err := provider.store.Create(ctx, role.Role); err != nil {
return err
}
return nil
return provider.store.Create(ctx, role)
}
func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) (*authtypes.Role, error) {
@@ -236,26 +213,6 @@ func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, ro
return role, nil
}
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, error) {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
role, err := provider.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
if err != nil {
return nil, err
}
transactionGroups := authtypes.MustNewTransactionGroupsFromTuples(tuples)
return authtypes.MakeRoleWithTransactionGroups(role, transactionGroups), nil
}
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*coretypes.Object, error) {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
@@ -290,36 +247,6 @@ func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id
return objects, nil
}
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.RoleWithTransactionGroups) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
existingRole, err := provider.GetWithTransactionGroups(ctx, orgID, updatedRole.ID)
if err != nil {
return err
}
additions, deletions := existingRole.TransactionGroups.Diff(updatedRole.TransactionGroups)
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
if err != nil {
return err
}
deletionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, deletions)
if err != nil {
return err
}
err = provider.Write(ctx, additionTuples, deletionTuples)
if err != nil {
return err
}
return provider.store.Update(ctx, orgID, updatedRole.Role)
}
func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
@@ -359,7 +286,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
role, err := provider.GetWithTransactionGroups(ctx, orgID, id)
role, err := provider.store.Get(ctx, orgID, id)
if err != nil {
return err
}
@@ -375,12 +302,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
}
}
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
if err != nil {
return err
}
if err := provider.Write(ctx, nil, tuples); err != nil {
if err := provider.deleteTuples(ctx, role.Name, orgID); err != nil {
return errors.WithAdditionalf(err, "failed to delete tuples for the role: %s", role.Name)
}
@@ -439,7 +361,7 @@ func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) []*
return tuples
}
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
func (provider *provider) deleteTuples(ctx context.Context, roleName string, orgID valuer.UUID) error {
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
tuples := make([]*openfgav1.TupleKey, 0)
@@ -449,10 +371,26 @@ func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName str
Object: objectType.StringValue() + ":",
})
if err != nil {
return nil, err
return err
}
tuples = append(tuples, typeTuples...)
}
return tuples, nil
if len(tuples) == 0 {
return nil
}
for idx := 0; idx < len(tuples); idx += provider.config.OpenFGA.MaxTuplesPerWrite {
end := idx + provider.config.OpenFGA.MaxTuplesPerWrite
if end > len(tuples) {
end = len(tuples)
}
err := provider.Write(ctx, nil, tuples[idx:end])
if err != nil {
return err
}
}
return nil
}

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

@@ -101,10 +101,6 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
h.community.QueryRange(rw, req)
}
func (h *handler) QueryRangePreview(rw http.ResponseWriter, req *http.Request) {
h.community.QueryRangePreview(rw, req)
}
func (h *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request) {
h.community.QueryRawStream(rw, req)
}

View File

@@ -48,14 +48,13 @@ const config: Config.InitialOptions = {
],
'^.+\\.(js|jsx)$': 'babel-jest',
},
// TODO: https://github.com/SigNoz/engineering-pod/issues/5334
transformIgnorePatterns: [
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard)/)',
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard)[^/]*/node_modules)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/public/'],

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

@@ -55,6 +55,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
),
[pathname],
);
const isOldRoute = oldRoutes.indexOf(pathname) > -1;
const currentRoute = mapRoutes.get('current');
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
@@ -82,36 +83,12 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
}, [usersData?.data]);
// Handle old routes - redirect to new routes
const isOldRoute = oldRoutes.indexOf(pathname) > -1;
if (isOldRoute) {
const redirectUrl = oldNewRoutesMapping[pathname];
// TODO(H4ad): Remove this after https://github.com/SigNoz/engineering-pod/issues/5322
// A mapped target may itself carry a query string (e.g. `/alerts?tab=Channels`).
// react-router does not re-parse a `?` embedded in the `pathname` field, so split
// it out and merge with the incoming search params.
const [redirectPath, redirectSearch = ''] = redirectUrl.split('?');
const mergedParams = new URLSearchParams(location.search);
new URLSearchParams(redirectSearch).forEach((value, name) => {
mergedParams.set(name, value);
});
const search = mergedParams.toString();
return (
<Redirect
to={{
pathname: redirectPath,
search: search ? `?${search}` : '',
hash: location.hash,
}}
/>
);
}
if (pathname.startsWith('/settings/channels/edit/')) {
const channelId = pathname.replace('/settings/channels/edit/', '');
return (
<Redirect
to={{
pathname: `/alerts/channels/edit/${channelId}`,
pathname: redirectUrl,
search: location.search,
hash: location.hash,
}}

View File

@@ -73,13 +73,7 @@ const queryClient = new QueryClient({
// Component to capture current location for assertions
function LocationDisplay(): ReactElement {
const location = useLocation();
return (
<>
<div data-testid="location-display">{location.pathname}</div>
<div data-testid="location-search">{location.search}</div>
<div data-testid="location-hash">{location.hash}</div>
</>
);
return <div data-testid="location-display">{location.pathname}</div>;
}
// Helper to create mock user
@@ -1481,10 +1475,12 @@ describe('PrivateRoute', () => {
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
});
it('should redirect VIEWER from /alerts/channels/new (ADMIN only)', async () => {
// After moving channels under /alerts, CHANNELS_NEW ('/alerts/channels/new')
// is an exact, ADMIN-only route with no overlapping non-exact ALL_CHANNELS
// route to match last, so a VIEWER is now correctly redirected.
it('should not redirect VIEWER from /settings/channels/new due to route matching order (ALL_CHANNELS matches last)', () => {
// Note: This tests the ACTUAL behavior of Private.tsx route matching
// CHANNELS_NEW has path '/settings/channels/new' with permission ['ADMIN']
// ALL_CHANNELS has path '/settings/channels' with permission ['ADMIN', 'EDITOR', 'VIEWER']
// Due to non-exact matching and array order, ALL_CHANNELS matches LAST for '/settings/channels/new'
// This is a known limitation - actual permission enforcement happens in the page component
renderPrivateRoute({
initialRoute: ROUTES.CHANNELS_NEW,
appContext: {
@@ -1493,7 +1489,8 @@ describe('PrivateRoute', () => {
},
});
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
assertRendersChildren();
assertStaysOnRoute(ROUTES.CHANNELS_NEW);
});
it('should allow EDITOR to access /get-started route', () => {
@@ -1551,60 +1548,4 @@ describe('PrivateRoute', () => {
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
});
});
describe('Old channel route redirects', () => {
it.each([
['/settings/channels', '/alerts', 'tab=Channels'],
['/settings/channels/new', '/alerts/channels/new', ''],
])(
'should redirect %s to %s',
async (oldRoute, expectedPath, expectedSearch) => {
renderPrivateRoute({
initialRoute: oldRoute,
appContext: { isLoggedIn: true },
});
await waitFor(() => {
expect(screen.getByTestId('location-display')).toHaveTextContent(
expectedPath,
);
});
if (expectedSearch) {
const search = screen.getByTestId('location-search').textContent ?? '';
const params = new URLSearchParams(search);
new URLSearchParams(expectedSearch).forEach((value, name) => {
expect(params.get(name)).toBe(value);
});
} else {
expect(screen.getByTestId('location-search')).toHaveTextContent('');
}
},
);
it('should redirect dynamic channel edit route preserving the channel id', async () => {
renderPrivateRoute({
initialRoute: '/settings/channels/edit/abc123',
appContext: { isLoggedIn: true },
});
await assertRedirectsTo('/alerts/channels/edit/abc123');
});
it('should merge incoming query params with the embedded query of the target', async () => {
renderPrivateRoute({
initialRoute: '/settings/channels?foo=bar',
appContext: { isLoggedIn: true },
});
await waitFor(() => {
expect(screen.getByTestId('location-display')).toHaveTextContent('/alerts');
});
const search = screen.getByTestId('location-search').textContent ?? '';
const params = new URLSearchParams(search);
expect(params.get('tab')).toBe('Channels');
expect(params.get('foo')).toBe('bar');
});
});
});

View File

@@ -142,12 +142,12 @@ export const AlertOverview = Loadable(
() => import(/* webpackChunkName: "Alert Overview" */ 'pages/AlertList'),
);
export const ChannelsNew = Loadable(
() => import(/* webpackChunkName: "Create Channels" */ 'pages/AlertList'),
export const CreateAlertChannelAlerts = Loadable(
() => import(/* webpackChunkName: "Create Channels" */ 'pages/Settings'),
);
export const ChannelsEdit = Loadable(
() => import(/* webpackChunkName: "Edit Channels" */ 'pages/AlertList'),
export const AllAlertChannels = Loadable(
() => import(/* webpackChunkName: "All Channels" */ 'pages/Settings'),
);
export const AllErrors = Loadable(

View File

@@ -5,10 +5,10 @@ import {
AIAssistantPage,
AlertHistory,
AlertOverview,
AllAlertChannels,
AllErrors,
ApiMonitoring,
ChannelsEdit,
ChannelsNew,
CreateAlertChannelAlerts,
CreateNewAlerts,
DashboardPage,
DashboardsListPage,
@@ -269,16 +269,16 @@ const routes: AppRoutes[] = [
{
path: ROUTES.CHANNELS_NEW,
exact: true,
component: ChannelsNew,
component: CreateAlertChannelAlerts,
isPrivate: true,
key: 'CHANNELS_NEW',
},
{
path: ROUTES.CHANNELS_EDIT,
path: ROUTES.ALL_CHANNELS,
exact: true,
component: ChannelsEdit,
component: AllAlertChannels,
isPrivate: true,
key: 'CHANNELS_EDIT',
key: 'ALL_CHANNELS',
},
{
path: ROUTES.ALL_ERROR,
@@ -469,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,
@@ -534,9 +541,6 @@ export const oldNewRoutesMapping: Record<string, string> = {
'/messaging-queues': '/messaging-queues/overview',
'/alerts/edit': '/alerts/overview',
'/alerts/type-selection': '/alerts/new',
// TODO(H4ad): Update this after https://github.com/SigNoz/engineering-pod/issues/5322
'/settings/channels': '/alerts?tab=Channels',
'/settings/channels/new': '/alerts/channels/new',
};
export const oldRoutes = Object.keys(oldNewRoutesMapping);

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

@@ -12,8 +12,6 @@ import type {
} from 'react-query';
import type {
QueryRangePreviewV5200,
QueryRangePreviewV5Params,
QueryRangeV5200,
Querybuildertypesv5QueryRangeRequestDTO,
RenderErrorResponseDTO,
@@ -106,107 +104,6 @@ export const useQueryRangeV5 = <
> => {
return useMutation(getQueryRangeV5MutationOptions(options));
};
/**
* Validate a composite query without executing it. Accepts the same payload as the query range endpoint. By default (verbose=true) returns, for each query, the rendered underlying ClickHouse statement(s) with each statement's EXPLAIN ESTIMATE (per-table parts/rows/marks) and granule index analysis (candidate/surviving granules, skip score, and the per-index pruning funnel), plus two top-level scores: selectivityScore (0-100 granule-skip selectivity; higher is better) and magnitudeScore (0-100 absolute scan cost; higher is cheaper). Pass ?verbose=false for the lightweight per-query verdict (valid/error/warnings) with no rendered SQL and no ClickHouse round trips. Intended for agentic/dry-run consumption: per-query errors are reported in the response rather than failing the whole request.
* @summary Query range preview
*/
export const queryRangePreviewV5 = (
querybuildertypesv5QueryRangeRequestDTO?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>,
params?: QueryRangePreviewV5Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<QueryRangePreviewV5200>({
url: `/api/v5/query_range/preview`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: querybuildertypesv5QueryRangeRequestDTO,
params,
signal,
});
};
export const getQueryRangePreviewV5MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof queryRangePreviewV5>>,
TError,
{
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: QueryRangePreviewV5Params;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof queryRangePreviewV5>>,
TError,
{
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: QueryRangePreviewV5Params;
},
TContext
> => {
const mutationKey = ['queryRangePreviewV5'];
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 queryRangePreviewV5>>,
{
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: QueryRangePreviewV5Params;
}
> = (props) => {
const { data, params } = props ?? {};
return queryRangePreviewV5(data, params);
};
return { mutationFn, ...mutationOptions };
};
export type QueryRangePreviewV5MutationResult = NonNullable<
Awaited<ReturnType<typeof queryRangePreviewV5>>
>;
export type QueryRangePreviewV5MutationBody =
| BodyType<Querybuildertypesv5QueryRangeRequestDTO>
| undefined;
export type QueryRangePreviewV5MutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Query range preview
*/
export const useQueryRangePreviewV5 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof queryRangePreviewV5>>,
TError,
{
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: QueryRangePreviewV5Params;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof queryRangePreviewV5>>,
TError,
{
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: QueryRangePreviewV5Params;
},
TContext
> => {
return useMutation(getQueryRangePreviewV5MutationOptions(options));
};
/**
* Replace variables in a query
* @summary Replace variables

View File

@@ -20,7 +20,6 @@ import type {
import type {
AuthtypesPatchableRoleDTO,
AuthtypesPostableRoleDTO,
AuthtypesUpdatableRoleDTO,
CoretypesPatchableObjectsDTO,
CreateRole201,
DeleteRolePathParameters,
@@ -32,7 +31,6 @@ import type {
PatchObjectsPathParameters,
PatchRolePathParameters,
RenderErrorResponseDTO,
UpdateRolePathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
@@ -367,7 +365,6 @@ export const invalidateGetRole = async (
/**
* This endpoint patches a role
* @deprecated
* @summary Patch role
*/
export const patchRole = (
@@ -439,7 +436,6 @@ export type PatchRoleMutationBody =
export type PatchRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Patch role
*/
export const usePatchRole = <
@@ -466,105 +462,6 @@ export const usePatchRole = <
> => {
return useMutation(getPatchRoleMutationOptions(options));
};
/**
* This endpoint updates a role
* @summary Update role
*/
export const updateRole = (
{ id }: UpdateRolePathParameters,
authtypesUpdatableRoleDTO?: BodyType<AuthtypesUpdatableRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/roles/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: authtypesUpdatableRoleDTO,
signal,
});
};
export const getUpdateRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
TError,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
TError,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
},
TContext
> => {
const mutationKey = ['updateRole'];
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 updateRole>>,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateRole(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof updateRole>>
>;
export type UpdateRoleMutationBody =
| BodyType<AuthtypesUpdatableRoleDTO>
| undefined;
export type UpdateRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update role
*/
export const useUpdateRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRole>>,
TError,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateRole>>,
TError,
{
pathParams: UpdateRolePathParameters;
data?: BodyType<AuthtypesUpdatableRoleDTO>;
},
TContext
> => {
return useMutation(getUpdateRoleMutationOptions(options));
};
/**
* Gets all objects connected to the specified role via a given relation type
* @summary Get objects for a role by relation
@@ -668,7 +565,6 @@ export const invalidateGetObjects = async (
/**
* Patches the objects connected to the specified role via a given relation type
* @deprecated
* @summary Patch objects for a role by relation
*/
export const patchObjects = (
@@ -740,7 +636,6 @@ export type PatchObjectsMutationBody =
export type PatchObjectsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Patch objects for a role by relation
*/
export const usePatchObjects = <

View File

@@ -2224,31 +2224,15 @@ export interface AuthtypesPostableEmailPasswordSessionDTO {
password?: string;
}
export interface CoretypesObjectGroupDTO {
resource: CoretypesResourceRefDTO;
/**
* @type array
*/
selectors: string[];
}
export interface AuthtypesTransactionGroupDTO {
objectGroup: CoretypesObjectGroupDTO;
relation: AuthtypesRelationDTO;
}
export type AuthtypesTransactionGroupsDTO = AuthtypesTransactionGroupDTO[];
export interface AuthtypesPostableRoleDTO {
/**
* @type string
*/
description: string;
description?: string;
/**
* @type string
*/
name: string;
transactionGroups: AuthtypesTransactionGroupsDTO;
}
export interface AuthtypesPostableRotateTokenDTO {
@@ -2291,40 +2275,6 @@ export interface AuthtypesRoleDTO {
updatedAt?: string;
}
export interface AuthtypesRoleWithTransactionGroupsDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
description: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
transactionGroups: AuthtypesTransactionGroupsDTO;
/**
* @type string
*/
type: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export interface AuthtypesSessionContextDTO {
/**
* @type boolean
@@ -2345,14 +2295,6 @@ export interface AuthtypesUpdatableAuthDomainDTO {
config?: AuthtypesAuthDomainConfigDTO;
}
export interface AuthtypesUpdatableRoleDTO {
/**
* @type string
*/
description: string;
transactionGroups: AuthtypesTransactionGroupsDTO;
}
export interface AuthtypesUserRoleDTO {
/**
* @type string
@@ -3123,6 +3065,14 @@ export interface CommonJSONRefDTO {
$ref?: string;
}
export interface CoretypesObjectGroupDTO {
resource: CoretypesResourceRefDTO;
/**
* @type array
*/
selectors: string[];
}
export interface CoretypesPatchableObjectsDTO {
/**
* @type array,null
@@ -6627,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
@@ -6788,6 +6889,10 @@ export interface MetricsexplorertypesMetricDashboardDTO {
* @type string
*/
dashboardName: string;
/**
* @type array
*/
groupBy?: string[];
/**
* @type string
*/
@@ -7130,32 +7235,6 @@ export interface Querybuildertypesv5ColumnDescriptorDTO {
unit?: string;
}
export interface Querybuildertypesv5EstimateEntryDTO {
/**
* @type string
*/
database?: string;
/**
* @type integer
* @format int64
*/
marks?: number;
/**
* @type integer
* @format int64
*/
parts?: number;
/**
* @type integer
* @format int64
*/
rows?: number;
/**
* @type string
*/
table?: string;
}
export type Querybuildertypesv5ExecStatsDTOStepIntervals = {
[key: string]: number;
};
@@ -7196,99 +7275,6 @@ export interface Querybuildertypesv5FormatOptionsDTO {
formatTableResultForUI?: boolean;
}
export interface Querybuildertypesv5IndexStepDTO {
/**
* @type string
*/
condition?: string;
/**
* @type integer
* @format int64
*/
initialGranules?: number;
/**
* @type integer
* @format int64
*/
initialParts?: number;
/**
* @type array
*/
keys?: string[];
/**
* @type string
*/
name?: string;
/**
* @type integer
* @format int64
*/
selectedGranules?: number;
/**
* @type integer
* @format int64
*/
selectedParts?: number;
/**
* @type string
*/
type?: string;
}
export interface Querybuildertypesv5MergeTreeReadDTO {
/**
* @type array,null
*/
steps?: Querybuildertypesv5IndexStepDTO[] | null;
/**
* @type string
*/
table?: string;
}
export interface Querybuildertypesv5GranulesDTO {
/**
* @type integer
* @format int64
*/
initial?: number;
/**
* @type array
*/
reads?: Querybuildertypesv5MergeTreeReadDTO[];
/**
* @type integer
* @format int64
*/
selected?: number;
/**
* @type number
* @format double
*/
skipScore?: number;
/**
* @type integer
* @format int64
*/
skipped?: number;
}
export interface Querybuildertypesv5PreviewStatementDTO {
/**
* @type array
*/
'db.statement.args'?: unknown[];
/**
* @type string
*/
'db.statement.query'?: string;
/**
* @type array
*/
estimate?: Querybuildertypesv5EstimateEntryDTO[];
granules?: Querybuildertypesv5GranulesDTO;
}
export interface Querybuildertypesv5TimeSeriesDataDTO {
/**
* @type array,null
@@ -7370,49 +7356,6 @@ export type Querybuildertypesv5QueryDataDTO =
results?: unknown[] | null;
});
export interface Querybuildertypesv5QueryPreviewDTO {
error?: unknown;
/**
* @type number,null
*/
magnitudeScore?: number | null;
/**
* @type number,null
*/
selectivityScore?: number | null;
/**
* @type array
*/
statements?: Querybuildertypesv5PreviewStatementDTO[];
/**
* @type boolean
*/
valid?: boolean;
/**
* @type array
*/
warnings?: string[];
}
export type Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQueryAnyOf =
{ [key: string]: Querybuildertypesv5QueryPreviewDTO };
/**
* @nullable
*/
export type Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQuery =
Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQueryAnyOf | null;
/**
* Response from the v5 query range preview (dry-run) endpoint. For each query in the composite query, returns the underlying ClickHouse statement(s) it renders to without executing them (one per PromQL metric selector; exactly one for builder/ClickHouse/trace-operator queries), with the optional EXPLAIN ESTIMATE and granule analysis attached per statement when requested.
*/
export interface Querybuildertypesv5QueryRangePreviewResponseDTO {
/**
* @type object,null
*/
compositeQuery?: Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQuery;
}
export enum Querybuildertypesv5VariableTypeDTO {
query = 'query',
dynamic = 'dynamic',
@@ -9662,16 +9605,6 @@ export type ListLLMPricingRulesParams = {
* @description undefined
*/
limit?: number;
/**
* @type string
* @description undefined
*/
q?: string;
/**
* @type boolean,null
* @description undefined
*/
isOverride?: boolean | null;
};
export type ListLLMPricingRules200 = {
@@ -9781,7 +9714,7 @@ export type GetRolePathParameters = {
id: string;
};
export type GetRole200 = {
data: AuthtypesRoleWithTransactionGroupsDTO;
data: AuthtypesRoleDTO;
/**
* @type string
*/
@@ -9791,9 +9724,6 @@ export type GetRole200 = {
export type PatchRolePathParameters = {
id: string;
};
export type UpdateRolePathParameters = {
id: string;
};
export type GetObjectsPathParameters = {
id: string;
relation: string;
@@ -10541,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;
/**
@@ -10557,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;
/**
@@ -11112,22 +11104,6 @@ export type QueryRangeV5200 = {
status: string;
};
export type QueryRangePreviewV5Params = {
/**
* @type string
* @description undefined
*/
verbose?: string;
};
export type QueryRangePreviewV5200 = {
data: Querybuildertypesv5QueryRangePreviewResponseDTO;
/**
* @type string
*/
status: string;
};
export type ReplaceVariables200 = {
data: Querybuildertypesv5QueryRangeRequestDTO;
/**

View File

@@ -274,110 +274,4 @@ describe('convertV5ResponseToLegacy', () => {
},
});
});
it('clickhouse_sql scalar keeps each value column distinct (regression: all-"A" collapse)', () => {
const scalar: ScalarData = {
columns: [
{
name: 'service.name',
queryName: 'A',
aggregationIndex: 0,
columnType: 'group',
} as unknown as ScalarData['columns'][number],
{
name: 'current_availability',
queryName: 'A',
aggregationIndex: 0,
columnType: 'aggregation',
} as unknown as ScalarData['columns'][number],
{
name: 'error_budget_remaining',
queryName: 'A',
aggregationIndex: 1,
columnType: 'aggregation',
} as unknown as ScalarData['columns'][number],
{
name: 'budget_status',
queryName: 'A',
aggregationIndex: 2,
columnType: 'group',
} as unknown as ScalarData['columns'][number],
{
name: 'total_requests',
queryName: 'A',
aggregationIndex: 4,
columnType: 'aggregation',
} as unknown as ScalarData['columns'][number],
],
data: [['kuja-api_gateway-service', 99.985, 0.985, 'Healthy ✅', 2181216]],
};
const v5Data: QueryRangeResponseV5 = {
type: 'scalar',
data: { results: [scalar] },
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
};
// A clickhouse_sql envelope contributes no aggregation metadata.
const params = makeBaseParams('scalar', [
{
type: 'clickhouse_sql',
spec: {
name: 'A',
query: 'SELECT ...',
disabled: false,
},
} as unknown as QueryRangeRequestV5['compositeQuery']['queries'][number],
]);
const input: SuccessResponse<MetricRangePayloadV5, QueryRangeRequestV5> =
makeBaseSuccess({ data: v5Data }, params);
// formatForWeb=true is the table-panel path.
const result = convertV5ResponseToLegacy(input, { A: '' }, true);
const [tableEntry] = result.payload.data.result;
// Headers keep their real names instead of collapsing to "A".
expect(tableEntry.table?.columns).toStrictEqual([
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
{
name: 'current_availability',
queryName: 'A',
isValueColumn: true,
id: 'current_availability',
},
{
name: 'error_budget_remaining',
queryName: 'A',
isValueColumn: true,
id: 'error_budget_remaining',
},
{
name: 'budget_status',
queryName: 'A',
isValueColumn: false,
id: 'budget_status',
},
{
name: 'total_requests',
queryName: 'A',
isValueColumn: true,
id: 'total_requests',
},
]);
// Ids are unique, so value columns don't overwrite each other in the row.
expect(tableEntry.table?.rows?.[0]).toStrictEqual({
data: {
'service.name': 'kuja-api_gateway-service',
current_availability: 99.985,
error_budget_remaining: 0.985,
budget_status: 'Healthy ✅',
total_requests: 2181216,
},
});
});
});

View File

@@ -15,7 +15,6 @@ function getColName(
col: ScalarData['columns'][number],
legendMap: Record<string, string>,
aggregationPerQuery: Record<string, any>,
clickhouseQueryNames: Set<string>,
): string {
if (col.columnType === 'group') {
return col.name;
@@ -40,32 +39,16 @@ function getColName(
return alias || expression || col.queryName;
}
// clickhouse_sql value columns carry their real SQL alias in col.name — use
// it so each value column keeps its own header instead of collapsing onto
// the query name. Formulas/promql use placeholder names, so they fall back
// to legend || queryName.
if (clickhouseQueryNames.has(col.queryName)) {
return col.name;
}
return legend || col.queryName;
}
function getColId(
col: ScalarData['columns'][number],
aggregationPerQuery: Record<string, any>,
clickhouseQueryNames: Set<string>,
): string {
if (col.columnType === 'group') {
return col.name;
}
// clickhouse_sql value columns are keyed by their real SQL alias so multiple
// value columns stay unique instead of all collapsing onto the query name
// (which would overwrite every cell in the row with the last column's value).
if (clickhouseQueryNames.has(col.queryName)) {
return col.name;
}
const aggregation =
aggregationPerQuery?.[col.queryName]?.[col.aggregationIndex];
const expression = aggregation?.expression || '';
@@ -158,7 +141,6 @@ function convertScalarDataArrayToTable(
scalarDataArray: ScalarData[],
legendMap: Record<string, string>,
aggregationPerQuery: Record<string, any>,
clickhouseQueryNames: Set<string>,
): QueryDataV3[] {
// If no scalar data, return empty structure
@@ -184,10 +166,10 @@ function convertScalarDataArrayToTable(
// Collect columns for this specific query
const columns = scalarData?.columns?.map((col) => ({
name: getColName(col, legendMap, aggregationPerQuery, clickhouseQueryNames),
name: getColName(col, legendMap, aggregationPerQuery),
queryName: col.queryName,
isValueColumn: col.columnType === 'aggregation',
id: getColId(col, aggregationPerQuery, clickhouseQueryNames),
id: getColId(col, aggregationPerQuery),
}));
// Process rows for this specific query
@@ -195,13 +177,8 @@ function convertScalarDataArrayToTable(
const rowData: Record<string, any> = {};
scalarData?.columns?.forEach((col, colIndex) => {
const columnName = getColName(
col,
legendMap,
aggregationPerQuery,
clickhouseQueryNames,
);
const columnId = getColId(col, aggregationPerQuery, clickhouseQueryNames);
const columnName = getColName(col, legendMap, aggregationPerQuery);
const columnId = getColId(col, aggregationPerQuery);
rowData[columnId || columnName] = dataRow[colIndex];
});
@@ -225,7 +202,6 @@ function convertScalarWithFormatForWeb(
scalarDataArray: ScalarData[],
legendMap: Record<string, string>,
aggregationPerQuery: Record<string, any>,
clickhouseQueryNames: Set<string>,
): QueryDataV3[] {
if (!scalarDataArray || scalarDataArray.length === 0) {
return [];
@@ -234,18 +210,13 @@ function convertScalarWithFormatForWeb(
return scalarDataArray.map((scalarData) => {
const columns =
scalarData.columns?.map((col) => {
const colName = getColName(
col,
legendMap,
aggregationPerQuery,
clickhouseQueryNames,
);
const colName = getColName(col, legendMap, aggregationPerQuery);
return {
name: colName,
queryName: col.queryName,
isValueColumn: col.columnType === 'aggregation',
id: getColId(col, aggregationPerQuery, clickhouseQueryNames),
id: getColId(col, aggregationPerQuery),
};
}) || [];
@@ -318,7 +289,6 @@ function convertV5DataByType(
v5Data: any,
legendMap: Record<string, string>,
aggregationPerQuery: Record<string, any>,
clickhouseQueryNames: Set<string>,
): MetricRangePayloadV3['data'] {
switch (v5Data?.type) {
case 'time_series': {
@@ -337,7 +307,6 @@ function convertV5DataByType(
scalarData,
legendMap,
aggregationPerQuery,
clickhouseQueryNames,
);
return {
resultType: 'scalar',
@@ -404,15 +373,6 @@ export function convertV5ResponseToLegacy(
{} as Record<string, any>,
) || {};
// clickhouse_sql queries have no aggregation metadata; their value columns
// are named/keyed by the real SQL alias the response carries (see getColId).
const clickhouseQueryNames = new Set<string>(
(params?.compositeQuery?.queries ?? [])
.filter((query) => query.type === 'clickhouse_sql')
.map((query) => (query.spec as { name?: string })?.name)
.filter((name): name is string => !!name),
);
// If formatForWeb is true, return as-is (like existing logic)
if (formatForWeb && v5Data?.type === 'scalar') {
const scalarData = v5Data.data.results as ScalarData[];
@@ -420,7 +380,6 @@ export function convertV5ResponseToLegacy(
scalarData,
legendMap,
aggregationPerQuery,
clickhouseQueryNames,
);
return {
@@ -443,7 +402,6 @@ export function convertV5ResponseToLegacy(
v5Data,
legendMap,
aggregationPerQuery,
clickhouseQueryNames,
);
// Create legacy-compatible response structure

View File

@@ -29,10 +29,9 @@ const ROUTES = {
ALERTS_NEW: '/alerts/new',
ALERT_HISTORY: '/alerts/history',
ALERT_OVERVIEW: '/alerts/overview',
// TODO(H4ad): Add test to forbidden ? in this map after https://github.com/SigNoz/engineering-pod/issues/5322
ALL_CHANNELS: '/alerts?tab=Channels',
CHANNELS_NEW: '/alerts/channels/new',
CHANNELS_EDIT: '/alerts/channels/edit/:channelId',
ALL_CHANNELS: '/settings/channels',
CHANNELS_NEW: '/settings/channels/new',
CHANNELS_EDIT: '/settings/channels/edit/:channelId',
ALL_ERROR: '/exceptions',
ERROR_DETAIL: '/error-detail',
VERSION: '/status',
@@ -78,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',

View File

@@ -94,7 +94,7 @@ describe('resourceRoute', () => {
it('routes channels to the edit page', () => {
expect(resourceRoute(ResourceType.channel, 'channel-uuid-1')).toBe(
'/alerts/channels/edit/channel-uuid-1',
'/settings/channels/edit/channel-uuid-1',
);
});
});

View File

@@ -1,4 +1,4 @@
.alert-channels-container {
width: 100%;
padding: 0 var(--spacing-8);
width: 90%;
margin: 12px auto;
}

View File

@@ -1,5 +1,7 @@
.create-alert-channels-container {
width: 100%;
width: 90%;
margin: 12px auto;
border: 1px solid var(--l1-border);
background: var(--l2-background);
border-radius: 3px;

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

@@ -116,8 +116,7 @@ function CreateRoleModal({
} else {
const data: AuthtypesPostableRoleDTO = {
name: values.name,
description: values.description || '',
transactionGroups: [],
...(values.description ? { description: values.description } : {}),
};
createRole({ data });
}

View File

@@ -80,7 +80,7 @@ import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
import { useCmdK } from '../../providers/cmdKProvider';
import { routeConfig } from './config';
import { buildNavUrl, getQueryString } from './helper';
import { getQueryString } from './helper';
import {
defaultMoreMenuItems,
getUserSettingsDropdownMenuItems,
@@ -486,13 +486,12 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
const availableParams = routeConfig[key];
const queryString = getQueryString(availableParams || [], params);
const url = buildNavUrl(key, queryString);
if (pathname !== key) {
if (event && isModifierKeyPressed(event)) {
openInNewTab(url);
openInNewTab(`${key}?${queryString.join('&')}`);
} else {
history.push(url, {
history.push(`${key}?${queryString.join('&')}`, {
from: pathname,
});
}

View File

@@ -8,14 +8,3 @@ export const getQueryString = (
}
return '';
});
/**
* @deprecated This should be removed after https://github.com/SigNoz/engineering-pod/issues/5322 is done
*/
export const buildNavUrl = (key: string, queryString: string[]): string => {
if (key.includes('?')) {
const extra = queryString.filter(Boolean).join('&');
return extra ? `${key}&${extra}` : key;
}
return `${key}?${queryString.join('&')}`;
};

View File

@@ -337,7 +337,6 @@ export const settingsNavSections: SettingsNavSection[] = [
isEnabled: true,
itemKey: 'account',
},
// TODO(@SigNoz/pulse-frontend): https://github.com/SigNoz/engineering-pod/issues/5323
{
key: ROUTES.ALL_CHANNELS,
label: 'Notification Channels',

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

@@ -4,17 +4,14 @@ import { Tabs, TabsProps } from 'antd';
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import ROUTES from 'constants/routes';
import AllAlertChannels from 'container/AllAlertChannels';
import AllAlertRules from 'container/ListAlertRules';
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
import RoutingPolicies from 'container/RoutingPolicies';
import TriggeredAlerts from 'container/TriggeredAlerts';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { Cable, GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import AlertDetails from 'pages/AlertDetails';
import ChannelsEdit from 'pages/ChannelsEdit';
import ChannelsNew from 'pages/ChannelsNew';
import { AlertListSubTabs, AlertListTabs } from './types';
@@ -29,9 +26,6 @@ function AllAlertList(): JSX.Element {
const subTab = urlQuery.get('subTab');
const isAlertHistory = location.pathname === ROUTES.ALERT_HISTORY;
const isAlertOverview = location.pathname === ROUTES.ALERT_OVERVIEW;
const isChannelsNew = location.pathname === ROUTES.CHANNELS_NEW;
const isChannelsEdit = location.pathname.startsWith('/alerts/channels/edit/');
const isChannelDetails = isChannelsNew || isChannelsEdit;
const handleConfigurationTabChange = useCallback(
(subTab: string): void => {
@@ -92,22 +86,6 @@ function AllAlertList(): JSX.Element {
</div>
),
},
{
label: (
<div className="periscope-tab top-level-tab">
<Cable size={14} />
Notification Channels
</div>
),
key: AlertListTabs.CHANNELS,
children: (
<div className="alert-rules-container">
{isChannelsNew && <ChannelsNew />}
{isChannelsEdit && <ChannelsEdit />}
{!isChannelDetails && <AllAlertChannels />}
</div>
),
},
{
label: (
<div className="periscope-tab top-level-tab">
@@ -120,21 +98,11 @@ function AllAlertList(): JSX.Element {
},
];
const getActiveKey = (): string => {
if (isAlertHistory || isAlertOverview) {
return AlertListTabs.ALERT_RULES;
}
if (isChannelDetails) {
return AlertListTabs.CHANNELS;
}
return tab || AlertListTabs.ALERT_RULES;
};
return (
<Tabs
destroyInactiveTabPane
items={items}
activeKey={getActiveKey()}
activeKey={tab || AlertListTabs.ALERT_RULES}
onChange={(tab): void => {
const queryParams = new URLSearchParams();
@@ -152,9 +120,7 @@ function AllAlertList(): JSX.Element {
safeNavigate(`/alerts?${queryParams.toString()}`);
}}
className={`alerts-container ${
isAlertHistory || isAlertOverview || isChannelDetails
? 'alert-details-tabs'
: ''
isAlertHistory || isAlertOverview ? 'alert-details-tabs' : ''
}`}
tabBarExtraContent={
<HeaderRightSection

View File

@@ -7,5 +7,4 @@ export enum AlertListTabs {
TRIGGERED_ALERTS = 'TriggeredAlerts',
ALERT_RULES = 'AlertRules',
CONFIGURATION = 'Configuration',
CHANNELS = 'Channels',
}

View File

@@ -4,9 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { Typography } from '@signozhq/ui/typography';
import get from 'api/channels/get';
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import Spinner from 'components/Spinner';
import ROUTES from 'constants/routes';
import {
ChannelType,
MsTeamsChannel,
@@ -24,9 +22,9 @@ import './ChannelsEdit.styles.scss';
function ChannelsEdit(): JSX.Element {
const { t } = useTranslation();
// Extract channelId from URL pathname
// Extract channelId from URL pathname since useParams doesn't work in nested routing
const { pathname } = window.location;
const channelIdMatch = pathname.match(/\/alerts\/channels\/edit\/([^/]+)/);
const channelIdMatch = pathname.match(/\/settings\/channels\/edit\/([^/]+)/);
const channelId = channelIdMatch ? channelIdMatch[1] : undefined;
const { isFetching, isError, data, error } = useQuery<
@@ -137,25 +135,17 @@ function ChannelsEdit(): JSX.Element {
const target = prepChannelConfig();
return (
<>
<AlertBreadcrumb
items={[
{ title: 'Channels', route: ROUTES.ALL_CHANNELS },
{ title: value.name || 'Edit Channel', isLast: true },
]}
<div className="edit-alert-channels-container">
<EditAlertChannels
{...{
initialValue: {
...target.channel,
type: target.type,
name: value.name,
},
}}
/>
<div className="edit-alert-channels-container">
<EditAlertChannels
{...{
initialValue: {
...target.channel,
type: target.type,
name: value.name,
},
}}
/>
</div>
</>
</div>
);
}

View File

@@ -1,23 +0,0 @@
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import ROUTES from 'constants/routes';
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import styles from './styles.module.scss';
function ChannelsNew(): JSX.Element {
return (
<>
<AlertBreadcrumb
items={[
{ title: 'Channels', route: ROUTES.ALL_CHANNELS },
{ title: 'New Channel', isLast: true },
]}
/>
<div className={styles.content}>
<CreateAlertChannels preType={ChannelType.Slack} />
</div>
</>
);
}
export default ChannelsNew;

View File

@@ -1,4 +0,0 @@
.content {
padding: var(--spacing-8);
padding-top: 0px;
}

View File

@@ -5,7 +5,6 @@ import type {
import {
extractAggregationsPerQuery,
extractClickhouseQueryNames,
prepareScalarTables,
} from '../prepareScalarTables';
@@ -57,24 +56,6 @@ describe('extractAggregationsPerQuery', () => {
});
});
describe('extractClickhouseQueryNames', () => {
it('collects names of clickhouse_sql queries, ignoring other envelope types', () => {
const request = requestWith([
{ type: 'clickhouse_sql', spec: { name: 'A', query: 'SELECT 1' } },
{
type: 'builder_query',
spec: { name: 'B', aggregations: [{ expression: 'count()' }] },
},
{ type: 'promql', spec: { name: 'P', query: 'up' } },
]);
expect(extractClickhouseQueryNames(request)).toStrictEqual(new Set(['A']));
});
it('returns an empty set for an undefined payload', () => {
expect(extractClickhouseQueryNames(undefined)).toStrictEqual(new Set());
});
});
describe('prepareScalarTables', () => {
it('builds keyed rows with group + aggregation columns (V1 getColName/getColId parity)', () => {
const [table] = prepareScalarTables({
@@ -213,115 +194,18 @@ describe('prepareScalarTables', () => {
expect(tables.map((t) => t.queryName)).toStrictEqual(['A', 'B']);
});
it('clickhouse_sql single value column uses the SQL alias over the legend', () => {
it('queries without aggregation metadata fall back to legend || queryName', () => {
const [table] = prepareScalarTables({
results: [
scalarResult(
[
{
name: 'current_availability',
queryName: 'A',
columnType: 'aggregation',
},
],
[],
),
],
legendMap: { A: 'Legend' },
requestPayload: requestWith([
{ type: 'clickhouse_sql', spec: { name: 'A', query: 'SELECT ...' } },
]),
});
// The query is clickhouse_sql, so the response column's real SQL alias is
// used for both header and key (a single legend can't be the column name).
expect(table.columns[0].name).toBe('current_availability');
expect(table.columns[0].id).toBe('current_availability');
});
it('non-clickhouse query without aggregation metadata falls back to legend || queryName', () => {
const [table] = prepareScalarTables({
results: [
// Formulas/promql carry placeholder names and are not clickhouse_sql,
// so they must not adopt the response column name.
scalarResult(
[{ name: '__result_0', queryName: 'A', columnType: 'aggregation' }],
[],
),
],
legendMap: { A: 'Legend' },
requestPayload: requestWith([
{ type: 'promql', spec: { name: 'A', query: 'up' } },
]),
requestPayload: requestWith([]),
});
expect(table.columns[0].name).toBe('Legend');
expect(table.columns[0].id).toBe('A');
});
it('clickhouse_sql query keeps each value column distinct (regression: all-"A" collapse)', () => {
const [table] = prepareScalarTables({
results: [
scalarResult(
[
{ name: 'service.name', queryName: 'A', columnType: 'group' },
{
name: 'current_availability',
queryName: 'A',
columnType: 'aggregation',
aggregationIndex: 0,
},
{
name: 'error_budget_remaining',
queryName: 'A',
columnType: 'aggregation',
aggregationIndex: 1,
},
{ name: 'budget_status', queryName: 'A', columnType: 'group' },
{
name: 'total_requests',
queryName: 'A',
columnType: 'aggregation',
aggregationIndex: 4,
},
],
[['kuja-api_gateway-service', 99.985, 0.985, 'Healthy ✅', 2181216]],
),
],
legendMap: { A: '' },
// A clickhouse_sql envelope contributes no aggregation metadata.
requestPayload: requestWith([
{
type: 'clickhouse_sql',
spec: { name: 'A', query: 'SELECT ...' },
},
]),
});
// Headers keep their real names instead of collapsing to "A".
expect(table.columns.map((col) => col.name)).toStrictEqual([
'service.name',
'current_availability',
'error_budget_remaining',
'budget_status',
'total_requests',
]);
// Ids are unique, so value columns don't overwrite each other in the row.
expect(table.columns.map((col) => col.id)).toStrictEqual([
'service.name',
'current_availability',
'error_budget_remaining',
'budget_status',
'total_requests',
]);
expect(table.rows).toStrictEqual([
{
data: {
'service.name': 'kuja-api_gateway-service',
current_availability: 99.985,
error_budget_remaining: 0.985,
budget_status: 'Healthy ✅',
total_requests: 2181216,
},
},
]);
});
});

View File

@@ -1,6 +1,5 @@
import type {
Querybuildertypesv5ColumnDescriptorDTO,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO,
Querybuildertypesv5QueryRangeRequestDTO,
Querybuildertypesv5ScalarDataDTO,
} from 'api/generated/services/sigNoz.schemas';
@@ -45,43 +44,16 @@ export function extractAggregationsPerQuery(
return perQuery;
}
/**
* Names of the request's clickhouse_sql queries. These have no aggregation
* metadata, but their value columns carry the user's real SQL alias in the
* response `col.name` — so columns of these queries are named/keyed by that
* alias rather than collapsing onto the query name. Builder/formula/promql use
* placeholder names (`__result`/`__result_N`) and are excluded here.
*/
export function extractClickhouseQueryNames(
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined,
): Set<string> {
const names = new Set<string>();
(requestPayload?.compositeQuery?.queries ?? []).forEach((envelope) => {
if (envelope.type !== Querybuildertypesv5QueryTypeDTO.clickhouse_sql) {
return;
}
const spec = (envelope as Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO)
.spec;
if (spec?.name) {
names.add(spec.name);
}
});
return names;
}
/**
* Column display name. Group columns keep their field name; aggregation
* columns resolve alias > legend > expression > queryName — with the legend
* skipped when the query has multiple aggregations, because one legend can't
* label several value columns. clickhouse_sql columns have no aggregation
* metadata, so their value columns are named by the real SQL alias the
* response carries in `col.name`. (Port of V1 `getColName`.)
* label several value columns. (Port of V1 `getColName`.)
*/
function getColName(
col: Querybuildertypesv5ColumnDescriptorDTO,
legendMap: Record<string, string>,
aggregationsPerQuery: AggregationsPerQuery,
clickhouseQueryNames: Set<string>,
): string {
if (col.columnType === 'group') {
return col.name;
@@ -102,13 +74,6 @@ function getColName(
return alias || expression || queryName;
}
// clickhouse_sql value columns carry their real SQL alias in col.name — use
// it so each value column keeps its own header instead of collapsing onto
// the query name. Formulas/promql use placeholder names, so they fall back
// to legend || queryName.
if (clickhouseQueryNames.has(queryName)) {
return col.name;
}
return legend || queryName;
}
@@ -120,23 +85,15 @@ function getColName(
function getColId(
col: Querybuildertypesv5ColumnDescriptorDTO,
aggregationsPerQuery: AggregationsPerQuery,
clickhouseQueryNames: Set<string>,
): string {
if (col.columnType === 'group') {
return col.name;
}
const queryName = col.queryName ?? '';
// clickhouse_sql value columns are keyed by their real SQL alias so multiple
// value columns stay unique instead of all collapsing onto the query name
// (which would overwrite every cell in the row with the last column's value).
if (clickhouseQueryNames.has(queryName)) {
return col.name;
}
const aggregations = aggregationsPerQuery[queryName];
const expression = aggregations?.[col.aggregationIndex ?? 0]?.expression || '';
if ((aggregations?.length || 0) > 1 && expression) {
return `${queryName}.${expression}`;
}
@@ -162,7 +119,6 @@ export function prepareScalarTables({
requestPayload,
}: PrepareScalarTablesArgs): PanelTable[] {
const aggregationsPerQuery = extractAggregationsPerQuery(requestPayload);
const clickhouseQueryNames = extractClickhouseQueryNames(requestPayload);
return results.map((scalarData) => {
if (!scalarData) {
@@ -176,10 +132,10 @@ export function prepareScalarTables({
const queryName = scalarData.columns?.[0]?.queryName ?? '';
const columns: PanelTableColumn[] = (scalarData.columns ?? []).map((col) => ({
name: getColName(col, legendMap, aggregationsPerQuery, clickhouseQueryNames),
name: getColName(col, legendMap, aggregationsPerQuery),
queryName: col.queryName ?? '',
isValueColumn: col.columnType === 'aggregation',
id: getColId(col, aggregationsPerQuery, clickhouseQueryNames),
id: getColId(col, aggregationsPerQuery),
}));
const rows = (scalarData.data ?? []).map((dataRow) => {

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

@@ -6,7 +6,7 @@ import RouteTab from 'components/RouteTab';
import { FeatureKeys } from 'constants/features';
import ROUTES from 'constants/routes';
import { routeConfig } from 'container/SideNav/config';
import { buildNavUrl, getQueryString } from 'container/SideNav/helper';
import { getQueryString } from 'container/SideNav/helper';
import { settingsNavSections } from 'container/SideNav/menuItems';
import NavItem from 'container/SideNav/NavItem/NavItem';
import { SidebarItem } from 'container/SideNav/sideNav.types';
@@ -240,13 +240,12 @@ function SettingsPage(): JSX.Element {
const availableParams = routeConfig[key];
const queryString = getQueryString(availableParams || [], params);
const url = buildNavUrl(key, queryString);
if (pathname !== key) {
if (event && isModifierKeyPressed(event)) {
openInNewTab(url);
openInNewTab(`${key}?${queryString.join('&')}`);
} else {
history.push(url, {
history.push(`${key}?${queryString.join('&')}`, {
from: pathname,
});
}
@@ -260,6 +259,17 @@ function SettingsPage(): JSX.Element {
};
const isActiveNavItem = (key: string): boolean => {
if (pathname.startsWith(ROUTES.ALL_CHANNELS) && key === ROUTES.ALL_CHANNELS) {
return true;
}
if (
pathname.startsWith(ROUTES.CHANNELS_EDIT) &&
key === ROUTES.ALL_CHANNELS
) {
return true;
}
if (
pathname.startsWith(ROUTES.ROLES_SETTINGS) &&
key === ROUTES.ROLES_SETTINGS

View File

@@ -1,6 +1,9 @@
import { RouteTabProps } from 'components/RouteTab/types';
import ROUTES from 'constants/routes';
import AlertChannels from 'container/AllAlertChannels';
import BillingContainer from 'container/BillingContainer/BillingContainer';
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import GeneralSettings from 'container/GeneralSettings';
import GeneralSettingsCloud from 'container/GeneralSettingsCloud';
import IngestionSettings from 'container/IngestionSettings/IngestionSettings';
@@ -13,16 +16,20 @@ import RoleDetailsPage from 'container/RolesSettings/RoleDetails';
import { TFunction } from 'i18next';
import {
Backpack,
BellDot,
Bot,
Building,
Cpu,
CreditCard,
Keyboard,
Pencil,
Plus,
Shield,
Sparkles,
User,
Users,
} from '@signozhq/icons';
import ChannelsEdit from 'pages/ChannelsEdit';
import MembersSettings from 'pages/MembersSettings';
import ServiceAccountsSettings from 'pages/ServiceAccountsSettings';
import Shortcuts from 'pages/Shortcuts';
@@ -40,6 +47,19 @@ export const organizationSettings = (t: TFunction): RouteTabProps['routes'] => [
},
];
export const alertChannels = (t: TFunction): RouteTabProps['routes'] => [
{
Component: AlertChannels,
name: (
<div className="periscope-tab">
<BellDot size={16} /> {t('routes:alert_channels').toString()}
</div>
),
route: ROUTES.ALL_CHANNELS,
key: ROUTES.ALL_CHANNELS,
},
];
export const ingestionSettings = (t: TFunction): RouteTabProps['routes'] => [
{
Component: IngestionSettings,
@@ -199,3 +219,31 @@ export const mcpServerSettings = (t: TFunction): RouteTabProps['routes'] => [
key: ROUTES.MCP_SERVER,
},
];
export const createAlertChannels = (t: TFunction): RouteTabProps['routes'] => [
{
Component: (): JSX.Element => (
<CreateAlertChannels preType={ChannelType.Slack} />
),
name: (
<div className="periscope-tab">
<Plus size={16} /> {t('routes:create_alert_channels').toString()}
</div>
),
route: ROUTES.CHANNELS_NEW,
key: ROUTES.CHANNELS_NEW,
},
];
export const editAlertChannels = (t: TFunction): RouteTabProps['routes'] => [
{
Component: ChannelsEdit,
name: (
<div className="periscope-tab">
<Pencil size={16} /> {t('routes:edit_alert_channels').toString()}
</div>
),
route: ROUTES.CHANNELS_EDIT,
key: ROUTES.CHANNELS_EDIT,
},
];

View File

@@ -3,7 +3,10 @@ import { TFunction } from 'i18next';
import { ROLES, USER_ROLES } from 'types/roles';
import {
alertChannels,
billingSettings,
createAlertChannels,
editAlertChannels,
generalSettings,
ingestionSettings,
keyboardShortcuts,
@@ -57,6 +60,8 @@ export const getRoutes = (
settings.push(...ingestionSettings(t));
}
settings.push(...alertChannels(t));
// Visible to all authenticated users
settings.push(
...serviceAccountsSettings(t),
@@ -75,6 +80,8 @@ export const getRoutes = (
settings.push(
...mySettings(t),
...createAlertChannels(t),
...editAlertChannels(t),
...keyboardShortcuts(t),
...mcpServerSettings(t),
);

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'],

2
go.mod
View File

@@ -180,7 +180,7 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/ClickHouse/ch-go v0.71.0
github.com/ClickHouse/ch-go v0.71.0 // indirect
github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/Yiling-J/theine-go v0.6.2 // indirect
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b

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

@@ -451,23 +451,6 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v5/query_range/preview", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.QueryRangePreview), handler.OpenAPIDef{
ID: "QueryRangePreviewV5",
Tags: []string{"querier"},
Summary: "Query range preview",
Description: "Validate a composite query without executing it. Accepts the same payload as the query range endpoint. By default (verbose=true) returns, for each query, the rendered underlying ClickHouse statement(s) with each statement's EXPLAIN ESTIMATE (per-table parts/rows/marks) and granule index analysis (candidate/surviving granules, skip score, and the per-index pruning funnel), plus two top-level scores: selectivityScore (0-100 granule-skip selectivity; higher is better) and magnitudeScore (0-100 absolute scan cost; higher is cheaper). Pass ?verbose=false for the lightweight per-query verdict (valid/error/warnings) with no rendered SQL and no ClickHouse round trips. Intended for agentic/dry-run consumption: per-query errors are reported in the response rather than failing the whole request.",
Request: new(qbtypes.QueryRangeRequest),
RequestQuery: new(qbtypes.QueryRangePreviewParams),
RequestContentType: "application/json",
Response: new(qbtypes.QueryRangePreviewResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v5/substitute_vars", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.ReplaceVariables), handler.OpenAPIDef{
ID: "ReplaceVariables",
Tags: []string{"querier"},

View File

@@ -73,7 +73,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
Description: "This endpoint gets a role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.RoleWithTransactionGroups),
Response: new(authtypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -91,60 +91,6 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Update, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateRole",
Tags: []string{"role"},
Summary: "Update role",
Description: "This endpoint updates a role",
Request: new(authtypes.UpdatableRole),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Delete, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteRole",
Tags: []string{"role"},
Summary: "Delete role",
Description: "This endpoint deletes a role",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.GetObjects, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
@@ -185,7 +131,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: true,
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
@@ -212,7 +158,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: true,
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
@@ -226,5 +172,32 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Delete, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteRole",
Tags: []string{"role"},
Summary: "Delete role",
Description: "This endpoint deletes a role",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -33,8 +33,8 @@ type AuthZ interface {
// Lists the selectors for objects assigned to subject (s) with relation (r) on resource (s)
ListObjects(context.Context, string, authtypes.Relation, coretypes.Type) ([]*coretypes.Object, error)
// Creates the role with its transaction groups.
Create(context.Context, valuer.UUID, *authtypes.RoleWithTransactionGroups) error
// Creates the role.
Create(context.Context, valuer.UUID, *authtypes.Role) error
// Gets the role if it exists or creates one.
GetOrCreate(context.Context, valuer.UUID, *authtypes.Role) (*authtypes.Role, error)
@@ -48,18 +48,12 @@ type AuthZ interface {
// Patches the objects in authorization server associated with the given role and relation
PatchObjects(context.Context, valuer.UUID, string, authtypes.Relation, []*coretypes.Object, []*coretypes.Object) error
// Updates the role's metadata and reconciles its transaction groups.
Update(context.Context, valuer.UUID, *authtypes.RoleWithTransactionGroups) error
// Deletes the role and tuples in authorization server.
Delete(context.Context, valuer.UUID, valuer.UUID) error
// Gets the role
Get(context.Context, valuer.UUID, valuer.UUID) (*authtypes.Role, error)
// Gets the role with transaction groups
GetWithTransactionGroups(context.Context, valuer.UUID, valuer.UUID) (*authtypes.RoleWithTransactionGroups, error)
// Gets the role by org_id and name
GetByOrgIDAndName(context.Context, valuer.UUID, string) (*authtypes.Role, error)
@@ -107,8 +101,6 @@ type Handler interface {
PatchObjects(http.ResponseWriter, *http.Request)
Update(http.ResponseWriter, *http.Request)
Check(http.ResponseWriter, *http.Request)
Delete(http.ResponseWriter, *http.Request)

View File

@@ -83,10 +83,6 @@ func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.
return provider.store.Get(ctx, orgID, id)
}
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, error) {
return nil, errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.Role, error) {
return provider.store.GetByOrgIDAndName(ctx, orgID, name)
}
@@ -172,7 +168,7 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
return provider.Grant(ctx, orgID, []string{authtypes.SigNozAdminRoleName}, authtypes.MustNewSubject(coretypes.NewResourceUser(), userID.String(), orgID, nil))
}
func (setter *provider) Create(_ context.Context, _ valuer.UUID, _ *authtypes.RoleWithTransactionGroups) error {
func (setter *provider) Create(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}
@@ -184,10 +180,6 @@ func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id
return nil, errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) Update(_ context.Context, _ valuer.UUID, _ *authtypes.RoleWithTransactionGroups) error {
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) Patch(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}

View File

@@ -212,30 +212,6 @@ func (server *Server) CheckWithTupleCreationWithoutClaims(ctx context.Context, o
}
func (server *Server) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
maxTuplesPerWrite := server.config.OpenFGA.MaxTuplesPerWrite
if len(additions)+len(deletions) <= maxTuplesPerWrite {
return server.write(ctx, additions, deletions)
}
for idx := 0; idx < len(additions); idx += maxTuplesPerWrite {
end := min(idx+maxTuplesPerWrite, len(additions))
if err := server.write(ctx, additions[idx:end], nil); err != nil {
return err
}
}
for idx := 0; idx < len(deletions); idx += maxTuplesPerWrite {
end := min(idx+maxTuplesPerWrite, len(deletions))
if err := server.write(ctx, nil, deletions[idx:end]); err != nil {
return err
}
}
return nil
}
func (server *Server) write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
if len(additions) == 0 && len(deletions) == 0 {
return nil
}

View File

@@ -36,14 +36,14 @@ func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
return
}
roleWithTransactionGroups := authtypes.NewRoleWithTransactionGroups(req.Name, req.Description, authtypes.RoleTypeCustom, valuer.MustNewUUID(claims.OrgID), req.TransactionGroups)
err = handler.authz.Create(ctx, valuer.MustNewUUID(claims.OrgID), roleWithTransactionGroups)
role := authtypes.NewRole(req.Name, req.Description, authtypes.RoleTypeCustom, valuer.MustNewUUID(claims.OrgID))
err = handler.authz.Create(ctx, valuer.MustNewUUID(claims.OrgID), role)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, types.Identifiable{ID: roleWithTransactionGroups.ID})
render.Success(rw, http.StatusCreated, types.Identifiable{ID: role.ID})
}
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
@@ -65,13 +65,13 @@ func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
return
}
roleWithTransactionGroups, err := handler.authz.GetWithTransactionGroups(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
role, err := handler.authz.Get(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, roleWithTransactionGroups)
render.Success(rw, http.StatusOK, role)
}
func (handler *handler) GetObjects(rw http.ResponseWriter, r *http.Request) {
@@ -224,48 +224,6 @@ func (handler *handler) PatchObjects(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
id, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
req := new(authtypes.UpdatableRole)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
role, err := handler.authz.Get(ctx, valuer.MustNewUUID(claims.OrgID), id)
if err != nil {
render.Error(rw, err)
return
}
roleWithTransactionGroups := authtypes.MakeRoleWithTransactionGroups(role, nil)
err = roleWithTransactionGroups.Update(req.Description, req.TransactionGroups)
if err != nil {
render.Error(rw, err)
return
}
err = handler.authz.Update(ctx, valuer.MustNewUUID(claims.OrgID), roleWithTransactionGroups)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) Delete(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)

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

@@ -54,7 +54,7 @@ func (h *handler) List(rw http.ResponseWriter, r *http.Request) {
return
}
rules, total, err := h.module.List(ctx, orgID, q.Offset, q.Limit, q.Search, q.IsOverride)
rules, total, err := h.module.List(ctx, orgID, q.Offset, q.Limit)
if err != nil {
render.Error(rw, err)
return

View File

@@ -21,8 +21,8 @@ func NewModule(store llmpricingruletypes.Store) llmpricingrule.Module {
return &module{store: store}
}
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
return module.store.List(ctx, orgID, offset, limit, search, isOverride)
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
return module.store.List(ctx, orgID, offset, limit)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error) {
@@ -108,7 +108,7 @@ func (module *module) RecommendAgentConfig(orgID valuer.UUID, currentConfYaml []
}
func (module *module) getEnabledRules(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.LLMPricingRule, error) {
rules, _, err := module.List(ctx, orgID, 0, 10000, "", nil)
rules, _, err := module.List(ctx, orgID, 0, 10000)
if err != nil {
return nil, err
}

View File

@@ -17,25 +17,14 @@ func NewStore(sqlstore sqlstore.SQLStore) llmpricingruletypes.Store {
return &store{sqlstore: sqlstore}
}
func (store *store) List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
func (store *store) List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
rules := make([]*llmpricingruletypes.LLMPricingRule, 0)
query := store.sqlstore.
count, err := store.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&rules).
Where("org_id = ?", orgID)
if search != "" {
like := "%" + search + "%"
query = query.Where("(LOWER(model) LIKE LOWER(?) OR LOWER(provider) LIKE LOWER(?))", like, like)
}
if isOverride != nil {
query = query.Where("is_override = ?", *isOverride)
}
count, err := query.
Where("org_id = ?", orgID).
Order("created_at DESC").
Offset(offset).
Limit(limit).

View File

@@ -13,7 +13,7 @@ type Module interface {
// Since this module interacts with OpAMP, it must implement the AgentFeature interface.
agentConf.AgentFeature
List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error)
List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*llmpricingruletypes.LLMPricingRule, int, error)
Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error)
CreateOrUpdate(ctx context.Context, orgID valuer.UUID, userEmail string, rules []*llmpricingruletypes.UpdatableLLMPricingRule) (err error)
Delete(ctx context.Context, orgID, id valuer.UUID) error

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

@@ -1,94 +0,0 @@
package clickhouseprometheus
import (
"context"
"sync"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/prometheus/prometheus/prompb"
"github.com/prometheus/prometheus/storage"
)
// statementRecorder collects the ClickHouse statements a PromQL evaluation would
// run. It is safe for concurrent use because the Prometheus engine may evaluate
// (and therefore Select) multiple selectors concurrently.
type statementRecorder struct {
mu sync.Mutex
statements []prometheus.CapturedStatement
}
func (r *statementRecorder) record(query string, args []any) {
r.mu.Lock()
defer r.mu.Unlock()
r.statements = append(r.statements, prometheus.CapturedStatement{Query: query, Args: args})
}
func (r *statementRecorder) Statements() []prometheus.CapturedStatement {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]prometheus.CapturedStatement, len(r.statements))
copy(out, r.statements)
return out
}
// captureClient is a remote.ReadClient that builds the same ClickHouse SQL as
// the real client but records it instead of executing, returning an empty
// result so the engine completes without touching ClickHouse. It records the
// self-contained samples query per selector (which embeds the series-selection
// subquery), so the recorded statement reflects the actual data read.
type captureClient struct {
*client
recorder *statementRecorder
}
func (c *captureClient) Read(ctx context.Context, query *prompb.Query, _ bool) (storage.SeriesSet, error) {
// Raw-SQL passthrough ({job="rawsql", query="..."}): record the raw query.
if len(query.Matchers) == 2 {
var hasJob bool
var queryString string
for _, m := range query.Matchers {
if m.Type == prompb.LabelMatcher_EQ && m.Name == "job" && m.Value == "rawsql" {
hasJob = true
}
if m.Type == prompb.LabelMatcher_EQ && m.Name == "query" {
queryString = m.Value
}
}
if hasJob && queryString != "" {
c.recorder.record(queryString, nil)
return storage.EmptySeriesSet(), nil
}
}
var metricName string
for _, matcher := range query.Matchers {
if matcher.Name == "__name__" {
metricName = matcher.Value
}
}
// Build the series-selection subquery and the self-contained samples query
// exactly as the executing path would, but only record them.
subQuery, args, err := c.queryToClickhouseQuery(ctx, query, metricName, true)
if err != nil {
return nil, err
}
samplesQuery, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricName, subQuery, args)
c.recorder.record(samplesQuery, samplesArgs)
return storage.EmptySeriesSet(), nil
}
// captureQueryable adapts the capturing read client to storage.Queryable,
// mirroring how the real provider wraps its querier.
type captureQueryable struct {
inner storage.SampleAndChunkQueryable
}
func (c captureQueryable) Querier(mint, maxt int64) (storage.Querier, error) {
querier, err := c.inner.Querier(mint, maxt)
if err != nil {
return nil, err
}
return storage.NewMergeQuerier(nil, []storage.Querier{querier}, storage.ChainedSeriesMerge), nil
}

View File

@@ -204,11 +204,8 @@ func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, qu
return fingerprints, nil
}
// buildSamplesQuery renders the samples SQL (and its args) that fetches the
// data points for the series selected by subQuery. It embeds the series
// selection as a subquery, so the returned statement is self-contained — the
// dry-run/preview path renders it without executing.
func buildSamplesQuery(start int64, end int64, metricName string, subQuery string, args []any) (string, []any) {
func (client *client) querySamples(ctx context.Context, start int64, end int64, fingerprints map[uint64][]prompb.Label, metricName string, subQuery string, args []any) ([]*prompb.TimeSeries, error) {
ctx = client.withClickhousePrometheusContext(ctx, "querySamples")
argCount := len(args)
query := fmt.Sprintf(`
@@ -220,13 +217,6 @@ func buildSamplesQuery(start int64, end int64, metricName string, subQuery strin
allArgs := append([]any{metricName}, args...)
allArgs = append(allArgs, start, end)
return query, allArgs
}
func (client *client) querySamples(ctx context.Context, start int64, end int64, fingerprints map[uint64][]prompb.Label, metricName string, subQuery string, args []any) ([]*prompb.TimeSeries, error) {
ctx = client.withClickhousePrometheusContext(ctx, "querySamples")
query, allArgs := buildSamplesQuery(start, end, metricName, subQuery, args)
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, allArgs...)
if err != nil {

View File

@@ -5,8 +5,8 @@ import (
"sort"
"testing"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/stretchr/testify/require"
"github.com/DATA-DOG/go-sqlmock"

View File

@@ -64,17 +64,3 @@ func (provider *provider) Querier(mint, maxt int64) (storage.Querier, error) {
return storage.NewMergeQuerier(nil, []storage.Querier{querier}, storage.ChainedSeriesMerge), nil
}
// CapturingStorage implements prometheus.StatementCapturer: it returns a Storage
// that records the ClickHouse SQL each selector would run (without executing
// it) and a recorder to read the captured statements back. A fresh recorder is
// created per call so concurrent dry-runs don't share state.
func (provider *provider) CapturingStorage() (storage.Queryable, prometheus.StatementRecorder) {
recorder := &statementRecorder{}
capture := &captureClient{
client: &client{settings: provider.settings, telemetryStore: provider.telemetryStore},
recorder: recorder,
}
queryable := remote.NewSampleAndChunkQueryableClient(capture, labels.EmptyLabels(), []*labels.Matcher{}, false, stCallback)
return captureQueryable{inner: queryable}, recorder
}

View File

@@ -15,25 +15,3 @@ type Prometheus interface {
Storage() storage.Queryable
Parser() Parser
}
// CapturedStatement is one underlying datastore statement that a PromQL query would
// run, captured without executing it.
type CapturedStatement struct {
Query string
Args []any
}
// StatementRecorder collects the Statements captured while a PromQL query is
// evaluated against a capturing Storage (see StatementCapturer).
type StatementRecorder interface {
Statements() []CapturedStatement
}
// StatementCapturer is an optional capability of a Prometheus provider: it
// returns a Storage that records the datastore statement(s) each Select would
// run — without executing them — together with a recorder to read them back.
// The query dry-run path discovers it via a type assertion, so providers that
// do not implement it simply expose no underlying SQL.
type StatementCapturer interface {
CapturingStorage() (storage.Queryable, StatementRecorder)
}

View File

@@ -73,61 +73,6 @@ func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, queryRangeResponse)
}
// QueryRangePreview is the dry-run counterpart of QueryRange. It accepts the
// same payload, validates and renders the underlying SQL/PromQL for each query
// without executing it, and returns the per-query statements. ?verbose defaults
// to true: each rendered statement carries its ClickHouse EXPLAIN ESTIMATE and
// granule index analysis (with the top-level scores). ?verbose=false returns the
// lightweight verdict-only response with no rendered SQL.
func (handler *handler) QueryRangePreview(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "querier",
instrumentationtypes.CodeFunctionName: "QueryRangePreview",
})
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
var queryRangeRequest qbtypes.QueryRangeRequest
if err := json.NewDecoder(req.Body).Decode(&queryRangeRequest); err != nil {
render.Error(rw, err)
return
}
// NB: validation is intentionally NOT done here. QueryRangePreview checks
// request-level invariants (aborting on failure) and validates each query's
// spec individually, reporting per-query structural errors in the response
// instead of failing fast on the first one — the point of the dry-run.
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
// verbose defaults to true (full preview); ?verbose=false returns the
// lightweight verdict-only response.
previewParams := qbtypes.QueryRangePreviewParams{Verbose: req.URL.Query().Get("verbose")}
previewOpts, err := previewParams.Validate()
if err != nil {
render.Error(rw, err)
return
}
preview, err := handler.querier.QueryRangePreview(ctx, orgID, &queryRangeRequest, previewOpts)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, preview)
}
func (handler *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()

View File

@@ -194,12 +194,6 @@ func (q *builderQuery[T]) isWindowList() bool {
return true
}
// Statement renders the SQL statement for the builder query without executing
// it. It is used by the dry-run/preview path.
func (q *builderQuery[T]) Statement(ctx context.Context) (*qbtypes.Statement, error) {
return q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.variables)
}
func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error) {
// can we do window based pagination?

View File

@@ -99,16 +99,6 @@ func (q *chSQLQuery) renderVars(query string, vars map[string]qbtypes.VariableIt
return newQuery.String(), nil
}
// Statement renders the SQL statement for the ClickHouse SQL query without
// executing it. It is used by the dry-run/preview path.
func (q *chSQLQuery) Statement(_ context.Context) (*qbtypes.Statement, error) {
rendered, err := q.renderVars(q.query.Query, q.vars, q.fromMS, q.toMS)
if err != nil {
return nil, err
}
return &qbtypes.Statement{Query: rendered, Args: q.args}, nil
}
func (q *chSQLQuery) Execute(ctx context.Context) (*qbtypes.Result, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.QueryDuration: instrumentationtypes.DurationBucket(q.fromMS, q.toMS),

View File

@@ -14,11 +14,6 @@ type Querier interface {
QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error)
QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream)
statsreporter.StatsCollector
// QueryRangePreview validates and renders the queries in req without
// executing them. opts controls dry-run behavior such as which
// EXPLAIN variant to attach to the response; the zero value performs
// a validation-only preview with no EXPLAIN.
QueryRangePreview(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, opts qbtypes.QueryRangePreviewOptions) (*qbtypes.QueryRangePreviewResponse, error)
}
// BucketCache is the interface for bucket-based caching.
@@ -31,10 +26,6 @@ type BucketCache interface {
type Handler interface {
QueryRange(rw http.ResponseWriter, req *http.Request)
// QueryRangePreview is the dry-run endpoint: it validates and renders the
// queries without executing them, optionally attaching each statement's
// ClickHouse EXPLAIN ESTIMATE (?estimate=) and granule analysis (?granules=).
QueryRangePreview(rw http.ResponseWriter, req *http.Request)
QueryRawStream(rw http.ResponseWriter, req *http.Request)
ReplaceVariables(rw http.ResponseWriter, req *http.Request)
}

View File

@@ -1,637 +0,0 @@
package querier
import (
"context"
"encoding/json"
"fmt"
"math"
"reflect"
"slices"
"strings"
"sync"
chproto "github.com/ClickHouse/ch-go/proto"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
)
// magnitudeReferenceRows is the estimated row count treated as "fully expensive"
// (magnitude score 0) by magnitudeScoreFromRows. It's a heuristic reference — the
// point past which a scan is considered maximally costly — and is deliberately a
// single tunable constant rather than per-table, so the score is comparable
// across queries.
const magnitudeReferenceRows = 1e9
// userFacingClickHouseErrorCodes mirrors PR #10679's userFacingCHCodes: the
// ClickHouse error codes that indicate a problem with the query itself (bad SQL,
// unknown table/column, …) rather than a server-side/infra failure — i.e. the
// ones that should map to invalid input (400) instead of internal (500).
//
// TODO(#10679): once that PR lands, delete this and have explainBindCheck call
// the shared querier.mapClickHouseError so there's a single source of truth.
var userFacingClickHouseErrorCodes = map[chproto.Error]bool{
chproto.ErrSyntaxError: true,
chproto.ErrUnknownTable: true,
chproto.ErrUnknownDatabase: true,
chproto.ErrUnknownIdentifier: true,
chproto.ErrUnknownFunction: true,
chproto.ErrUnknownAggregateFunction: true,
chproto.ErrUnknownType: true,
chproto.ErrUnknownStorage: true,
chproto.ErrUnknownElementInAst: true,
chproto.ErrUnknownTypeOfQuery: true,
chproto.ErrIllegalTypeOfArgument: true,
chproto.ErrIllegalColumn: true,
chproto.ErrNumberOfArgumentsDoesntMatch: true,
chproto.ErrTooManyArgumentsForFunction: true,
chproto.ErrTooLessArgumentsForFunction: true,
}
// statementProvider is implemented by query types that can render the
// underlying SQL/PromQL statement without executing it.
type statementProvider interface {
Statement(ctx context.Context) (*qbtypes.Statement, error)
}
// previewTask is one rendered ClickHouse statement queued for ClickHouse-bound
// preview work (the granules and/or estimate analysis). stmtIdx is the index into
// the query's Statements list that this task's results merge back into.
type previewTask struct {
name string
stmtIdx int
query string
args []any
}
type explainPlanNode struct {
NodeType string `json:"Node Type"`
Description string `json:"Description"`
Indexes []explainPlanIndex `json:"Indexes"`
Plans []explainPlanNode `json:"Plans"`
}
type explainPlanIndex struct {
Type string `json:"Type"`
Name string `json:"Name"`
Keys []string `json:"Keys"`
Condition string `json:"Condition"`
InitialParts *int64 `json:"Initial Parts"`
SelectedParts *int64 `json:"Selected Parts"`
InitialGranules *int64 `json:"Initial Granules"`
SelectedGranules *int64 `json:"Selected Granules"`
}
// QueryRangePreview validates each query in the composite query without
// executing it. With opts.Verbose=false it returns a lightweight per-query
// verdict (valid/error/warnings). With opts.Verbose=true it also renders the
// underlying ClickHouse statement(s) each query would run and attaches, per
// statement, the EXPLAIN ESTIMATE and granule index analysis, deriving the
// top-level SelectivityScore and MagnitudeScore.
func (q *querier) QueryRangePreview(
ctx context.Context,
_ valuer.UUID,
req *qbtypes.QueryRangeRequest,
opts qbtypes.QueryRangePreviewOptions,
) (*qbtypes.QueryRangePreviewResponse, error) {
validationOpts, err := req.ValidateRequestScope()
if err != nil {
return nil, err
}
dependencyQueries, err := q.constructTraceOperatorDependencyMap(req.CompositeQuery.Queries)
if err != nil {
return nil, err
}
results := make(map[string]qbtypes.QueryPreview, len(req.CompositeQuery.Queries))
prepared := make(map[string]qbtypes.QueryPreview, len(req.CompositeQuery.Queries))
missingMetricQuerySet := make(map[string]bool)
for idx := range req.CompositeQuery.Queries {
name := req.CompositeQuery.Queries[idx].GetQueryName()
ps := qbtypes.QueryPreview{}
if vErr := req.CompositeQuery.Queries[idx].Validate(validationOpts...); vErr != nil {
ps.Error = vErr
prepared[name] = ps
continue
}
env := []qbtypes.QueryEnvelope{req.CompositeQuery.Queries[idx]}
ps.Warnings = q.adjustStepInterval(env, req.Start, req.End)
missingMetricQueries, metricWarnings, mErr := q.resolveMetricMetadata(ctx, env, req.Start, req.End)
if mErr != nil {
// Don't abort the whole preview: report this query's error and keep
// going so the agent sees every problem in one round trip.
ps.Error = mErr
} else {
ps.Warnings = append(ps.Warnings, metricWarnings...)
if len(missingMetricQueries) > 0 {
missingMetricQuerySet[name] = true
if len(metricWarnings) == 0 {
if metricNames := missingMetricNames(env[0]); len(metricNames) > 0 {
ps.Warnings = append(ps.Warnings, fmt.Sprintf(
"query %q references metric(s) %s with no data available; it will return an empty result",
name, strings.Join(metricNames, ", ")))
}
}
}
}
req.CompositeQuery.Queries[idx] = env[0]
prepared[name] = ps
}
skip := make(map[string]bool, len(prepared))
for name, ps := range prepared {
if ps.Error != nil || missingMetricQuerySet[name] {
skip[name] = true
}
}
providers, buildErrs := q.buildPreviewProviders(req, dependencyQueries, missingMetricQuerySet, skip)
// Render the statement for each query that actually executes, and collect the
// ClickHouse-bound work (granules/estimate analyses) to run concurrently.
var previewTasks []previewTask
for _, query := range req.CompositeQuery.Queries {
name := query.GetQueryName()
ps := prepared[name]
// Surface a phase-1 error (e.g. a not-found metric) without rendering.
if ps.Error != nil {
results[name] = ps
continue
}
// Every aggregation resolved to a missing metric: QueryRange returns an
// empty result for this query and renders no SQL. Mirror that.
if missingMetricQuerySet[name] {
results[name] = ps
continue
}
// A build error is this query's verdict — attribute it and move on
// instead of aborting the whole preview.
if bErr := buildErrs[name]; bErr != nil {
ps.Error = bErr
results[name] = ps
continue
}
provider, ok := providers[name]
if !ok {
// Formula/join/sub-query are valid query types that render no standalone
// statement of their own — they're evaluated from the queries they
// reference, which are previewed individually. Report them as valid with
// a note rather than failing them.
if !rendersStandaloneStatement(query.Type) {
ps.Warnings = append(ps.Warnings, fmt.Sprintf(
"query type %q has no standalone statement to preview; it is evaluated from the queries it references", query.Type.StringValue()))
results[name] = ps
continue
}
ps.Error = errors.NewInternalf(errors.CodeInternal, "query produced no provider")
results[name] = ps
continue
}
stmtProvider, ok := provider.(statementProvider)
if !ok {
ps.Error = errors.NewInternalf(errors.CodeInternal, "query does not support preview")
results[name] = ps
continue
}
stmt, sErr := stmtProvider.Statement(ctx)
if sErr != nil {
ps.Error = sErr
results[name] = ps
continue
}
ps.Warnings = append(ps.Warnings, stmt.Warnings...)
// clickhouse_sql is user-authored raw SQL; rendering only substitutes
// variables, so by itself it doesn't prove the SQL is valid. Verify it
// parses and binds (tables/columns/types resolve) via EXPLAIN PLAN —
// without executing. Builder/PromQL/trace-operator SQL is engine-generated
// and well-formed by construction, so this is scoped to clickhouse_sql.
if query.Type == qbtypes.QueryTypeClickHouseSQL {
if bindErr := q.explainBindCheck(ctx, stmt.Query, stmt.Args); bindErr != nil {
if errors.Ast(bindErr, errors.TypeInvalidInput) {
ps.Error = bindErr
results[name] = ps
continue
}
// Validity unknown (infra/non-user-facing failure) — warn, don't
// falsely mark the query invalid.
ps.Warnings = append(ps.Warnings, "could not validate ClickHouse SQL: "+bindErr.Error())
}
}
if !opts.Verbose {
results[name] = ps
continue
}
if query.Type == qbtypes.QueryTypePromQL {
if pq, ok := provider.(*promqlQuery); ok {
sqlStmts, pErr := pq.PreviewStatements(ctx)
if pErr != nil {
ps.Warnings = append(ps.Warnings, "could not render underlying ClickHouse SQL: "+pErr.Error())
} else {
for _, s := range sqlStmts {
ps.Statements = append(ps.Statements, qbtypes.PreviewStatement{Query: s.Query, Args: s.Args})
}
}
}
} else {
ps.Statements = []qbtypes.PreviewStatement{{Query: stmt.Query, Args: stmt.Args}}
}
results[name] = ps
for j := range ps.Statements {
previewTasks = append(previewTasks, previewTask{name: name, stmtIdx: j, query: ps.Statements[j].Query, args: ps.Statements[j].Args})
}
}
q.runPreviewTasks(ctx, previewTasks, results)
for name, ps := range results {
var minSelectivity, minMagnitude *float64
for i := range ps.Statements {
if g := ps.Statements[i].Granules; g != nil && (minSelectivity == nil || g.SkipScore < *minSelectivity) {
s := g.SkipScore
minSelectivity = &s
}
if est := ps.Statements[i].Estimate; len(est) > 0 {
var rows int64
for j := range est {
rows += est[j].Rows
}
if m := magnitudeScoreFromRows(rows); minMagnitude == nil || m < *minMagnitude {
minMagnitude = &m
}
}
}
if minSelectivity != nil {
ps.SelectivityScore = minSelectivity
}
if minMagnitude != nil {
ps.MagnitudeScore = minMagnitude
}
results[name] = ps
}
return &qbtypes.QueryRangePreviewResponse{
CompositeQuery: results,
}, nil
}
// missingMetricNames returns the distinct metric names referenced by a metric
// builder query, in order of first appearance. It is used to name the metric(s)
// in the warning attached to a fully-missing-metric query. Returns nil for any
// non-metric query.
func missingMetricNames(env qbtypes.QueryEnvelope) []string {
spec, ok := env.Spec.(qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation])
if !ok {
return nil
}
names := make([]string, 0, len(spec.Aggregations))
for _, agg := range spec.Aggregations {
if agg.MetricName != "" && !slices.Contains(names, agg.MetricName) {
names = append(names, agg.MetricName)
}
}
return names
}
func (q *querier) buildPreviewProviders(
req *qbtypes.QueryRangeRequest,
dependencyQueries map[string]bool,
missingMetricQuerySet map[string]bool,
skip map[string]bool,
) (providers map[string]qbtypes.Query, errs map[string]error) {
providers = make(map[string]qbtypes.Query)
errs = make(map[string]error)
// buildQueries records analytics on the event; the preview emits none.
event := &qbtypes.QBEvent{}
for _, query := range req.CompositeQuery.Queries {
name := query.GetQueryName()
if skip[name] {
continue
}
sub := *req // shallow copy: only CompositeQuery and RequestType are swapped
// deps is the set buildQueries skips within this composite: empty for a
// standalone query (so it gets built), and the operator's referenced
// siblings for a trace operator (so only the operator is built from it).
var deps map[string]bool
switch {
case query.GetType() == qbtypes.QueryTypeTraceOperator:
refs, rErr := q.traceOperatorPreviewComposite(req, query)
if rErr != nil {
errs[name] = rErr
continue
}
sub.CompositeQuery = qbtypes.CompositeQuery{Queries: refs}
deps = dependencyQueries
case dependencyQueries[name]:
sub.RequestType = qbtypes.RequestTypeRaw
sub.CompositeQuery = qbtypes.CompositeQuery{Queries: []qbtypes.QueryEnvelope{query}}
default:
sub.CompositeQuery = qbtypes.CompositeQuery{Queries: []qbtypes.QueryEnvelope{query}}
}
built, _, bErr := q.buildQueries(&sub, deps, missingMetricQuerySet, event)
if bErr != nil {
errs[name] = bErr
continue
}
if provider, ok := built[name]; ok {
providers[name] = provider
}
}
return providers, errs
}
// rendersStandaloneStatement reports whether a query type renders to its own
// ClickHouse/PromQL statement the preview can build and analyze. Formula, join,
// and sub-query are valid query types but carry no statement of their own —
// they're evaluated from the queries they reference — so buildQueries (and hence
// the preview) renders nothing for them. Mirrors buildQueries' switch.
func rendersStandaloneStatement(t qbtypes.QueryType) bool {
switch t {
case qbtypes.QueryTypeBuilder,
qbtypes.QueryTypePromQL,
qbtypes.QueryTypeClickHouseSQL,
qbtypes.QueryTypeTraceOperator:
return true
default:
return false
}
}
func (q *querier) traceOperatorPreviewComposite(req *qbtypes.QueryRangeRequest, operator qbtypes.QueryEnvelope) ([]qbtypes.QueryEnvelope, error) {
spec, ok := operator.Spec.(qbtypes.QueryBuilderTraceOperator)
if !ok {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid trace operator query spec %T", operator.Spec)
}
if err := spec.ParseExpression(); err != nil {
return nil, err
}
referenced := make(map[string]bool)
for _, name := range spec.CollectReferencedQueries(spec.ParsedExpression) {
referenced[name] = true
}
queries := make([]qbtypes.QueryEnvelope, 0, len(referenced)+1)
for _, qe := range req.CompositeQuery.Queries {
if referenced[qe.GetQueryName()] {
queries = append(queries, qe)
}
}
return append(queries, operator), nil
}
func (q *querier) runPreviewTasks(ctx context.Context, tasks []previewTask, previews map[string]qbtypes.QueryPreview) {
if len(tasks) == 0 {
return
}
type outcome struct {
granules *qbtypes.Granules
estimate []qbtypes.EstimateEntry
warnings []string
}
outcomes := make([]outcome, len(tasks))
var wg sync.WaitGroup
for i := range tasks {
wg.Add(1)
go func(i int) {
defer wg.Done()
t := tasks[i]
var out outcome
if granules, ok, scErr := q.computeGranuleStats(ctx, t.query, t.args); scErr != nil {
// Surface the failure instead of silently dropping the score.
out.warnings = append(out.warnings, "could not compute query score: "+scErr.Error())
} else if ok {
out.granules = &granules
}
if estimate, eErr := q.runExplainEstimate(ctx, t.query, t.args); eErr != nil {
// Surface the failure instead of silently dropping the output.
out.warnings = append(out.warnings, "could not run EXPLAIN ESTIMATE: "+eErr.Error())
} else {
out.estimate = estimate
}
outcomes[i] = out
}(i)
}
wg.Wait()
for i := range tasks {
ps := previews[tasks[i].name]
if idx := tasks[i].stmtIdx; idx >= 0 && idx < len(ps.Statements) {
if outcomes[i].granules != nil {
ps.Statements[idx].Granules = outcomes[i].granules
}
if len(outcomes[i].estimate) > 0 {
ps.Statements[idx].Estimate = outcomes[i].estimate
}
}
ps.Warnings = append(ps.Warnings, outcomes[i].warnings...)
previews[tasks[i].name] = ps
}
}
func (q *querier) runExplainEstimate(ctx context.Context, stmt string, args []any) ([]qbtypes.EstimateEntry, error) {
rows, err := q.telemetryStore.ClickhouseDB().Query(ctx, "EXPLAIN ESTIMATE "+stmt, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to run EXPLAIN ESTIMATE")
}
defer rows.Close()
colTypes := rows.ColumnTypes()
var entries []qbtypes.EstimateEntry
for rows.Next() {
dest := make([]any, len(colTypes))
for i, ct := range colTypes {
dest[i] = reflect.New(ct.ScanType()).Interface()
}
if err := rows.Scan(dest...); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan EXPLAIN ESTIMATE row")
}
var entry qbtypes.EstimateEntry
for i, ct := range colTypes {
val := reflect.ValueOf(dest[i]).Elem().Interface()
switch strings.ToLower(ct.Name()) {
case "database":
entry.Database = fmt.Sprintf("%v", val)
case "table":
entry.Table = fmt.Sprintf("%v", val)
case "parts":
entry.Parts = toInt64(val)
case "rows":
entry.Rows = toInt64(val)
case "marks":
entry.Marks = toInt64(val)
}
}
entries = append(entries, entry)
}
if err := rows.Err(); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "EXPLAIN ESTIMATE row iteration failed")
}
return entries, nil
}
// toInt64 coerces a driver-scanned numeric value (ESTIMATE's parts/rows/marks
// arrive as unsigned integers) to int64. A non-numeric value yields 0.
func toInt64(v any) int64 {
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return rv.Int()
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return int64(rv.Uint())
case reflect.Float32, reflect.Float64:
return int64(rv.Float())
default:
return 0
}
}
func (q *querier) explainBindCheck(ctx context.Context, stmt string, args []any) error {
rows, err := q.telemetryStore.ClickhouseDB().Query(ctx, "EXPLAIN PLAN "+stmt, args...)
if err != nil {
var ex *clickhouse.Exception
if errors.As(err, &ex) && userFacingClickHouseErrorCodes[chproto.Error(ex.Code)] {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid ClickHouse SQL: %s", ex.Message)
}
return err
}
rows.Close()
return nil
}
func magnitudeScoreFromRows(rows int64) float64 {
if rows <= 1 {
return 100
}
ratio := math.Log10(float64(rows)) / math.Log10(magnitudeReferenceRows)
if ratio < 0 {
ratio = 0
}
if ratio > 1 {
ratio = 1
}
return math.Round((1-ratio)*100*100) / 100 // percentage, 2 decimal places
}
func (q *querier) computeGranuleStats(ctx context.Context, stmt string, args []any) (qbtypes.Granules, bool, error) {
rows, err := q.telemetryStore.ClickhouseDB().Query(ctx, "EXPLAIN json = 1, indexes = 1 "+stmt, args...)
if err != nil {
return qbtypes.Granules{}, false, errors.WrapInternalf(err, errors.CodeInternal, "failed to run EXPLAIN for query score")
}
defer rows.Close()
// json=1 emits the plan as a single JSON document; read every row and join
// so we are robust to the driver splitting it across rows.
var sb strings.Builder
for rows.Next() {
var line string
if err := rows.Scan(&line); err != nil {
return qbtypes.Granules{}, false, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan EXPLAIN json row")
}
sb.WriteString(line)
sb.WriteByte('\n')
}
if err := rows.Err(); err != nil {
return qbtypes.Granules{}, false, errors.WrapInternalf(err, errors.CodeInternal, "EXPLAIN json row iteration failed")
}
var plans []struct {
Plan explainPlanNode `json:"Plan"`
}
if err := json.Unmarshal([]byte(sb.String()), &plans); err != nil {
return qbtypes.Granules{}, false, errors.WrapInternalf(err, errors.CodeInternal, "failed to parse EXPLAIN json")
}
var totalInitial, totalSelected int64
var reads []qbtypes.MergeTreeRead
for i := range plans {
collectMergeTreeReads(&plans[i].Plan, &reads, &totalInitial, &totalSelected)
}
if totalInitial <= 0 {
// No MergeTree index analysis in the plan — nothing to score.
return qbtypes.Granules{}, false, nil
}
if totalSelected < 0 {
totalSelected = 0
}
skippedGranules := totalInitial - totalSelected
if skippedGranules < 0 {
skippedGranules = 0
}
ratio := float64(skippedGranules) / float64(totalInitial)
score := math.Round(ratio*100*100) / 100 // percentage, 2 decimal places
return qbtypes.Granules{
Initial: totalInitial,
Selected: totalSelected,
Skipped: skippedGranules,
SkipScore: score,
Reads: reads,
}, true, nil
}
func derefInt64(p *int64) int64 {
if p == nil {
return 0
}
return *p
}
func collectMergeTreeReads(node *explainPlanNode, reads *[]qbtypes.MergeTreeRead, totalInitial, totalSelected *int64) {
if node.NodeType == "ReadFromMergeTree" && len(node.Indexes) > 0 {
steps := make([]qbtypes.IndexStep, 0, len(node.Indexes))
var initial, selected *int64
for i := range node.Indexes {
idx := node.Indexes[i]
if idx.InitialGranules != nil && initial == nil {
initial = idx.InitialGranules
}
if idx.SelectedGranules != nil {
selected = idx.SelectedGranules
}
steps = append(steps, qbtypes.IndexStep{
Type: idx.Type,
Name: idx.Name,
Keys: idx.Keys,
Condition: idx.Condition,
InitialParts: derefInt64(idx.InitialParts),
SelectedParts: derefInt64(idx.SelectedParts),
InitialGranules: derefInt64(idx.InitialGranules),
SelectedGranules: derefInt64(idx.SelectedGranules),
})
}
if initial != nil && selected != nil {
*totalInitial += *initial
*totalSelected += *selected
}
*reads = append(*reads, qbtypes.MergeTreeRead{Table: node.Description, Steps: steps})
}
for i := range node.Plans {
collectMergeTreeReads(&node.Plans[i], reads, totalInitial, totalSelected)
}
}

View File

@@ -220,68 +220,6 @@ func (q *promqlQuery) renderVars(query string, vars map[string]qbv5.VariableItem
return newQuery.String(), nil
}
// Statement renders the PromQL query string after variable substitution. It
// is used by the dry-run/preview path; PromQL queries do not have a
// SQL-style argument list.
func (q *promqlQuery) Statement(_ context.Context) (*qbv5.Statement, error) {
rendered, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
if err != nil {
return nil, err
}
return &qbv5.Statement{Query: rendered}, nil
}
// PreviewStatements returns the underlying ClickHouse statement(s) this PromQL
// query would run, captured without executing them. PromQL is evaluated by the
// Prometheus engine rather than compiled to one SQL statement: the engine calls
// the storage adapter's Select per metric selector, which builds ClickHouse
// SQL. We drive the engine with a capturing Storage that records that SQL and
// returns empty results, so nothing is read from ClickHouse. Returns nil when
// the provider does not support capture (e.g. test doubles).
func (q *promqlQuery) PreviewStatements(ctx context.Context) ([]prometheus.CapturedStatement, error) {
storer, ok := q.promEngine.(prometheus.StatementCapturer)
if !ok {
return nil, nil
}
rendered, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
if err != nil {
return nil, err
}
start := int64(querybuilder.ToNanoSecs(q.tr.From))
end := int64(querybuilder.ToNanoSecs(q.tr.To))
capStorage, recorder := storer.CapturingStorage()
qry, err := q.promEngine.Engine().NewRangeQuery(
ctx,
capStorage,
nil,
rendered,
time.Unix(0, start),
time.Unix(0, end),
q.query.Step.Duration,
)
if err != nil {
if e := tryEnhancePromQLExecError(err); e != nil {
return nil, e
}
return nil, enhancePromQLError(rendered, err)
}
defer qry.Close()
// Evaluate against the capturing storage: this drives a Select per selector
// (recording the SQL) but reads no data, so the result is discarded.
if res := qry.Exec(ctx); res.Err != nil {
if e := tryEnhancePromQLExecError(res.Err); e != nil {
return nil, e
}
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "query execution error: %v", res.Err)
}
return recorder.Statements(), nil
}
func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{

View File

@@ -92,6 +92,11 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
req.Start = querybuilder.ToMilliSecs(req.Start)
req.End = querybuilder.ToMilliSecs(req.End)
tmplVars := req.Variables
if tmplVars == nil {
tmplVars = make(map[string]qbtypes.VariableItem)
}
event := &qbtypes.QBEvent{
Version: "v5",
NumberOfQueries: len(req.CompositeQuery.Queries),
@@ -111,6 +116,9 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
// We need to set if it is unspecified or adjust it if value is not within recommended range
intervalWarnings := q.adjustStepInterval(req.CompositeQuery.Queries, req.Start, req.End)
queries := make(map[string]qbtypes.Query)
steps := make(map[string]qbtypes.Step)
missingMetricQueries, metricWarnings, err := q.resolveMetricMetadata(ctx, req.CompositeQuery.Queries, req.Start, req.End)
if err != nil {
return nil, err
@@ -120,64 +128,6 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
missingMetricQuerySet[name] = true
}
queries, steps, err := q.buildQueries(req, dependencyQueries, missingMetricQuerySet, event)
if err != nil {
return nil, err
}
preseededResults := make(map[string]any)
for _, name := range missingMetricQueries {
switch req.RequestType {
case qbtypes.RequestTypeTimeSeries:
preseededResults[name] = &qbtypes.TimeSeriesData{QueryName: name}
case qbtypes.RequestTypeScalar:
preseededResults[name] = &qbtypes.ScalarData{QueryName: name}
case qbtypes.RequestTypeRaw:
preseededResults[name] = &qbtypes.RawData{QueryName: name}
}
}
qbResp, qbErr := q.run(ctx, orgID, queries, req, steps, event, preseededResults)
if qbResp != nil {
qbResp.QBEvent = event
if len(intervalWarnings) != 0 && req.RequestType == qbtypes.RequestTypeTimeSeries {
if qbResp.Warning == nil {
qbResp.Warning = &qbtypes.QueryWarnData{
Warnings: make([]qbtypes.QueryWarnDataAdditional, len(intervalWarnings)),
}
for idx := range intervalWarnings {
qbResp.Warning.Warnings[idx] = qbtypes.QueryWarnDataAdditional{Message: intervalWarnings[idx]}
}
}
}
if len(metricWarnings) > 0 {
if qbResp.Warning == nil {
qbResp.Warning = &qbtypes.QueryWarnData{}
}
for _, w := range metricWarnings {
qbResp.Warning.Warnings = append(qbResp.Warning.Warnings, qbtypes.QueryWarnDataAdditional{
Message: w,
})
}
}
}
return qbResp, qbErr
}
func (q *querier) buildQueries(
req *qbtypes.QueryRangeRequest,
dependencyQueries map[string]bool,
missingMetricQuerySet map[string]bool,
event *qbtypes.QBEvent,
) (map[string]qbtypes.Query, map[string]qbtypes.Step, error) {
tmplVars := req.Variables
if tmplVars == nil {
tmplVars = make(map[string]qbtypes.VariableItem)
}
queries := make(map[string]qbtypes.Query)
steps := make(map[string]qbtypes.Step)
for _, query := range req.CompositeQuery.Queries {
queryName := query.GetQueryName()
@@ -190,7 +140,7 @@ func (q *querier) buildQueries(
case qbtypes.QueryTypePromQL:
promQuery, ok := query.Spec.(qbtypes.PromQuery)
if !ok {
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query spec %T", query.Spec)
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query spec %T", query.Spec)
}
promqlQuery := newPromqlQuery(q.logger, q.promEngine, promQuery, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType, tmplVars)
queries[promQuery.Name] = promqlQuery
@@ -198,14 +148,14 @@ func (q *querier) buildQueries(
case qbtypes.QueryTypeClickHouseSQL:
chQuery, ok := query.Spec.(qbtypes.ClickHouseQuery)
if !ok {
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid clickhouse query spec %T", query.Spec)
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid clickhouse query spec %T", query.Spec)
}
chSQLQuery := newchSQLQuery(q.logger, q.telemetryStore, chQuery, nil, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType, tmplVars)
queries[chQuery.Name] = chSQLQuery
case qbtypes.QueryTypeTraceOperator:
traceOpQuery, ok := query.Spec.(qbtypes.QueryBuilderTraceOperator)
if !ok {
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid trace operator query spec %T", query.Spec)
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid trace operator query spec %T", query.Spec)
}
toq := &traceOperatorQuery{
telemetryStore: q.telemetryStore,
@@ -258,12 +208,46 @@ func (q *querier) buildQueries(
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
default:
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported builder spec type %T", query.Spec)
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported builder spec type %T", query.Spec)
}
}
}
return queries, steps, nil
preseededResults := make(map[string]any)
for _, name := range missingMetricQueries {
switch req.RequestType {
case qbtypes.RequestTypeTimeSeries:
preseededResults[name] = &qbtypes.TimeSeriesData{QueryName: name}
case qbtypes.RequestTypeScalar:
preseededResults[name] = &qbtypes.ScalarData{QueryName: name}
case qbtypes.RequestTypeRaw:
preseededResults[name] = &qbtypes.RawData{QueryName: name}
}
}
qbResp, qbErr := q.run(ctx, orgID, queries, req, steps, event, preseededResults)
if qbResp != nil {
qbResp.QBEvent = event
if len(intervalWarnings) != 0 && req.RequestType == qbtypes.RequestTypeTimeSeries {
if qbResp.Warning == nil {
qbResp.Warning = &qbtypes.QueryWarnData{
Warnings: make([]qbtypes.QueryWarnDataAdditional, len(intervalWarnings)),
}
for idx := range intervalWarnings {
qbResp.Warning.Warnings[idx] = qbtypes.QueryWarnDataAdditional{Message: intervalWarnings[idx]}
}
}
}
if len(metricWarnings) > 0 {
if qbResp.Warning == nil {
qbResp.Warning = &qbtypes.QueryWarnData{}
}
for _, w := range metricWarnings {
qbResp.Warning.Warnings = append(qbResp.Warning.Warnings, qbtypes.QueryWarnDataAdditional{
Message: w,
})
}
}
}
return qbResp, qbErr
}
func (q *querier) populateQBEvent(event *qbtypes.QBEvent, queries []qbtypes.QueryEnvelope) {

View File

@@ -32,12 +32,6 @@ func (q *traceOperatorQuery) Window() (uint64, uint64) {
return q.fromMS, q.toMS
}
// Statement renders the SQL statement for the trace operator query without
// executing it. It is used by the dry-run/preview path.
func (q *traceOperatorQuery) Statement(ctx context.Context) (*qbtypes.Statement, error) {
return q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.compositeQuery)
}
func (q *traceOperatorQuery) Execute(ctx context.Context) (*qbtypes.Result, error) {
stmt, err := q.stmtBuilder.Build(
ctx,

View File

@@ -145,7 +145,7 @@ func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (PreparedWhere
"Found %d syntax errors while parsing the search expression.",
len(parserErrorListener.SyntaxErrors),
)
additionals := make([]string, 0, len(parserErrorListener.SyntaxErrors))
additionals := make([]string, len(parserErrorListener.SyntaxErrors))
for _, err := range parserErrorListener.SyntaxErrors {
if err.Error() != "" {
additionals = append(additionals, err.Error())

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,
}
}

Some files were not shown because too many files have changed in this diff Show More