mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-31 02:01:12 +01:00
Compare commits
46 Commits
v2-transpi
...
issue_5601
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d81ae64515 | ||
|
|
13b25a6be1 | ||
|
|
e62bfb4a4c | ||
|
|
51d5d3ea35 | ||
|
|
7eb3f7df32 | ||
|
|
5eb3b5e3e0 | ||
|
|
b88ee12cd5 | ||
|
|
6f3dd0b7ad | ||
|
|
d32d93cd39 | ||
|
|
e9a931788c | ||
|
|
e51c464417 | ||
|
|
d6d09549f0 | ||
|
|
04637bd960 | ||
|
|
d71c0c938a | ||
|
|
0703fbf961 | ||
|
|
e911223b1d | ||
|
|
4e45620b72 | ||
|
|
a9506f1354 | ||
|
|
cb3cc3de56 | ||
|
|
017e62814a | ||
|
|
e2e7caf1ca | ||
|
|
f10f526249 | ||
|
|
a5350682c3 | ||
|
|
a7b74005ea | ||
|
|
787e1be974 | ||
|
|
19ee51be66 | ||
|
|
b2c5af428e | ||
|
|
093f9b41c4 | ||
|
|
596e128005 | ||
|
|
2e1da92367 | ||
|
|
1e8d72e8fa | ||
|
|
cce080e1ae | ||
|
|
deca060a4d | ||
|
|
03c7e524e7 | ||
|
|
815dc7d88b | ||
|
|
f50d9199fe | ||
|
|
31efe177a4 | ||
|
|
d502d12ac3 | ||
|
|
bd9f15a716 | ||
|
|
813ef988c9 | ||
|
|
40e6799285 | ||
|
|
1caa60a3cd | ||
|
|
3f781f0083 | ||
|
|
6aec05cf7a | ||
|
|
683a52f35a | ||
|
|
e924fa1e62 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -53,6 +53,7 @@ jobs:
|
||||
- queriermetrics
|
||||
- querierscalar
|
||||
- queriercommon
|
||||
- querierai
|
||||
- rawexportdata
|
||||
- promqlconformance
|
||||
- querierauthz
|
||||
|
||||
10
cmd/authz.go
10
cmd/authz.go
@@ -93,9 +93,13 @@ func runGenerateAuthz(_ context.Context) error {
|
||||
registry := coretypes.NewRegistry()
|
||||
|
||||
allowedResources := map[string]bool{
|
||||
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMeterMetrics).String(): true,
|
||||
}
|
||||
|
||||
allowedTypes := map[string]bool{}
|
||||
|
||||
@@ -2,9 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/zeustypes"
|
||||
)
|
||||
@@ -25,8 +25,8 @@ var meterConfigs = []metercollector.Config{
|
||||
Name: zeustypes.MeterLogSize,
|
||||
Unit: zeustypes.MeterUnitBytes,
|
||||
Aggregation: zeustypes.MeterAggregationSum,
|
||||
DBName: telemetrylogs.DBName,
|
||||
TableName: telemetrylogs.LogsV2LocalTableName,
|
||||
DBName: logstelemetryschema.DBName,
|
||||
TableName: logstelemetryschema.LogsV2LocalTableName,
|
||||
DefaultRetentionDays: retentiontypes.DefaultLogsRetentionDays,
|
||||
},
|
||||
},
|
||||
@@ -36,8 +36,8 @@ var meterConfigs = []metercollector.Config{
|
||||
Name: zeustypes.MeterSpanSize,
|
||||
Unit: zeustypes.MeterUnitBytes,
|
||||
Aggregation: zeustypes.MeterAggregationSum,
|
||||
DBName: telemetrytraces.DBName,
|
||||
TableName: telemetrytraces.SpanIndexV3LocalTableName,
|
||||
DBName: tracestelemetryschema.DBName,
|
||||
TableName: tracestelemetryschema.SpanIndexV3LocalTableName,
|
||||
DefaultRetentionDays: retentiontypes.DefaultTracesRetentionDays,
|
||||
},
|
||||
},
|
||||
@@ -47,8 +47,8 @@ var meterConfigs = []metercollector.Config{
|
||||
Name: zeustypes.MeterDatapointCount,
|
||||
Unit: zeustypes.MeterUnitCount,
|
||||
Aggregation: zeustypes.MeterAggregationSum,
|
||||
DBName: telemetrymetrics.DBName,
|
||||
TableName: telemetrymetrics.SamplesV4LocalTableName,
|
||||
DBName: metricstelemetryschema.DBName,
|
||||
TableName: metricstelemetryschema.SamplesV4LocalTableName,
|
||||
DefaultRetentionDays: retentiontypes.DefaultMetricsRetentionDays,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1496,6 +1496,7 @@ components:
|
||||
- redis
|
||||
- cloudsql_postgres
|
||||
- memorystore_redis
|
||||
- computeengine
|
||||
type: string
|
||||
CloudintegrationtypesServiceMetadata:
|
||||
properties:
|
||||
@@ -6899,6 +6900,7 @@ components:
|
||||
Querybuildertypesv5QueryEnvelope:
|
||||
discriminator:
|
||||
mapping:
|
||||
builder_ai_query: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderAI'
|
||||
builder_formula: '#/components/schemas/Querybuildertypesv5QueryEnvelopeFormula'
|
||||
builder_query: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilder'
|
||||
builder_trace_operator: '#/components/schemas/Querybuildertypesv5QueryEnvelopeTraceOperator'
|
||||
@@ -6907,6 +6909,7 @@ components:
|
||||
propertyName: type
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilder'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderAI'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeFormula'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeTraceOperator'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopePromQL'
|
||||
@@ -6921,6 +6924,15 @@ components:
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
Querybuildertypesv5QueryEnvelopeBuilderAI:
|
||||
properties:
|
||||
spec:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
|
||||
type:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQL:
|
||||
properties:
|
||||
spec:
|
||||
@@ -7034,6 +7046,7 @@ components:
|
||||
Querybuildertypesv5QueryType:
|
||||
enum:
|
||||
- builder_query
|
||||
- builder_ai_query
|
||||
- builder_formula
|
||||
- builder_trace_operator
|
||||
- clickhouse_sql
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metertelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/licensetypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
@@ -153,7 +153,7 @@ func (provider *Provider) Collect(
|
||||
func buildOriginQuery(meterName string) (string, []any) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("toInt64(ifNull(min(unix_milli), 0))")
|
||||
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
|
||||
sb.From(metertelemetryschema.DBName + "." + metertelemetryschema.SamplesTableName)
|
||||
sb.Where(sb.Equal("metric_name", meterName))
|
||||
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func buildQuery(meterName string, segment *retentiontypes.RetentionPolicySegment
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(selects...)
|
||||
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
|
||||
sb.From(metertelemetryschema.DBName + "." + metertelemetryschema.SamplesTableName)
|
||||
sb.Where(
|
||||
sb.Equal("metric_name", meterName),
|
||||
sb.GTE("unix_milli", segment.StartMs),
|
||||
|
||||
@@ -8,16 +8,16 @@ import (
|
||||
sqlbuilder "github.com/huandu/go-sqlbuilder"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
|
||||
)
|
||||
|
||||
var (
|
||||
reductionRulesTable = telemetrymetrics.DBName + "." + telemetrymetrics.ReductionRulesTableName
|
||||
metadataTable = telemetrymetrics.DBName + "." + telemetrymetrics.AttributesMetadataTableName
|
||||
bufferSeriesTable = telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4BufferTableName
|
||||
reductionRulesTable = metricstelemetryschema.DBName + "." + metricstelemetryschema.ReductionRulesTableName
|
||||
metadataTable = metricstelemetryschema.DBName + "." + metricstelemetryschema.AttributesMetadataTableName
|
||||
bufferSeriesTable = metricstelemetryschema.DBName + "." + metricstelemetryschema.TimeseriesV4BufferTableName
|
||||
)
|
||||
|
||||
const timeSeriesBucketMilli = int64(time.Hour / time.Millisecond)
|
||||
@@ -192,7 +192,7 @@ func (c *clickhouse) ingestedSeriesCount(ctx context.Context, metricNames []stri
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("metric_name", "uniq(fingerprint)")
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
sb.From(metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName)
|
||||
conds := []string{
|
||||
sb.In("metric_name", names...),
|
||||
sb.GE("unix_milli", startMs),
|
||||
@@ -229,8 +229,8 @@ func (c *clickhouse) ingestedSeriesCount(ctx context.Context, metricNames []stri
|
||||
// reduced sample tables.
|
||||
func (c *clickhouse) reducedSeriesCount(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
|
||||
out := make(map[string]uint64, len(metricNames))
|
||||
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
|
||||
counts, err := c.reducedSeriesCountForTable(ctx, telemetrymetrics.DBName+"."+table, metricNames, effectiveFrom, startMs, endMs)
|
||||
for _, table := range []string{metricstelemetryschema.SamplesV4ReducedLastTableName, metricstelemetryschema.SamplesV4ReducedSumTableName} {
|
||||
counts, err := c.reducedSeriesCountForTable(ctx, metricstelemetryschema.DBName+"."+table, metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -300,9 +300,9 @@ func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, eff
|
||||
direction = "DESC"
|
||||
}
|
||||
|
||||
ingestedTable := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName
|
||||
reducedLast := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedLastTableName
|
||||
reducedSum := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedSumTableName
|
||||
ingestedTable := metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName
|
||||
reducedLast := metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4ReducedLastTableName
|
||||
reducedSum := metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4ReducedSumTableName
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("base.metric_name AS metric_name", "ifNull(i.cnt, 0) AS ingested", "ifNull(d.cnt, 0) AS reduced")
|
||||
@@ -352,16 +352,16 @@ func (c *clickhouse) SampleVolumeByMetric(ctx context.Context, metricNames []str
|
||||
}
|
||||
ctx = c.withThreads(ctx)
|
||||
|
||||
ingested, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, "count()", metricNames, effectiveFrom, startMs, endMs)
|
||||
ingested, err := c.countSamplesByMetric(ctx, metricstelemetryschema.DBName+"."+metricstelemetryschema.SamplesV4BufferTableName, "count()", metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
last, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedLastTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
|
||||
last, err := c.countSamplesByMetric(ctx, metricstelemetryschema.DBName+"."+metricstelemetryschema.SamplesV4ReducedLastTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sum, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedSumTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
|
||||
sum, err := c.countSamplesByMetric(ctx, metricstelemetryschema.DBName+"."+metricstelemetryschema.SamplesV4ReducedSumTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -419,7 +419,7 @@ func (c *clickhouse) TotalVolume(ctx context.Context, startMs, endMs int64) (uin
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("uniq(fingerprint)", "count()")
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
sb.From(metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName)
|
||||
sb.Where(sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs))
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
@@ -470,7 +470,7 @@ func (c *clickhouse) SampleTimeseries(ctx context.Context, ruledMetrics []string
|
||||
func (c *clickhouse) totalSamplesByBucket(ctx context.Context, startMs, endMs int64) (map[int64]uint64, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(sampleBucketExpr, "count()")
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
sb.From(metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName)
|
||||
sb.Where(sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs))
|
||||
sb.GroupBy("bucket")
|
||||
|
||||
@@ -485,7 +485,7 @@ func (c *clickhouse) ruledIngestedSamplesByBucket(ctx context.Context, metricNam
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(sampleBucketExpr, "count()")
|
||||
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
|
||||
sb.From(metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
@@ -499,7 +499,7 @@ func (c *clickhouse) ruledIngestedSamplesByBucket(ctx context.Context, metricNam
|
||||
// reduced 60s rows are versioned by computed_at, so count distinct buckets.
|
||||
func (c *clickhouse) ruledRetainedSamplesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
|
||||
out := make(map[int64]uint64)
|
||||
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
|
||||
for _, table := range []string{metricstelemetryschema.SamplesV4ReducedLastTableName, metricstelemetryschema.SamplesV4ReducedSumTableName} {
|
||||
names := make([]any, len(metricNames))
|
||||
for i, name := range metricNames {
|
||||
names[i] = name
|
||||
@@ -507,7 +507,7 @@ func (c *clickhouse) ruledRetainedSamplesByBucket(ctx context.Context, metricNam
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(sampleBucketExpr, "uniq(reduced_fingerprint, unix_milli)")
|
||||
sb.From(telemetrymetrics.DBName + "." + table)
|
||||
sb.From(metricstelemetryschema.DBName + "." + table)
|
||||
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
|
||||
if len(effectiveFrom) > 0 {
|
||||
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
|
||||
|
||||
2
frontend/docs/assets/drawer-example.svg
Normal file
2
frontend/docs/assets/drawer-example.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 139 KiB |
2
frontend/docs/assets/edit-example.svg
Normal file
2
frontend/docs/assets/edit-example.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 49 KiB |
2
frontend/docs/assets/list-example.svg
Normal file
2
frontend/docs/assets/list-example.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 86 KiB |
2
frontend/docs/assets/quick-filters-example.svg
Normal file
2
frontend/docs/assets/quick-filters-example.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 64 KiB |
136
frontend/docs/authz-guide.md
Normal file
136
frontend/docs/authz-guide.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# AuthZ Guide
|
||||
|
||||
How to structure a page so it works with the permission system.
|
||||
|
||||
We are migrating from the `ADMIN | EDITOR | VIEWER` roles to per-action checks. Instead of granting `VIEWER` and
|
||||
exposing everything, a user can now be granted access to a single resource, eg: `Logs` only.
|
||||
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Core rules](#core-rules)
|
||||
- [Page patterns](#page-patterns)
|
||||
- [What "blocked" means](#what-blocked-means)
|
||||
- [Migration checklist](#migration-checklist)
|
||||
- [Testing](#testing)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Check whether the resource your page represents is supported: see
|
||||
[permissions.type.ts](../src/lib/authz/hooks/useAuthZ/permissions.type.ts) for the current resources and their allowed
|
||||
verbs.
|
||||
|
||||
**If the resource is not listed there, skip authz for now**. The backend does not enforce it yet, so any frontend
|
||||
check would be decorative. Revisit once the resource is generated into that file (it is auto-generated from the
|
||||
backend).
|
||||
|
||||
## Core rules
|
||||
|
||||
These hold for every page. The per-pattern sections below only add to them.
|
||||
|
||||
1. **A page is always reachable, regardless of permission.** The intended action must be known before permission can
|
||||
be checked, so the route renders first and individual pieces are gated.
|
||||
2. **`update` requires both `read` and `update`.** A user who can write but cannot read the current value must not get
|
||||
an edit affordance.
|
||||
3. **`delete` is independent of `read`.** Delete controls stay visible for a user who holds `delete` but not `read`.
|
||||
4. **Never gate per row.** If a user can `list`, render every row. Check `read` only when the row is opened (drawer or
|
||||
detail route).
|
||||
5. **Gate the narrowest thing that works**, a button over a section, a section over a page.
|
||||
6. **A resource may be gated while a sub-resource is not.** A user without `read` on Service Accounts can still hold
|
||||
`create` on API Keys, so blocking the outer container would hide work they are allowed to do.
|
||||
7. **Verbs not covered here** (`attach`, `detach`, `assignee`) behave like `delete`: gate the control that triggers
|
||||
them, not the surrounding content.
|
||||
|
||||
## Page patterns
|
||||
|
||||
### List page
|
||||
|
||||

|
||||
|
||||
Without `list`, but with any of `read` / `create` / `update`, only the table is blocked:
|
||||
|
||||
- Title, description, search filters and action buttons stay visible.
|
||||
- Filters and any control that drives the table are non-interactive.
|
||||
- The create button stays enabled if the user holds `create`.
|
||||
|
||||
### Edit page
|
||||
|
||||

|
||||
|
||||
Without `read`:
|
||||
|
||||
- Block the content with `withAuthZContent`, not the whole route.
|
||||
- Keep delete visible (rule 3).
|
||||
- Keep update blocked (rule 2).
|
||||
|
||||
### Drawer
|
||||
|
||||

|
||||
|
||||
Without `read`:
|
||||
|
||||
- Block the drawer body, not the drawer itself.
|
||||
- Prefer routing that carries the resource ID in the URL, so delete stays reachable without `read`.
|
||||
- Keep update blocked (rule 2).
|
||||
- Watch for sub-resources the user may still be allowed to act on (rule 6).
|
||||
|
||||
### Create
|
||||
|
||||
Without `create`:
|
||||
|
||||
| Entry point | Gate with |
|
||||
| --------------------- | ------------------------------------------------------------- |
|
||||
| Button | `AuthZButton` |
|
||||
| Dedicated create page | `withAuthZPage` |
|
||||
| Create drawer opened directly (deep link) | `withAuthZContent` on the body + `AuthZButton` on footer actions |
|
||||
|
||||
### Delete
|
||||
|
||||
Gate the control with `AuthZButton`, or `AuthZTooltip` for a non-button trigger such as an icon or menu item.
|
||||
|
||||
### Quick filters
|
||||
|
||||

|
||||
|
||||
## What "blocked" means
|
||||
|
||||
Blocking is always a visible denial, never a silent removal. Use the components in
|
||||
[`lib/authz/components`](../src/lib/authz/components/README.md) rather than hand-rolling a check, they carry the
|
||||
denial message and the loading state.
|
||||
|
||||
| Scope | Component | Denied state |
|
||||
| --------------- | ----------------------------------------- | ------------------------------------------- |
|
||||
| Button | `AuthZButton` | Disabled + tooltip |
|
||||
| Any element | `AuthZTooltip` | Disabled child + tooltip |
|
||||
| Section | `withAuthZContent` / `AuthZGuardContent` | Inline `PermissionDeniedCallout` |
|
||||
| Page / route | `withAuthZPage` / `AuthZGuardPage` | `PermissionDeniedFullPage` |
|
||||
| Custom fallback | `withAuthZ` / `AuthZGuard` | Whatever you pass as `fallback` |
|
||||
|
||||
Prefer the HOC (`withAuthZ*`); reach for the JSX guard (`AuthZGuard*`) when the gate depends on conditional rendering
|
||||
and an HOC cannot express it. The components README has the full decision tree and how to build the `checks` array.
|
||||
|
||||
## Migration checklist
|
||||
|
||||
Use the existing components. Create a new one only if none fit.
|
||||
|
||||
- [ ] Can I apply a `withAuthZ*` variant directly, or must I extract the content into a component first?
|
||||
- If extraction is needed, declare the new content component in the same file to keep the diff small, then move it
|
||||
to its own file in a follow-up commit or PR.
|
||||
- [ ] Is the layout structured to respect the [core rules](#core-rules)?
|
||||
- If not, raise it in the frontend Slack channel before reshaping the page.
|
||||
- [ ] Am I gating a shared component rather than a page or section?
|
||||
- If so, it must stay functional with no permission. Example: the Query Builder works without field suggestions when
|
||||
the user cannot read them.
|
||||
|
||||
## Testing
|
||||
|
||||
### Devtool
|
||||
|
||||
Press `Cmd + K` (`Ctrl + K` on Windows/Linux) to open the shortcuts, search for `AuthZ`, and pick the first
|
||||
result. The devtool simulates granted and denied permissions across the UI.
|
||||
|
||||
Available only in local development, it is stripped from production builds.
|
||||
|
||||
### Unit tests
|
||||
|
||||
[`lib/authz/utils/README.md`](../src/lib/authz/utils/README.md) covers the `*.authz.test.tsx` naming convention, the
|
||||
MSW handlers (`setupAuthzAdmin`, `setupAuthzDenyAll`, `setupAuthzDeny`, `setupAuthzAllow`,
|
||||
`setupAuthzGrantByPrefix`), and how to test the loading state.
|
||||
@@ -14,7 +14,10 @@ import { useAppContext } from 'providers/App/App';
|
||||
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
|
||||
import { OrgPreference } from 'types/api/preferences/preference';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { routePermission } from 'utils/permission';
|
||||
import {
|
||||
routePermission,
|
||||
routeWithInitialAuthZSupport,
|
||||
} from 'utils/permission';
|
||||
|
||||
import routes, {
|
||||
LIST_LICENSES,
|
||||
@@ -42,7 +45,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
|
||||
|
||||
const mapRoutes = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
@@ -226,7 +228,16 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
if (isPrivate) {
|
||||
if (isLoggedInState) {
|
||||
const route = routePermission[key];
|
||||
if (route && route.find((e) => e === user.role) === undefined) {
|
||||
const hasInitialAuthZSupport = Object.hasOwn(
|
||||
routeWithInitialAuthZSupport,
|
||||
key,
|
||||
);
|
||||
|
||||
if (
|
||||
route &&
|
||||
route.find((e) => e === user.role) === undefined &&
|
||||
hasInitialAuthZSupport === false
|
||||
) {
|
||||
return <Redirect to={ROUTES.UN_AUTHORIZED} />;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import { OrgPreference } from 'types/api/preferences/preference';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
import { routeWithInitialAuthZSupport } from 'utils/permission';
|
||||
|
||||
import PrivateRoute from '../Private';
|
||||
|
||||
@@ -178,6 +179,7 @@ function createMockAppContext(
|
||||
isFetchingHosts: false,
|
||||
isFetchingFeatureFlags: false,
|
||||
isFetchingOrgPreferences: false,
|
||||
isFetchingUserPreferences: false,
|
||||
userFetchError: null,
|
||||
activeLicenseFetchError: null,
|
||||
hostsFetchError: null,
|
||||
@@ -198,24 +200,39 @@ function createMockAppContext(
|
||||
};
|
||||
}
|
||||
|
||||
// Roles that no authz-aware route grants through the legacy routePermission table
|
||||
const DENIED_ROLES: ROLES[] = [
|
||||
USER_ROLES.ANONYMOUS as ROLES,
|
||||
USER_ROLES.AUTHOR as ROLES,
|
||||
];
|
||||
|
||||
const PERMITTED_ROLES: ROLES[] = [
|
||||
USER_ROLES.ADMIN as ROLES,
|
||||
USER_ROLES.EDITOR as ROLES,
|
||||
USER_ROLES.VIEWER as ROLES,
|
||||
];
|
||||
|
||||
interface AuthzRouteCase {
|
||||
path: string;
|
||||
deniedRoles: ROLES[];
|
||||
}
|
||||
|
||||
interface RenderPrivateRouteOptions {
|
||||
initialRoute?: string;
|
||||
appContext?: Partial<IAppContext>;
|
||||
isCloudUser?: boolean;
|
||||
}
|
||||
|
||||
function renderPrivateRoute(options: RenderPrivateRouteOptions = {}): void {
|
||||
const {
|
||||
initialRoute = ROUTES.HOME,
|
||||
appContext = {},
|
||||
isCloudUser = true,
|
||||
} = options;
|
||||
function buildPrivateRouteTree(
|
||||
options: RenderPrivateRouteOptions,
|
||||
): ReactElement {
|
||||
const { initialRoute = ROUTES.HOME, appContext = {} } = options;
|
||||
|
||||
mockIsCloudUser = isCloudUser;
|
||||
const contextValue = createMockAppContext({
|
||||
...appContext,
|
||||
});
|
||||
|
||||
const contextValue = createMockAppContext(appContext);
|
||||
|
||||
render(
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[initialRoute]}>
|
||||
<AppContext.Provider value={contextValue}>
|
||||
@@ -229,10 +246,15 @@ function renderPrivateRoute(options: RenderPrivateRouteOptions = {}): void {
|
||||
</PrivateRoute>
|
||||
</AppContext.Provider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderPrivateRoute(options: RenderPrivateRouteOptions = {}): void {
|
||||
mockIsCloudUser = options.isCloudUser ?? true;
|
||||
render(buildPrivateRouteTree(options));
|
||||
}
|
||||
|
||||
// Generic assertion helpers for navigation behavior
|
||||
// Using location-based assertions since Private.tsx now uses Redirect component
|
||||
|
||||
@@ -1432,6 +1454,25 @@ describe('PrivateRoute', () => {
|
||||
|
||||
assertStaysOnRoute(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
});
|
||||
|
||||
it.each([...PERMITTED_ROLES, ...DENIED_ROLES])(
|
||||
'should render the unauthorized page for a %s instead of bouncing off it',
|
||||
(role) => {
|
||||
// routePermission.UN_AUTHORIZED must grant every role: a role denied here
|
||||
// is redirected to /un-authorized and then redirected away from it again,
|
||||
// which loops until React bails out with a blank screen.
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.UN_AUTHORIZED,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role }),
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.UN_AUTHORIZED);
|
||||
assertRendersChildren();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
@@ -1477,6 +1518,174 @@ describe('PrivateRoute', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthZ Support (routeWithInitialAuthZSupport)', () => {
|
||||
// Routes in routeWithInitialAuthZSupport always bypass legacy role check
|
||||
const AUTHZ_ROUTE_CASES: Record<
|
||||
keyof typeof routeWithInitialAuthZSupport,
|
||||
AuthzRouteCase
|
||||
> = {
|
||||
// Everything under /settings resolves to the non-exact SETTINGS route
|
||||
SETTINGS: { path: ROUTES.SETTINGS, deniedRoles: DENIED_ROLES },
|
||||
MY_SETTINGS: { path: ROUTES.MY_SETTINGS, deniedRoles: DENIED_ROLES },
|
||||
ROLES_SETTINGS: { path: ROUTES.ROLES_SETTINGS, deniedRoles: DENIED_ROLES },
|
||||
ROLE_CREATE: { path: ROUTES.ROLE_CREATE, deniedRoles: DENIED_ROLES },
|
||||
ROLE_DETAILS: {
|
||||
path: ROUTES.ROLE_DETAILS.replace(':roleId', 'role-id-1'),
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
ROLE_EDIT: {
|
||||
path: ROUTES.ROLE_EDIT.replace(':roleId', 'role-id-1'),
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
SERVICE_ACCOUNTS_SETTINGS: {
|
||||
path: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
TRACES_EXPLORER: { path: ROUTES.TRACES_EXPLORER, deniedRoles: DENIED_ROLES },
|
||||
TRACE: { path: ROUTES.TRACE, deniedRoles: DENIED_ROLES },
|
||||
TRACE_DETAIL: {
|
||||
path: ROUTES.TRACE_DETAIL.replace(':id', 'trace-id-1'),
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
TRACE_DETAIL_OLD: {
|
||||
path: ROUTES.TRACE_DETAIL_OLD.replace(':id', 'trace-id-1'),
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
// LOGS and LOGS_EXPLORER share a path - matchPath resolves it to whichever
|
||||
// route definition comes last, and both keys are authz-aware either way.
|
||||
LOGS: { path: ROUTES.LOGS, deniedRoles: DENIED_ROLES },
|
||||
LOGS_EXPLORER: { path: ROUTES.LOGS_EXPLORER, deniedRoles: DENIED_ROLES },
|
||||
LIVE_LOGS: { path: ROUTES.LIVE_LOGS, deniedRoles: DENIED_ROLES },
|
||||
OLD_LOGS_EXPLORER: {
|
||||
path: ROUTES.OLD_LOGS_EXPLORER,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
METRICS_EXPLORER: {
|
||||
path: ROUTES.METRICS_EXPLORER,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
METRICS_EXPLORER_EXPLORER: {
|
||||
path: ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
METRICS_EXPLORER_VOLUME_CONTROL: {
|
||||
path: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
METER: { path: ROUTES.METER, deniedRoles: DENIED_ROLES },
|
||||
METER_EXPLORER: { path: ROUTES.METER_EXPLORER, deniedRoles: DENIED_ROLES },
|
||||
// SUPPORT already grants ANONYMOUS in routePermission, so only AUTHOR
|
||||
// exercises the redirect branch here.
|
||||
SUPPORT: {
|
||||
path: ROUTES.SUPPORT,
|
||||
deniedRoles: [USER_ROLES.AUTHOR as ROLES],
|
||||
},
|
||||
};
|
||||
|
||||
const authzRouteRolePairs: [string, string, ROLES][] = Object.entries(
|
||||
AUTHZ_ROUTE_CASES,
|
||||
).flatMap(([name, { path, deniedRoles }]) =>
|
||||
deniedRoles.map((role): [string, string, ROLES] => [name, path, role]),
|
||||
);
|
||||
|
||||
// Routes with no authz check - legacy role check still applies
|
||||
const NON_AUTHZ_ROUTE_CASES: [string, string, ROLES][] = [
|
||||
['ALERTS_NEW', ROUTES.ALERTS_NEW, USER_ROLES.VIEWER as ROLES],
|
||||
['LIST_LICENSES', ROUTES.LIST_LICENSES, USER_ROLES.EDITOR as ROLES],
|
||||
['ONBOARDING', ROUTES.ONBOARDING, USER_ROLES.VIEWER as ROLES],
|
||||
['APPLICATION', ROUTES.APPLICATION, USER_ROLES.ANONYMOUS as ROLES],
|
||||
[
|
||||
'METRICS_EXPLORER_VIEWS',
|
||||
ROUTES.METRICS_EXPLORER_VIEWS,
|
||||
USER_ROLES.ANONYMOUS as ROLES,
|
||||
],
|
||||
];
|
||||
|
||||
it.each(authzRouteRolePairs)(
|
||||
'should not redirect %s (%s) for role %s - authz routes bypass legacy role check',
|
||||
(_name, path, role) => {
|
||||
// Routes in routeWithInitialAuthZSupport always bypass the legacy role check.
|
||||
// Authorization is handled by downstream components via fine-grained authz.
|
||||
renderPrivateRoute({
|
||||
initialRoute: path,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role }),
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(path);
|
||||
assertRendersChildren();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([...PERMITTED_ROLES, ...DENIED_ROLES])(
|
||||
'should not redirect a %s from an authz-aware route',
|
||||
(role) => {
|
||||
// All roles pass through - authorization handled downstream
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.LOGS_EXPLORER,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role }),
|
||||
},
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.LOGS_EXPLORER);
|
||||
assertRendersChildren();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(NON_AUTHZ_ROUTE_CASES)(
|
||||
'should redirect %s (%s) for role %s - non-authz routes use legacy role check',
|
||||
async (_name, path, role) => {
|
||||
// Routes NOT in routeWithInitialAuthZSupport still use legacy role check
|
||||
renderPrivateRoute({
|
||||
initialRoute: path,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
user: createMockUser({ role }),
|
||||
},
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(authzRouteRolePairs)(
|
||||
'should still redirect unauthenticated users away from %s (%s) with role %s',
|
||||
async (_name, path, role) => {
|
||||
// The authz bypass only relaxes the role check, never the login check.
|
||||
renderPrivateRoute({
|
||||
initialRoute: path,
|
||||
appContext: {
|
||||
isLoggedIn: false,
|
||||
user: createMockUser({ role }),
|
||||
},
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.LOGIN);
|
||||
},
|
||||
);
|
||||
|
||||
it('should still redirect to workspace locked from an authz-aware route', async () => {
|
||||
// Workspace guards run before the role check and must not be bypassed.
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.LOGS_EXPLORER,
|
||||
appContext: {
|
||||
isLoggedIn: true,
|
||||
isFetchingActiveLicense: false,
|
||||
activeLicense: createMockLicense({ platform: LicensePlatform.CLOUD }),
|
||||
trialInfo: createMockTrialInfo({ workSpaceBlock: true }),
|
||||
user: createMockUser({ role: USER_ROLES.VIEWER as ROLES }),
|
||||
},
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Old channel route redirects', () => {
|
||||
it.each([
|
||||
['/settings/channels', '/alerts', 'tab=Channels'],
|
||||
|
||||
@@ -2815,6 +2815,7 @@ export enum CloudintegrationtypesServiceIDDTO {
|
||||
redis = 'redis',
|
||||
cloudsql_postgres = 'cloudsql_postgres',
|
||||
memorystore_redis = 'memorystore_redis',
|
||||
computeengine = 'computeengine',
|
||||
}
|
||||
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
/**
|
||||
@@ -4298,6 +4299,18 @@ export interface Querybuildertypesv5QueryEnvelopeBuilderDTO {
|
||||
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5QueryEnvelopeBuilderAIDTOType {
|
||||
builder_ai_query = 'builder_ai_query',
|
||||
}
|
||||
export interface Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
|
||||
spec?: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @enum builder_ai_query
|
||||
*/
|
||||
type: Querybuildertypesv5QueryEnvelopeBuilderAIDTOType;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -4481,6 +4494,7 @@ export interface Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO {
|
||||
|
||||
export type Querybuildertypesv5QueryEnvelopeDTO =
|
||||
| Querybuildertypesv5QueryEnvelopeBuilderDTO
|
||||
| Querybuildertypesv5QueryEnvelopeBuilderAIDTO
|
||||
| Querybuildertypesv5QueryEnvelopeFormulaDTO
|
||||
| Querybuildertypesv5QueryEnvelopeTraceOperatorDTO
|
||||
| Querybuildertypesv5QueryEnvelopePromQLDTO
|
||||
@@ -8284,6 +8298,7 @@ export interface Querybuildertypesv5QueryRangeResponseDTO {
|
||||
|
||||
export enum Querybuildertypesv5QueryTypeDTO {
|
||||
builder_query = 'builder_query',
|
||||
builder_ai_query = 'builder_ai_query',
|
||||
builder_formula = 'builder_formula',
|
||||
builder_trace_operator = 'builder_trace_operator',
|
||||
clickhouse_sql = 'clickhouse_sql',
|
||||
|
||||
@@ -438,7 +438,11 @@ function LogDetailInner({
|
||||
destroyOnClose
|
||||
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
|
||||
>
|
||||
<div className="log-detail-drawer__content" data-log-detail-ignore="true">
|
||||
<div
|
||||
className="log-detail-drawer__content"
|
||||
data-log-detail-ignore="true"
|
||||
data-testid="log-detail-drawer"
|
||||
>
|
||||
<div className="log-detail-drawer__log">
|
||||
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
|
||||
<Tooltip
|
||||
|
||||
@@ -49,7 +49,11 @@ import { unquote } from 'utils/stringUtils';
|
||||
import { getRecentQueries } from 'lib/recentQueries/getRecentQueries';
|
||||
import type { SignalType } from 'types/api/v5/queryRange';
|
||||
|
||||
import { queryExamples, SUGGESTIONS_SECTION } from './constants';
|
||||
import {
|
||||
queryExamples,
|
||||
SUGGESTION_FETCH_DEBOUNCE_MS,
|
||||
SUGGESTIONS_SECTION,
|
||||
} from './constants';
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
dedupeOptionsByLabel,
|
||||
@@ -353,7 +357,7 @@ function QuerySearch({
|
||||
);
|
||||
|
||||
const debouncedFetchKeySuggestions = useMemo(
|
||||
() => debounce(fetchKeySuggestions, 300),
|
||||
() => debounce(fetchKeySuggestions, SUGGESTION_FETCH_DEBOUNCE_MS),
|
||||
[fetchKeySuggestions],
|
||||
);
|
||||
|
||||
@@ -584,7 +588,7 @@ function QuerySearch({
|
||||
);
|
||||
|
||||
const debouncedFetchValueSuggestions = useMemo(
|
||||
() => debounce(fetchValueSuggestions, 300),
|
||||
() => debounce(fetchValueSuggestions, SUGGESTION_FETCH_DEBOUNCE_MS),
|
||||
[fetchValueSuggestions],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export const RECENTS_SECTION = { name: 'Recent searches', rank: 1 } as const;
|
||||
export const SUGGESTIONS_SECTION = { name: 'Suggestions', rank: 2 } as const;
|
||||
|
||||
export const SUGGESTION_FETCH_DEBOUNCE_MS = 300;
|
||||
|
||||
// TODO: move to using TelemetrytypesFieldContextDTO when we migrate getKeySuggestions API (Part of https://github.com/SigNoz/engineering-pod/issues/5289)
|
||||
export const FIELD_CONTEXTS = [
|
||||
'attribute',
|
||||
|
||||
@@ -23,6 +23,13 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Shrink the suggestion-fetch debounce (300ms in prod) so these integration
|
||||
// tests aren't paced by it; coalescing semantics stay intact.
|
||||
jest.mock('../QuerySearch/constants', () => ({
|
||||
...jest.requireActual('../QuerySearch/constants'),
|
||||
SUGGESTION_FETCH_DEBOUNCE_MS: 30,
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => {
|
||||
const handleRunQuery = jest.fn();
|
||||
return {
|
||||
|
||||
@@ -88,6 +88,7 @@ function TanStackCustomTableRow<TData, TItemKey = string>({
|
||||
{...props}
|
||||
className={rowClassName}
|
||||
style={rowStyle}
|
||||
data-testid={context?.getRowTestId?.(rowData)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={clearHovered}
|
||||
>
|
||||
@@ -154,6 +155,12 @@ function areTableRowPropsEqual<TData, TItemKey = string>(
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevTestId = prev.context?.getRowTestId?.(prevData) ?? '';
|
||||
const nextTestId = next.context?.getRowTestId?.(nextData) ?? '';
|
||||
if (prevTestId !== nextTestId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevStyle = prev.context?.getRowStyle?.(prevData);
|
||||
const nextStyle = next.context?.getRowStyle?.(nextData);
|
||||
if (prevStyle !== nextStyle) {
|
||||
|
||||
@@ -33,7 +33,6 @@ function TanStackRowCellsInner<TData, TItemKey = string>({
|
||||
// Stable references via destructuring, keep them as is
|
||||
const onRowClick = context?.onRowClick;
|
||||
const onRowClickNewTab = context?.onRowClickNewTab;
|
||||
const onRowDeactivate = context?.onRowDeactivate;
|
||||
const isRowActive = context?.isRowActive;
|
||||
const getRowKeyData = context?.getRowKeyData;
|
||||
const rowIndex = row.index;
|
||||
@@ -52,21 +51,9 @@ function TanStackRowCellsInner<TData, TItemKey = string>({
|
||||
}
|
||||
|
||||
const isActive = isRowActive?.(rowData) ?? false;
|
||||
if (isActive && onRowDeactivate) {
|
||||
onRowDeactivate();
|
||||
} else {
|
||||
onRowClick?.(rowData, itemKey);
|
||||
}
|
||||
onRowClick?.(rowData, itemKey, { isActive });
|
||||
},
|
||||
[
|
||||
isRowActive,
|
||||
onRowDeactivate,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
rowData,
|
||||
getRowKeyData,
|
||||
rowIndex,
|
||||
],
|
||||
[isRowActive, onRowClick, onRowClickNewTab, rowData, getRowKeyData, rowIndex],
|
||||
);
|
||||
|
||||
if (itemKind === 'expansion') {
|
||||
@@ -120,7 +107,6 @@ function areRowCellsPropsEqual<TData>(
|
||||
prev.columnVisibilityKey === next.columnVisibilityKey &&
|
||||
prev.context?.onRowClick === next.context?.onRowClick &&
|
||||
prev.context?.onRowClickNewTab === next.context?.onRowClickNewTab &&
|
||||
prev.context?.onRowDeactivate === next.context?.onRowDeactivate &&
|
||||
prev.context?.isRowActive === next.context?.isRowActive &&
|
||||
prev.context?.getRowKeyData === next.context?.getRowKeyData &&
|
||||
prev.context?.renderRowActions === next.context?.renderRowActions &&
|
||||
|
||||
@@ -92,11 +92,11 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
getGroupKey,
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
getRowTestId,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
onSort,
|
||||
activeRowIndex,
|
||||
renderExpandedRow,
|
||||
@@ -378,11 +378,11 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
() => ({
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
getRowTestId,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
renderExpandedRow,
|
||||
getRowKeyData,
|
||||
colCount: visibleColumnsCount,
|
||||
@@ -396,11 +396,11 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
[
|
||||
getRowStyle,
|
||||
getRowClassName,
|
||||
getRowTestId,
|
||||
isRowActive,
|
||||
renderRowActions,
|
||||
onRowClick,
|
||||
onRowClickNewTab,
|
||||
onRowDeactivate,
|
||||
renderExpandedRow,
|
||||
getRowKeyData,
|
||||
visibleColumnsCount,
|
||||
|
||||
@@ -85,7 +85,9 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
|
||||
isActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('fires onRowClick with empty itemKey when getRowKeyData is not provided', async () => {
|
||||
@@ -117,17 +119,19 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '');
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '', {
|
||||
isActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onRowDeactivate instead of onRowClick when row is active', async () => {
|
||||
it('calls onRowClick with isActive: true when the row is active', async () => {
|
||||
// The table no longer owns open/close — it reports the active state and the
|
||||
// consumer routes the click. An active row must still fire onRowClick.
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const onRowDeactivate = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
onRowDeactivate,
|
||||
isRowActive: () => true,
|
||||
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
|
||||
hasSingleColumn: false,
|
||||
@@ -152,8 +156,44 @@ describe('TanStackRowCells', () => {
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowDeactivate).toHaveBeenCalled();
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
|
||||
isActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onRowClick with isActive: false when the row is not active', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const ctx: TableRowContext<Row> = {
|
||||
colCount: 1,
|
||||
onRowClick,
|
||||
isRowActive: () => false,
|
||||
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
|
||||
hasSingleColumn: false,
|
||||
columnOrderKey: '',
|
||||
columnVisibilityKey: '',
|
||||
};
|
||||
const row = buildMockRow([{ id: 'body' }]);
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<TanStackRowCells<Row>
|
||||
row={row as never}
|
||||
context={ctx}
|
||||
itemKind="row"
|
||||
hasSingleColumn={false}
|
||||
columnOrderKey=""
|
||||
columnVisibilityKey=""
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
|
||||
isActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render renderRowActions before hover', () => {
|
||||
|
||||
@@ -611,6 +611,7 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1', name: 'Item 1' }),
|
||||
'1',
|
||||
{ isActive: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -639,6 +640,7 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1', name: 'Item 1' }),
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ isActive: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -659,15 +661,15 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(row).toHaveClass('tableRowActive');
|
||||
});
|
||||
|
||||
it('calls onRowDeactivate when clicking active row', async () => {
|
||||
it('calls onRowClick with isActive: true when clicking the active row', async () => {
|
||||
// The consumer owns open/close routing — the table just reports the
|
||||
// active state via the click context.
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
const onRowDeactivate = jest.fn();
|
||||
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
onRowClick,
|
||||
onRowDeactivate,
|
||||
isRowActive: (row) => row.id === '1',
|
||||
},
|
||||
});
|
||||
@@ -678,8 +680,11 @@ describe('TanStackTableView Integration', () => {
|
||||
|
||||
await user.click(screen.getByText('Item 1'));
|
||||
|
||||
expect(onRowDeactivate).toHaveBeenCalled();
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1' }),
|
||||
expect.anything(),
|
||||
{ isActive: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('opens in new tab on ctrl+click', async () => {
|
||||
|
||||
@@ -116,9 +116,11 @@ export * from './useTableParams';
|
||||
* getItemKey={(row) => row.id}
|
||||
* isRowActive={(row) => row.id === selectedId}
|
||||
* activeRowIndex={selectedIndex}
|
||||
* onRowClick={(row, itemKey) => setSelectedId(itemKey)}
|
||||
* // The table reports the click + the row's active state; the consumer owns open/close.
|
||||
* onRowClick={(row, itemKey, { isActive }) =>
|
||||
* setSelectedId(isActive ? undefined : itemKey)
|
||||
* }
|
||||
* onRowClickNewTab={(row, itemKey) => openInNewTab(itemKey)}
|
||||
* onRowDeactivate={() => setSelectedId(undefined)}
|
||||
* getRowClassName={(row) => (row.severity === 'error' ? 'row-error' : '')}
|
||||
* getRowStyle={(row) => (row.dimmed ? { opacity: 0.5 } : {})}
|
||||
* renderRowActions={(row) => <Button size="small">Open</Button>}
|
||||
|
||||
@@ -81,15 +81,19 @@ export type FlatItem<TData> =
|
||||
| { kind: 'row'; row: TanStackRowType<TData> }
|
||||
| { kind: 'expansion'; row: TanStackRowType<TData> };
|
||||
|
||||
export type RowClickContext = {
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type TableRowContext<TData, TItemKey = string> = {
|
||||
getRowStyle?: (row: TData) => CSSProperties;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
getRowTestId?: (row: TData) => string;
|
||||
isRowActive?: (row: TData) => boolean;
|
||||
renderRowActions?: (row: TData) => ReactNode;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
renderExpandedRow?: (
|
||||
row: TData,
|
||||
rowKey: string,
|
||||
@@ -178,12 +182,12 @@ export type TanStackTableProps<TData, TItemKey = string> = {
|
||||
getGroupKey?: (row: TData) => Record<string, string>;
|
||||
getRowStyle?: (row: TData) => CSSProperties;
|
||||
getRowClassName?: (row: TData) => string;
|
||||
getRowTestId?: (row: TData) => string;
|
||||
isRowActive?: (row: TData) => boolean;
|
||||
renderRowActions?: (row: TData) => ReactNode;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
|
||||
/** Called when ctrl+click or cmd+click on a row */
|
||||
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
|
||||
onRowDeactivate?: () => void;
|
||||
activeRowIndex?: number;
|
||||
renderExpandedRow?: (
|
||||
row: TData,
|
||||
|
||||
@@ -104,6 +104,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
isFetchingFeatureFlags,
|
||||
featureFlagsFetchError,
|
||||
userPreferences,
|
||||
isFetchingUserPreferences,
|
||||
updateChangelog,
|
||||
toggleChangelogModal,
|
||||
showChangelogModal,
|
||||
@@ -724,12 +725,12 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Set sidebar as loaded after user preferences are fetched
|
||||
// Set sidebar as loaded after user preferences fetch completes (success or error)
|
||||
useEffect(() => {
|
||||
if (userPreferences !== null) {
|
||||
if (!isFetchingUserPreferences) {
|
||||
setIsSidebarLoaded(true);
|
||||
}
|
||||
}, [userPreferences]);
|
||||
}, [isFetchingUserPreferences]);
|
||||
|
||||
// Use localStorage value as fallback until preferences are loaded
|
||||
const isSideNavPinned = isSidebarLoaded
|
||||
|
||||
@@ -60,3 +60,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-linked-row td {
|
||||
background-color: var(--row-active-bg) !important;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,9 @@ function LiveLogsList({
|
||||
data={formattedLogs}
|
||||
isLoading={false}
|
||||
isRowActive={(log): boolean => log.id === activeLog?.id}
|
||||
getRowClassName={(log): string =>
|
||||
log.id === activeLogId ? 'logs-linked-row' : ''
|
||||
}
|
||||
getRowStyle={(log): CSSProperties =>
|
||||
({
|
||||
'--row-active-bg': getRowBackgroundColor(
|
||||
@@ -216,10 +219,13 @@ function LiveLogsList({
|
||||
),
|
||||
}) as CSSProperties
|
||||
}
|
||||
onRowClick={(log): void => {
|
||||
handleSetActiveLog(log);
|
||||
onRowClick={(log, _itemKey, { isActive }): void => {
|
||||
if (isActive) {
|
||||
handleCloseLogDetail();
|
||||
} else {
|
||||
handleSetActiveLog(log);
|
||||
}
|
||||
}}
|
||||
onRowDeactivate={handleCloseLogDetail}
|
||||
activeRowIndex={activeLogIndex}
|
||||
renderRowActions={(log): ReactNode => (
|
||||
<LogLinesActionButtons
|
||||
|
||||
@@ -323,3 +323,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-linked-row td {
|
||||
background-color: var(--row-active-bg) !important;
|
||||
}
|
||||
|
||||
@@ -211,9 +211,11 @@ function LogsExplorerList({
|
||||
data={logs}
|
||||
isLoading={isLoading || isFetching}
|
||||
onEndReached={onEndReached}
|
||||
isRowActive={(log): boolean =>
|
||||
log.id === activeLog?.id || log.id === activeLogId
|
||||
isRowActive={(log): boolean => log.id === activeLog?.id}
|
||||
getRowClassName={(log): string =>
|
||||
log.id === activeLogId ? 'logs-linked-row' : ''
|
||||
}
|
||||
getRowTestId={(log): string => `logs-table-row-${log.id}`}
|
||||
getRowStyle={(log): CSSProperties =>
|
||||
({
|
||||
'--row-active-bg': getRowBackgroundColor(
|
||||
@@ -226,10 +228,13 @@ function LogsExplorerList({
|
||||
),
|
||||
}) as CSSProperties
|
||||
}
|
||||
onRowClick={(log): void => {
|
||||
handleSetActiveLog(log);
|
||||
onRowClick={(log, _itemKey, { isActive }): void => {
|
||||
if (isActive) {
|
||||
handleCloseLogDetail();
|
||||
} else {
|
||||
handleSetActiveLog(log);
|
||||
}
|
||||
}}
|
||||
onRowDeactivate={handleCloseLogDetail}
|
||||
activeRowIndex={activeLogIndex}
|
||||
renderRowActions={(log): ReactNode => (
|
||||
<LogLinesActionButtons
|
||||
@@ -282,6 +287,7 @@ function LogsExplorerList({
|
||||
options.maxLines,
|
||||
options.fontSize,
|
||||
activeLogIndex,
|
||||
activeLogId,
|
||||
logs,
|
||||
onEndReached,
|
||||
getItemContent,
|
||||
|
||||
@@ -151,7 +151,9 @@ describe('JsonEditor', () => {
|
||||
);
|
||||
await user.click(await within(readToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'test-key-123{enter}');
|
||||
|
||||
await switchToJsonMode();
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
|
||||
async function expandAllCards(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const expandButton = await screen.findByTestId('expand-all-button');
|
||||
await user.click(expandButton);
|
||||
}
|
||||
import { expandAllCards, renderCreateRolePage } from './testUtils';
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(setupAuthzAdmin());
|
||||
@@ -21,35 +12,16 @@ afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
async function renderPage(): Promise<ReturnType<typeof render>> {
|
||||
const result = render(
|
||||
<TooltipProvider>
|
||||
<Switch>
|
||||
<Route path={ROUTES.ROLES_SETTINGS} exact>
|
||||
<div data-testid="roles-list-redirect" />
|
||||
</Route>
|
||||
<Route path={ROUTES.ROLE_CREATE}>
|
||||
<CreateEditRolePage />
|
||||
</Route>
|
||||
</Switch>
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles/new' },
|
||||
);
|
||||
await screen.findByTestId('permission-editor');
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('PermissionEditor', () => {
|
||||
describe('mode toggle', () => {
|
||||
it('renders permission editor with testId', async () => {
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
expect(screen.getByTestId('permission-editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults to interactive mode', async () => {
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const interactiveRadio = screen.getByTestId(
|
||||
'permission-editor-mode-interactive',
|
||||
@@ -59,7 +31,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('switches to JSON mode when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
|
||||
await user.click(jsonRadio);
|
||||
@@ -70,7 +42,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('switches back to interactive mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
|
||||
await user.click(jsonRadio);
|
||||
@@ -87,7 +59,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
describe('resource cards', () => {
|
||||
it('renders all resource cards', async () => {
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
expect(
|
||||
screen.getByTestId('resource-card-factor-api-key'),
|
||||
@@ -99,7 +71,7 @@ describe('PermissionEditor', () => {
|
||||
});
|
||||
|
||||
it('resource cards are collapsed by default', async () => {
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
@@ -111,7 +83,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('expands resource card when header clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
@@ -125,7 +97,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('collapses expanded resource card when header clicked again', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const header = within(apiKeyCard).getByTestId(
|
||||
@@ -139,7 +111,7 @@ describe('PermissionEditor', () => {
|
||||
});
|
||||
|
||||
it('shows granted count in resource card header', async () => {
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
await expect(
|
||||
@@ -150,7 +122,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
describe('action toggles', () => {
|
||||
it('renders action toggles for each available action', async () => {
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -169,7 +141,7 @@ describe('PermissionEditor', () => {
|
||||
});
|
||||
|
||||
it('defaults all actions to None scope', async () => {
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -187,7 +159,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('changes scope to All when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -208,7 +180,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('updates granted count when scope changed', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -227,7 +199,7 @@ describe('PermissionEditor', () => {
|
||||
describe('Only Selected scope', () => {
|
||||
it('shows item input selector when Only Selected is chosen', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -239,12 +211,14 @@ describe('PermissionEditor', () => {
|
||||
await within(createToggle).findByText('Only selected');
|
||||
await user.click(onlySelectedBtn);
|
||||
|
||||
expect(screen.getByTestId('item-input-selector')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('item-input-selector-factor-api-key-read'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds item when typed and Enter pressed', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -254,7 +228,9 @@ describe('PermissionEditor', () => {
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'api-key-001{enter}');
|
||||
|
||||
await expect(screen.findByText('api-key-001')).resolves.toBeInTheDocument();
|
||||
@@ -262,7 +238,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('adds item when Add button clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -272,10 +248,14 @@ describe('PermissionEditor', () => {
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'api-key-002');
|
||||
|
||||
const addBtn = screen.getByTestId('item-input-selector-add-btn');
|
||||
const addBtn = screen.getByTestId(
|
||||
'item-input-selector-add-btn-factor-api-key-read',
|
||||
);
|
||||
await user.click(addBtn);
|
||||
|
||||
await expect(screen.findByText('api-key-002')).resolves.toBeInTheDocument();
|
||||
@@ -283,7 +263,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('adds multiple items separated by comma', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -293,7 +273,9 @@ describe('PermissionEditor', () => {
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'key-a, key-b, key-c{enter}');
|
||||
|
||||
await expect(screen.findByText('key-a')).resolves.toBeInTheDocument();
|
||||
@@ -303,7 +285,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('adds multiple items separated by space', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -313,7 +295,9 @@ describe('PermissionEditor', () => {
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'key-x key-y key-z{enter}');
|
||||
|
||||
await expect(screen.findByText('key-x')).resolves.toBeInTheDocument();
|
||||
@@ -323,7 +307,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('does not add duplicate items', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -333,7 +317,9 @@ describe('PermissionEditor', () => {
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'same-key{enter}');
|
||||
await user.type(input, 'same-key{enter}');
|
||||
|
||||
@@ -343,7 +329,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('removes item when X clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -353,20 +339,138 @@ describe('PermissionEditor', () => {
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'removable-key{enter}');
|
||||
|
||||
const removeBtn = screen.getByRole('button', {
|
||||
name: /remove removable-key/i,
|
||||
const badge = await screen.findByTestId('item-badge-factor-api-key-read-0');
|
||||
const removeBtn = within(badge).getByRole('button', {
|
||||
name: 'Remove removable-key',
|
||||
});
|
||||
await user.click(removeBtn);
|
||||
|
||||
expect(screen.queryByText('removable-key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names each badge close button after the item it removes', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
await user.click(await within(readToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'key-one key-two{enter}');
|
||||
|
||||
const firstBadge = await screen.findByTestId(
|
||||
'item-badge-factor-api-key-read-0',
|
||||
);
|
||||
const secondBadge = screen.getByTestId('item-badge-factor-api-key-read-1');
|
||||
|
||||
expect(
|
||||
within(firstBadge).getByRole('button', { name: 'Remove key-one' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(secondBadge).getByRole('button', { name: 'Remove key-two' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the full item value as a title so truncated badges stay readable', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
await user.click(await within(readToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'a-very-long-api-key-identifier-000001{enter}');
|
||||
|
||||
const badge = await screen.findByTestId('item-badge-factor-api-key-read-0');
|
||||
expect(
|
||||
within(badge).getByTitle('a-very-long-api-key-identifier-000001'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('moves focus to the previous badge when closed with the keyboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
await user.click(await within(readToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'key-one key-two{enter}');
|
||||
|
||||
const secondBadge = await screen.findByTestId(
|
||||
'item-badge-factor-api-key-read-1',
|
||||
);
|
||||
within(secondBadge).getByRole('button', { name: 'Remove key-two' }).focus();
|
||||
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
const firstBadge = screen.getByTestId('item-badge-factor-api-key-read-0');
|
||||
expect(
|
||||
within(firstBadge).getByRole('button', { name: 'Remove key-one' }),
|
||||
).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not steal focus when a badge is closed with the mouse', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
await user.click(await within(readToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'key-one key-two{enter}');
|
||||
|
||||
const secondBadge = await screen.findByTestId(
|
||||
'item-badge-factor-api-key-read-1',
|
||||
);
|
||||
await user.click(
|
||||
within(secondBadge).getByRole('button', { name: 'Remove key-two' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('key-two')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const firstBadge = screen.getByTestId('item-badge-factor-api-key-read-0');
|
||||
expect(
|
||||
within(firstBadge).getByRole('button', { name: 'Remove key-one' }),
|
||||
).not.toHaveFocus();
|
||||
});
|
||||
|
||||
it('shows Add button disabled when input is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -376,7 +480,9 @@ describe('PermissionEditor', () => {
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const addBtn = screen.getByTestId('item-input-selector-add-btn');
|
||||
const addBtn = screen.getByTestId(
|
||||
'item-input-selector-add-btn-factor-api-key-read',
|
||||
);
|
||||
expect(addBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -384,7 +490,7 @@ describe('PermissionEditor', () => {
|
||||
describe('scope change confirmation dialog', () => {
|
||||
it('shows confirm dialog when leaving Only Selected with items', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -394,7 +500,9 @@ describe('PermissionEditor', () => {
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'will-be-cleared{enter}');
|
||||
|
||||
await user.click(await within(createToggle).findByText('All'));
|
||||
@@ -406,7 +514,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('clears items when confirmed', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -416,7 +524,9 @@ describe('PermissionEditor', () => {
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'to-be-cleared{enter}');
|
||||
|
||||
await user.click(await within(createToggle).findByText('All'));
|
||||
@@ -433,7 +543,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('keeps items when cancelled', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -443,7 +553,9 @@ describe('PermissionEditor', () => {
|
||||
|
||||
await user.click(await within(createToggle).findByText('Only selected'));
|
||||
|
||||
const input = screen.getByTestId('item-input-selector-input');
|
||||
const input = screen.getByTestId(
|
||||
'item-input-selector-input-factor-api-key-read',
|
||||
);
|
||||
await user.type(input, 'preserved-key{enter}');
|
||||
|
||||
await user.click(await within(createToggle).findByText('None'));
|
||||
@@ -455,12 +567,14 @@ describe('PermissionEditor', () => {
|
||||
screen.findByText('preserved-key'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId('item-input-selector')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('item-input-selector-factor-api-key-read'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show dialog when leaving Only Selected with no items', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -479,7 +593,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
describe('verbs without Only Selected option', () => {
|
||||
it('does not show Only Selected for list verb', async () => {
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -501,7 +615,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
describe('collapse/expand all resources', () => {
|
||||
it('shows expand/collapse toggle group', async () => {
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
expect(screen.getByTestId('toggle-all-group')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('expand-all-button')).toBeInTheDocument();
|
||||
@@ -509,7 +623,7 @@ describe('PermissionEditor', () => {
|
||||
});
|
||||
|
||||
it('expands all cards when expand button clicked', async () => {
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
@@ -523,7 +637,7 @@ describe('PermissionEditor', () => {
|
||||
describe('resource card error states', () => {
|
||||
it('shows error border on collapsed card with validation error', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'valid-role');
|
||||
@@ -553,7 +667,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('hides error border when card is expanded', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'valid-role');
|
||||
@@ -590,7 +704,7 @@ describe('PermissionEditor', () => {
|
||||
|
||||
it('clears validation error when permission is changed', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await renderCreateRolePage();
|
||||
|
||||
const nameInput = screen.getByTestId('role-name-input');
|
||||
await user.type(nameInput, 'valid-role');
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
import { server } from 'mocks-server/server';
|
||||
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import { expandAllCards, renderCreateRolePage } from './testUtils';
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
async function selectOnlySelected(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
resource = 'logs',
|
||||
): Promise<void> {
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const card = screen.getByTestId(`resource-card-${resource}`);
|
||||
const readToggle = within(card).getByTestId(`action-toggle-${resource}-read`);
|
||||
await user.click(await within(readToggle).findByText('Only selected'));
|
||||
}
|
||||
|
||||
async function selectLogsOnlySelected(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
): Promise<void> {
|
||||
await selectOnlySelected(user, 'logs');
|
||||
}
|
||||
|
||||
async function openWizard(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
resource = 'logs',
|
||||
): Promise<void> {
|
||||
await selectOnlySelected(user, resource);
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId(`telemetry-wizard-trigger-${resource}-read`),
|
||||
);
|
||||
await screen.findByTestId(`telemetry-wizard-dialog-${resource}-read`);
|
||||
}
|
||||
|
||||
async function openLogsWizard(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
): Promise<void> {
|
||||
await openWizard(user, 'logs');
|
||||
}
|
||||
|
||||
describe('PermissionEditor - TelemetrySelectorWizard', () => {
|
||||
it('shows wizard button for telemetry resources', async () => {
|
||||
const user = userEvent.setup();
|
||||
await selectLogsOnlySelected(user);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('telemetry-wizard-trigger-logs-read'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show wizard button for non-telemetry resources', async () => {
|
||||
await renderCreateRolePage();
|
||||
await expandAllCards();
|
||||
|
||||
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
|
||||
const readToggle = within(apiKeyCard).getByTestId(
|
||||
'action-toggle-factor-api-key-read',
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(await within(readToggle).findByText('Only selected'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('telemetry-wizard-trigger-logs-read'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens wizard dialog when trigger clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await selectLogsOnlySelected(user);
|
||||
|
||||
await user.click(screen.getByTestId('telemetry-wizard-trigger-logs-read'));
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('telemetry-wizard-dialog-logs-read'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a query-type wildcard when the value is left empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('builder_query/*'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not offer builder sub query', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
|
||||
|
||||
expect(screen.queryByText('Builder Sub Query')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show PromQL for logs', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('wizard-query-type-option-promql-logs-read'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show PromQL for traces', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openWizard(user, 'traces');
|
||||
|
||||
await user.click(screen.getByTestId('wizard-query-type-select-traces-read'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('wizard-query-type-option-promql-traces-read'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows PromQL for metrics', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openWizard(user, 'metrics');
|
||||
|
||||
await user.click(screen.getByTestId('wizard-query-type-select-metrics-read'));
|
||||
await user.click(
|
||||
await screen.findByTestId('wizard-query-type-option-promql-metrics-read'),
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-add-btn-metrics-read'));
|
||||
|
||||
await expect(screen.findByText('promql/*')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hardcodes the key and does not let it be edited', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const keyInput = screen.getByTestId('wizard-key-input-logs-read');
|
||||
expect(keyInput).toHaveValue('signoz.workspace.key.id');
|
||||
expect(keyInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it('hides Key field for query types that do not support key scoping', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
|
||||
await user.click(await screen.findByText('ClickHouse SQL'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('wizard-key-input-logs-read'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a key-scoped selector when the value is filled', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '123');
|
||||
|
||||
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
|
||||
'builder_query/signoz.workspace.key.id/123',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('builder_query/signoz.workspace.key.id/123'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('replaces the value with a wildcard when any resource is checked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.click(screen.getByLabelText('Any value'));
|
||||
|
||||
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue('*');
|
||||
|
||||
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('builder_query/signoz.workspace.key.id/*'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears the value when any resource is unchecked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const anyResource = screen.getByLabelText('Any value');
|
||||
await user.click(anyResource);
|
||||
expect(anyResource).toBeChecked();
|
||||
|
||||
await user.click(anyResource);
|
||||
|
||||
expect(anyResource).not.toBeChecked();
|
||||
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('checks any resource when the value is typed as a wildcard', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '*');
|
||||
|
||||
expect(screen.getByLabelText('Any value')).toBeChecked();
|
||||
});
|
||||
|
||||
it('disables value scoping for query types that do not support it', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
|
||||
await user.click(await screen.findByText('ClickHouse SQL'));
|
||||
|
||||
expect(screen.getByTestId('wizard-value-input-logs-read')).toBeDisabled();
|
||||
expect(screen.getByLabelText('Any value')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('clears the value when switching to a query type without key scoping', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '123');
|
||||
|
||||
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
|
||||
await user.click(await screen.findByText('ClickHouse SQL'));
|
||||
|
||||
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue('');
|
||||
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
|
||||
'clickhouse_sql/*',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('clickhouse_sql/*'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('follows a hand-edited selector on the query type and value inputs', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(
|
||||
selectorInput,
|
||||
'clickhouse_sql/signoz.workspace.key.id/checkout',
|
||||
);
|
||||
|
||||
const selectTrigger = screen.getByTestId(
|
||||
'wizard-query-type-select-logs-read',
|
||||
);
|
||||
expect(within(selectTrigger).getByText('ClickHouse SQL')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue(
|
||||
'checkout',
|
||||
);
|
||||
});
|
||||
|
||||
it('checks any resource when the selector is edited to a wildcard value', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(selectorInput, 'builder_query/signoz.workspace.key.id/*');
|
||||
|
||||
expect(screen.getByLabelText('Any value')).toBeChecked();
|
||||
});
|
||||
|
||||
it('keeps the key input hardcoded when the selector uses another key', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(selectorInput, 'builder_query/service.name/frontend');
|
||||
|
||||
expect(screen.getByTestId('wizard-key-input-logs-read')).toHaveValue(
|
||||
'signoz.workspace.key.id',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('wizard-selector-hint-logs-read'),
|
||||
).toHaveTextContent('Allow service.name=frontend for Builder Query queries.');
|
||||
expect(screen.getByTestId('wizard-add-btn-logs-read')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('restores the hardcoded key in the selector once the value changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(selectorInput, 'builder_query/service.name/frontend');
|
||||
|
||||
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '2');
|
||||
|
||||
expect(selectorInput).toHaveValue(
|
||||
'builder_query/signoz.workspace.key.id/frontend2',
|
||||
);
|
||||
expect(screen.getByTestId('wizard-add-btn-logs-read')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('blocks adding when the selector has an unknown query type', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(selectorInput, 'sql_query/*');
|
||||
|
||||
expect(
|
||||
screen.getByTestId('wizard-selector-hint-logs-read'),
|
||||
).toHaveTextContent('"sql_query" is not a supported query type.');
|
||||
expect(screen.getByTestId('wizard-add-btn-logs-read')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('blocks adding when the selector is emptied', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.clear(screen.getByTestId('wizard-selector-input-logs-read'));
|
||||
|
||||
expect(
|
||||
screen.getByTestId('wizard-selector-hint-logs-read'),
|
||||
).toHaveTextContent('Enter a selector.');
|
||||
expect(screen.getByTestId('wizard-add-btn-logs-read')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('adds the hand-edited selector verbatim', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(selectorInput, 'builder_query/signoz.workspace.key.id/a/b');
|
||||
|
||||
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('builder_query/signoz.workspace.key.id/a/b'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes dialog after adding selector', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('telemetry-wizard-dialog-logs-read'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not add duplicate selectors', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
|
||||
|
||||
await user.click(screen.getByTestId('telemetry-wizard-trigger-logs-read'));
|
||||
await screen.findByTestId('telemetry-wizard-dialog-logs-read');
|
||||
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
|
||||
|
||||
const badges = screen.getAllByText('builder_query/*');
|
||||
expect(badges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('resets wizard state when dialog is closed and reopened', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '123');
|
||||
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
|
||||
await user.click(await screen.findByText('ClickHouse SQL'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('telemetry-wizard-dialog-logs-read'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTestId('telemetry-wizard-trigger-logs-read'));
|
||||
const selectTrigger = await screen.findByTestId(
|
||||
'wizard-query-type-select-logs-read',
|
||||
);
|
||||
|
||||
expect(within(selectTrigger).getByText('Builder Query')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue('');
|
||||
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
|
||||
'builder_query/*',
|
||||
);
|
||||
});
|
||||
|
||||
it('previews the query-type wildcard while the value is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
|
||||
'builder_query/*',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('wizard-selector-hint-logs-read'),
|
||||
).toHaveTextContent('Allow every "Builder Query" query.');
|
||||
});
|
||||
|
||||
it('describes a key-scoped selector', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '123');
|
||||
|
||||
expect(
|
||||
screen.getByTestId('wizard-selector-hint-logs-read'),
|
||||
).toHaveTextContent(
|
||||
'Allow signoz.workspace.key.id=123 for Builder Query queries.',
|
||||
);
|
||||
});
|
||||
|
||||
it('describes an any-resource selector', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.click(screen.getByLabelText('Any value'));
|
||||
|
||||
expect(
|
||||
screen.getByTestId('wizard-selector-hint-logs-read'),
|
||||
).toHaveTextContent(
|
||||
'Allow every signoz.workspace.key.id for Builder Query queries.',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not show query type descriptions', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
|
||||
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'Visual query builder for selecting data sources, filters, and aggregations',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
|
||||
export async function renderCreateRolePage(): Promise<
|
||||
ReturnType<typeof render>
|
||||
> {
|
||||
const result = render(
|
||||
<TooltipProvider>
|
||||
<Switch>
|
||||
<Route path={ROUTES.ROLES_SETTINGS} exact>
|
||||
<div data-testid="roles-list-redirect" />
|
||||
</Route>
|
||||
<Route path={ROUTES.ROLE_CREATE}>
|
||||
<CreateEditRolePage />
|
||||
</Route>
|
||||
</Switch>
|
||||
</TooltipProvider>,
|
||||
undefined,
|
||||
{ initialRoute: '/settings/roles/new' },
|
||||
);
|
||||
await screen.findByTestId('permission-editor');
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function expandAllCards(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const expandButton = await screen.findByTestId('expand-all-button');
|
||||
await user.click(expandButton);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import { PermissionScope } from '../../types';
|
||||
import { getResourcePanel } from '../../permissions.config';
|
||||
import ItemInputSelector from './ItemInputSelector';
|
||||
import TelemetrySelectorWizard from './TelemetrySelectorWizard';
|
||||
|
||||
import styles from './ActionToggle.module.scss';
|
||||
import { AuthZResource, AuthZVerb } from 'lib/authz/hooks/useAuthZ/types';
|
||||
@@ -39,10 +40,13 @@ function ActionToggle({
|
||||
onSelectedIdsChange,
|
||||
hasError = false,
|
||||
}: ActionToggleProps): JSX.Element {
|
||||
const panel = getResourcePanel(resource);
|
||||
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const [pendingScope, setPendingScope] = useState<PermissionScope | null>(null);
|
||||
|
||||
const displayLabel = getActionLabel(action);
|
||||
const selectorTestId = `${resource}-${action}`;
|
||||
|
||||
const scopeItems: Array<{ value: PermissionScope; label: string }> =
|
||||
useMemo(() => {
|
||||
@@ -121,11 +125,25 @@ function ActionToggle({
|
||||
<Divider />
|
||||
|
||||
<ItemInputSelector
|
||||
placeholder={getResourcePanel(resource).selectorPlaceholder}
|
||||
placeholder={panel.selectorPlaceholder}
|
||||
selectedIds={selectedIds}
|
||||
onChange={onSelectedIdsChange}
|
||||
docsAnchor={getResourcePanel(resource).docsAnchor}
|
||||
testId={selectorTestId}
|
||||
docsAnchor={panel.docsAnchor}
|
||||
hasError={hasError}
|
||||
prefixElement={
|
||||
panel.selectorType === 'telemetryBuilder' ? (
|
||||
<TelemetrySelectorWizard
|
||||
resource={resource}
|
||||
testId={selectorTestId}
|
||||
onAdd={(selector): void => {
|
||||
if (!selectedIds.includes(selector)) {
|
||||
onSelectedIdsChange([...selectedIds, selector]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
gap: var(--spacing-4);
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--spacing-4);
|
||||
|
||||
--input-prefix-padding: var(--spacing-2);
|
||||
--input-suffix-padding: var(--spacing-2);
|
||||
}
|
||||
|
||||
@@ -41,6 +42,11 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.itemInputSelectorBadge {
|
||||
--badge-border-radius: 4px;
|
||||
--badge-hover-background: var(--l2-background) !important;
|
||||
}
|
||||
|
||||
.itemInputSelectorInfoIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
@@ -51,55 +57,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.itemInputSelectorBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 140px;
|
||||
padding: 2px 4px 2px 6px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.itemInputSelectorBadgeLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemInputSelectorBadgeRemove {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.itemInputSelectorHint {
|
||||
margin: 0;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Info, Plus, X } from '@signozhq/icons';
|
||||
import { Info, Plus } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -15,8 +16,10 @@ export interface ItemInputSelectorProps {
|
||||
placeholder: string;
|
||||
selectedIds: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
testId: string;
|
||||
docsAnchor?: string;
|
||||
hasError?: boolean;
|
||||
prefixElement?: React.ReactNode;
|
||||
}
|
||||
|
||||
function parseInputValues(input: string): string[] {
|
||||
@@ -30,11 +33,13 @@ function ItemInputSelector({
|
||||
placeholder,
|
||||
selectedIds,
|
||||
onChange,
|
||||
testId,
|
||||
docsAnchor = 'role',
|
||||
hasError = false,
|
||||
prefixElement,
|
||||
}: ItemInputSelectorProps): JSX.Element {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const footerRef = useRef<HTMLDivElement>(null);
|
||||
const badgesRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const addValues = useCallback(
|
||||
(input: string): void => {
|
||||
@@ -87,26 +92,24 @@ function ItemInputSelector({
|
||||
[selectedIds, onChange],
|
||||
);
|
||||
|
||||
const handleBadgeKeyDown = useCallback(
|
||||
(
|
||||
e: React.KeyboardEvent<HTMLButtonElement>,
|
||||
itemId: string,
|
||||
index: number,
|
||||
): void => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleBadgeClose = useCallback(
|
||||
(e: React.MouseEvent, itemId: string, index: number): void => {
|
||||
e.preventDefault();
|
||||
handleRemove(itemId);
|
||||
|
||||
// Activating a button via Enter/Space reports detail 0;
|
||||
// a real click reports 1 or more
|
||||
// Only trigger focus when using keyboard
|
||||
const isKeyboardActivation = e.detail === 0;
|
||||
|
||||
if (!isKeyboardActivation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIndex = index > 0 ? index - 1 : 0;
|
||||
requestAnimationFrame(() => {
|
||||
const buttons = footerRef.current?.querySelectorAll('button');
|
||||
const targetButton = buttons?.[targetIndex] as
|
||||
| HTMLButtonElement
|
||||
| undefined;
|
||||
targetButton?.focus();
|
||||
const buttons = badgesRef.current?.querySelectorAll('button');
|
||||
buttons?.[targetIndex]?.focus();
|
||||
});
|
||||
},
|
||||
[handleRemove],
|
||||
@@ -120,7 +123,7 @@ function ItemInputSelector({
|
||||
styles.itemInputSelector,
|
||||
showError ? styles.itemInputSelectorError : '',
|
||||
)}
|
||||
data-testid="item-input-selector"
|
||||
data-testid={`item-input-selector-${testId}`}
|
||||
>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
@@ -128,14 +131,15 @@ function ItemInputSelector({
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
data-testid="item-input-selector-input"
|
||||
data-testid={`item-input-selector-input-${testId}`}
|
||||
prefix={prefixElement}
|
||||
suffix={
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
onClick={handleAddClick}
|
||||
disabled={!inputValue.trim()}
|
||||
data-testid="item-input-selector-add-btn"
|
||||
data-testid={`item-input-selector-add-btn-${testId}`}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add
|
||||
@@ -144,28 +148,22 @@ function ItemInputSelector({
|
||||
/>
|
||||
|
||||
{selectedIds.length > 0 ? (
|
||||
<div ref={footerRef} className={styles.itemInputSelectorFooter}>
|
||||
<div className={styles.itemInputSelectorBadges}>
|
||||
<div className={styles.itemInputSelectorFooter}>
|
||||
<div ref={badgesRef} className={styles.itemInputSelectorBadges}>
|
||||
{selectedIds.map((id, index) => (
|
||||
<span key={id} className={styles.itemInputSelectorBadge} title={id}>
|
||||
<Typography
|
||||
as="span"
|
||||
size="small"
|
||||
truncate={1}
|
||||
className={styles.itemInputSelectorBadgeLabel}
|
||||
>
|
||||
<Badge
|
||||
key={id}
|
||||
color="secondary"
|
||||
className={styles.itemInputSelectorBadge}
|
||||
testId={`item-badge-${testId}-${index}`}
|
||||
closable
|
||||
closeAriaLabel={`Remove ${id}`}
|
||||
onClose={(e): void => handleBadgeClose(e, id, index)}
|
||||
>
|
||||
<Typography as="span" size="small" truncate={1} title={id}>
|
||||
{id}
|
||||
</Typography>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.itemInputSelectorBadgeRemove}
|
||||
onClick={(): void => handleRemove(id)}
|
||||
onKeyDown={(e): void => handleBadgeKeyDown(e, id, index)}
|
||||
aria-label={`Remove ${id}`}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<TooltipSimple
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// intentionally omitting few query types
|
||||
// from pkg/types/querybuildertypes/querybuildertypesv5/query_type.go
|
||||
export type QueryTypeId = 'builder_query' | 'promql' | 'clickhouse_sql';
|
||||
|
||||
export interface QueryTypeOption {
|
||||
id: QueryTypeId;
|
||||
label: string;
|
||||
supportsKeyScoping: boolean;
|
||||
metricsOnly?: boolean;
|
||||
}
|
||||
|
||||
export const QUERY_TYPES: readonly QueryTypeOption[] = [
|
||||
{
|
||||
id: 'builder_query',
|
||||
label: 'Builder Query',
|
||||
supportsKeyScoping: true,
|
||||
},
|
||||
{
|
||||
id: 'promql',
|
||||
label: 'PromQL',
|
||||
supportsKeyScoping: false,
|
||||
metricsOnly: true,
|
||||
},
|
||||
{
|
||||
id: 'clickhouse_sql',
|
||||
label: 'ClickHouse SQL',
|
||||
supportsKeyScoping: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_QUERY_TYPE: QueryTypeId = 'builder_query';
|
||||
|
||||
export const SUPPORTED_GRANT_KEY = 'signoz.workspace.key.id';
|
||||
|
||||
export const ANY_RESOURCE_VALUE = '*';
|
||||
|
||||
export interface SelectorDraft {
|
||||
queryType: QueryTypeId;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ParsedSelector {
|
||||
queryType?: QueryTypeId;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SelectorValidation {
|
||||
message: string;
|
||||
isError: boolean;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
.wizardDialog {
|
||||
border-color: var(--l1-border);
|
||||
|
||||
--select-trigger-background-color: var(--l3-background);
|
||||
--select-trigger-border-color: var(--l3-background);
|
||||
--select-content-background: var(--l3-background);
|
||||
--select-item-highlight-background: var(--l3-background-hover);
|
||||
--select-trigger-outline-width: 1px;
|
||||
--select-trigger-outline-offset: 0px;
|
||||
|
||||
--input-background: var(--l3-background);
|
||||
--input-hover-background: var(--l3-background);
|
||||
--input-focus-background: var(--l3-background);
|
||||
--input-disabled-background: var(--l3-background);
|
||||
--input-border-color: var(--l3-border);
|
||||
--input-hover-border-color: var(--l3-border);
|
||||
--input-focus-border-color: var(--l3-border);
|
||||
|
||||
outline: none;
|
||||
|
||||
& > div {
|
||||
background-color: var(--l2-background);
|
||||
}
|
||||
}
|
||||
|
||||
.wizardBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.wizardField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.wizardValueRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.wizardValueInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selectContent {
|
||||
z-index: 10;
|
||||
background-color: var(--l3-background);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Wand } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import {
|
||||
ANY_RESOURCE_VALUE,
|
||||
QUERY_TYPES,
|
||||
SUPPORTED_GRANT_KEY,
|
||||
} from './TelemetrySelectorWizard.constants';
|
||||
import { isQueryTypeAvailable } from './TelemetrySelectorWizard.utils';
|
||||
import useTelemetrySelectorWizard from './useTelemetrySelectorWizard';
|
||||
|
||||
import styles from './TelemetrySelectorWizard.module.scss';
|
||||
import { AuthZResource } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
interface TelemetrySelectorWizardProps {
|
||||
onAdd: (selector: string) => void;
|
||||
resource: AuthZResource;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function TelemetrySelectorWizard({
|
||||
onAdd,
|
||||
resource,
|
||||
testId,
|
||||
}: TelemetrySelectorWizardProps): JSX.Element {
|
||||
const {
|
||||
open,
|
||||
queryType,
|
||||
selectedQueryType,
|
||||
value,
|
||||
selector,
|
||||
isAnyResource,
|
||||
supportsKeyScoping,
|
||||
validation,
|
||||
canAdd,
|
||||
handleOpenChange,
|
||||
handleQueryTypeChange,
|
||||
handleValueChange,
|
||||
handleAnyResourceChange,
|
||||
handleSelectorChange,
|
||||
handleAdd,
|
||||
handleInputKeyDown,
|
||||
} = useTelemetrySelectorWizard({ onAdd });
|
||||
|
||||
const trigger = (
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
data-testid={`telemetry-wizard-trigger-${testId}`}
|
||||
>
|
||||
<Wand size={14} />
|
||||
Wizard
|
||||
</Button>
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={(): void => handleOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
onClick={handleAdd}
|
||||
disabled={!canAdd}
|
||||
data-testid={`wizard-add-btn-${testId}`}
|
||||
>
|
||||
Add Selector
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title="Selector Wizard"
|
||||
width="wide"
|
||||
testId={`telemetry-wizard-dialog-${testId}`}
|
||||
trigger={trigger}
|
||||
footer={footer}
|
||||
className={styles.wizardDialog}
|
||||
>
|
||||
<div className={styles.wizardBody}>
|
||||
<div className={styles.wizardField}>
|
||||
<Typography as="label" weight="medium">
|
||||
Query Type
|
||||
</Typography>
|
||||
<Select value={queryType} onChange={handleQueryTypeChange}>
|
||||
<SelectTrigger data-testid={`wizard-query-type-select-${testId}`}>
|
||||
<SelectValue>{selectedQueryType?.label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent withPortal={false} className={styles.selectContent}>
|
||||
{QUERY_TYPES.filter((queryTypeOption) =>
|
||||
isQueryTypeAvailable(queryTypeOption, resource),
|
||||
).map((queryTypeOption) => (
|
||||
<SelectItem
|
||||
key={queryTypeOption.id}
|
||||
value={queryTypeOption.id}
|
||||
testId={`wizard-query-type-option-${queryTypeOption.id}-${testId}`}
|
||||
>
|
||||
{queryTypeOption.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{supportsKeyScoping && (
|
||||
<div className={styles.wizardField}>
|
||||
<Typography as="label" weight="medium">
|
||||
Key
|
||||
</Typography>
|
||||
<Input
|
||||
value={SUPPORTED_GRANT_KEY}
|
||||
readOnly
|
||||
disabled
|
||||
testId={`wizard-key-input-${testId}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.wizardField}>
|
||||
<Typography as="label" weight="medium">
|
||||
Value
|
||||
</Typography>
|
||||
<div className={styles.wizardValueRow}>
|
||||
<Input
|
||||
className={styles.wizardValueInput}
|
||||
placeholder={
|
||||
supportsKeyScoping
|
||||
? 'Value or leave empty to allow every query'
|
||||
: ANY_RESOURCE_VALUE
|
||||
}
|
||||
value={value}
|
||||
disabled={!supportsKeyScoping}
|
||||
onChange={handleValueChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
testId={`wizard-value-input-${testId}`}
|
||||
/>
|
||||
<Checkbox
|
||||
id={`wizard-any-resource-${testId}`}
|
||||
value={isAnyResource}
|
||||
disabled={!supportsKeyScoping}
|
||||
onChange={(checked): void => handleAnyResourceChange(checked === true)}
|
||||
testId={`wizard-any-resource-checkbox-${testId}`}
|
||||
>
|
||||
Any value
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.wizardField}>
|
||||
<Typography as="label" weight="medium">
|
||||
Selector
|
||||
</Typography>
|
||||
<Input
|
||||
value={selector}
|
||||
onChange={handleSelectorChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
testId={`wizard-selector-input-${testId}`}
|
||||
/>
|
||||
<Typography.Text
|
||||
size="small"
|
||||
color={validation.isError ? 'danger' : 'muted'}
|
||||
testId={`wizard-selector-hint-${testId}`}
|
||||
>
|
||||
{validation.message}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default TelemetrySelectorWizard;
|
||||
@@ -0,0 +1,139 @@
|
||||
import { AuthZResource } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
import {
|
||||
ANY_RESOURCE_VALUE,
|
||||
ParsedSelector,
|
||||
QUERY_TYPES,
|
||||
QueryTypeId,
|
||||
QueryTypeOption,
|
||||
SelectorDraft,
|
||||
SelectorValidation,
|
||||
SUPPORTED_GRANT_KEY,
|
||||
} from './TelemetrySelectorWizard.constants';
|
||||
|
||||
const METRIC_RESOURCES: ReadonlySet<AuthZResource> = new Set<AuthZResource>([
|
||||
'metrics',
|
||||
'meter-metrics',
|
||||
]);
|
||||
|
||||
export function getQueryTypeOption(
|
||||
queryType: string,
|
||||
): QueryTypeOption | undefined {
|
||||
return QUERY_TYPES.find((option) => option.id === queryType);
|
||||
}
|
||||
|
||||
export function isQueryTypeAvailable(
|
||||
option: QueryTypeOption,
|
||||
resource: AuthZResource,
|
||||
): boolean {
|
||||
return !option.metricsOnly || METRIC_RESOURCES.has(resource);
|
||||
}
|
||||
|
||||
export function supportsKeyScoping(queryType: string): boolean {
|
||||
return getQueryTypeOption(queryType)?.supportsKeyScoping ?? false;
|
||||
}
|
||||
|
||||
export function isAnyResourceValue(value: string): boolean {
|
||||
return value.trim() === ANY_RESOURCE_VALUE;
|
||||
}
|
||||
|
||||
function splitSelector(selector: string): string[] {
|
||||
const parts = selector.split('/');
|
||||
|
||||
if (parts.length <= 3) {
|
||||
return parts;
|
||||
}
|
||||
|
||||
return [parts[0], parts[1], parts.slice(2).join('/')];
|
||||
}
|
||||
|
||||
export function buildSelector({ queryType, value }: SelectorDraft): string {
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (!supportsKeyScoping(queryType) || !trimmedValue) {
|
||||
return `${queryType}/${ANY_RESOURCE_VALUE}`;
|
||||
}
|
||||
|
||||
return `${queryType}/${SUPPORTED_GRANT_KEY}/${trimmedValue}`;
|
||||
}
|
||||
|
||||
export function parseSelector(selector: string): ParsedSelector {
|
||||
const parts = splitSelector(selector.trim());
|
||||
|
||||
return {
|
||||
queryType: getQueryTypeOption(parts[0])?.id,
|
||||
value: parts.length >= 3 ? parts[2] : '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This does a basic validation, intentionally omitting deep validations since this is to be made at backend,
|
||||
* so this will allow to produce invalid selectors, and the validation will be done after the user try to save
|
||||
*/
|
||||
export function validateSelector(selector: string): SelectorValidation {
|
||||
const trimmed = selector.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return { message: 'Enter a selector.', isError: true };
|
||||
}
|
||||
|
||||
if (trimmed === ANY_RESOURCE_VALUE) {
|
||||
return { message: 'Allow every query of every type.', isError: false };
|
||||
}
|
||||
|
||||
const parts = splitSelector(trimmed);
|
||||
const option = getQueryTypeOption(parts[0]);
|
||||
|
||||
if (!option) {
|
||||
return {
|
||||
message: `"${parts[0]}" is not a supported query type.`,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (parts.length < 3) {
|
||||
if (parts.length === 2 && parts[1] !== ANY_RESOURCE_VALUE) {
|
||||
if (!option.supportsKeyScoping) {
|
||||
return {
|
||||
message: `This query type does not support key scoping. Use ${option.id}/*`,
|
||||
isError: false, // intentionally not an error
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Use <query-type>/${ANY_RESOURCE_VALUE} or <query-type>/${SUPPORTED_GRANT_KEY}/<value>.`,
|
||||
isError: false, // intentionally not an error
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Allow every "${option.label}" query.`,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
const [, key, value] = parts;
|
||||
|
||||
if (value === ANY_RESOURCE_VALUE) {
|
||||
return {
|
||||
message: `Allow every ${key} for ${option.label} queries.`,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!option.supportsKeyScoping) {
|
||||
return {
|
||||
message: `This query type does not support key scoping. Use ${option.id}/*`,
|
||||
isError: false, // intentionally not an error
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Allow ${key}=${value} for ${option.label} queries.`,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultSelector(queryType: QueryTypeId): string {
|
||||
return buildSelector({ queryType, value: '' });
|
||||
}
|
||||
@@ -76,9 +76,10 @@ function createGrantPermissionAsReadonly(
|
||||
return {
|
||||
label: 'readonly',
|
||||
insertText: resources
|
||||
.filter(
|
||||
(r) => r.allowedVerbs.includes('read') || r.allowedVerbs.includes('list'),
|
||||
)
|
||||
.filter((r) => {
|
||||
const verbs: readonly string[] = r.allowedVerbs;
|
||||
return verbs.includes('read') || verbs.includes('list');
|
||||
})
|
||||
.flatMap((r) => {
|
||||
const verbs = r.allowedVerbs.filter((v) => v === 'read' || v === 'list');
|
||||
return verbs.map(
|
||||
@@ -103,7 +104,12 @@ function buildResourceSnippets(): SnippetDef[] {
|
||||
for (const resource of resources) {
|
||||
const { kind, type, allowedVerbs } = resource;
|
||||
|
||||
snippets.push(createGrantAllPermissionSnippet(kind, allowedVerbs, type));
|
||||
// If only one verb is supported, no point on add grant all
|
||||
// that will only add one permission at time, just leave the verb snippet
|
||||
// and it will look cleaner
|
||||
if (allowedVerbs.length > 1) {
|
||||
snippets.push(createGrantAllPermissionSnippet(kind, allowedVerbs, type));
|
||||
}
|
||||
|
||||
for (const verb of allowedVerbs) {
|
||||
snippets.push(createGrantPermissionToVerbAndKind(kind, verb, type));
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
ANY_RESOURCE_VALUE,
|
||||
DEFAULT_QUERY_TYPE,
|
||||
QueryTypeId,
|
||||
QueryTypeOption,
|
||||
SelectorValidation,
|
||||
} from './TelemetrySelectorWizard.constants';
|
||||
import {
|
||||
buildSelector,
|
||||
getDefaultSelector,
|
||||
getQueryTypeOption,
|
||||
isAnyResourceValue,
|
||||
parseSelector,
|
||||
validateSelector,
|
||||
} from './TelemetrySelectorWizard.utils';
|
||||
|
||||
interface UseTelemetrySelectorWizardParams {
|
||||
onAdd: (selector: string) => void;
|
||||
}
|
||||
|
||||
interface UseTelemetrySelectorWizardResult {
|
||||
open: boolean;
|
||||
queryType: QueryTypeId;
|
||||
selectedQueryType: QueryTypeOption | undefined;
|
||||
value: string;
|
||||
selector: string;
|
||||
isAnyResource: boolean;
|
||||
supportsKeyScoping: boolean;
|
||||
validation: SelectorValidation;
|
||||
canAdd: boolean;
|
||||
handleOpenChange: (nextOpen: boolean) => void;
|
||||
handleQueryTypeChange: (value: string | string[]) => void;
|
||||
handleValueChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleAnyResourceChange: (checked: boolean) => void;
|
||||
handleSelectorChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleAdd: () => void;
|
||||
handleInputKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
function useTelemetrySelectorWizard({
|
||||
onAdd,
|
||||
}: UseTelemetrySelectorWizardParams): UseTelemetrySelectorWizardResult {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [queryType, setQueryType] = useState<QueryTypeId>(DEFAULT_QUERY_TYPE);
|
||||
const [value, setValue] = useState('');
|
||||
const [selector, setSelector] = useState(() =>
|
||||
getDefaultSelector(DEFAULT_QUERY_TYPE),
|
||||
);
|
||||
|
||||
const selectedQueryType = useMemo(
|
||||
() => getQueryTypeOption(queryType),
|
||||
[queryType],
|
||||
);
|
||||
const supportsKeyScoping = selectedQueryType?.supportsKeyScoping ?? false;
|
||||
|
||||
const validation = useMemo(() => validateSelector(selector), [selector]);
|
||||
|
||||
const applyDraft = useCallback(
|
||||
(nextQueryType: QueryTypeId, nextValue: string): void => {
|
||||
setQueryType(nextQueryType);
|
||||
setValue(nextValue);
|
||||
setSelector(buildSelector({ queryType: nextQueryType, value: nextValue }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleQueryTypeChange = useCallback(
|
||||
(next: string | string[]): void => {
|
||||
const selected = (Array.isArray(next) ? next[0] : next) as QueryTypeId;
|
||||
const keepsValue = getQueryTypeOption(selected)?.supportsKeyScoping ?? false;
|
||||
|
||||
applyDraft(selected, keepsValue ? value : '');
|
||||
},
|
||||
[applyDraft, value],
|
||||
);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
applyDraft(queryType, event.target.value);
|
||||
},
|
||||
[applyDraft, queryType],
|
||||
);
|
||||
|
||||
const handleAnyResourceChange = useCallback(
|
||||
(checked: boolean): void => {
|
||||
applyDraft(queryType, checked ? ANY_RESOURCE_VALUE : '');
|
||||
},
|
||||
[applyDraft, queryType],
|
||||
);
|
||||
|
||||
const handleSelectorChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const nextSelector = event.target.value;
|
||||
setSelector(nextSelector);
|
||||
|
||||
const parsed = parseSelector(nextSelector);
|
||||
if (parsed.queryType) {
|
||||
setQueryType(parsed.queryType);
|
||||
}
|
||||
setValue(parsed.value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback((nextOpen: boolean): void => {
|
||||
setOpen(nextOpen);
|
||||
|
||||
if (!nextOpen) {
|
||||
setQueryType(DEFAULT_QUERY_TYPE);
|
||||
setValue('');
|
||||
setSelector(getDefaultSelector(DEFAULT_QUERY_TYPE));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAdd = useCallback((): void => {
|
||||
const trimmed = selector.trim();
|
||||
|
||||
if (validateSelector(trimmed).isError) {
|
||||
return;
|
||||
}
|
||||
|
||||
onAdd(trimmed);
|
||||
handleOpenChange(false);
|
||||
}, [selector, onAdd, handleOpenChange]);
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'Enter') {
|
||||
handleAdd();
|
||||
}
|
||||
},
|
||||
[handleAdd],
|
||||
);
|
||||
|
||||
return {
|
||||
open,
|
||||
queryType,
|
||||
selectedQueryType,
|
||||
value,
|
||||
selector,
|
||||
isAnyResource: isAnyResourceValue(value),
|
||||
supportsKeyScoping,
|
||||
validation,
|
||||
canAdd: !validation.isError,
|
||||
handleOpenChange,
|
||||
handleQueryTypeChange,
|
||||
handleValueChange,
|
||||
handleAnyResourceChange,
|
||||
handleSelectorChange,
|
||||
handleAdd,
|
||||
handleInputKeyDown,
|
||||
};
|
||||
}
|
||||
|
||||
export default useTelemetrySelectorWizard;
|
||||
@@ -329,11 +329,15 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
|
||||
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
|
||||
const result = transformTransactionGroupsToResourcePermissions([]);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result).toHaveLength(7);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'role',
|
||||
'serviceaccount',
|
||||
'logs',
|
||||
'traces',
|
||||
'metrics',
|
||||
'meter-metrics',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -414,11 +418,15 @@ describe('createEmptyRolePermissions', () => {
|
||||
it('creates permissions for all resources in RESOURCE_ORDER', () => {
|
||||
const result = createEmptyRolePermissions();
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result).toHaveLength(7);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'role',
|
||||
'serviceaccount',
|
||||
'logs',
|
||||
'traces',
|
||||
'metrics',
|
||||
'meter-metrics',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Bot, Key, Shield } from '@signozhq/icons';
|
||||
import {
|
||||
Bot,
|
||||
ChartLine,
|
||||
DraftingCompass,
|
||||
Gauge,
|
||||
Key,
|
||||
Logs,
|
||||
Shield,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
|
||||
import {
|
||||
@@ -14,12 +22,15 @@ type IconComponent = typeof Shield;
|
||||
|
||||
const OBJECT_SCOPED_VERB_SET = new Set<string>(OBJECT_SCOPED_VERBS);
|
||||
|
||||
export type SelectorType = 'input' | 'telemetryBuilder';
|
||||
|
||||
export interface ResourcePanelConfig {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: IconComponent;
|
||||
selectorPlaceholder: string;
|
||||
docsAnchor: string;
|
||||
selectorType?: SelectorType;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +61,42 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
|
||||
'Type service account ID, separate multiple with comma or space',
|
||||
docsAnchor: 'service-account',
|
||||
},
|
||||
logs: {
|
||||
label: 'Logs',
|
||||
description: 'Log data collected across the workspace.',
|
||||
icon: Logs,
|
||||
selectorPlaceholder:
|
||||
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
|
||||
docsAnchor: 'logs',
|
||||
selectorType: 'telemetryBuilder',
|
||||
},
|
||||
traces: {
|
||||
label: 'Traces',
|
||||
description: 'Distributed tracing data collected across the workspace.',
|
||||
icon: DraftingCompass,
|
||||
selectorPlaceholder:
|
||||
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
|
||||
docsAnchor: 'traces',
|
||||
selectorType: 'telemetryBuilder',
|
||||
},
|
||||
metrics: {
|
||||
label: 'Metrics',
|
||||
description: 'Metric data collected across the workspace.',
|
||||
icon: ChartLine,
|
||||
selectorPlaceholder:
|
||||
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
|
||||
docsAnchor: 'metrics',
|
||||
selectorType: 'telemetryBuilder',
|
||||
},
|
||||
'meter-metrics': {
|
||||
label: 'Meter Metrics',
|
||||
description: 'Usage metering data for the workspace.',
|
||||
icon: Gauge,
|
||||
selectorPlaceholder:
|
||||
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
|
||||
docsAnchor: 'meter-metrics',
|
||||
selectorType: 'telemetryBuilder',
|
||||
},
|
||||
};
|
||||
|
||||
export const RESOURCE_ORDER = Object.keys(RESOURCE_PANELS) as AuthZResource[];
|
||||
|
||||
@@ -108,6 +108,7 @@ export function getAppContextMockState(
|
||||
isFetchingHosts: false,
|
||||
isFetchingFeatureFlags: false,
|
||||
isFetchingOrgPreferences: false,
|
||||
isFetchingUserPreferences: false,
|
||||
userFetchError: undefined,
|
||||
activeLicenseFetchError: null,
|
||||
hostsFetchError: undefined,
|
||||
|
||||
@@ -144,6 +144,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
trialInfo,
|
||||
isLoggedIn,
|
||||
userPreferences,
|
||||
isFetchingUserPreferences,
|
||||
changelog,
|
||||
toggleChangelogModal,
|
||||
updateUserPreferenceInContext,
|
||||
@@ -261,6 +262,11 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
|
||||
// Compute initial pinned items and secondary menu items synchronously to avoid flash
|
||||
const computedPinnedMenuItems = useMemo(() => {
|
||||
// While loading, return empty to avoid flash
|
||||
if (isFetchingUserPreferences) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const navShortcutsPreference = userPreferences?.find(
|
||||
(preference) => preference.name === USER_PREFERENCES.NAV_SHORTCUTS,
|
||||
);
|
||||
@@ -268,11 +274,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
| string[]
|
||||
| undefined;
|
||||
|
||||
// If userPreferences not loaded yet, return empty to avoid showing defaults before preferences load
|
||||
if (userPreferences === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// If preference exists with non-empty array, use stored shortcuts
|
||||
if (isArray(navShortcuts) && navShortcuts.length > 0) {
|
||||
return navShortcuts
|
||||
@@ -282,9 +283,9 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
.filter((item): item is SidebarItem => item !== undefined);
|
||||
}
|
||||
|
||||
// No preference, or empty array → use defaults
|
||||
// No preference, or empty array, or error loading → use defaults
|
||||
return defaultMoreMenuItems.filter((item) => item.isPinned);
|
||||
}, [userPreferences]);
|
||||
}, [isFetchingUserPreferences, userPreferences]);
|
||||
|
||||
const computedSecondaryMenuItems = useMemo(() => {
|
||||
const shouldShowIntegrationsValue =
|
||||
@@ -322,12 +323,16 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
// Sync state only on initial load when userPreferences first becomes available
|
||||
useEffect(() => {
|
||||
// Only sync once: when userPreferences loads for the first time
|
||||
if (!hasInitializedRef.current && userPreferences !== null) {
|
||||
if (!hasInitializedRef.current && isFetchingUserPreferences === false) {
|
||||
setPinnedMenuItems(computedPinnedMenuItems);
|
||||
setSecondaryMenuItems(computedSecondaryMenuItems);
|
||||
hasInitializedRef.current = true;
|
||||
}
|
||||
}, [computedPinnedMenuItems, computedSecondaryMenuItems, userPreferences]);
|
||||
}, [
|
||||
computedPinnedMenuItems,
|
||||
computedSecondaryMenuItems,
|
||||
isFetchingUserPreferences,
|
||||
]);
|
||||
|
||||
const isChatSupportEnabled = featureFlags?.find(
|
||||
(flag) => flag.name === FeatureKeys.CHAT_SUPPORT,
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const DEFAULT_COPIED_RESET_MS = 2000;
|
||||
|
||||
export interface UseCopyToClipboardOptions {
|
||||
/** How long (ms) to keep "copied" state before resetting. Default 2000. */
|
||||
copiedResetMs?: number;
|
||||
}
|
||||
|
||||
export type ID = number | string | null;
|
||||
|
||||
export interface UseCopyToClipboardReturn {
|
||||
/** Copy text to clipboard. Pass an optional id to track which item was copied (e.g. seriesIndex). */
|
||||
copyToClipboard: (text: string, id?: ID) => void;
|
||||
/** True when something was just copied and still within the reset threshold. */
|
||||
isCopied: boolean;
|
||||
/** The id passed to the last successful copy, or null after reset. Use to show "copied" state for a specific item (e.g. copiedId === item.seriesIndex). */
|
||||
id: ID;
|
||||
}
|
||||
|
||||
export function useCopyToClipboard(
|
||||
options: UseCopyToClipboardOptions = {},
|
||||
): UseCopyToClipboardReturn {
|
||||
const { copiedResetMs = DEFAULT_COPIED_RESET_MS } = options;
|
||||
const [state, setState] = useState<{ isCopied: boolean; id: ID }>({
|
||||
isCopied: false,
|
||||
id: null,
|
||||
});
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = useCallback(
|
||||
(text: string, id?: ID): void => {
|
||||
// oxlint-disable-next-line signoz/no-navigator-clipboard
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setState({ isCopied: true, id: id ?? null });
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setState({ isCopied: false, id: null });
|
||||
timeoutRef.current = null;
|
||||
}, copiedResetMs);
|
||||
});
|
||||
},
|
||||
[copiedResetMs],
|
||||
);
|
||||
|
||||
return {
|
||||
copyToClipboard,
|
||||
isCopied: state.isCopied,
|
||||
id: state.id,
|
||||
};
|
||||
}
|
||||
@@ -19,7 +19,7 @@ describe('PermissionDeniedCallout', () => {
|
||||
it('renders multiple denied permissions', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('serviceaccount', '*')),
|
||||
buildPermission('update', buildObjectString('role', 'admin')),
|
||||
buildPermission('update', buildObjectString<'update'>('role', 'admin')),
|
||||
];
|
||||
render(<PermissionDeniedCallout deniedPermissions={deniedPermissions} />);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('PermissionDeniedFullPage', () => {
|
||||
it('renders with multiple denied permissions', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('role', 'admin')),
|
||||
buildPermission('update', buildObjectString('role', 'admin')),
|
||||
buildPermission('update', buildObjectString<'update'>('role', 'admin')),
|
||||
];
|
||||
render(<PermissionDeniedFullPage deniedPermissions={deniedPermissions} />);
|
||||
expect(screen.getByText(/read:role:admin/)).toBeInTheDocument();
|
||||
|
||||
@@ -35,6 +35,26 @@ export default {
|
||||
'update',
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'logs',
|
||||
type: 'telemetryresource',
|
||||
allowedVerbs: ['read'],
|
||||
},
|
||||
{
|
||||
kind: 'meter-metrics',
|
||||
type: 'telemetryresource',
|
||||
allowedVerbs: ['read'],
|
||||
},
|
||||
{
|
||||
kind: 'metrics',
|
||||
type: 'telemetryresource',
|
||||
allowedVerbs: ['read'],
|
||||
},
|
||||
{
|
||||
kind: 'traces',
|
||||
type: 'telemetryresource',
|
||||
allowedVerbs: ['read'],
|
||||
},
|
||||
],
|
||||
relations: {
|
||||
assignee: ['role'],
|
||||
@@ -43,7 +63,7 @@ export default {
|
||||
delete: ['metaresource', 'role', 'serviceaccount'],
|
||||
detach: ['metaresource', 'role', 'serviceaccount'],
|
||||
list: ['metaresource', 'role', 'serviceaccount'],
|
||||
read: ['metaresource', 'role', 'serviceaccount'],
|
||||
read: ['metaresource', 'role', 'serviceaccount', 'telemetryresource'],
|
||||
update: ['metaresource', 'role', 'serviceaccount'],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -23,6 +23,40 @@ export function buildPermission<R extends AuthZRelation>(
|
||||
return `${relation}${PermissionSeparator}${object}` as BrandedPermission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an object string for use with `buildPermission`.
|
||||
*
|
||||
* ## Type Inference Behavior
|
||||
*
|
||||
* TypeScript infers `R` from `resource`. If a resource belongs to multiple relations,
|
||||
* the return type becomes a union of all matching `AuthZObject` types.
|
||||
*
|
||||
* Example: 'role' is valid for 'read', 'update', 'delete', 'assignee'.
|
||||
* Without explicit generic, return type = `AuthZObject<'read' | 'update' | 'delete' | 'assignee'>`.
|
||||
*
|
||||
* ## When to specify explicit generic
|
||||
*
|
||||
* **Needed** when resource belongs to multiple relations AND you pass result to `buildPermission`
|
||||
* with a relation that has FEWER valid resources than others:
|
||||
*
|
||||
* ```ts
|
||||
* // ERROR: 'read' includes telemetry resources, 'update' does not
|
||||
* buildPermission('update', buildObjectString('role', 'admin'))
|
||||
*
|
||||
* // OK: explicit generic narrows return type
|
||||
* buildPermission('update', buildObjectString<'update'>('role', 'admin'))
|
||||
* ```
|
||||
*
|
||||
* **Not needed** when:
|
||||
* - Resource only belongs to one relation in the constraint
|
||||
* - Using with 'read' relation (widest type, accepts all)
|
||||
* - Storing in a variable typed as `BrandedPermission` (already opaque)
|
||||
*
|
||||
* ```ts
|
||||
* // OK: 'read' is widest, accepts union return type
|
||||
* buildPermission('read', buildObjectString('role', 'admin'))
|
||||
* ```
|
||||
*/
|
||||
export function buildObjectString<
|
||||
R extends 'delete' | 'read' | 'update' | 'assignee',
|
||||
>(resource: ResourcesForRelation<R>, objectId: string): AuthZObject<R> {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import { Input } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
|
||||
import { LegendPosition, LegendProps } from '../types';
|
||||
|
||||
@@ -33,7 +31,6 @@ export default function Legend({
|
||||
}: LegendProps): JSX.Element {
|
||||
const legendContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [legendSearchQuery, setLegendSearchQuery] = useState('');
|
||||
const { copyToClipboard, id: copiedId } = useCopyToClipboard();
|
||||
|
||||
// Search is intrinsic to the right-positioned legend.
|
||||
const searchEnabled = position === LegendPosition.RIGHT;
|
||||
@@ -57,17 +54,8 @@ export default function Legend({
|
||||
return items.filter((item) => item.label?.toLowerCase().includes(query));
|
||||
}, [searchEnabled, legendSearchQuery, items]);
|
||||
|
||||
const handleCopyLegendItem = useCallback(
|
||||
(e: React.MouseEvent, seriesIndex: number, label: string): void => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(label, seriesIndex);
|
||||
},
|
||||
[copyToClipboard],
|
||||
);
|
||||
|
||||
const renderLegendItem = useCallback(
|
||||
(item: LegendItem): JSX.Element => {
|
||||
const isCopied = copiedId === item.seriesIndex;
|
||||
// `color` is uPlot's stroke union (string | fn | gradient); only a string
|
||||
// is a usable CSS colour for the marker.
|
||||
const markerColor = typeof item.color === 'string' ? item.color : undefined;
|
||||
@@ -91,36 +79,18 @@ export default function Legend({
|
||||
</div>
|
||||
</TooltipSimple>
|
||||
{showCopy && (
|
||||
<TooltipSimple
|
||||
title={isCopied ? 'Copied' : 'Copy'}
|
||||
arrow
|
||||
side="top"
|
||||
disableHoverableContent
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className="legend-copy-button"
|
||||
onClick={(e): void =>
|
||||
handleCopyLegendItem(e, item.seriesIndex, item.label ?? '')
|
||||
}
|
||||
aria-label={`Copy ${item.label}`}
|
||||
// data-testid (not testId): TooltipSimple's trigger injects
|
||||
// data-testid:undefined via Radix Slot, and Button spreads
|
||||
// incoming props after its own testId — so set it as a prop
|
||||
// that wins the Slot merge and survives the spread.
|
||||
data-testid="legend-copy"
|
||||
>
|
||||
{isCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
<CopyButton
|
||||
value={item.label ?? ''}
|
||||
size={12}
|
||||
className="legend-copy-button"
|
||||
ariaLabel={`Copy ${item.label}`}
|
||||
testId="legend-copy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[copiedId, focusedSeriesIndex, handleCopyLegendItem, position, showCopy],
|
||||
[focusedSeriesIndex, position, showCopy],
|
||||
);
|
||||
|
||||
const isEmptyState = useMemo(() => {
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
RenderResult,
|
||||
screen,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { render, RenderResult, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
@@ -15,9 +9,6 @@ import { useLegendActions } from '../../hooks/useLegendActions';
|
||||
import UPlotLegend from '../Legend/UPlotLegend';
|
||||
import { LegendPosition } from '../types';
|
||||
|
||||
const mockWriteText = jest.fn().mockResolvedValue(undefined);
|
||||
let clipboardSpy: jest.SpyInstance | undefined;
|
||||
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
@@ -49,15 +40,6 @@ const mockUseLegendActions = useLegendActions as jest.MockedFunction<
|
||||
>;
|
||||
|
||||
describe('UPlotLegend', () => {
|
||||
beforeAll(() => {
|
||||
// JSDOM does not define navigator.clipboard; add it so we can spy on writeText
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: () => Promise.resolve() },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
const baseLegendItemsMap = {
|
||||
0: {
|
||||
seriesIndex: 0,
|
||||
@@ -89,11 +71,6 @@ describe('UPlotLegend', () => {
|
||||
onLegendMouseMove = jest.fn();
|
||||
onLegendMouseLeave = jest.fn();
|
||||
onFocusSeries = jest.fn();
|
||||
mockWriteText.mockClear();
|
||||
|
||||
clipboardSpy = jest
|
||||
.spyOn(navigator.clipboard, 'writeText')
|
||||
.mockImplementation(mockWriteText);
|
||||
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: baseLegendItemsMap,
|
||||
@@ -110,7 +87,6 @@ describe('UPlotLegend', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clipboardSpy?.mockRestore();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -237,47 +213,4 @@ describe('UPlotLegend', () => {
|
||||
expect(onLegendMouseLeave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy action', () => {
|
||||
it('copies the legend label to clipboard when copy button is clicked', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const firstLegendItem = document.querySelector(
|
||||
'[data-legend-item-id="0"]',
|
||||
) as HTMLElement;
|
||||
const copyButton = within(firstLegendItem).getByTestId('legend-copy');
|
||||
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
expect(mockWriteText).toHaveBeenCalledWith('A');
|
||||
});
|
||||
|
||||
it('copies the correct label when copy is clicked on a different legend item', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const thirdLegendItem = document.querySelector(
|
||||
'[data-legend-item-id="2"]',
|
||||
) as HTMLElement;
|
||||
const copyButton = within(thirdLegendItem).getByTestId('legend-copy');
|
||||
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
expect(mockWriteText).toHaveBeenCalledWith('C');
|
||||
});
|
||||
|
||||
it('does not call onLegendClick when copy button is clicked', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const firstLegendItem = document.querySelector(
|
||||
'[data-legend-item-id="0"]',
|
||||
) as HTMLElement;
|
||||
const copyButton = within(firstLegendItem).getByTestId('legend-copy');
|
||||
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(onLegendClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { CSSProperties, useCallback } from 'react';
|
||||
import { CSSProperties, type MouseEvent, useCallback } from 'react';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import cx from 'classnames';
|
||||
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
|
||||
|
||||
import styles from './CopyButton.module.scss';
|
||||
import { useCopyButton } from './useCopyButton';
|
||||
|
||||
export interface CopyButtonProps {
|
||||
/** Text written to the clipboard on click. */
|
||||
@@ -30,11 +30,15 @@ function CopyButton({
|
||||
className,
|
||||
testId,
|
||||
}: CopyButtonProps): JSX.Element {
|
||||
const { copyToClipboard, isCopied } = useCopyToClipboard();
|
||||
const { copyToClipboard, isCopied } = useCopyButton();
|
||||
|
||||
const handleClick = useCallback((): void => {
|
||||
copyToClipboard(value);
|
||||
}, [copyToClipboard, value]);
|
||||
const handleClick = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>): void => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(value);
|
||||
},
|
||||
[copyToClipboard, value],
|
||||
);
|
||||
|
||||
const stackStyle: CSSProperties = { width: size, height: size };
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import CopyButton from '../CopyButton';
|
||||
|
||||
const mockCopy = jest.fn();
|
||||
|
||||
// Exercise the real useCopyButton — stub only react-use's underlying copy so the
|
||||
// click doesn't hit copy-to-clipboard's jsdom fallback (window.prompt).
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
|
||||
}));
|
||||
|
||||
describe('CopyButton', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
mockCopy.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('copies its value on click', async () => {
|
||||
render(<CopyButton value="hello" testId="copy" />);
|
||||
|
||||
await user.click(screen.getByTestId('copy'));
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('hello');
|
||||
});
|
||||
|
||||
it('does not trigger parent click handlers (stops propagation)', async () => {
|
||||
const onParentClick = jest.fn();
|
||||
render(
|
||||
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
||||
<div onClick={onParentClick}>
|
||||
<CopyButton value="x" testId="copy" />
|
||||
</div>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('copy'));
|
||||
|
||||
expect(onParentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the copied state after clicking and reverts after the reset window', async () => {
|
||||
const { container } = render(<CopyButton value="hello" testId="copy" />);
|
||||
const iconStack = container.querySelector('[data-copied]') as HTMLElement;
|
||||
|
||||
expect(iconStack).toHaveAttribute('data-copied', 'false');
|
||||
|
||||
await user.click(screen.getByTestId('copy'));
|
||||
expect(iconStack).toHaveAttribute('data-copied', 'true');
|
||||
|
||||
act(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
expect(iconStack).toHaveAttribute('data-copied', 'false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
/** How long (ms) CopyButton stays in the "copied" state before reverting. */
|
||||
export const COPIED_RESET_MS = 1000;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import { COPIED_RESET_MS } from './constants';
|
||||
|
||||
export interface UseCopyButtonReturn {
|
||||
copyToClipboard: (text: string) => void;
|
||||
isCopied: boolean;
|
||||
}
|
||||
|
||||
export function useCopyButton(): UseCopyButtonReturn {
|
||||
const [, copy] = useCopyToClipboard();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = useCallback(
|
||||
(text: string): void => {
|
||||
copy(text);
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setIsCopied(true);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
timeoutRef.current = null;
|
||||
}, COPIED_RESET_MS);
|
||||
},
|
||||
[copy],
|
||||
);
|
||||
|
||||
return {
|
||||
copyToClipboard,
|
||||
isCopied,
|
||||
};
|
||||
}
|
||||
@@ -434,6 +434,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
isFetchingHosts,
|
||||
isFetchingFeatureFlags,
|
||||
isFetchingOrgPreferences,
|
||||
isFetchingUserPreferences,
|
||||
userFetchError,
|
||||
activeLicenseFetchError,
|
||||
hostsFetchError,
|
||||
@@ -464,6 +465,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
isFetchingHosts,
|
||||
isFetchingFeatureFlags,
|
||||
isFetchingOrgPreferences,
|
||||
isFetchingUserPreferences,
|
||||
isFetchingUser,
|
||||
isLoggedIn,
|
||||
hostsData,
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface IAppContext {
|
||||
isFetchingHosts: boolean;
|
||||
isFetchingFeatureFlags: boolean;
|
||||
isFetchingOrgPreferences: boolean;
|
||||
isFetchingUserPreferences: boolean;
|
||||
userFetchError: unknown;
|
||||
activeLicenseFetchError: APIError | null;
|
||||
hostsFetchError: unknown;
|
||||
|
||||
@@ -242,6 +242,7 @@ export function getAppContextMock(
|
||||
userPreferences: [],
|
||||
updateUserPreferenceInContext: jest.fn(),
|
||||
isFetchingOrgPreferences: false,
|
||||
isFetchingUserPreferences: false,
|
||||
orgPreferencesFetchError: null,
|
||||
isLoggedIn: true,
|
||||
isPreflightLoading: false,
|
||||
|
||||
@@ -23,6 +23,11 @@ export type ComponentTypes =
|
||||
| 'add_panel_locked_dashboard'
|
||||
| 'manage_llm_pricing';
|
||||
|
||||
/**
|
||||
* @deprecated Before adding a new value here, check if what you want to add permission is supported by authz.
|
||||
* If so, read AuthZ Guidelines on how to add permission check via AuthZ.
|
||||
* If not, you can keep adding to this record.
|
||||
*/
|
||||
export const componentPermission: Record<ComponentTypes, ROLES[]> = {
|
||||
current_org_settings: ['ADMIN'],
|
||||
invite_members: ['ADMIN'],
|
||||
@@ -46,11 +51,16 @@ export const componentPermission: Record<ComponentTypes, ROLES[]> = {
|
||||
manage_llm_pricing: ['ADMIN'],
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated You can still add new permissions/routes here but be aware if this page/module supports authz.
|
||||
* If so, also implement the correct authz checks in the page itself, and here you can add ADMIN/EDITOR/VIEWER,
|
||||
* and also update/include the route at {@link routeWithInitialAuthZSupport}
|
||||
*/
|
||||
export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
HOME: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ALERTS_NEW: ['ADMIN', 'EDITOR'],
|
||||
ORG_SETTINGS: ['ADMIN'],
|
||||
MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SERVICE_MAP: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ALL_CHANNELS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
INGESTION_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
@@ -75,13 +85,15 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
NOT_FOUND: ['ADMIN', 'VIEWER', 'EDITOR', 'ANONYMOUS'],
|
||||
PASSWORD_RESET: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SERVICE_METRICS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SIGN_UP: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
TRACES_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
TRACE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
TRACE_DETAIL: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
TRACE_DETAIL_OLD: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
UN_AUTHORIZED: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
// Every role must be able to land here - a role missing from this list is
|
||||
// redirected to /un-authorized and then redirected off it again, looping.
|
||||
UN_AUTHORIZED: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS', 'AUTHOR'],
|
||||
USAGE_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
VERSION: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LOGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
@@ -95,12 +107,12 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
GET_STARTED_WITH_CLOUD: ['ADMIN', 'EDITOR'],
|
||||
WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ROLES_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
ROLE_CREATE: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
ROLE_DETAILS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
ROLE_EDIT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
ROLES_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ROLE_CREATE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ROLE_DETAILS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ROLE_EDIT: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
MEMBERS_SETTINGS: ['ADMIN'],
|
||||
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
BILLING: ['ADMIN'],
|
||||
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
@@ -141,3 +153,34 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
AI_OBSERVABILITY_OVERVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
AI_OBSERVABILITY_CONFIGURATION: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Any route that will start be supported under AuthZ should be added here.
|
||||
* This will help correctly identify when fallback to show the page
|
||||
* or just return unauthorized.
|
||||
*
|
||||
* This prevents us from adding `ANONYMOUS` on the `routePermission`
|
||||
*/
|
||||
export const routeWithInitialAuthZSupport = {
|
||||
MY_SETTINGS: true,
|
||||
SETTINGS: true,
|
||||
TRACES_EXPLORER: true,
|
||||
TRACE: true,
|
||||
TRACE_DETAIL: true,
|
||||
TRACE_DETAIL_OLD: true,
|
||||
LOGS: true,
|
||||
LOGS_EXPLORER: true,
|
||||
LIVE_LOGS: true,
|
||||
ROLES_SETTINGS: true,
|
||||
ROLE_CREATE: true,
|
||||
ROLE_DETAILS: true,
|
||||
ROLE_EDIT: true,
|
||||
SERVICE_ACCOUNTS_SETTINGS: true,
|
||||
SUPPORT: true,
|
||||
OLD_LOGS_EXPLORER: true,
|
||||
METRICS_EXPLORER: true,
|
||||
METRICS_EXPLORER_EXPLORER: true,
|
||||
METRICS_EXPLORER_VOLUME_CONTROL: true,
|
||||
METER_EXPLORER: true,
|
||||
METER: true,
|
||||
} as const satisfies Partial<Record<keyof typeof ROUTES, true>>;
|
||||
|
||||
@@ -143,6 +143,12 @@ export default defineConfig(({ mode }): UserConfig => {
|
||||
plugins,
|
||||
resolve: {
|
||||
alias: {
|
||||
// @grafana/data imports bare CJS `lodash`, whose UMD footer checks for an
|
||||
// AMD loader before assigning module.exports. Any third-party script that
|
||||
// defines window.define.amd first leaves the bundled namespace empty, so
|
||||
// every lodash method reached through it is undefined at runtime. lodash-es
|
||||
// is the same version, real ESM, and tree-shakes.
|
||||
lodash: 'lodash-es',
|
||||
'@': resolve(__dirname, './src'),
|
||||
utils: resolve(__dirname, './src/utils'),
|
||||
types: resolve(__dirname, './src/types'),
|
||||
|
||||
2
go.mod
2
go.mod
@@ -4,7 +4,7 @@ go 1.25.7
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.2
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.3
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/SigNoz/clickhouse-go-mock v0.14.0
|
||||
|
||||
4
go.sum
4
go.sum
@@ -66,8 +66,8 @@ dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.2 h1:KCxdmvuVawVF4yl0ZS33q7olgmLZwvZG5BjWHp6o414=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.2/go.mod h1:Qi3qvPTfZb/aFwI5V4WFOahgjsLJa4MzVijIAfwOhDw=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.3 h1:6iap8XGjuSjD3w7r1UNrg66ljBugcv2P39s4eo/ZLRw=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.5.3/go.mod h1:Qi3qvPTfZb/aFwI5V4WFOahgjsLJa4MzVijIAfwOhDw=
|
||||
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
|
||||
|
||||
@@ -14,8 +14,6 @@ var (
|
||||
FeatureEnableAIObservability = featuretypes.MustNewName("enable_ai_observability")
|
||||
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
|
||||
FeatureUseInfraMonitoringV2 = featuretypes.MustNewName("use_infra_monitoring_v2")
|
||||
|
||||
FeatureUsePrometheusClickhouseV2 = featuretypes.MustNewName("use_prometheus_clickhouse_v2")
|
||||
)
|
||||
|
||||
func MustNewRegistry() featuretypes.Registry {
|
||||
@@ -108,14 +106,6 @@ func MustNewRegistry() featuretypes.Registry {
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
&featuretypes.Feature{
|
||||
Name: FeatureUsePrometheusClickhouseV2,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
Stage: featuretypes.StageExperimental,
|
||||
Description: "Runs PromQL queries on the clickhousev2 provider alongside the served engine result and logs any difference; serving is unaffected.",
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#aecbfa;}.cls-2{fill:#669df6;}.cls-3{fill:#4285f4;}</style></defs><title>Icon_24px_ComputeEngine_Color</title><g data-name="Product Icons"><rect class="cls-1" x="9" y="9" width="6" height="6"/><rect class="cls-2" x="11" y="2" width="2" height="4"/><rect class="cls-2" x="7" y="2" width="2" height="4"/><rect class="cls-2" x="15" y="2" width="2" height="4"/><rect class="cls-3" x="11" y="18" width="2" height="4"/><rect class="cls-3" x="7" y="18" width="2" height="4"/><rect class="cls-3" x="15" y="18" width="2" height="4"/><rect class="cls-3" x="19" y="10" width="2" height="4" transform="translate(8 32) rotate(-90)"/><rect class="cls-3" x="19" y="14" width="2" height="4" transform="translate(4 36) rotate(-90)"/><rect class="cls-3" x="19" y="6" width="2" height="4" transform="translate(12 28) rotate(-90)"/><rect class="cls-2" x="3" y="10" width="2" height="4" transform="translate(-8 16) rotate(-90)"/><rect class="cls-2" x="3" y="14" width="2" height="4" transform="translate(-12 20) rotate(-90)"/><rect class="cls-2" x="3" y="6" width="2" height="4" transform="translate(-4 12) rotate(-90)"/><path class="cls-1" d="M5,5V19H19V5ZM17,17H7V7H17Z"/><polygon class="cls-2" points="9 15 15 15 12 12 9 15"/><polygon class="cls-3" points="12 12 15 15 15 9 12 12"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"id": "computeengine",
|
||||
"title": "GCP Compute Engine",
|
||||
"icon": "file://icon.svg",
|
||||
"overview": "file://overview.md",
|
||||
"supportedSignals": {
|
||||
"metrics": true,
|
||||
"logs": true
|
||||
},
|
||||
"dataCollected": {
|
||||
"metrics": [
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/uptime_total",
|
||||
"unit": "Seconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/cpu/utilization",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/guest/memory/bytes_used",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/guest/memory/percent_used",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "agent.googleapis.com/memory/percent_used",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/disk/average_io_latency",
|
||||
"unit": "Microseconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/disk/read_bytes_count",
|
||||
"unit": "Bytes",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/disk/write_bytes_count",
|
||||
"unit": "Bytes",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/disk/read_ops_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/disk/write_ops_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/disk/performance_status",
|
||||
"unit": "None",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/network/received_bytes_count",
|
||||
"unit": "Bytes",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/network/sent_bytes_count",
|
||||
"unit": "Bytes",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/network/received_packets_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/instance/network/sent_packets_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "compute.googleapis.com/firewall/dropped_packets_count",
|
||||
"unit": "Count",
|
||||
"type": "Sum",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"logs": []
|
||||
},
|
||||
"telemetryCollectionStrategy": {
|
||||
"gcp": {}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "overview",
|
||||
"title": "GCP Compute Engine Overview",
|
||||
"description": "Overview of GCP Compute Engine metrics",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
### Monitor GCP Compute Engine with SigNoz
|
||||
|
||||
Collect key GCP Compute Engine metrics and view them with an out of the box dashboard.
|
||||
@@ -5,14 +5,14 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func (m *module) Collect(ctx context.Context, _ valuer.UUID) (map[string]any, error) {
|
||||
stats := make(map[string]any)
|
||||
|
||||
metadataTable := fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName)
|
||||
metadataTable := fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.AttributesMetadataTableName)
|
||||
var (
|
||||
systemMetricCount uint64
|
||||
k8sMetricCount uint64
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -281,7 +281,7 @@ func (m *module) getPerGroupContainerStatusCounts(
|
||||
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, buildPageGroupsFilterExpr(pageGroups))
|
||||
|
||||
samplesStartMs, flooredEndMs, tsAdjustedStart, _, localTimeSeriesTable, distributedSamplesTable, _ := alignedMetricWindow(start, end)
|
||||
valueCol := telemetrymetrics.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
valueCol := metricstelemetryschema.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
|
||||
// Built once; identical across the two fps CTEs (buildFilterClause hits the
|
||||
// metadata store + parses the expression). AddWhereClause only reads it.
|
||||
@@ -310,7 +310,7 @@ func (m *module) getPerGroupContainerStatusCounts(
|
||||
)
|
||||
}
|
||||
stateFps.Select(stateFpsCols...)
|
||||
stateFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
stateFps.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTimeSeriesTable))
|
||||
stateFps.Where(
|
||||
stateFps.E("metric_name", containerStatusStateMetricName),
|
||||
stateFps.GE("unix_milli", tsAdjustedStart),
|
||||
@@ -342,7 +342,7 @@ func (m *module) getPerGroupContainerStatusCounts(
|
||||
containerState.Select(containerStateCols...)
|
||||
containerState.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN state_fps AS fps ON samples.fingerprint = fps.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
metricstelemetryschema.DBName, distributedSamplesTable,
|
||||
))
|
||||
containerState.Where(
|
||||
containerState.E("samples.metric_name", containerStatusStateMetricName),
|
||||
@@ -361,7 +361,7 @@ func (m *module) getPerGroupContainerStatusCounts(
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS container_name", reasonFps.Var(containerNameAttrKey)),
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS reason", reasonFps.Var(containerStatusReasonAttrKey)),
|
||||
)
|
||||
reasonFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
reasonFps.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTimeSeriesTable))
|
||||
reasonFps.Where(
|
||||
reasonFps.E("metric_name", containerStatusReasonMetricName),
|
||||
reasonFps.GE("unix_milli", tsAdjustedStart),
|
||||
@@ -391,7 +391,7 @@ func (m *module) getPerGroupContainerStatusCounts(
|
||||
)
|
||||
reasonInner.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN reason_fps AS fps ON samples.fingerprint = fps.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
metricstelemetryschema.DBName, distributedSamplesTable,
|
||||
))
|
||||
reasonInner.Where(
|
||||
reasonInner.E("samples.metric_name", containerStatusReasonMetricName),
|
||||
@@ -546,7 +546,7 @@ func (m *module) getPerGroupContainerRestartCounts(
|
||||
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, buildPageGroupsFilterExpr(pageGroups))
|
||||
|
||||
samplesStartMs, flooredEndMs, tsAdjustedStart, _, localTimeSeriesTable, distributedSamplesTable, _ := alignedMetricWindow(start, end)
|
||||
valueCol := telemetrymetrics.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
valueCol := metricstelemetryschema.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
|
||||
var (
|
||||
filterClause *sqlbuilder.WhereClause
|
||||
@@ -572,7 +572,7 @@ func (m *module) getPerGroupContainerRestartCounts(
|
||||
)
|
||||
}
|
||||
restartFps.Select(restartFpsCols...)
|
||||
restartFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
restartFps.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTimeSeriesTable))
|
||||
restartFps.Where(
|
||||
restartFps.E("metric_name", containerRestartsMetricName),
|
||||
restartFps.GE("unix_milli", tsAdjustedStart),
|
||||
@@ -602,7 +602,7 @@ func (m *module) getPerGroupContainerRestartCounts(
|
||||
containerRestarts.Select(containerRestartsCols...)
|
||||
containerRestarts.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN restart_fps AS fps ON samples.fingerprint = fps.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
metricstelemetryschema.DBName, distributedSamplesTable,
|
||||
))
|
||||
containerRestarts.Where(
|
||||
containerRestarts.E("samples.metric_name", containerRestartsMetricName),
|
||||
@@ -690,7 +690,7 @@ func (m *module) getPerGroupContainerReadyCounts(
|
||||
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, buildPageGroupsFilterExpr(pageGroups))
|
||||
|
||||
samplesStartMs, flooredEndMs, tsAdjustedStart, _, localTimeSeriesTable, distributedSamplesTable, _ := alignedMetricWindow(start, end)
|
||||
valueCol := telemetrymetrics.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
valueCol := metricstelemetryschema.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
|
||||
var (
|
||||
filterClause *sqlbuilder.WhereClause
|
||||
@@ -716,7 +716,7 @@ func (m *module) getPerGroupContainerReadyCounts(
|
||||
)
|
||||
}
|
||||
readyFps.Select(readyFpsCols...)
|
||||
readyFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
readyFps.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTimeSeriesTable))
|
||||
readyFps.Where(
|
||||
readyFps.E("metric_name", containerReadyMetricName),
|
||||
readyFps.GE("unix_milli", tsAdjustedStart),
|
||||
@@ -746,7 +746,7 @@ func (m *module) getPerGroupContainerReadyCounts(
|
||||
containerReady.Select(containerReadyCols...)
|
||||
containerReady.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN ready_fps AS fps ON samples.fingerprint = fps.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
metricstelemetryschema.DBName, distributedSamplesTable,
|
||||
))
|
||||
containerReady.Where(
|
||||
containerReady.E("samples.metric_name", containerReadyMetricName),
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
@@ -344,11 +344,11 @@ func alignedMetricWindow(startMs, endMs int64) (
|
||||
flooredEndMs = flooredEndMs - (flooredEndMs % (adjustStep * 1000))
|
||||
}
|
||||
|
||||
tsAdjustedStartMs, _, distributedTSTable, localTSTable := telemetrymetrics.WhichTSTableToUse(
|
||||
tsAdjustedStartMs, _, distributedTSTable, localTSTable := metricstelemetryschema.WhichTSTableToUse(
|
||||
samplesAdjustedStartMs, flooredEndMs, false, nil,
|
||||
)
|
||||
|
||||
distributedSamplesTable, localSamplesTable := telemetrymetrics.WhichSamplesTableToUse(
|
||||
distributedSamplesTable, localSamplesTable := metricstelemetryschema.WhichSamplesTableToUse(
|
||||
samplesAdjustedStartMs, flooredEndMs,
|
||||
metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, false, nil,
|
||||
)
|
||||
@@ -362,7 +362,7 @@ func alignedMetricWindow(startMs, endMs int64) (
|
||||
func (m *module) buildSamplesTblFingerprintSubQuery(metricNames []string, samplesTable string, flooredStart, flooredEnd uint64) *sqlbuilder.SelectBuilder {
|
||||
fpSB := sqlbuilder.NewSelectBuilder()
|
||||
fpSB.Select("DISTINCT fingerprint")
|
||||
fpSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, samplesTable))
|
||||
fpSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, samplesTable))
|
||||
fpSB.Where(
|
||||
fpSB.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
fpSB.GE("unix_milli", flooredStart),
|
||||
@@ -376,7 +376,7 @@ func (m *module) buildSamplesTblFingerprintSubQuery(metricNames []string, sample
|
||||
func (m *module) buildReducedSamplesTblFingerprintSubQuery(metricNames []string, flooredStart, flooredEnd uint64) *sqlbuilder.SelectBuilder {
|
||||
lastSB := sqlbuilder.NewSelectBuilder()
|
||||
lastSB.Select("reduced_fingerprint")
|
||||
lastSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4ReducedLastTableName))
|
||||
lastSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4ReducedLastTableName))
|
||||
lastSB.Where(
|
||||
lastSB.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
lastSB.GE("unix_milli", flooredStart),
|
||||
@@ -385,7 +385,7 @@ func (m *module) buildReducedSamplesTblFingerprintSubQuery(metricNames []string,
|
||||
|
||||
sumSB := sqlbuilder.NewSelectBuilder()
|
||||
sumSB.Select("reduced_fingerprint")
|
||||
sumSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4ReducedSumTableName))
|
||||
sumSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4ReducedSumTableName))
|
||||
sumSB.Where(
|
||||
sumSB.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
sumSB.GE("unix_milli", flooredStart),
|
||||
@@ -478,7 +478,7 @@ func (m *module) getEarliestMetricTime(ctx context.Context, metricNames []string
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("min(first_reported_unix_milli) AS min_first_reported")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.AttributesMetadataTableName))
|
||||
sb.Where(sb.In("metric_name", sqlbuilder.List(metricNames)))
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
@@ -504,7 +504,7 @@ func (m *module) getMetricsExistence(ctx context.Context, metricNames []string)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("metric_name", "count(*) AS cnt")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.AttributesMetadataTableName))
|
||||
sb.Where(sb.In("metric_name", sqlbuilder.List(metricNames)))
|
||||
sb.GroupBy("metric_name")
|
||||
|
||||
@@ -549,7 +549,7 @@ func (m *module) getAttributesExistence(ctx context.Context, metricNames, attrNa
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("attr_name", "count(*) AS cnt")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.AttributesMetadataTableName))
|
||||
sb.Where(
|
||||
sb.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
sb.In("attr_name", sqlbuilder.List(attrNames)),
|
||||
@@ -656,7 +656,7 @@ func (m *module) getMetadata(
|
||||
|
||||
rawSrc := sqlbuilder.NewSelectBuilder()
|
||||
rawSrc.Select("labels", "unix_milli")
|
||||
rawSrc.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTableName))
|
||||
rawSrc.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTableName))
|
||||
rawSrc.Where(
|
||||
rawSrc.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
rawSrc.GE("unix_milli", tsAdjustedStartMs),
|
||||
@@ -669,7 +669,7 @@ func (m *module) getMetadata(
|
||||
|
||||
reducedSrc := sqlbuilder.NewSelectBuilder()
|
||||
reducedSrc.Select("labels", "unix_milli")
|
||||
reducedSrc.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
|
||||
reducedSrc.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedTableName))
|
||||
reducedSrc.Where(
|
||||
reducedSrc.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
reducedSrc.GE("unix_milli", tsAdjustedStartMs),
|
||||
@@ -683,7 +683,7 @@ func (m *module) getMetadata(
|
||||
// Inner query reads over the union of raw + reduced series.
|
||||
innerSB.From(innerSB.BuilderAs(sqlbuilder.UnionAll(rawSrc, reducedSrc), "series"))
|
||||
} else {
|
||||
innerSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTableName))
|
||||
innerSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTableName))
|
||||
innerSB.Where(
|
||||
innerSB.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
innerSB.GE("unix_milli", tsAdjustedStartMs),
|
||||
@@ -870,7 +870,7 @@ func (m *module) getPerGroupDistinctCounts(
|
||||
|
||||
rawSrc := sqlbuilder.NewSelectBuilder()
|
||||
rawSrc.Select("labels")
|
||||
rawSrc.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTimeSeriesTbl))
|
||||
rawSrc.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTimeSeriesTbl))
|
||||
rawSrc.Where(
|
||||
rawSrc.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
rawSrc.GE("unix_milli", tsAdjustedStartMs),
|
||||
@@ -883,7 +883,7 @@ func (m *module) getPerGroupDistinctCounts(
|
||||
|
||||
reducedSrc := sqlbuilder.NewSelectBuilder()
|
||||
reducedSrc.Select("labels")
|
||||
reducedSrc.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
|
||||
reducedSrc.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedTableName))
|
||||
reducedSrc.Where(
|
||||
reducedSrc.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
reducedSrc.GE("unix_milli", tsAdjustedStartMs),
|
||||
@@ -896,7 +896,7 @@ func (m *module) getPerGroupDistinctCounts(
|
||||
|
||||
sb.From(sb.BuilderAs(sqlbuilder.UnionAll(rawSrc, reducedSrc), "series"))
|
||||
} else {
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTimeSeriesTbl))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTimeSeriesTbl))
|
||||
sb.Where(
|
||||
sb.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
sb.GE("unix_milli", tsAdjustedStartMs),
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
@@ -72,7 +72,7 @@ func (m *module) getPerGroupHostStatusCounts(
|
||||
|
||||
rawSrc := sqlbuilder.NewSelectBuilder()
|
||||
rawSrc.Select("labels")
|
||||
rawSrc.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTimeSeriesTableName))
|
||||
rawSrc.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTimeSeriesTableName))
|
||||
rawSrc.Where(
|
||||
rawSrc.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
rawSrc.GE("unix_milli", tsAdjustedStartMs),
|
||||
@@ -85,7 +85,7 @@ func (m *module) getPerGroupHostStatusCounts(
|
||||
|
||||
reducedSrc := sqlbuilder.NewSelectBuilder()
|
||||
reducedSrc.Select("labels")
|
||||
reducedSrc.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
|
||||
reducedSrc.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedTableName))
|
||||
reducedSrc.Where(
|
||||
reducedSrc.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
reducedSrc.GE("unix_milli", tsAdjustedStartMs),
|
||||
@@ -101,7 +101,7 @@ func (m *module) getPerGroupHostStatusCounts(
|
||||
|
||||
fpSB := m.buildSamplesTblFingerprintSubQuery(metricNames, localSamplesTable, samplesStartMs, flooredEndMs)
|
||||
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTimeSeriesTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTimeSeriesTableName))
|
||||
sb.Where(
|
||||
sb.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
sb.GE("unix_milli", tsAdjustedStartMs),
|
||||
@@ -357,7 +357,7 @@ func (m *module) getActiveHostsQuery(metricNames []string, hostNameAttr string,
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Distinct()
|
||||
sb.Select("attr_string_value")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.AttributesMetadataTableName))
|
||||
sb.Where(
|
||||
sb.In("metric_name", sqlbuilder.List(metricNames)),
|
||||
sb.E("attr_name", hostNameAttr),
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
@@ -40,8 +40,8 @@ func NewModule(
|
||||
providerSettings factory.ProviderSettings,
|
||||
cfg inframonitoring.Config,
|
||||
) inframonitoring.Module {
|
||||
fieldMapper := telemetrymetrics.NewFieldMapper()
|
||||
condBuilder := telemetrymetrics.NewConditionBuilder(fieldMapper)
|
||||
fieldMapper := metricstelemetryschema.NewFieldMapper()
|
||||
condBuilder := metricstelemetryschema.NewConditionBuilder(fieldMapper)
|
||||
return &module{
|
||||
telemetryStore: telemetryStore,
|
||||
telemetryMetadataStore: telemetryMetadataStore,
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -186,7 +186,7 @@ func (m *module) getPerGroupNodeConditionCounts(
|
||||
|
||||
// Step-floor bounds + resolve tables in one shot to match QB v5 querier.
|
||||
samplesStartMs, flooredEndMs, tsAdjustedStartMs, _, localTimeSeriesTable, distributedSamplesTable, _ := alignedMetricWindow(start, end)
|
||||
valueCol := telemetrymetrics.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
valueCol := metricstelemetryschema.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
|
||||
// ----- timeSeriesFPs -----
|
||||
timeSeriesFPs := sqlbuilder.NewSelectBuilder()
|
||||
@@ -200,7 +200,7 @@ func (m *module) getPerGroupNodeConditionCounts(
|
||||
)
|
||||
}
|
||||
timeSeriesFPs.Select(timeSeriesFPsSelectCols...)
|
||||
timeSeriesFPs.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
timeSeriesFPs.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTimeSeriesTable))
|
||||
timeSeriesFPs.Where(
|
||||
timeSeriesFPs.E("metric_name", nodeConditionMetricName),
|
||||
timeSeriesFPs.GE("unix_milli", tsAdjustedStartMs),
|
||||
@@ -237,7 +237,7 @@ func (m *module) getPerGroupNodeConditionCounts(
|
||||
latestConditionPerNode.Select(latestConditionPerNodeSelectCols...)
|
||||
latestConditionPerNode.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN time_series_fps AS tsfp ON samples.fingerprint = tsfp.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
metricstelemetryschema.DBName, distributedSamplesTable,
|
||||
))
|
||||
latestConditionPerNode.Where(
|
||||
latestConditionPerNode.E("samples.metric_name", nodeConditionMetricName),
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -284,7 +284,7 @@ func (m *module) getPerGroupPodStatusCounts(
|
||||
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, buildPageGroupsFilterExpr(pageGroups))
|
||||
|
||||
samplesStartMs, flooredEndMs, tsAdjustedStart, _, localTimeSeriesTable, distributedSamplesTable, _ := alignedMetricWindow(start, end)
|
||||
valueCol := telemetrymetrics.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
valueCol := metricstelemetryschema.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
|
||||
// Build the merged filter clause once; it's identical across the three fps
|
||||
// CTEs, and buildFilterClause hits the metadata store + parses the
|
||||
@@ -313,7 +313,7 @@ func (m *module) getPerGroupPodStatusCounts(
|
||||
)
|
||||
}
|
||||
phaseFps.Select(phaseFpsCols...)
|
||||
phaseFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
phaseFps.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTimeSeriesTable))
|
||||
phaseFps.Where(
|
||||
phaseFps.E("metric_name", podPhaseMetricName),
|
||||
phaseFps.GE("unix_milli", tsAdjustedStart),
|
||||
@@ -340,7 +340,7 @@ func (m *module) getPerGroupPodStatusCounts(
|
||||
phasePerPod.Select(phasePerPodCols...)
|
||||
phasePerPod.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN phase_fps AS fps ON samples.fingerprint = fps.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
metricstelemetryschema.DBName, distributedSamplesTable,
|
||||
))
|
||||
phasePerPod.Where(
|
||||
phasePerPod.E("samples.metric_name", podPhaseMetricName),
|
||||
@@ -357,7 +357,7 @@ func (m *module) getPerGroupPodStatusCounts(
|
||||
"fingerprint",
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS pod_uid", podReasonFps.Var(podUIDAttrKey)),
|
||||
)
|
||||
podReasonFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
podReasonFps.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTimeSeriesTable))
|
||||
podReasonFps.Where(
|
||||
podReasonFps.E("metric_name", podStatusReasonMetricName),
|
||||
podReasonFps.GE("unix_milli", tsAdjustedStart),
|
||||
@@ -377,7 +377,7 @@ func (m *module) getPerGroupPodStatusCounts(
|
||||
)
|
||||
podReasonPerPod.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN pod_reason_fps AS fps ON samples.fingerprint = fps.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
metricstelemetryschema.DBName, distributedSamplesTable,
|
||||
))
|
||||
podReasonPerPod.Where(
|
||||
podReasonPerPod.E("samples.metric_name", podStatusReasonMetricName),
|
||||
@@ -396,7 +396,7 @@ func (m *module) getPerGroupPodStatusCounts(
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS container_name", containerReasonFps.Var(containerNameAttrKey)),
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS reason", containerReasonFps.Var(containerStatusReasonAttrKey)),
|
||||
)
|
||||
containerReasonFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
containerReasonFps.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTimeSeriesTable))
|
||||
containerReasonFps.Where(
|
||||
containerReasonFps.E("metric_name", containerStatusReasonMetricName),
|
||||
containerReasonFps.GE("unix_milli", tsAdjustedStart),
|
||||
@@ -432,7 +432,7 @@ func (m *module) getPerGroupPodStatusCounts(
|
||||
)
|
||||
containerInner.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN container_reason_fps AS fps ON samples.fingerprint = fps.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
metricstelemetryschema.DBName, distributedSamplesTable,
|
||||
))
|
||||
containerInner.Where(
|
||||
containerInner.E("samples.metric_name", containerStatusReasonMetricName),
|
||||
@@ -611,7 +611,7 @@ func (m *module) getPerGroupPodRestartCounts(
|
||||
mergedFilterExpr := mergeFilterExpressions(userFilterExpr, buildPageGroupsFilterExpr(pageGroups))
|
||||
|
||||
samplesStartMs, flooredEndMs, tsAdjustedStart, _, localTimeSeriesTable, distributedSamplesTable, _ := alignedMetricWindow(start, end)
|
||||
valueCol := telemetrymetrics.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
valueCol := metricstelemetryschema.ValueColumnForSamplesTable(distributedSamplesTable)
|
||||
|
||||
var (
|
||||
filterClause *sqlbuilder.WhereClause
|
||||
@@ -637,7 +637,7 @@ func (m *module) getPerGroupPodRestartCounts(
|
||||
)
|
||||
}
|
||||
restartFps.Select(restartFpsCols...)
|
||||
restartFps.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
restartFps.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTimeSeriesTable))
|
||||
restartFps.Where(
|
||||
restartFps.E("metric_name", containerRestartsMetricName),
|
||||
restartFps.GE("unix_milli", tsAdjustedStart),
|
||||
@@ -667,7 +667,7 @@ func (m *module) getPerGroupPodRestartCounts(
|
||||
containerRestarts.Select(containerRestartsCols...)
|
||||
containerRestarts.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN restart_fps AS fps ON samples.fingerprint = fps.fingerprint",
|
||||
telemetrymetrics.DBName, distributedSamplesTable,
|
||||
metricstelemetryschema.DBName, distributedSamplesTable,
|
||||
))
|
||||
containerRestarts.Where(
|
||||
containerRestarts.E("samples.metric_name", containerRestartsMetricName),
|
||||
|
||||
@@ -213,18 +213,18 @@ func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", llmpricingruletypes.GenAIRequestModel)},
|
||||
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", telemetrytypes.GenAIRequestModel)},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{Expression: "count()", Alias: "spanCount"},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: llmpricingruletypes.GenAIRequestModel,
|
||||
Name: telemetrytypes.GenAIRequestModel,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: llmpricingruletypes.GenAIProviderName,
|
||||
Name: telemetrytypes.GenAIProviderName,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
@@ -254,9 +254,9 @@ func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]
|
||||
switch c.Type {
|
||||
case qbtypes.ColumnTypeGroup:
|
||||
switch c.Name {
|
||||
case llmpricingruletypes.GenAIRequestModel:
|
||||
case telemetrytypes.GenAIRequestModel:
|
||||
modelIdx = i
|
||||
case llmpricingruletypes.GenAIProviderName:
|
||||
case telemetrytypes.GenAIProviderName:
|
||||
providerIdx = i
|
||||
}
|
||||
case qbtypes.ColumnTypeAggregation:
|
||||
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metertelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
@@ -49,8 +49,8 @@ type module struct {
|
||||
|
||||
// NewModule constructs the metrics module with the provided dependencies.
|
||||
func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetrytypes.MetadataStore, cache cache.Cache, ruleStore ruletypes.RuleStore, dashboardModule dashboard.Module, fl flagger.Flagger, providerSettings factory.ProviderSettings, cfg metricsexplorer.Config) metricsexplorer.Module {
|
||||
fieldMapper := telemetrymetrics.NewFieldMapper()
|
||||
condBuilder := telemetrymetrics.NewConditionBuilder(fieldMapper)
|
||||
fieldMapper := metricstelemetryschema.NewFieldMapper()
|
||||
condBuilder := metricstelemetryschema.NewConditionBuilder(fieldMapper)
|
||||
return &module{
|
||||
telemetryStore: ts,
|
||||
fieldMapper: fieldMapper,
|
||||
@@ -89,7 +89,7 @@ func (m *module) listMeterMetrics(ctx context.Context, params *metricsexplorerty
|
||||
"argMax(temporality, unix_milli) AS temporality",
|
||||
"argMax(is_monotonic, unix_milli) AS is_monotonic",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymeter.DBName, telemetrymeter.SamplesTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metertelemetryschema.DBName, metertelemetryschema.SamplesTableName))
|
||||
|
||||
if params.Start != nil && params.End != nil {
|
||||
sb.Where(sb.Between("unix_milli", *params.Start, *params.End))
|
||||
@@ -160,11 +160,11 @@ func (m *module) listMetrics(ctx context.Context, orgID valuer.UUID, params *met
|
||||
hasRange := params.Start != nil && params.End != nil
|
||||
if hasRange {
|
||||
var distributedTsTable string
|
||||
rangeStart, rangeEnd, distributedTsTable, _ = telemetrymetrics.WhichTSTableToUse(uint64(*params.Start), uint64(*params.End), false, nil)
|
||||
rawSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTsTable))
|
||||
rangeStart, rangeEnd, distributedTsTable, _ = metricstelemetryschema.WhichTSTableToUse(uint64(*params.Start), uint64(*params.End), false, nil)
|
||||
rawSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTsTable))
|
||||
rawSB.Where(rawSB.Between("unix_milli", rangeStart, rangeEnd))
|
||||
} else {
|
||||
rawSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV41weekTableName))
|
||||
rawSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV41weekTableName))
|
||||
}
|
||||
|
||||
applyListFilters(rawSB)
|
||||
@@ -174,7 +174,7 @@ func (m *module) listMetrics(ctx context.Context, orgID valuer.UUID, params *met
|
||||
if reductionEnabled {
|
||||
reducedSB := sqlbuilder.NewSelectBuilder()
|
||||
reducedSB.Select("DISTINCT metric_name")
|
||||
reducedSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
|
||||
reducedSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedTableName))
|
||||
if hasRange {
|
||||
reducedSB.Where(reducedSB.Between("unix_milli", rangeStart, rangeEnd))
|
||||
}
|
||||
@@ -512,7 +512,7 @@ func (m *module) CheckMetricExists(ctx context.Context, orgID valuer.UUID, metri
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("count(*) > 0 as metricExists")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.AttributesMetadataTableName))
|
||||
sb.Where(sb.E("metric_name", metricName))
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
@@ -534,7 +534,7 @@ func (m *module) HasNonSigNozMetrics(ctx context.Context) (bool, error) {
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("count(*) > 0")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV41weekTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV41weekTableName))
|
||||
sb.Where("metric_name NOT LIKE 'signoz_%'")
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
@@ -570,10 +570,10 @@ func (m *module) InspectMetrics(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tsStart, _, tsTable, _ := telemetrymetrics.WhichTSTableToUse(start, end, false, nil)
|
||||
tsStart, _, tsTable, _ := metricstelemetryschema.WhichTSTableToUse(start, end, false, nil)
|
||||
tsSb := sqlbuilder.NewSelectBuilder()
|
||||
tsSb.Select("fingerprint", "labels")
|
||||
tsSb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, tsTable))
|
||||
tsSb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, tsTable))
|
||||
tsSb.Where(
|
||||
tsSb.E("metric_name", req.MetricName),
|
||||
tsSb.GE("unix_milli", tsStart),
|
||||
@@ -625,7 +625,7 @@ func (m *module) InspectMetrics(
|
||||
|
||||
samplesSb := sqlbuilder.NewSelectBuilder()
|
||||
samplesSb.Select("fingerprint", "unix_milli", "value")
|
||||
samplesSb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4TableName))
|
||||
samplesSb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4TableName))
|
||||
samplesSb.Where(
|
||||
samplesSb.In("fingerprint", fingerprints...),
|
||||
samplesSb.E("metric_name", req.MetricName),
|
||||
@@ -717,7 +717,7 @@ func (m *module) fetchUpdatedMetadata(ctx context.Context, orgID valuer.UUID, me
|
||||
"argMax(temporality, created_at) AS temporality",
|
||||
"argMax(is_monotonic, created_at) AS is_monotonic",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.UpdatedMetadataTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.UpdatedMetadataTableName))
|
||||
sb.Where(sb.In("metric_name", args...))
|
||||
sb.GroupBy("metric_name")
|
||||
|
||||
@@ -777,7 +777,7 @@ func (m *module) fetchTimeseriesMetadata(ctx context.Context, orgID valuer.UUID,
|
||||
"anyLast(temporality) AS temporality",
|
||||
"argMax(is_monotonic, unix_milli) AS is_monotonic",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4TableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4TableName))
|
||||
sb.Where(sb.In("metric_name", args...))
|
||||
sb.GroupBy("metric_name")
|
||||
|
||||
@@ -891,7 +891,7 @@ func (m *module) checkForLabelInMetric(ctx context.Context, metricName string, l
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("count(*) > 0 AS has_label")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.AttributesMetadataTableName))
|
||||
sb.Where(sb.E("metric_name", metricName))
|
||||
sb.Where(sb.E("attr_name", label))
|
||||
sb.Limit(1)
|
||||
@@ -914,7 +914,7 @@ func (m *module) insertMetricsMetadata(ctx context.Context, orgID valuer.UUID, r
|
||||
createdAt := time.Now().UnixMilli()
|
||||
|
||||
ib := sqlbuilder.NewInsertBuilder()
|
||||
ib.InsertInto(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.UpdatedMetadataTableName))
|
||||
ib.InsertInto(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.UpdatedMetadataTableName))
|
||||
ib.Cols("metric_name", "temporality", "is_monotonic", "type", "description", "unit", "created_at")
|
||||
ib.Values(
|
||||
req.MetricName,
|
||||
@@ -1016,9 +1016,9 @@ func (m *module) fetchMetricsStatsWithSamples(
|
||||
}
|
||||
}
|
||||
|
||||
start, end, distributedTsTable, localTsTable := telemetrymetrics.WhichTSTableToUse(uint64(req.Start), uint64(req.End), false, nil)
|
||||
distributedSamplesTable, _ := telemetrymetrics.WhichSamplesTableToUse(uint64(req.Start), uint64(req.End), metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, false, nil)
|
||||
countExp := telemetrymetrics.CountExpressionForSamplesTable(distributedSamplesTable)
|
||||
start, end, distributedTsTable, localTsTable := metricstelemetryschema.WhichTSTableToUse(uint64(req.Start), uint64(req.End), false, nil)
|
||||
distributedSamplesTable, _ := metricstelemetryschema.WhichSamplesTableToUse(uint64(req.Start), uint64(req.End), metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, false, nil)
|
||||
countExp := metricstelemetryschema.CountExpressionForSamplesTable(distributedSamplesTable)
|
||||
|
||||
// Timeseries counts per metric
|
||||
tsSB := sqlbuilder.NewSelectBuilder()
|
||||
@@ -1026,7 +1026,7 @@ func (m *module) fetchMetricsStatsWithSamples(
|
||||
"metric_name",
|
||||
"uniq(fingerprint) AS timeseries",
|
||||
)
|
||||
tsSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTsTable))
|
||||
tsSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTsTable))
|
||||
tsSB.Where(tsSB.Between("unix_milli", start, end))
|
||||
tsSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
if filterWhereClause != nil {
|
||||
@@ -1040,7 +1040,7 @@ func (m *module) fetchMetricsStatsWithSamples(
|
||||
"metric_name",
|
||||
fmt.Sprintf("%s AS samples", countExp),
|
||||
)
|
||||
samplesSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedSamplesTable))
|
||||
samplesSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedSamplesTable))
|
||||
samplesSB.Where(samplesSB.Between("unix_milli", req.Start, req.End))
|
||||
samplesSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
|
||||
@@ -1053,7 +1053,7 @@ func (m *module) fetchMetricsStatsWithSamples(
|
||||
if filterWhereClause != nil {
|
||||
fingerprintSB := sqlbuilder.NewSelectBuilder()
|
||||
fingerprintSB.Select("fingerprint")
|
||||
fingerprintSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTsTable))
|
||||
fingerprintSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTsTable))
|
||||
fingerprintSB.Where(fingerprintSB.Between("unix_milli", start, end))
|
||||
fingerprintSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
fingerprintSB.AddWhereClause(sqlbuilder.CopyWhereClause(filterWhereClause))
|
||||
@@ -1064,7 +1064,7 @@ func (m *module) fetchMetricsStatsWithSamples(
|
||||
} else {
|
||||
metricNamesSB := sqlbuilder.NewSelectBuilder()
|
||||
metricNamesSB.Select("DISTINCT metric_name")
|
||||
metricNamesSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTsTable))
|
||||
metricNamesSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTsTable))
|
||||
metricNamesSB.Where(metricNamesSB.Between("unix_milli", start, end))
|
||||
metricNamesSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
|
||||
@@ -1090,18 +1090,18 @@ func (m *module) fetchMetricsStatsWithSamples(
|
||||
// the data lands either of the _reduced_last/_reduced_sum tables
|
||||
reducedLastSB := sqlbuilder.NewSelectBuilder()
|
||||
reducedLastSB.Select("metric_name", "uniq(reduced_fingerprint, unix_milli) AS cnt")
|
||||
reducedLastSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4ReducedLastTableName))
|
||||
reducedLastSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4ReducedLastTableName))
|
||||
reducedLastSB.Where(reducedLastSB.Between("unix_milli", req.Start, req.End))
|
||||
reducedLastSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
|
||||
reducedSumSB := sqlbuilder.NewSelectBuilder()
|
||||
reducedSumSB.Select("metric_name", "uniq(reduced_fingerprint, unix_milli) AS cnt")
|
||||
reducedSumSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4ReducedSumTableName))
|
||||
reducedSumSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4ReducedSumTableName))
|
||||
reducedSumSB.Where(reducedSumSB.Between("unix_milli", req.Start, req.End))
|
||||
reducedSumSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
|
||||
// separate query for reduced series counts
|
||||
reducedTsSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
|
||||
reducedTsSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedTableName))
|
||||
reducedTsSB.Where(reducedTsSB.Between("unix_milli", start, end))
|
||||
reducedTsSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
reducedTsSB.GroupBy("metric_name")
|
||||
@@ -1112,7 +1112,7 @@ func (m *module) fetchMetricsStatsWithSamples(
|
||||
// samples uses a separate cte with local table
|
||||
reducedFpSB := sqlbuilder.NewSelectBuilder()
|
||||
reducedFpSB.Select("fingerprint")
|
||||
reducedFpSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedLocalTableName))
|
||||
reducedFpSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedLocalTableName))
|
||||
reducedFpSB.Where(reducedFpSB.Between("unix_milli", start, end))
|
||||
reducedFpSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
reducedFpSB.AddWhereClause(sqlbuilder.CopyWhereClause(filterWhereClause))
|
||||
@@ -1220,11 +1220,11 @@ func (m *module) computeTimeseriesTreemap(ctx context.Context, orgID valuer.UUID
|
||||
}
|
||||
}
|
||||
|
||||
start, end, distributedTsTable, _ := telemetrymetrics.WhichTSTableToUse(uint64(req.Start), uint64(req.End), false, nil)
|
||||
start, end, distributedTsTable, _ := metricstelemetryschema.WhichTSTableToUse(uint64(req.Start), uint64(req.End), false, nil)
|
||||
|
||||
totalTSBuilder := sqlbuilder.NewSelectBuilder()
|
||||
totalTSBuilder.Select("uniq(fingerprint) AS total_time_series")
|
||||
totalTSBuilder.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTsTable))
|
||||
totalTSBuilder.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTsTable))
|
||||
totalTSBuilder.Where(totalTSBuilder.Between("unix_milli", start, end))
|
||||
|
||||
metricsSB := sqlbuilder.NewSelectBuilder()
|
||||
@@ -1232,7 +1232,7 @@ func (m *module) computeTimeseriesTreemap(ctx context.Context, orgID valuer.UUID
|
||||
"metric_name",
|
||||
"uniq(fingerprint) AS total_value",
|
||||
)
|
||||
metricsSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTsTable))
|
||||
metricsSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTsTable))
|
||||
metricsSB.Where(metricsSB.Between("unix_milli", start, end))
|
||||
metricsSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
if filterWhereClause != nil {
|
||||
@@ -1244,7 +1244,7 @@ func (m *module) computeTimeseriesTreemap(ctx context.Context, orgID valuer.UUID
|
||||
if reductionEnabled {
|
||||
reducedTotalTSBuilder := sqlbuilder.NewSelectBuilder()
|
||||
reducedTotalTSBuilder.Select("uniq(fingerprint) AS total_time_series")
|
||||
reducedTotalTSBuilder.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
|
||||
reducedTotalTSBuilder.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedTableName))
|
||||
reducedTotalTSBuilder.Where(reducedTotalTSBuilder.Between("unix_milli", start, end))
|
||||
|
||||
reducedMetricsSB := sqlbuilder.NewSelectBuilder()
|
||||
@@ -1252,7 +1252,7 @@ func (m *module) computeTimeseriesTreemap(ctx context.Context, orgID valuer.UUID
|
||||
"metric_name",
|
||||
"uniq(fingerprint) AS total_value",
|
||||
)
|
||||
reducedMetricsSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
|
||||
reducedMetricsSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedTableName))
|
||||
reducedMetricsSB.Where(reducedMetricsSB.Between("unix_milli", start, end))
|
||||
reducedMetricsSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
if filterWhereClause != nil {
|
||||
@@ -1338,15 +1338,15 @@ func (m *module) computeSamplesTreemap(ctx context.Context, orgID valuer.UUID, r
|
||||
}
|
||||
}
|
||||
|
||||
start, end, distributedTsTable, localTsTable := telemetrymetrics.WhichTSTableToUse(uint64(req.Start), uint64(req.End), false, nil)
|
||||
distributedSamplesTable, _ := telemetrymetrics.WhichSamplesTableToUse(uint64(req.Start), uint64(req.End), metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, false, nil)
|
||||
countExp := telemetrymetrics.CountExpressionForSamplesTable(distributedSamplesTable)
|
||||
start, end, distributedTsTable, localTsTable := metricstelemetryschema.WhichTSTableToUse(uint64(req.Start), uint64(req.End), false, nil)
|
||||
distributedSamplesTable, _ := metricstelemetryschema.WhichSamplesTableToUse(uint64(req.Start), uint64(req.End), metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, false, nil)
|
||||
countExp := metricstelemetryschema.CountExpressionForSamplesTable(distributedSamplesTable)
|
||||
|
||||
candidateLimit := req.Limit + 50
|
||||
|
||||
metricCandidatesSB := sqlbuilder.NewSelectBuilder()
|
||||
metricCandidatesSB.Select("metric_name")
|
||||
metricCandidatesSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTsTable))
|
||||
metricCandidatesSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedTsTable))
|
||||
metricCandidatesSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
metricCandidatesSB.Where(metricCandidatesSB.Between("unix_milli", start, end))
|
||||
if filterWhereClause != nil {
|
||||
@@ -1364,7 +1364,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, orgID valuer.UUID, r
|
||||
if reductionEnabled {
|
||||
reducedCandidatesSB = sqlbuilder.NewSelectBuilder()
|
||||
reducedCandidatesSB.Select("metric_name")
|
||||
reducedCandidatesSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedTableName))
|
||||
reducedCandidatesSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedTableName))
|
||||
reducedCandidatesSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
reducedCandidatesSB.Where(reducedCandidatesSB.Between("unix_milli", start, end))
|
||||
if filterWhereClause != nil {
|
||||
@@ -1379,7 +1379,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, orgID valuer.UUID, r
|
||||
|
||||
totalSamplesSB := sqlbuilder.NewSelectBuilder()
|
||||
totalSamplesSB.Select(fmt.Sprintf("%s AS total_samples", countExp))
|
||||
totalSamplesSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedSamplesTable))
|
||||
totalSamplesSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedSamplesTable))
|
||||
totalSamplesSB.Where(totalSamplesSB.Between("unix_milli", req.Start, req.End))
|
||||
|
||||
sampleCountsSB := sqlbuilder.NewSelectBuilder()
|
||||
@@ -1387,7 +1387,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, orgID valuer.UUID, r
|
||||
"metric_name",
|
||||
fmt.Sprintf("%s AS samples", countExp),
|
||||
)
|
||||
sampleCountsSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedSamplesTable))
|
||||
sampleCountsSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, distributedSamplesTable))
|
||||
sampleCountsSB.Where(sampleCountsSB.Between("unix_milli", req.Start, req.End))
|
||||
sampleCountsSB.Where("metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates)")
|
||||
|
||||
@@ -1397,13 +1397,13 @@ func (m *module) computeSamplesTreemap(ctx context.Context, orgID valuer.UUID, r
|
||||
if reductionEnabled {
|
||||
reducedLastSB = sqlbuilder.NewSelectBuilder()
|
||||
reducedLastSB.Select("metric_name", "uniq(reduced_fingerprint, unix_milli) AS cnt")
|
||||
reducedLastSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4ReducedLastTableName))
|
||||
reducedLastSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4ReducedLastTableName))
|
||||
reducedLastSB.Where(reducedLastSB.Between("unix_milli", req.Start, req.End))
|
||||
reducedLastSB.Where("metric_name GLOBAL IN (SELECT metric_name FROM __reduced_metric_candidates)")
|
||||
|
||||
reducedSumSB = sqlbuilder.NewSelectBuilder()
|
||||
reducedSumSB.Select("metric_name", "uniq(reduced_fingerprint, unix_milli) AS cnt")
|
||||
reducedSumSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4ReducedSumTableName))
|
||||
reducedSumSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4ReducedSumTableName))
|
||||
reducedSumSB.Where(reducedSumSB.Between("unix_milli", req.Start, req.End))
|
||||
reducedSumSB.Where("metric_name GLOBAL IN (SELECT metric_name FROM __reduced_metric_candidates)")
|
||||
}
|
||||
@@ -1411,7 +1411,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, orgID valuer.UUID, r
|
||||
if filterWhereClause != nil {
|
||||
fingerprintSB := sqlbuilder.NewSelectBuilder()
|
||||
fingerprintSB.Select("fingerprint")
|
||||
fingerprintSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTsTable))
|
||||
fingerprintSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, localTsTable))
|
||||
fingerprintSB.Where(fingerprintSB.Between("unix_milli", start, end))
|
||||
fingerprintSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
fingerprintSB.AddWhereClause(sqlbuilder.CopyWhereClause(filterWhereClause))
|
||||
@@ -1423,7 +1423,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, orgID valuer.UUID, r
|
||||
if reductionEnabled {
|
||||
reducedFingerprintSB := sqlbuilder.NewSelectBuilder()
|
||||
reducedFingerprintSB.Select("fingerprint")
|
||||
reducedFingerprintSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4ReducedLocalTableName))
|
||||
reducedFingerprintSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedLocalTableName))
|
||||
reducedFingerprintSB.Where(reducedFingerprintSB.Between("unix_milli", start, end))
|
||||
reducedFingerprintSB.Where("NOT startsWith(metric_name, 'signoz')")
|
||||
reducedFingerprintSB.AddWhereClause(sqlbuilder.CopyWhereClause(filterWhereClause))
|
||||
@@ -1457,12 +1457,12 @@ func (m *module) computeSamplesTreemap(ctx context.Context, orgID valuer.UUID, r
|
||||
// Grand total includes reduced sample volume so percentages stay consistent.
|
||||
reducedTotalLastSB := sqlbuilder.NewSelectBuilder()
|
||||
reducedTotalLastSB.Select("uniq(reduced_fingerprint, unix_milli) AS cnt")
|
||||
reducedTotalLastSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4ReducedLastTableName))
|
||||
reducedTotalLastSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4ReducedLastTableName))
|
||||
reducedTotalLastSB.Where(reducedTotalLastSB.Between("unix_milli", req.Start, req.End))
|
||||
|
||||
reducedTotalSumSB := sqlbuilder.NewSelectBuilder()
|
||||
reducedTotalSumSB.Select("uniq(reduced_fingerprint, unix_milli) AS cnt")
|
||||
reducedTotalSumSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4ReducedSumTableName))
|
||||
reducedTotalSumSB.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4ReducedSumTableName))
|
||||
reducedTotalSumSB.Where(reducedTotalSumSB.Between("unix_milli", req.Start, req.End))
|
||||
|
||||
reducedTotalSamplesSB := sqlbuilder.NewSelectBuilder()
|
||||
@@ -1552,7 +1552,7 @@ func (m *module) getMetricDataPoints(ctx context.Context, metricName string) (ui
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("sum(count) AS data_points")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4Agg30mTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4Agg30mTableName))
|
||||
sb.Where(sb.E("metric_name", metricName))
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
@@ -1574,7 +1574,7 @@ func (m *module) getMetricLastReceived(ctx context.Context, metricName string) (
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("MAX(last_reported_unix_milli) AS last_received_time")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.AttributesMetadataTableName))
|
||||
sb.Where(sb.E("metric_name", metricName))
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
@@ -1599,7 +1599,7 @@ func (m *module) getTotalTimeSeriesForMetricName(ctx context.Context, metricName
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("uniq(fingerprint) AS time_series_count")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV41weekTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV41weekTableName))
|
||||
sb.Where(sb.E("metric_name", metricName))
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
@@ -1623,7 +1623,7 @@ func (m *module) getActiveTimeSeriesForMetricName(ctx context.Context, metricNam
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("uniq(fingerprint) AS active_time_series")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4TableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4TableName))
|
||||
sb.Where(sb.E("metric_name", metricName))
|
||||
sb.Where(sb.GTE("unix_milli", milli))
|
||||
|
||||
@@ -1649,7 +1649,7 @@ func (m *module) fetchMetricAttributes(ctx context.Context, metricName string, s
|
||||
"groupUniqArray(1000)(attr_string_value) AS values",
|
||||
"uniq(attr_string_value) AS valueCount",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.AttributesMetadataTableName))
|
||||
sb.Where(sb.E("metric_name", metricName))
|
||||
sb.Where("NOT startsWith(attr_name, '__')")
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
@@ -52,7 +52,7 @@ func (m *module) ListPromotedAndIndexedPaths(ctx context.Context) ([]promotetype
|
||||
|
||||
response := []promotetypes.PromotePath{}
|
||||
for _, path := range promotedPaths {
|
||||
fullPath := telemetrylogs.BodyPromotedColumnPrefix + path
|
||||
fullPath := logstelemetryschema.BodyPromotedColumnPrefix + path
|
||||
path = telemetrytypes.BodyJSONStringSearchPrefix + path
|
||||
item := promotetypes.PromotePath{
|
||||
Path: path,
|
||||
@@ -68,7 +68,7 @@ func (m *module) ListPromotedAndIndexedPaths(ctx context.Context) ([]promotetype
|
||||
|
||||
// add the paths that are not promoted but have indexes
|
||||
for path, indexes := range aggr {
|
||||
path := strings.TrimPrefix(path, telemetrylogs.BodyV2ColumnPrefix)
|
||||
path := strings.TrimPrefix(path, logstelemetryschema.BodyV2ColumnPrefix)
|
||||
path = telemetrytypes.BodyJSONStringSearchPrefix + path
|
||||
response = append(response, promotetypes.PromotePath{
|
||||
Path: path,
|
||||
@@ -108,8 +108,8 @@ func (m *module) createIndexes(ctx context.Context, indexes []schemamigrator.Ind
|
||||
|
||||
for _, index := range indexes {
|
||||
alterStmt := schemamigrator.AlterTableAddIndex{
|
||||
Database: telemetrylogs.DBName,
|
||||
Table: telemetrylogs.LogsV2LocalTableName,
|
||||
Database: logstelemetryschema.DBName,
|
||||
Table: logstelemetryschema.LogsV2LocalTableName,
|
||||
Index: index,
|
||||
}
|
||||
op := alterStmt.OnCluster(m.telemetryStore.Cluster())
|
||||
@@ -153,10 +153,10 @@ func (m *module) PromoteAndIndexPaths(
|
||||
}
|
||||
}
|
||||
if len(it.Indexes) > 0 {
|
||||
parentColumn := telemetrylogs.LogsV2BodyV2Column
|
||||
parentColumn := logstelemetryschema.LogsV2BodyV2Column
|
||||
// if the path is already promoted or is being promoted, add it to the promoted column
|
||||
if _, promoted := existingPromotedPaths[it.Path]; promoted || it.Promote {
|
||||
parentColumn = telemetrylogs.LogsV2BodyPromotedColumn
|
||||
parentColumn = logstelemetryschema.LogsV2BodyPromotedColumn
|
||||
}
|
||||
|
||||
for _, index := range it.Indexes {
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/services"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
@@ -39,7 +39,7 @@ func (m *module) FetchTopLevelOperations(ctx context.Context, start time.Time, s
|
||||
ctx = m.withServicesContext(ctx, "FetchTopLevelOperations")
|
||||
|
||||
db := m.TelemetryStore.ClickhouseDB()
|
||||
query := fmt.Sprintf("SELECT name, serviceName, max(time) as ts FROM %s.%s WHERE time >= @start", telemetrytraces.DBName, telemetrytraces.TopLevelOperationsTableName)
|
||||
query := fmt.Sprintf("SELECT name, serviceName, max(time) as ts FROM %s.%s WHERE time >= @start", tracestelemetryschema.DBName, tracestelemetryschema.TopLevelOperationsTableName)
|
||||
args := []any{clickhouse.Named("start", start)}
|
||||
if len(services) > 0 {
|
||||
query += " AND serviceName IN @services"
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
"github.com/prometheus/prometheus/util/annotations"
|
||||
)
|
||||
|
||||
// statementRecorder collects the statements a PromQL evaluation would run.
|
||||
// Safe for concurrent use: the engine may Select selectors concurrently.
|
||||
type statementRecorder struct {
|
||||
mu sync.Mutex
|
||||
statements []prometheus.CapturedStatement
|
||||
}
|
||||
|
||||
func (r *statementRecorder) record(query string, args []any) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.statements = append(r.statements, prometheus.CapturedStatement{Query: query, Args: args})
|
||||
}
|
||||
|
||||
func (r *statementRecorder) Statements() []prometheus.CapturedStatement {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]prometheus.CapturedStatement, len(r.statements))
|
||||
copy(out, r.statements)
|
||||
return out
|
||||
}
|
||||
|
||||
type captureQueryable struct {
|
||||
client *client
|
||||
recorder *statementRecorder
|
||||
}
|
||||
|
||||
func (c *captureQueryable) Querier(mint, maxt int64) (storage.Querier, error) {
|
||||
return &captureQuerier{
|
||||
querier: querier{mint: mint, maxt: maxt, client: c.client},
|
||||
recorder: c.recorder,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// captureQuerier builds the same SQL as the live querier but records it and
|
||||
// returns no data. The fingerprint filter always takes the subquery form:
|
||||
// without executing the series lookup, the inline literal set is unknown.
|
||||
type captureQuerier struct {
|
||||
querier
|
||||
recorder *statementRecorder
|
||||
}
|
||||
|
||||
func (c *captureQuerier) Select(ctx context.Context, _ bool, hints *storage.SelectHints, matchers ...*labels.Matcher) storage.SeriesSet {
|
||||
|
||||
start, end := c.window(hints)
|
||||
|
||||
samplesQuery, args, err := buildSamplesQuery(start, end, metricNamesFromMatchers(matchers), matchers, c.lastSamplePerStepFor(ctx, hints))
|
||||
if err != nil {
|
||||
return storage.ErrSeriesSet(err)
|
||||
}
|
||||
c.recorder.record(samplesQuery, args)
|
||||
|
||||
return storage.EmptySeriesSet()
|
||||
}
|
||||
|
||||
func (c *captureQuerier) LabelValues(context.Context, string, *storage.LabelHints, ...*labels.Matcher) ([]string, annotations.Annotations, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (c *captureQuerier) LabelNames(context.Context, *storage.LabelHints, ...*labels.Matcher) ([]string, annotations.Annotations, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// metricNamesFromMatchers extracts the statically known metric name, if any.
|
||||
// The live path derives names from the matched series; the capture path has
|
||||
// no execution results, so only a __name__ equality contributes.
|
||||
func metricNamesFromMatchers(matchers []*labels.Matcher) []string {
|
||||
for _, m := range matchers {
|
||||
if m.Name == metricNameLabel && m.Type == labels.MatchEqual && m.Value != "" {
|
||||
return []string{m.Value}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var updateGolden = flag.Bool("update", false, "rewrite the classification golden file")
|
||||
|
||||
const goldenFile = "testdata/classification_golden.json"
|
||||
|
||||
// corpusFile is the conformance corpus the integration suite replays; the
|
||||
// golden freezes how the classifier routes every one of its expressions.
|
||||
const corpusFile = "../../../tests/integration/testdata/promqltestcorpus/corpus.json"
|
||||
|
||||
type goldenEntry struct {
|
||||
Expr string `json:"expr"`
|
||||
StartMs int64 `json:"start_ms"`
|
||||
EndMs int64 `json:"end_ms"`
|
||||
StepMs int64 `json:"step_ms"`
|
||||
// Plan is the routing decision: "full" (whole query in ClickHouse),
|
||||
// "hybrid" (units substituted, engine on top), "fallback" (engine over
|
||||
// the native querier).
|
||||
Plan string `json:"plan"`
|
||||
// Units is the substituted-unit count for hybrid plans.
|
||||
Units int `json:"units,omitempty"`
|
||||
// Reason is the coarse fallback bucket (fallbackShape).
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// TestClassificationGolden freezes the classifier's routing decision for
|
||||
// every (expression, grid) of the conformance corpus. Routing is a
|
||||
// correctness surface of its own: a change that silently sends rate() to the
|
||||
// engine path costs the pushdown, and one that silently starts transpiling a
|
||||
// shape never proven equivalent risks wrong numbers — both must show up in
|
||||
// review as a diff of this file, with the corpus suite's clickhousev2 leg
|
||||
// judging whether the new routing still returns the reference answers.
|
||||
//
|
||||
// Regenerate after intentional classifier changes:
|
||||
//
|
||||
// go test ./pkg/prometheus/clickhouseprometheusv2 -run TestClassificationGolden -update
|
||||
func TestClassificationGolden(t *testing.T) {
|
||||
raw, err := os.ReadFile(corpusFile)
|
||||
require.NoError(t, err)
|
||||
|
||||
var corpus struct {
|
||||
Cases []struct {
|
||||
Expr string `json:"expr"`
|
||||
StartMs int64 `json:"start_ms"`
|
||||
EndMs int64 `json:"end_ms"`
|
||||
StepMs int64 `json:"step_ms"`
|
||||
} `json:"cases"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(raw, &corpus))
|
||||
require.NotEmpty(t, corpus.Cases)
|
||||
|
||||
promParser := parser.NewParser(parser.Options{})
|
||||
seen := map[goldenEntry]bool{}
|
||||
var entries []goldenEntry
|
||||
for _, c := range corpus.Cases {
|
||||
key := goldenEntry{Expr: c.Expr, StartMs: c.StartMs, EndMs: c.EndMs, StepMs: c.StepMs}
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
|
||||
expr, err := promParser.ParseExpr(c.Expr)
|
||||
require.NoError(t, err, "corpus expression must parse: %q", c.Expr)
|
||||
|
||||
entry := key
|
||||
plan, ok := classify(expr, gridContext{startMs: c.StartMs, endMs: c.EndMs, stepMs: c.StepMs})
|
||||
switch {
|
||||
case ok && plan.full:
|
||||
entry.Plan = "full"
|
||||
case ok:
|
||||
entry.Plan = "hybrid"
|
||||
entry.Units = len(plan.units)
|
||||
default:
|
||||
entry.Plan = "fallback"
|
||||
entry.Reason = fallbackShape(expr)
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
a, b := entries[i], entries[j]
|
||||
if a.Expr != b.Expr {
|
||||
return a.Expr < b.Expr
|
||||
}
|
||||
if a.StartMs != b.StartMs {
|
||||
return a.StartMs < b.StartMs
|
||||
}
|
||||
if a.EndMs != b.EndMs {
|
||||
return a.EndMs < b.EndMs
|
||||
}
|
||||
return a.StepMs < b.StepMs
|
||||
})
|
||||
|
||||
got, err := json.MarshalIndent(entries, "", " ")
|
||||
require.NoError(t, err)
|
||||
got = append(got, '\n')
|
||||
|
||||
if *updateGolden {
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(goldenFile), 0o755))
|
||||
require.NoError(t, os.WriteFile(goldenFile, got, 0o644))
|
||||
return
|
||||
}
|
||||
|
||||
want, err := os.ReadFile(goldenFile)
|
||||
require.NoError(t, err, "golden missing — generate it with -update")
|
||||
require.Equal(t, string(want), string(got),
|
||||
"classification routing changed; if intentional, regenerate with -update and justify the diff in review")
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
promValue "github.com/prometheus/prometheus/model/value"
|
||||
)
|
||||
|
||||
// seriesLookup holds a series lookup's result: matched fingerprints with
|
||||
// their labels, and the distinct metric names seen on them.
|
||||
type seriesLookup struct {
|
||||
fingerprints map[uint64]labels.Labels
|
||||
metricNames []string
|
||||
}
|
||||
|
||||
// client executes the series, samples and raw queries against ClickHouse.
|
||||
type client struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
lookbackMs int64
|
||||
}
|
||||
|
||||
func newClient(settings factory.ScopedProviderSettings, telemetryStore telemetrystore.TelemetryStore, cfg prometheus.Config) *client {
|
||||
lookback := cfg.LookbackDelta
|
||||
if lookback <= 0 {
|
||||
// Mirror the engine: promql defaults an unset lookback to 5m.
|
||||
lookback = defaultLookbackDelta
|
||||
}
|
||||
return &client{
|
||||
settings: settings,
|
||||
telemetryStore: telemetryStore,
|
||||
lookbackMs: lookback.Milliseconds(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) withContext(ctx context.Context, functionName string) context.Context {
|
||||
return ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-prometheus-v2",
|
||||
instrumentationtypes.CodeFunctionName: functionName,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *client) selectSeries(ctx context.Context, query string, args []any) (*seriesLookup, error) {
|
||||
ctx = c.withContext(ctx, "selectSeries")
|
||||
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
lookup := &seriesLookup{fingerprints: make(map[uint64]labels.Labels)}
|
||||
names := make(map[string]struct{})
|
||||
|
||||
var fingerprint uint64
|
||||
var labelsJSON string
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(&fingerprint, &labelsJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lset, err := unmarshalLabels(labelsJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lookup.fingerprints[fingerprint] = lset
|
||||
if name := lset.Get(metricNameLabel); name != "" {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for name := range names {
|
||||
lookup.metricNames = append(lookup.metricNames, name)
|
||||
}
|
||||
slices.Sort(lookup.metricNames)
|
||||
|
||||
return lookup, nil
|
||||
}
|
||||
|
||||
// unmarshalLabels parses the labels JSON column, dropping empty-valued
|
||||
// labels: empty means "absent" in Prometheus, but stored attribute JSON can
|
||||
// carry them.
|
||||
func unmarshalLabels(s string) (labels.Labels, error) {
|
||||
m := make(map[string]string)
|
||||
if err := json.Unmarshal([]byte(s), &m); err != nil {
|
||||
return labels.EmptyLabels(), err
|
||||
}
|
||||
builder := labels.NewScratchBuilder(len(m))
|
||||
for k, v := range m {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
builder.Add(k, v)
|
||||
}
|
||||
builder.Sort()
|
||||
return builder.Labels(), nil
|
||||
}
|
||||
|
||||
// selectSamples assembles per-series sample slices from a samples query (raw
|
||||
// or last-sample-per-step; same column shape), whose rows arrive ordered by
|
||||
// (fingerprint, unix_milli). Fingerprints missing from the lookup are skipped:
|
||||
// the samples query's semi-join re-runs the series predicates and can match
|
||||
// series registered after the lookup ran — the lookup is the read snapshot.
|
||||
// Stale flags map to the engine's StaleNaN. Duplicate
|
||||
// timestamps pass through: uniqueness is ingest's job, and v1 feeds them to
|
||||
// the engine as-is over the same dirty data.
|
||||
func (c *client) selectSamples(ctx context.Context, query string, args []any, lookup *seriesLookup) ([]*series, error) {
|
||||
ctx = c.withContext(ctx, "selectSamples")
|
||||
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var (
|
||||
result []*series
|
||||
current *series
|
||||
fingerprint uint64
|
||||
prevFp uint64
|
||||
timestampMs int64
|
||||
val float64
|
||||
flags uint32
|
||||
first = true
|
||||
haveCurrent bool
|
||||
staleMarker = math.Float64frombits(promValue.StaleNaN)
|
||||
unknownCount int
|
||||
)
|
||||
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(&fingerprint, ×tampMs, &val, &flags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if first || fingerprint != prevFp {
|
||||
first = false
|
||||
prevFp = fingerprint
|
||||
lset, ok := lookup.fingerprints[fingerprint]
|
||||
if !ok {
|
||||
unknownCount++
|
||||
haveCurrent = false
|
||||
continue
|
||||
}
|
||||
current = &series{lset: lset}
|
||||
result = append(result, current)
|
||||
haveCurrent = true
|
||||
}
|
||||
if !haveCurrent {
|
||||
continue
|
||||
}
|
||||
|
||||
if flags&1 == 1 {
|
||||
val = staleMarker
|
||||
}
|
||||
current.ts = append(current.ts, timestampMs)
|
||||
current.vs = append(current.vs, val)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if unknownCount > 0 {
|
||||
c.settings.Logger().DebugContext(ctx, "skipped samples of fingerprints missing from series lookup",
|
||||
slog.Int("unknown_fingerprints", unknownCount))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
// Package clickhouseprometheusv2 is the second-generation ClickHouse-backed
|
||||
// Prometheus provider. It exists because the v1 provider fetches every raw
|
||||
// sample of a query's union window through the remote-read protobuf layer
|
||||
// and hands it to the engine — the cost is a function of ingested data, not
|
||||
// of the question asked, which is how a dashboard of PromQL panels takes an
|
||||
// instance down.
|
||||
//
|
||||
// Every query runs in one of two ways, decided per query:
|
||||
//
|
||||
// - Transpiled: the query is evaluated entirely inside ClickHouse and only
|
||||
// final (or near-final) per-group grid arrays come back, built on the
|
||||
// timeSeries*ToGrid aggregate functions (the supported ClickHouse floor
|
||||
// is >= 25.6, so they are assumed available).
|
||||
// - Engine: the stock promql engine evaluates over this package's native
|
||||
// storage.Querier. This is the path for everything not transpilable.
|
||||
//
|
||||
// Correctness is the constraint that shaped both paths: a PromQL result that
|
||||
// differs from upstream Prometheus is a lost user, so anything that cannot
|
||||
// reproduce engine semantics exactly falls back rather than approximate.
|
||||
// The rest of this comment is the PromQL -> SQL story, because that mapping
|
||||
// is where correctness is won or lost.
|
||||
//
|
||||
// # The evaluation model the SQL must reproduce
|
||||
//
|
||||
// A PromQL range query is an instant query evaluated at every grid point
|
||||
// t_i = start + i*step, i = 0..(end-start)/step. At each t_i:
|
||||
//
|
||||
// - an instant selector resolves to the latest sample in the left-open
|
||||
// lookback window (t_i - lookback, t_i], and to nothing when that latest
|
||||
// sample is a stale marker — even if older real samples sit inside the
|
||||
// window;
|
||||
// - a range selector [r] collects every sample in (t_i - r, t_i], stale
|
||||
// markers excluded;
|
||||
// - offset d shifts both windows to (t_i - d - w, t_i - d].
|
||||
//
|
||||
// The transpilation invariant follows from this: every transpiled construct
|
||||
// produces, per output series, one array with exactly one slot per grid
|
||||
// point — slot i holds the value at t_i, NULL means absent. This is what
|
||||
// makes composition correct, not just convenient: the engine evaluates
|
||||
// these operators independently per t_i, so any representation that gets
|
||||
// every slot right gets the whole query right, and spatial aggregation over
|
||||
// arrays is sound because it combines values that belong to the same t_i by
|
||||
// construction. Slot index i maps back to t_i = start + i*step at scan time
|
||||
// (toMatrix). Everything below is about filling those slots with exactly
|
||||
// the numbers the engine would compute — and each equivalence was validated
|
||||
// against the vendored engine on live data before its shape entered the
|
||||
// allowlist; anything unproven stays on the engine path.
|
||||
//
|
||||
// # Classification: finding what a statement can answer
|
||||
//
|
||||
// classify walks the parsed AST looking for "core units" — maximal subtrees
|
||||
// of the shape
|
||||
//
|
||||
// [agg by/without (...)] [fn(] selector[range] [offset d] [)] [op scalar]...
|
||||
//
|
||||
// classifyCore peels that chain from the outside in: an optional
|
||||
// sum/min/max/avg/count aggregation, then one of the allowlisted functions
|
||||
// or a bare instant selector, then the selector with its offset; on the way
|
||||
// out it accumulates number-literal arithmetic, comparisons (including
|
||||
// bool) and unary minus into a scalar-op pipeline. A node qualifies only if
|
||||
// its type, arguments and children are in the proven set — an allowlist, so
|
||||
// an overlooked construct becomes a fallback instead of a wrong number.
|
||||
//
|
||||
// Three unit kinds come out of this, each with its own SQL form:
|
||||
// unitRange (rate, irate, increase, delta, idelta over a range selector),
|
||||
// unitInstant (instant vector selection, bare or comparison-filtered) and
|
||||
// unitOverTime (avg/min/max/sum/count/last _over_time).
|
||||
//
|
||||
// If the entire tree is one unit, the plan is "full": the statement's rows
|
||||
// are the query result. Otherwise every maximal unit is cut out and replaced
|
||||
// in the expression with a synthetic selector __signoz_transpiled_N__, and
|
||||
// the rewritten expression runs in the engine over the units' materialized
|
||||
// results ("hybrid") — histogram_quantile, topk, or/and/unless and vector
|
||||
// matching keep exact engine semantics while their expensive inputs were
|
||||
// aggregated server-side.
|
||||
//
|
||||
// Classification refuses when exact semantics cannot be guaranteed
|
||||
// server-side: the @ modifier anywhere and default-resolution subqueries
|
||||
// (their resolution is a server runtime setting the transpiler cannot see);
|
||||
// duration expressions (offset step(), [range()], ...) anywhere — they are
|
||||
// resolved into the selector's static fields only at evaluation time, so at
|
||||
// classification time those fields still hold their zero values and
|
||||
// transpiling would silently use the wrong offset or range;
|
||||
// steps or ranges that are not whole seconds (the grid functions take
|
||||
// whole-second parameters); grouping by or matching on __name__ in hybrid
|
||||
// plans (the synthetic name would leak into results); name-keeping units —
|
||||
// bare/comparison instant selectors and last_over_time keep their real
|
||||
// __name__ (keepsName), which substitution would replace, so they transpile
|
||||
// only as full plans; and every function outside the allowlist (changes,
|
||||
// resets, quantile_over_time, absent, native-histogram functions, ...).
|
||||
//
|
||||
// Units inside a fixed-resolution subquery evaluate on the subquery's own
|
||||
// grid instead of the query grid: epoch-aligned multiples of the resolution
|
||||
// strictly after outerStart - offset - range, ending at outer end - offset —
|
||||
// the exact derivation the engine uses, because a grid shifted by one step
|
||||
// changes which samples every window sees.
|
||||
//
|
||||
// # From one unit to one statement
|
||||
//
|
||||
// buildUnitSQL renders each unit as a single statement. For
|
||||
// sum by (pod) (rate(m{job="api"}[5m])) the skeleton is:
|
||||
//
|
||||
// SELECT gkey, sumForEach(grid) AS grid FROM (
|
||||
// SELECT series.gkey AS gkey,
|
||||
// timeSeriesRateToGrid(<start>, <end>, <step>, <range>)(fromUnixTimestamp64Milli(unix_milli), value) AS grid
|
||||
// FROM signoz_metrics.distributed_samples_v4 AS points
|
||||
// INNER JOIN (
|
||||
// SELECT fingerprint, <group key expr> AS gkey
|
||||
// FROM signoz_metrics.time_series_v4
|
||||
// WHERE <series predicates>
|
||||
// GROUP BY fingerprint, gkey
|
||||
// ) AS series ON points.fingerprint = series.fingerprint
|
||||
// WHERE metric_name = ? AND temporality IN ['Cumulative', 'Unspecified']
|
||||
// AND points.fingerprint IN (<matched fingerprints>)
|
||||
// AND unix_milli > <start - range> AND unix_milli <= <end>
|
||||
// AND bitAnd(flags, 1) = 0
|
||||
// GROUP BY points.fingerprint, series.gkey
|
||||
// ) GROUP BY gkey
|
||||
// SETTINGS allow_experimental_ts_to_grid_aggregate_function = 1
|
||||
//
|
||||
// Reading it inside out:
|
||||
//
|
||||
// The time window is the selector's semantics verbatim: strict > on the
|
||||
// lower bound and <= on the upper is the left-open (t - w, t] rule, with the
|
||||
// whole window shifted by the offset. bitAnd(flags, 1) = 0 drops stale
|
||||
// markers, which PromQL excludes from range vectors.
|
||||
//
|
||||
// The inner GROUP BY computes one grid array per series.
|
||||
// timeSeriesRateToGrid(start, end, step, range) is a parametric aggregate:
|
||||
// fed (timestamp, value) pairs it produces Array(Nullable(Float64)) with one
|
||||
// slot per grid point. Correct because it implements the engine's
|
||||
// extrapolatedRate decision for decision — counter resets, the zero-point
|
||||
// clamp, the extrapolation thresholds, the >= 2 samples rule, the left-open
|
||||
// window — verified by feeding identical samples to both and comparing
|
||||
// slot for slot: the only difference ever observed is the last bit
|
||||
// (ClickHouse's C++ and Go round the same formula differently), which is
|
||||
// the floating-point floor, not a semantic gap. irate/delta/idelta map to
|
||||
// their own timeSeries*ToGrid functions with the same verification;
|
||||
// increase has no function of its own and is emitted as
|
||||
// arrayMap(x -> x * <range seconds>, <rate expr>), exact by definition —
|
||||
// extrapolatedRate computes the same extrapolated delta for both and
|
||||
// divides by the range only when isRate, so multiplying it back is the
|
||||
// identity, not an approximation. The grid parameters are rendered as
|
||||
// literals, not bound args — they are aggregate-function parameters — and
|
||||
// the experimental gate rides as a SETTINGS clause on the statement itself
|
||||
// so telemetrystore hooks cannot clobber it.
|
||||
//
|
||||
// The join annotates each series with its group key, in one of two forms.
|
||||
// by (...) extracts each listed label as a plain column
|
||||
// (JSONExtractString(labels, 'pod') AS g0) and groups on the columns
|
||||
// directly: the projection is a known short list and the label names live
|
||||
// in Go, so building, sorting and stringifying every label pair per row
|
||||
// would be waste. Correct because column-tuple equality is label-set
|
||||
// equality on the projection, and an extracted '' is the label being
|
||||
// absent — Prometheus semantics for by() over missing labels, and empties
|
||||
// are skipped when the columns turn back into labels. without and
|
||||
// no-aggregation project a label SET that varies per series, so they get
|
||||
// the canonical key: toJSONString of the sorted [label, value] pairs the
|
||||
// unit projects (without excludes the listed labels plus __name__; no
|
||||
// aggregation keeps everything, the name coming off in Go per the engine's
|
||||
// name-dropping rules). There the sort is load-bearing — stored JSON key
|
||||
// order is not canonical across fingerprints, and two orderings of the same
|
||||
// labels must land in one group — empty values are filtered for the same
|
||||
// absent-label reason, and the same string parses back into the output
|
||||
// label set (labelsFromGroupKey).
|
||||
//
|
||||
// The outer GROUP BY is the spatial aggregation: sum/min/max/avg/count
|
||||
// by/without become the -ForEach combinators. Element-wise aggregation over
|
||||
// grid arrays is the engine's per-t_i aggregation, because slot i of every
|
||||
// input array refers to the same t_i; the combinators skip NULLs, which is
|
||||
// the engine aggregating only the series present at t_i, and an index where
|
||||
// every series is absent stays NULL. Two edges need explicit handling:
|
||||
// countForEach wraps in a mapping of 0 back to NULL, because a count over
|
||||
// an all-absent index is an absent point, not 0; and a unit without
|
||||
// aggregation still passes through maxForEach — the identity for the common
|
||||
// one-fingerprint group, and a deterministic NULL-skipping merge when a
|
||||
// regex __name__ selector collapses distinct metrics onto one projected
|
||||
// label set. One caveat is inherent: summation order over series differs
|
||||
// from the engine's, so spatial aggregates can differ in the last ULP —
|
||||
// float addition is not associative; no ordering reproduces the engine's
|
||||
// bit-exactly from inside a GROUP BY.
|
||||
//
|
||||
// # Instant selectors: staleness needs two aggregates
|
||||
//
|
||||
// unitInstant uses window = lookback and must reproduce the shadowing rule:
|
||||
// the point is absent when the latest in-window sample is a stale marker.
|
||||
// timeSeriesLastToGrid alone cannot express that — skipping stale rows in
|
||||
// WHERE would resurrect the older real sample the marker was written to
|
||||
// bury. So stale rows stay in the scan for this kind only, and the grid
|
||||
// expression compares three aggregates per slot:
|
||||
//
|
||||
// arrayMap((tall, tok, vok) -> if(tall IS NULL OR tok IS NULL OR tall != tok, NULL, vok),
|
||||
// timeSeriesLastToGrid(...)(ts, toFloat64(unix_milli)), -- last sample overall
|
||||
// timeSeriesLastToGridIf(...)(ts, toFloat64(unix_milli), bitAnd(flags, 1) = 0), -- last non-stale, its timestamp
|
||||
// timeSeriesLastToGridIf(...)(ts, value, bitAnd(flags, 1) = 0)) -- last non-stale, its value
|
||||
//
|
||||
// Correct by cases on a slot's window. No samples at all: both timestamp
|
||||
// aggregates are NULL, the slot is NULL — absent, as the engine says. Latest
|
||||
// sample non-stale: it is the latest overall and the latest non-stale, the
|
||||
// timestamps agree, the slot takes its value — the engine's pick. Latest
|
||||
// sample stale: the last-overall timestamp is the marker's, the
|
||||
// last-non-stale timestamp is older (or NULL when only markers are in
|
||||
// window), they disagree, the slot is NULL — the marker shadows, exactly
|
||||
// the engine's rule. Timestamps are unique per series (ingest dedups), so
|
||||
// timestamp equality identifies "the same sample" without ambiguity. The
|
||||
// -If combinator's applicability to these experimental aggregates was
|
||||
// probed before being trusted, not assumed.
|
||||
//
|
||||
// # Windowed *_over_time: whole buckets instead of a grid function
|
||||
//
|
||||
// avg/min/max/sum/count _over_time aggregate every raw sample in the window,
|
||||
// and no timeSeries*ToGrid function computes them. (last_over_time is the
|
||||
// exception: the last sample of a range vector — stale markers excluded from
|
||||
// range vectors by PromQL, excluded here in WHERE — is exactly
|
||||
// timeSeriesLastToGrid.) These transpile only when the range is a whole
|
||||
// multiple of the step, and then the window needs no per-sample fan-out at
|
||||
// all: with W = range/step, the window (t_k - range, t_k] is exactly the
|
||||
// union of W step buckets — both are left-open on the same boundaries — so
|
||||
// bucket membership fully determines window membership. Each sample lands
|
||||
// in exactly one bucket by a plain GROUP BY:
|
||||
//
|
||||
// intDiv(unix_milli - <start> + <range> - 1, <step>) AS jj
|
||||
//
|
||||
// (ceil((ts - start)/step) shifted by W-1 so the earliest in-window sample
|
||||
// sits at 0; slot k's window is buckets jj in [k, k+W-1]). The alternative —
|
||||
// fanning each sample into all W windows that cover it — multiplies rows by
|
||||
// W, which for a long range over a short step is a row explosion measured
|
||||
// in billions; the bucketed form's row count is series x buckets, the size
|
||||
// of the output, regardless of W.
|
||||
//
|
||||
// The shard level aggregates per (series, group key, bucket): a bucket
|
||||
// count plus the function's value aggregate (sum for sum/avg, min, max).
|
||||
// The assembly level places the partials into dense arrays
|
||||
// (groupArrayInsertAt — positions are unique, one row per bucket; counts
|
||||
// and sums default to 0, which contributes nothing) and slides: slot k
|
||||
// combines its at-most-W bucket partials by direct aggregation, so window
|
||||
// sums are added the way the engine adds them — no prefix-sum differencing,
|
||||
// whose large-minus-large cancellation would drift past the shadow
|
||||
// tolerance on counter-sized values. Correct per slot because the bucket
|
||||
// union is the exact window multiset and avg/min/max/sum/count are
|
||||
// order-insensitive on a multiset (sum/avg up to summation order, the float
|
||||
// caveat above). A slot with zero window count is absent; min/max filter
|
||||
// their slices on the bucket counts, so an empty bucket's default can never
|
||||
// be mistaken for a value — a real sample can legitimately be +Inf.
|
||||
//
|
||||
// Ranges that don't divide the step, and windows wider than
|
||||
// maxWindowBuckets buckets (the slide costs W combines per slot), fall back
|
||||
// to the engine path, which is exact.
|
||||
//
|
||||
// # Scalar ops, full plans, hybrid plans
|
||||
//
|
||||
// The scalar-op pipeline applies in Go to the returned arrays
|
||||
// (applyScalarOps), slot by slot: arithmetic operators compute, comparisons
|
||||
// filter (the slot keeps the vector-side value or becomes NULL) or return
|
||||
// 0/1 under bool. Correct trivially: it is the same float64 operation the
|
||||
// engine would apply to the same slot value, in the same operator order the
|
||||
// AST dictates — running it in Go instead of another SQL layer changes
|
||||
// where, not what.
|
||||
//
|
||||
// A full plan's arrays map straight to the result matrix. A hybrid plan
|
||||
// materializes each unit's arrays as synthetic series under its
|
||||
// __signoz_transpiled_N__ name and evaluates the rewritten expression over
|
||||
// a storage that serves synthetic names from memory and everything else
|
||||
// live. Substitution is sound because a unit's output is a plain instant
|
||||
// vector to the engine — same values at same timestamps under a different
|
||||
// name, and the name cannot matter: plans that group by or match on
|
||||
// __name__ were refused at classification, and name-keeping units are never
|
||||
// substituted. One subtlety makes it exact: stale markers are written at
|
||||
// absent grid points, because the engine's lookback would otherwise
|
||||
// resurrect a point from up to lookback earlier — the marker encodes
|
||||
// "absent here" the way the engine itself encodes it. Units evaluate
|
||||
// concurrently; each is one series lookup plus one grid statement. A step
|
||||
// of 0 is an instant query: a single evaluation at end.
|
||||
//
|
||||
// # Series lookup
|
||||
//
|
||||
// Both paths resolve matchers the same way, once per selector
|
||||
// (selectSeries), against the series tables holding one row per
|
||||
// (fingerprint, bucket) at 1h/6h/1d/1w granularities; timeSeriesTableFor
|
||||
// picks the table whose bucket fits the window and rounds the window start
|
||||
// down to the bucket boundary. How matchers become SQL, and why regexes are
|
||||
// anchored, is documented at applySeriesConditions. Empty-valued labels come
|
||||
// off at this boundary: an empty value means "label absent" in Prometheus,
|
||||
// but stored attribute JSON can carry them.
|
||||
//
|
||||
// # The engine path
|
||||
//
|
||||
// Queries that do not transpile run in the stock engine over this package's
|
||||
// storage.Querier, which is still not the v1 path. Samples are fetched per
|
||||
// selector using the engine's per-selector hints, not the query-wide union
|
||||
// window, so foo / foo offset 1d reads two narrow windows instead of the
|
||||
// widest one twice. Instant selectors of subquery-free queries fetch only
|
||||
// the last sample per step bucket (lastSamplePerStep): buckets anchor at the
|
||||
// selector's first evaluation timestamp — recovered from the hints as
|
||||
// hints.Start + lookback - 1ms, the inverse of how the engine derives
|
||||
// hints.Start — so bucket boundaries coincide with evaluation timestamps and
|
||||
// a non-final sample of a bucket can never be the latest sample in
|
||||
// (t - lookback, t] for any grid t. Real timestamps are preserved, so the
|
||||
// engine's own lookback and staleness handling stay exact. Range selectors
|
||||
// always fetch raw — every sample feeds the range function — and the
|
||||
// subquery-free proof travels in the context as prometheus.QueryTraits,
|
||||
// because subquery selectors evaluate at the subquery's step while the
|
||||
// hints carry the top-level step. Row assembly maps stale flags to the
|
||||
// engine's StaleNaN and merges series with identical label sets
|
||||
// (sortAndMerge) — the engine assumes storages never emit duplicates.
|
||||
//
|
||||
// # Sharding
|
||||
//
|
||||
// samples_v4 and time_series_v4 (and all their rollups) shard on the same
|
||||
// key — cityHash64(env, temporality, metric_name, fingerprint) — so a
|
||||
// series' samples and catalog rows live on the same shard. The transpiled
|
||||
// statement above exploits that: the distributed samples table at the
|
||||
// top-level FROM makes ClickHouse rewrite the whole inner query per shard,
|
||||
// where the join against the shard-local series table and the per-series
|
||||
// grid aggregation run next to the data; the initiator only merges
|
||||
// aggregate states and applies the spatial -ForEach step. Same layout as
|
||||
// the telemetrymetrics statement builder. The group-key join alone
|
||||
// restricts the transpiled scan to the matched series; the engine path's
|
||||
// samples fetch restricts by the same predicates as a shard-local
|
||||
// semi-join, not a GLOBAL broadcast of the matched set. The temporality
|
||||
// filter on every
|
||||
// samples statement is a semantic no-op — the matched fingerprints already
|
||||
// come from those temporalities — that engages the leading samples
|
||||
// primary-key column. Delta-temporality series stay invisible to PromQL
|
||||
// here exactly as they are in v1: the rollout gate is parity with v1, and
|
||||
// making Delta visible is its own change with its own semantics to design —
|
||||
// a Delta stream fed to rate() as-if-cumulative would be wrong, not just
|
||||
// new.
|
||||
//
|
||||
// # Observability
|
||||
//
|
||||
// Every statement carries a log_comment with
|
||||
// code.namespace=clickhouse-prometheus-v2 and code.function.name naming the
|
||||
// call site (selectSeries, selectSamples, transpiledUnit, LabelValues,
|
||||
// LabelNames), so this provider's work is attributable in system.query_log
|
||||
// without guessing from query text.
|
||||
package clickhouseprometheusv2
|
||||
@@ -1,84 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
)
|
||||
|
||||
// Provider ties the package together: its own engine and parser, the
|
||||
// ClickHouse client behind the native storage.Querier, and the transpiler
|
||||
// executor. See the package documentation for what runs where and why. It is
|
||||
// exported as a concrete type — pkg/querier holds it directly for shadow
|
||||
// comparison and pinned serving, and an interface with a single
|
||||
// implementation would only hide that dependency.
|
||||
type Provider struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
engine *prometheus.Engine
|
||||
parser prometheus.Parser
|
||||
client *client
|
||||
executor *executor
|
||||
}
|
||||
|
||||
var (
|
||||
_ prometheus.Prometheus = (*Provider)(nil)
|
||||
_ prometheus.StatementCapturer = (*Provider)(nil)
|
||||
)
|
||||
|
||||
func NewFactory(telemetryStore telemetrystore.TelemetryStore) factory.ProviderFactory[prometheus.Prometheus, prometheus.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("clickhousev2"), func(ctx context.Context, providerSettings factory.ProviderSettings, config prometheus.Config) (prometheus.Prometheus, error) {
|
||||
return New(ctx, providerSettings, config, telemetryStore)
|
||||
})
|
||||
}
|
||||
|
||||
func New(_ context.Context, providerSettings factory.ProviderSettings, config prometheus.Config, telemetryStore telemetrystore.TelemetryStore) (*Provider, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheusv2")
|
||||
|
||||
engine := prometheus.NewEngine(settings.Logger(), config)
|
||||
parser := prometheus.NewParser()
|
||||
client := newClient(settings, telemetryStore, config)
|
||||
|
||||
return &Provider{
|
||||
settings: settings,
|
||||
engine: engine,
|
||||
parser: parser,
|
||||
client: client,
|
||||
executor: &executor{client: client, engine: engine, parser: parser},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TryExecuteRange evaluates transpilable query shapes directly in ClickHouse
|
||||
// (see transpiler.go). ok=false means the shape is not transpilable and the
|
||||
// caller should evaluate through Engine over Storage instead.
|
||||
func (p *Provider) TryExecuteRange(ctx context.Context, query string, start, end time.Time, step time.Duration) (promql.Matrix, bool, error) {
|
||||
return p.executor.TryExecuteRange(ctx, query, start, end, step)
|
||||
}
|
||||
|
||||
func (p *Provider) Engine() *prometheus.Engine {
|
||||
return p.engine
|
||||
}
|
||||
|
||||
func (p *Provider) Parser() prometheus.Parser {
|
||||
return p.parser
|
||||
}
|
||||
|
||||
func (p *Provider) Storage() storage.Queryable {
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *Provider) Querier(mint, maxt int64) (storage.Querier, error) {
|
||||
return &querier{mint: mint, maxt: maxt, client: p.client}, nil
|
||||
}
|
||||
|
||||
// CapturingStorage implements prometheus.StatementCapturer: a storage that
|
||||
// records each selector's SQL without executing it, for the preview path.
|
||||
// A fresh recorder per call keeps concurrent dry-runs isolated.
|
||||
func (p *Provider) CapturingStorage() (storage.Queryable, prometheus.StatementRecorder) {
|
||||
recorder := &statementRecorder{}
|
||||
return &captureQueryable{client: p.client, recorder: recorder}, recorder
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
"github.com/prometheus/prometheus/util/annotations"
|
||||
)
|
||||
|
||||
// defaultLookbackDelta mirrors promql's default when the config leaves the
|
||||
// lookback unset; the engine and the storage must agree on it for
|
||||
// last-sample-per-step bucket anchoring.
|
||||
const defaultLookbackDelta = 5 * time.Minute
|
||||
|
||||
// querier is a native storage.Querier over ClickHouse: Select builds SQL
|
||||
// directly from the matchers and hints, with no remote-read protobuf layer.
|
||||
type querier struct {
|
||||
mint, maxt int64
|
||||
client *client
|
||||
}
|
||||
|
||||
var _ storage.Querier = (*querier)(nil)
|
||||
|
||||
func (q *querier) Select(ctx context.Context, sortSeries bool, hints *storage.SelectHints, matchers ...*labels.Matcher) storage.SeriesSet {
|
||||
start, end := q.window(hints)
|
||||
|
||||
seriesQuery, seriesArgs, err := buildSeriesQuery(start, end, matchers)
|
||||
if err != nil {
|
||||
return storage.ErrSeriesSet(err)
|
||||
}
|
||||
lookup, err := q.client.selectSeries(ctx, seriesQuery, seriesArgs)
|
||||
if err != nil {
|
||||
return storage.ErrSeriesSet(err)
|
||||
}
|
||||
if len(lookup.fingerprints) == 0 {
|
||||
return storage.EmptySeriesSet()
|
||||
}
|
||||
|
||||
list, err := q.fetchSamples(ctx, start, end, matchers, lookup, q.lastSamplePerStepFor(ctx, hints))
|
||||
if err != nil {
|
||||
return storage.ErrSeriesSet(err)
|
||||
}
|
||||
|
||||
// The engine assumes storages never emit duplicate label sets.
|
||||
list = sortAndMerge(list)
|
||||
return newSeriesSet(list)
|
||||
}
|
||||
|
||||
func (q *querier) LabelValues(ctx context.Context, name string, hints *storage.LabelHints, matchers ...*labels.Matcher) ([]string, annotations.Annotations, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
if name == metricNameLabel {
|
||||
sb.Select("DISTINCT metric_name AS value")
|
||||
} else {
|
||||
sb.Select(fmt.Sprintf("DISTINCT JSONExtractString(labels, %s) AS value", sb.Var(name)))
|
||||
}
|
||||
adjustedStart, table := timeSeriesTableFor(q.mint, q.maxt)
|
||||
sb.From(fmt.Sprintf("%s.%s", databaseName, table))
|
||||
if err := applySeriesConditions(sb, adjustedStart, q.maxt, matchers); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sb.Where("value != ''")
|
||||
if hints != nil && hints.Limit > 0 {
|
||||
sb.Limit(hints.Limit)
|
||||
}
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
values, err := q.selectStrings(ctx, "LabelValues", query, args)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
slices.Sort(values)
|
||||
return values, nil, nil
|
||||
}
|
||||
|
||||
func (q *querier) LabelNames(ctx context.Context, hints *storage.LabelHints, matchers ...*labels.Matcher) ([]string, annotations.Annotations, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("DISTINCT arrayJoin(JSONExtractKeys(labels)) AS name")
|
||||
adjustedStart, table := timeSeriesTableFor(q.mint, q.maxt)
|
||||
sb.From(fmt.Sprintf("%s.%s", databaseName, table))
|
||||
if err := applySeriesConditions(sb, adjustedStart, q.maxt, matchers); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if hints != nil && hints.Limit > 0 {
|
||||
sb.Limit(hints.Limit)
|
||||
}
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
names, err := q.selectStrings(ctx, "LabelNames", query, args)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
slices.Sort(names)
|
||||
return names, nil, nil
|
||||
}
|
||||
|
||||
func (q *querier) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// window returns the per-selector fetch window from the hints (already
|
||||
// adjusted for offset, @, range and lookback); mint/maxt span the union of
|
||||
// all selectors and are the fallback.
|
||||
func (q *querier) window(hints *storage.SelectHints) (int64, int64) {
|
||||
if hints != nil && hints.Start != 0 && hints.End != 0 && hints.Start <= hints.End {
|
||||
return hints.Start, hints.End
|
||||
}
|
||||
return q.mint, q.maxt
|
||||
}
|
||||
|
||||
// lastSamplePerStepFor decides whether the fetch can keep only the last
|
||||
// sample per step bucket (see lastSamplePerStep). Only instant selectors
|
||||
// (hints.Range == 0) of subquery-free queries qualify: range selectors need
|
||||
// every raw sample, and subquery selectors evaluate at the subquery's own
|
||||
// step while hints carry the top-level step — the subquery-free proof
|
||||
// arrives as QueryTraits in the context. The first evaluation timestamp is
|
||||
// recovered as hints.Start + lookback - 1ms, inverting how the engine
|
||||
// derives hints.Start.
|
||||
func (q *querier) lastSamplePerStepFor(ctx context.Context, hints *storage.SelectHints) *lastSamplePerStep {
|
||||
if hints == nil || hints.Range != 0 || hints.Start <= 0 {
|
||||
return nil
|
||||
}
|
||||
traits, ok := prometheus.QueryTraitsFromContext(ctx)
|
||||
if !ok || !traits.SubqueryFree {
|
||||
return nil
|
||||
}
|
||||
firstEval := hints.Start + q.client.lookbackMs - 1
|
||||
if firstEval > hints.End {
|
||||
// Defensive: never anchor a bucket past the window.
|
||||
firstEval = hints.End
|
||||
}
|
||||
return &lastSamplePerStep{firstEvalMs: firstEval, stepMs: hints.Step}
|
||||
}
|
||||
|
||||
// fetchSamples runs the samples query for the matched series (see
|
||||
// buildSamplesQuery).
|
||||
func (q *querier) fetchSamples(ctx context.Context, start, end int64, matchers []*labels.Matcher, lookup *seriesLookup, lastPerStep *lastSamplePerStep) ([]*series, error) {
|
||||
query, args, err := buildSamplesQuery(start, end, lookup.metricNames, matchers, lastPerStep)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.client.selectSamples(ctx, query, args, lookup)
|
||||
}
|
||||
|
||||
func (q *querier) selectStrings(ctx context.Context, fn, query string, args []any) ([]string, error) {
|
||||
ctx = q.client.withContext(ctx, fn)
|
||||
rows, err := q.client.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []string
|
||||
var v string
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/prometheus/prometheus/model/histogram"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
"github.com/prometheus/prometheus/tsdb/chunkenc"
|
||||
"github.com/prometheus/prometheus/util/annotations"
|
||||
)
|
||||
|
||||
// series is one time series with samples as parallel slices ordered by
|
||||
// timestamp. Deliberately not storage.NewListSeries: that boxes every sample
|
||||
// as an interface value, a per-sample allocation this fetch path exists to
|
||||
// avoid.
|
||||
type series struct {
|
||||
lset labels.Labels
|
||||
ts []int64
|
||||
vs []float64
|
||||
}
|
||||
|
||||
var _ storage.Series = (*series)(nil)
|
||||
|
||||
func (s *series) Labels() labels.Labels {
|
||||
return s.lset
|
||||
}
|
||||
|
||||
func (s *series) Iterator(it chunkenc.Iterator) chunkenc.Iterator {
|
||||
if fit, ok := it.(*floatIterator); ok {
|
||||
fit.reset(s)
|
||||
return fit
|
||||
}
|
||||
fit := &floatIterator{}
|
||||
fit.reset(s)
|
||||
return fit
|
||||
}
|
||||
|
||||
// floatIterator implements chunkenc.Iterator over a series' sample slices.
|
||||
type floatIterator struct {
|
||||
s *series
|
||||
i int
|
||||
}
|
||||
|
||||
var _ chunkenc.Iterator = (*floatIterator)(nil)
|
||||
|
||||
func (it *floatIterator) reset(s *series) {
|
||||
it.s = s
|
||||
it.i = -1
|
||||
}
|
||||
|
||||
func (it *floatIterator) Next() chunkenc.ValueType {
|
||||
it.i++
|
||||
if it.i >= len(it.s.ts) {
|
||||
return chunkenc.ValNone
|
||||
}
|
||||
return chunkenc.ValFloat
|
||||
}
|
||||
|
||||
func (it *floatIterator) Seek(t int64) chunkenc.ValueType { //nolint:govet // stdmethods flags io.Seeker; this is chunkenc.Iterator's Seek
|
||||
if it.i < 0 {
|
||||
it.i = 0
|
||||
}
|
||||
if it.i >= len(it.s.ts) {
|
||||
return chunkenc.ValNone
|
||||
}
|
||||
// The current position, once valid, must not move backwards.
|
||||
if it.s.ts[it.i] >= t {
|
||||
return chunkenc.ValFloat
|
||||
}
|
||||
it.i += sort.Search(len(it.s.ts)-it.i, func(j int) bool {
|
||||
return it.s.ts[it.i+j] >= t
|
||||
})
|
||||
if it.i >= len(it.s.ts) {
|
||||
return chunkenc.ValNone
|
||||
}
|
||||
return chunkenc.ValFloat
|
||||
}
|
||||
|
||||
func (it *floatIterator) At() (int64, float64) {
|
||||
return it.s.ts[it.i], it.s.vs[it.i]
|
||||
}
|
||||
|
||||
func (it *floatIterator) AtHistogram(*histogram.Histogram) (int64, *histogram.Histogram) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (it *floatIterator) AtFloatHistogram(*histogram.FloatHistogram) (int64, *histogram.FloatHistogram) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (it *floatIterator) AtT() int64 {
|
||||
return it.s.ts[it.i]
|
||||
}
|
||||
|
||||
// AtST returns the current start timestamp; not tracked by this storage.
|
||||
func (it *floatIterator) AtST() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (it *floatIterator) Err() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// seriesSet iterates a fully materialized, label-sorted list of series.
|
||||
type seriesSet struct {
|
||||
series []*series
|
||||
i int
|
||||
}
|
||||
|
||||
var _ storage.SeriesSet = (*seriesSet)(nil)
|
||||
|
||||
func newSeriesSet(list []*series) *seriesSet {
|
||||
return &seriesSet{series: list, i: -1}
|
||||
}
|
||||
|
||||
func (s *seriesSet) Next() bool {
|
||||
s.i++
|
||||
return s.i < len(s.series)
|
||||
}
|
||||
|
||||
func (s *seriesSet) At() storage.Series {
|
||||
return s.series[s.i]
|
||||
}
|
||||
|
||||
func (s *seriesSet) Err() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *seriesSet) Warnings() annotations.Annotations {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sortAndMerge orders series by label set and merges identical label sets
|
||||
// by timestamp (first sample wins ties): distinct fingerprints can carry
|
||||
// identical label sets, and the engine assumes storages never emit
|
||||
// duplicates.
|
||||
func sortAndMerge(list []*series) []*series {
|
||||
if len(list) < 2 {
|
||||
return list
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
return labels.Compare(list[i].lset, list[j].lset) < 0
|
||||
})
|
||||
out := list[:1]
|
||||
for _, s := range list[1:] {
|
||||
last := out[len(out)-1]
|
||||
if labels.Compare(last.lset, s.lset) != 0 {
|
||||
out = append(out, s)
|
||||
continue
|
||||
}
|
||||
merged := mergeSamples(last, s)
|
||||
out[len(out)-1] = merged
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeSamples(a, b *series) *series {
|
||||
ts := make([]int64, 0, len(a.ts)+len(b.ts))
|
||||
vs := make([]float64, 0, len(a.ts)+len(b.ts))
|
||||
i, j := 0, 0
|
||||
for i < len(a.ts) && j < len(b.ts) {
|
||||
switch {
|
||||
case a.ts[i] < b.ts[j]:
|
||||
ts = append(ts, a.ts[i])
|
||||
vs = append(vs, a.vs[i])
|
||||
i++
|
||||
case a.ts[i] > b.ts[j]:
|
||||
ts = append(ts, b.ts[j])
|
||||
vs = append(vs, b.vs[j])
|
||||
j++
|
||||
default:
|
||||
ts = append(ts, a.ts[i])
|
||||
vs = append(vs, a.vs[i])
|
||||
i++
|
||||
j++
|
||||
}
|
||||
}
|
||||
ts = append(ts, a.ts[i:]...)
|
||||
vs = append(vs, a.vs[i:]...)
|
||||
ts = append(ts, b.ts[j:]...)
|
||||
vs = append(vs, b.vs[j:]...)
|
||||
return &series{lset: a.lset, ts: ts, vs: vs}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
)
|
||||
|
||||
// buildSeriesQuery renders the series lookup: one row per matched fingerprint
|
||||
// with its labels.
|
||||
func buildSeriesQuery(start, end int64, matchers []*labels.Matcher) (string, []any, error) {
|
||||
adjustedStart, table := timeSeriesTableFor(start, end)
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("fingerprint", "any(labels)")
|
||||
sb.From(fmt.Sprintf("%s.%s", databaseName, table))
|
||||
if err := applySeriesConditions(sb, adjustedStart, end, matchers); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
sb.GroupBy("fingerprint")
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return query, args, nil
|
||||
}
|
||||
|
||||
// buildSamplesQuery renders the samples fetch for the matched series: a
|
||||
// semi-join re-runs the series predicates against the shard-local series
|
||||
// table (complete by fingerprint co-locality, see localTimeSeriesTable — a
|
||||
// GLOBAL broadcast would ship the matched set to every shard, and ClickHouse
|
||||
// materializes the subquery's set per shard before the scan, so it still
|
||||
// engages the fingerprint primary-key column). metricNames (observed on the
|
||||
// matched series when the selector had no __name__ equality) narrows the
|
||||
// primary-key scan. A non-nil lastPerStep groups to the last sample per
|
||||
// step bucket.
|
||||
func buildSamplesQuery(start, end int64, metricNames []string, matchers []*labels.Matcher, lastPerStep *lastSamplePerStep) (string, []any, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
if lastPerStep != nil {
|
||||
// Aliases must not shadow source columns: ClickHouse resolves aliases
|
||||
// in WHERE too, and "max(unix_milli) AS unix_milli" would put an
|
||||
// aggregate into the WHERE clause (error 184).
|
||||
sb.Select("fingerprint", "max(unix_milli) AS ts", "argMax(value, unix_milli) AS val", "argMax(flags, unix_milli) AS fl")
|
||||
} else {
|
||||
sb.Select("fingerprint", "unix_milli", "value", "flags")
|
||||
}
|
||||
sb.From(fmt.Sprintf("%s.%s", databaseName, distributedSamplesV4))
|
||||
|
||||
switch len(metricNames) {
|
||||
case 0:
|
||||
// No name constraint derivable; the primary-key prefix goes unused.
|
||||
case 1:
|
||||
sb.Where(sb.EQ("metric_name", metricNames[0]))
|
||||
default:
|
||||
sb.Where(sb.In("metric_name", sqlbuilder.List(metricNames)))
|
||||
}
|
||||
// Semantically redundant (the fingerprints already come from these
|
||||
// temporalities) but engages the leading primary-key column.
|
||||
sb.Where("temporality IN ['Cumulative', 'Unspecified']")
|
||||
sub := sqlbuilder.NewSelectBuilder()
|
||||
sub.Select("fingerprint")
|
||||
adjustedStart, table := timeSeriesTableFor(start, end)
|
||||
sub.From(fmt.Sprintf("%s.%s", databaseName, localTimeSeriesTable(table)))
|
||||
if err := applySeriesConditions(sub, adjustedStart, end, matchers); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
sb.Where(sb.In("fingerprint", sub))
|
||||
sb.Where(sb.GTE("unix_milli", start), sb.LTE("unix_milli", end))
|
||||
|
||||
if lastPerStep != nil {
|
||||
sb.GroupBy("fingerprint")
|
||||
if expr := lastPerStep.bucketExpr(); expr != "" {
|
||||
sb.GroupBy(expr)
|
||||
}
|
||||
sb.OrderBy("fingerprint", "ts")
|
||||
} else {
|
||||
sb.OrderBy("fingerprint", "unix_milli")
|
||||
}
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return query, args, nil
|
||||
}
|
||||
|
||||
// applySeriesConditions adds the WHERE conditions of a series table scan:
|
||||
// __name__ matchers (all four types) translate to the metric_name column,
|
||||
// every other matcher to a JSONExtractString condition on the labels column.
|
||||
// An equality matcher against "" matches series without the label, mirroring
|
||||
// PromQL, because JSONExtractString returns "" for missing keys. Regexes are
|
||||
// anchored: PromQL matchers match the whole value, ClickHouse match()
|
||||
// searches for a substring.
|
||||
func applySeriesConditions(sb *sqlbuilder.SelectBuilder, start, end int64, matchers []*labels.Matcher) error {
|
||||
for _, m := range matchers {
|
||||
if m.Name != metricNameLabel {
|
||||
continue
|
||||
}
|
||||
switch m.Type {
|
||||
case labels.MatchEqual:
|
||||
sb.Where(sb.EQ("metric_name", m.Value))
|
||||
case labels.MatchNotEqual:
|
||||
sb.Where(sb.NE("metric_name", m.Value))
|
||||
case labels.MatchRegexp:
|
||||
sb.Where(fmt.Sprintf("match(metric_name, %s)", sb.Var(anchorRegex(m.Value))))
|
||||
case labels.MatchNotRegexp:
|
||||
sb.Where(fmt.Sprintf("NOT match(metric_name, %s)", sb.Var(anchorRegex(m.Value))))
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported matcher type %q for __name__", m.Type)
|
||||
}
|
||||
}
|
||||
|
||||
sb.Where("temporality IN ['Cumulative', 'Unspecified']")
|
||||
// Inclusive upper bound: registration rows are hour-floored (and 6h/1d/1w
|
||||
// for the rollup tables) by the exporter, so a series first registered in
|
||||
// the bucket starting exactly at `end` would otherwise be invisible while
|
||||
// its samples (<= end) are in range.
|
||||
sb.Where(sb.GTE("unix_milli", start), sb.LTE("unix_milli", end))
|
||||
|
||||
for _, m := range matchers {
|
||||
if m.Name == metricNameLabel {
|
||||
continue
|
||||
}
|
||||
switch m.Type {
|
||||
case labels.MatchEqual:
|
||||
sb.Where(fmt.Sprintf("JSONExtractString(labels, %s) = %s", sb.Var(m.Name), sb.Var(m.Value)))
|
||||
case labels.MatchNotEqual:
|
||||
sb.Where(fmt.Sprintf("JSONExtractString(labels, %s) != %s", sb.Var(m.Name), sb.Var(m.Value)))
|
||||
case labels.MatchRegexp:
|
||||
sb.Where(fmt.Sprintf("match(JSONExtractString(labels, %s), %s)", sb.Var(m.Name), sb.Var(anchorRegex(m.Value))))
|
||||
case labels.MatchNotRegexp:
|
||||
sb.Where(fmt.Sprintf("NOT match(JSONExtractString(labels, %s), %s)", sb.Var(m.Name), sb.Var(anchorRegex(m.Value))))
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported matcher type %q", m.Type)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// anchorRegex turns a PromQL regex into its fully-anchored form (see
|
||||
// applySeriesConditions).
|
||||
func anchorRegex(v string) string {
|
||||
return "^(?:" + v + ")$"
|
||||
}
|
||||
|
||||
// lastSamplePerStep reduces an instant-selector fetch to the last sample of
|
||||
// each step bucket: bucket 0 is (start, firstEval], bucket i is
|
||||
// (firstEval+(i-1)·step, firstEval+i·step]. Anchoring buckets at the first
|
||||
// evaluation timestamp makes every bucket boundary an evaluation timestamp,
|
||||
// so a non-final sample of a bucket can never be the latest sample in any
|
||||
// (t-lookback, t] the engine resolves — the reduction is lossless. Real
|
||||
// timestamps are preserved, so the engine's own lookback and staleness
|
||||
// handling remain exact.
|
||||
type lastSamplePerStep struct {
|
||||
firstEvalMs int64
|
||||
stepMs int64
|
||||
}
|
||||
|
||||
func (t *lastSamplePerStep) bucketExpr() string {
|
||||
if t.stepMs <= 0 {
|
||||
// Instant query: a single evaluation at firstEval; one bucket.
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"if(unix_milli <= %d, 0, intDiv(unix_milli - %d - 1, %d) + 1)",
|
||||
t.firstEvalMs, t.firstEvalMs, t.stepMs,
|
||||
)
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func mustMatcher(t *testing.T, mt labels.MatchType, name, value string) *labels.Matcher {
|
||||
t.Helper()
|
||||
m, err := labels.NewMatcher(mt, name, value)
|
||||
require.NoError(t, err)
|
||||
return m
|
||||
}
|
||||
|
||||
func TestTimeSeriesTableFor(t *testing.T) {
|
||||
base := time.Date(2026, 7, 10, 3, 27, 0, 0, time.UTC).UnixMilli()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
span time.Duration
|
||||
wantTable string
|
||||
roundTo time.Duration
|
||||
}{
|
||||
{"under 6h uses hourly table", 2 * time.Hour, distributedTimeSeriesV4, time.Hour},
|
||||
{"under 1d uses 6h table", 12 * time.Hour, distributedTimeSeriesV46hrs, 6 * time.Hour},
|
||||
{"under 1w uses 1d table", 3 * 24 * time.Hour, distributedTimeSeriesV41day, 24 * time.Hour},
|
||||
{"over 1w uses 1w table", 10 * 24 * time.Hour, distributedTimeSeriesV41week, 7 * 24 * time.Hour},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
start, table := timeSeriesTableFor(base, base+tt.span.Milliseconds())
|
||||
assert.Equal(t, tt.wantTable, table)
|
||||
assert.Zero(t, start%tt.roundTo.Milliseconds())
|
||||
assert.LessOrEqual(t, start, base)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSeriesQuery(t *testing.T) {
|
||||
start := int64(1_700_000_000_000)
|
||||
end := start + time.Hour.Milliseconds()
|
||||
// The series table window rounds down to the table's bucket boundary.
|
||||
adjustedStart := start - (start % time.Hour.Milliseconds())
|
||||
|
||||
t.Run("equality name and label matchers", func(t *testing.T) {
|
||||
query, args, err := buildSeriesQuery(start, end, []*labels.Matcher{
|
||||
mustMatcher(t, labels.MatchEqual, "__name__", "http_requests_total"),
|
||||
mustMatcher(t, labels.MatchEqual, "job", "api"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t,
|
||||
"SELECT fingerprint, any(labels) FROM signoz_metrics.distributed_time_series_v4 WHERE metric_name = ? AND temporality IN ['Cumulative', 'Unspecified'] AND unix_milli >= ? AND unix_milli <= ? AND JSONExtractString(labels, ?) = ? GROUP BY fingerprint",
|
||||
query,
|
||||
)
|
||||
assert.Equal(t, []any{"http_requests_total", adjustedStart, end, "job", "api"}, args)
|
||||
})
|
||||
|
||||
t.Run("regex matchers are anchored", func(t *testing.T) {
|
||||
_, args, err := buildSeriesQuery(start, end, []*labels.Matcher{
|
||||
mustMatcher(t, labels.MatchEqual, "__name__", "up"),
|
||||
mustMatcher(t, labels.MatchRegexp, "instance", "prod.*"),
|
||||
mustMatcher(t, labels.MatchNotRegexp, "env", "dev|test"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []any{"up", adjustedStart, end, "instance", "^(?:prod.*)$", "env", "^(?:dev|test)$"}, args)
|
||||
})
|
||||
|
||||
t.Run("regex name matcher uses metric_name column", func(t *testing.T) {
|
||||
query, args, err := buildSeriesQuery(start, end, []*labels.Matcher{
|
||||
mustMatcher(t, labels.MatchRegexp, "__name__", "node_cpu.*|node_memory.*"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, query, "match(metric_name, ?)")
|
||||
assert.NotContains(t, query, "JSONExtractString")
|
||||
assert.Equal(t, []any{"^(?:node_cpu.*|node_memory.*)$", adjustedStart, end}, args)
|
||||
})
|
||||
|
||||
t.Run("no name matcher omits metric_name condition", func(t *testing.T) {
|
||||
query, _, err := buildSeriesQuery(start, end, []*labels.Matcher{
|
||||
mustMatcher(t, labels.MatchEqual, "job", "api"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, query, "metric_name")
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildSamplesQuery(t *testing.T) {
|
||||
start := int64(1_700_000_000_000)
|
||||
end := start + time.Hour.Milliseconds()
|
||||
adjustedStart := start - (start % time.Hour.Milliseconds())
|
||||
matchers := []*labels.Matcher{
|
||||
mustMatcher(t, labels.MatchEqual, "__name__", "up"),
|
||||
mustMatcher(t, labels.MatchEqual, "job", "api"),
|
||||
}
|
||||
|
||||
t.Run("raw fetch filters by a shard-local semi-join", func(t *testing.T) {
|
||||
query, args, err := buildSamplesQuery(start, end, []string{"up"}, matchers, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, query, "fingerprint IN (SELECT fingerprint FROM signoz_metrics.time_series_v4 WHERE ")
|
||||
assert.NotContains(t, query, "GLOBAL IN")
|
||||
assert.Contains(t, query, "ORDER BY fingerprint, unix_milli")
|
||||
// Args follow placeholder order: samples metric name, the semi-join's
|
||||
// series predicates, then the samples window bounds.
|
||||
assert.Equal(t, []any{"up", "up", adjustedStart, end, "job", "api", start, end}, args)
|
||||
})
|
||||
|
||||
t.Run("last-sample-per-step groups by step bucket anchored at first eval", func(t *testing.T) {
|
||||
lastPerStep := &lastSamplePerStep{firstEvalMs: start + 299_999, stepMs: 60_000}
|
||||
query, _, err := buildSamplesQuery(start, end, []string{"up"}, matchers, lastPerStep)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, query, "argMax(value, unix_milli) AS val")
|
||||
assert.Contains(t, query, "argMax(flags, unix_milli) AS fl")
|
||||
assert.Contains(t, query, "GROUP BY fingerprint, if(unix_milli <= 1700000299999, 0, intDiv(unix_milli - 1700000299999 - 1, 60000) + 1)")
|
||||
assert.Contains(t, query, "ORDER BY fingerprint, ts")
|
||||
// Aliases must not shadow the source columns referenced in WHERE.
|
||||
assert.NotContains(t, query, "AS unix_milli")
|
||||
assert.NotContains(t, query, "AS value")
|
||||
assert.NotContains(t, query, "AS flags")
|
||||
})
|
||||
|
||||
t.Run("instant query keeps one bucket", func(t *testing.T) {
|
||||
lastPerStep := &lastSamplePerStep{firstEvalMs: end, stepMs: 0}
|
||||
query, _, err := buildSamplesQuery(start, end, []string{"up"}, matchers, lastPerStep)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, query, "GROUP BY fingerprint ORDER BY fingerprint, ts")
|
||||
assert.NotContains(t, query, "intDiv")
|
||||
})
|
||||
|
||||
t.Run("multiple metric names from regex selector", func(t *testing.T) {
|
||||
query, args, err := buildSamplesQuery(start, end, []string{"node_cpu", "node_memory"}, matchers, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, query, "metric_name IN (?, ?)")
|
||||
assert.Equal(t, []any{"node_cpu", "node_memory", "up", adjustedStart, end, "job", "api", start, end}, args)
|
||||
})
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import "time"
|
||||
|
||||
// TODO(srikanthccv): consolidate the metrics table names and the
|
||||
// window-to-table selection across the query builders (telemetrymetrics)
|
||||
// and the prometheus providers instead of each package carrying its own
|
||||
// copy.
|
||||
|
||||
const (
|
||||
// metricNameLabel is the reserved PromQL label holding the metric name.
|
||||
metricNameLabel string = "__name__"
|
||||
|
||||
databaseName string = "signoz_metrics"
|
||||
distributedTimeSeriesV4 string = "distributed_time_series_v4"
|
||||
distributedTimeSeriesV46hrs string = "distributed_time_series_v4_6hrs"
|
||||
distributedTimeSeriesV41day string = "distributed_time_series_v4_1day"
|
||||
distributedTimeSeriesV41week string = "distributed_time_series_v4_1week"
|
||||
distributedSamplesV4 string = "distributed_samples_v4"
|
||||
|
||||
localTimeSeriesV4 string = "time_series_v4"
|
||||
localTimeSeriesV46hrs string = "time_series_v4_6hrs"
|
||||
localTimeSeriesV41day string = "time_series_v4_1day"
|
||||
localTimeSeriesV41week string = "time_series_v4_1week"
|
||||
)
|
||||
|
||||
// localTimeSeriesTable maps a distributed time series table to its shard-local
|
||||
// table. Samples and time series shard on the same key
|
||||
// (cityHash64(env, temporality, metric_name, fingerprint)), so a query whose
|
||||
// top-level FROM is the distributed samples table can join or semi-join the
|
||||
// local time series table inside each shard: the shard rewrite runs the
|
||||
// subquery against the shard's own series rows, which are exactly the series
|
||||
// of the shard's samples. No broadcast, no initiator-side join.
|
||||
func localTimeSeriesTable(distributed string) string {
|
||||
switch distributed {
|
||||
case distributedTimeSeriesV46hrs:
|
||||
return localTimeSeriesV46hrs
|
||||
case distributedTimeSeriesV41day:
|
||||
return localTimeSeriesV41day
|
||||
case distributedTimeSeriesV41week:
|
||||
return localTimeSeriesV41week
|
||||
default:
|
||||
return localTimeSeriesV4
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
oneHourInMilliseconds = time.Hour.Milliseconds()
|
||||
sixHoursInMilliseconds = time.Hour.Milliseconds() * 6
|
||||
oneDayInMilliseconds = time.Hour.Milliseconds() * 24
|
||||
oneWeekInMilliseconds = time.Hour.Milliseconds() * 24 * 7
|
||||
)
|
||||
|
||||
// timeSeriesTableFor returns the adjusted start and the time series table for
|
||||
// the window. Time series tables hold one row per (fingerprint, bucket), with
|
||||
// bucket granularities of 1h, 6h, 1d and 1w; the start is rounded down to the
|
||||
// bucket boundary so a window beginning mid-bucket still matches the bucket's
|
||||
// row.
|
||||
func timeSeriesTableFor(start, end int64) (int64, string) {
|
||||
switch {
|
||||
case end-start < sixHoursInMilliseconds:
|
||||
return start - (start % oneHourInMilliseconds), distributedTimeSeriesV4
|
||||
case end-start < oneDayInMilliseconds:
|
||||
return start - (start % sixHoursInMilliseconds), distributedTimeSeriesV46hrs
|
||||
case end-start < oneWeekInMilliseconds:
|
||||
return start - (start % oneDayInMilliseconds), distributedTimeSeriesV41day
|
||||
default:
|
||||
return start - (start % oneWeekInMilliseconds), distributedTimeSeriesV41week
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,492 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
)
|
||||
|
||||
// The compiler turns PromQL subtrees into single ClickHouse queries built on
|
||||
// the timeSeries*ToGrid aggregate functions (CH >= 25.6), whose semantics
|
||||
// were verified against this repo's vendored engine: exact extrapolatedRate
|
||||
// behavior including counter resets, the counter zero-point clamp, the
|
||||
// 1.1x-average extrapolation threshold, left-open windows, the >= 2 samples
|
||||
// rule, stale-marker shadowing, and millisecond grid starts. Sample rows
|
||||
// never leave ClickHouse: one row per output series comes back, holding the
|
||||
// whole grid as an array.
|
||||
//
|
||||
// Scope (the allowlist): an optional sum/min/max/avg/count by/without
|
||||
// aggregation over a core unit — a rate/increase/delta/irate/idelta range
|
||||
// selection, an instant vector selection, or an avg/min/max/sum/count/last
|
||||
// _over_time window — plus number-literal arithmetic/comparisons and unary
|
||||
// minus on top. Units inside fixed-resolution subqueries evaluate on the
|
||||
// subquery's own grid. Everything else either falls back to the engine over
|
||||
// this package's querier, or — when a transpilable subtree sits under a
|
||||
// non-transpilable node — runs hybrid: the subtree's grids are computed in
|
||||
// ClickHouse and substituted into the engine as synthetic series (see
|
||||
// compiler_exec.go). See doc.go for the fallback list and the reasons behind
|
||||
// each entry.
|
||||
|
||||
// rangeFn is a transpilable range-vector function.
|
||||
type rangeFn string
|
||||
|
||||
const (
|
||||
fnRate rangeFn = "rate"
|
||||
fnIncrease rangeFn = "increase"
|
||||
fnDelta rangeFn = "delta"
|
||||
fnIRate rangeFn = "irate"
|
||||
fnIDelta rangeFn = "idelta"
|
||||
)
|
||||
|
||||
var gridFunction = map[rangeFn]string{
|
||||
fnRate: "timeSeriesRateToGrid",
|
||||
fnIncrease: "timeSeriesRateToGrid", // increase == rate * range seconds, exactly (same factor algebra)
|
||||
fnDelta: "timeSeriesDeltaToGrid",
|
||||
fnIRate: "timeSeriesInstantRateToGrid",
|
||||
fnIDelta: "timeSeriesInstantDeltaToGrid",
|
||||
}
|
||||
|
||||
// scalarOp is one number-literal arithmetic or comparison applied to a
|
||||
// compiled vector, evaluated in Go during assembly with the same float64
|
||||
// operations the engine uses.
|
||||
type scalarOp struct {
|
||||
op parser.ItemType
|
||||
scalar float64
|
||||
scalarOnLeft bool
|
||||
returnBool bool
|
||||
}
|
||||
|
||||
// isComparison reports whether the op is a filtering/bool comparison, which
|
||||
// preserves the metric name (arithmetic drops it).
|
||||
func (o scalarOp) isComparison() bool {
|
||||
return o.op.IsComparisonOperator()
|
||||
}
|
||||
|
||||
// unitKind is the selector shape at the bottom of a core unit.
|
||||
type unitKind int
|
||||
|
||||
const (
|
||||
// unitRange: rate/increase/delta/irate/idelta over a matrix selector.
|
||||
unitRange unitKind = iota
|
||||
// unitInstant: a plain vector selector resolved per grid point with
|
||||
// lookback and stale-marker shadowing.
|
||||
unitInstant
|
||||
// unitOverTime: avg/min/max/sum/count/last_over_time over a matrix
|
||||
// selector (aggregation over the window's samples, stale rows excluded).
|
||||
unitOverTime
|
||||
)
|
||||
|
||||
// coreUnit is one transpilable subtree: selector [-> range function] ->
|
||||
// optional aggregation -> scalar op pipeline.
|
||||
type coreUnit struct {
|
||||
kind unitKind
|
||||
matchers []*labels.Matcher
|
||||
offsetMs int64
|
||||
fn rangeFn // unitRange
|
||||
overFn string // unitOverTime: avg|min|max|sum|count|last
|
||||
rangeMs int64 // unitRange/unitOverTime window
|
||||
|
||||
hasAgg bool
|
||||
aggOp parser.ItemType // SUM MIN MAX AVG COUNT
|
||||
by bool
|
||||
grouping []string
|
||||
|
||||
ops []scalarOp
|
||||
}
|
||||
|
||||
// keepsName reports whether the unit's output series keep their real
|
||||
// __name__: bare/comparison-filtered instant selectors and last_over_time do
|
||||
// (it returns the raw sample, name included); range functions, the other
|
||||
// *_over_time functions, aggregations, arithmetic and bool comparisons all
|
||||
// drop it — a bool comparison returns 0/1, not the sample, so the engine
|
||||
// drops the name there too. Units that keep the name cannot be substituted
|
||||
// as synthetic series in hybrid plans — the synthetic name would replace
|
||||
// the real one — but transpile fine as full plans, where assembly emits the
|
||||
// real names.
|
||||
func (u *coreUnit) keepsName() bool {
|
||||
nameKeepingSelector := u.kind == unitInstant || (u.kind == unitOverTime && u.overFn == "last")
|
||||
if !nameKeepingSelector || u.hasAgg {
|
||||
return false
|
||||
}
|
||||
for _, op := range u.ops {
|
||||
if !op.isComparison() || op.returnBool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// gridContext is the evaluation grid a unit computes on. The query grid for
|
||||
// top-level units; for units inside subqueries, the subquery's own grid:
|
||||
// epoch-aligned multiples of its resolution covering the subquery window,
|
||||
// exactly as the engine derives it (engine.go, *parser.SubqueryExpr case).
|
||||
type gridContext struct {
|
||||
startMs int64
|
||||
endMs int64
|
||||
stepMs int64
|
||||
}
|
||||
|
||||
// subqueryGrid derives the inner grid for a subquery evaluated on outer:
|
||||
// interval S, end = outer end − offset, start = first multiple of S strictly
|
||||
// greater than outer start − offset − range.
|
||||
func subqueryGrid(outer gridContext, rangeMs, stepMs, offsetMs int64) gridContext {
|
||||
lower := outer.startMs - offsetMs - rangeMs
|
||||
start := stepMs * (lower / stepMs)
|
||||
if start <= lower {
|
||||
start += stepMs
|
||||
}
|
||||
return gridContext{startMs: start, endMs: outer.endMs - offsetMs, stepMs: stepMs}
|
||||
}
|
||||
|
||||
// transpiledUnit is a coreUnit scheduled for execution, named for hybrid
|
||||
// substitution, carrying the grid it evaluates on.
|
||||
type transpiledUnit struct {
|
||||
core coreUnit
|
||||
name string // __signoz_transpiled_<n>__
|
||||
grid gridContext
|
||||
}
|
||||
|
||||
// transpilePlan is the outcome of classifying a query.
|
||||
type transpilePlan struct {
|
||||
units []*transpiledUnit
|
||||
grid gridContext // the query's top-level grid
|
||||
// full is set when the entire query is units[0]; otherwise rewritten
|
||||
// holds the query with each unit replaced by a synthetic selector, to be
|
||||
// evaluated by the engine over a hybrid storage.
|
||||
full bool
|
||||
rewritten string
|
||||
}
|
||||
|
||||
const syntheticNamePrefix = "__signoz_transpiled_"
|
||||
|
||||
func syntheticName(i int) string {
|
||||
return fmt.Sprintf("%s%d__", syntheticNamePrefix, i)
|
||||
}
|
||||
|
||||
// classifyCore matches a subtree against the transpilable core shape.
|
||||
// stepMs gates second-granularity: the grid functions take whole-second step
|
||||
// and window parameters (grid *starts* are millisecond-precise).
|
||||
func classifyCore(node parser.Expr, stepMs int64) (*coreUnit, bool) {
|
||||
unit := &coreUnit{}
|
||||
|
||||
expr := node
|
||||
// Peel scalar ops and parens off the top, outermost first; ops apply in
|
||||
// evaluation order, so prepend while peeling.
|
||||
for {
|
||||
switch n := expr.(type) {
|
||||
case *parser.ParenExpr:
|
||||
expr = n.Expr
|
||||
continue
|
||||
case *parser.UnaryExpr:
|
||||
if n.Op != parser.SUB {
|
||||
expr = n.Expr // unary '+' is a no-op
|
||||
continue
|
||||
}
|
||||
// -x == -1 * x for every float64 (incl. NaN and signed zero).
|
||||
unit.ops = append([]scalarOp{{op: parser.MUL, scalar: -1}}, unit.ops...)
|
||||
expr = n.Expr
|
||||
continue
|
||||
case *parser.StepInvariantExpr:
|
||||
// @-pinned expressions evaluate on a different grid.
|
||||
return nil, false
|
||||
case *parser.BinaryExpr:
|
||||
lit, litOnLeft, ok := numberLiteralSide(n)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if !n.Op.IsOperator() && !n.Op.IsComparisonOperator() {
|
||||
return nil, false
|
||||
}
|
||||
if n.Op == parser.ATAN2 {
|
||||
// atan2 is arithmetic in PromQL but rarely used; keep the
|
||||
// allowlist tight.
|
||||
return nil, false
|
||||
}
|
||||
returnBool := n.ReturnBool
|
||||
unit.ops = append([]scalarOp{{op: n.Op, scalar: lit, scalarOnLeft: litOnLeft, returnBool: returnBool}}, unit.ops...)
|
||||
if litOnLeft {
|
||||
expr = n.RHS
|
||||
} else {
|
||||
expr = n.LHS
|
||||
}
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Optional aggregation.
|
||||
if agg, ok := expr.(*parser.AggregateExpr); ok {
|
||||
switch agg.Op {
|
||||
case parser.SUM, parser.MIN, parser.MAX, parser.AVG, parser.COUNT:
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
for _, g := range agg.Grouping {
|
||||
if g == metricNameLabel {
|
||||
// by(__name__)/without(__name__) over synthetic or compiled
|
||||
// output needs name bookkeeping the compiler doesn't do.
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
unit.hasAgg = true
|
||||
unit.aggOp = agg.Op
|
||||
unit.by = !agg.Without
|
||||
unit.grouping = agg.Grouping
|
||||
expr = agg.Expr
|
||||
for {
|
||||
if p, ok := expr.(*parser.ParenExpr); ok {
|
||||
expr = p.Expr
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// The grid functions take whole-second steps; stepMs == 0 is an instant
|
||||
// query (single-point grid).
|
||||
if stepMs < 0 || stepMs%1000 != 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Bare instant selector: resolved per grid point with lookback and
|
||||
// stale-marker shadowing (see compiler_sql.go).
|
||||
if vs, ok := expr.(*parser.VectorSelector); ok {
|
||||
// A duration expression (offset step(), offset range()*2, ...) is
|
||||
// resolved into OriginalOffset only at evaluation time; at
|
||||
// classification time the field still holds its zero value, so
|
||||
// transpiling would silently use the wrong offset.
|
||||
if vs.Timestamp != nil || vs.StartOrEnd != 0 || vs.Anchored || vs.Smoothed || vs.OriginalOffsetExpr != nil {
|
||||
return nil, false
|
||||
}
|
||||
offsetMs := vs.OriginalOffset.Milliseconds()
|
||||
if offsetMs < 0 {
|
||||
return nil, false
|
||||
}
|
||||
unit.kind = unitInstant
|
||||
unit.offsetMs = offsetMs
|
||||
unit.matchers = vs.LabelMatchers
|
||||
return unit, true
|
||||
}
|
||||
|
||||
// Range or *_over_time function over a plain matrix selector.
|
||||
call, ok := expr.(*parser.Call)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
var fn rangeFn
|
||||
var overFn string
|
||||
switch call.Func.Name {
|
||||
case "rate":
|
||||
fn = fnRate
|
||||
case "increase":
|
||||
fn = fnIncrease
|
||||
case "delta":
|
||||
fn = fnDelta
|
||||
case "irate":
|
||||
fn = fnIRate
|
||||
case "idelta":
|
||||
fn = fnIDelta
|
||||
case "avg_over_time", "min_over_time", "max_over_time", "sum_over_time", "count_over_time", "last_over_time":
|
||||
overFn = strings.TrimSuffix(call.Func.Name, "_over_time")
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
if len(call.Args) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
ms, ok := call.Args[0].(*parser.MatrixSelector)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
vs, ok := ms.VectorSelector.(*parser.VectorSelector)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
// Duration expressions resolve at evaluation time (see the instant
|
||||
// selector case above); Range/OriginalOffset would be read as zero here.
|
||||
if vs.Timestamp != nil || vs.StartOrEnd != 0 || vs.Anchored || vs.Smoothed || vs.OriginalOffsetExpr != nil || ms.RangeExpr != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
rangeMs := ms.Range.Milliseconds()
|
||||
offsetMs := vs.OriginalOffset.Milliseconds()
|
||||
if rangeMs <= 0 || rangeMs%1000 != 0 || offsetMs < 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if overFn != "" {
|
||||
unit.kind = unitOverTime
|
||||
unit.overFn = overFn
|
||||
} else {
|
||||
unit.kind = unitRange
|
||||
unit.fn = fn
|
||||
}
|
||||
unit.rangeMs = rangeMs
|
||||
unit.offsetMs = offsetMs
|
||||
unit.matchers = vs.LabelMatchers
|
||||
return unit, true
|
||||
}
|
||||
|
||||
// numberLiteralSide returns the number literal on one side of a binary
|
||||
// expression (peeling parens and unary minus), and which side it is on.
|
||||
func numberLiteralSide(b *parser.BinaryExpr) (float64, bool, bool) {
|
||||
if v, ok := literalValue(b.LHS); ok {
|
||||
return v, true, true
|
||||
}
|
||||
if v, ok := literalValue(b.RHS); ok {
|
||||
return v, false, true
|
||||
}
|
||||
return 0, false, false
|
||||
}
|
||||
|
||||
func literalValue(e parser.Expr) (float64, bool) {
|
||||
neg := false
|
||||
for {
|
||||
switch n := e.(type) {
|
||||
case *parser.ParenExpr:
|
||||
e = n.Expr
|
||||
continue
|
||||
case *parser.StepInvariantExpr:
|
||||
e = n.Expr
|
||||
continue
|
||||
case *parser.UnaryExpr:
|
||||
if n.Op == parser.SUB {
|
||||
neg = !neg
|
||||
}
|
||||
e = n.Expr
|
||||
continue
|
||||
case *parser.NumberLiteral:
|
||||
if neg {
|
||||
return -n.Val, true
|
||||
}
|
||||
return n.Val, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// classify builds the compile plan for a query: full when the root is a core
|
||||
// unit, hybrid when core units sit strictly below the root (including inside
|
||||
// fixed-resolution subqueries, computed on the subquery grid), none
|
||||
// otherwise.
|
||||
func classify(root parser.Expr, grid gridContext) (*transpilePlan, bool) {
|
||||
if unit, ok := classifyCore(root, grid.stepMs); ok {
|
||||
return &transpilePlan{
|
||||
units: []*transpiledUnit{{core: *unit, name: syntheticName(0), grid: grid}},
|
||||
grid: grid,
|
||||
full: true,
|
||||
}, true
|
||||
}
|
||||
|
||||
plan := &transpilePlan{grid: grid}
|
||||
rewritten := rewrite(root, grid, plan, false)
|
||||
if len(plan.units) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
plan.rewritten = rewritten.String()
|
||||
return plan, true
|
||||
}
|
||||
|
||||
// rewrite walks top-down replacing maximal transpilable subtrees with synthetic
|
||||
// vector selectors. nameSensitive marks scopes where an ancestor's semantics
|
||||
// depend on __name__ (grouping or vector matching on it): synthetic series
|
||||
// carry a synthetic __name__, so substitution there would change results.
|
||||
// Fixed-resolution subqueries recurse with the subquery's own grid; scopes
|
||||
// whose evaluation grid is unknowable (@-pinned, default-resolution
|
||||
// subqueries) are not entered.
|
||||
func rewrite(node parser.Expr, grid gridContext, plan *transpilePlan, nameSensitive bool) parser.Expr {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !nameSensitive {
|
||||
// Units whose output keeps the real __name__ (bare instant selectors)
|
||||
// cannot be substituted: the synthetic name would replace it in the
|
||||
// engine's output. They still compile as full plans.
|
||||
if unit, ok := classifyCore(node, grid.stepMs); ok && !unit.keepsName() {
|
||||
cu := &transpiledUnit{core: *unit, name: syntheticName(len(plan.units)), grid: grid}
|
||||
plan.units = append(plan.units, cu)
|
||||
return &parser.VectorSelector{
|
||||
Name: cu.name,
|
||||
LabelMatchers: []*labels.Matcher{
|
||||
labels.MustNewMatcher(labels.MatchEqual, metricNameLabel, cu.name),
|
||||
},
|
||||
PosRange: node.PositionRange(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch n := node.(type) {
|
||||
case *parser.ParenExpr:
|
||||
n.Expr = rewrite(n.Expr, grid, plan, nameSensitive)
|
||||
case *parser.UnaryExpr:
|
||||
n.Expr = rewrite(n.Expr, grid, plan, nameSensitive)
|
||||
case *parser.AggregateExpr:
|
||||
sensitive := nameSensitive || groupingUsesName(n.Grouping)
|
||||
n.Expr = rewrite(n.Expr, grid, plan, sensitive)
|
||||
// n.Param is a scalar/string; nothing transpilable inside for our core.
|
||||
case *parser.Call:
|
||||
for i, arg := range n.Args {
|
||||
n.Args[i] = rewrite(arg, grid, plan, nameSensitive)
|
||||
}
|
||||
case *parser.BinaryExpr:
|
||||
sensitive := nameSensitive || vectorMatchingUsesName(n.VectorMatching)
|
||||
n.LHS = rewrite(n.LHS, grid, plan, sensitive)
|
||||
n.RHS = rewrite(n.RHS, grid, plan, sensitive)
|
||||
case *parser.SubqueryExpr:
|
||||
// The alert-smoothing idiom fn_over_time((expr)[R:S]) dominates real
|
||||
// rule fleets; inner units evaluate on the subquery grid, and the
|
||||
// engine does the smoothing over the synthetic series. Requires an
|
||||
// explicit whole-second resolution (S == 0 needs the engine's
|
||||
// default-interval function) and no @ pinning.
|
||||
stepMs := n.Step.Milliseconds()
|
||||
rangeMs := n.Range.Milliseconds()
|
||||
offsetMs := n.OriginalOffset.Milliseconds()
|
||||
if n.Timestamp == nil && n.StartOrEnd == 0 &&
|
||||
n.RangeExpr == nil && n.StepExpr == nil && n.OriginalOffsetExpr == nil &&
|
||||
stepMs > 0 && stepMs%1000 == 0 && rangeMs%1000 == 0 && offsetMs >= 0 {
|
||||
inner := subqueryGrid(grid, rangeMs, stepMs, offsetMs)
|
||||
n.Expr = rewrite(n.Expr, inner, plan, nameSensitive)
|
||||
}
|
||||
case *parser.StepInvariantExpr, *parser.MatrixSelector,
|
||||
*parser.VectorSelector, *parser.NumberLiteral, *parser.StringLiteral:
|
||||
// Leaves, or scopes substitution must not enter.
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func groupingUsesName(grouping []string) bool {
|
||||
for _, g := range grouping {
|
||||
if g == metricNameLabel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func vectorMatchingUsesName(vm *parser.VectorMatching) bool {
|
||||
if vm == nil {
|
||||
return false
|
||||
}
|
||||
for _, l := range append(append([]string{}, vm.MatchingLabels...), vm.Include...) {
|
||||
if l == metricNameLabel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Default (all-labels) matching ignores __name__, and by()/ignoring()
|
||||
// lists were checked above.
|
||||
return false
|
||||
}
|
||||
|
||||
// isSyntheticSelector reports whether matchers target a compiled unit.
|
||||
func isSyntheticSelector(matchers []*labels.Matcher) (string, bool) {
|
||||
for _, m := range matchers {
|
||||
if m.Name == metricNameLabel && m.Type == labels.MatchEqual && strings.HasPrefix(m.Value, syntheticNamePrefix) {
|
||||
return m.Value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestClassifyCorpus measures real-workload compiler coverage: it classifies
|
||||
// every query of a JSON-lines corpus (one JSON-encoded PromQL string per
|
||||
// line) with the live classifier and reports full / hybrid / fallback
|
||||
// shares. Skipped unless PROMQL_CORPUS points to one or more files
|
||||
// (comma-separated). Dashboard template variables are substituted with
|
||||
// placeholder values before parsing, mirroring the production render step.
|
||||
//
|
||||
// PROMQL_CORPUS=corpus-a.jsonl,corpus-b.jsonl go test -run TestClassifyCorpus -v
|
||||
func TestClassifyCorpus(t *testing.T) {
|
||||
corpus := os.Getenv("PROMQL_CORPUS")
|
||||
if corpus == "" {
|
||||
t.Skip("PROMQL_CORPUS not set")
|
||||
}
|
||||
|
||||
varRe := regexp.MustCompile(`\{\{\s*\.?[\w.]+\s*\}\}|\[\[\s*[\w.]+\s*\]\]|\$[\w.]+`)
|
||||
promParser := parser.NewParser(parser.Options{})
|
||||
|
||||
for _, path := range strings.Split(corpus, ",") {
|
||||
f, err := os.Open(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
var full, hybrid, fallbackInstant, fallbackOther, parseErrs int
|
||||
fallbackReasons := map[string]int{}
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
var query string
|
||||
require.NoError(t, json.Unmarshal(scanner.Bytes(), &query))
|
||||
query = varRe.ReplaceAllString(query, "placeholder")
|
||||
|
||||
expr, err := promParser.ParseExpr(query)
|
||||
if err != nil {
|
||||
parseErrs++
|
||||
continue
|
||||
}
|
||||
|
||||
plan, ok := classify(expr, gridContext{startMs: 1_700_000_000_000, endMs: 1_700_007_200_000, stepMs: 60_000})
|
||||
switch {
|
||||
case ok && plan.full:
|
||||
full++
|
||||
case ok:
|
||||
hybrid++
|
||||
default:
|
||||
reason := fallbackShape(expr)
|
||||
fallbackReasons[reason]++
|
||||
if reason == "instant-selector shape (last-sample-per-step engine path)" {
|
||||
fallbackInstant++
|
||||
} else {
|
||||
fallbackOther++
|
||||
}
|
||||
}
|
||||
}
|
||||
require.NoError(t, scanner.Err())
|
||||
_ = f.Close()
|
||||
|
||||
total := full + hybrid + fallbackInstant + fallbackOther
|
||||
if total == 0 {
|
||||
t.Logf("%s: no parseable queries (%d parse errors)", path, parseErrs)
|
||||
continue
|
||||
}
|
||||
t.Logf("%s: %d queries — full=%d (%.0f%%) hybrid=%d (%.0f%%) fallback=%d (%.0f%%; instant-shape=%d) parse_errors=%d",
|
||||
path, total,
|
||||
full, 100*float64(full)/float64(total),
|
||||
hybrid, 100*float64(hybrid)/float64(total),
|
||||
fallbackInstant+fallbackOther, 100*float64(fallbackInstant+fallbackOther)/float64(total),
|
||||
fallbackInstant, parseErrs)
|
||||
|
||||
reasons := make([]string, 0, len(fallbackReasons))
|
||||
for r := range fallbackReasons {
|
||||
reasons = append(reasons, r)
|
||||
}
|
||||
sort.Slice(reasons, func(i, j int) bool { return fallbackReasons[reasons[i]] > fallbackReasons[reasons[j]] })
|
||||
for _, r := range reasons {
|
||||
t.Logf(" fallback %4d %s", fallbackReasons[r], r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallbackShape buckets a non-transpilable query by why it stays on the engine
|
||||
// path, to separate "already served well" (instant selectors on the last-sample-per-step
|
||||
// path) from genuine compiler gaps.
|
||||
func fallbackShape(expr parser.Expr) string {
|
||||
var hasMatrix, hasSubquery, hasAt, hasDurationExpr, overTime bool
|
||||
rangeFns := map[string]bool{"rate": true, "increase": true, "delta": true, "irate": true, "idelta": true}
|
||||
var unsupportedFns []string
|
||||
parser.Inspect(expr, func(node parser.Node, _ []parser.Node) error {
|
||||
switch n := node.(type) {
|
||||
case *parser.MatrixSelector:
|
||||
hasMatrix = true
|
||||
if n.RangeExpr != nil {
|
||||
hasDurationExpr = true
|
||||
}
|
||||
case *parser.SubqueryExpr:
|
||||
hasSubquery = true
|
||||
if n.RangeExpr != nil || n.StepExpr != nil || n.OriginalOffsetExpr != nil {
|
||||
hasDurationExpr = true
|
||||
}
|
||||
case *parser.VectorSelector:
|
||||
if n.Timestamp != nil || n.StartOrEnd != 0 {
|
||||
hasAt = true
|
||||
}
|
||||
if n.OriginalOffsetExpr != nil {
|
||||
hasDurationExpr = true
|
||||
}
|
||||
case *parser.Call:
|
||||
if strings.HasSuffix(n.Func.Name, "_over_time") {
|
||||
overTime = true
|
||||
} else if !rangeFns[n.Func.Name] {
|
||||
unsupportedFns = append(unsupportedFns, n.Func.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
switch {
|
||||
case hasDurationExpr:
|
||||
return "duration expression (resolved only at evaluation time)"
|
||||
case hasSubquery:
|
||||
return "subquery"
|
||||
case hasAt:
|
||||
return "@ modifier"
|
||||
case overTime:
|
||||
return "*_over_time range function"
|
||||
case !hasMatrix:
|
||||
return "instant-selector shape (last-sample-per-step engine path)"
|
||||
case len(unsupportedFns) > 0:
|
||||
return fmt.Sprintf("range shape with unsupported function(s): %s", strings.Join(dedupe(unsupportedFns), ",")) //nolint:makezero
|
||||
default:
|
||||
return "other range shape"
|
||||
}
|
||||
}
|
||||
|
||||
func dedupe(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, s := range in {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
promValue "github.com/prometheus/prometheus/model/value"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// executor evaluates transpilable PromQL directly in ClickHouse, falling
|
||||
// back (ok=false) whenever the query shape or the step doesn't qualify. The
|
||||
// timeSeries*ToGrid functions it builds on are assumed available: the
|
||||
// supported ClickHouse floor is >= 25.6.
|
||||
type executor struct {
|
||||
client *client
|
||||
engine *prometheus.Engine
|
||||
parser prometheus.Parser
|
||||
}
|
||||
|
||||
// maxWindowBuckets caps range/step for the windowed *_over_time form: every
|
||||
// grid slot combines that many bucket partials, and the fleet's windows sit
|
||||
// well under it ([1m]..[17m] at 30-60s steps) — anything larger is a
|
||||
// long-range query whose step a dashboard scales up anyway, and the engine
|
||||
// path serves the rest.
|
||||
const maxWindowBuckets = 64
|
||||
|
||||
// TryExecuteRange transpiles and runs the query in ClickHouse when its shape
|
||||
// is in the allowlist. ok=false means "not transpilable" and carries no
|
||||
// error; the caller runs the engine path.
|
||||
func (e *executor) TryExecuteRange(ctx context.Context, qs string, start, end time.Time, step time.Duration) (promql.Matrix, bool, error) {
|
||||
expr, err := e.parser.ParseExpr(qs)
|
||||
if err != nil {
|
||||
// Let the engine path produce the (enhanced) parse error.
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
plan, ok := classify(expr, queryGrid(start, end, step))
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// timeSeriesLastToGrid widens its window to max(window, step) — probed: a
|
||||
// sample aged (window, step] still fills the slot — while the rate/delta
|
||||
// family enforces the window strictly. The Last-style kinds used to fall
|
||||
// back when window < step because of that widening; the window-sliver
|
||||
// filter (see samplesConditions) makes the widening harmless there:
|
||||
// samples exist only inside (t_k - window, t_k] slivers, so the widened
|
||||
// window intersected with the data IS the lookback window — and if a
|
||||
// future ClickHouse stops widening, the unwidened window is the sliver
|
||||
// too. Correct either way. A non-positive window still falls back: the
|
||||
// sliver argument needs a real window to filter to.
|
||||
//
|
||||
// The windowed *_over_time form gates only the range >= step regime: it
|
||||
// decomposes the window into whole step buckets (see windowedInner),
|
||||
// which is exact only when the range is a multiple of the step, and its
|
||||
// per-slot slide costs range/step bucket combines — bounded by
|
||||
// maxWindowBuckets so a long-range short-step query cannot turn the
|
||||
// slide into the bottleneck. range < step needs neither gate: the
|
||||
// windows are disjoint slivers, aggregated one slot each with no slide.
|
||||
// Every miss falls back to the engine path, which is exact.
|
||||
for _, unit := range plan.units {
|
||||
stepMs := unit.grid.stepMs
|
||||
if stepMs == 0 {
|
||||
stepMs = 1000
|
||||
}
|
||||
switch {
|
||||
case unit.core.kind == unitInstant || (unit.core.kind == unitOverTime && unit.core.overFn == "last"):
|
||||
windowMs := unit.core.rangeMs
|
||||
if unit.core.kind == unitInstant {
|
||||
windowMs = e.client.lookbackMs
|
||||
}
|
||||
if windowMs <= 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
case unit.core.kind == unitOverTime:
|
||||
if unit.core.rangeMs < unit.grid.stepMs {
|
||||
// Disjoint slivers: no divisibility or width requirement.
|
||||
continue
|
||||
}
|
||||
if unit.core.rangeMs%stepMs != 0 || unit.core.rangeMs/stepMs > maxWindowBuckets {
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate every unit concurrently on its own grid (the query grid, or a
|
||||
// subquery grid); each is one series lookup plus one grid query.
|
||||
results := make([][]transpiledSeries, len(plan.units))
|
||||
eg, egCtx := errgroup.WithContext(ctx)
|
||||
for i, unit := range plan.units {
|
||||
eg.Go(func() error {
|
||||
res, err := e.executeUnit(egCtx, &unit.core, unit.grid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
results[i] = res
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := eg.Wait(); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
|
||||
if plan.full {
|
||||
g := plan.units[0].grid
|
||||
return toMatrix(results[0], g.startMs, g.stepMs), true, nil
|
||||
}
|
||||
|
||||
matrix, err := e.executeHybrid(ctx, plan, results)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
return matrix, true, nil
|
||||
}
|
||||
|
||||
// queryGrid derives the top-level evaluation grid; step 0 is an instant
|
||||
// query: a single evaluation at end, whatever start was.
|
||||
func queryGrid(start, end time.Time, step time.Duration) gridContext {
|
||||
startMs, endMs, stepMs := start.UnixMilli(), end.UnixMilli(), step.Milliseconds()
|
||||
if stepMs == 0 {
|
||||
startMs = endMs
|
||||
}
|
||||
return gridContext{startMs: startMs, endMs: endMs, stepMs: stepMs}
|
||||
}
|
||||
|
||||
// transpiledSeries is one output series of a unit: projected labels and one
|
||||
// value pointer per grid point (nil = absent).
|
||||
type transpiledSeries struct {
|
||||
lset labels.Labels
|
||||
values []*float64
|
||||
}
|
||||
|
||||
// executeUnit runs one core unit on its grid: series lookup (budgets,
|
||||
// fingerprints, metric names), then the single grid query, then the
|
||||
// scalar-op pipeline.
|
||||
func (e *executor) executeUnit(ctx context.Context, unit *coreUnit, grid gridContext) ([]transpiledSeries, error) {
|
||||
startMs, endMs, stepMs := grid.startMs, grid.endMs, grid.stepMs
|
||||
windowMs := unit.rangeMs
|
||||
if unit.kind == unitInstant {
|
||||
windowMs = e.client.lookbackMs
|
||||
}
|
||||
dataStart := startMs - unit.offsetMs - windowMs
|
||||
dataEnd := endMs - unit.offsetMs
|
||||
|
||||
seriesQuery, seriesArgs, err := buildSeriesQuery(dataStart, dataEnd, unit.matchers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lookup, err := e.client.selectSeries(ctx, seriesQuery, seriesArgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(lookup.fingerprints) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
query, args, err := buildUnitSQL(unit, lookup.metricNames, dataStart, dataEnd, startMs, endMs, stepMs, e.client.lookbackMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := e.client.telemetryStore.ClickhouseDB().Query(e.client.withContext(ctx, "transpiledUnit"), query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Name-dropping units keep __name__ in the SQL group key so distinct
|
||||
// metrics never merge server-side; the name comes off here. Two metrics
|
||||
// can then share a labelset — the engine merges their samples into one
|
||||
// series when they never overlap in time (a selector spanning metrics
|
||||
// whose series alternate across lookback windows) and raises the
|
||||
// duplicate-labelset error only when two samples land on the same
|
||||
// evaluation timestamp. mergeSameLabelsetSeries reproduces exactly that.
|
||||
stripName := !unit.hasAgg && !unit.keepsName()
|
||||
|
||||
// by (...) units return one plain column per grouped label; everything
|
||||
// else returns the single canonical JSON key (see groupKeyColumns).
|
||||
keyNames := groupKeyColumns(unit)
|
||||
keyVals := make([]string, max(len(keyNames), 1))
|
||||
targets := make([]any, 0, len(keyVals)+1)
|
||||
for i := range keyVals {
|
||||
targets = append(targets, &keyVals[i])
|
||||
}
|
||||
var gridValues []*float64
|
||||
targets = append(targets, &gridValues)
|
||||
|
||||
var out []transpiledSeries
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(targets...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var lset labels.Labels
|
||||
if keyNames != nil {
|
||||
builder := labels.NewScratchBuilder(len(keyNames))
|
||||
for i, name := range keyNames {
|
||||
// An empty extracted value is the label being absent.
|
||||
if keyVals[i] != "" {
|
||||
builder.Add(name, keyVals[i])
|
||||
}
|
||||
}
|
||||
builder.Sort()
|
||||
lset = builder.Labels()
|
||||
} else {
|
||||
lset, err = labelsFromGroupKey(keyVals[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if stripName {
|
||||
lset = labels.NewBuilder(lset).Del(metricNameLabel).Labels()
|
||||
}
|
||||
values := make([]*float64, len(gridValues))
|
||||
copy(values, gridValues)
|
||||
applyScalarOps(unit.ops, values)
|
||||
out = append(out, transpiledSeries{lset: lset, values: values})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if stripName {
|
||||
if out, err = mergeSameLabelsetSeries(out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return labels.Compare(out[i].lset, out[j].lset) < 0 })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// mergeSameLabelsetSeries combines series left with identical labelsets by a
|
||||
// name strip, slot by slot: the engine assembles its result matrix by
|
||||
// labelset, so post-strip twins whose points interleave in time are one
|
||||
// series to it, and two values on the same evaluation timestamp are its
|
||||
// duplicate-labelset error — v1 would have errored there too, so silently
|
||||
// picking one value would be a divergence.
|
||||
func mergeSameLabelsetSeries(in []transpiledSeries) ([]transpiledSeries, error) {
|
||||
index := make(map[uint64]int, len(in))
|
||||
out := in[:0]
|
||||
for _, s := range in {
|
||||
hash := s.lset.Hash()
|
||||
idx, ok := index[hash]
|
||||
if ok && labels.Equal(out[idx].lset, s.lset) {
|
||||
dst := out[idx].values
|
||||
for k, v := range s.values {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if dst[k] != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "vector cannot contain metrics with the same labelset")
|
||||
}
|
||||
dst[k] = v
|
||||
}
|
||||
continue
|
||||
}
|
||||
index[hash] = len(out)
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// labelsFromGroupKey parses the toJSONString'd sorted [key, value] pairs.
|
||||
func labelsFromGroupKey(gkey string) (labels.Labels, error) {
|
||||
var pairs [][]string
|
||||
if err := json.Unmarshal([]byte(gkey), &pairs); err != nil {
|
||||
return labels.EmptyLabels(), errors.WrapInternalf(err, errors.CodeInternal, "malformed compiled group key %q", gkey)
|
||||
}
|
||||
builder := labels.NewScratchBuilder(len(pairs))
|
||||
for _, p := range pairs {
|
||||
if len(p) != 2 {
|
||||
return labels.EmptyLabels(), errors.NewInternalf(errors.CodeInternal, "malformed compiled group key pair %q", gkey)
|
||||
}
|
||||
builder.Add(p[0], p[1])
|
||||
}
|
||||
builder.Sort()
|
||||
return builder.Labels(), nil
|
||||
}
|
||||
|
||||
// applyScalarOps applies the number-literal op pipeline in place, with the
|
||||
// same float64 arithmetic and comparison-filter semantics as the engine.
|
||||
func applyScalarOps(ops []scalarOp, values []*float64) {
|
||||
for _, op := range ops {
|
||||
for i, v := range values {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
lhs, rhs := *v, op.scalar
|
||||
if op.scalarOnLeft {
|
||||
lhs, rhs = op.scalar, *v
|
||||
}
|
||||
switch op.op {
|
||||
case parser.ADD:
|
||||
res := lhs + rhs
|
||||
values[i] = &res
|
||||
case parser.SUB:
|
||||
res := lhs - rhs
|
||||
values[i] = &res
|
||||
case parser.MUL:
|
||||
res := lhs * rhs
|
||||
values[i] = &res
|
||||
case parser.DIV:
|
||||
res := lhs / rhs
|
||||
values[i] = &res
|
||||
case parser.MOD:
|
||||
res := math.Mod(lhs, rhs)
|
||||
values[i] = &res
|
||||
case parser.POW:
|
||||
res := math.Pow(lhs, rhs)
|
||||
values[i] = &res
|
||||
default:
|
||||
keep := compare(op.op, lhs, rhs)
|
||||
switch {
|
||||
case op.returnBool:
|
||||
res := 0.0
|
||||
if keep {
|
||||
res = 1.0
|
||||
}
|
||||
values[i] = &res
|
||||
case keep:
|
||||
// Filter comparisons keep the vector-side value.
|
||||
vec := *v
|
||||
values[i] = &vec
|
||||
default:
|
||||
values[i] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func compare(op parser.ItemType, lhs, rhs float64) bool {
|
||||
switch op {
|
||||
case parser.EQLC:
|
||||
return lhs == rhs
|
||||
case parser.NEQ:
|
||||
return lhs != rhs
|
||||
case parser.GTR:
|
||||
return lhs > rhs
|
||||
case parser.LSS:
|
||||
return lhs < rhs
|
||||
case parser.GTE:
|
||||
return lhs >= rhs
|
||||
case parser.LTE:
|
||||
return lhs <= rhs
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// toMatrix converts a unit result to a promql matrix on the query grid.
|
||||
func toMatrix(series []transpiledSeries, startMs, stepMs int64) promql.Matrix {
|
||||
matrix := make(promql.Matrix, 0, len(series))
|
||||
for _, s := range series {
|
||||
var floats []promql.FPoint
|
||||
for i, v := range s.values {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
floats = append(floats, promql.FPoint{T: startMs + int64(i)*stepMs, F: *v})
|
||||
}
|
||||
if len(floats) == 0 {
|
||||
continue
|
||||
}
|
||||
matrix = append(matrix, promql.Series{Metric: s.lset, Floats: floats})
|
||||
}
|
||||
return matrix
|
||||
}
|
||||
|
||||
// executeHybrid substitutes each unit's grids into the engine as synthetic
|
||||
// series and evaluates the rewritten query over a storage that serves
|
||||
// synthetic selectors from memory and everything else from the live querier.
|
||||
// Absent grid points become stale markers so the engine's lookback cannot
|
||||
// resurrect the previous grid point. Each unit's synthetic samples sit on its
|
||||
// own grid (query grid, or subquery grid for units inside subqueries).
|
||||
func (e *executor) executeHybrid(ctx context.Context, plan *transpilePlan, results [][]transpiledSeries) (promql.Matrix, error) {
|
||||
synthetic := make(map[string][]*series, len(plan.units))
|
||||
staleMarker := math.Float64frombits(promValue.StaleNaN)
|
||||
|
||||
queryGrid := plan.grid
|
||||
|
||||
for i, unit := range plan.units {
|
||||
g := unit.grid
|
||||
gridLen := 1
|
||||
if g.stepMs > 0 {
|
||||
gridLen = int((g.endMs-g.startMs)/g.stepMs) + 1
|
||||
}
|
||||
list := make([]*series, 0, len(results[i]))
|
||||
for _, cs := range results[i] {
|
||||
builder := labels.NewBuilder(cs.lset)
|
||||
builder.Set(metricNameLabel, unit.name)
|
||||
s := &series{lset: builder.Labels()}
|
||||
s.ts = make([]int64, 0, gridLen)
|
||||
s.vs = make([]float64, 0, gridLen)
|
||||
for idx := 0; idx < gridLen; idx++ {
|
||||
t := g.startMs + int64(idx)*g.stepMs
|
||||
var v float64
|
||||
if idx < len(cs.values) && cs.values[idx] != nil {
|
||||
v = *cs.values[idx]
|
||||
} else {
|
||||
v = staleMarker
|
||||
}
|
||||
s.ts = append(s.ts, t)
|
||||
s.vs = append(s.vs, v)
|
||||
}
|
||||
list = append(list, s)
|
||||
}
|
||||
synthetic[unit.name] = list
|
||||
}
|
||||
|
||||
hybrid := &hybridQueryable{client: e.client, synthetic: synthetic}
|
||||
|
||||
var qry promql.Query
|
||||
var err error
|
||||
if queryGrid.stepMs == 0 {
|
||||
qry, err = e.engine.NewInstantQuery(ctx, hybrid, nil, plan.rewritten, time.UnixMilli(queryGrid.endMs))
|
||||
} else {
|
||||
qry, err = e.engine.NewRangeQuery(ctx, hybrid, nil, plan.rewritten, time.UnixMilli(queryGrid.startMs), time.UnixMilli(queryGrid.endMs), time.Duration(queryGrid.stepMs)*time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer qry.Close()
|
||||
|
||||
res := qry.Exec(ctx)
|
||||
if res.Err != nil {
|
||||
return nil, res.Err
|
||||
}
|
||||
|
||||
matrix, err := resultToMatrix(res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Deep-copy before Close returns the result's slices to the engine pool,
|
||||
// and drop the synthetic __name__ that filter comparisons preserve.
|
||||
out := make(promql.Matrix, 0, len(matrix))
|
||||
for _, s := range matrix {
|
||||
lset := s.Metric
|
||||
if name := lset.Get(metricNameLabel); len(name) >= len(syntheticNamePrefix) && name[:len(syntheticNamePrefix)] == syntheticNamePrefix {
|
||||
builder := labels.NewBuilder(lset)
|
||||
builder.Del(metricNameLabel)
|
||||
lset = builder.Labels()
|
||||
}
|
||||
floats := make([]promql.FPoint, len(s.Floats))
|
||||
copy(floats, s.Floats)
|
||||
out = append(out, promql.Series{Metric: lset.Copy(), Floats: floats})
|
||||
}
|
||||
// The strip can leave twins: two units' outputs distinguishable only by
|
||||
// their synthetic names (e.g. -metric_a or -metric_b, both {} to the
|
||||
// engine's real evaluation once names dropped). The engine assembles its
|
||||
// matrix by labelset, merging such temporally-disjoint elements into one
|
||||
// series; reproduce that, with its duplicate error on same-timestamp
|
||||
// overlap.
|
||||
out, err = mergeMatrixByLabelset(out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return labels.Compare(out[i].Metric, out[j].Metric) < 0 })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// mergeMatrixByLabelset merges series sharing a labelset by interleaving
|
||||
// their points in timestamp order; a timestamp present in both is the
|
||||
// engine's duplicate-labelset error.
|
||||
func mergeMatrixByLabelset(matrix promql.Matrix) (promql.Matrix, error) {
|
||||
index := make(map[uint64]int, len(matrix))
|
||||
out := matrix[:0]
|
||||
for _, s := range matrix {
|
||||
hash := s.Metric.Hash()
|
||||
idx, ok := index[hash]
|
||||
if ok && labels.Equal(out[idx].Metric, s.Metric) {
|
||||
merged := make([]promql.FPoint, 0, len(out[idx].Floats)+len(s.Floats))
|
||||
a, b := out[idx].Floats, s.Floats
|
||||
for len(a) > 0 && len(b) > 0 {
|
||||
switch {
|
||||
case a[0].T < b[0].T:
|
||||
merged, a = append(merged, a[0]), a[1:]
|
||||
case b[0].T < a[0].T:
|
||||
merged, b = append(merged, b[0]), b[1:]
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "vector cannot contain metrics with the same labelset")
|
||||
}
|
||||
}
|
||||
out[idx].Floats = append(append(merged, a...), b...)
|
||||
continue
|
||||
}
|
||||
index[hash] = len(out)
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resultToMatrix(res *promql.Result) (promql.Matrix, error) {
|
||||
switch v := res.Value.(type) {
|
||||
case promql.Matrix:
|
||||
return v, nil
|
||||
case promql.Vector:
|
||||
matrix := make(promql.Matrix, 0, len(v))
|
||||
for _, s := range v {
|
||||
matrix = append(matrix, promql.Series{Metric: s.Metric, Floats: []promql.FPoint{{T: s.T, F: s.F}}})
|
||||
}
|
||||
return matrix, nil
|
||||
case promql.Scalar:
|
||||
return promql.Matrix{{Metric: labels.EmptyLabels(), Floats: []promql.FPoint{{T: v.T, F: v.V}}}}, nil
|
||||
default:
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "unexpected hybrid result type %T", res.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// hybridQueryable serves synthetic (compiled) selectors from memory and
|
||||
// everything else from the live storage.
|
||||
type hybridQueryable struct {
|
||||
client *client
|
||||
synthetic map[string][]*series
|
||||
}
|
||||
|
||||
func (h *hybridQueryable) Querier(mint, maxt int64) (storage.Querier, error) {
|
||||
return &hybridQuerier{
|
||||
querier: querier{mint: mint, maxt: maxt, client: h.client},
|
||||
synthetic: h.synthetic,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type hybridQuerier struct {
|
||||
querier
|
||||
synthetic map[string][]*series
|
||||
}
|
||||
|
||||
func (h *hybridQuerier) Select(ctx context.Context, sortSeries bool, hints *storage.SelectHints, matchers ...*labels.Matcher) storage.SeriesSet {
|
||||
if name, ok := isSyntheticSelector(matchers); ok {
|
||||
list := h.synthetic[name]
|
||||
if sortSeries {
|
||||
sorted := make([]*series, len(list))
|
||||
copy(sorted, list)
|
||||
sort.Slice(sorted, func(i, j int) bool { return labels.Compare(sorted[i].lset, sorted[j].lset) < 0 })
|
||||
list = sorted
|
||||
}
|
||||
return newSeriesSet(list)
|
||||
}
|
||||
return h.querier.Select(ctx, sortSeries, hints, matchers...)
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// experimental gate for the timeSeries*ToGrid aggregate functions; attached
|
||||
// as a SETTINGS clause so telemetrystore hooks cannot clobber it.
|
||||
const gridFunctionsSetting = "SETTINGS allow_experimental_ts_to_grid_aggregate_function = 1"
|
||||
|
||||
var aggForEach = map[string]string{
|
||||
"sum": "sumForEach",
|
||||
"min": "minForEach",
|
||||
"max": "maxForEach",
|
||||
"avg": "avgForEach",
|
||||
"count": "countForEach",
|
||||
}
|
||||
|
||||
// buildUnitSQL renders the single ClickHouse query evaluating a core unit
|
||||
// over the [startMs, endMs] / stepMs evaluation grid: per-series grids via a
|
||||
// timeSeries*ToGrid aggregate (or a windowed aggregation for *_over_time),
|
||||
// then spatial aggregation with -ForEach combinators grouped by a canonical
|
||||
// JSON key of the projected label pairs.
|
||||
//
|
||||
// The heavy level is shaped to run on the shards: the top-level FROM is the
|
||||
// distributed samples table and the group-key join partner is a subquery on
|
||||
// the shard-local time series table, so the shard rewrite executes the join
|
||||
// and the per-(fingerprint, group key) aggregation next to the data —
|
||||
// complete by fingerprint co-locality (see localTimeSeriesTable) — and the
|
||||
// initiator only merges the per-series states and applies the spatial
|
||||
// -ForEach step. Same layout as the telemetrymetrics statement builder.
|
||||
// The windowed *_over_time form shares the frame but aggregates per
|
||||
// (series, group key, step bucket) instead of straight to grids
|
||||
// (see windowedInner).
|
||||
//
|
||||
// The selector's data window is offset-shifted; the resulting grid indices
|
||||
// map 1:1 onto the query grid (output ts = startMs + i*stepMs). Grid
|
||||
// parameters are rendered as literals — they are aggregate-function
|
||||
// parameters, not bindable values.
|
||||
//
|
||||
// Statements nest builder-rendered SQL as text, so the returned args must be
|
||||
// ordered by where each fragment lands in the final statement: ClickHouse
|
||||
// binds ? placeholders by position. A JOIN renders before WHERE, so a joined
|
||||
// subquery's args precede the outer query's own condition args.
|
||||
//
|
||||
// Row shape: the group-key columns (see groupKeyColumns) followed by
|
||||
// grid Array(Nullable(Float64)); NULL grid points are absent points (the
|
||||
// engine's "no value here"), which the -ForEach combinators preserve: an
|
||||
// index where every series is NULL aggregates to NULL, and countForEach's 0
|
||||
// is mapped back to NULL.
|
||||
func buildUnitSQL(unit *coreUnit, metricNames []string, dataStart, dataEnd int64, startMs, endMs, stepMs, lookbackMs int64) (string, []any, error) {
|
||||
selStart := startMs - unit.offsetMs
|
||||
selEnd := endMs - unit.offsetMs
|
||||
stepSec := stepMs / 1000
|
||||
if stepSec == 0 {
|
||||
// Instant query: start == end, so the grid has one point for any
|
||||
// positive step.
|
||||
stepSec = 1
|
||||
}
|
||||
windowMs := unit.rangeMs
|
||||
if unit.kind == unitInstant {
|
||||
windowMs = lookbackMs
|
||||
}
|
||||
windowSec := windowMs / 1000
|
||||
|
||||
adjustedTsStart, tsTable := timeSeriesTableFor(dataStart, dataEnd)
|
||||
keyNames := groupKeyColumns(unit)
|
||||
|
||||
// seriesSub computes fingerprint -> group key columns. It reads the
|
||||
// local series table when it rides inside the shard-rewritten samples
|
||||
// query, and the distributed one when it joins at the initiator
|
||||
// (windowed form).
|
||||
seriesSub := func(table string) (string, []any, error) {
|
||||
sub := sqlbuilder.NewSelectBuilder()
|
||||
selects := []string{"fingerprint"}
|
||||
if keyNames == nil {
|
||||
selects = append(selects, groupKeyExpr(unit)+" AS gkey")
|
||||
} else {
|
||||
// by (...) grouping extracts exactly the listed labels as plain
|
||||
// columns: no reason to build, sort and stringify every label
|
||||
// pair per row when the projection is a known short list and
|
||||
// the label names live in Go anyway.
|
||||
for i, name := range keyNames {
|
||||
selects = append(selects, fmt.Sprintf("JSONExtractString(labels, %s) AS g%d", sub.Var(name), i))
|
||||
}
|
||||
}
|
||||
sub.Select(selects...)
|
||||
sub.From(fmt.Sprintf("%s.%s", databaseName, table))
|
||||
if err := applySeriesConditions(sub, adjustedTsStart, dataEnd, unit.matchers); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
sub.GroupBy(append([]string{"fingerprint"}, keyColumnAliases(keyNames)...)...)
|
||||
q, args := sub.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return q, args, nil
|
||||
}
|
||||
|
||||
// samplesConditions adds the samples-side WHERE. The group-key join
|
||||
// restricts to the matched series; no fingerprint condition is added
|
||||
// here.
|
||||
samplesConditions := func(sb *sqlbuilder.SelectBuilder, excludeStale bool) {
|
||||
switch len(metricNames) {
|
||||
case 0:
|
||||
// No name constraint derivable; correct but unable to use the
|
||||
// metric_name primary-key prefix.
|
||||
case 1:
|
||||
sb.Where(sb.EQ("metric_name", metricNames[0]))
|
||||
default:
|
||||
sb.Where(sb.In("metric_name", sqlbuilder.List(metricNames)))
|
||||
}
|
||||
// temporality precedes metric_name in the samples primary key; the
|
||||
// fingerprints already come from these temporalities, so this only
|
||||
// helps granule pruning.
|
||||
sb.Where("temporality IN ['Cumulative', 'Unspecified']")
|
||||
// When the window is narrower than the step, the grid windows
|
||||
// (t_k − window, t_k] cover only window/step of the selector's
|
||||
// timeline; a sample in a gap belongs to no window and cannot move
|
||||
// any grid point, but the grid aggregate buffers every row it is
|
||||
// fed. Keeping only in-window rows cut a 36k-series 1w rate from
|
||||
// 74s/28GiB to 16s/4.3GiB on fleet data — the read stays the same,
|
||||
// the aggregate input shrinks by the coverage ratio. The lattice
|
||||
// anchors at selStart (end may sit off-lattice on unaligned grids),
|
||||
// positiveModulo because samples above selStart make the dividend
|
||||
// negative, and the upper bound tightens to the last grid point —
|
||||
// rows past it are equally windowless. window >= step tiles the
|
||||
// timeline and keeps today's plain bounds.
|
||||
sliver := stepMs > 0 && windowMs > 0 && windowMs < stepMs
|
||||
upper := selEnd
|
||||
if sliver {
|
||||
upper = selStart + (selEnd-selStart)/stepMs*stepMs
|
||||
}
|
||||
// Left-open window: a sample exactly at the window's lower boundary
|
||||
// is never used (range selectors and lookback are both left-open).
|
||||
sb.Where(sb.GT("unix_milli", selStart-windowMs), sb.LTE("unix_milli", upper))
|
||||
if sliver {
|
||||
sb.Where(fmt.Sprintf("positiveModulo(%s - unix_milli, %s) < %s",
|
||||
sb.Var(selStart), sb.Var(stepMs), sb.Var(windowMs)))
|
||||
}
|
||||
if excludeStale {
|
||||
// PromQL excludes stale markers from range vectors. Instant
|
||||
// selectors need the stale rows for shadowing instead.
|
||||
sb.Where("bitAnd(flags, 1) = 0")
|
||||
}
|
||||
}
|
||||
|
||||
keyCols := keyColumnAliases(keyNames)
|
||||
|
||||
// joinedInner builds the shard-side SELECT for the single-pass kinds:
|
||||
// grid expression per (fingerprint, group key), group-key join against
|
||||
// the local series table.
|
||||
joinedInner := func(gridExpr string, excludeStale bool) (string, []any, error) {
|
||||
seriesSQL, seriesArgs, err := seriesSub(localTimeSeriesTable(tsTable))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
selects := make([]string, 0, len(keyCols)+1)
|
||||
// A fingerprint is the hash of one labelset, so every group-key
|
||||
// column is functionally dependent on it: any() is exact, and
|
||||
// grouping by the fingerprint alone spares hashing the joined
|
||||
// string per sample row — measured -10-13% on a 1.9B-row rate.
|
||||
for _, col := range keyCols {
|
||||
selects = append(selects, fmt.Sprintf("any(series.%s) AS %s", col, col))
|
||||
}
|
||||
sb.Select(append(selects, gridExpr+" AS grid")...)
|
||||
sb.From(fmt.Sprintf("%s.%s AS points", databaseName, distributedSamplesV4))
|
||||
sb.JoinWithOption(sqlbuilder.InnerJoin, fmt.Sprintf("(%s) AS series", seriesSQL), "points.fingerprint = series.fingerprint")
|
||||
samplesConditions(sb, excludeStale)
|
||||
sb.GroupBy("points.fingerprint")
|
||||
q, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
// The join text renders before WHERE: its args come first.
|
||||
return q, append(seriesArgs, args...), nil
|
||||
}
|
||||
|
||||
var inner string
|
||||
var innerArgs []any
|
||||
var err error
|
||||
switch unit.kind {
|
||||
case unitInstant:
|
||||
// Instant selection with stale shadowing: the grid value is the last
|
||||
// non-stale sample in (t-lookback, t], absent when the overall last
|
||||
// sample in that window is a stale marker (verified semantics: the
|
||||
// -If combinator applies to the grid aggregates, and NULL comparisons
|
||||
// make a stale-latest point absent).
|
||||
gridParams := fmt.Sprintf("(fromUnixTimestamp64Milli(%d), fromUnixTimestamp64Milli(%d), %d, %d)", selStart, selEnd, stepSec, windowSec)
|
||||
gridExpr := fmt.Sprintf(
|
||||
"arrayMap((tall, tok, vok) -> if(tall IS NULL OR tok IS NULL OR tall != tok, NULL, vok), timeSeriesLastToGrid%s(fromUnixTimestamp64Milli(unix_milli), toFloat64(unix_milli)), timeSeriesLastToGridIf%s(fromUnixTimestamp64Milli(unix_milli), toFloat64(unix_milli), bitAnd(flags, 1) = 0), timeSeriesLastToGridIf%s(fromUnixTimestamp64Milli(unix_milli), value, bitAnd(flags, 1) = 0))",
|
||||
gridParams, gridParams, gridParams,
|
||||
)
|
||||
inner, innerArgs, err = joinedInner(gridExpr, false)
|
||||
case unitOverTime:
|
||||
if unit.overFn == "last" {
|
||||
// last_over_time == last non-stale sample in the window: the
|
||||
// stale rows are already excluded in WHERE.
|
||||
gridExpr := fmt.Sprintf(
|
||||
"timeSeriesLastToGrid(fromUnixTimestamp64Milli(%d), fromUnixTimestamp64Milli(%d), %d, %d)(fromUnixTimestamp64Milli(unix_milli), value)",
|
||||
selStart, selEnd, stepSec, windowSec,
|
||||
)
|
||||
inner, innerArgs, err = joinedInner(gridExpr, true)
|
||||
break
|
||||
}
|
||||
inner, innerArgs, err = windowedInner(unit, samplesConditions, seriesSub, keyCols, localTimeSeriesTable(tsTable), selStart, selEnd, stepMs, windowMs)
|
||||
default: // unitRange
|
||||
gridExpr := fmt.Sprintf(
|
||||
"%s(fromUnixTimestamp64Milli(%d), fromUnixTimestamp64Milli(%d), %d, %d)(fromUnixTimestamp64Milli(unix_milli), value)",
|
||||
gridFunction[unit.fn], selStart, selEnd, stepSec, windowSec,
|
||||
)
|
||||
if unit.fn == fnIncrease {
|
||||
// increase == rate * range-seconds, exactly: extrapolatedRate
|
||||
// divides by the range only when isRate.
|
||||
gridExpr = fmt.Sprintf("arrayMap(x -> x * %d, %s)", windowSec, gridExpr)
|
||||
}
|
||||
inner, innerArgs, err = joinedInner(gridExpr, true)
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
spatial := "maxForEach(grid)"
|
||||
switch {
|
||||
case !unit.hasAgg:
|
||||
// Per-series output: one row per (labels-minus-__name__) group.
|
||||
// Distinct fingerprints can collapse onto the same projected label
|
||||
// set only via a regex __name__ selector over metrics with identical
|
||||
// other labels; maxForEach is a deterministic NULL-skipping merge and
|
||||
// the identity for the overwhelmingly common one-fingerprint group.
|
||||
case unit.aggOp.String() == "count":
|
||||
// count over an all-absent index is an absent point, not 0.
|
||||
spatial = "arrayMap(c -> if(c = 0, NULL, toFloat64(c)), countForEach(grid))"
|
||||
default:
|
||||
spatial = fmt.Sprintf("%s(grid)", aggForEach[unit.aggOp.String()])
|
||||
}
|
||||
|
||||
keyList := strings.Join(keyCols, ", ")
|
||||
query := fmt.Sprintf("SELECT %s, %s AS grid FROM (%s) GROUP BY %s %s", keyList, spatial, inner, keyList, gridFunctionsSetting)
|
||||
return query, innerArgs, nil
|
||||
}
|
||||
|
||||
// groupKeyColumns returns the label names to extract as plain group-key
|
||||
// columns, or nil when the unit needs the canonical JSON key instead. Only
|
||||
// by (...) grouping qualifies: its projection is a known short list, so
|
||||
// extracting each label directly beats building, sorting and stringifying
|
||||
// every label pair per row. without and no-aggregation project a label SET
|
||||
// that varies per series — there the sorted-JSON key is load-bearing: the
|
||||
// sort is what makes two fingerprints with different stored JSON key order
|
||||
// land in one group, and the string carries the labels back out.
|
||||
func groupKeyColumns(unit *coreUnit) []string {
|
||||
if unit.hasAgg && unit.by && len(unit.grouping) > 0 {
|
||||
return unit.grouping
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// keyColumnAliases names the group-key columns in every SELECT level: g0..gN
|
||||
// for direct extraction, the single canonical gkey otherwise.
|
||||
func keyColumnAliases(keyNames []string) []string {
|
||||
if keyNames == nil {
|
||||
return []string{"gkey"}
|
||||
}
|
||||
cols := make([]string, len(keyNames))
|
||||
for i := range keyNames {
|
||||
cols[i] = fmt.Sprintf("g%d", i)
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// windowedInner builds the avg/min/max/sum/count _over_time form without
|
||||
// fanning samples out. It runs only when the range is a whole multiple of
|
||||
// the step (see the transpile gate), because then the window
|
||||
// (t_k - range, t_k] is exactly the union of W = range/step step buckets —
|
||||
// both are left-open on the same boundaries — so bucket membership fully
|
||||
// determines window membership. Fanning each sample into all W windows it
|
||||
// covers (ARRAY JOIN) multiplies rows by W, which at long ranges over short
|
||||
// steps is a row explosion measured in billions.
|
||||
//
|
||||
// The bucketing itself is the -Resample combinator: one group per (series,
|
||||
// group key) whose state is a fixed array of per-bucket aggregates, updated
|
||||
// in place per sample. Grouping by (series, bucket) instead — measured on a
|
||||
// 100k-series x 371-bucket workload — creates a 37M-entry hash aggregation
|
||||
// whose per-thread partial tables scale memory WITH max_threads (12 -> 48
|
||||
// GiB from 2 to 8 threads, dead at 16) and ships one row per group to the
|
||||
// initiator; the Resample form carries the same numbers in 100k compact
|
||||
// array states, like every other unit kind.
|
||||
//
|
||||
// The wrapper level slides the window: slot k combines buckets k..k+W-1 by
|
||||
// direct aggregation over at most W partials — no prefix-sum tricks, so no
|
||||
// large-minus-large cancellation against the engine's directly-summed
|
||||
// windows. A slot with zero window count is absent, which also keeps
|
||||
// min/max honest: their slices filter on the bucket counts, so an empty
|
||||
// bucket's zero-fill can never be mistaken for a value (a real sample can
|
||||
// legitimately be 0 or +Inf).
|
||||
func windowedInner(unit *coreUnit, samplesConditions func(*sqlbuilder.SelectBuilder, bool), seriesSub func(string) (string, []any, error), keyCols []string, localSeriesTable string, selStart, selEnd, stepMs, windowMs int64) (string, []any, error) {
|
||||
effStepMs := stepMs
|
||||
if effStepMs == 0 {
|
||||
effStepMs = 1000
|
||||
}
|
||||
lastIdx := (selEnd - selStart) / effStepMs
|
||||
gridLen := lastIdx + 1
|
||||
w := windowMs / effStepMs
|
||||
bucketLen := gridLen + w
|
||||
|
||||
// A window narrower than the step makes the windows (t_k - range, t_k]
|
||||
// pairwise disjoint: there is nothing to slide, each slot reads exactly
|
||||
// its own window's aggregate. This is exact ONLY over sliver-filtered
|
||||
// rows (samplesConditions adds the window<step predicate): the index
|
||||
// below assigns every gap sample to the window above it, and the filter
|
||||
// is what removes them. Requires a real step — instant queries carry no
|
||||
// sliver filter, so they keep the tiled form and its gates.
|
||||
disjoint := stepMs > 0 && windowMs < stepMs
|
||||
if disjoint {
|
||||
w = 1
|
||||
bucketLen = gridLen
|
||||
}
|
||||
|
||||
seriesSQL, seriesArgs, err := seriesSub(localSeriesTable)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// Bucket index, shifted so the earliest in-window sample lands at 0:
|
||||
// jj = ceil((ts - selStart)/step) + W - 1, folded into one intDiv. Slot
|
||||
// k's window is then buckets jj in [k, k+W-1]. In the disjoint form the
|
||||
// same ceil lands each in-window sample directly on its slot (W = 1),
|
||||
// and the numerator stays positive: the fetch floor is
|
||||
// selStart - range > selStart - step.
|
||||
jjShift := windowMs
|
||||
if disjoint {
|
||||
jjShift = effStepMs
|
||||
}
|
||||
jj := fmt.Sprintf("intDiv(unix_milli - %d + %d - 1, %d)", selStart, jjShift, effStepMs)
|
||||
buckets := sqlbuilder.NewSelectBuilder()
|
||||
selects := make([]string, 0, len(keyCols)+2)
|
||||
// any() over the group key: exact because the key is functionally
|
||||
// dependent on the fingerprint (see joinedInner).
|
||||
for _, col := range keyCols {
|
||||
selects = append(selects, fmt.Sprintf("any(series.%s) AS %s", col, col))
|
||||
}
|
||||
selects = append(selects, fmt.Sprintf("countResample(0, %d, 1)(value, %s) AS cnts", bucketLen, jj))
|
||||
if unit.overFn != "count" {
|
||||
selects = append(selects, fmt.Sprintf("%sResample(0, %d, 1)(value, %s) AS vals", map[string]string{
|
||||
"avg": "sum",
|
||||
"sum": "sum",
|
||||
"min": "min",
|
||||
"max": "max",
|
||||
}[unit.overFn], bucketLen, jj))
|
||||
}
|
||||
buckets.Select(selects...)
|
||||
buckets.From(fmt.Sprintf("%s.%s AS points", databaseName, distributedSamplesV4))
|
||||
buckets.JoinWithOption(sqlbuilder.InnerJoin, fmt.Sprintf("(%s) AS series", seriesSQL), "points.fingerprint = series.fingerprint")
|
||||
samplesConditions(buckets, true)
|
||||
buckets.GroupBy("points.fingerprint")
|
||||
bucketsSQL, bucketsArgs := buckets.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
windowCnt := fmt.Sprintf("arraySum(arraySlice(cnts, k + 1, %d))", w)
|
||||
var slot string
|
||||
switch unit.overFn {
|
||||
case "count":
|
||||
slot = fmt.Sprintf("if(%s = 0, NULL, toFloat64(%s))", windowCnt, windowCnt)
|
||||
case "sum":
|
||||
slot = fmt.Sprintf("if(%s = 0, NULL, arraySum(arraySlice(vals, k + 1, %d)))", windowCnt, w)
|
||||
case "avg":
|
||||
slot = fmt.Sprintf("if(%s = 0, NULL, arraySum(arraySlice(vals, k + 1, %d)) / %s)", windowCnt, w, windowCnt)
|
||||
case "min":
|
||||
slot = fmt.Sprintf("if(%s = 0, NULL, arrayMin(arrayFilter((v, c) -> c > 0, arraySlice(vals, k + 1, %d), arraySlice(cnts, k + 1, %d))))", windowCnt, w, w)
|
||||
case "max":
|
||||
slot = fmt.Sprintf("if(%s = 0, NULL, arrayMax(arrayFilter((v, c) -> c > 0, arraySlice(vals, k + 1, %d), arraySlice(cnts, k + 1, %d))))", windowCnt, w, w)
|
||||
}
|
||||
|
||||
keyList := strings.Join(keyCols, ", ")
|
||||
inner := fmt.Sprintf(
|
||||
"SELECT %s, arrayMap(k -> %s, range(toUInt64(%d))) AS grid FROM (%s)",
|
||||
keyList, slot, gridLen, bucketsSQL,
|
||||
)
|
||||
return inner, append(seriesArgs, bucketsArgs...), nil
|
||||
}
|
||||
|
||||
// groupKeyExpr renders the canonical JSON group key for the units whose
|
||||
// projected label SET varies per series (see groupKeyColumns): the sorted
|
||||
// [key, value] pairs of the projected labels, JSON-encoded.
|
||||
// - by () with no labels: one constant group;
|
||||
// - without (a, b): keep everything except the listed labels and __name__;
|
||||
// - no aggregation: keep everything including __name__ — even when the
|
||||
// unit drops the name from its OUTPUT, the key must keep it so distinct
|
||||
// metrics never merge in SQL; executeUnit strips the name afterwards and
|
||||
// turns a post-strip collision into the engine's duplicate-labelset
|
||||
// error instead of a silently invented merge.
|
||||
func groupKeyExpr(unit *coreUnit) string {
|
||||
// An empty label value means "label absent" in Prometheus; the stored
|
||||
// labels JSON can carry empty attribute values, which must not become
|
||||
// output labels or group keys.
|
||||
pairs := "arraySort(JSONExtractKeysAndValues(labels, 'String'))"
|
||||
if !unit.hasAgg {
|
||||
return fmt.Sprintf("toJSONString(arrayFilter(p -> p.2 != '', %s))", pairs)
|
||||
}
|
||||
if unit.by {
|
||||
// Non-empty by (...) never reaches here; groupKeyColumns extracts
|
||||
// those labels as plain columns instead.
|
||||
return "'[]'"
|
||||
}
|
||||
excluded := append([]string{metricNameLabel}, unit.grouping...)
|
||||
return fmt.Sprintf("toJSONString(arrayFilter(p -> p.2 != '' AND p.1 NOT IN (%s), %s))", quotedList(excluded), pairs)
|
||||
}
|
||||
|
||||
func quotedList(items []string) string {
|
||||
quoted := make([]string, len(items))
|
||||
for i, s := range items {
|
||||
quoted[i] = "'" + strings.ReplaceAll(s, "'", "\\'") + "'"
|
||||
}
|
||||
return strings.Join(quoted, ", ")
|
||||
}
|
||||
@@ -1,751 +0,0 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestClient(t *testing.T) (*client, *telemetrystoretest.Provider) {
|
||||
t.Helper()
|
||||
store := telemetrystoretest.New(telemetrystore.Config{Provider: "clickhouse"}, sqlmock.QueryMatcherRegexp)
|
||||
settings := factory.NewScopedProviderSettings(instrumentationtest.New().ToProviderSettings(), "clickhouseprometheusv2_test")
|
||||
return newClient(settings, store, prometheus.Config{}), store
|
||||
}
|
||||
|
||||
var seriesCols = []cmock.ColumnType{
|
||||
{Name: "fingerprint", Type: "UInt64"},
|
||||
{Name: "labels", Type: "String"},
|
||||
}
|
||||
|
||||
func parse(t *testing.T, q string) parser.Expr {
|
||||
t.Helper()
|
||||
expr, err := parser.NewParser(parser.Options{}).ParseExpr(q)
|
||||
require.NoError(t, err)
|
||||
return expr
|
||||
}
|
||||
|
||||
func TestClassifyFullShapes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
check func(t *testing.T, u *coreUnit)
|
||||
}{
|
||||
{
|
||||
name: "sum by rate",
|
||||
query: `sum by (pod) (rate(http_requests_total{job="api"}[5m]))`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
assert.Equal(t, fnRate, u.fn)
|
||||
assert.Equal(t, int64(300_000), u.rangeMs)
|
||||
assert.True(t, u.hasAgg)
|
||||
assert.True(t, u.by)
|
||||
assert.Equal(t, []string{"pod"}, u.grouping)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bare increase with offset",
|
||||
query: `increase(errors_total[10m] offset 30m)`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
assert.Equal(t, fnIncrease, u.fn)
|
||||
assert.Equal(t, int64(1_800_000), u.offsetMs)
|
||||
assert.False(t, u.hasAgg)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "avg without over delta",
|
||||
query: `avg without (instance) (delta(gauge_metric[15m]))`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
assert.Equal(t, fnDelta, u.fn)
|
||||
assert.True(t, u.hasAgg)
|
||||
assert.False(t, u.by)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scalar pipeline with comparison",
|
||||
query: `sum(rate(x[5m])) * 100 > 5`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
require.Len(t, u.ops, 2)
|
||||
assert.Equal(t, parser.ItemType(parser.MUL), u.ops[0].op)
|
||||
assert.Equal(t, 100.0, u.ops[0].scalar)
|
||||
assert.Equal(t, parser.ItemType(parser.GTR), u.ops[1].op)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scalar on left with unary minus",
|
||||
query: `-1 * sum(rate(x[5m]))`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
require.Len(t, u.ops, 1)
|
||||
assert.True(t, u.ops[0].scalarOnLeft)
|
||||
assert.Equal(t, -1.0, u.ops[0].scalar)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bool comparison",
|
||||
query: `sum(rate(x[5m])) >= bool 0.5`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
require.Len(t, u.ops, 1)
|
||||
assert.True(t, u.ops[0].returnBool)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "irate utf8 name",
|
||||
query: `sum by ("k8s.pod.name") (irate({"k8s.container.cpu.time"}[2m]))`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
assert.Equal(t, fnIRate, u.fn)
|
||||
assert.Equal(t, []string{"k8s.pod.name"}, u.grouping)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bare instant selector keeps name",
|
||||
query: `up{job="api"}`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
assert.Equal(t, unitInstant, u.kind)
|
||||
assert.True(t, u.keepsName())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gauge aggregation",
|
||||
query: `sum by (pod) (container_memory offset 5m)`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
assert.Equal(t, unitInstant, u.kind)
|
||||
assert.Equal(t, int64(300_000), u.offsetMs)
|
||||
assert.True(t, u.hasAgg)
|
||||
assert.False(t, u.keepsName())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gauge comparison keeps name",
|
||||
query: `container_memory > 100`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
assert.Equal(t, unitInstant, u.kind)
|
||||
assert.True(t, u.keepsName())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gauge arithmetic drops name",
|
||||
query: `container_memory / 1024`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
assert.Equal(t, unitInstant, u.kind)
|
||||
assert.False(t, u.keepsName())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "avg_over_time",
|
||||
query: `max by (node) (avg_over_time(load1[10m]))`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
assert.Equal(t, unitOverTime, u.kind)
|
||||
assert.Equal(t, "avg", u.overFn)
|
||||
assert.Equal(t, int64(600_000), u.rangeMs)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "last_over_time keeps name",
|
||||
query: `last_over_time(load1[10m])`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
assert.Equal(t, unitOverTime, u.kind)
|
||||
assert.Equal(t, "last", u.overFn)
|
||||
assert.True(t, u.keepsName())
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
plan, ok := classify(parse(t, tt.query), testGrid(60_000))
|
||||
require.True(t, ok, "expected transpilable")
|
||||
require.True(t, plan.full, "expected full compilation")
|
||||
require.Len(t, plan.units, 1)
|
||||
tt.check(t, &plan.units[0].core)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyFallbackShapes(t *testing.T) {
|
||||
queries := []struct {
|
||||
name string
|
||||
query string
|
||||
step int64
|
||||
}{
|
||||
{"default-resolution subquery", `max_over_time(rate(x[5m])[30m:])`, 60_000},
|
||||
{"at modifier", `sum(rate(x[5m] @ 1609746000))`, 60_000},
|
||||
{"at modifier on gauge", `sum(container_memory @ 1609746000)`, 60_000},
|
||||
{"sub-second step", `sum(rate(x[5m]))`, 500},
|
||||
{"sub-second range", `sum(rate(x[1500ms]))`, 60_000},
|
||||
{"by __name__ full", `sum by (__name__) (rate({__name__=~"a|b"}[5m]))`, 60_000},
|
||||
{"quantile_over_time unsupported", `quantile_over_time(0.9, load1[10m])`, 60_000},
|
||||
// Duration expressions resolve into the selectors' static fields only
|
||||
// at evaluation time; classification reads those fields as zero, so
|
||||
// transpiling would silently use the wrong offset (caught by the
|
||||
// conformance corpus' duration_expression.test cases). Offset
|
||||
// expressions parse without the experimental-parser flag, so they do
|
||||
// reach the transpiler; range-position expressions are rejected at
|
||||
// parse (the RangeExpr/StepExpr guards are defense-in-depth).
|
||||
{"duration expression offset on instant", `x offset step()`, 60_000},
|
||||
{"duration expression offset arithmetic", `x offset -step()*2`, 60_000},
|
||||
{"duration expression offset on range", `sum(rate(x[5m] offset max(3s, step())))`, 60_000},
|
||||
{"duration expression subquery step", `max_over_time(rate(x[5m])[30m:step()])`, 60_000},
|
||||
}
|
||||
for _, tt := range queries {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, ok := classify(parse(t, tt.query), testGrid(tt.step))
|
||||
assert.False(t, ok, "expected fallback for %s", tt.query)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyHybridShapes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantUnits int
|
||||
wantRewritten string
|
||||
}{
|
||||
{
|
||||
name: "histogram quantile",
|
||||
query: `histogram_quantile(0.95, sum by (le) (rate(http_bucket[5m])))`,
|
||||
wantUnits: 1,
|
||||
wantRewritten: `histogram_quantile(0.95, __signoz_transpiled_0__)`,
|
||||
},
|
||||
{
|
||||
name: "topk over compiled",
|
||||
query: `topk(5, sum by (pod) (rate(x[5m])))`,
|
||||
wantUnits: 1,
|
||||
wantRewritten: `topk(5, __signoz_transpiled_0__)`,
|
||||
},
|
||||
{
|
||||
name: "ratio of compiled units",
|
||||
query: `sum(rate(a[5m])) / sum(rate(b[5m]))`,
|
||||
wantUnits: 2,
|
||||
wantRewritten: `__signoz_transpiled_0__ / __signoz_transpiled_1__`,
|
||||
},
|
||||
{
|
||||
name: "or vector zero",
|
||||
query: `sum(rate(a[5m])) or vector(0)`,
|
||||
wantUnits: 1,
|
||||
wantRewritten: `__signoz_transpiled_0__ or vector(0)`,
|
||||
},
|
||||
{
|
||||
name: "quantile agg over compiled rate",
|
||||
query: `quantile(0.9, rate(x[5m]))`,
|
||||
wantUnits: 1,
|
||||
wantRewritten: `quantile(0.9, __signoz_transpiled_0__)`,
|
||||
},
|
||||
{
|
||||
name: "non-literal scalar side stays engine-side",
|
||||
query: `sum(rate(x[5m])) * scalar(y)`,
|
||||
wantUnits: 1,
|
||||
wantRewritten: `__signoz_transpiled_0__ * scalar(y)`,
|
||||
},
|
||||
{
|
||||
name: "compiled mixed with raw selector",
|
||||
query: `sum by (pod) (rate(a[5m])) / on (pod) group_left () b`,
|
||||
wantUnits: 1,
|
||||
wantRewritten: `__signoz_transpiled_0__ / on (pod) group_left () b`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
plan, ok := classify(parse(t, tt.query), testGrid(60_000))
|
||||
require.True(t, ok)
|
||||
assert.False(t, plan.full)
|
||||
assert.Len(t, plan.units, tt.wantUnits)
|
||||
assert.Equal(t, tt.wantRewritten, plan.rewritten)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyHybridGuards(t *testing.T) {
|
||||
t.Run("no substitution under on(__name__)", func(t *testing.T) {
|
||||
plan, ok := classify(parse(t, `sum(rate(a[5m])) * on (__name__) b`), testGrid(60_000))
|
||||
_ = plan
|
||||
assert.False(t, ok, "matching on __name__ must not see synthetic names")
|
||||
})
|
||||
t.Run("no substitution inside @-pinned subquery", func(t *testing.T) {
|
||||
_, ok := classify(parse(t, `max_over_time(rate(x[5m])[30m:1m] @ 1609746000)`), testGrid(60_000))
|
||||
assert.False(t, ok)
|
||||
})
|
||||
}
|
||||
|
||||
// The alert-smoothing idiom: units inside a fixed-resolution subquery
|
||||
// evaluate on the subquery grid — epoch-aligned multiples of the resolution,
|
||||
// starting strictly after (outer start - range), exactly as the engine
|
||||
// derives it.
|
||||
func TestClassifySubqueryUnits(t *testing.T) {
|
||||
grid := gridContext{startMs: 1_700_000_030_000, endMs: 1_700_007_200_000, stepMs: 60_000}
|
||||
|
||||
plan, ok := classify(parse(t, `min_over_time((sum by (ns) (increase(x[5m])))[10m:5m]) > 0`), grid)
|
||||
require.True(t, ok)
|
||||
require.False(t, plan.full)
|
||||
require.Len(t, plan.units, 1)
|
||||
assert.Equal(t, `min_over_time(__signoz_transpiled_0__[10m:5m]) > 0`, plan.rewritten)
|
||||
|
||||
unit := plan.units[0]
|
||||
// lower bound = outer start - range = 1_699_999_430_000; first multiple
|
||||
// of 300_000 strictly greater is 1_699_999_500_000.
|
||||
assert.Equal(t, int64(1_699_999_500_000), unit.grid.startMs)
|
||||
assert.Equal(t, grid.endMs, unit.grid.endMs)
|
||||
assert.Equal(t, int64(300_000), unit.grid.stepMs)
|
||||
assert.Equal(t, fnIncrease, unit.core.fn)
|
||||
|
||||
t.Run("subquery offset shifts the grid", func(t *testing.T) {
|
||||
plan, ok := classify(parse(t, `max_over_time((sum(rate(x[5m])))[10m:5m] offset 30m)`), grid)
|
||||
require.True(t, ok)
|
||||
require.Len(t, plan.units, 1)
|
||||
// lower = start - offset - range = 1_699_997_630_000 -> first
|
||||
// multiple of 300_000 above = 1_699_997_700_000; end shifts too.
|
||||
assert.Equal(t, int64(1_699_997_700_000), plan.units[0].grid.startMs)
|
||||
assert.Equal(t, grid.endMs-1_800_000, plan.units[0].grid.endMs)
|
||||
})
|
||||
|
||||
t.Run("mollusk ratio-inside-subquery idiom", func(t *testing.T) {
|
||||
q := `min_over_time(((sum by (a) (rate(m1[5m]))) / (avg by (a) (m2)))[5m:1m])`
|
||||
plan, ok := classify(parse(t, q), grid)
|
||||
require.True(t, ok)
|
||||
// Both sides compile on the subquery grid: the rate side and the
|
||||
// gauge aggregation side; the engine joins them and smooths.
|
||||
require.Len(t, plan.units, 2)
|
||||
assert.Equal(t, int64(60_000), plan.units[0].grid.stepMs)
|
||||
assert.Equal(t, unitInstant, plan.units[1].core.kind)
|
||||
assert.Contains(t, plan.rewritten, `__signoz_transpiled_0__ / __signoz_transpiled_1__`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildUnitSQL(t *testing.T) {
|
||||
unit := &coreUnit{
|
||||
fn: fnRate,
|
||||
rangeMs: 300_000,
|
||||
hasAgg: true,
|
||||
aggOp: parser.SUM,
|
||||
by: true,
|
||||
grouping: []string{"pod"},
|
||||
matchers: []*labels.Matcher{mustMatcher(t, labels.MatchEqual, "__name__", "http_requests_total")},
|
||||
}
|
||||
sql, args, err := buildUnitSQL(unit, []string{"http_requests_total"}, 1_699_999_700_000, 1_700_003_600_000, 1_700_000_000_000, 1_700_003_600_000, 60_000, 300_000)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, sql, "timeSeriesRateToGrid(fromUnixTimestamp64Milli(1700000000000), fromUnixTimestamp64Milli(1700003600000), 60, 300)(fromUnixTimestamp64Milli(unix_milli), value)")
|
||||
assert.Contains(t, sql, "unix_milli > ? AND unix_milli <= ?")
|
||||
assert.Contains(t, sql, "bitAnd(flags, 1) = 0")
|
||||
assert.Contains(t, sql, "sumForEach(grid)")
|
||||
// The group-key join rides inside the shard query: distributed samples
|
||||
// at the top level, the local series table in the join subquery, the
|
||||
// grid aggregation grouped per (fingerprint, group key) shard-side.
|
||||
assert.Contains(t, sql, "FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint,")
|
||||
assert.Contains(t, sql, "FROM signoz_metrics.time_series_v4 WHERE")
|
||||
// The group key is functionally dependent on the fingerprint (one
|
||||
// labelset per fingerprint): any() is exact and the per-row hash key
|
||||
// shrinks to the fingerprint alone.
|
||||
assert.Contains(t, sql, "any(series.g0) AS g0")
|
||||
assert.Contains(t, sql, "GROUP BY points.fingerprint)")
|
||||
// No samples-side fingerprint condition: the group-key join restricts.
|
||||
assert.NotContains(t, sql, "points.fingerprint IN (")
|
||||
// by (pod) extracts the grouped label directly — no per-row JSON
|
||||
// build/sort/stringify for a known projection.
|
||||
assert.Contains(t, sql, "JSONExtractString(labels, ?) AS g0")
|
||||
assert.NotContains(t, sql, "toJSONString")
|
||||
assert.Contains(t, sql, "SETTINGS allow_experimental_ts_to_grid_aggregate_function = 1")
|
||||
// Args follow placeholder order: the joined series subquery renders
|
||||
// before the samples WHERE, and its select list ('pod') renders before
|
||||
// its own conditions.
|
||||
assert.Equal(t, []any{"pod", "http_requests_total", int64(1_699_999_200_000), int64(1_700_003_600_000), "http_requests_total", int64(1_699_999_700_000), int64(1_700_003_600_000)}, args)
|
||||
}
|
||||
|
||||
func TestBuildUnitSQLIncreaseAndOffset(t *testing.T) {
|
||||
unit := &coreUnit{
|
||||
fn: fnIncrease,
|
||||
rangeMs: 600_000,
|
||||
offsetMs: 1_800_000,
|
||||
matchers: []*labels.Matcher{mustMatcher(t, labels.MatchEqual, "__name__", "errors_total")},
|
||||
}
|
||||
sql, _, err := buildUnitSQL(unit, nil, 1_699_997_600_000, 1_700_001_800_000, 1_700_000_000_000, 1_700_003_600_000, 60_000, 300_000)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Grid and window shift by the offset; increase multiplies rate by the
|
||||
// range in seconds.
|
||||
assert.Contains(t, sql, "fromUnixTimestamp64Milli(1699998200000), fromUnixTimestamp64Milli(1700001800000)")
|
||||
assert.Contains(t, sql, "arrayMap(x -> x * 600, timeSeriesRateToGrid")
|
||||
assert.Contains(t, sql, "maxForEach(grid)")
|
||||
}
|
||||
|
||||
func TestBuildUnitSQLOverLimitJoinOnly(t *testing.T) {
|
||||
// Past the inline limit no fingerprint filter is rendered: the series
|
||||
// join restricts to the matched fingerprints on its own.
|
||||
unit := &coreUnit{
|
||||
fn: fnRate,
|
||||
rangeMs: 300_000,
|
||||
hasAgg: true,
|
||||
aggOp: parser.SUM,
|
||||
by: true,
|
||||
matchers: []*labels.Matcher{mustMatcher(t, labels.MatchEqual, "__name__", "http_requests_total")},
|
||||
}
|
||||
sql, _, err := buildUnitSQL(unit, []string{"http_requests_total"}, 1_699_999_700_000, 1_700_003_600_000, 1_700_000_000_000, 1_700_003_600_000, 60_000, 300_000)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotContains(t, sql, "points.fingerprint IN")
|
||||
assert.Contains(t, sql, "INNER JOIN (SELECT fingerprint,")
|
||||
assert.Contains(t, sql, "FROM signoz_metrics.time_series_v4 WHERE")
|
||||
}
|
||||
|
||||
func TestBuildUnitSQLWindowSliver(t *testing.T) {
|
||||
// rate[5m] on a 30m grid evaluates only a 5m sliver before each grid
|
||||
// point — samples in the gaps belong to no window and would only be
|
||||
// buffered by the grid aggregate. The WHERE must keep exactly the
|
||||
// in-window rows: positiveModulo anchored at the selector start (end
|
||||
// can sit off-lattice on unaligned grids, and samples above the start
|
||||
// make the plain modulo dividend negative), and the scan capped at the
|
||||
// last grid point — rows past it are equally windowless.
|
||||
unit := &coreUnit{
|
||||
fn: fnRate,
|
||||
rangeMs: 300_000,
|
||||
hasAgg: true,
|
||||
aggOp: parser.SUM,
|
||||
by: true,
|
||||
grouping: []string{"pod"},
|
||||
matchers: []*labels.Matcher{mustMatcher(t, labels.MatchEqual, "__name__", "http_requests_total")},
|
||||
}
|
||||
sql, args, err := buildUnitSQL(unit, []string{"http_requests_total"}, 1_699_999_700_000, 1_700_003_600_000, 1_700_000_000_000, 1_700_003_600_000, 1_800_000, 300_000)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, sql, "positiveModulo(? - unix_milli, ?) < ?")
|
||||
assert.Equal(t, []any{"pod", "http_requests_total", int64(1_699_999_200_000), int64(1_700_003_600_000), "http_requests_total", int64(1_699_999_700_000), int64(1_700_003_600_000), int64(1_700_000_000_000), int64(1_800_000), int64(300_000)}, args)
|
||||
|
||||
t.Run("off-lattice end caps the scan at the last grid point", func(t *testing.T) {
|
||||
// end - start = 50m at a 30m step: the only grid points are start
|
||||
// and start+30m; samples in the trailing 20m serve no window.
|
||||
_, args, err := buildUnitSQL(unit, []string{"http_requests_total"}, 1_699_999_700_000, 1_700_003_000_000, 1_700_000_000_000, 1_700_003_000_000, 1_800_000, 300_000)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, args, int64(1_700_001_800_000))
|
||||
})
|
||||
|
||||
t.Run("window covering the step keeps plain bounds", func(t *testing.T) {
|
||||
sql, _, err := buildUnitSQL(unit, []string{"http_requests_total"}, 1_699_999_700_000, 1_700_003_600_000, 1_700_000_000_000, 1_700_003_600_000, 60_000, 300_000)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, sql, "positiveModulo")
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildUnitSQLWindowedBucketsWithoutFanOut(t *testing.T) {
|
||||
// The window is W = range/step whole buckets, so each sample lands in
|
||||
// exactly one bucket via GROUP BY and the window slides over bucket
|
||||
// partials — fanning samples into every covered window (ARRAY JOIN)
|
||||
// multiplies rows by W, a row explosion at long ranges.
|
||||
unit := &coreUnit{
|
||||
kind: unitOverTime,
|
||||
overFn: "avg",
|
||||
rangeMs: 600_000,
|
||||
matchers: []*labels.Matcher{mustMatcher(t, labels.MatchEqual, "__name__", "node_load1")},
|
||||
}
|
||||
sql, _, err := buildUnitSQL(unit, []string{"node_load1"}, 1_699_999_400_000, 1_700_003_600_000, 1_700_000_000_000, 1_700_003_600_000, 60_000, 600_000)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotContains(t, sql, "ARRAY JOIN")
|
||||
// One group per series with fixed per-bucket arrays (-Resample); the
|
||||
// bucket index jj = ceil((ts - start)/step) + W - 1 folded into a single
|
||||
// intDiv. Grouping by (series, bucket) instead measured 37M hash groups
|
||||
// whose per-thread partials scale memory with max_threads.
|
||||
assert.Contains(t, sql, "countResample(0, 71, 1)(value, intDiv(unix_milli - 1700000000000 + 600000 - 1, 60000)) AS cnts")
|
||||
assert.Contains(t, sql, "sumResample(0, 71, 1)(value, intDiv(unix_milli - 1700000000000 + 600000 - 1, 60000)) AS vals")
|
||||
assert.Contains(t, sql, "any(series.gkey) AS gkey")
|
||||
assert.Contains(t, sql, "GROUP BY points.fingerprint)")
|
||||
assert.NotContains(t, sql, "jj) AS jj")
|
||||
assert.Contains(t, sql, "INNER JOIN (SELECT fingerprint,")
|
||||
assert.Contains(t, sql, "FROM signoz_metrics.time_series_v4 WHERE")
|
||||
// Slide: W = 10 buckets per slot, absent when the window count is 0.
|
||||
assert.Contains(t, sql, "arraySum(arraySlice(cnts, k + 1, 10))")
|
||||
assert.Contains(t, sql, "arraySum(arraySlice(vals, k + 1, 10))")
|
||||
}
|
||||
|
||||
func TestBuildUnitSQLDisjointOverTime(t *testing.T) {
|
||||
// avg_over_time[5m] on a 30m grid: the windows are pairwise disjoint,
|
||||
// so there is no slide — one Resample bucket per grid slot, read
|
||||
// directly. Exact only together with the window-sliver predicate, which
|
||||
// removes the gap samples the ceil index would otherwise assign to the
|
||||
// window above them.
|
||||
unit := &coreUnit{
|
||||
kind: unitOverTime,
|
||||
overFn: "avg",
|
||||
rangeMs: 300_000,
|
||||
matchers: []*labels.Matcher{mustMatcher(t, labels.MatchEqual, "__name__", "node_load1")},
|
||||
}
|
||||
sql, _, err := buildUnitSQL(unit, []string{"node_load1"}, 1_699_999_700_000, 1_700_003_600_000, 1_700_000_000_000, 1_700_003_600_000, 1_800_000, 300_000)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotContains(t, sql, "ARRAY JOIN")
|
||||
// gridLen = 3 slots, bucket array the same length — no W tail.
|
||||
assert.Contains(t, sql, "countResample(0, 3, 1)(value, intDiv(unix_milli - 1700000000000 + 1800000 - 1, 1800000)) AS cnts")
|
||||
assert.Contains(t, sql, "sumResample(0, 3, 1)(value, intDiv(unix_milli - 1700000000000 + 1800000 - 1, 1800000)) AS vals")
|
||||
// Single-bucket window: the slide degenerates to reading one slot.
|
||||
assert.Contains(t, sql, "arraySum(arraySlice(cnts, k + 1, 1))")
|
||||
// The sliver predicate is the correctness precondition of this form.
|
||||
assert.Contains(t, sql, "positiveModulo(? - unix_milli, ?) < ?")
|
||||
}
|
||||
|
||||
// TestDisjointWindowLattice brute-forces the disjoint-form arithmetic: a
|
||||
// sample survives the sliver predicate exactly when some grid window
|
||||
// contains it, and the ceil bucket index then lands it on that window's
|
||||
// slot. This is the pure-Go mirror of the SQL expressions — the predicate
|
||||
// in samplesConditions and jj in windowedInner — over random lattices,
|
||||
// including off-lattice ends and samples beyond the last grid point.
|
||||
func TestDisjointWindowLattice(t *testing.T) {
|
||||
rng := func(seed *uint64) int64 {
|
||||
*seed = *seed*6364136223846793005 + 1442695040888963407
|
||||
return int64(*seed >> 33)
|
||||
}
|
||||
seed := uint64(42)
|
||||
for trial := 0; trial < 2000; trial++ {
|
||||
stepMs := 1_000 * (1 + rng(&seed)%3600)
|
||||
windowMs := 1 + rng(&seed)%(stepMs-1) // strictly below the step
|
||||
selStart := 1_700_000_000_000 + rng(&seed)%1_000_000
|
||||
selEnd := selStart + rng(&seed)%(50*stepMs) // end may sit off-lattice
|
||||
lastIdx := (selEnd - selStart) / stepMs
|
||||
upper := selStart + lastIdx*stepMs
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
u := selStart - windowMs - stepMs + rng(&seed)%(selEnd-selStart+3*stepMs)
|
||||
|
||||
// Oracle: is u inside any window (t_k - window, t_k]?
|
||||
inWindow := false
|
||||
var slot int64 = -1
|
||||
for k := int64(0); k <= lastIdx; k++ {
|
||||
tk := selStart + k*stepMs
|
||||
if u > tk-windowMs && u <= tk {
|
||||
inWindow = true
|
||||
slot = k
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// The SQL: fetch bounds, then the sliver predicate
|
||||
// positiveModulo(selStart - u, step) < window.
|
||||
kept := u > selStart-windowMs && u <= upper
|
||||
if kept {
|
||||
pmod := (selStart - u) % stepMs
|
||||
if pmod < 0 {
|
||||
pmod += stepMs
|
||||
}
|
||||
kept = pmod < windowMs
|
||||
}
|
||||
|
||||
require.Equal(t, inWindow, kept,
|
||||
"sliver keep mismatch: u=%d selStart=%d step=%d window=%d", u, selStart, stepMs, windowMs)
|
||||
if !kept {
|
||||
continue
|
||||
}
|
||||
// jj = ceil((u - selStart)/step) via one intDiv; numerator is
|
||||
// positive because u > selStart - window > selStart - step.
|
||||
jj := (u - selStart + stepMs - 1) / stepMs
|
||||
require.Equal(t, slot, jj,
|
||||
"slot mismatch: u=%d selStart=%d step=%d window=%d", u, selStart, stepMs, windowMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryExecuteRange_WindowedGateFallsBack(t *testing.T) {
|
||||
c, store := newTestClient(t)
|
||||
e := &executor{client: c, parser: prometheus.NewParser()}
|
||||
|
||||
start := time.UnixMilli(1_700_000_000_000)
|
||||
end := time.UnixMilli(1_700_003_600_000)
|
||||
|
||||
// 10m range at 90s step: the window is not a whole number of buckets.
|
||||
_, ok, err := e.TryExecuteRange(context.Background(), `avg_over_time(up[10m])`, start, end, 90*time.Second)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok, "range not divisible by step must not transpile")
|
||||
|
||||
// 1d range at 60s step: 1440 bucket combines per slot, over the cap.
|
||||
_, ok, err = e.TryExecuteRange(context.Background(), `avg_over_time(up[1d])`, start, end, time.Minute)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok, "range/step above maxWindowBuckets must not transpile")
|
||||
|
||||
// 1m range at 5m step: the windows are disjoint slivers — no
|
||||
// divisibility or width requirement, so this transpiles.
|
||||
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("up", int64(1_699_999_200_000), int64(1_700_003_600_000)).WillReturnRows(cmock.NewRows(seriesCols, [][]any{}))
|
||||
_, ok, err = e.TryExecuteRange(context.Background(), `avg_over_time(up[1m])`, start, end, 5*time.Minute)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok, "range below step is the disjoint form and must transpile")
|
||||
}
|
||||
|
||||
func TestApplyScalarOps(t *testing.T) {
|
||||
f := func(v float64) *float64 { return &v }
|
||||
|
||||
t.Run("arithmetic chain", func(t *testing.T) {
|
||||
values := []*float64{f(2), nil, f(4)}
|
||||
applyScalarOps([]scalarOp{{op: parser.MUL, scalar: 100}, {op: parser.ADD, scalar: 1}}, values)
|
||||
require.NotNil(t, values[0])
|
||||
assert.Equal(t, 201.0, *values[0])
|
||||
assert.Nil(t, values[1])
|
||||
assert.Equal(t, 401.0, *values[2])
|
||||
})
|
||||
|
||||
t.Run("comparison filters points", func(t *testing.T) {
|
||||
values := []*float64{f(1), f(10)}
|
||||
applyScalarOps([]scalarOp{{op: parser.GTR, scalar: 5}}, values)
|
||||
assert.Nil(t, values[0])
|
||||
require.NotNil(t, values[1])
|
||||
assert.Equal(t, 10.0, *values[1], "filter comparisons keep the original value")
|
||||
})
|
||||
|
||||
t.Run("bool comparison emits 0/1", func(t *testing.T) {
|
||||
values := []*float64{f(1), f(10)}
|
||||
applyScalarOps([]scalarOp{{op: parser.GTR, scalar: 5, returnBool: true}}, values)
|
||||
assert.Equal(t, 0.0, *values[0])
|
||||
assert.Equal(t, 1.0, *values[1])
|
||||
})
|
||||
|
||||
t.Run("scalar on left division", func(t *testing.T) {
|
||||
values := []*float64{f(4)}
|
||||
applyScalarOps([]scalarOp{{op: parser.DIV, scalar: 100, scalarOnLeft: true}}, values)
|
||||
assert.Equal(t, 25.0, *values[0])
|
||||
})
|
||||
}
|
||||
|
||||
func TestLabelsFromGroupKey(t *testing.T) {
|
||||
lset, err := labelsFromGroupKey(`[["pod","api-0"],["ns","prod"]]`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "api-0", lset.Get("pod"))
|
||||
assert.Equal(t, "prod", lset.Get("ns"))
|
||||
|
||||
empty, err := labelsFromGroupKey(`[]`)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, empty.IsEmpty())
|
||||
}
|
||||
|
||||
// testGrid is a 2h query grid ending on a round timestamp.
|
||||
func testGrid(stepMs int64) gridContext {
|
||||
return gridContext{startMs: 1_700_000_000_000, endMs: 1_700_007_200_000, stepMs: stepMs}
|
||||
}
|
||||
|
||||
// A bool comparison returns 0/1, not the sample, so the engine drops
|
||||
// __name__; keeping it would change downstream vector matching.
|
||||
func TestKeepsName_BoolComparisonDropsName(t *testing.T) {
|
||||
plan, ok := classify(parse(t, `up > bool 0`), testGrid(60_000))
|
||||
require.True(t, ok)
|
||||
assert.False(t, plan.units[0].core.keepsName())
|
||||
|
||||
plan, ok = classify(parse(t, `up > 0`), testGrid(60_000))
|
||||
require.True(t, ok)
|
||||
assert.True(t, plan.units[0].core.keepsName())
|
||||
}
|
||||
|
||||
// timeSeriesLastToGrid widens its window to max(window, step) — probed on
|
||||
// 25.12 — so Last-style units at window < step must fall back or they would
|
||||
// resurrect samples the engine's lookback already dropped.
|
||||
func TestTryExecuteRange_LastStyleWindowBelowStepTranspiles(t *testing.T) {
|
||||
// These used to fall back because timeSeriesLastToGrid widens its window
|
||||
// to max(window, step). Over sliver-filtered rows the widening is
|
||||
// harmless — the widened window intersected with the data IS the
|
||||
// lookback window — so the gate is gone and both shapes transpile. The
|
||||
// mock returns no series: the point here is the routing, the value
|
||||
// semantics are the parity suite's job.
|
||||
c, store := newTestClient(t)
|
||||
e := &executor{client: c, parser: prometheus.NewParser()}
|
||||
|
||||
start := time.UnixMilli(1_700_000_000_000)
|
||||
end := time.UnixMilli(1_700_003_600_000)
|
||||
|
||||
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("up", int64(1_699_999_200_000), int64(1_700_003_600_000)).WillReturnRows(cmock.NewRows(seriesCols, [][]any{}))
|
||||
_, ok, err := e.TryExecuteRange(context.Background(), `sum by (pod) (up)`, start, end, time.Hour)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok, "instant selection at step > lookback must transpile")
|
||||
|
||||
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("up", int64(1_699_999_200_000), int64(1_700_003_600_000)).WillReturnRows(cmock.NewRows(seriesCols, [][]any{}))
|
||||
_, ok, err = e.TryExecuteRange(context.Background(), `last_over_time(up[10m])`, start, end, time.Hour)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok, "last_over_time at range < step must transpile")
|
||||
}
|
||||
|
||||
// Two metrics collapsing onto one labelset after the name drop, with values
|
||||
// on the same grid slot, is the engine's duplicate-labelset error; merging
|
||||
// them would invent a series no engine would produce. (Temporally disjoint
|
||||
// twins merge instead — see TestMergeSameLabelsetSeries.)
|
||||
func TestExecuteUnit_NameCollisionErrors(t *testing.T) {
|
||||
c, store := newTestClient(t)
|
||||
e := &executor{client: c, parser: prometheus.NewParser()}
|
||||
|
||||
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("^(?:a|b)$", int64(1_699_999_200_000), int64(1_700_003_600_000)).WillReturnRows(cmock.NewRows(seriesCols, [][]any{
|
||||
{uint64(1), `{"__name__":"a","job":"x"}`},
|
||||
{uint64(2), `{"__name__":"b","job":"x"}`},
|
||||
}))
|
||||
store.Mock().ExpectQuery("SELECT gkey").
|
||||
WithArgs("^(?:a|b)$", int64(1_699_999_200_000), int64(1_700_003_600_000), "a", "b", int64(1_699_999_700_000), int64(1_700_003_600_000)).
|
||||
WillReturnRows(cmock.NewRows(gkeyCols, [][]any{
|
||||
{`[["__name__","a"],["job","x"]]`, []*float64{f64(1)}},
|
||||
{`[["__name__","b"],["job","x"]]`, []*float64{f64(2)}},
|
||||
}))
|
||||
|
||||
plan, ok := classify(parse(t, `rate({__name__=~"a|b"}[5m])`), gridContext{startMs: 1_700_000_000_000, endMs: 1_700_003_600_000, stepMs: 60_000})
|
||||
require.True(t, ok)
|
||||
|
||||
_, err := e.executeUnit(context.Background(), &plan.units[0].core, plan.units[0].grid)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "vector cannot contain metrics with the same labelset")
|
||||
}
|
||||
|
||||
var gkeyCols = []cmock.ColumnType{
|
||||
{Name: "gkey", Type: "String"},
|
||||
{Name: "grid", Type: "Array(Nullable(Float64))"},
|
||||
}
|
||||
|
||||
func f64(v float64) *float64 { return &v }
|
||||
|
||||
// A nameless selector can span metrics whose series alternate in time (one
|
||||
// dies inside the lookback before the other appears); after the name drop
|
||||
// the engine merges them into ONE series and errors only when two samples
|
||||
// share an evaluation timestamp. Pinned by conformance cases
|
||||
// operators.test:994/997 (-{job="api"} over http_requests/http_errors).
|
||||
func TestMergeSameLabelsetSeries(t *testing.T) {
|
||||
f := func(v float64) *float64 { return &v }
|
||||
api := labels.FromStrings("job", "api")
|
||||
|
||||
out, err := mergeSameLabelsetSeries([]transpiledSeries{
|
||||
{lset: api, values: []*float64{f(-2), nil}},
|
||||
{lset: api, values: []*float64{nil, f(-4)}},
|
||||
{lset: labels.FromStrings("job", "web"), values: []*float64{f(7), nil}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 2)
|
||||
assert.Equal(t, []*float64{f(-2), f(-4)}, out[0].values, "temporally disjoint twins must merge into one series")
|
||||
|
||||
_, err = mergeSameLabelsetSeries([]transpiledSeries{
|
||||
{lset: api, values: []*float64{f(1), nil}},
|
||||
{lset: api, values: []*float64{f(2), nil}},
|
||||
})
|
||||
require.Error(t, err, "two values on one evaluation timestamp is the engine's duplicate error")
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
}
|
||||
|
||||
// Hybrid twin case: stripping the synthetic __name__ can leave two engine
|
||||
// output series distinguishable only by those names (-metric_a or -metric_b:
|
||||
// both {} once real names are dropped). Pinned by conformance cases
|
||||
// name_label_dropping.test:137 and operators.test:1016.
|
||||
func TestMergeMatrixByLabelset(t *testing.T) {
|
||||
empty := labels.EmptyLabels()
|
||||
|
||||
out, err := mergeMatrixByLabelset(promql.Matrix{
|
||||
{Metric: empty, Floats: []promql.FPoint{{T: 0, F: -1}}},
|
||||
{Metric: empty, Floats: []promql.FPoint{{T: 600_000, F: -4}}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 1)
|
||||
assert.Equal(t, []promql.FPoint{{T: 0, F: -1}, {T: 600_000, F: -4}}, out[0].Floats)
|
||||
|
||||
_, err = mergeMatrixByLabelset(promql.Matrix{
|
||||
{Metric: empty, Floats: []promql.FPoint{{T: 0, F: -1}}},
|
||||
{Metric: empty, Floats: []promql.FPoint{{T: 0, F: -3}}},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
}
|
||||
@@ -24,10 +24,6 @@ type Config struct {
|
||||
|
||||
// Timeout is the maximum time a query is allowed to run before being aborted.
|
||||
Timeout time.Duration `mapstructure:"timeout"`
|
||||
|
||||
// ProviderName selects the storage provider: "clickhouse" (default) or
|
||||
// "clickhousev2".
|
||||
ProviderName string `mapstructure:"provider"`
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
@@ -41,8 +37,7 @@ func newConfig() factory.Config {
|
||||
Path: "",
|
||||
MaxConcurrent: 20,
|
||||
},
|
||||
Timeout: 2 * time.Minute,
|
||||
ProviderName: "clickhouse",
|
||||
Timeout: 2 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,15 +45,9 @@ func (c Config) Validate() error {
|
||||
if c.Timeout <= 0 {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "prometheus::timeout must be greater than 0")
|
||||
}
|
||||
if c.ProviderName != "" && c.ProviderName != "clickhouse" && c.ProviderName != "clickhousev2" {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "prometheus::provider must be one of [clickhouse, clickhousev2], got %q", c.ProviderName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) Provider() string {
|
||||
if c.ProviderName == "" {
|
||||
return "clickhouse"
|
||||
}
|
||||
return c.ProviderName
|
||||
return "clickhouse"
|
||||
}
|
||||
|
||||
@@ -35,9 +35,3 @@ type StatementRecorder interface {
|
||||
type StatementCapturer interface {
|
||||
CapturingStorage() (storage.Queryable, StatementRecorder)
|
||||
}
|
||||
|
||||
// ProviderClickhouseV2 is the clickhousev2 provider name: the factory
|
||||
// registration, the prometheus::provider config value and the
|
||||
// X-SigNoz-PromQL-Provider request header all use it, so they cannot drift
|
||||
// apart.
|
||||
const ProviderClickhouseV2 = "clickhousev2"
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package prometheus
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
)
|
||||
|
||||
type queryTraitsKey struct{}
|
||||
|
||||
// QueryTraits carries per-query facts a storage implementation cannot derive
|
||||
// from SelectHints alone. Call sites that parse the PromQL expression attach
|
||||
// traits to the context before handing it to the engine; storages treat a
|
||||
// missing traits value as "unknown" and stay conservative.
|
||||
type QueryTraits struct {
|
||||
// SubqueryFree is true when the query contains no subquery expression.
|
||||
// Subquery selectors are evaluated at the subquery's own step, but
|
||||
// SelectHints.Step always carries the top-level step, so step-aligned
|
||||
// storage optimizations (e.g. keeping only the last sample per step
|
||||
// bucket) are safe only when this is true.
|
||||
SubqueryFree bool
|
||||
}
|
||||
|
||||
// DetectQueryTraits derives QueryTraits from a parsed PromQL expression.
|
||||
func DetectQueryTraits(expr parser.Expr) QueryTraits {
|
||||
subqueryFree := true
|
||||
parser.Inspect(expr, func(node parser.Node, _ []parser.Node) error {
|
||||
if _, ok := node.(*parser.SubqueryExpr); ok {
|
||||
subqueryFree = false
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return QueryTraits{SubqueryFree: subqueryFree}
|
||||
}
|
||||
|
||||
// NewContextWithQueryTraits returns a context carrying the given traits.
|
||||
func NewContextWithQueryTraits(ctx context.Context, traits QueryTraits) context.Context {
|
||||
return context.WithValue(ctx, queryTraitsKey{}, traits)
|
||||
}
|
||||
|
||||
// QueryTraitsFromContext returns the traits attached to ctx, if any.
|
||||
//
|
||||
// Context is used here, unlike for backend selection, because traits must
|
||||
// cross the promql engine to reach storage.Querier.Select, and the engine's
|
||||
// interfaces offer no other channel; the alternative is a Prometheus fork.
|
||||
func QueryTraitsFromContext(ctx context.Context) (QueryTraits, bool) {
|
||||
traits, ok := ctx.Value(queryTraitsKey{}).(QueryTraits)
|
||||
return traits, ok
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user