mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-23 08:30:35 +01:00
Compare commits
7 Commits
feat/test_
...
issue-5340
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2345d26192 | ||
|
|
b8567664da | ||
|
|
643aac4424 | ||
|
|
2cf7ef93ea | ||
|
|
dba827ee33 | ||
|
|
467a556062 | ||
|
|
a8f6b8187e |
@@ -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))
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
|
||||
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.128.0
|
||||
image: signoz/signoz:v0.129.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.128.0
|
||||
image: signoz/signoz:v0.129.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.128.0}
|
||||
image: signoz/signoz:${VERSION:-v0.129.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.128.0}
|
||||
image: signoz/signoz:${VERSION:-v0.129.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -5022,6 +5022,169 @@ components:
|
||||
required:
|
||||
- rules
|
||||
type: object
|
||||
MetricreductionruletypesAffectedAsset:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
impactedLabels:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesAssetType'
|
||||
widget:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- id
|
||||
- name
|
||||
- impactedLabels
|
||||
type: object
|
||||
MetricreductionruletypesAssetType:
|
||||
enum:
|
||||
- dashboard
|
||||
- alert_rule
|
||||
type: string
|
||||
MetricreductionruletypesGettableReductionRule:
|
||||
properties:
|
||||
active:
|
||||
type: boolean
|
||||
effectiveFrom:
|
||||
format: date-time
|
||||
type: string
|
||||
ingestedSeries:
|
||||
minimum: 0
|
||||
type: integer
|
||||
labels:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
matchType:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesMatchType'
|
||||
metricName:
|
||||
type: string
|
||||
reducedSeries:
|
||||
minimum: 0
|
||||
type: integer
|
||||
reductionPercent:
|
||||
format: double
|
||||
type: number
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
required:
|
||||
- metricName
|
||||
- matchType
|
||||
- labels
|
||||
- effectiveFrom
|
||||
- updatedAt
|
||||
- updatedBy
|
||||
- active
|
||||
- ingestedSeries
|
||||
- reducedSeries
|
||||
- reductionPercent
|
||||
type: object
|
||||
MetricreductionruletypesGettableReductionRulePreview:
|
||||
properties:
|
||||
affectedAssets:
|
||||
items:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesAffectedAsset'
|
||||
nullable: true
|
||||
type: array
|
||||
droppedLabels:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
effectiveFrom:
|
||||
format: date-time
|
||||
type: string
|
||||
ingestedSeries:
|
||||
minimum: 0
|
||||
type: integer
|
||||
reducedSeries:
|
||||
minimum: 0
|
||||
type: integer
|
||||
reductionPercent:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- ingestedSeries
|
||||
- reducedSeries
|
||||
- reductionPercent
|
||||
- droppedLabels
|
||||
- affectedAssets
|
||||
- effectiveFrom
|
||||
type: object
|
||||
MetricreductionruletypesGettableReductionRules:
|
||||
properties:
|
||||
rules:
|
||||
items:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesGettableReductionRule'
|
||||
nullable: true
|
||||
type: array
|
||||
total:
|
||||
type: integer
|
||||
required:
|
||||
- rules
|
||||
- total
|
||||
type: object
|
||||
MetricreductionruletypesMatchType:
|
||||
enum:
|
||||
- drop
|
||||
- keep
|
||||
type: string
|
||||
MetricreductionruletypesOrder:
|
||||
enum:
|
||||
- asc
|
||||
- desc
|
||||
type: string
|
||||
MetricreductionruletypesPostableReductionRule:
|
||||
properties:
|
||||
labels:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
matchType:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesMatchType'
|
||||
required:
|
||||
- matchType
|
||||
- labels
|
||||
type: object
|
||||
MetricreductionruletypesPostableReductionRulePreview:
|
||||
properties:
|
||||
labels:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
lookbackMs:
|
||||
format: int64
|
||||
type: integer
|
||||
matchType:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesMatchType'
|
||||
metricName:
|
||||
type: string
|
||||
required:
|
||||
- metricName
|
||||
- matchType
|
||||
- labels
|
||||
type: object
|
||||
MetricreductionruletypesReductionRuleOrderBy:
|
||||
enum:
|
||||
- metricname
|
||||
- ingestedvolume
|
||||
- reducedvolume
|
||||
- reduction
|
||||
- lastupdated
|
||||
type: string
|
||||
MetricsexplorertypesInspectMetricsRequest:
|
||||
properties:
|
||||
end:
|
||||
@@ -5138,6 +5301,10 @@ components:
|
||||
type: string
|
||||
dashboardName:
|
||||
type: string
|
||||
groupBy:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
widgetId:
|
||||
type: string
|
||||
widgetName:
|
||||
@@ -15897,6 +16064,229 @@ paths:
|
||||
summary: Update metric metadata
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/{metric_name}/reduction_rule:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: Removes the volume-control (label reduction) rule for a specified
|
||||
metric, reverting it to full fidelity. Admin only; enterprise feature.
|
||||
operationId: DeleteMetricReductionRule
|
||||
parameters:
|
||||
- in: path
|
||||
name: metric_name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"451":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unavailable For Legal Reasons
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
"501":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Implemented
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Delete a metric reduction rule
|
||||
tags:
|
||||
- metrics
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the active volume-control (label reduction) rule for a
|
||||
specified metric. Enterprise feature.
|
||||
operationId: GetMetricReductionRule
|
||||
parameters:
|
||||
- in: path
|
||||
name: metric_name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesGettableReductionRule'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"451":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unavailable For Legal Reasons
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
"501":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Implemented
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get a metric reduction rule
|
||||
tags:
|
||||
- metrics
|
||||
put:
|
||||
deprecated: false
|
||||
description: Creates or updates the volume-control (label reduction) rule for
|
||||
a specified metric. The rule takes effect after a short activation delay.
|
||||
Admin only; enterprise feature.
|
||||
operationId: UpsertMetricReductionRule
|
||||
parameters:
|
||||
- in: path
|
||||
name: metric_name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesPostableReductionRule'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesGettableReductionRule'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"451":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unavailable For Legal Reasons
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
"501":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Implemented
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Create or update a metric reduction rule
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/inspect:
|
||||
post:
|
||||
deprecated: false
|
||||
@@ -16002,6 +16392,158 @@ paths:
|
||||
summary: Check if non-SigNoz metrics have been received
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/reduction_rules:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns active metric volume-control (label reduction) rules, sorted
|
||||
and paginated server-side. Enterprise feature.
|
||||
operationId: ListMetricReductionRules
|
||||
parameters:
|
||||
- in: query
|
||||
name: orderBy
|
||||
schema:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesReductionRuleOrderBy'
|
||||
- in: query
|
||||
name: order
|
||||
schema:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesOrder'
|
||||
- in: query
|
||||
name: offset
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesGettableReductionRules'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"451":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unavailable For Legal Reasons
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
"501":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Implemented
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List metric reduction rules
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/reduction_rules/preview:
|
||||
post:
|
||||
deprecated: false
|
||||
description: Estimates the series reduction and related-asset impact of a candidate
|
||||
volume-control rule without persisting it. Enterprise feature.
|
||||
operationId: PreviewMetricReductionRule
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesPostableReductionRulePreview'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/MetricreductionruletypesGettableReductionRulePreview'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"451":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unavailable For Legal Reasons
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
"501":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Implemented
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Preview a metric reduction rule
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/metrics/stats:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -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(¤t, &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()
|
||||
}
|
||||
390
ee/modules/metricreductionrule/implmetricreductionrule/module.go
Normal file
390
ee/modules/metricreductionrule/implmetricreductionrule/module.go
Normal 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
|
||||
}
|
||||
102
ee/modules/metricreductionrule/implmetricreductionrule/store.go
Normal file
102
ee/modules/metricreductionrule/implmetricreductionrule/store.go
Normal 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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6577,6 +6577,157 @@ export interface LlmpricingruletypesUpdatableLLMPricingRulesDTO {
|
||||
rules: LlmpricingruletypesUpdatableLLMPricingRuleDTO[] | null;
|
||||
}
|
||||
|
||||
export enum MetricreductionruletypesAssetTypeDTO {
|
||||
dashboard = 'dashboard',
|
||||
alert_rule = 'alert_rule',
|
||||
}
|
||||
export interface MetricreductionruletypesAffectedAssetDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
impactedLabels: string[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
type: MetricreductionruletypesAssetTypeDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
widget?: string;
|
||||
}
|
||||
|
||||
export enum MetricreductionruletypesMatchTypeDTO {
|
||||
drop = 'drop',
|
||||
keep = 'keep',
|
||||
}
|
||||
export interface MetricreductionruletypesGettableReductionRuleDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
active: boolean;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
effectiveFrom: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
ingestedSeries: number;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
labels: string[] | null;
|
||||
matchType: MetricreductionruletypesMatchTypeDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
metricName: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
reducedSeries: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
reductionPercent: number;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface MetricreductionruletypesGettableReductionRulePreviewDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
affectedAssets: MetricreductionruletypesAffectedAssetDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
droppedLabels: string[] | null;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
effectiveFrom: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
ingestedSeries: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
reducedSeries: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
reductionPercent: number;
|
||||
}
|
||||
|
||||
export interface MetricreductionruletypesGettableReductionRulesDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
rules: MetricreductionruletypesGettableReductionRuleDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
total: number;
|
||||
}
|
||||
|
||||
export enum MetricreductionruletypesOrderDTO {
|
||||
asc = 'asc',
|
||||
desc = 'desc',
|
||||
}
|
||||
export interface MetricreductionruletypesPostableReductionRuleDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
labels: string[] | null;
|
||||
matchType: MetricreductionruletypesMatchTypeDTO;
|
||||
}
|
||||
|
||||
export interface MetricreductionruletypesPostableReductionRulePreviewDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
labels: string[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
lookbackMs?: number;
|
||||
matchType: MetricreductionruletypesMatchTypeDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
metricName: string;
|
||||
}
|
||||
|
||||
export enum MetricreductionruletypesReductionRuleOrderByDTO {
|
||||
metricname = 'metricname',
|
||||
ingestedvolume = 'ingestedvolume',
|
||||
reducedvolume = 'reducedvolume',
|
||||
reduction = 'reduction',
|
||||
lastupdated = 'lastupdated',
|
||||
}
|
||||
export interface MetricsexplorertypesInspectMetricsRequestDTO {
|
||||
/**
|
||||
* @type integer
|
||||
@@ -6738,6 +6889,10 @@ export interface MetricsexplorertypesMetricDashboardDTO {
|
||||
* @type string
|
||||
*/
|
||||
dashboardName: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
groupBy?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -10316,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;
|
||||
/**
|
||||
@@ -10332,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;
|
||||
/**
|
||||
|
||||
@@ -1,67 +1,29 @@
|
||||
/* eslint-disable sonarjs/no-identical-functions */
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Skeleton } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { OPERATORS } from 'constants/antlrQueryConstants';
|
||||
import {
|
||||
DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { cloneDeep, isArray, isFunction } from 'lodash-es';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import CheckboxFilterHeader from './CheckboxFilterHeader';
|
||||
import CheckboxValueRow from './CheckboxValueRow';
|
||||
import LogsQuickFilterEmptyState from './LogsQuickFilterEmptyState';
|
||||
import { isKeyMatch } from './utils';
|
||||
import useActiveQueryIndex from './useActiveQueryIndex';
|
||||
import useCheckboxDisclosure from './useCheckboxDisclosure';
|
||||
import useCheckboxFilterActions from './useCheckboxFilterActions';
|
||||
import useCheckboxFilterState from './useCheckboxFilterState';
|
||||
import useCheckboxFilterValues from './useCheckboxFilterValues';
|
||||
|
||||
import './Checkbox.styles.scss';
|
||||
|
||||
const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
|
||||
const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
|
||||
|
||||
const SOURCES_WITH_EMPTY_STATE_ENABLED = [QuickFiltersSource.LOGS_EXPLORER];
|
||||
|
||||
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
|
||||
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
|
||||
|
||||
/**
|
||||
* Returns the correct NOT_IN operator value based on source.
|
||||
* InfraMonitoring backend expects 'nin', others expect 'not in'.
|
||||
*/
|
||||
function getNotInOperator(source: QuickFiltersSource): string {
|
||||
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
|
||||
return 'nin';
|
||||
}
|
||||
return getOperatorValue('NOT_IN');
|
||||
}
|
||||
|
||||
function setDefaultValues(
|
||||
values: string[],
|
||||
trueOrFalse: boolean,
|
||||
): Record<string, boolean> {
|
||||
const defaultState: Record<string, boolean> = {};
|
||||
values.forEach((val) => {
|
||||
defaultState[val] = trueOrFalse;
|
||||
});
|
||||
return defaultState;
|
||||
}
|
||||
interface ICheckboxProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
@@ -72,194 +34,39 @@ interface ICheckboxProps {
|
||||
export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
const { source, filter, onFilterChange } = props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
// null = no user action, true = user opened, false = user closed
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(10);
|
||||
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
|
||||
const {
|
||||
lastUsedQuery,
|
||||
currentQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
panelType,
|
||||
} = useQueryBuilder();
|
||||
|
||||
// Determine if we're in ListView mode
|
||||
const isListView = panelType === PANEL_TYPES.LIST;
|
||||
// In ListView mode, use index 0 for most sources; for TRACES_EXPLORER, use lastUsedQuery
|
||||
// Otherwise use lastUsedQuery for non-ListView modes
|
||||
const activeQueryIndex = useMemo(() => {
|
||||
if (isListView) {
|
||||
return source === QuickFiltersSource.TRACES_EXPLORER
|
||||
? lastUsedQuery || 0
|
||||
: 0;
|
||||
}
|
||||
return lastUsedQuery || 0;
|
||||
}, [isListView, source, lastUsedQuery]);
|
||||
|
||||
// Check if this filter has active filters in the query
|
||||
const isSomeFilterPresentForCurrentAttribute = useMemo(
|
||||
() =>
|
||||
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items?.some(
|
||||
(item) => isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
),
|
||||
[currentQuery.builder.queryData, activeQueryIndex, filter.attributeKey.key],
|
||||
);
|
||||
|
||||
// Derive isOpen from filter state + user action
|
||||
const isOpen = useMemo(() => {
|
||||
// If user explicitly toggled, respect that
|
||||
if (userToggleState !== null) {
|
||||
return userToggleState;
|
||||
}
|
||||
|
||||
// Auto-open if this filter has active filters in the query
|
||||
if (isSomeFilterPresentForCurrentAttribute) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise use default behavior (first 2 filters open)
|
||||
return filter.defaultOpen;
|
||||
}, [
|
||||
userToggleState,
|
||||
isOpen,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
filter.defaultOpen,
|
||||
]);
|
||||
visibleItemsCount,
|
||||
onToggleOpen,
|
||||
onShowMore,
|
||||
} = useCheckboxDisclosure({ filter, activeQueryIndex });
|
||||
|
||||
const { data, isLoading } = useGetAggregateValues(
|
||||
{
|
||||
aggregateOperator: filter.aggregateOperator || 'noop',
|
||||
dataSource: filter.dataSource || DataSource.LOGS,
|
||||
aggregateAttribute: filter.aggregateAttribute || '',
|
||||
attributeKey: filter.attributeKey.key,
|
||||
filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY,
|
||||
tagType: filter.attributeKey.type || '',
|
||||
searchText: searchText ?? '',
|
||||
},
|
||||
{
|
||||
enabled: isOpen && source !== QuickFiltersSource.METER_EXPLORER,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
const { attributeValues, isLoading } = useCheckboxFilterValues({
|
||||
filter,
|
||||
source,
|
||||
searchText,
|
||||
isOpen,
|
||||
});
|
||||
|
||||
const { data: keyValueSuggestions, isLoading: isLoadingKeyValueSuggestions } =
|
||||
useGetQueryKeyValueSuggestions({
|
||||
key: filter.attributeKey.key,
|
||||
signal: filter.dataSource || DataSource.LOGS,
|
||||
signalSource: 'meter',
|
||||
options: {
|
||||
enabled: isOpen && source === QuickFiltersSource.METER_EXPLORER,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
});
|
||||
const { currentFilterState, isFilterDisabled, isMultipleValuesTrueForTheKey } =
|
||||
useCheckboxFilterState({ filter, attributeValues, activeQueryIndex });
|
||||
|
||||
const attributeValues: string[] = useMemo(() => {
|
||||
const dataType = filter.attributeKey.dataType || DataTypes.String;
|
||||
|
||||
if (source === QuickFiltersSource.METER_EXPLORER && keyValueSuggestions) {
|
||||
// Process the response data
|
||||
const responseData = keyValueSuggestions?.data as any;
|
||||
const values = responseData.data?.values || {};
|
||||
const stringValues = values.stringValues || [];
|
||||
const numberValues = values.numberValues || [];
|
||||
|
||||
// Generate options from string values - explicitly handle empty strings
|
||||
const stringOptions = stringValues
|
||||
// Strict filtering for empty string - we'll handle it as a special case if needed
|
||||
.filter(
|
||||
(value: string | null | undefined): value is string =>
|
||||
value !== null && value !== undefined && value !== '',
|
||||
);
|
||||
|
||||
// Generate options from number values
|
||||
const numberOptions = numberValues
|
||||
.filter(
|
||||
(value: number | null | undefined): value is number =>
|
||||
value !== null && value !== undefined,
|
||||
)
|
||||
.map((value: number) => value.toString());
|
||||
|
||||
// Combine all options and make sure we don't have duplicate labels
|
||||
return [...stringOptions, ...numberOptions];
|
||||
}
|
||||
|
||||
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
|
||||
return (data?.payload?.[key] || []).filter(
|
||||
(val) => val !== undefined && val !== null,
|
||||
);
|
||||
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
|
||||
const { onChange, onClear } = useCheckboxFilterActions({
|
||||
filter,
|
||||
source,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
});
|
||||
|
||||
const setSearchTextDebounced = useDebouncedFn((...args) => {
|
||||
setSearchText(args[0] as string);
|
||||
}, DEBOUNCE_DELAY);
|
||||
|
||||
// derive the state of each filter key here in the renderer itself and keep it in sync with current query
|
||||
// also we need to keep a note of last focussed query.
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const currentFilterState = useMemo(() => {
|
||||
let filterState: Record<string, boolean> = setDefaultValues(
|
||||
attributeValues,
|
||||
false,
|
||||
);
|
||||
const filterSync = currentQuery?.builder.queryData?.[
|
||||
activeQueryIndex
|
||||
]?.filters?.items.find((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
|
||||
if (filterSync) {
|
||||
if (SELECTED_OPERATORS.includes(filterSync.op)) {
|
||||
if (isArray(filterSync.value)) {
|
||||
filterSync.value.forEach((val) => {
|
||||
filterState[String(val)] = true;
|
||||
});
|
||||
} else if (typeof filterSync.value === 'string') {
|
||||
filterState[filterSync.value] = true;
|
||||
} else if (typeof filterSync.value === 'boolean') {
|
||||
filterState[String(filterSync.value)] = true;
|
||||
} else if (typeof filterSync.value === 'number') {
|
||||
filterState[String(filterSync.value)] = true;
|
||||
}
|
||||
} else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) {
|
||||
filterState = setDefaultValues(attributeValues, true);
|
||||
if (isArray(filterSync.value)) {
|
||||
filterSync.value.forEach((val) => {
|
||||
filterState[String(val)] = false;
|
||||
});
|
||||
} else if (typeof filterSync.value === 'string') {
|
||||
filterState[filterSync.value] = false;
|
||||
} else if (typeof filterSync.value === 'boolean') {
|
||||
filterState[String(filterSync.value)] = false;
|
||||
} else if (typeof filterSync.value === 'number') {
|
||||
filterState[String(filterSync.value)] = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filterState = setDefaultValues(attributeValues, true);
|
||||
}
|
||||
return filterState;
|
||||
}, [
|
||||
attributeValues,
|
||||
currentQuery?.builder.queryData,
|
||||
filter.attributeKey,
|
||||
activeQueryIndex,
|
||||
]);
|
||||
|
||||
// disable the filter when there are multiple entries of the same attribute key present in the filter bar
|
||||
const isFilterDisabled = useMemo(
|
||||
() =>
|
||||
(currentQuery?.builder?.queryData?.[
|
||||
activeQueryIndex
|
||||
]?.filters?.items?.filter((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
)?.length || 0) > 1,
|
||||
|
||||
[currentQuery?.builder?.queryData, activeQueryIndex, filter.attributeKey],
|
||||
);
|
||||
|
||||
// variable to check if the current filter has multiple values to its name in the key op value section
|
||||
const isMultipleValuesTrueForTheKey =
|
||||
Object.values(currentFilterState).filter((val) => val).length > 1;
|
||||
|
||||
// Sort checked items to the top, then unchecked items
|
||||
const currentAttributeKeys = useMemo(() => {
|
||||
const checkedValues = attributeValues.filter(
|
||||
@@ -277,293 +84,6 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
[currentAttributeKeys, currentFilterState],
|
||||
);
|
||||
|
||||
const handleClearFilterAttribute = (): void => {
|
||||
const preparedQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item, idx) => ({
|
||||
...item,
|
||||
filter: {
|
||||
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
|
||||
filter.attributeKey.key,
|
||||
]),
|
||||
},
|
||||
filters: {
|
||||
...item.filters,
|
||||
items:
|
||||
idx === activeQueryIndex
|
||||
? item.filters?.items?.filter(
|
||||
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
|
||||
) || []
|
||||
: [...(item.filters?.items || [])],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
if (onFilterChange && isFunction(onFilterChange)) {
|
||||
onFilterChange(preparedQuery);
|
||||
} else {
|
||||
redirectWithQueryBuilderData(preparedQuery);
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = (
|
||||
value: string,
|
||||
checked: boolean,
|
||||
isOnlyOrAllClicked: boolean,
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): void => {
|
||||
const query = cloneDeep(currentQuery.builder.queryData?.[activeQueryIndex]);
|
||||
|
||||
// if only or all are clicked we do not need to worry about anything just override whatever we have
|
||||
// by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL.
|
||||
if (isOnlyOrAllClicked && query?.filters?.items) {
|
||||
const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute
|
||||
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only';
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(q) => !isKeyMatch(q.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
|
||||
if (isOnlyOrAll === 'Only') {
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS.IN),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
query.filters.items = [...query.filters.items, newFilterItem];
|
||||
}
|
||||
} else if (query?.filters?.items) {
|
||||
if (
|
||||
query.filters?.items?.some((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
)
|
||||
) {
|
||||
// if there is already a running filter for the current attribute key then
|
||||
// we split the cases by which particular operator is present right now!
|
||||
const currentFilter = query.filters?.items?.find((q) =>
|
||||
isKeyMatch(q.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
if (currentFilter) {
|
||||
const runningOperator = currentFilter?.op;
|
||||
switch (runningOperator) {
|
||||
case 'in':
|
||||
if (checked) {
|
||||
// if it's an IN operator then if we are checking another value it get's added to the
|
||||
// filter clause. example - key IN [value1, currentSelectedValue]
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [...currentFilter.value, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
// if the current state wasn't an array we make it one and add our value
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else if (!checked) {
|
||||
// if we are removing some value when the running operator is IN we filter.
|
||||
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value.filter((val) => val !== value),
|
||||
};
|
||||
|
||||
if (newFilter.value.length === 0) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
} else {
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// if not an array remove the whole thing altogether!
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'nin':
|
||||
case 'not in':
|
||||
// if the current running operator is NIN then when unchecking the value it gets
|
||||
// added to the clause like key NIN [value1 , currentUnselectedValue]
|
||||
if (!checked) {
|
||||
// in case of array add the currentUnselectedValue to the list.
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [...currentFilter.value, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
// in case of not an array make it one!
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else if (checked) {
|
||||
// opposite of above!
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value.filter((val) => val !== value),
|
||||
};
|
||||
if (newFilter.value.length === 0) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value === value ? null : currentFilter.value,
|
||||
};
|
||||
if (newFilter.value === null && query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case '=':
|
||||
if (checked) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
op: getOperatorValue(OPERATORS.IN),
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else if (!checked) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case '!=':
|
||||
if (!checked) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
op: getNotInOperator(source),
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else if (checked) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// case - when there is no filter for the current key that means all are selected right now.
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getNotInOperator(source),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
query.filters.items = [...query.filters.items, newFilterItem];
|
||||
}
|
||||
}
|
||||
const finalQuery = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
...currentQuery.builder.queryData.map((q, idx) => {
|
||||
if (idx === activeQueryIndex) {
|
||||
return query;
|
||||
}
|
||||
return q;
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
if (onFilterChange && isFunction(onFilterChange)) {
|
||||
onFilterChange(finalQuery);
|
||||
} else {
|
||||
redirectWithQueryBuilderData(finalQuery);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmptyStateWithDocsEnabled =
|
||||
SOURCES_WITH_EMPTY_STATE_ENABLED.includes(source) &&
|
||||
!searchText &&
|
||||
@@ -571,48 +91,19 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="checkbox-filter">
|
||||
<section
|
||||
className="filter-header-checkbox"
|
||||
onClick={(): void => {
|
||||
if (isOpen) {
|
||||
setUserToggleState(false);
|
||||
setVisibleItemsCount(10);
|
||||
} else {
|
||||
setUserToggleState(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section className="left-action">
|
||||
{isOpen ? (
|
||||
<ChevronDown size={13} cursor="pointer" />
|
||||
) : (
|
||||
<ChevronRight size={13} cursor="pointer" />
|
||||
)}
|
||||
<Typography.Text className="title">{filter.title}</Typography.Text>
|
||||
<CheckboxFilterHeader
|
||||
title={filter.title}
|
||||
isOpen={isOpen}
|
||||
showClearAll={!!attributeValues.length}
|
||||
onToggleOpen={onToggleOpen}
|
||||
onClear={onClear}
|
||||
/>
|
||||
{isOpen && isLoading && !attributeValues.length && (
|
||||
<section className="loading">
|
||||
<Skeleton paragraph={{ rows: 4 }} />
|
||||
</section>
|
||||
<section className="right-action">
|
||||
{isOpen && !!attributeValues.length && (
|
||||
<Typography.Text
|
||||
className="clear-all"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleClearFilterAttribute();
|
||||
}}
|
||||
>
|
||||
Clear All
|
||||
</Typography.Text>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
{isOpen &&
|
||||
(isLoading || isLoadingKeyValueSuggestions) &&
|
||||
!attributeValues.length && (
|
||||
<section className="loading">
|
||||
<Skeleton paragraph={{ rows: 4 }} />
|
||||
</section>
|
||||
)}
|
||||
{isOpen && !isLoading && !isLoadingKeyValueSuggestions && (
|
||||
)}
|
||||
{isOpen && !isLoading && (
|
||||
<>
|
||||
{!isEmptyStateWithDocsEnabled && (
|
||||
<section className="search">
|
||||
@@ -634,48 +125,24 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
data-testid="filter-separator"
|
||||
/>
|
||||
)}
|
||||
<div className="value">
|
||||
<Checkbox
|
||||
onChange={(checked): void =>
|
||||
onChange(value, checked === true, false)
|
||||
}
|
||||
value={currentFilterState[value]}
|
||||
disabled={isFilterDisabled}
|
||||
className="check-box"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
'checkbox-value-section',
|
||||
isFilterDisabled ? 'filter-disabled' : '',
|
||||
)}
|
||||
onClick={(): void => {
|
||||
if (isFilterDisabled) {
|
||||
return;
|
||||
}
|
||||
onChange(value, currentFilterState[value], true);
|
||||
}}
|
||||
>
|
||||
<div className={`${filter.title} label-${value}`} />
|
||||
{filter.customRendererForValue ? (
|
||||
filter.customRendererForValue(value)
|
||||
) : (
|
||||
<Typography.Text className="value-string" truncate={1}>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Button type="text" className="only-btn">
|
||||
{isSomeFilterPresentForCurrentAttribute
|
||||
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only'}
|
||||
</Button>
|
||||
<Button type="text" className="toggle-btn">
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CheckboxValueRow
|
||||
value={value}
|
||||
checked={currentFilterState[value]}
|
||||
disabled={isFilterDisabled}
|
||||
title={filter.title}
|
||||
onlyButtonLabel={
|
||||
isSomeFilterPresentForCurrentAttribute
|
||||
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only'
|
||||
}
|
||||
customRendererForValue={filter.customRendererForValue}
|
||||
onCheckboxChange={(checked): void => onChange(value, checked, false)}
|
||||
onOnlyOrAllClick={(): void =>
|
||||
onChange(value, currentFilterState[value], true)
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</section>
|
||||
@@ -688,10 +155,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
)}
|
||||
{visibleItemsCount < attributeValues?.length && (
|
||||
<section className="show-more">
|
||||
<Typography.Text
|
||||
className="show-more-text"
|
||||
onClick={(): void => setVisibleItemsCount((prev) => prev + 10)}
|
||||
>
|
||||
<Typography.Text className="show-more-text" onClick={onShowMore}>
|
||||
Show More...
|
||||
</Typography.Text>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
|
||||
interface CheckboxFilterHeaderProps {
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
showClearAll: boolean;
|
||||
onToggleOpen: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
function CheckboxFilterHeader({
|
||||
title,
|
||||
isOpen,
|
||||
showClearAll,
|
||||
onToggleOpen,
|
||||
onClear,
|
||||
}: CheckboxFilterHeaderProps): JSX.Element {
|
||||
return (
|
||||
<section className="filter-header-checkbox" onClick={onToggleOpen}>
|
||||
<section className="left-action">
|
||||
{isOpen ? (
|
||||
<ChevronDown size={13} cursor="pointer" />
|
||||
) : (
|
||||
<ChevronRight size={13} cursor="pointer" />
|
||||
)}
|
||||
<Typography.Text className="title">{title}</Typography.Text>
|
||||
</section>
|
||||
<section className="right-action">
|
||||
{isOpen && showClearAll && (
|
||||
<Typography.Text
|
||||
className="clear-all"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onClear();
|
||||
}}
|
||||
>
|
||||
Clear All
|
||||
</Typography.Text>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default CheckboxFilterHeader;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Button } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
interface CheckboxValueRowProps {
|
||||
value: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
title: string;
|
||||
onlyButtonLabel: string;
|
||||
customRendererForValue?: (value: string) => JSX.Element;
|
||||
onCheckboxChange: (checked: boolean) => void;
|
||||
onOnlyOrAllClick: () => void;
|
||||
}
|
||||
|
||||
function CheckboxValueRow({
|
||||
value,
|
||||
checked,
|
||||
disabled,
|
||||
title,
|
||||
onlyButtonLabel,
|
||||
customRendererForValue,
|
||||
onCheckboxChange,
|
||||
onOnlyOrAllClick,
|
||||
}: CheckboxValueRowProps): JSX.Element {
|
||||
return (
|
||||
<div className="value">
|
||||
<Checkbox
|
||||
onChange={(isChecked): void => onCheckboxChange(isChecked === true)}
|
||||
value={checked}
|
||||
disabled={disabled}
|
||||
className="check-box"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cx('checkbox-value-section', disabled ? 'filter-disabled' : '')}
|
||||
onClick={(): void => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
onOnlyOrAllClick();
|
||||
}}
|
||||
>
|
||||
<div className={`${title} label-${value}`} />
|
||||
{customRendererForValue ? (
|
||||
customRendererForValue(value)
|
||||
) : (
|
||||
<Typography.Text className="value-string" truncate={1}>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Button type="text" className="only-btn">
|
||||
{onlyButtonLabel}
|
||||
</Button>
|
||||
<Button type="text" className="toggle-btn">
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CheckboxValueRow.defaultProps = {
|
||||
customRendererForValue: undefined,
|
||||
};
|
||||
|
||||
export default CheckboxValueRow;
|
||||
@@ -0,0 +1,417 @@
|
||||
/* eslint-disable sonarjs/no-identical-functions */
|
||||
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { OPERATORS } from 'constants/antlrQueryConstants';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { cloneDeep, isArray } from 'lodash-es';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { isKeyMatch } from './utils';
|
||||
|
||||
export const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
|
||||
export const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
|
||||
|
||||
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
|
||||
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
|
||||
|
||||
/**
|
||||
* Returns the correct NOT_IN operator value based on source.
|
||||
* InfraMonitoring backend expects 'nin', others expect 'not in'.
|
||||
*/
|
||||
export function getNotInOperator(source: QuickFiltersSource): string {
|
||||
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
|
||||
return 'nin';
|
||||
}
|
||||
return getOperatorValue('NOT_IN');
|
||||
}
|
||||
|
||||
function setDefaultValues(
|
||||
values: string[],
|
||||
trueOrFalse: boolean,
|
||||
): Record<string, boolean> {
|
||||
const defaultState: Record<string, boolean> = {};
|
||||
values.forEach((val) => {
|
||||
defaultState[val] = trueOrFalse;
|
||||
});
|
||||
return defaultState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the checked/unchecked state for each attribute value by reading the
|
||||
* active filter clause for this attribute key out of the query.
|
||||
*
|
||||
* - No matching clause -> every value is checked (all selected).
|
||||
* - IN / `=` clause -> only the listed values are checked.
|
||||
* - NOT IN / `!=` clause -> every value is checked except the excluded ones.
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function deriveCheckboxState({
|
||||
attributeValues,
|
||||
filterItems,
|
||||
filterKey,
|
||||
}: {
|
||||
attributeValues: string[];
|
||||
filterItems: TagFilterItem[] | undefined;
|
||||
filterKey: string;
|
||||
}): Record<string, boolean> {
|
||||
let filterState: Record<string, boolean> = setDefaultValues(
|
||||
attributeValues,
|
||||
false,
|
||||
);
|
||||
const filterSync = filterItems?.find((item) =>
|
||||
isKeyMatch(item.key?.key, filterKey),
|
||||
);
|
||||
|
||||
if (filterSync) {
|
||||
if (SELECTED_OPERATORS.includes(filterSync.op)) {
|
||||
if (isArray(filterSync.value)) {
|
||||
filterSync.value.forEach((val) => {
|
||||
filterState[String(val)] = true;
|
||||
});
|
||||
} else if (typeof filterSync.value === 'string') {
|
||||
filterState[filterSync.value] = true;
|
||||
} else if (typeof filterSync.value === 'boolean') {
|
||||
filterState[String(filterSync.value)] = true;
|
||||
} else if (typeof filterSync.value === 'number') {
|
||||
filterState[String(filterSync.value)] = true;
|
||||
}
|
||||
} else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) {
|
||||
filterState = setDefaultValues(attributeValues, true);
|
||||
if (isArray(filterSync.value)) {
|
||||
filterSync.value.forEach((val) => {
|
||||
filterState[String(val)] = false;
|
||||
});
|
||||
} else if (typeof filterSync.value === 'string') {
|
||||
filterState[filterSync.value] = false;
|
||||
} else if (typeof filterSync.value === 'boolean') {
|
||||
filterState[String(filterSync.value)] = false;
|
||||
} else if (typeof filterSync.value === 'number') {
|
||||
filterState[String(filterSync.value)] = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filterState = setDefaultValues(attributeValues, true);
|
||||
}
|
||||
return filterState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new query with every clause for this attribute key removed, both
|
||||
* from the structured filter items and the raw filter expression.
|
||||
*/
|
||||
export function clearFilterFromQuery({
|
||||
currentQuery,
|
||||
filter,
|
||||
activeQueryIndex,
|
||||
}: {
|
||||
currentQuery: Query;
|
||||
filter: IQuickFiltersConfig;
|
||||
activeQueryIndex: number;
|
||||
}): Query {
|
||||
return {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item, idx) => ({
|
||||
...item,
|
||||
filter: {
|
||||
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
|
||||
filter.attributeKey.key,
|
||||
]),
|
||||
},
|
||||
filters: {
|
||||
...item.filters,
|
||||
items:
|
||||
idx === activeQueryIndex
|
||||
? item.filters?.items?.filter(
|
||||
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
|
||||
) || []
|
||||
: [...(item.filters?.items || [])],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function applyCheckboxToggle({
|
||||
currentQuery,
|
||||
activeQueryIndex,
|
||||
filter,
|
||||
source,
|
||||
attributeValues,
|
||||
value,
|
||||
checked,
|
||||
isOnlyOrAllClicked,
|
||||
}: {
|
||||
currentQuery: Query;
|
||||
activeQueryIndex: number;
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
attributeValues: string[];
|
||||
value: string;
|
||||
checked: boolean;
|
||||
isOnlyOrAllClicked: boolean;
|
||||
}): Query {
|
||||
const activeItems =
|
||||
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items;
|
||||
|
||||
const isSomeFilterPresentForCurrentAttribute = !!activeItems?.some((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
|
||||
const currentFilterState = deriveCheckboxState({
|
||||
attributeValues,
|
||||
filterItems: activeItems,
|
||||
filterKey: filter.attributeKey.key,
|
||||
});
|
||||
|
||||
const isMultipleValuesTrueForTheKey =
|
||||
Object.values(currentFilterState).filter((val) => val).length > 1;
|
||||
|
||||
const query = cloneDeep(currentQuery.builder.queryData?.[activeQueryIndex]);
|
||||
|
||||
// if only or all are clicked we do not need to worry about anything just override whatever we have
|
||||
// by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL.
|
||||
if (isOnlyOrAllClicked && query?.filters?.items) {
|
||||
const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute
|
||||
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only';
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(q) => !isKeyMatch(q.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(query.filter.expression, [
|
||||
filter.attributeKey.key,
|
||||
]);
|
||||
}
|
||||
|
||||
if (isOnlyOrAll === 'Only') {
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS.IN),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
query.filters.items = [...query.filters.items, newFilterItem];
|
||||
}
|
||||
} else if (query?.filters?.items) {
|
||||
if (
|
||||
query.filters?.items?.some((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
)
|
||||
) {
|
||||
// if there is already a running filter for the current attribute key then
|
||||
// we split the cases by which particular operator is present right now!
|
||||
const currentFilter = query.filters?.items?.find((q) =>
|
||||
isKeyMatch(q.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
if (currentFilter) {
|
||||
const runningOperator = currentFilter?.op;
|
||||
switch (runningOperator) {
|
||||
case 'in':
|
||||
if (checked) {
|
||||
// if it's an IN operator then if we are checking another value it get's added to the
|
||||
// filter clause. example - key IN [value1, currentSelectedValue]
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [...currentFilter.value, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
// if the current state wasn't an array we make it one and add our value
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else if (!checked) {
|
||||
// if we are removing some value when the running operator is IN we filter.
|
||||
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value.filter((val) => val !== value),
|
||||
};
|
||||
|
||||
if (newFilter.value.length === 0) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
} else {
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// if not an array remove the whole thing altogether!
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'nin':
|
||||
case 'not in':
|
||||
// if the current running operator is NIN then when unchecking the value it gets
|
||||
// added to the clause like key NIN [value1 , currentUnselectedValue]
|
||||
if (!checked) {
|
||||
// in case of array add the currentUnselectedValue to the list.
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [...currentFilter.value, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
// in case of not an array make it one!
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else if (checked) {
|
||||
// opposite of above!
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value.filter((val) => val !== value),
|
||||
};
|
||||
if (newFilter.value.length === 0) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value === value ? null : currentFilter.value,
|
||||
};
|
||||
if (newFilter.value === null && query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case '=':
|
||||
if (checked) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
op: getOperatorValue(OPERATORS.IN),
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else if (!checked) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case '!=':
|
||||
if (!checked) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
op: getNotInOperator(source),
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else if (checked) {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// case - when there is no filter for the current key that means all are selected right now.
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getNotInOperator(source),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
query.filters.items = [...query.filters.items, newFilterItem];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
...currentQuery.builder.queryData.map((q, idx) => {
|
||||
if (idx === activeQueryIndex) {
|
||||
return query;
|
||||
}
|
||||
return q;
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useMemo } from 'react';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
/**
|
||||
* Resolves which query-builder query index the checkbox filter reads from and
|
||||
* writes to.
|
||||
*
|
||||
* In ListView most sources use index 0; TRACES_EXPLORER and every non-ListView
|
||||
* mode track the last focused query.
|
||||
*/
|
||||
function useActiveQueryIndex(source: QuickFiltersSource): number {
|
||||
const { lastUsedQuery, panelType } = useQueryBuilder();
|
||||
const isListView = panelType === PANEL_TYPES.LIST;
|
||||
|
||||
return useMemo(() => {
|
||||
if (isListView) {
|
||||
return source === QuickFiltersSource.TRACES_EXPLORER
|
||||
? lastUsedQuery || 0
|
||||
: 0;
|
||||
}
|
||||
return lastUsedQuery || 0;
|
||||
}, [isListView, source, lastUsedQuery]);
|
||||
}
|
||||
|
||||
export default useActiveQueryIndex;
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
import { isKeyMatch } from './utils';
|
||||
|
||||
const DEFAULT_VISIBLE_ITEMS_COUNT = 10;
|
||||
|
||||
interface UseCheckboxDisclosureProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
activeQueryIndex: number;
|
||||
}
|
||||
|
||||
interface UseCheckboxDisclosureReturn {
|
||||
isOpen: boolean;
|
||||
isSomeFilterPresentForCurrentAttribute: boolean;
|
||||
visibleItemsCount: number;
|
||||
onToggleOpen: () => void;
|
||||
onShowMore: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the open/collapsed state of a checkbox filter section and how many
|
||||
* values are visible.
|
||||
*
|
||||
* Auto-opens when the query already has a clause for this attribute, otherwise
|
||||
* falls back to `filter.defaultOpen`. An explicit user toggle always wins.
|
||||
* Collapsing resets the visible count.
|
||||
*/
|
||||
function useCheckboxDisclosure({
|
||||
filter,
|
||||
activeQueryIndex,
|
||||
}: UseCheckboxDisclosureProps): UseCheckboxDisclosureReturn {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
// null = no user action, true = user opened, false = user closed
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(
|
||||
DEFAULT_VISIBLE_ITEMS_COUNT,
|
||||
);
|
||||
|
||||
const isSomeFilterPresentForCurrentAttribute = useMemo(
|
||||
() =>
|
||||
!!currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items?.some(
|
||||
(item) => isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
),
|
||||
[currentQuery.builder.queryData, activeQueryIndex, filter.attributeKey.key],
|
||||
);
|
||||
|
||||
const isOpen = useMemo(() => {
|
||||
// If user explicitly toggled, respect that
|
||||
if (userToggleState !== null) {
|
||||
return userToggleState;
|
||||
}
|
||||
|
||||
// Auto-open if this filter has active filters in the query
|
||||
if (isSomeFilterPresentForCurrentAttribute) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise use default behavior (first 2 filters open)
|
||||
return filter.defaultOpen;
|
||||
}, [
|
||||
userToggleState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
filter.defaultOpen,
|
||||
]);
|
||||
|
||||
const onToggleOpen = (): void => {
|
||||
if (isOpen) {
|
||||
setUserToggleState(false);
|
||||
setVisibleItemsCount(DEFAULT_VISIBLE_ITEMS_COUNT);
|
||||
} else {
|
||||
setUserToggleState(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onShowMore = (): void => {
|
||||
setVisibleItemsCount((prev) => prev + DEFAULT_VISIBLE_ITEMS_COUNT);
|
||||
};
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
visibleItemsCount,
|
||||
onToggleOpen,
|
||||
onShowMore,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCheckboxDisclosure;
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { isFunction } from 'lodash-es';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
applyCheckboxToggle,
|
||||
clearFilterFromQuery,
|
||||
} from './checkboxFilterQuery';
|
||||
|
||||
interface UseCheckboxFilterActionsProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
attributeValues: string[];
|
||||
activeQueryIndex: number;
|
||||
onFilterChange?: ((query: Query) => void) | null;
|
||||
}
|
||||
|
||||
interface UseCheckboxFilterActionsReturn {
|
||||
onChange: (
|
||||
value: string,
|
||||
checked: boolean,
|
||||
isOnlyOrAllClicked: boolean,
|
||||
) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the pure checkbox query algebra to query-builder dispatch: the
|
||||
* caller-provided `onFilterChange` when present, otherwise a URL redirect.
|
||||
*/
|
||||
function useCheckboxFilterActions({
|
||||
filter,
|
||||
source,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
}: UseCheckboxFilterActionsProps): UseCheckboxFilterActionsReturn {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
const dispatch = (query: Query): void => {
|
||||
if (onFilterChange && isFunction(onFilterChange)) {
|
||||
onFilterChange(query);
|
||||
} else {
|
||||
redirectWithQueryBuilderData(query);
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = (
|
||||
value: string,
|
||||
checked: boolean,
|
||||
isOnlyOrAllClicked: boolean,
|
||||
): void => {
|
||||
dispatch(
|
||||
applyCheckboxToggle({
|
||||
currentQuery,
|
||||
activeQueryIndex,
|
||||
filter,
|
||||
source,
|
||||
attributeValues,
|
||||
value,
|
||||
checked,
|
||||
isOnlyOrAllClicked,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onClear = (): void => {
|
||||
dispatch(clearFilterFromQuery({ currentQuery, filter, activeQueryIndex }));
|
||||
};
|
||||
|
||||
return { onChange, onClear };
|
||||
}
|
||||
|
||||
export default useCheckboxFilterActions;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useMemo } from 'react';
|
||||
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
import { deriveCheckboxState } from './checkboxFilterQuery';
|
||||
import { isKeyMatch } from './utils';
|
||||
|
||||
interface UseCheckboxFilterStateProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
attributeValues: string[];
|
||||
activeQueryIndex: number;
|
||||
}
|
||||
|
||||
interface UseCheckboxFilterStateReturn {
|
||||
currentFilterState: Record<string, boolean>;
|
||||
isFilterDisabled: boolean;
|
||||
isMultipleValuesTrueForTheKey: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the active query and derives the per-value checked state for this
|
||||
* attribute, whether the filter is disabled (same key used more than once in
|
||||
* the filter bar), and whether more than one value is currently selected.
|
||||
*/
|
||||
function useCheckboxFilterState({
|
||||
filter,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
}: UseCheckboxFilterStateProps): UseCheckboxFilterStateReturn {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
// derive the state of each filter key here and keep it in sync with current query
|
||||
const currentFilterState = useMemo(
|
||||
() =>
|
||||
deriveCheckboxState({
|
||||
attributeValues,
|
||||
filterItems:
|
||||
currentQuery?.builder.queryData?.[activeQueryIndex]?.filters?.items,
|
||||
filterKey: filter.attributeKey.key,
|
||||
}),
|
||||
[
|
||||
attributeValues,
|
||||
currentQuery?.builder.queryData,
|
||||
filter.attributeKey,
|
||||
activeQueryIndex,
|
||||
],
|
||||
);
|
||||
|
||||
// disable the filter when there are multiple entries of the same attribute key present in the filter bar
|
||||
const isFilterDisabled = useMemo(
|
||||
() =>
|
||||
(currentQuery?.builder?.queryData?.[
|
||||
activeQueryIndex
|
||||
]?.filters?.items?.filter((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
)?.length || 0) > 1,
|
||||
[currentQuery?.builder?.queryData, activeQueryIndex, filter.attributeKey],
|
||||
);
|
||||
|
||||
// whether the current filter has multiple values to its name in the key op value section
|
||||
const isMultipleValuesTrueForTheKey =
|
||||
Object.values(currentFilterState).filter((val) => val).length > 1;
|
||||
|
||||
return {
|
||||
currentFilterState,
|
||||
isFilterDisabled,
|
||||
isMultipleValuesTrueForTheKey,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCheckboxFilterState;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY } from 'constants/queryBuilder';
|
||||
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
|
||||
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
interface UseCheckboxFilterValuesProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
searchText: string;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
interface UseCheckboxFilterValuesReturn {
|
||||
attributeValues: string[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function useCheckboxFilterValues({
|
||||
filter,
|
||||
source,
|
||||
searchText,
|
||||
isOpen,
|
||||
}: UseCheckboxFilterValuesProps): UseCheckboxFilterValuesReturn {
|
||||
const { data, isLoading } = useGetAggregateValues(
|
||||
{
|
||||
aggregateOperator: filter.aggregateOperator || 'noop',
|
||||
dataSource: filter.dataSource || DataSource.LOGS,
|
||||
aggregateAttribute: filter.aggregateAttribute || '',
|
||||
attributeKey: filter.attributeKey.key,
|
||||
filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY,
|
||||
tagType: filter.attributeKey.type || '',
|
||||
searchText: searchText ?? '',
|
||||
},
|
||||
{
|
||||
enabled: isOpen && source !== QuickFiltersSource.METER_EXPLORER,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: keyValueSuggestions, isLoading: isLoadingKeyValueSuggestions } =
|
||||
useGetQueryKeyValueSuggestions({
|
||||
key: filter.attributeKey.key,
|
||||
signal: filter.dataSource || DataSource.LOGS,
|
||||
signalSource: 'meter',
|
||||
options: {
|
||||
enabled: isOpen && source === QuickFiltersSource.METER_EXPLORER,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
});
|
||||
|
||||
const attributeValues: string[] = useMemo(() => {
|
||||
const dataType = filter.attributeKey.dataType || DataTypes.String;
|
||||
|
||||
if (source === QuickFiltersSource.METER_EXPLORER && keyValueSuggestions) {
|
||||
// Process the response data
|
||||
const responseData = keyValueSuggestions?.data as any;
|
||||
const values = responseData.data?.values || {};
|
||||
const stringValues = values.stringValues || [];
|
||||
const numberValues = values.numberValues || [];
|
||||
|
||||
// Generate options from string values - explicitly handle empty strings
|
||||
const stringOptions = stringValues
|
||||
// Strict filtering for empty string - we'll handle it as a special case if needed
|
||||
.filter(
|
||||
(value: string | null | undefined): value is string =>
|
||||
value !== null && value !== undefined && value !== '',
|
||||
);
|
||||
|
||||
// Generate options from number values
|
||||
const numberOptions = numberValues
|
||||
.filter(
|
||||
(value: number | null | undefined): value is number =>
|
||||
value !== null && value !== undefined,
|
||||
)
|
||||
.map((value: number) => value.toString());
|
||||
|
||||
// Combine all options and make sure we don't have duplicate labels
|
||||
return [...stringOptions, ...numberOptions];
|
||||
}
|
||||
|
||||
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
|
||||
return (data?.payload?.[key] || []).filter(
|
||||
(val) => val !== undefined && val !== null,
|
||||
);
|
||||
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
|
||||
|
||||
return {
|
||||
attributeValues,
|
||||
isLoading: isLoading || isLoadingKeyValueSuggestions,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCheckboxFilterValues;
|
||||
@@ -77,6 +77,7 @@ const ROUTES = {
|
||||
METRICS_EXPLORER: '/metrics-explorer/summary',
|
||||
METRICS_EXPLORER_EXPLORER: '/metrics-explorer/explorer',
|
||||
METRICS_EXPLORER_VIEWS: '/metrics-explorer/views',
|
||||
METRICS_EXPLORER_VOLUME_CONTROL: '/metrics-explorer/volume-control',
|
||||
API_MONITORING_BASE: '/api-monitoring',
|
||||
API_MONITORING: '/api-monitoring/explorer',
|
||||
METRICS_EXPLORER_BASE: '/metrics-explorer',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'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;
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type RuleMode = 'all' | 'include' | 'exclude';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import APIError from 'types/api/error';
|
||||
import DashboardActions from './DashboardActions/DashboardActions';
|
||||
import DashboardInfo from './DashboardInfo/DashboardInfo';
|
||||
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
|
||||
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
|
||||
|
||||
import styles from './DashboardPageToolbar.module.scss';
|
||||
|
||||
@@ -52,6 +53,10 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
);
|
||||
|
||||
// Single global fetch of the public-sharing meta (the drawer reuses this cache);
|
||||
// drives the public-access badge.
|
||||
const { isPublic: isPublicDashboard } = usePublicDashboardMeta(id);
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
|
||||
@@ -117,7 +122,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
image={image}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={false}
|
||||
isPublicDashboard={isPublicDashboard}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
|
||||
@@ -1,106 +1,15 @@
|
||||
// settings card wrapper — mirrors the V1 public dashboard treatment
|
||||
.publicDashboardCard {
|
||||
// Publish tab — "status strip" direction (Claude Design: Publish Drawer Final).
|
||||
// Fills the drawer height so the actions anchor a footer instead of floating.
|
||||
.publishTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l2-border);
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.statusTitle {
|
||||
margin-bottom: 16px;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.timeRangeSelectGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.timeRangeSelectLabel {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.timeRangeSelect {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.urlGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.urlLabel {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.urlContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.urlText {
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.callout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding: 12px 8px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--primary-background) 10%, transparent);
|
||||
}
|
||||
|
||||
.calloutIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-robin-300);
|
||||
}
|
||||
|
||||
.calloutText {
|
||||
color: var(--text-robin-300);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 32px;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.footer {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Globe, Trash } from '@signozhq/icons';
|
||||
import { Globe, RefreshCw, Trash } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import styles from './PublicDashboard.module.scss';
|
||||
import styles from './PublicDashboardActions.module.scss';
|
||||
|
||||
interface PublicDashboardActionsProps {
|
||||
isPublic: boolean;
|
||||
@@ -25,7 +25,7 @@ function PublicDashboardActions({
|
||||
onUnpublish,
|
||||
}: PublicDashboardActionsProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
<div className={styles.footer}>
|
||||
{isPublic ? (
|
||||
<>
|
||||
<Button
|
||||
@@ -33,22 +33,22 @@ function PublicDashboardActions({
|
||||
color="destructive"
|
||||
disabled={disabled}
|
||||
loading={isUnpublishing}
|
||||
prefix={<Trash size={14} />}
|
||||
prefix={<Trash size={15} />}
|
||||
testId="public-dashboard-unpublish"
|
||||
onClick={onUnpublish}
|
||||
>
|
||||
Unpublish dashboard
|
||||
Unpublish Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled={disabled}
|
||||
loading={isUpdating}
|
||||
prefix={<Globe size={14} />}
|
||||
prefix={<RefreshCw size={15} />}
|
||||
testId="public-dashboard-update"
|
||||
onClick={onUpdate}
|
||||
>
|
||||
Update published dashboard
|
||||
Update Dashboard
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -57,11 +57,11 @@ function PublicDashboardActions({
|
||||
color="primary"
|
||||
disabled={disabled}
|
||||
loading={isPublishing}
|
||||
prefix={<Globe size={14} />}
|
||||
prefix={<Globe size={15} />}
|
||||
testId="public-dashboard-publish"
|
||||
onClick={onPublish}
|
||||
>
|
||||
Publish dashboard
|
||||
Publish Dashboard
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Info } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './PublicDashboard.module.scss';
|
||||
|
||||
function PublicDashboardCallout(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.callout}>
|
||||
<Info size={12} className={styles.calloutIcon} />
|
||||
<Typography.Text className={styles.calloutText}>
|
||||
Dashboard variables won't work in public dashboards
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardCallout;
|
||||
@@ -0,0 +1,19 @@
|
||||
.hint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding-top: 2px;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.hintIcon {
|
||||
flex: none;
|
||||
margin-top: 1px;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.hintText {
|
||||
color: var(--l3-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Info } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './PublicDashboardHint.module.scss';
|
||||
|
||||
function PublicDashboardHint(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.hint}>
|
||||
<Info size={14} className={styles.hintIcon} />
|
||||
<Typography.Text className={styles.hintText}>
|
||||
Dashboard variables aren't supported on public links.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardHint;
|
||||
@@ -0,0 +1,34 @@
|
||||
.switchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fieldGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
// Render the (non-portaled) dropdown above the drawer.
|
||||
[data-radix-popper-content-wrapper] {
|
||||
z-index: 1100 !important;
|
||||
}
|
||||
|
||||
// Radix sets --radix-select-trigger-width on the content element (the wrapper's
|
||||
// child), so match it there to make the dropdown take the input's width.
|
||||
// SelectSimple exposes no content className, hence the descendant selector.
|
||||
[data-radix-popper-content-wrapper] > * {
|
||||
width: var(--radix-select-trigger-width);
|
||||
min-width: var(--radix-select-trigger-width);
|
||||
}
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.timeRangeSelect {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { RelativeDurationOptions } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
|
||||
import { TIME_RANGE_PRESETS_OPTIONS } from './constants';
|
||||
import styles from './PublicDashboard.module.scss';
|
||||
import styles from './PublicDashboardSettingsForm.module.scss';
|
||||
|
||||
interface PublicDashboardSettingsFormProps {
|
||||
timeRangeEnabled: boolean;
|
||||
@@ -22,28 +22,29 @@ function PublicDashboardSettingsForm({
|
||||
}: PublicDashboardSettingsFormProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Checkbox
|
||||
id="public-dashboard-enable-time-range"
|
||||
className={styles.checkbox}
|
||||
testId="public-dashboard-time-range-toggle"
|
||||
value={timeRangeEnabled}
|
||||
disabled={disabled}
|
||||
onChange={(checked): void => onTimeRangeEnabledChange(checked === true)}
|
||||
>
|
||||
Enable time range
|
||||
</Checkbox>
|
||||
<div className={styles.switchRow}>
|
||||
<Switch
|
||||
testId="public-dashboard-time-range-toggle"
|
||||
value={timeRangeEnabled}
|
||||
disabled={disabled}
|
||||
onChange={onTimeRangeEnabledChange}
|
||||
>
|
||||
Enable time range
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<div className={styles.timeRangeSelectGroup}>
|
||||
<Typography.Text className={styles.timeRangeSelectLabel}>
|
||||
<div className={styles.fieldGroup}>
|
||||
<Typography.Text className={styles.fieldLabel}>
|
||||
Default time range
|
||||
</Typography.Text>
|
||||
<SelectSimple
|
||||
className={styles.timeRangeSelect}
|
||||
testId="public-dashboard-default-time-range"
|
||||
placeholder="Select default time range"
|
||||
items={TIME_RANGE_PRESETS_OPTIONS}
|
||||
items={RelativeDurationOptions}
|
||||
value={defaultTimeRange}
|
||||
disabled={disabled}
|
||||
withPortal={false}
|
||||
onChange={(value): void => onDefaultTimeRangeChange(value as string)}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './PublicDashboard.module.scss';
|
||||
|
||||
interface PublicDashboardStatusProps {
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
function PublicDashboardStatus({
|
||||
isPublic,
|
||||
}: PublicDashboardStatusProps): JSX.Element {
|
||||
return (
|
||||
<Typography.Text className={styles.statusTitle}>
|
||||
{isPublic
|
||||
? 'This dashboard is publicly accessible. Anyone with the link can view it.'
|
||||
: 'This dashboard is private. Publish it to make it accessible to anyone with the link.'}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardStatus;
|
||||
@@ -0,0 +1,67 @@
|
||||
.statusStrip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.statusStripLive {
|
||||
border-color: var(--callout-primary-border);
|
||||
background: var(--callout-primary-background);
|
||||
}
|
||||
|
||||
.statusMedallion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l3-background);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.statusMedallionLive {
|
||||
border-color: var(--callout-primary-border);
|
||||
background: var(--callout-primary-background);
|
||||
color: var(--callout-primary-icon);
|
||||
}
|
||||
|
||||
.statusBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.statusTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.statusSubtitle {
|
||||
margin-top: 2px;
|
||||
color: var(--l3-foreground);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.statusSubtitleLive {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.statusBadgeDot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
background: currentColor;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Globe, LockKeyhole } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './PublicDashboardStatus.module.scss';
|
||||
|
||||
interface PublicDashboardStatusProps {
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
function PublicDashboardStatus({
|
||||
isPublic,
|
||||
}: PublicDashboardStatusProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.statusStrip, { [styles.statusStripLive]: isPublic })}
|
||||
>
|
||||
<span
|
||||
className={cx(styles.statusMedallion, {
|
||||
[styles.statusMedallionLive]: isPublic,
|
||||
})}
|
||||
>
|
||||
{isPublic ? <Globe size={18} /> : <LockKeyhole size={18} />}
|
||||
</span>
|
||||
|
||||
<div className={styles.statusBody}>
|
||||
<Typography.Text className={styles.statusTitle}>
|
||||
{isPublic ? 'This dashboard is live' : 'This dashboard is private'}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
className={cx(styles.statusSubtitle, {
|
||||
[styles.statusSubtitleLive]: isPublic,
|
||||
})}
|
||||
>
|
||||
{isPublic
|
||||
? 'Anyone with the link can view it — no account needed.'
|
||||
: 'Publish it to share a read-only view with anyone who has the link.'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<Badge variant="outline" color={isPublic ? 'robin' : 'secondary'}>
|
||||
<span className={styles.statusBadgeDot} />
|
||||
{isPublic ? 'Public' : 'Private'}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardStatus;
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Copy, ExternalLink } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './PublicDashboard.module.scss';
|
||||
|
||||
interface PublicDashboardUrlProps {
|
||||
url: string;
|
||||
onCopy: () => void;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
function PublicDashboardUrl({
|
||||
url,
|
||||
onCopy,
|
||||
onOpen,
|
||||
}: PublicDashboardUrlProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.urlGroup}>
|
||||
<Typography.Text className={styles.urlLabel}>
|
||||
Public dashboard URL
|
||||
</Typography.Text>
|
||||
|
||||
<div className={styles.urlContainer}>
|
||||
<Typography.Text className={styles.urlText}>{url}</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Copy public dashboard URL"
|
||||
testId="public-dashboard-copy-url"
|
||||
onClick={onCopy}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Open public dashboard in new tab"
|
||||
testId="public-dashboard-open-url"
|
||||
onClick={onOpen}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardUrl;
|
||||
@@ -0,0 +1,69 @@
|
||||
.fieldGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.linkPlaceholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.linkPlaceholderIcon {
|
||||
flex: none;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.linkPlaceholderText {
|
||||
color: var(--l3-foreground);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.linkField {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 40px;
|
||||
padding: 0 5px 0 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
}
|
||||
|
||||
.linkUrl {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--l2-foreground);
|
||||
font-family: var(--font-mono, 'Geist Mono'), monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.linkDivider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
margin: 0 4px;
|
||||
background: var(--l2-border);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Copy, ExternalLink, Link2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './PublicDashboardUrl.module.scss';
|
||||
|
||||
interface PublicDashboardUrlProps {
|
||||
isPublic: boolean;
|
||||
url: string;
|
||||
onCopy: () => void;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
function PublicDashboardUrl({
|
||||
isPublic,
|
||||
url,
|
||||
onCopy,
|
||||
onOpen,
|
||||
}: PublicDashboardUrlProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.fieldGroup}>
|
||||
<Typography.Text className={styles.fieldLabel}>Public link</Typography.Text>
|
||||
|
||||
{isPublic ? (
|
||||
<div className={styles.linkField}>
|
||||
<Typography.Text className={styles.linkUrl}>{url}</Typography.Text>
|
||||
<span className={styles.linkDivider} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Copy link"
|
||||
testId="public-dashboard-copy-url"
|
||||
onClick={onCopy}
|
||||
>
|
||||
<Copy size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Open link"
|
||||
testId="public-dashboard-open-url"
|
||||
onClick={onOpen}
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.linkPlaceholder}>
|
||||
<Link2 size={15} className={styles.linkPlaceholderIcon} />
|
||||
<Typography.Text className={styles.linkPlaceholderText}>
|
||||
Your shareable link will appear here once published
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardUrl;
|
||||
@@ -1,14 +0,0 @@
|
||||
export interface TimeRangePresetOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// Default time-range presets offered for the public dashboard viewer.
|
||||
export const TIME_RANGE_PRESETS_OPTIONS: TimeRangePresetOption[] = [
|
||||
{ label: 'Last 5 minutes', value: '5m' },
|
||||
{ label: 'Last 15 minutes', value: '15m' },
|
||||
{ label: 'Last 30 minutes', value: '30m' },
|
||||
{ label: 'Last 1 hour', value: '1h' },
|
||||
{ label: 'Last 6 hours', value: '6h' },
|
||||
{ label: 'Last 1 day', value: '24h' },
|
||||
];
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import PublicDashboardActions from './PublicDashboardActions';
|
||||
import PublicDashboardCallout from './PublicDashboardCallout';
|
||||
import PublicDashboardSettingsForm from './PublicDashboardSettingsForm';
|
||||
import PublicDashboardStatus from './PublicDashboardStatus';
|
||||
import PublicDashboardUrl from './PublicDashboardUrl';
|
||||
import PublicDashboardActions from './PublicDashboardActions/PublicDashboardActions';
|
||||
import PublicDashboardHint from './PublicDashboardHint/PublicDashboardHint';
|
||||
import PublicDashboardSettingsForm from './PublicDashboardSettingsForm/PublicDashboardSettingsForm';
|
||||
import PublicDashboardStatus from './PublicDashboardStatus/PublicDashboardStatus';
|
||||
import PublicDashboardUrl from './PublicDashboardUrl/PublicDashboardUrl';
|
||||
import { usePublicDashboard } from './usePublicDashboard';
|
||||
import styles from './PublicDashboard.module.scss';
|
||||
|
||||
@@ -37,22 +37,27 @@ function PublicDashboardSettings({
|
||||
const controlsDisabled = isLoading || !isAdmin;
|
||||
|
||||
return (
|
||||
<div className={styles.publicDashboardCard}>
|
||||
<PublicDashboardStatus isPublic={isPublic} />
|
||||
<div className={styles.publishTab}>
|
||||
<div className={styles.content}>
|
||||
<PublicDashboardStatus isPublic={isPublic} />
|
||||
|
||||
<PublicDashboardSettingsForm
|
||||
timeRangeEnabled={timeRangeEnabled}
|
||||
defaultTimeRange={defaultTimeRange}
|
||||
disabled={controlsDisabled}
|
||||
onTimeRangeEnabledChange={setTimeRangeEnabled}
|
||||
onDefaultTimeRangeChange={setDefaultTimeRange}
|
||||
/>
|
||||
<PublicDashboardUrl
|
||||
isPublic={isPublic}
|
||||
url={publicUrl}
|
||||
onCopy={onCopyUrl}
|
||||
onOpen={onOpenUrl}
|
||||
/>
|
||||
|
||||
{isPublic && (
|
||||
<PublicDashboardUrl url={publicUrl} onCopy={onCopyUrl} onOpen={onOpenUrl} />
|
||||
)}
|
||||
<PublicDashboardSettingsForm
|
||||
timeRangeEnabled={timeRangeEnabled}
|
||||
defaultTimeRange={defaultTimeRange}
|
||||
disabled={controlsDisabled}
|
||||
onTimeRangeEnabledChange={setTimeRangeEnabled}
|
||||
onDefaultTimeRangeChange={setDefaultTimeRange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PublicDashboardCallout />
|
||||
<PublicDashboardHint />
|
||||
|
||||
<PublicDashboardActions
|
||||
isPublic={isPublic}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
invalidateGetPublicDashboard,
|
||||
useCreatePublicDashboard,
|
||||
useDeletePublicDashboard,
|
||||
useGetPublicDashboard,
|
||||
useUpdatePublicDashboard,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
@@ -17,6 +16,8 @@ import { USER_ROLES } from 'types/roles';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { usePublicDashboardMeta } from './usePublicDashboardMeta';
|
||||
|
||||
export interface UsePublicDashboardReturn {
|
||||
isPublic: boolean;
|
||||
isAdmin: boolean;
|
||||
@@ -54,22 +55,16 @@ export function usePublicDashboard(
|
||||
const [defaultTimeRange, setDefaultTimeRange] =
|
||||
useState<string>(DEFAULT_TIME_RANGE);
|
||||
|
||||
// Read the shared public-meta cache — the GET is owned globally (toolbar), so the
|
||||
// drawer reuses it rather than issuing its own request.
|
||||
const {
|
||||
data,
|
||||
publicMeta,
|
||||
isPublic,
|
||||
isLoading: isLoadingMeta,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
} = useGetPublicDashboard(
|
||||
{ id: dashboardId },
|
||||
{ query: { enabled: !!dashboardId, retry: false } },
|
||||
);
|
||||
|
||||
// react-query retains the last successful `data` even after a refetch errors, so
|
||||
// after unpublishing (the refetch 404s) `data` still holds the old publicPath.
|
||||
// Gate on `!error` so the UI flips back to the private state.
|
||||
const publicMeta = error ? undefined : data?.data;
|
||||
const isPublic = !!publicMeta?.publicPath;
|
||||
} = usePublicDashboardMeta(dashboardId);
|
||||
|
||||
// Seed form state from the server config when published.
|
||||
useEffect(() => {
|
||||
@@ -103,7 +98,7 @@ export function usePublicDashboard(
|
||||
(message: string): void => {
|
||||
toast.success(message);
|
||||
void invalidateGetPublicDashboard(queryClient, { id: dashboardId });
|
||||
void refetch();
|
||||
refetch();
|
||||
},
|
||||
[queryClient, dashboardId, refetch],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetPublicDashboard } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettablePublicDasbhboardDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
export interface UsePublicDashboardMetaReturn {
|
||||
publicMeta: DashboardtypesGettablePublicDasbhboardDTO | undefined;
|
||||
isPublic: boolean;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
error: unknown;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
// How long a fetched result stays fresh before a natural trigger may refresh it.
|
||||
const PUBLIC_META_STALE_TIME = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Single source of truth for a dashboard's public-sharing meta. Keyed by dashboard
|
||||
* id via the generated query, so the GET happens once globally (the toolbar mounts it
|
||||
* with the dashboard) and every other caller — the publish settings drawer — reads the
|
||||
* same cache instead of issuing its own request. A mutation that invalidates
|
||||
* getGetPublicDashboardQueryKey refreshes all consumers at once.
|
||||
*
|
||||
* Only fetched on cloud / enterprise tenants, where public dashboards are available.
|
||||
*/
|
||||
export function usePublicDashboardMeta(
|
||||
dashboardId: string,
|
||||
): UsePublicDashboardMetaReturn {
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
const enabled = !!dashboardId && (isCloudUser || isEnterpriseSelfHostedUser);
|
||||
|
||||
const { data, isLoading, isFetching, error, refetch } = useGetPublicDashboard(
|
||||
{ id: dashboardId },
|
||||
{
|
||||
query: {
|
||||
enabled,
|
||||
retry: false,
|
||||
// refetchOnMount: false stops opening the drawer / switching to the Publish
|
||||
// tab from refiring the GET — it reuses the toolbar's cached result. A finite
|
||||
// staleTime still lets it refresh naturally once the data ages, and mutations
|
||||
// invalidate the key to refresh the published state immediately.
|
||||
staleTime: PUBLIC_META_STALE_TIME,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// react-query retains the last successful `data` after a refetch errors (e.g. the
|
||||
// 404 once a dashboard is unpublished), so gate on the error to reflect the
|
||||
// private state.
|
||||
const publicMeta = error ? undefined : data?.data;
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
publicMeta,
|
||||
isPublic: !!publicMeta?.publicPath,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
}),
|
||||
[publicMeta, isLoading, isFetching, error, refetch],
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -69,19 +69,24 @@ function stripUndefinedLabels(
|
||||
export function toPostableRuleDTO(
|
||||
local: PostableAlertRuleV2,
|
||||
): RuletypesPostableRuleDTO {
|
||||
const payload = {
|
||||
const payload: Record<keyof RuletypesPostableRuleDTO, any> = {
|
||||
alert: local.alert,
|
||||
alertType: toAlertTypeDTO(local.alertType),
|
||||
ruleType: toRuleTypeDTO(local.ruleType),
|
||||
condition: local.condition,
|
||||
annotations: local.annotations,
|
||||
labels: stripUndefinedLabels(local.labels),
|
||||
evalWindow: (local as unknown as RuletypesPostableRuleDTO).evalWindow,
|
||||
frequency: (local as unknown as RuletypesPostableRuleDTO).frequency,
|
||||
preferredChannels: (local as unknown as RuletypesPostableRuleDTO)
|
||||
.preferredChannels,
|
||||
notificationSettings: local.notificationSettings,
|
||||
evaluation: local.evaluation,
|
||||
schemaVersion: local.schemaVersion,
|
||||
source: local.source,
|
||||
version: local.version,
|
||||
disabled: local.disabled,
|
||||
description: (local as unknown as RuletypesPostableRuleDTO).description,
|
||||
};
|
||||
return payload as unknown as RuletypesPostableRuleDTO;
|
||||
}
|
||||
@@ -89,7 +94,7 @@ export function toPostableRuleDTO(
|
||||
export function toPostableRuleDTOFromAlertDef(
|
||||
local: AlertDef,
|
||||
): RuletypesPostableRuleDTO {
|
||||
const payload = {
|
||||
const payload: Record<keyof RuletypesPostableRuleDTO, any> = {
|
||||
alert: local.alert,
|
||||
alertType: toAlertTypeDTO(local.alertType),
|
||||
ruleType: toRuleTypeDTO(local.ruleType),
|
||||
@@ -99,11 +104,16 @@ export function toPostableRuleDTOFromAlertDef(
|
||||
evalWindow: local.evalWindow,
|
||||
frequency: local.frequency,
|
||||
preferredChannels: local.preferredChannels,
|
||||
notificationSettings: (local as unknown as RuletypesPostableRuleDTO)
|
||||
.notificationSettings,
|
||||
evaluation: (local as unknown as RuletypesPostableRuleDTO).evaluation,
|
||||
schemaVersion: (local as unknown as RuletypesPostableRuleDTO).schemaVersion,
|
||||
source: local.source,
|
||||
version: local.version,
|
||||
disabled: local.disabled,
|
||||
description: (local as unknown as RuletypesPostableRuleDTO).description,
|
||||
};
|
||||
return payload as unknown as RuletypesPostableRuleDTO;
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function fromRuleDTOToPostableRuleV2(
|
||||
|
||||
@@ -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'],
|
||||
|
||||
104
pkg/apiserver/signozapiserver/metricreductionrule.go
Normal file
104
pkg/apiserver/signozapiserver/metricreductionrule.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -304,7 +304,7 @@ func TestCompositeKeyFromLabels(t *testing.T) {
|
||||
name: "daemonset and namespace group-by",
|
||||
labels: map[string]string{
|
||||
"k8s.daemonset.name": "web-1",
|
||||
"k8s.namespace.name": "ns-x",
|
||||
"k8s.namespace.name": "ns-x",
|
||||
},
|
||||
groupBy: []qbtypes.GroupByKey{daemonSetNameGroupByKey, namespaceNameGroupByKey},
|
||||
expected: "web-1\x00ns-x",
|
||||
@@ -330,6 +330,47 @@ func TestCompositeKeyFromLabels(t *testing.T) {
|
||||
groupBy: []qbtypes.GroupByKey{deploymentNameGroupByKey, namespaceNameGroupByKey, clusterNameGroupByKey},
|
||||
expected: "web-1\x00ns-x\x00",
|
||||
},
|
||||
{
|
||||
// volumes default group identity: (pvc, namespace, cluster).
|
||||
name: "pvc, namespace and cluster group-by",
|
||||
labels: map[string]string{
|
||||
"k8s.persistentvolumeclaim.name": "data-pg-0",
|
||||
"k8s.namespace.name": "ns-x",
|
||||
"k8s.cluster.name": "cluster-a",
|
||||
},
|
||||
groupBy: []qbtypes.GroupByKey{pvcNameGroupByKey, namespaceNameGroupByKey, clusterNameGroupByKey},
|
||||
expected: "data-pg-0\x00ns-x\x00cluster-a",
|
||||
},
|
||||
{
|
||||
// absent cluster label on a PVC -> empty trailing segment.
|
||||
name: "pvc missing cluster label yields empty trailing segment",
|
||||
labels: map[string]string{
|
||||
"k8s.persistentvolumeclaim.name": "data-pg-0",
|
||||
"k8s.namespace.name": "ns-x",
|
||||
},
|
||||
groupBy: []qbtypes.GroupByKey{pvcNameGroupByKey, namespaceNameGroupByKey, clusterNameGroupByKey},
|
||||
expected: "data-pg-0\x00ns-x\x00",
|
||||
},
|
||||
{
|
||||
// namespaces default group identity: (namespace, cluster) — namespaces are
|
||||
// cluster-scoped, so cluster is the only cross-cluster disambiguator.
|
||||
name: "namespace and cluster group-by",
|
||||
labels: map[string]string{
|
||||
"k8s.namespace.name": "ns-x",
|
||||
"k8s.cluster.name": "cluster-a",
|
||||
},
|
||||
groupBy: []qbtypes.GroupByKey{namespaceNameGroupByKey, clusterNameGroupByKey},
|
||||
expected: "ns-x\x00cluster-a",
|
||||
},
|
||||
{
|
||||
// absent cluster label on a namespace -> empty trailing segment.
|
||||
name: "namespace missing cluster label yields empty trailing segment",
|
||||
labels: map[string]string{
|
||||
"k8s.namespace.name": "ns-x",
|
||||
},
|
||||
groupBy: []qbtypes.GroupByKey{namespaceNameGroupByKey, clusterNameGroupByKey},
|
||||
expected: "ns-x\x00",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -360,7 +360,7 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
}
|
||||
|
||||
if len(req.GroupBy) == 0 {
|
||||
req.GroupBy = []qbtypes.GroupByKey{namespaceNameGroupByKey}
|
||||
req.GroupBy = []qbtypes.GroupByKey{namespaceNameGroupByKey, clusterNameGroupByKey}
|
||||
resp.Type = inframonitoringtypes.ResponseTypeList
|
||||
} else {
|
||||
resp.Type = inframonitoringtypes.ResponseTypeGroupedList
|
||||
@@ -535,7 +535,7 @@ func (m *module) ListVolumes(ctx context.Context, orgID valuer.UUID, req *infram
|
||||
}
|
||||
|
||||
if len(req.GroupBy) == 0 {
|
||||
req.GroupBy = []qbtypes.GroupByKey{pvcNameGroupByKey}
|
||||
req.GroupBy = []qbtypes.GroupByKey{pvcNameGroupByKey, namespaceNameGroupByKey, clusterNameGroupByKey}
|
||||
resp.Type = inframonitoringtypes.ResponseTypeList
|
||||
} else {
|
||||
resp.Type = inframonitoringtypes.ResponseTypeGroupedList
|
||||
|
||||
@@ -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(), ¶ms); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
out, err := h.module.List(r.Context(), valuer.MustNewUUID(claims.OrgID), ¶ms)
|
||||
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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
25
pkg/modules/metricreductionrule/metricreductionrule.go
Normal file
25
pkg/modules/metricreductionrule/metricreductionrule.go
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,11 +119,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
||||
queries := make(map[string]qbtypes.Query)
|
||||
steps := make(map[string]qbtypes.Step)
|
||||
|
||||
// Resolve metric metadata once per request: patches each metric-aggregation
|
||||
// query's spec in place, returns the queries whose every aggregation was
|
||||
// missing (used for preseeded empty results), and any dormant-metric
|
||||
// warning string. NotFound errors for never-seen metrics are propagated.
|
||||
missingMetricQueries, dormantMetricsWarningMsg, err := q.resolveMetricMetadata(ctx, req.CompositeQuery.Queries, req.Start, req.End)
|
||||
missingMetricQueries, metricWarnings, err := q.resolveMetricMetadata(ctx, req.CompositeQuery.Queries, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -240,13 +236,15 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
||||
}
|
||||
}
|
||||
}
|
||||
if dormantMetricsWarningMsg != "" {
|
||||
if len(metricWarnings) > 0 {
|
||||
if qbResp.Warning == nil {
|
||||
qbResp.Warning = &qbtypes.QueryWarnData{}
|
||||
}
|
||||
qbResp.Warning.Warnings = append(qbResp.Warning.Warnings, qbtypes.QueryWarnDataAdditional{
|
||||
Message: dormantMetricsWarningMsg,
|
||||
})
|
||||
for _, w := range metricWarnings {
|
||||
qbResp.Warning.Warnings = append(qbResp.Warning.Warnings, qbtypes.QueryWarnDataAdditional{
|
||||
Message: w,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return qbResp, qbErr
|
||||
@@ -302,12 +300,11 @@ func (q *querier) populateQBEvent(event *qbtypes.QBEvent, queries []qbtypes.Quer
|
||||
// - missingMetricQueries: names of queries whose every aggregation was
|
||||
// missing. Used downstream to preseed empty result placeholders so the
|
||||
// response still has an entry per requested query name.
|
||||
// - dormantWarning: a human-readable warning describing metrics that exist in
|
||||
// the store but produced no data within the query window. Empty when no
|
||||
// such metrics are present.
|
||||
// - err: NotFound when one or more referenced metrics have never been seen,
|
||||
// or Internal when a metadata fetch fails.
|
||||
func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.QueryEnvelope, start, end uint64) (missingMetricQueries []string, dormantWarning string, err error) {
|
||||
// - metricWarnings: human-readable warnings for metrics that could not be
|
||||
// resolved: never-seen metrics and dormant metrics (seen but no data in
|
||||
// the query window).
|
||||
// - err: Internal when a metadata fetch fails.
|
||||
func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.QueryEnvelope, start, end uint64) (missingMetricQueries []string, metricWarnings []string, err error) {
|
||||
metricNames := make([]string, 0)
|
||||
for idx := range queries {
|
||||
if queries[idx].Type != qbtypes.QueryTypeBuilder {
|
||||
@@ -325,13 +322,13 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
|
||||
}
|
||||
|
||||
if len(metricNames) == 0 {
|
||||
return nil, "", nil
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
metricTemporality, metricTypes, err := q.metadataStore.FetchTemporalityAndTypeMulti(ctx, start, end, metricNames...)
|
||||
if err != nil {
|
||||
q.logger.WarnContext(ctx, "failed to fetch metric temporality", errors.Attr(err), slog.Any("metrics", metricNames))
|
||||
return nil, "", errors.NewInternalf(errors.CodeInternal, "failed to fetch metrics temporality")
|
||||
return nil, nil, errors.NewInternalf(errors.CodeInternal, "failed to fetch metrics temporality")
|
||||
}
|
||||
q.logger.DebugContext(ctx, "fetched metric temporalities and types", slog.Any("metric_temporality", metricTemporality), slog.Any("metric_types", metricTypes))
|
||||
|
||||
@@ -363,7 +360,7 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
|
||||
}
|
||||
// Type is resolved now; validate aggregation compatibility against it.
|
||||
if err := spec.Aggregations[i].ValidateForType(); err != nil {
|
||||
return nil, "", err
|
||||
return nil, nil, err
|
||||
}
|
||||
presentAggregations = append(presentAggregations, spec.Aggregations[i])
|
||||
}
|
||||
@@ -376,7 +373,7 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
|
||||
}
|
||||
|
||||
if len(missingMetrics) == 0 {
|
||||
return missingMetricQueries, "", nil
|
||||
return missingMetricQueries, nil, nil
|
||||
}
|
||||
|
||||
isInternalMetric := func(n string) bool { return strings.HasPrefix(n, "signoz.") || strings.HasPrefix(n, "signoz_") }
|
||||
@@ -387,29 +384,33 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
|
||||
}
|
||||
}
|
||||
if len(externalMissingMetrics) == 0 {
|
||||
// this means all missing metrics are internal, and since internal metrics
|
||||
// aren't user-controlled, skip errors/warnings for them since users can't act on them
|
||||
return missingMetricQueries, "", nil
|
||||
return missingMetricQueries, nil, nil
|
||||
}
|
||||
|
||||
// Classify each missing metric: never-seen → NotFound error; seen-but-no-
|
||||
// data-in-window → dormant warning.
|
||||
// Classify each missing metric: never-seen -> warning with empty result;
|
||||
// seen-but-no-data-in-window -> dormant warning.
|
||||
lastSeenInfo, _ := q.metadataStore.FetchLastSeenInfoMulti(ctx, externalMissingMetrics...)
|
||||
nonExistentMetrics := []string{}
|
||||
var nonExistentMetrics []string
|
||||
var dormantMetrics []string
|
||||
for _, name := range externalMissingMetrics {
|
||||
if ts, ok := lastSeenInfo[name]; ok && ts > 0 {
|
||||
dormantMetrics = append(dormantMetrics, name)
|
||||
continue
|
||||
}
|
||||
nonExistentMetrics = append(nonExistentMetrics, name)
|
||||
}
|
||||
|
||||
var warnings []string
|
||||
|
||||
// Never-seen metrics: the query already gets a preseeded empty result
|
||||
// via the aggregation-dropping path above; we just attach a warning.
|
||||
if len(nonExistentMetrics) == 1 {
|
||||
return nil, "", errors.NewNotFoundf(errors.CodeNotFound, "could not find the metric %s", nonExistentMetrics[0])
|
||||
}
|
||||
if len(nonExistentMetrics) > 1 {
|
||||
return nil, "", errors.NewNotFoundf(errors.CodeNotFound, "the following metrics were not found: %s", strings.Join(nonExistentMetrics, ", "))
|
||||
warnings = append(warnings, fmt.Sprintf("metric %s has never been received. Check the metric name and instrumentation", nonExistentMetrics[0]))
|
||||
} else if len(nonExistentMetrics) > 1 {
|
||||
warnings = append(warnings, fmt.Sprintf("the following metrics have never been received. Check the metric names and instrumentation: %s", strings.Join(nonExistentMetrics, ", ")))
|
||||
}
|
||||
|
||||
// All missing metrics are dormant — assemble the warning string.
|
||||
// Dormant metrics: seen before but no data in the query window.
|
||||
lastSeenStr := func(name string) string {
|
||||
if ts, ok := lastSeenInfo[name]; ok && ts > 0 {
|
||||
ago := humanize.RelTime(time.UnixMilli(ts), time.Now(), "ago", "from now")
|
||||
@@ -417,16 +418,16 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
|
||||
}
|
||||
return name
|
||||
}
|
||||
if len(externalMissingMetrics) == 1 {
|
||||
dormantWarning = fmt.Sprintf("no data found for the metric %s in the query time range", lastSeenStr(missingMetrics[0]))
|
||||
} else {
|
||||
parts := make([]string, len(externalMissingMetrics))
|
||||
for i, m := range externalMissingMetrics {
|
||||
if len(dormantMetrics) == 1 {
|
||||
warnings = append(warnings, fmt.Sprintf("no data found for the metric %s in the query time range", lastSeenStr(dormantMetrics[0])))
|
||||
} else if len(dormantMetrics) > 1 {
|
||||
parts := make([]string, len(dormantMetrics))
|
||||
for i, m := range dormantMetrics {
|
||||
parts[i] = lastSeenStr(m)
|
||||
}
|
||||
dormantWarning = fmt.Sprintf("no data found for the following metrics in the query time range: %s", strings.Join(parts, ", "))
|
||||
warnings = append(warnings, fmt.Sprintf("no data found for the following metrics in the query time range: %s", strings.Join(parts, ", ")))
|
||||
}
|
||||
return missingMetricQueries, dormantWarning, nil
|
||||
return missingMetricQueries, warnings, nil
|
||||
}
|
||||
|
||||
func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream) {
|
||||
|
||||
@@ -37,7 +37,7 @@ func (m *mockMetricStmtBuilder) Build(_ context.Context, _, _ uint64, _ qbtypes.
|
||||
|
||||
func TestQueryRange_MetricTypeMissing(t *testing.T) {
|
||||
// When a metric has UnspecifiedType and is not found in the metadata store,
|
||||
// the querier should return a not-found error, even if the request provides a temporality
|
||||
// the querier should return an empty result with a warning instead of an error.
|
||||
providerSettings := instrumentationtest.New().ToProviderSettings()
|
||||
metadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
|
||||
@@ -80,9 +80,14 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
_, err := q.QueryRange(context.Background(), valuer.GenerateUUID(), req)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "could not find the metric unknown_metric")
|
||||
resp, err := q.QueryRange(context.Background(), valuer.GenerateUUID(), req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, resp.Warning)
|
||||
|
||||
require.Len(t, resp.Warning.Warnings, 1)
|
||||
assert.Contains(t, resp.Warning.Warnings[0].Message, "unknown_metric")
|
||||
assert.Contains(t, resp.Warning.Warnings[0].Message, "has never been received")
|
||||
}
|
||||
|
||||
func TestQueryRange_MetricTypeFromStore(t *testing.T) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule/implmetricreductionrule"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
@@ -63,7 +64,7 @@ func TestNewModules(t *testing.T) {
|
||||
|
||||
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
|
||||
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule())
|
||||
|
||||
reflectVal := reflect.ValueOf(modules)
|
||||
for i := 0; i < reflectVal.NumField(); i++ {
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/fields"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
@@ -68,6 +69,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ dashboard.Module }{},
|
||||
struct{ dashboard.Handler }{},
|
||||
struct{ metricsexplorer.Handler }{},
|
||||
struct{ metricreductionrule.Handler }{},
|
||||
struct{ inframonitoring.Handler }{},
|
||||
struct{ gateway.Handler }{},
|
||||
struct{ fields.Handler }{},
|
||||
|
||||
@@ -215,6 +215,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewRecreateUserDashboardPreferenceFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewMigrateRecurrenceBoundsFactory(sqlstore),
|
||||
sqlmigration.NewAddDashboardViewFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddMetricReductionRulesFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -295,6 +296,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
modules.Dashboard,
|
||||
handlers.Dashboard,
|
||||
handlers.MetricsExplorer,
|
||||
handlers.MetricReductionRule,
|
||||
handlers.InfraMonitoring,
|
||||
handlers.GatewayHandler,
|
||||
handlers.Fields,
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/meterreporter"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention"
|
||||
@@ -114,6 +115,7 @@ func New(
|
||||
meterReporterProviderFactories func(context.Context, factory.ProviderSettings, flagger.Flagger, licensing.Licensing, telemetrystore.TelemetryStore, retention.Getter, organization.Getter, zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string),
|
||||
querierHandlerCallback func(factory.ProviderSettings, querier.Querier, analytics.Analytics) querier.Handler,
|
||||
cloudIntegrationCallback func(sqlstore.SQLStore, dashboard.Module, global.Global, zeus.Zeus, gateway.Gateway, licensing.Licensing, serviceaccount.Module, cloudintegration.Config) (cloudintegration.Module, error),
|
||||
metricReductionRuleModuleCallback func(sqlstore.SQLStore, telemetrystore.TelemetryStore, dashboard.Module, queryparser.QueryParser, licensing.Licensing, factory.ProviderSettings, int) metricreductionrule.Module,
|
||||
rulerProviderFactories func(cache.Cache, alertmanager.Alertmanager, sqlstore.SQLStore, telemetrystore.TelemetryStore, telemetrytypes.MetadataStore, prometheus.Prometheus, organization.Getter, rulestatehistory.Module, querier.Querier, queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]],
|
||||
) (*SigNoz, error) {
|
||||
// Initialize instrumentation
|
||||
@@ -464,8 +466,10 @@ func New(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metricReductionRuleModule := metricReductionRuleModuleCallback(sqlstore, telemetrystore, dashboard, queryParser, licensing, providerSettings, config.MetricsExplorer.TelemetryStore.Threads)
|
||||
|
||||
// Initialize all modules
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, cloudIntegrationModule, retentionGetter, flagger, tagModule)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule)
|
||||
|
||||
// Initialize ruler from the variant-specific provider factories
|
||||
rulerInstance, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Ruler, rulerProviderFactories(cache, alertmanager, sqlstore, telemetrystore, telemetryMetadataStore, prometheus, orgGetter, modules.RuleStateHistory, querier, queryParser), "signoz")
|
||||
|
||||
90
pkg/sqlmigration/096_add_metric_reduction_rules.go
Normal file
90
pkg/sqlmigration/096_add_metric_reduction_rules.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addMetricReductionRules struct {
|
||||
sqlschema sqlschema.SQLSchema
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddMetricReductionRulesFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_metric_reduction_rule"), func(_ context.Context, _ factory.ProviderSettings, _ Config) (SQLMigration, error) {
|
||||
return &addMetricReductionRules{
|
||||
sqlschema: sqlschema,
|
||||
sqlstore: sqlstore,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addMetricReductionRules) Register(migrations *migrate.Migrations) error {
|
||||
if err := migrations.Register(migration.Up, migration.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *addMetricReductionRules) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
sqls := [][]byte{}
|
||||
|
||||
tableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
|
||||
Name: "metric_reduction_rule",
|
||||
Columns: []*sqlschema.Column{
|
||||
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "metric_name", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "match_type", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "labels", DataType: sqlschema.DataTypeText, Nullable: false, Default: "'[]'"},
|
||||
{Name: "effective_from", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "created_by", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "updated_by", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
},
|
||||
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
|
||||
ColumnNames: []sqlschema.ColumnName{"id"},
|
||||
},
|
||||
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("org_id"),
|
||||
ReferencedTableName: sqlschema.TableName("organizations"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
sqls = append(sqls, tableSQLs...)
|
||||
|
||||
indexSQLs := migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{
|
||||
TableName: "metric_reduction_rule",
|
||||
ColumnNames: []sqlschema.ColumnName{"org_id", "metric_name"},
|
||||
})
|
||||
sqls = append(sqls, indexSQLs...)
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addMetricReductionRules) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -101,9 +101,29 @@ func (b *MetricQueryStatementBuilder) Build(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pairFallbackWarnings []string
|
||||
for _, sel := range keySelectors {
|
||||
if _, ok := keys[sel.Name]; !ok {
|
||||
keys[sel.Name] = []*telemetrytypes.TelemetryFieldKey{{
|
||||
Name: sel.Name,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
}}
|
||||
pairFallbackWarnings = append(pairFallbackWarnings,
|
||||
fmt.Sprintf("key `%s` not found on metric %s", sel.Name, query.Aggregations[0].MetricName),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
start, end = querybuilder.AdjustedMetricTimeRange(start, end, uint64(query.StepInterval.Seconds()), query)
|
||||
|
||||
return b.buildPipelineStatement(ctx, start, end, query, keys, variables)
|
||||
stmt, err := b.buildPipelineStatement(ctx, start, end, query, keys, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stmt.Warnings = append(stmt.Warnings, pairFallbackWarnings...)
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
func (b *MetricQueryStatementBuilder) buildPipelineStatement(
|
||||
|
||||
@@ -217,6 +217,39 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "test_missing_key_falls_back_to_labels",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "signoz_calls_total",
|
||||
Type: metrictypes.SumType,
|
||||
Temporality: metrictypes.Cumulative,
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "k8s.statefulset.name = 'my-statefulset'",
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "k8s.statefulset.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `k8s.statefulset.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `k8s.statefulset.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'k8s.statefulset.name') AS `k8s.statefulset.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND __normalized = ? AND JSONExtractString(labels, 'k8s.statefulset.name') = ? GROUP BY fingerprint, `k8s.statefulset.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `k8s.statefulset.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `k8s.statefulset.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `k8s.statefulset.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `k8s.statefulset.name`, ts",
|
||||
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", false, "my-statefulset", "signoz_calls_total", uint64(1747947360000), uint64(1747983420000), 0},
|
||||
Warnings: []string{"key `k8s.statefulset.name` not found on metric signoz_calls_total"},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
}
|
||||
|
||||
fm := NewFieldMapper()
|
||||
|
||||
@@ -9,27 +9,34 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
DBName = "signoz_metrics"
|
||||
UpdatedMetadataTableName = "distributed_updated_metadata"
|
||||
UpdatedMetadataLocalTableName = "updated_metadata"
|
||||
SamplesV4TableName = "distributed_samples_v4"
|
||||
SamplesV4LocalTableName = "samples_v4"
|
||||
SamplesV4Agg5mTableName = "distributed_samples_v4_agg_5m"
|
||||
SamplesV4Agg5mLocalTableName = "samples_v4_agg_5m"
|
||||
SamplesV4Agg30mTableName = "distributed_samples_v4_agg_30m"
|
||||
SamplesV4Agg30mLocalTableName = "samples_v4_agg_30m"
|
||||
ExpHistogramTableName = "distributed_exp_hist"
|
||||
ExpHistogramLocalTableName = "exp_hist"
|
||||
TimeseriesV4TableName = "distributed_time_series_v4"
|
||||
TimeseriesV4LocalTableName = "time_series_v4"
|
||||
TimeseriesV46hrsTableName = "distributed_time_series_v4_6hrs"
|
||||
TimeseriesV46hrsLocalTableName = "time_series_v4_6hrs"
|
||||
TimeseriesV41dayTableName = "distributed_time_series_v4_1day"
|
||||
TimeseriesV41dayLocalTableName = "time_series_v4_1day"
|
||||
TimeseriesV41weekTableName = "distributed_time_series_v4_1week"
|
||||
TimeseriesV41weekLocalTableName = "time_series_v4_1week"
|
||||
AttributesMetadataTableName = "distributed_metadata"
|
||||
AttributesMetadataLocalTableName = "metadata"
|
||||
DBName = "signoz_metrics"
|
||||
UpdatedMetadataTableName = "distributed_updated_metadata"
|
||||
UpdatedMetadataLocalTableName = "updated_metadata"
|
||||
SamplesV4TableName = "distributed_samples_v4"
|
||||
SamplesV4LocalTableName = "samples_v4"
|
||||
SamplesV4Agg5mTableName = "distributed_samples_v4_agg_5m"
|
||||
SamplesV4Agg5mLocalTableName = "samples_v4_agg_5m"
|
||||
SamplesV4Agg30mTableName = "distributed_samples_v4_agg_30m"
|
||||
SamplesV4Agg30mLocalTableName = "samples_v4_agg_30m"
|
||||
ExpHistogramTableName = "distributed_exp_hist"
|
||||
ExpHistogramLocalTableName = "exp_hist"
|
||||
TimeseriesV4TableName = "distributed_time_series_v4"
|
||||
TimeseriesV4LocalTableName = "time_series_v4"
|
||||
TimeseriesV46hrsTableName = "distributed_time_series_v4_6hrs"
|
||||
TimeseriesV46hrsLocalTableName = "time_series_v4_6hrs"
|
||||
TimeseriesV41dayTableName = "distributed_time_series_v4_1day"
|
||||
TimeseriesV41dayLocalTableName = "time_series_v4_1day"
|
||||
TimeseriesV41weekTableName = "distributed_time_series_v4_1week"
|
||||
TimeseriesV41weekLocalTableName = "time_series_v4_1week"
|
||||
// Reduction tables written by the collector; see signoz-otel-collector#839.
|
||||
TimeseriesV4BufferTableName = "distributed_time_series_v4_buffer"
|
||||
TimeseriesV4BufferLocalTableName = "time_series_v4_buffer"
|
||||
TimeseriesV4ReducedTableName = "distributed_time_series_v4_reduced"
|
||||
TimeseriesV4ReducedLocalTableName = "time_series_v4_reduced"
|
||||
AttributesMetadataTableName = "distributed_metadata"
|
||||
AttributesMetadataLocalTableName = "metadata"
|
||||
ReductionRulesTableName = "distributed_metric_reduction_rules"
|
||||
ReductionRulesLocalTableName = "metric_reduction_rules"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
232
pkg/types/metricreductionruletypes/reduction_rule.go
Normal file
232
pkg/types/metricreductionruletypes/reduction_rule.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package metricreductionruletypes
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeMetricReductionRuleUnsupported = errors.MustNewCode("metric_reduction_rule_unsupported")
|
||||
ErrCodeMetricReductionRuleNotFound = errors.MustNewCode("metric_reduction_rule_not_found")
|
||||
ErrCodeMetricReductionRuleProtectedLabel = errors.MustNewCode("metric_reduction_rule_protected_label")
|
||||
ErrCodeMetricReductionRuleUnsupportedMetricType = errors.MustNewCode("metric_reduction_rule_unsupported_metric_type")
|
||||
)
|
||||
|
||||
type MatchType struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
MatchTypeDrop = MatchType{valuer.NewString("drop")}
|
||||
MatchTypeKeep = MatchType{valuer.NewString("keep")}
|
||||
)
|
||||
|
||||
func (MatchType) Enum() []any {
|
||||
return []any{MatchTypeDrop, MatchTypeKeep}
|
||||
}
|
||||
|
||||
type AssetType struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
AssetTypeDashboard = AssetType{valuer.NewString("dashboard")}
|
||||
AssetTypeAlert = AssetType{valuer.NewString("alert_rule")}
|
||||
)
|
||||
|
||||
func (AssetType) Enum() []any {
|
||||
return []any{AssetTypeDashboard, AssetTypeAlert}
|
||||
}
|
||||
|
||||
type Order struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
OrderAsc = Order{valuer.NewString("asc")}
|
||||
OrderDesc = Order{valuer.NewString("desc")}
|
||||
)
|
||||
|
||||
func (Order) Enum() []any {
|
||||
return []any{OrderAsc, OrderDesc}
|
||||
}
|
||||
|
||||
type ReductionRuleOrderBy struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
OrderByMetricName = ReductionRuleOrderBy{valuer.NewString("metricName")}
|
||||
OrderByIngestedVolume = ReductionRuleOrderBy{valuer.NewString("ingestedVolume")}
|
||||
OrderByReducedVolume = ReductionRuleOrderBy{valuer.NewString("reducedVolume")}
|
||||
OrderByReduction = ReductionRuleOrderBy{valuer.NewString("reduction")}
|
||||
OrderByLastUpdated = ReductionRuleOrderBy{valuer.NewString("lastUpdated")}
|
||||
)
|
||||
|
||||
func (ReductionRuleOrderBy) Enum() []any {
|
||||
return []any{OrderByMetricName, OrderByIngestedVolume, OrderByReducedVolume, OrderByReduction, OrderByLastUpdated}
|
||||
}
|
||||
|
||||
// LabelList is a []string persisted as a single JSON text column.
|
||||
type LabelList []string
|
||||
|
||||
func (l LabelList) Value() (driver.Value, error) {
|
||||
if l == nil {
|
||||
return "[]", nil
|
||||
}
|
||||
b, err := json.Marshal(l)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func (l *LabelList) Scan(src any) error {
|
||||
var raw []byte
|
||||
switch v := src.(type) {
|
||||
case string:
|
||||
raw = []byte(v)
|
||||
case []byte:
|
||||
raw = v
|
||||
case nil:
|
||||
*l = nil
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInternalf(errors.CodeInternal, "metricreductionruletypes: cannot scan %T into LabelList", src)
|
||||
}
|
||||
return json.Unmarshal(raw, l)
|
||||
}
|
||||
|
||||
type StorableReductionRule struct {
|
||||
bun.BaseModel `bun:"table:metric_reduction_rule" json:"-"`
|
||||
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
|
||||
OrgID valuer.UUID `bun:"org_id,type:text,notnull"`
|
||||
MetricName string `bun:"metric_name,type:text,notnull"`
|
||||
MatchType MatchType `bun:"match_type,type:text,notnull"`
|
||||
Labels LabelList `bun:"labels,type:text,notnull,default:'[]'"`
|
||||
EffectiveFrom time.Time `bun:"effective_from,notnull"`
|
||||
}
|
||||
|
||||
func NewReductionRule(orgID valuer.UUID, metricName string, matchType MatchType, labels []string, effectiveFrom time.Time, by string) *StorableReductionRule {
|
||||
now := time.Now()
|
||||
return &StorableReductionRule{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
|
||||
UserAuditable: types.UserAuditable{CreatedBy: by, UpdatedBy: by},
|
||||
OrgID: orgID,
|
||||
MetricName: metricName,
|
||||
MatchType: matchType,
|
||||
Labels: LabelList(labels),
|
||||
EffectiveFrom: effectiveFrom,
|
||||
}
|
||||
}
|
||||
|
||||
type GettableReductionRule struct {
|
||||
MetricName string `json:"metricName" required:"true"`
|
||||
MatchType MatchType `json:"matchType" required:"true"`
|
||||
Labels []string `json:"labels" required:"true" nullable:"true"`
|
||||
EffectiveFrom time.Time `json:"effectiveFrom" required:"true"`
|
||||
UpdatedAt time.Time `json:"updatedAt" required:"true"`
|
||||
UpdatedBy string `json:"updatedBy" required:"true"`
|
||||
Active bool `json:"active" required:"true"`
|
||||
IngestedSeries uint64 `json:"ingestedSeries" required:"true"`
|
||||
ReducedSeries uint64 `json:"reducedSeries" required:"true"`
|
||||
ReductionPercent float64 `json:"reductionPercent" required:"true"`
|
||||
}
|
||||
|
||||
type GettableReductionRules struct {
|
||||
Rules []GettableReductionRule `json:"rules" required:"true" nullable:"true"`
|
||||
Total int `json:"total" required:"true"`
|
||||
}
|
||||
|
||||
type ListReductionRulesParams struct {
|
||||
OrderBy ReductionRuleOrderBy `query:"orderBy,default=reduction" json:"orderBy"`
|
||||
Order Order `query:"order,default=desc" json:"order"`
|
||||
Offset int `query:"offset" json:"offset"`
|
||||
Limit int `query:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
func (p *ListReductionRulesParams) Validate() error {
|
||||
if p.Limit <= 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be greater than 0")
|
||||
}
|
||||
if p.Offset < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset must not be negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type PostableReductionRule struct {
|
||||
MetricName string `json:"-"`
|
||||
MatchType MatchType `json:"matchType" required:"true"`
|
||||
Labels []string `json:"labels" required:"true" nullable:"true"`
|
||||
}
|
||||
|
||||
func (req *PostableReductionRule) Validate() error {
|
||||
if req == nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
|
||||
}
|
||||
if req.MetricName == "" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName is required")
|
||||
}
|
||||
if req.MatchType != MatchTypeDrop && req.MatchType != MatchTypeKeep {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"matchType must be one of %q or %q", MatchTypeDrop.StringValue(), MatchTypeKeep.StringValue())
|
||||
}
|
||||
if len(req.Labels) == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"labels must not be empty; to allow all attributes, delete the rule instead")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type PostableReductionRulePreview struct {
|
||||
MetricName string `json:"metricName" required:"true"`
|
||||
MatchType MatchType `json:"matchType" required:"true"`
|
||||
Labels []string `json:"labels" required:"true" nullable:"true"`
|
||||
LookbackMs int64 `json:"lookbackMs,omitempty"`
|
||||
}
|
||||
|
||||
func (req *PostableReductionRulePreview) Validate() error {
|
||||
if req == nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
|
||||
}
|
||||
if req.MetricName == "" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName is required")
|
||||
}
|
||||
if req.MatchType != MatchTypeDrop && req.MatchType != MatchTypeKeep {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"matchType must be one of %q or %q", MatchTypeDrop.StringValue(), MatchTypeKeep.StringValue())
|
||||
}
|
||||
if len(req.Labels) == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "labels must not be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type AffectedAsset struct {
|
||||
Type AssetType `json:"type" required:"true"`
|
||||
ID string `json:"id" required:"true"`
|
||||
Name string `json:"name" required:"true"`
|
||||
Widget string `json:"widget,omitempty"`
|
||||
ImpactedLabels []string `json:"impactedLabels" required:"true" nullable:"true"`
|
||||
}
|
||||
|
||||
type GettableReductionRulePreview struct {
|
||||
IngestedSeries uint64 `json:"ingestedSeries" required:"true"`
|
||||
ReducedSeries uint64 `json:"reducedSeries" required:"true"`
|
||||
ReductionPercent float64 `json:"reductionPercent" required:"true"`
|
||||
DroppedLabels []string `json:"droppedLabels" required:"true" nullable:"true"`
|
||||
AffectedAssets []AffectedAsset `json:"affectedAssets" required:"true" nullable:"true"`
|
||||
EffectiveFrom time.Time `json:"effectiveFrom" required:"true"`
|
||||
}
|
||||
24
pkg/types/metricreductionruletypes/reduction_rule_test.go
Normal file
24
pkg/types/metricreductionruletypes/reduction_rule_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package metricreductionruletypes_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestListReductionRulesParamsSortDefaults(t *testing.T) {
|
||||
var params metricreductionruletypes.ListReductionRulesParams
|
||||
require.NoError(t, binding.Query.BindQuery(map[string][]string{"limit": {"10"}}, ¶ms))
|
||||
|
||||
assert.Equal(t, metricreductionruletypes.OrderByReduction, params.OrderBy, "orderBy defaults to reduction")
|
||||
assert.Equal(t, metricreductionruletypes.OrderDesc, params.Order, "order defaults to desc")
|
||||
}
|
||||
|
||||
func TestListReductionRulesParamsValidate(t *testing.T) {
|
||||
require.Error(t, (&metricreductionruletypes.ListReductionRulesParams{Limit: 0}).Validate(), "limit must be set")
|
||||
require.Error(t, (&metricreductionruletypes.ListReductionRulesParams{Limit: 10, Offset: -1}).Validate(), "offset must not be negative")
|
||||
require.NoError(t, (&metricreductionruletypes.ListReductionRulesParams{Limit: 10}).Validate())
|
||||
}
|
||||
15
pkg/types/metricreductionruletypes/store.go
Normal file
15
pkg/types/metricreductionruletypes/store.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package metricreductionruletypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
List(ctx context.Context, orgID valuer.UUID, params *ListReductionRulesParams) ([]*StorableReductionRule, int, error)
|
||||
Get(ctx context.Context, orgID valuer.UUID, metricName string) (*StorableReductionRule, error)
|
||||
Upsert(ctx context.Context, rule *StorableReductionRule) error
|
||||
Delete(ctx context.Context, orgID valuer.UUID, metricName string) error
|
||||
RunInTx(ctx context.Context, cb func(ctx context.Context) error) error
|
||||
}
|
||||
@@ -241,10 +241,11 @@ type MetricAlertsResponse struct {
|
||||
|
||||
// MetricDashboard represents a dashboard/widget referencing a metric.
|
||||
type MetricDashboard struct {
|
||||
DashboardName string `json:"dashboardName" required:"true"`
|
||||
DashboardID string `json:"dashboardId" required:"true"`
|
||||
WidgetID string `json:"widgetId" required:"true"`
|
||||
WidgetName string `json:"widgetName" required:"true"`
|
||||
DashboardName string `json:"dashboardName" required:"true"`
|
||||
DashboardID string `json:"dashboardId" required:"true"`
|
||||
WidgetID string `json:"widgetId" required:"true"`
|
||||
WidgetName string `json:"widgetName" required:"true"`
|
||||
GroupBy []string `json:"groupBy,omitempty"`
|
||||
}
|
||||
|
||||
// MetricDashboardsResponse represents the response for metric dashboards endpoint.
|
||||
|
||||
27
tests/integration/testdata/inframonitoring/namespaces_same_name_across_clusters.jsonl
vendored
Normal file
27
tests/integration/testdata/inframonitoring/namespaces_same_name_across_clusters.jsonl
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.3, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 0.3, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 0.3, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 100000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 100000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 100000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g1-p1-uid", "k8s.pod.name": "dup-ns-g1-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 0.5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 0.5, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 300000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 300000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 300000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 4, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 4, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g2-p1-uid", "k8s.pod.name": "dup-ns-g2-p1", "k8s.namespace.name": "dup-ns", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 4, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 0.1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.cpu.usage", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 0.1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 200000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 200000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.memory.working_set", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 200000000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "dup-ns-g3-p1-uid", "k8s.pod.name": "dup-ns-g3-p1", "k8s.namespace.name": "dup-ns"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
60
tests/integration/testdata/inframonitoring/volumes_same_name_across_ns_and_clusters.jsonl
vendored
Normal file
60
tests/integration/testdata/inframonitoring/volumes_same_name_across_ns_and_clusters.jsonl
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 60.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 60.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 60.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 100.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 100.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 100.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 1000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 1000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 600.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 600.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 600.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 400.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 400.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g1-p1-uid", "k8s.pod.name": "dup-pvc-g1-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 400.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 100.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 100.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 100.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 5000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 5000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 5000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 1000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 1000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 4000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 4000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g2-p1-uid", "k8s.pod.name": "dup-pvc-g2-p1", "k8s.namespace.name": "ns-y", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-a"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 4000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 50.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 50.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 50.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 300.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 300.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 300.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 3000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 3000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 3000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 2500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g3-p1-uid", "k8s.pod.name": "dup-pvc-g3-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup", "k8s.cluster.name": "cluster-b"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 2500.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 0.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.available", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 0.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 200.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 200.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.capacity", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 200.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 2000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 2000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 0.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 0.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.free", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 0.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 2000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
{"metric_name": "k8s.volume.inodes.used", "labels": {"k8s.persistentvolumeclaim.name": "dup-pvc", "k8s.pod.uid": "dup-pvc-g4-p1-uid", "k8s.pod.name": "dup-pvc-g4-p1", "k8s.namespace.name": "ns-x", "k8s.node.name": "node-x", "k8s.statefulset.name": "ss-dup"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 2000.0, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
|
||||
@@ -317,23 +317,94 @@ def test_namespaces_pod_phase_aggregation(
|
||||
}
|
||||
|
||||
|
||||
# Float record fields compared with tolerance; everything else compared with ==.
|
||||
_GROUPBY_FLOAT_FIELDS = {
|
||||
"namespaceCPU",
|
||||
"namespaceMemory",
|
||||
}
|
||||
|
||||
|
||||
def _phase(pending=0, running=0, succeeded=0, failed=0, unknown=0) -> dict:
|
||||
return {"pending": pending, "running": running, "succeeded": succeeded, "failed": failed, "unknown": unknown}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"group_key,expected_running",
|
||||
"scenario",
|
||||
[
|
||||
# groupBy=[k8s.namespace.name]: one record per namespace, namespaceName
|
||||
# populated (namespaces.go:27-30). Each namespace has 1 running pod.
|
||||
# Explicit groupBy=[k8s.namespace.name]: one record per namespace,
|
||||
# namespaceName populated (namespaces.go:27-30), response grouped_list.
|
||||
# Each namespace has 1 running pod.
|
||||
pytest.param(
|
||||
"k8s.namespace.name",
|
||||
{"gb-ns-1": 1, "gb-ns-2": 1, "gb-ns-3": 1, "gb-ns-4": 1},
|
||||
{
|
||||
"fixture": "namespaces_groupby.jsonl",
|
||||
"group_by": "k8s.namespace.name",
|
||||
"filter": None,
|
||||
"group_meta_keys": ["k8s.namespace.name"],
|
||||
"expected_type": "grouped_list",
|
||||
"groups": {
|
||||
"gb-ns-1": {"namespaceName": "gb-ns-1", "podCountsByPhase": _phase(running=1)},
|
||||
"gb-ns-2": {"namespaceName": "gb-ns-2", "podCountsByPhase": _phase(running=1)},
|
||||
"gb-ns-3": {"namespaceName": "gb-ns-3", "podCountsByPhase": _phase(running=1)},
|
||||
"gb-ns-4": {"namespaceName": "gb-ns-4", "podCountsByPhase": _phase(running=1)},
|
||||
},
|
||||
},
|
||||
id="namespace_name",
|
||||
),
|
||||
# groupBy=[k8s.cluster.name]: aggregated across each cluster's 2
|
||||
# namespaces, namespaceName empty. Each cluster has 2 x 1 = 2 running pods.
|
||||
# Explicit groupBy=[k8s.cluster.name]: aggregated across each cluster's 2
|
||||
# namespaces, namespaceName empty, response grouped_list. 2 running each.
|
||||
pytest.param(
|
||||
"k8s.cluster.name",
|
||||
{"gb-cluster-a": 2, "gb-cluster-b": 2},
|
||||
{
|
||||
"fixture": "namespaces_groupby.jsonl",
|
||||
"group_by": "k8s.cluster.name",
|
||||
"filter": None,
|
||||
"group_meta_keys": ["k8s.cluster.name"],
|
||||
"expected_type": "grouped_list",
|
||||
"groups": {
|
||||
"gb-cluster-a": {"namespaceName": "", "podCountsByPhase": _phase(running=2)},
|
||||
"gb-cluster-b": {"namespaceName": "", "podCountsByPhase": _phase(running=2)},
|
||||
},
|
||||
},
|
||||
id="cluster",
|
||||
),
|
||||
# Default groupBy (no groupBy in request) => [k8s.namespace.name,
|
||||
# k8s.cluster.name] (module.go ListNamespaces), response list. Namespaces
|
||||
# are cluster-scoped, so a same-named namespace must NOT collapse across
|
||||
# clusters; the empty-cluster group (k8s.cluster.name label absent on the
|
||||
# source pods) must appear as its own row with real metrics, not be dropped.
|
||||
# Single pod per group => SpaceAggregationSum == seeded value.
|
||||
# Fails on the pre-cluster default (name only) — the three groups would
|
||||
# collapse into one summed row.
|
||||
pytest.param(
|
||||
{
|
||||
"fixture": "namespaces_same_name_across_clusters.jsonl",
|
||||
"group_by": None,
|
||||
"filter": "k8s.namespace.name = 'dup-ns'",
|
||||
"group_meta_keys": ["k8s.namespace.name", "k8s.cluster.name"],
|
||||
"expected_type": "list",
|
||||
"groups": {
|
||||
("dup-ns", "cluster-a"): {
|
||||
"namespaceName": "dup-ns",
|
||||
"namespaceCPU": 0.3,
|
||||
"namespaceMemory": 100000000.0,
|
||||
"podCountsByPhase": _phase(running=1),
|
||||
},
|
||||
("dup-ns", "cluster-b"): {
|
||||
"namespaceName": "dup-ns",
|
||||
"namespaceCPU": 0.5,
|
||||
"namespaceMemory": 300000000.0,
|
||||
"podCountsByPhase": _phase(failed=1),
|
||||
},
|
||||
# empty-cluster group: k8s.cluster.name label absent on the source pods.
|
||||
("dup-ns", ""): {
|
||||
"namespaceName": "dup-ns",
|
||||
"namespaceCPU": 0.1,
|
||||
"namespaceMemory": 200000000.0,
|
||||
"podCountsByPhase": _phase(pending=1),
|
||||
},
|
||||
},
|
||||
},
|
||||
id="default_disambiguates_cluster",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_namespaces_groupby(
|
||||
@@ -341,55 +412,64 @@ def test_namespaces_groupby(
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
insert_metrics,
|
||||
group_key: str,
|
||||
expected_running: dict,
|
||||
scenario: dict,
|
||||
) -> None:
|
||||
"""groupBy returns one record per distinct group with aggregated pod-phase
|
||||
counts. namespaceName is populated only when grouping by k8s.namespace.name
|
||||
(namespaces.go:27-30 list-vs-grouped branch); meta surfaces the groupBy key."""
|
||||
"""groupBy determines row identity. Explicit groupBy returns one grouped_list
|
||||
record per distinct group (namespaceName populated only when grouping by
|
||||
k8s.namespace.name; namespaces.go:27-30). With no groupBy the default is
|
||||
[k8s.namespace.name, k8s.cluster.name] (module.go ListNamespaces), so
|
||||
same-named namespaces across clusters stay as separate, un-collapsed list rows
|
||||
(incl. an absent-cluster group keyed by ""). meta always surfaces the grouping
|
||||
key(s)."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
get_testdata_file_path("inframonitoring/namespaces_groupby.jsonl"),
|
||||
get_testdata_file_path(f"inframonitoring/{scenario['fixture']}"),
|
||||
base_time=now - timedelta(minutes=4),
|
||||
)
|
||||
)
|
||||
|
||||
body: dict = {
|
||||
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
"end": int(now.timestamp() * 1000),
|
||||
"limit": 50,
|
||||
}
|
||||
if scenario["group_by"] is not None:
|
||||
body["groupBy"] = [{"name": scenario["group_by"], "fieldDataType": "string", "fieldContext": "resource"}]
|
||||
if scenario["filter"] is not None:
|
||||
body["filter"] = {"expression": scenario["filter"]}
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
"end": int(now.timestamp() * 1000),
|
||||
"limit": 50,
|
||||
"groupBy": [
|
||||
{
|
||||
"name": group_key,
|
||||
"fieldDataType": "string",
|
||||
"fieldContext": "resource",
|
||||
}
|
||||
],
|
||||
},
|
||||
json=body,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
data = response.json()["data"]
|
||||
assert data["total"] == len(expected_running)
|
||||
|
||||
group_of = lambda r: r["namespaceName"] if group_key == "k8s.namespace.name" else r["meta"][group_key] # noqa: E731 # pylint: disable=unnecessary-lambda-assignment
|
||||
by_group = {group_of(r): r for r in data["records"]}
|
||||
assert set(by_group.keys()) == set(expected_running.keys())
|
||||
groups = scenario["groups"]
|
||||
meta_keys = scenario["group_meta_keys"]
|
||||
assert data["type"] == scenario["expected_type"]
|
||||
assert data["total"] == len(groups)
|
||||
|
||||
for group, running in expected_running.items():
|
||||
rec = by_group[group]
|
||||
# namespaceName populated per namespace when grouping by k8s.namespace.name,
|
||||
# empty otherwise.
|
||||
assert rec["namespaceName"] == (group if group_key == "k8s.namespace.name" else "")
|
||||
assert rec["podCountsByPhase"]["running"] == running
|
||||
for other in ("pending", "succeeded", "failed", "unknown"):
|
||||
assert rec["podCountsByPhase"][other] == 0
|
||||
assert group_key in rec["meta"], rec["meta"]
|
||||
def _gid(rec: dict):
|
||||
vals = [rec["meta"][k] for k in meta_keys]
|
||||
return vals[0] if len(vals) == 1 else tuple(vals)
|
||||
|
||||
by_group = {_gid(r): r for r in data["records"]}
|
||||
assert set(by_group.keys()) == set(groups.keys())
|
||||
|
||||
for gid, exp in groups.items():
|
||||
rec = by_group[gid]
|
||||
for k in meta_keys:
|
||||
assert k in rec["meta"], rec["meta"]
|
||||
for field, val in exp.items():
|
||||
if field in _GROUPBY_FLOAT_FIELDS:
|
||||
assert compare_values(rec[field], val, 1e-6), f"{gid}.{field}: got {rec[field]}, expected {val}"
|
||||
else:
|
||||
assert rec[field] == val, f"{gid}.{field}: got {rec[field]}, expected {val}"
|
||||
|
||||
|
||||
def test_namespaces_pagination(
|
||||
|
||||
@@ -376,23 +376,111 @@ def test_volumes_non_pvc_volume_filtered(
|
||||
assert rec["persistentVolumeClaimName"] == "np-real-pvc"
|
||||
|
||||
|
||||
# Float record fields compared with tolerance; everything else compared with ==.
|
||||
_GROUPBY_FLOAT_FIELDS = {
|
||||
"volumeAvailable",
|
||||
"volumeCapacity",
|
||||
"volumeUsage",
|
||||
"volumeInodes",
|
||||
"volumeInodesFree",
|
||||
"volumeInodesUsed",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"group_key,expected_groups",
|
||||
"scenario",
|
||||
[
|
||||
# groupBy=[k8s.persistentvolumeclaim.name]: one record per PVC,
|
||||
# persistentVolumeClaimName populated (volumes.go:26-29).
|
||||
# Explicit groupBy=[k8s.persistentvolumeclaim.name]: one record per PVC,
|
||||
# persistentVolumeClaimName populated (volumes.go:26-29), response grouped_list.
|
||||
pytest.param(
|
||||
"k8s.persistentvolumeclaim.name",
|
||||
{"gb-pvc-a1", "gb-pvc-a2", "gb-pvc-b1", "gb-pvc-b2"},
|
||||
{
|
||||
"fixture": "volumes_groupby.jsonl",
|
||||
"group_by": "k8s.persistentvolumeclaim.name",
|
||||
"filter": None,
|
||||
"group_meta_keys": ["k8s.persistentvolumeclaim.name"],
|
||||
"expected_type": "grouped_list",
|
||||
"groups": {
|
||||
"gb-pvc-a1": {"persistentVolumeClaimName": "gb-pvc-a1"},
|
||||
"gb-pvc-a2": {"persistentVolumeClaimName": "gb-pvc-a2"},
|
||||
"gb-pvc-b1": {"persistentVolumeClaimName": "gb-pvc-b1"},
|
||||
"gb-pvc-b2": {"persistentVolumeClaimName": "gb-pvc-b2"},
|
||||
},
|
||||
},
|
||||
id="pvc_name",
|
||||
),
|
||||
# groupBy=[k8s.namespace.name]: aggregated per namespace,
|
||||
# persistentVolumeClaimName cleared (custom-groupBy branch).
|
||||
# Explicit groupBy=[k8s.namespace.name]: aggregated per namespace,
|
||||
# persistentVolumeClaimName cleared, response grouped_list.
|
||||
pytest.param(
|
||||
"k8s.namespace.name",
|
||||
{"gb-ns-a", "gb-ns-b"},
|
||||
{
|
||||
"fixture": "volumes_groupby.jsonl",
|
||||
"group_by": "k8s.namespace.name",
|
||||
"filter": None,
|
||||
"group_meta_keys": ["k8s.namespace.name"],
|
||||
"expected_type": "grouped_list",
|
||||
"groups": {
|
||||
"gb-ns-a": {"persistentVolumeClaimName": ""},
|
||||
"gb-ns-b": {"persistentVolumeClaimName": ""},
|
||||
},
|
||||
},
|
||||
id="namespace",
|
||||
),
|
||||
# Default groupBy (no groupBy in request) => [k8s.persistentvolumeclaim.name,
|
||||
# k8s.namespace.name, k8s.cluster.name] (module.go ListVolumes), response list.
|
||||
# Same PVC name must NOT collapse across namespaces OR clusters; the
|
||||
# empty-cluster group (k8s.cluster.name label absent on the source series)
|
||||
# must appear as its own row with real metrics, not be dropped.
|
||||
# Single series per group => SpaceAggregationSum == seeded value.
|
||||
# Fails on the pre-cluster default (name+ns) — the three ns-x groups would
|
||||
# collapse into one summed row.
|
||||
pytest.param(
|
||||
{
|
||||
"fixture": "volumes_same_name_across_ns_and_clusters.jsonl",
|
||||
"group_by": None,
|
||||
"filter": "k8s.persistentvolumeclaim.name = 'dup-pvc'",
|
||||
"group_meta_keys": ["k8s.persistentvolumeclaim.name", "k8s.namespace.name", "k8s.cluster.name"],
|
||||
"expected_type": "list",
|
||||
"groups": {
|
||||
("dup-pvc", "ns-x", "cluster-a"): {
|
||||
"persistentVolumeClaimName": "dup-pvc",
|
||||
"volumeCapacity": 100.0,
|
||||
"volumeAvailable": 60.0,
|
||||
"volumeUsage": 40.0,
|
||||
"volumeInodes": 1000.0,
|
||||
"volumeInodesFree": 600.0,
|
||||
"volumeInodesUsed": 400.0,
|
||||
},
|
||||
("dup-pvc", "ns-y", "cluster-a"): {
|
||||
"persistentVolumeClaimName": "dup-pvc",
|
||||
"volumeCapacity": 500.0,
|
||||
"volumeAvailable": 100.0,
|
||||
"volumeUsage": 400.0,
|
||||
"volumeInodes": 5000.0,
|
||||
"volumeInodesFree": 1000.0,
|
||||
"volumeInodesUsed": 4000.0,
|
||||
},
|
||||
("dup-pvc", "ns-x", "cluster-b"): {
|
||||
"persistentVolumeClaimName": "dup-pvc",
|
||||
"volumeCapacity": 300.0,
|
||||
"volumeAvailable": 50.0,
|
||||
"volumeUsage": 250.0,
|
||||
"volumeInodes": 3000.0,
|
||||
"volumeInodesFree": 500.0,
|
||||
"volumeInodesUsed": 2500.0,
|
||||
},
|
||||
# empty-cluster group: k8s.cluster.name label absent on the source series.
|
||||
("dup-pvc", "ns-x", ""): {
|
||||
"persistentVolumeClaimName": "dup-pvc",
|
||||
"volumeCapacity": 200.0,
|
||||
"volumeAvailable": 0.0,
|
||||
"volumeUsage": 200.0,
|
||||
"volumeInodes": 2000.0,
|
||||
"volumeInodesFree": 0.0,
|
||||
"volumeInodesUsed": 2000.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
id="default_disambiguates_ns_and_cluster",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_volumes_groupby(
|
||||
@@ -400,51 +488,64 @@ def test_volumes_groupby(
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token,
|
||||
insert_metrics,
|
||||
group_key: str,
|
||||
expected_groups: set,
|
||||
scenario: dict,
|
||||
) -> None:
|
||||
"""groupBy returns one record per distinct group. persistentVolumeClaimName
|
||||
is populated only when grouping by k8s.persistentvolumeclaim.name
|
||||
(volumes.go:26-29 list-vs-grouped branch); meta surfaces the groupBy key."""
|
||||
"""groupBy determines row identity. Explicit groupBy returns one grouped_list
|
||||
record per distinct group (persistentVolumeClaimName populated only when
|
||||
grouping by k8s.persistentvolumeclaim.name; volumes.go:26-29). With no groupBy
|
||||
the default is [k8s.persistentvolumeclaim.name, k8s.namespace.name,
|
||||
k8s.cluster.name] (module.go ListVolumes), so same-named PVCs across
|
||||
namespaces/clusters stay as separate, un-collapsed list rows (incl. an
|
||||
absent-cluster group keyed by ""). meta always surfaces the grouping key(s)."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
get_testdata_file_path("inframonitoring/volumes_groupby.jsonl"),
|
||||
get_testdata_file_path(f"inframonitoring/{scenario['fixture']}"),
|
||||
base_time=now - timedelta(minutes=4),
|
||||
)
|
||||
)
|
||||
|
||||
body: dict = {
|
||||
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
"end": int(now.timestamp() * 1000),
|
||||
"limit": 50,
|
||||
}
|
||||
if scenario["group_by"] is not None:
|
||||
body["groupBy"] = [{"name": scenario["group_by"], "fieldDataType": "string", "fieldContext": "resource"}]
|
||||
if scenario["filter"] is not None:
|
||||
body["filter"] = {"expression": scenario["filter"]}
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(ENDPOINT),
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
"end": int(now.timestamp() * 1000),
|
||||
"limit": 50,
|
||||
"groupBy": [
|
||||
{
|
||||
"name": group_key,
|
||||
"fieldDataType": "string",
|
||||
"fieldContext": "resource",
|
||||
}
|
||||
],
|
||||
},
|
||||
json=body,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
data = response.json()["data"]
|
||||
assert data["total"] == len(expected_groups)
|
||||
|
||||
is_pvc_group = group_key == "k8s.persistentvolumeclaim.name"
|
||||
group_of = lambda r: r["persistentVolumeClaimName"] if is_pvc_group else r["meta"][group_key] # noqa: E731 # pylint: disable=unnecessary-lambda-assignment
|
||||
by_group = {group_of(r): r for r in data["records"]}
|
||||
assert set(by_group.keys()) == expected_groups
|
||||
groups = scenario["groups"]
|
||||
meta_keys = scenario["group_meta_keys"]
|
||||
assert data["type"] == scenario["expected_type"]
|
||||
assert data["total"] == len(groups)
|
||||
|
||||
for group, rec in by_group.items():
|
||||
# persistentVolumeClaimName populated per PVC when grouping by it, empty otherwise.
|
||||
assert rec["persistentVolumeClaimName"] == (group if is_pvc_group else "")
|
||||
assert group_key in rec["meta"], rec["meta"]
|
||||
def _gid(rec: dict):
|
||||
vals = [rec["meta"][k] for k in meta_keys]
|
||||
return vals[0] if len(vals) == 1 else tuple(vals)
|
||||
|
||||
by_group = {_gid(r): r for r in data["records"]}
|
||||
assert set(by_group.keys()) == set(groups.keys())
|
||||
|
||||
for gid, exp in groups.items():
|
||||
rec = by_group[gid]
|
||||
for k in meta_keys:
|
||||
assert k in rec["meta"], rec["meta"]
|
||||
for field, val in exp.items():
|
||||
if field in _GROUPBY_FLOAT_FIELDS:
|
||||
assert compare_values(rec[field], val, 1e-6), f"{gid}.{field}: got {rec[field]}, expected {val}"
|
||||
else:
|
||||
assert rec[field] == val, f"{gid}.{field}: got {rec[field]}, expected {val}"
|
||||
|
||||
|
||||
def test_volumes_pagination(
|
||||
|
||||
@@ -614,7 +614,7 @@ def test_histogram_p90_returns_warning_outside_data_window(
|
||||
assert warnings[0]["message"].startswith(f"no data found for the metric {metric_name}")
|
||||
|
||||
|
||||
def test_non_existent_metrics_returns_404(
|
||||
def test_non_existent_metrics_returns_warning(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
@@ -635,9 +635,11 @@ def test_non_existent_metrics_returns_404(
|
||||
|
||||
start_2h = int((now - timedelta(hours=2)).timestamp() * 1000)
|
||||
response = make_query_request(signoz, token, start_2h, end_ms, [query])
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
assert get_error_message(response.json()) == "could not find the metric whatevergoennnsgoeshere"
|
||||
data = response.json()
|
||||
warnings = get_all_warnings(data)
|
||||
assert any("whatevergoennnsgoeshere" in w["message"] and "has never been received" in w["message"] for w in warnings), f"expected never-seen metric warning, got: {warnings}"
|
||||
|
||||
|
||||
def test_non_existent_internal_metrics_returns_no_warning(
|
||||
|
||||
Reference in New Issue
Block a user