mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-05 09:50:31 +01:00
Compare commits
6 Commits
feat/billi
...
fix/cursor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf99f946e2 | ||
|
|
8b0ccc8ddc | ||
|
|
1118136b69 | ||
|
|
ae3f5114c4 | ||
|
|
8409a9798d | ||
|
|
de6e4890ae |
@@ -18,13 +18,11 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/cache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/gateway"
|
||||
"github.com/SigNoz/signoz/pkg/gateway/noopgateway"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/licensing/nooplicensing"
|
||||
"github.com/SigNoz/signoz/pkg/meterreporter"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
@@ -111,9 +109,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
func(_ licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]] {
|
||||
return signoz.NewAuditorProviderFactories()
|
||||
},
|
||||
func(_ context.Context, _ flagger.Flagger, _ licensing.Licensing, _ telemetrystore.TelemetryStore, _ sqlstore.SQLStore, _ organization.Getter, _ zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string) {
|
||||
return signoz.NewMeterReporterProviderFactories(), "noop"
|
||||
},
|
||||
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
|
||||
return querier.NewHandler(ps, q, a)
|
||||
},
|
||||
|
||||
@@ -17,14 +17,6 @@ import (
|
||||
"github.com/SigNoz/signoz/ee/gateway/httpgateway"
|
||||
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
|
||||
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
|
||||
"github.com/SigNoz/signoz/ee/metercollector/baseplatformfeemetercollector"
|
||||
"github.com/SigNoz/signoz/ee/metercollector/datapointcountmetercollector"
|
||||
"github.com/SigNoz/signoz/ee/metercollector/datapointsizemetercollector"
|
||||
"github.com/SigNoz/signoz/ee/metercollector/logcountmetercollector"
|
||||
"github.com/SigNoz/signoz/ee/metercollector/logsizemetercollector"
|
||||
"github.com/SigNoz/signoz/ee/metercollector/spancountmetercollector"
|
||||
"github.com/SigNoz/signoz/ee/metercollector/spansizemetercollector"
|
||||
"github.com/SigNoz/signoz/ee/meterreporter/httpmeterreporter"
|
||||
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration/implcloudprovider"
|
||||
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
|
||||
@@ -43,12 +35,9 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/cache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
pkgflagger "github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/gateway"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/meterreporter"
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
pkgcloudintegration "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
@@ -68,10 +57,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
)
|
||||
@@ -171,19 +157,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
}
|
||||
return factories
|
||||
},
|
||||
func(ctx context.Context, flagger pkgflagger.Flagger, licensing licensing.Licensing, telemetryStore telemetrystore.TelemetryStore, sqlStore sqlstore.SQLStore, orgGetter organization.Getter, zeus zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string) {
|
||||
factories := signoz.NewMeterReporterProviderFactories()
|
||||
if err := factories.Add(httpmeterreporter.NewFactory(newMeterCollectors(licensing, telemetryStore, sqlStore), licensing, telemetryStore, orgGetter, zeus)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
evalCtx := featuretypes.NewFlaggerEvaluationContext(valuer.UUID{})
|
||||
if flagger.BooleanOrEmpty(ctx, pkgflagger.FeatureUseMeterReporter, evalCtx) {
|
||||
return factories, "http"
|
||||
}
|
||||
|
||||
return factories, "noop"
|
||||
},
|
||||
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
|
||||
communityHandler := querier.NewHandler(ps, q, a)
|
||||
return eequerier.NewHandler(ps, q, communityHandler)
|
||||
@@ -243,15 +216,3 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newMeterCollectors(licensing licensing.Licensing, telemetryStore telemetrystore.TelemetryStore, sqlStore sqlstore.SQLStore) map[metercollectortypes.Name]metercollector.MeterCollector {
|
||||
return map[metercollectortypes.Name]metercollector.MeterCollector{
|
||||
baseplatformfeemetercollector.MeterName: baseplatformfeemetercollector.New(licensing),
|
||||
logcountmetercollector.MeterName: logcountmetercollector.New(telemetryStore, sqlStore),
|
||||
logsizemetercollector.MeterName: logsizemetercollector.New(telemetryStore, sqlStore),
|
||||
datapointcountmetercollector.MeterName: datapointcountmetercollector.New(telemetryStore, sqlStore),
|
||||
datapointsizemetercollector.MeterName: datapointsizemetercollector.New(telemetryStore, sqlStore),
|
||||
spancountmetercollector.MeterName: spancountmetercollector.New(telemetryStore, sqlStore),
|
||||
spansizemetercollector.MeterName: spansizemetercollector.New(telemetryStore, sqlStore),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,10 +429,3 @@ authz:
|
||||
openfga:
|
||||
# maximum tuples allowed per openfga write operation.
|
||||
max_tuples_per_write: 100
|
||||
|
||||
##################### Meter Reporter #####################
|
||||
meterreporter:
|
||||
# The interval between collection ticks. Minimum 5m.
|
||||
interval: 6h
|
||||
# The per-tick timeout that bounds collect-and-ship work. Minimum 3m and must be less than interval.
|
||||
timeout: 5m
|
||||
|
||||
@@ -301,34 +301,20 @@ components:
|
||||
type: string
|
||||
type: object
|
||||
AuthtypesGettableAuthDomain:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AuthtypesSamlConfig'
|
||||
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
|
||||
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
|
||||
properties:
|
||||
authNProviderInfo:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProviderInfo'
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
googleAuthConfig:
|
||||
$ref: '#/components/schemas/AuthtypesGoogleConfig'
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
oidcConfig:
|
||||
$ref: '#/components/schemas/AuthtypesOIDCConfig'
|
||||
orgId:
|
||||
type: string
|
||||
roleMapping:
|
||||
$ref: '#/components/schemas/AuthtypesRoleMapping'
|
||||
samlConfig:
|
||||
$ref: '#/components/schemas/AuthtypesSamlConfig'
|
||||
ssoEnabled:
|
||||
type: boolean
|
||||
ssoType:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
@@ -589,7 +575,7 @@ components:
|
||||
- relation
|
||||
- object
|
||||
type: object
|
||||
AuthtypesUpdateableAuthDomain:
|
||||
AuthtypesUpdatableAuthDomain:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
|
||||
@@ -7079,20 +7065,20 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesPostableAuthDomain'
|
||||
responses:
|
||||
"200":
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
|
||||
$ref: '#/components/schemas/TypesIdentifiable'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
@@ -7248,7 +7234,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesUpdateableAuthDomain'
|
||||
$ref: '#/components/schemas/AuthtypesUpdatableAuthDomain'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
// Package baseplatformfeemetercollector collects the license-derived base platform fee meter.
|
||||
package baseplatformfeemetercollector
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// MeterName is the typed registry key for this collector.
|
||||
var (
|
||||
MeterName = metercollectortypes.MustNewName("signoz.meter.base.platform.fee")
|
||||
meterUnit = metercollectortypes.UnitCount
|
||||
meterAggregation = metercollectortypes.AggregationMax
|
||||
)
|
||||
|
||||
var _ metercollector.MeterCollector = (*Provider)(nil)
|
||||
|
||||
// Provider collects base platform fee meters.
|
||||
type Provider struct {
|
||||
licensing licensing.Licensing
|
||||
}
|
||||
|
||||
func New(licensing licensing.Licensing) *Provider {
|
||||
return &Provider{licensing: licensing}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() metercollectortypes.Name { return MeterName }
|
||||
func (p *Provider) Unit() metercollectortypes.Unit { return meterUnit }
|
||||
func (p *Provider) Aggregation() metercollectortypes.Aggregation {
|
||||
return meterAggregation
|
||||
}
|
||||
|
||||
// Collect emits value 1 when the org has an active license.
|
||||
func (p *Provider) Collect(ctx context.Context, orgID valuer.UUID, window meterreportertypes.Window) ([]meterreportertypes.Meter, error) {
|
||||
if !window.IsValid() {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, metercollector.ErrCodeCollectFailed, "invalid window [%d, %d)", window.StartUnixMilli, window.EndUnixMilli)
|
||||
}
|
||||
|
||||
license, err := p.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "fetch active license for base platform fee meter")
|
||||
}
|
||||
if license == nil || license.Key == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return []meterreportertypes.Meter{
|
||||
meterreportertypes.NewMeter(MeterName, 1, meterUnit, meterAggregation, window, map[string]string{
|
||||
metercollector.DimensionOrganizationID: orgID.StringValue(),
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package baseplatformfeemetercollector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/types/licensetypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCollectEmitsBasePlatformFeeMeterForValidLicense(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
window := completedWindow()
|
||||
provider := New(&fakeLicensing{
|
||||
license: &licensetypes.License{Key: "license-key"},
|
||||
})
|
||||
|
||||
readings, err := provider.Collect(context.Background(), orgID, window)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []meterreportertypes.Meter{
|
||||
meterreportertypes.NewMeter(MeterName, 1, metercollectortypes.UnitCount, metercollectortypes.AggregationMax, window, map[string]string{
|
||||
metercollector.DimensionOrganizationID: orgID.StringValue(),
|
||||
}),
|
||||
}, readings)
|
||||
}
|
||||
|
||||
func TestCollectSkipsNilLicense(t *testing.T) {
|
||||
readings, err := New(&fakeLicensing{}).Collect(context.Background(), valuer.GenerateUUID(), completedWindow())
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, readings)
|
||||
}
|
||||
|
||||
func TestProviderMetadata(t *testing.T) {
|
||||
provider := New(&fakeLicensing{})
|
||||
|
||||
require.Equal(t, "signoz.meter.base.platform.fee", provider.Name().String())
|
||||
require.Equal(t, metercollectortypes.UnitCount, provider.Unit())
|
||||
require.Equal(t, metercollectortypes.AggregationMax, provider.Aggregation())
|
||||
}
|
||||
|
||||
func TestCollectRejectsInvalidWindowBeforeLicensing(t *testing.T) {
|
||||
readings, err := New(nil).Collect(context.Background(), valuer.GenerateUUID(), meterreportertypes.Window{})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, readings)
|
||||
}
|
||||
|
||||
func completedWindow() meterreportertypes.Window {
|
||||
start := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)
|
||||
return meterreportertypes.Window{
|
||||
StartUnixMilli: start.UnixMilli(),
|
||||
EndUnixMilli: start.AddDate(0, 0, 1).UnixMilli(),
|
||||
IsCompleted: true,
|
||||
}
|
||||
}
|
||||
|
||||
var _ licensing.Licensing = (*fakeLicensing)(nil)
|
||||
|
||||
type fakeLicensing struct {
|
||||
license *licensetypes.License
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeLicensing) Start(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeLicensing) Stop(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeLicensing) Validate(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeLicensing) Activate(context.Context, valuer.UUID, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeLicensing) GetActive(context.Context, valuer.UUID) (*licensetypes.License, error) {
|
||||
return f.license, f.err
|
||||
}
|
||||
|
||||
func (f *fakeLicensing) Refresh(context.Context, valuer.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeLicensing) Checkout(context.Context, valuer.UUID, *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) {
|
||||
return &licensetypes.GettableSubscription{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeLicensing) Portal(context.Context, valuer.UUID, *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) {
|
||||
return &licensetypes.GettableSubscription{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeLicensing) GetFeatureFlags(context.Context, valuer.UUID) ([]*licensetypes.Feature, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeLicensing) Collect(context.Context, valuer.UUID) (map[string]any, error) {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
// Package datapointcountmetercollector collects metric datapoint count meters
|
||||
// by workspace and retention. Keep the query local to this meter.
|
||||
package datapointcountmetercollector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/metercollector/retention"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// MeterName is the typed registry key for this collector.
|
||||
var (
|
||||
MeterName = metercollectortypes.MustNewName("signoz.meter.metric.datapoint.count")
|
||||
meterUnit = metercollectortypes.UnitCount
|
||||
meterAggregation = metercollectortypes.AggregationSum
|
||||
)
|
||||
|
||||
var _ metercollector.MeterCollector = (*Provider)(nil)
|
||||
|
||||
// Provider collects datapoint count meters.
|
||||
type Provider struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
sqlStore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func New(telemetryStore telemetrystore.TelemetryStore, sqlStore sqlstore.SQLStore) *Provider {
|
||||
return &Provider{
|
||||
telemetryStore: telemetryStore,
|
||||
sqlStore: sqlStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() metercollectortypes.Name { return MeterName }
|
||||
func (p *Provider) Unit() metercollectortypes.Unit { return meterUnit }
|
||||
func (p *Provider) Aggregation() metercollectortypes.Aggregation {
|
||||
return meterAggregation
|
||||
}
|
||||
|
||||
// Collect aggregates datapoint count for the window and emits an empty-day sentinel.
|
||||
func (p *Provider) Collect(ctx context.Context, orgID valuer.UUID, window meterreportertypes.Window) ([]meterreportertypes.Meter, error) {
|
||||
if !window.IsValid() {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, metercollector.ErrCodeCollectFailed, "invalid window [%d, %d)", window.StartUnixMilli, window.EndUnixMilli)
|
||||
}
|
||||
|
||||
meterName := MeterName.String()
|
||||
|
||||
slices, err := retention.LoadActiveSlices(
|
||||
ctx,
|
||||
p.sqlStore,
|
||||
orgID,
|
||||
telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4LocalTableName,
|
||||
retentiontypes.DefaultMetricsRetentionDays,
|
||||
window.StartUnixMilli, window.EndUnixMilli,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "load retention slices for meter %q", meterName)
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
dimensions map[string]string
|
||||
value float64
|
||||
}
|
||||
accumulator := make(map[string]*bucket)
|
||||
|
||||
for _, slice := range slices {
|
||||
query, args, dimensionColumns, err := buildQuery(meterName, slice)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build retention query for meter %q", meterName)
|
||||
}
|
||||
|
||||
rows, err := p.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "query meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
|
||||
if err := func() error {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
dimensionValues := make([]string, len(dimensionColumns))
|
||||
var retentionDays int32
|
||||
var retentionRuleIndex int32
|
||||
var value float64
|
||||
|
||||
scanDest := make([]any, 0, len(dimensionValues)+3)
|
||||
for i := range dimensionValues {
|
||||
scanDest = append(scanDest, &dimensionValues[i])
|
||||
}
|
||||
scanDest = append(scanDest, &retentionDays, &retentionRuleIndex, &value)
|
||||
|
||||
if err := rows.Scan(scanDest...); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "scan meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
|
||||
dimensions, err := buildDimensions(orgID, int(retentionDays), int(retentionRuleIndex), dimensionColumns, dimensionValues, slice.Rules)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build dimensions for meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
|
||||
key := bucketKey(dimensions)
|
||||
b, ok := accumulator[key]
|
||||
if !ok {
|
||||
b = &bucket{dimensions: dimensions}
|
||||
accumulator[key] = b
|
||||
}
|
||||
b.value += value
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "iterate meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
meters := make([]meterreportertypes.Meter, 0, len(accumulator))
|
||||
for _, b := range accumulator {
|
||||
meters = append(meters, meterreportertypes.NewMeter(MeterName, b.value, meterUnit, meterAggregation, window, b.dimensions))
|
||||
}
|
||||
|
||||
// Empty windows still emit a sentinel so checkpoints can advance.
|
||||
if len(meters) == 0 && len(slices) > 0 {
|
||||
meters = append(meters, meterreportertypes.NewMeter(MeterName, 0, meterUnit, meterAggregation, window, map[string]string{
|
||||
metercollector.DimensionOrganizationID: orgID.StringValue(),
|
||||
metercollector.DimensionRetentionDays: strconv.Itoa(slices[len(slices)-1].DefaultDays),
|
||||
}))
|
||||
}
|
||||
|
||||
return meters, nil
|
||||
}
|
||||
|
||||
// buildQuery stays local because each meter owns its billing query.
|
||||
func buildQuery(meterName string, slice retentiontypes.Slice) (string, []any, []dimensionColumn, error) {
|
||||
retentionExpr, err := retention.BuildMultiIfSQL(slice.Rules, slice.DefaultDays)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
retentionRuleIndexExpr, err := retention.BuildRuleIndexSQL(slice.Rules)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
columns, err := dimensionColumnsFor(slice.Rules)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
|
||||
selects := make([]string, 0, len(columns)+3)
|
||||
groupBy := make([]string, 0, len(columns)+2)
|
||||
for _, column := range columns {
|
||||
selects = append(selects, fmt.Sprintf("JSONExtractString(labels, '%s') AS %s", column.key, column.alias))
|
||||
groupBy = append(groupBy, column.alias)
|
||||
}
|
||||
selects = append(selects,
|
||||
retentionExpr+" AS retention_days",
|
||||
retentionRuleIndexExpr+" AS retention_rule_index",
|
||||
"ifNull(sum(value), 0) AS value",
|
||||
)
|
||||
groupBy = append(groupBy, "retention_days", "retention_rule_index")
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(selects...)
|
||||
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
|
||||
sb.Where(
|
||||
sb.Equal("metric_name", meterName),
|
||||
sb.GTE("unix_milli", slice.StartMs),
|
||||
sb.LT("unix_milli", slice.EndMs),
|
||||
)
|
||||
sb.GroupBy(groupBy...)
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return query, args, columns, nil
|
||||
}
|
||||
|
||||
type dimensionColumn struct {
|
||||
key string
|
||||
alias string
|
||||
}
|
||||
|
||||
func dimensionColumnsFor(rules []retentiontypes.CustomRetentionRule) ([]dimensionColumn, error) {
|
||||
dimensionKeys, err := retention.RuleDimensionKeys(rules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, 0, len(dimensionKeys)+1)
|
||||
keys = append(keys, metercollector.DimensionWorkspaceKeyID)
|
||||
for _, key := range dimensionKeys {
|
||||
if key == metercollector.DimensionWorkspaceKeyID {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
columns := make([]dimensionColumn, len(keys))
|
||||
for i, key := range keys {
|
||||
columns[i] = dimensionColumn{key: key, alias: fmt.Sprintf("dim_%d", i)}
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func buildDimensions(
|
||||
orgID valuer.UUID,
|
||||
retentionDays int,
|
||||
retentionRuleIndex int,
|
||||
columns []dimensionColumn,
|
||||
values []string,
|
||||
rules []retentiontypes.CustomRetentionRule,
|
||||
) (map[string]string, error) {
|
||||
if len(columns) != len(values) {
|
||||
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "dimension column/value count mismatch: %d columns, %d values", len(columns), len(values))
|
||||
}
|
||||
|
||||
valuesByKey := make(map[string]string, len(columns))
|
||||
for i, column := range columns {
|
||||
valuesByKey[column.key] = values[i]
|
||||
}
|
||||
|
||||
dimensions := map[string]string{
|
||||
metercollector.DimensionOrganizationID: orgID.StringValue(),
|
||||
metercollector.DimensionRetentionDays: strconv.Itoa(retentionDays),
|
||||
}
|
||||
addNonEmpty(dimensions, metercollector.DimensionWorkspaceKeyID, valuesByKey[metercollector.DimensionWorkspaceKeyID])
|
||||
|
||||
if retentionRuleIndex < 0 {
|
||||
return dimensions, nil
|
||||
}
|
||||
if retentionRuleIndex >= len(rules) {
|
||||
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "retention rule index %d out of range for %d rules", retentionRuleIndex, len(rules))
|
||||
}
|
||||
for _, filter := range rules[retentionRuleIndex].Filters {
|
||||
addNonEmpty(dimensions, filter.Key, valuesByKey[filter.Key])
|
||||
}
|
||||
return dimensions, nil
|
||||
}
|
||||
|
||||
func addNonEmpty(dimensions map[string]string, key, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
dimensions[key] = value
|
||||
}
|
||||
|
||||
func bucketKey(dimensions map[string]string) string {
|
||||
keys := make([]string, 0, len(dimensions))
|
||||
for key := range dimensions {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var b strings.Builder
|
||||
for _, key := range keys {
|
||||
value := dimensions[key]
|
||||
b.WriteString(strconv.Itoa(len(key)))
|
||||
b.WriteByte(':')
|
||||
b.WriteString(key)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(strconv.Itoa(len(value)))
|
||||
b.WriteByte(':')
|
||||
b.WriteString(value)
|
||||
b.WriteByte(';')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package datapointcountmetercollector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildDimensions(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
rules := []retentiontypes.CustomRetentionRule{{
|
||||
Filters: []retentiontypes.FilterCondition{{
|
||||
Key: "service.name",
|
||||
Values: []string{"api"},
|
||||
}},
|
||||
TTLDays: 7,
|
||||
}}
|
||||
columns := []dimensionColumn{
|
||||
{key: metercollector.DimensionWorkspaceKeyID, alias: "dim_0"},
|
||||
{key: "service.name", alias: "dim_1"},
|
||||
}
|
||||
|
||||
dimensions, err := buildDimensions(orgID, 30, 0, columns, []string{"workspace-1", "api"}, rules)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]string{
|
||||
metercollector.DimensionOrganizationID: orgID.StringValue(),
|
||||
metercollector.DimensionRetentionDays: "30",
|
||||
metercollector.DimensionWorkspaceKeyID: "workspace-1",
|
||||
"service.name": "api",
|
||||
}, dimensions)
|
||||
}
|
||||
|
||||
func TestProviderMetadata(t *testing.T) {
|
||||
provider := New(nil, nil)
|
||||
|
||||
require.Equal(t, "signoz.meter.metric.datapoint.count", provider.Name().String())
|
||||
require.Equal(t, metercollectortypes.UnitCount, provider.Unit())
|
||||
require.Equal(t, metercollectortypes.AggregationSum, provider.Aggregation())
|
||||
}
|
||||
|
||||
func TestBucketKeyIsStable(t *testing.T) {
|
||||
first := bucketKey(map[string]string{
|
||||
"service.name": "api",
|
||||
metercollector.DimensionRetentionDays: "30",
|
||||
metercollector.DimensionWorkspaceKeyID: "workspace-1",
|
||||
})
|
||||
second := bucketKey(map[string]string{
|
||||
metercollector.DimensionWorkspaceKeyID: "workspace-1",
|
||||
"service.name": "api",
|
||||
metercollector.DimensionRetentionDays: "30",
|
||||
})
|
||||
|
||||
require.Equal(t, first, second)
|
||||
require.NotEmpty(t, first)
|
||||
}
|
||||
|
||||
func TestCollectRejectsInvalidWindowBeforeQuerying(t *testing.T) {
|
||||
readings, err := New(nil, nil).Collect(context.Background(), valuer.GenerateUUID(), meterreportertypes.Window{})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, readings)
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
// Package datapointsizemetercollector collects metric datapoint size meters
|
||||
// by workspace and retention. Keep the query local to this meter.
|
||||
package datapointsizemetercollector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/metercollector/retention"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// MeterName is the typed registry key for this collector.
|
||||
var (
|
||||
MeterName = metercollectortypes.MustNewName("signoz.meter.metric.datapoint.size")
|
||||
meterUnit = metercollectortypes.UnitBytes
|
||||
meterAggregation = metercollectortypes.AggregationSum
|
||||
)
|
||||
|
||||
var _ metercollector.MeterCollector = (*Provider)(nil)
|
||||
|
||||
// Provider collects datapoint size meters.
|
||||
type Provider struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
sqlStore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func New(telemetryStore telemetrystore.TelemetryStore, sqlStore sqlstore.SQLStore) *Provider {
|
||||
return &Provider{
|
||||
telemetryStore: telemetryStore,
|
||||
sqlStore: sqlStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() metercollectortypes.Name { return MeterName }
|
||||
func (p *Provider) Unit() metercollectortypes.Unit { return meterUnit }
|
||||
func (p *Provider) Aggregation() metercollectortypes.Aggregation {
|
||||
return meterAggregation
|
||||
}
|
||||
|
||||
// Collect aggregates datapoint size for the window and emits an empty-day sentinel.
|
||||
func (p *Provider) Collect(ctx context.Context, orgID valuer.UUID, window meterreportertypes.Window) ([]meterreportertypes.Meter, error) {
|
||||
if !window.IsValid() {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, metercollector.ErrCodeCollectFailed, "invalid window [%d, %d)", window.StartUnixMilli, window.EndUnixMilli)
|
||||
}
|
||||
|
||||
meterName := MeterName.String()
|
||||
|
||||
slices, err := retention.LoadActiveSlices(
|
||||
ctx,
|
||||
p.sqlStore,
|
||||
orgID,
|
||||
telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4LocalTableName,
|
||||
retentiontypes.DefaultMetricsRetentionDays,
|
||||
window.StartUnixMilli, window.EndUnixMilli,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "load retention slices for meter %q", meterName)
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
dimensions map[string]string
|
||||
value float64
|
||||
}
|
||||
accumulator := make(map[string]*bucket)
|
||||
|
||||
for _, slice := range slices {
|
||||
query, args, dimensionColumns, err := buildQuery(meterName, slice)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build retention query for meter %q", meterName)
|
||||
}
|
||||
|
||||
rows, err := p.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "query meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
|
||||
if err := func() error {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
dimensionValues := make([]string, len(dimensionColumns))
|
||||
var retentionDays int32
|
||||
var retentionRuleIndex int32
|
||||
var value float64
|
||||
|
||||
scanDest := make([]any, 0, len(dimensionValues)+3)
|
||||
for i := range dimensionValues {
|
||||
scanDest = append(scanDest, &dimensionValues[i])
|
||||
}
|
||||
scanDest = append(scanDest, &retentionDays, &retentionRuleIndex, &value)
|
||||
|
||||
if err := rows.Scan(scanDest...); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "scan meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
|
||||
dimensions, err := buildDimensions(orgID, int(retentionDays), int(retentionRuleIndex), dimensionColumns, dimensionValues, slice.Rules)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build dimensions for meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
|
||||
key := bucketKey(dimensions)
|
||||
b, ok := accumulator[key]
|
||||
if !ok {
|
||||
b = &bucket{dimensions: dimensions}
|
||||
accumulator[key] = b
|
||||
}
|
||||
b.value += value
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "iterate meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
meters := make([]meterreportertypes.Meter, 0, len(accumulator))
|
||||
for _, b := range accumulator {
|
||||
meters = append(meters, meterreportertypes.NewMeter(MeterName, b.value, meterUnit, meterAggregation, window, b.dimensions))
|
||||
}
|
||||
|
||||
// Empty windows still emit a sentinel so checkpoints can advance.
|
||||
if len(meters) == 0 && len(slices) > 0 {
|
||||
meters = append(meters, meterreportertypes.NewMeter(MeterName, 0, meterUnit, meterAggregation, window, map[string]string{
|
||||
metercollector.DimensionOrganizationID: orgID.StringValue(),
|
||||
metercollector.DimensionRetentionDays: strconv.Itoa(slices[len(slices)-1].DefaultDays),
|
||||
}))
|
||||
}
|
||||
|
||||
return meters, nil
|
||||
}
|
||||
|
||||
// buildQuery stays local because each meter owns its billing query.
|
||||
func buildQuery(meterName string, slice retentiontypes.Slice) (string, []any, []dimensionColumn, error) {
|
||||
retentionExpr, err := retention.BuildMultiIfSQL(slice.Rules, slice.DefaultDays)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
retentionRuleIndexExpr, err := retention.BuildRuleIndexSQL(slice.Rules)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
columns, err := dimensionColumnsFor(slice.Rules)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
|
||||
selects := make([]string, 0, len(columns)+3)
|
||||
groupBy := make([]string, 0, len(columns)+2)
|
||||
for _, column := range columns {
|
||||
selects = append(selects, fmt.Sprintf("JSONExtractString(labels, '%s') AS %s", column.key, column.alias))
|
||||
groupBy = append(groupBy, column.alias)
|
||||
}
|
||||
selects = append(selects,
|
||||
retentionExpr+" AS retention_days",
|
||||
retentionRuleIndexExpr+" AS retention_rule_index",
|
||||
"ifNull(sum(value), 0) AS value",
|
||||
)
|
||||
groupBy = append(groupBy, "retention_days", "retention_rule_index")
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(selects...)
|
||||
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
|
||||
sb.Where(
|
||||
sb.Equal("metric_name", meterName),
|
||||
sb.GTE("unix_milli", slice.StartMs),
|
||||
sb.LT("unix_milli", slice.EndMs),
|
||||
)
|
||||
sb.GroupBy(groupBy...)
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return query, args, columns, nil
|
||||
}
|
||||
|
||||
type dimensionColumn struct {
|
||||
key string
|
||||
alias string
|
||||
}
|
||||
|
||||
func dimensionColumnsFor(rules []retentiontypes.CustomRetentionRule) ([]dimensionColumn, error) {
|
||||
dimensionKeys, err := retention.RuleDimensionKeys(rules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, 0, len(dimensionKeys)+1)
|
||||
keys = append(keys, metercollector.DimensionWorkspaceKeyID)
|
||||
for _, key := range dimensionKeys {
|
||||
if key == metercollector.DimensionWorkspaceKeyID {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
columns := make([]dimensionColumn, len(keys))
|
||||
for i, key := range keys {
|
||||
columns[i] = dimensionColumn{key: key, alias: fmt.Sprintf("dim_%d", i)}
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func buildDimensions(
|
||||
orgID valuer.UUID,
|
||||
retentionDays int,
|
||||
retentionRuleIndex int,
|
||||
columns []dimensionColumn,
|
||||
values []string,
|
||||
rules []retentiontypes.CustomRetentionRule,
|
||||
) (map[string]string, error) {
|
||||
if len(columns) != len(values) {
|
||||
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "dimension column/value count mismatch: %d columns, %d values", len(columns), len(values))
|
||||
}
|
||||
|
||||
valuesByKey := make(map[string]string, len(columns))
|
||||
for i, column := range columns {
|
||||
valuesByKey[column.key] = values[i]
|
||||
}
|
||||
|
||||
dimensions := map[string]string{
|
||||
metercollector.DimensionOrganizationID: orgID.StringValue(),
|
||||
metercollector.DimensionRetentionDays: strconv.Itoa(retentionDays),
|
||||
}
|
||||
addNonEmpty(dimensions, metercollector.DimensionWorkspaceKeyID, valuesByKey[metercollector.DimensionWorkspaceKeyID])
|
||||
|
||||
if retentionRuleIndex < 0 {
|
||||
return dimensions, nil
|
||||
}
|
||||
if retentionRuleIndex >= len(rules) {
|
||||
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "retention rule index %d out of range for %d rules", retentionRuleIndex, len(rules))
|
||||
}
|
||||
for _, filter := range rules[retentionRuleIndex].Filters {
|
||||
addNonEmpty(dimensions, filter.Key, valuesByKey[filter.Key])
|
||||
}
|
||||
return dimensions, nil
|
||||
}
|
||||
|
||||
func addNonEmpty(dimensions map[string]string, key, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
dimensions[key] = value
|
||||
}
|
||||
|
||||
func bucketKey(dimensions map[string]string) string {
|
||||
keys := make([]string, 0, len(dimensions))
|
||||
for key := range dimensions {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var b strings.Builder
|
||||
for _, key := range keys {
|
||||
value := dimensions[key]
|
||||
b.WriteString(strconv.Itoa(len(key)))
|
||||
b.WriteByte(':')
|
||||
b.WriteString(key)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(strconv.Itoa(len(value)))
|
||||
b.WriteByte(':')
|
||||
b.WriteString(value)
|
||||
b.WriteByte(';')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package datapointsizemetercollector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildDimensions(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
rules := []retentiontypes.CustomRetentionRule{{
|
||||
Filters: []retentiontypes.FilterCondition{{
|
||||
Key: "service.name",
|
||||
Values: []string{"api"},
|
||||
}},
|
||||
TTLDays: 7,
|
||||
}}
|
||||
columns := []dimensionColumn{
|
||||
{key: metercollector.DimensionWorkspaceKeyID, alias: "dim_0"},
|
||||
{key: "service.name", alias: "dim_1"},
|
||||
}
|
||||
|
||||
dimensions, err := buildDimensions(orgID, 30, 0, columns, []string{"workspace-1", "api"}, rules)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]string{
|
||||
metercollector.DimensionOrganizationID: orgID.StringValue(),
|
||||
metercollector.DimensionRetentionDays: "30",
|
||||
metercollector.DimensionWorkspaceKeyID: "workspace-1",
|
||||
"service.name": "api",
|
||||
}, dimensions)
|
||||
}
|
||||
|
||||
func TestProviderMetadata(t *testing.T) {
|
||||
provider := New(nil, nil)
|
||||
|
||||
require.Equal(t, "signoz.meter.metric.datapoint.size", provider.Name().String())
|
||||
require.Equal(t, metercollectortypes.UnitBytes, provider.Unit())
|
||||
require.Equal(t, metercollectortypes.AggregationSum, provider.Aggregation())
|
||||
}
|
||||
|
||||
func TestBucketKeyIsStable(t *testing.T) {
|
||||
first := bucketKey(map[string]string{
|
||||
"service.name": "api",
|
||||
metercollector.DimensionRetentionDays: "30",
|
||||
metercollector.DimensionWorkspaceKeyID: "workspace-1",
|
||||
})
|
||||
second := bucketKey(map[string]string{
|
||||
metercollector.DimensionWorkspaceKeyID: "workspace-1",
|
||||
"service.name": "api",
|
||||
metercollector.DimensionRetentionDays: "30",
|
||||
})
|
||||
|
||||
require.Equal(t, first, second)
|
||||
require.NotEmpty(t, first)
|
||||
}
|
||||
|
||||
func TestCollectRejectsInvalidWindowBeforeQuerying(t *testing.T) {
|
||||
readings, err := New(nil, nil).Collect(context.Background(), valuer.GenerateUUID(), meterreportertypes.Window{})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, readings)
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
// Package logcountmetercollector collects log count meters by workspace and
|
||||
// retention. Keep the query local to this meter.
|
||||
package logcountmetercollector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/metercollector/retention"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// MeterName is the typed registry key for this collector.
|
||||
var (
|
||||
MeterName = metercollectortypes.MustNewName("signoz.meter.log.count")
|
||||
meterUnit = metercollectortypes.UnitCount
|
||||
meterAggregation = metercollectortypes.AggregationSum
|
||||
)
|
||||
|
||||
var _ metercollector.MeterCollector = (*Provider)(nil)
|
||||
|
||||
// Provider collects log count meters.
|
||||
type Provider struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
sqlStore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func New(telemetryStore telemetrystore.TelemetryStore, sqlStore sqlstore.SQLStore) *Provider {
|
||||
return &Provider{
|
||||
telemetryStore: telemetryStore,
|
||||
sqlStore: sqlStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() metercollectortypes.Name { return MeterName }
|
||||
func (p *Provider) Unit() metercollectortypes.Unit { return meterUnit }
|
||||
func (p *Provider) Aggregation() metercollectortypes.Aggregation {
|
||||
return meterAggregation
|
||||
}
|
||||
|
||||
// Collect aggregates log count for the window and emits an empty-day sentinel.
|
||||
func (p *Provider) Collect(ctx context.Context, orgID valuer.UUID, window meterreportertypes.Window) ([]meterreportertypes.Meter, error) {
|
||||
if !window.IsValid() {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, metercollector.ErrCodeCollectFailed, "invalid window [%d, %d)", window.StartUnixMilli, window.EndUnixMilli)
|
||||
}
|
||||
|
||||
meterName := MeterName.String()
|
||||
|
||||
slices, err := retention.LoadActiveSlices(
|
||||
ctx,
|
||||
p.sqlStore,
|
||||
orgID,
|
||||
telemetrylogs.DBName+"."+telemetrylogs.LogsV2LocalTableName,
|
||||
retentiontypes.DefaultLogsRetentionDays,
|
||||
window.StartUnixMilli, window.EndUnixMilli,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "load retention slices for meter %q", meterName)
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
dimensions map[string]string
|
||||
value float64
|
||||
}
|
||||
accumulator := make(map[string]*bucket)
|
||||
|
||||
for _, slice := range slices {
|
||||
query, args, dimensionColumns, err := buildQuery(meterName, slice)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build retention query for meter %q", meterName)
|
||||
}
|
||||
|
||||
rows, err := p.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "query meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
|
||||
if err := func() error {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
dimensionValues := make([]string, len(dimensionColumns))
|
||||
var retentionDays int32
|
||||
var retentionRuleIndex int32
|
||||
var value float64
|
||||
|
||||
scanDest := make([]any, 0, len(dimensionValues)+3)
|
||||
for i := range dimensionValues {
|
||||
scanDest = append(scanDest, &dimensionValues[i])
|
||||
}
|
||||
scanDest = append(scanDest, &retentionDays, &retentionRuleIndex, &value)
|
||||
|
||||
if err := rows.Scan(scanDest...); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "scan meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
|
||||
dimensions, err := buildDimensions(orgID, int(retentionDays), int(retentionRuleIndex), dimensionColumns, dimensionValues, slice.Rules)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build dimensions for meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
|
||||
key := bucketKey(dimensions)
|
||||
b, ok := accumulator[key]
|
||||
if !ok {
|
||||
b = &bucket{dimensions: dimensions}
|
||||
accumulator[key] = b
|
||||
}
|
||||
b.value += value
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "iterate meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
meters := make([]meterreportertypes.Meter, 0, len(accumulator))
|
||||
for _, b := range accumulator {
|
||||
meters = append(meters, meterreportertypes.NewMeter(MeterName, b.value, meterUnit, meterAggregation, window, b.dimensions))
|
||||
}
|
||||
|
||||
// Empty windows still emit a sentinel so checkpoints can advance.
|
||||
if len(meters) == 0 && len(slices) > 0 {
|
||||
meters = append(meters, meterreportertypes.NewMeter(MeterName, 0, meterUnit, meterAggregation, window, map[string]string{
|
||||
metercollector.DimensionOrganizationID: orgID.StringValue(),
|
||||
metercollector.DimensionRetentionDays: strconv.Itoa(slices[len(slices)-1].DefaultDays),
|
||||
}))
|
||||
}
|
||||
|
||||
return meters, nil
|
||||
}
|
||||
|
||||
// buildQuery stays local because each meter owns its billing query.
|
||||
func buildQuery(meterName string, slice retentiontypes.Slice) (string, []any, []dimensionColumn, error) {
|
||||
retentionExpr, err := retention.BuildMultiIfSQL(slice.Rules, slice.DefaultDays)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
retentionRuleIndexExpr, err := retention.BuildRuleIndexSQL(slice.Rules)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
columns, err := dimensionColumnsFor(slice.Rules)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
|
||||
selects := make([]string, 0, len(columns)+3)
|
||||
groupBy := make([]string, 0, len(columns)+2)
|
||||
for _, column := range columns {
|
||||
selects = append(selects, fmt.Sprintf("JSONExtractString(labels, '%s') AS %s", column.key, column.alias))
|
||||
groupBy = append(groupBy, column.alias)
|
||||
}
|
||||
selects = append(selects,
|
||||
retentionExpr+" AS retention_days",
|
||||
retentionRuleIndexExpr+" AS retention_rule_index",
|
||||
"ifNull(sum(value), 0) AS value",
|
||||
)
|
||||
groupBy = append(groupBy, "retention_days", "retention_rule_index")
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(selects...)
|
||||
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
|
||||
sb.Where(
|
||||
sb.Equal("metric_name", meterName),
|
||||
sb.GTE("unix_milli", slice.StartMs),
|
||||
sb.LT("unix_milli", slice.EndMs),
|
||||
)
|
||||
sb.GroupBy(groupBy...)
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return query, args, columns, nil
|
||||
}
|
||||
|
||||
type dimensionColumn struct {
|
||||
key string
|
||||
alias string
|
||||
}
|
||||
|
||||
func dimensionColumnsFor(rules []retentiontypes.CustomRetentionRule) ([]dimensionColumn, error) {
|
||||
dimensionKeys, err := retention.RuleDimensionKeys(rules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, 0, len(dimensionKeys)+1)
|
||||
keys = append(keys, metercollector.DimensionWorkspaceKeyID)
|
||||
for _, key := range dimensionKeys {
|
||||
if key == metercollector.DimensionWorkspaceKeyID {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
columns := make([]dimensionColumn, len(keys))
|
||||
for i, key := range keys {
|
||||
columns[i] = dimensionColumn{key: key, alias: fmt.Sprintf("dim_%d", i)}
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func buildDimensions(
|
||||
orgID valuer.UUID,
|
||||
retentionDays int,
|
||||
retentionRuleIndex int,
|
||||
columns []dimensionColumn,
|
||||
values []string,
|
||||
rules []retentiontypes.CustomRetentionRule,
|
||||
) (map[string]string, error) {
|
||||
if len(columns) != len(values) {
|
||||
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "dimension column/value count mismatch: %d columns, %d values", len(columns), len(values))
|
||||
}
|
||||
|
||||
valuesByKey := make(map[string]string, len(columns))
|
||||
for i, column := range columns {
|
||||
valuesByKey[column.key] = values[i]
|
||||
}
|
||||
|
||||
dimensions := map[string]string{
|
||||
metercollector.DimensionOrganizationID: orgID.StringValue(),
|
||||
metercollector.DimensionRetentionDays: strconv.Itoa(retentionDays),
|
||||
}
|
||||
addNonEmpty(dimensions, metercollector.DimensionWorkspaceKeyID, valuesByKey[metercollector.DimensionWorkspaceKeyID])
|
||||
|
||||
if retentionRuleIndex < 0 {
|
||||
return dimensions, nil
|
||||
}
|
||||
if retentionRuleIndex >= len(rules) {
|
||||
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "retention rule index %d out of range for %d rules", retentionRuleIndex, len(rules))
|
||||
}
|
||||
for _, filter := range rules[retentionRuleIndex].Filters {
|
||||
addNonEmpty(dimensions, filter.Key, valuesByKey[filter.Key])
|
||||
}
|
||||
return dimensions, nil
|
||||
}
|
||||
|
||||
func addNonEmpty(dimensions map[string]string, key, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
dimensions[key] = value
|
||||
}
|
||||
|
||||
func bucketKey(dimensions map[string]string) string {
|
||||
keys := make([]string, 0, len(dimensions))
|
||||
for key := range dimensions {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var b strings.Builder
|
||||
for _, key := range keys {
|
||||
value := dimensions[key]
|
||||
b.WriteString(strconv.Itoa(len(key)))
|
||||
b.WriteByte(':')
|
||||
b.WriteString(key)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(strconv.Itoa(len(value)))
|
||||
b.WriteByte(':')
|
||||
b.WriteString(value)
|
||||
b.WriteByte(';')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package logcountmetercollector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildDimensions(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
rules := []retentiontypes.CustomRetentionRule{{
|
||||
Filters: []retentiontypes.FilterCondition{{
|
||||
Key: "service.name",
|
||||
Values: []string{"api"},
|
||||
}},
|
||||
TTLDays: 7,
|
||||
}}
|
||||
columns := []dimensionColumn{
|
||||
{key: metercollector.DimensionWorkspaceKeyID, alias: "dim_0"},
|
||||
{key: "service.name", alias: "dim_1"},
|
||||
}
|
||||
|
||||
dimensions, err := buildDimensions(orgID, 30, 0, columns, []string{"workspace-1", "api"}, rules)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]string{
|
||||
metercollector.DimensionOrganizationID: orgID.StringValue(),
|
||||
metercollector.DimensionRetentionDays: "30",
|
||||
metercollector.DimensionWorkspaceKeyID: "workspace-1",
|
||||
"service.name": "api",
|
||||
}, dimensions)
|
||||
}
|
||||
|
||||
func TestProviderMetadata(t *testing.T) {
|
||||
provider := New(nil, nil)
|
||||
|
||||
require.Equal(t, "signoz.meter.log.count", provider.Name().String())
|
||||
require.Equal(t, metercollectortypes.UnitCount, provider.Unit())
|
||||
require.Equal(t, metercollectortypes.AggregationSum, provider.Aggregation())
|
||||
}
|
||||
|
||||
func TestBucketKeyIsStable(t *testing.T) {
|
||||
first := bucketKey(map[string]string{
|
||||
"service.name": "api",
|
||||
metercollector.DimensionRetentionDays: "30",
|
||||
metercollector.DimensionWorkspaceKeyID: "workspace-1",
|
||||
})
|
||||
second := bucketKey(map[string]string{
|
||||
metercollector.DimensionWorkspaceKeyID: "workspace-1",
|
||||
"service.name": "api",
|
||||
metercollector.DimensionRetentionDays: "30",
|
||||
})
|
||||
|
||||
require.Equal(t, first, second)
|
||||
require.NotEmpty(t, first)
|
||||
}
|
||||
|
||||
func TestCollectRejectsInvalidWindowBeforeQuerying(t *testing.T) {
|
||||
readings, err := New(nil, nil).Collect(context.Background(), valuer.GenerateUUID(), meterreportertypes.Window{})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, readings)
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
// Package logsizemetercollector collects log size meters by workspace and
|
||||
// retention. Keep the query local to this meter.
|
||||
package logsizemetercollector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/metercollector/retention"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// MeterName is the typed registry key for this collector.
|
||||
var (
|
||||
MeterName = metercollectortypes.MustNewName("signoz.meter.log.size")
|
||||
meterUnit = metercollectortypes.UnitBytes
|
||||
meterAggregation = metercollectortypes.AggregationSum
|
||||
)
|
||||
|
||||
var _ metercollector.MeterCollector = (*Provider)(nil)
|
||||
|
||||
// Provider collects log size meters.
|
||||
type Provider struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
sqlStore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func New(telemetryStore telemetrystore.TelemetryStore, sqlStore sqlstore.SQLStore) *Provider {
|
||||
return &Provider{
|
||||
telemetryStore: telemetryStore,
|
||||
sqlStore: sqlStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() metercollectortypes.Name { return MeterName }
|
||||
func (p *Provider) Unit() metercollectortypes.Unit { return meterUnit }
|
||||
func (p *Provider) Aggregation() metercollectortypes.Aggregation {
|
||||
return meterAggregation
|
||||
}
|
||||
|
||||
// Collect aggregates log size for the window and emits an empty-day sentinel.
|
||||
func (p *Provider) Collect(ctx context.Context, orgID valuer.UUID, window meterreportertypes.Window) ([]meterreportertypes.Meter, error) {
|
||||
if !window.IsValid() {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, metercollector.ErrCodeCollectFailed, "invalid window [%d, %d)", window.StartUnixMilli, window.EndUnixMilli)
|
||||
}
|
||||
|
||||
meterName := MeterName.String()
|
||||
|
||||
slices, err := retention.LoadActiveSlices(
|
||||
ctx,
|
||||
p.sqlStore,
|
||||
orgID,
|
||||
telemetrylogs.DBName+"."+telemetrylogs.LogsV2LocalTableName,
|
||||
retentiontypes.DefaultLogsRetentionDays,
|
||||
window.StartUnixMilli, window.EndUnixMilli,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "load retention slices for meter %q", meterName)
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
dimensions map[string]string
|
||||
value float64
|
||||
}
|
||||
accumulator := make(map[string]*bucket)
|
||||
|
||||
for _, slice := range slices {
|
||||
query, args, dimensionColumns, err := buildQuery(meterName, slice)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build retention query for meter %q", meterName)
|
||||
}
|
||||
|
||||
rows, err := p.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "query meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
|
||||
if err := func() error {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
dimensionValues := make([]string, len(dimensionColumns))
|
||||
var retentionDays int32
|
||||
var retentionRuleIndex int32
|
||||
var value float64
|
||||
|
||||
scanDest := make([]any, 0, len(dimensionValues)+3)
|
||||
for i := range dimensionValues {
|
||||
scanDest = append(scanDest, &dimensionValues[i])
|
||||
}
|
||||
scanDest = append(scanDest, &retentionDays, &retentionRuleIndex, &value)
|
||||
|
||||
if err := rows.Scan(scanDest...); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "scan meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
|
||||
dimensions, err := buildDimensions(orgID, int(retentionDays), int(retentionRuleIndex), dimensionColumns, dimensionValues, slice.Rules)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build dimensions for meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
|
||||
key := bucketKey(dimensions)
|
||||
b, ok := accumulator[key]
|
||||
if !ok {
|
||||
b = &bucket{dimensions: dimensions}
|
||||
accumulator[key] = b
|
||||
}
|
||||
b.value += value
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "iterate meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
meters := make([]meterreportertypes.Meter, 0, len(accumulator))
|
||||
for _, b := range accumulator {
|
||||
meters = append(meters, meterreportertypes.NewMeter(MeterName, b.value, meterUnit, meterAggregation, window, b.dimensions))
|
||||
}
|
||||
|
||||
// Empty windows still emit a sentinel so checkpoints can advance.
|
||||
if len(meters) == 0 && len(slices) > 0 {
|
||||
meters = append(meters, meterreportertypes.NewMeter(MeterName, 0, meterUnit, meterAggregation, window, map[string]string{
|
||||
metercollector.DimensionOrganizationID: orgID.StringValue(),
|
||||
metercollector.DimensionRetentionDays: strconv.Itoa(slices[len(slices)-1].DefaultDays),
|
||||
}))
|
||||
}
|
||||
|
||||
return meters, nil
|
||||
}
|
||||
|
||||
// buildQuery stays local because each meter owns its billing query.
|
||||
func buildQuery(meterName string, slice retentiontypes.Slice) (string, []any, []dimensionColumn, error) {
|
||||
retentionExpr, err := retention.BuildMultiIfSQL(slice.Rules, slice.DefaultDays)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
retentionRuleIndexExpr, err := retention.BuildRuleIndexSQL(slice.Rules)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
columns, err := dimensionColumnsFor(slice.Rules)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
|
||||
selects := make([]string, 0, len(columns)+3)
|
||||
groupBy := make([]string, 0, len(columns)+2)
|
||||
for _, column := range columns {
|
||||
selects = append(selects, fmt.Sprintf("JSONExtractString(labels, '%s') AS %s", column.key, column.alias))
|
||||
groupBy = append(groupBy, column.alias)
|
||||
}
|
||||
selects = append(selects,
|
||||
retentionExpr+" AS retention_days",
|
||||
retentionRuleIndexExpr+" AS retention_rule_index",
|
||||
"ifNull(sum(value), 0) AS value",
|
||||
)
|
||||
groupBy = append(groupBy, "retention_days", "retention_rule_index")
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(selects...)
|
||||
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
|
||||
sb.Where(
|
||||
sb.Equal("metric_name", meterName),
|
||||
sb.GTE("unix_milli", slice.StartMs),
|
||||
sb.LT("unix_milli", slice.EndMs),
|
||||
)
|
||||
sb.GroupBy(groupBy...)
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return query, args, columns, nil
|
||||
}
|
||||
|
||||
type dimensionColumn struct {
|
||||
key string
|
||||
alias string
|
||||
}
|
||||
|
||||
func dimensionColumnsFor(rules []retentiontypes.CustomRetentionRule) ([]dimensionColumn, error) {
|
||||
dimensionKeys, err := retention.RuleDimensionKeys(rules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, 0, len(dimensionKeys)+1)
|
||||
keys = append(keys, metercollector.DimensionWorkspaceKeyID)
|
||||
for _, key := range dimensionKeys {
|
||||
if key == metercollector.DimensionWorkspaceKeyID {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
columns := make([]dimensionColumn, len(keys))
|
||||
for i, key := range keys {
|
||||
columns[i] = dimensionColumn{key: key, alias: fmt.Sprintf("dim_%d", i)}
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func buildDimensions(
|
||||
orgID valuer.UUID,
|
||||
retentionDays int,
|
||||
retentionRuleIndex int,
|
||||
columns []dimensionColumn,
|
||||
values []string,
|
||||
rules []retentiontypes.CustomRetentionRule,
|
||||
) (map[string]string, error) {
|
||||
if len(columns) != len(values) {
|
||||
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "dimension column/value count mismatch: %d columns, %d values", len(columns), len(values))
|
||||
}
|
||||
|
||||
valuesByKey := make(map[string]string, len(columns))
|
||||
for i, column := range columns {
|
||||
valuesByKey[column.key] = values[i]
|
||||
}
|
||||
|
||||
dimensions := map[string]string{
|
||||
metercollector.DimensionOrganizationID: orgID.StringValue(),
|
||||
metercollector.DimensionRetentionDays: strconv.Itoa(retentionDays),
|
||||
}
|
||||
addNonEmpty(dimensions, metercollector.DimensionWorkspaceKeyID, valuesByKey[metercollector.DimensionWorkspaceKeyID])
|
||||
|
||||
if retentionRuleIndex < 0 {
|
||||
return dimensions, nil
|
||||
}
|
||||
if retentionRuleIndex >= len(rules) {
|
||||
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "retention rule index %d out of range for %d rules", retentionRuleIndex, len(rules))
|
||||
}
|
||||
for _, filter := range rules[retentionRuleIndex].Filters {
|
||||
addNonEmpty(dimensions, filter.Key, valuesByKey[filter.Key])
|
||||
}
|
||||
return dimensions, nil
|
||||
}
|
||||
|
||||
func addNonEmpty(dimensions map[string]string, key, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
dimensions[key] = value
|
||||
}
|
||||
|
||||
func bucketKey(dimensions map[string]string) string {
|
||||
keys := make([]string, 0, len(dimensions))
|
||||
for key := range dimensions {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var b strings.Builder
|
||||
for _, key := range keys {
|
||||
value := dimensions[key]
|
||||
b.WriteString(strconv.Itoa(len(key)))
|
||||
b.WriteByte(':')
|
||||
b.WriteString(key)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(strconv.Itoa(len(value)))
|
||||
b.WriteByte(':')
|
||||
b.WriteString(value)
|
||||
b.WriteByte(';')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package logsizemetercollector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildDimensions(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
rules := []retentiontypes.CustomRetentionRule{{
|
||||
Filters: []retentiontypes.FilterCondition{{
|
||||
Key: "service.name",
|
||||
Values: []string{"api"},
|
||||
}},
|
||||
TTLDays: 7,
|
||||
}}
|
||||
columns := []dimensionColumn{
|
||||
{key: metercollector.DimensionWorkspaceKeyID, alias: "dim_0"},
|
||||
{key: "service.name", alias: "dim_1"},
|
||||
}
|
||||
|
||||
dimensions, err := buildDimensions(orgID, 30, 0, columns, []string{"workspace-1", "api"}, rules)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]string{
|
||||
metercollector.DimensionOrganizationID: orgID.StringValue(),
|
||||
metercollector.DimensionRetentionDays: "30",
|
||||
metercollector.DimensionWorkspaceKeyID: "workspace-1",
|
||||
"service.name": "api",
|
||||
}, dimensions)
|
||||
}
|
||||
|
||||
func TestProviderMetadata(t *testing.T) {
|
||||
provider := New(nil, nil)
|
||||
|
||||
require.Equal(t, "signoz.meter.log.size", provider.Name().String())
|
||||
require.Equal(t, metercollectortypes.UnitBytes, provider.Unit())
|
||||
require.Equal(t, metercollectortypes.AggregationSum, provider.Aggregation())
|
||||
}
|
||||
|
||||
func TestBucketKeyIsStable(t *testing.T) {
|
||||
first := bucketKey(map[string]string{
|
||||
"service.name": "api",
|
||||
metercollector.DimensionRetentionDays: "30",
|
||||
metercollector.DimensionWorkspaceKeyID: "workspace-1",
|
||||
})
|
||||
second := bucketKey(map[string]string{
|
||||
metercollector.DimensionWorkspaceKeyID: "workspace-1",
|
||||
"service.name": "api",
|
||||
metercollector.DimensionRetentionDays: "30",
|
||||
})
|
||||
|
||||
require.Equal(t, first, second)
|
||||
require.NotEmpty(t, first)
|
||||
}
|
||||
|
||||
func TestCollectRejectsInvalidWindowBeforeQuerying(t *testing.T) {
|
||||
readings, err := New(nil, nil).Collect(context.Background(), valuer.GenerateUUID(), meterreportertypes.Window{})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, readings)
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
// Package retention builds retention slices and SQL expressions for meters.
|
||||
// Collectors still own their table names, defaults, and aggregation queries.
|
||||
package retention
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
const secondsPerDay = 24 * 60 * 60
|
||||
|
||||
// These values are inlined into SQL, so keep the allowlist strict.
|
||||
var (
|
||||
labelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_.\-]+$`)
|
||||
labelValuePattern = regexp.MustCompile(`^[A-Za-z0-9_.\-:]+$`)
|
||||
)
|
||||
|
||||
// LoadActiveSlices returns TTL slices covering [startMs, endMs).
|
||||
// tableName must be fully qualified, for example "signoz_logs.logs_v2".
|
||||
func LoadActiveSlices(
|
||||
ctx context.Context,
|
||||
sqlstore sqlstore.SQLStore,
|
||||
orgID valuer.UUID,
|
||||
tableName string,
|
||||
fallbackDefaultDays int,
|
||||
startMs, endMs int64,
|
||||
) ([]retentiontypes.Slice, error) {
|
||||
if startMs >= endMs {
|
||||
return nil, nil
|
||||
}
|
||||
if sqlstore == nil {
|
||||
return nil, errors.New(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "sqlstore is nil")
|
||||
}
|
||||
if tableName == "" {
|
||||
return nil, errors.New(errors.TypeInvalidInput, metercollector.ErrCodeCollectFailed, "tableName is empty")
|
||||
}
|
||||
if fallbackDefaultDays <= 0 {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, metercollector.ErrCodeCollectFailed, "non-positive fallbackDefaultDays %d", fallbackDefaultDays)
|
||||
}
|
||||
|
||||
rows := []*retentiontypes.TTLSetting{}
|
||||
err := sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&rows).
|
||||
Where("table_name = ?", tableName).
|
||||
Where("org_id = ?", orgID.StringValue()).
|
||||
Where("status = ?", retentiontypes.TTLSettingStatusSuccess).
|
||||
Where("created_at < ?", time.UnixMilli(endMs).UTC()).
|
||||
OrderExpr("created_at ASC").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "load ttl_setting rows for org %q table %q", orgID.StringValue(), tableName)
|
||||
}
|
||||
|
||||
return buildSlicesFromRows(rows, fallbackDefaultDays, startMs, endMs)
|
||||
}
|
||||
|
||||
func buildSlicesFromRows(rows []*retentiontypes.TTLSetting, fallbackDefaultDays int, startMs, endMs int64) ([]retentiontypes.Slice, error) {
|
||||
if startMs >= endMs {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// The latest row before the window is active at the window start.
|
||||
var activeAtStart *retentiontypes.TTLSetting
|
||||
inWindow := make([]*retentiontypes.TTLSetting, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
rowMs := row.CreatedAt.UnixMilli()
|
||||
if rowMs <= startMs {
|
||||
activeAtStart = row
|
||||
continue
|
||||
}
|
||||
if rowMs >= endMs {
|
||||
continue
|
||||
}
|
||||
inWindow = append(inWindow, row)
|
||||
}
|
||||
|
||||
activeRules, activeDefault, err := parseTTLSetting(activeAtStart, fallbackDefaultDays)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slices := make([]retentiontypes.Slice, 0, len(inWindow)+1)
|
||||
cursor := startMs
|
||||
for _, row := range inWindow {
|
||||
rowMs := row.CreatedAt.UnixMilli()
|
||||
if rowMs <= cursor {
|
||||
// Same-ms updates collapse: replace active config, no empty slice.
|
||||
activeRules, activeDefault, err = parseTTLSetting(row, fallbackDefaultDays)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
slices = append(slices, retentiontypes.Slice{
|
||||
StartMs: cursor,
|
||||
EndMs: rowMs,
|
||||
Rules: activeRules,
|
||||
DefaultDays: activeDefault,
|
||||
})
|
||||
cursor = rowMs
|
||||
activeRules, activeDefault, err = parseTTLSetting(row, fallbackDefaultDays)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if cursor < endMs {
|
||||
slices = append(slices, retentiontypes.Slice{
|
||||
StartMs: cursor,
|
||||
EndMs: endMs,
|
||||
Rules: activeRules,
|
||||
DefaultDays: activeDefault,
|
||||
})
|
||||
}
|
||||
|
||||
return slices, nil
|
||||
}
|
||||
|
||||
// parseTTLSetting returns rules and default days for one ttl_setting row.
|
||||
func parseTTLSetting(row *retentiontypes.TTLSetting, fallbackDefaultDays int) ([]retentiontypes.CustomRetentionRule, int, error) {
|
||||
if row == nil {
|
||||
return nil, fallbackDefaultDays, nil
|
||||
}
|
||||
|
||||
defaultDays := row.TTL
|
||||
if row.Condition == "" {
|
||||
// V1 stores seconds; round up to days.
|
||||
defaultDays = (row.TTL + secondsPerDay - 1) / secondsPerDay
|
||||
}
|
||||
if defaultDays <= 0 {
|
||||
defaultDays = fallbackDefaultDays
|
||||
}
|
||||
|
||||
if row.Condition == "" {
|
||||
return nil, defaultDays, nil
|
||||
}
|
||||
|
||||
var rules []retentiontypes.CustomRetentionRule
|
||||
if err := json.Unmarshal([]byte(row.Condition), &rules); err != nil {
|
||||
return nil, 0, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "parse ttl_setting condition for row %q", row.ID.StringValue())
|
||||
}
|
||||
|
||||
return rules, defaultDays, nil
|
||||
}
|
||||
|
||||
// BuildMultiIfSQL renders the retention-days expression for one slice.
|
||||
func BuildMultiIfSQL(rules []retentiontypes.CustomRetentionRule, defaultDays int) (string, error) {
|
||||
if defaultDays <= 0 {
|
||||
return "", errors.Newf(errors.TypeInvalidInput, metercollector.ErrCodeCollectFailed, "non-positive default retention %d", defaultDays)
|
||||
}
|
||||
|
||||
if len(rules) == 0 {
|
||||
return "toInt32(" + strconv.Itoa(defaultDays) + ")", nil
|
||||
}
|
||||
|
||||
arms := make([]string, 0, 2*len(rules)+1)
|
||||
for ruleIndex, rule := range rules {
|
||||
if rule.TTLDays <= 0 {
|
||||
return "", errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "rule %d has non-positive ttl_days %d", ruleIndex, rule.TTLDays)
|
||||
}
|
||||
conditionExpr, err := buildRuleConditionSQL(ruleIndex, rule)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
arms = append(arms, conditionExpr)
|
||||
arms = append(arms, strconv.Itoa(rule.TTLDays))
|
||||
}
|
||||
arms = append(arms, strconv.Itoa(defaultDays))
|
||||
|
||||
return "toInt32(multiIf(" + strings.Join(arms, ", ") + "))", nil
|
||||
}
|
||||
|
||||
// BuildRuleIndexSQL renders the matched rule index, or -1 for fallback.
|
||||
func BuildRuleIndexSQL(rules []retentiontypes.CustomRetentionRule) (string, error) {
|
||||
if len(rules) == 0 {
|
||||
return "toInt32(-1)", nil
|
||||
}
|
||||
|
||||
arms := make([]string, 0, 2*len(rules)+1)
|
||||
for ruleIndex, rule := range rules {
|
||||
conditionExpr, err := buildRuleConditionSQL(ruleIndex, rule)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
arms = append(arms, conditionExpr)
|
||||
arms = append(arms, strconv.Itoa(ruleIndex))
|
||||
}
|
||||
arms = append(arms, "-1")
|
||||
|
||||
return "toInt32(multiIf(" + strings.Join(arms, ", ") + "))", nil
|
||||
}
|
||||
|
||||
func buildRuleConditionSQL(ruleIndex int, rule retentiontypes.CustomRetentionRule) (string, error) {
|
||||
if len(rule.Filters) == 0 {
|
||||
return "", errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "rule %d has no filters", ruleIndex)
|
||||
}
|
||||
|
||||
filterExprs := make([]string, 0, len(rule.Filters))
|
||||
for filterIndex, filter := range rule.Filters {
|
||||
if !labelKeyPattern.MatchString(filter.Key) {
|
||||
return "", errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "rule %d filter %d has invalid key %q", ruleIndex, filterIndex, filter.Key)
|
||||
}
|
||||
if len(filter.Values) == 0 {
|
||||
return "", errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "rule %d filter %d has no values", ruleIndex, filterIndex)
|
||||
}
|
||||
|
||||
quoted := make([]string, len(filter.Values))
|
||||
for valueIndex, value := range filter.Values {
|
||||
if !labelValuePattern.MatchString(value) {
|
||||
return "", errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "rule %d filter %d value %d is invalid %q", ruleIndex, filterIndex, valueIndex, value)
|
||||
}
|
||||
quoted[valueIndex] = "'" + value + "'"
|
||||
}
|
||||
|
||||
filterExprs = append(filterExprs, fmt.Sprintf("JSONExtractString(labels, '%s') IN (%s)", filter.Key, strings.Join(quoted, ", ")))
|
||||
}
|
||||
|
||||
return strings.Join(filterExprs, " AND "), nil
|
||||
}
|
||||
|
||||
// RuleDimensionKeys returns unique label keys referenced by retention rules.
|
||||
func RuleDimensionKeys(rules []retentiontypes.CustomRetentionRule) ([]string, error) {
|
||||
keys := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
for ruleIndex, rule := range rules {
|
||||
for filterIndex, filter := range rule.Filters {
|
||||
if !labelKeyPattern.MatchString(filter.Key) {
|
||||
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "rule %d filter %d has invalid key %q", ruleIndex, filterIndex, filter.Key)
|
||||
}
|
||||
if _, ok := seen[filter.Key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[filter.Key] = struct{}{}
|
||||
keys = append(keys, filter.Key)
|
||||
}
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
package retention
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildSlicesFromRows(t *testing.T) {
|
||||
start := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)
|
||||
end := start.AddDate(0, 0, 1)
|
||||
|
||||
ruleA := retentiontypes.CustomRetentionRule{
|
||||
Filters: []retentiontypes.FilterCondition{{Key: "service.name", Values: []string{"api"}}},
|
||||
TTLDays: 7,
|
||||
}
|
||||
ruleB := retentiontypes.CustomRetentionRule{
|
||||
Filters: []retentiontypes.FilterCondition{{Key: "env", Values: []string{"prod"}}},
|
||||
TTLDays: 15,
|
||||
}
|
||||
|
||||
t.Run("row before window is active at start", func(t *testing.T) {
|
||||
slices, err := buildSlicesFromRows(
|
||||
[]*retentiontypes.TTLSetting{
|
||||
ttlSetting(t, start.Add(-time.Hour), 45, []retentiontypes.CustomRetentionRule{ruleA}),
|
||||
},
|
||||
30,
|
||||
start.UnixMilli(),
|
||||
end.UnixMilli(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []retentiontypes.Slice{{
|
||||
StartMs: start.UnixMilli(),
|
||||
EndMs: end.UnixMilli(),
|
||||
Rules: []retentiontypes.CustomRetentionRule{ruleA},
|
||||
DefaultDays: 45,
|
||||
}}, slices)
|
||||
})
|
||||
|
||||
t.Run("row inside window splits slices", func(t *testing.T) {
|
||||
firstChange := start.Add(6 * time.Hour)
|
||||
secondChange := start.Add(18 * time.Hour)
|
||||
|
||||
slices, err := buildSlicesFromRows(
|
||||
[]*retentiontypes.TTLSetting{
|
||||
ttlSetting(t, firstChange, 21, []retentiontypes.CustomRetentionRule{ruleA}),
|
||||
ttlSetting(t, secondChange, 14, []retentiontypes.CustomRetentionRule{ruleB}),
|
||||
},
|
||||
30,
|
||||
start.UnixMilli(),
|
||||
end.UnixMilli(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []retentiontypes.Slice{
|
||||
{
|
||||
StartMs: start.UnixMilli(),
|
||||
EndMs: firstChange.UnixMilli(),
|
||||
DefaultDays: 30,
|
||||
},
|
||||
{
|
||||
StartMs: firstChange.UnixMilli(),
|
||||
EndMs: secondChange.UnixMilli(),
|
||||
Rules: []retentiontypes.CustomRetentionRule{ruleA},
|
||||
DefaultDays: 21,
|
||||
},
|
||||
{
|
||||
StartMs: secondChange.UnixMilli(),
|
||||
EndMs: end.UnixMilli(),
|
||||
Rules: []retentiontypes.CustomRetentionRule{ruleB},
|
||||
DefaultDays: 14,
|
||||
},
|
||||
}, slices)
|
||||
})
|
||||
|
||||
t.Run("no rows uses fallback", func(t *testing.T) {
|
||||
slices, err := buildSlicesFromRows(nil, 30, start.UnixMilli(), end.UnixMilli())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []retentiontypes.Slice{{
|
||||
StartMs: start.UnixMilli(),
|
||||
EndMs: end.UnixMilli(),
|
||||
DefaultDays: 30,
|
||||
}}, slices)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRetentionSQL(t *testing.T) {
|
||||
rules := []retentiontypes.CustomRetentionRule{{
|
||||
Filters: []retentiontypes.FilterCondition{{
|
||||
Key: "service.name",
|
||||
Values: []string{"api", "worker"},
|
||||
}},
|
||||
TTLDays: 7,
|
||||
}}
|
||||
|
||||
retentionSQL, err := BuildMultiIfSQL(rules, 30)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "toInt32(multiIf(JSONExtractString(labels, 'service.name') IN ('api', 'worker'), 7, 30))", retentionSQL)
|
||||
|
||||
ruleIndexSQL, err := BuildRuleIndexSQL(rules)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "toInt32(multiIf(JSONExtractString(labels, 'service.name') IN ('api', 'worker'), 0, -1))", ruleIndexSQL)
|
||||
|
||||
invalidRules := []retentiontypes.CustomRetentionRule{{
|
||||
Filters: []retentiontypes.FilterCondition{{
|
||||
Key: "service name",
|
||||
Values: []string{"api"},
|
||||
}},
|
||||
TTLDays: 7,
|
||||
}}
|
||||
|
||||
_, err = BuildMultiIfSQL(invalidRules, 30)
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = BuildRuleIndexSQL(invalidRules)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRuleDimensionKeysDedupes(t *testing.T) {
|
||||
keys, err := RuleDimensionKeys([]retentiontypes.CustomRetentionRule{
|
||||
{
|
||||
Filters: []retentiontypes.FilterCondition{
|
||||
{Key: "service.name", Values: []string{"api"}},
|
||||
{Key: "env", Values: []string{"prod"}},
|
||||
},
|
||||
TTLDays: 7,
|
||||
},
|
||||
{
|
||||
Filters: []retentiontypes.FilterCondition{
|
||||
{Key: "service.name", Values: []string{"worker"}},
|
||||
{Key: "cluster", Values: []string{"primary"}},
|
||||
},
|
||||
TTLDays: 15,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"service.name", "env", "cluster"}, keys)
|
||||
}
|
||||
|
||||
func ttlSetting(t *testing.T, createdAt time.Time, ttlDays int, rules []retentiontypes.CustomRetentionRule) *retentiontypes.TTLSetting {
|
||||
t.Helper()
|
||||
|
||||
condition, err := json.Marshal(rules)
|
||||
require.NoError(t, err)
|
||||
|
||||
return &retentiontypes.TTLSetting{
|
||||
CreatedAt: createdAt,
|
||||
TTL: ttlDays,
|
||||
Condition: string(condition),
|
||||
}
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
// Package spancountmetercollector collects span count meters by workspace and
|
||||
// retention. Keep the query local to this meter.
|
||||
package spancountmetercollector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/metercollector/retention"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// MeterName is the typed registry key for this collector.
|
||||
var (
|
||||
MeterName = metercollectortypes.MustNewName("signoz.meter.span.count")
|
||||
meterUnit = metercollectortypes.UnitCount
|
||||
meterAggregation = metercollectortypes.AggregationSum
|
||||
)
|
||||
|
||||
var _ metercollector.MeterCollector = (*Provider)(nil)
|
||||
|
||||
// Provider collects span count meters.
|
||||
type Provider struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
sqlStore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func New(telemetryStore telemetrystore.TelemetryStore, sqlStore sqlstore.SQLStore) *Provider {
|
||||
return &Provider{
|
||||
telemetryStore: telemetryStore,
|
||||
sqlStore: sqlStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() metercollectortypes.Name { return MeterName }
|
||||
func (p *Provider) Unit() metercollectortypes.Unit { return meterUnit }
|
||||
func (p *Provider) Aggregation() metercollectortypes.Aggregation {
|
||||
return meterAggregation
|
||||
}
|
||||
|
||||
// Collect aggregates span count for the window and emits an empty-day sentinel.
|
||||
func (p *Provider) Collect(ctx context.Context, orgID valuer.UUID, window meterreportertypes.Window) ([]meterreportertypes.Meter, error) {
|
||||
if !window.IsValid() {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, metercollector.ErrCodeCollectFailed, "invalid window [%d, %d)", window.StartUnixMilli, window.EndUnixMilli)
|
||||
}
|
||||
|
||||
meterName := MeterName.String()
|
||||
|
||||
slices, err := retention.LoadActiveSlices(
|
||||
ctx,
|
||||
p.sqlStore,
|
||||
orgID,
|
||||
telemetrytraces.DBName+"."+telemetrytraces.SpanIndexV3LocalTableName,
|
||||
retentiontypes.DefaultTracesRetentionDays,
|
||||
window.StartUnixMilli, window.EndUnixMilli,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "load retention slices for meter %q", meterName)
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
dimensions map[string]string
|
||||
value float64
|
||||
}
|
||||
accumulator := make(map[string]*bucket)
|
||||
|
||||
for _, slice := range slices {
|
||||
query, args, dimensionColumns, err := buildQuery(meterName, slice)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build retention query for meter %q", meterName)
|
||||
}
|
||||
|
||||
rows, err := p.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "query meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
|
||||
if err := func() error {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
dimensionValues := make([]string, len(dimensionColumns))
|
||||
var retentionDays int32
|
||||
var retentionRuleIndex int32
|
||||
var value float64
|
||||
|
||||
scanDest := make([]any, 0, len(dimensionValues)+3)
|
||||
for i := range dimensionValues {
|
||||
scanDest = append(scanDest, &dimensionValues[i])
|
||||
}
|
||||
scanDest = append(scanDest, &retentionDays, &retentionRuleIndex, &value)
|
||||
|
||||
if err := rows.Scan(scanDest...); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "scan meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
|
||||
dimensions, err := buildDimensions(orgID, int(retentionDays), int(retentionRuleIndex), dimensionColumns, dimensionValues, slice.Rules)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build dimensions for meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
|
||||
key := bucketKey(dimensions)
|
||||
b, ok := accumulator[key]
|
||||
if !ok {
|
||||
b = &bucket{dimensions: dimensions}
|
||||
accumulator[key] = b
|
||||
}
|
||||
b.value += value
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "iterate meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
meters := make([]meterreportertypes.Meter, 0, len(accumulator))
|
||||
for _, b := range accumulator {
|
||||
meters = append(meters, meterreportertypes.NewMeter(MeterName, b.value, meterUnit, meterAggregation, window, b.dimensions))
|
||||
}
|
||||
|
||||
// Empty windows still emit a sentinel so checkpoints can advance.
|
||||
if len(meters) == 0 && len(slices) > 0 {
|
||||
meters = append(meters, meterreportertypes.NewMeter(MeterName, 0, meterUnit, meterAggregation, window, map[string]string{
|
||||
metercollector.DimensionOrganizationID: orgID.StringValue(),
|
||||
metercollector.DimensionRetentionDays: strconv.Itoa(slices[len(slices)-1].DefaultDays),
|
||||
}))
|
||||
}
|
||||
|
||||
return meters, nil
|
||||
}
|
||||
|
||||
// buildQuery stays local because each meter owns its billing query.
|
||||
func buildQuery(meterName string, slice retentiontypes.Slice) (string, []any, []dimensionColumn, error) {
|
||||
retentionExpr, err := retention.BuildMultiIfSQL(slice.Rules, slice.DefaultDays)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
retentionRuleIndexExpr, err := retention.BuildRuleIndexSQL(slice.Rules)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
columns, err := dimensionColumnsFor(slice.Rules)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
|
||||
selects := make([]string, 0, len(columns)+3)
|
||||
groupBy := make([]string, 0, len(columns)+2)
|
||||
for _, column := range columns {
|
||||
selects = append(selects, fmt.Sprintf("JSONExtractString(labels, '%s') AS %s", column.key, column.alias))
|
||||
groupBy = append(groupBy, column.alias)
|
||||
}
|
||||
selects = append(selects,
|
||||
retentionExpr+" AS retention_days",
|
||||
retentionRuleIndexExpr+" AS retention_rule_index",
|
||||
"ifNull(sum(value), 0) AS value",
|
||||
)
|
||||
groupBy = append(groupBy, "retention_days", "retention_rule_index")
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(selects...)
|
||||
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
|
||||
sb.Where(
|
||||
sb.Equal("metric_name", meterName),
|
||||
sb.GTE("unix_milli", slice.StartMs),
|
||||
sb.LT("unix_milli", slice.EndMs),
|
||||
)
|
||||
sb.GroupBy(groupBy...)
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return query, args, columns, nil
|
||||
}
|
||||
|
||||
type dimensionColumn struct {
|
||||
key string
|
||||
alias string
|
||||
}
|
||||
|
||||
func dimensionColumnsFor(rules []retentiontypes.CustomRetentionRule) ([]dimensionColumn, error) {
|
||||
dimensionKeys, err := retention.RuleDimensionKeys(rules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, 0, len(dimensionKeys)+1)
|
||||
keys = append(keys, metercollector.DimensionWorkspaceKeyID)
|
||||
for _, key := range dimensionKeys {
|
||||
if key == metercollector.DimensionWorkspaceKeyID {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
columns := make([]dimensionColumn, len(keys))
|
||||
for i, key := range keys {
|
||||
columns[i] = dimensionColumn{key: key, alias: fmt.Sprintf("dim_%d", i)}
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func buildDimensions(
|
||||
orgID valuer.UUID,
|
||||
retentionDays int,
|
||||
retentionRuleIndex int,
|
||||
columns []dimensionColumn,
|
||||
values []string,
|
||||
rules []retentiontypes.CustomRetentionRule,
|
||||
) (map[string]string, error) {
|
||||
if len(columns) != len(values) {
|
||||
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "dimension column/value count mismatch: %d columns, %d values", len(columns), len(values))
|
||||
}
|
||||
|
||||
valuesByKey := make(map[string]string, len(columns))
|
||||
for i, column := range columns {
|
||||
valuesByKey[column.key] = values[i]
|
||||
}
|
||||
|
||||
dimensions := map[string]string{
|
||||
metercollector.DimensionOrganizationID: orgID.StringValue(),
|
||||
metercollector.DimensionRetentionDays: strconv.Itoa(retentionDays),
|
||||
}
|
||||
addNonEmpty(dimensions, metercollector.DimensionWorkspaceKeyID, valuesByKey[metercollector.DimensionWorkspaceKeyID])
|
||||
|
||||
if retentionRuleIndex < 0 {
|
||||
return dimensions, nil
|
||||
}
|
||||
if retentionRuleIndex >= len(rules) {
|
||||
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "retention rule index %d out of range for %d rules", retentionRuleIndex, len(rules))
|
||||
}
|
||||
for _, filter := range rules[retentionRuleIndex].Filters {
|
||||
addNonEmpty(dimensions, filter.Key, valuesByKey[filter.Key])
|
||||
}
|
||||
return dimensions, nil
|
||||
}
|
||||
|
||||
func addNonEmpty(dimensions map[string]string, key, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
dimensions[key] = value
|
||||
}
|
||||
|
||||
func bucketKey(dimensions map[string]string) string {
|
||||
keys := make([]string, 0, len(dimensions))
|
||||
for key := range dimensions {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var b strings.Builder
|
||||
for _, key := range keys {
|
||||
value := dimensions[key]
|
||||
b.WriteString(strconv.Itoa(len(key)))
|
||||
b.WriteByte(':')
|
||||
b.WriteString(key)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(strconv.Itoa(len(value)))
|
||||
b.WriteByte(':')
|
||||
b.WriteString(value)
|
||||
b.WriteByte(';')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package spancountmetercollector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildDimensions(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
rules := []retentiontypes.CustomRetentionRule{{
|
||||
Filters: []retentiontypes.FilterCondition{{
|
||||
Key: "service.name",
|
||||
Values: []string{"api"},
|
||||
}},
|
||||
TTLDays: 7,
|
||||
}}
|
||||
columns := []dimensionColumn{
|
||||
{key: metercollector.DimensionWorkspaceKeyID, alias: "dim_0"},
|
||||
{key: "service.name", alias: "dim_1"},
|
||||
}
|
||||
|
||||
dimensions, err := buildDimensions(orgID, 30, 0, columns, []string{"workspace-1", "api"}, rules)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]string{
|
||||
metercollector.DimensionOrganizationID: orgID.StringValue(),
|
||||
metercollector.DimensionRetentionDays: "30",
|
||||
metercollector.DimensionWorkspaceKeyID: "workspace-1",
|
||||
"service.name": "api",
|
||||
}, dimensions)
|
||||
}
|
||||
|
||||
func TestProviderMetadata(t *testing.T) {
|
||||
provider := New(nil, nil)
|
||||
|
||||
require.Equal(t, "signoz.meter.span.count", provider.Name().String())
|
||||
require.Equal(t, metercollectortypes.UnitCount, provider.Unit())
|
||||
require.Equal(t, metercollectortypes.AggregationSum, provider.Aggregation())
|
||||
}
|
||||
|
||||
func TestBucketKeyIsStable(t *testing.T) {
|
||||
first := bucketKey(map[string]string{
|
||||
"service.name": "api",
|
||||
metercollector.DimensionRetentionDays: "30",
|
||||
metercollector.DimensionWorkspaceKeyID: "workspace-1",
|
||||
})
|
||||
second := bucketKey(map[string]string{
|
||||
metercollector.DimensionWorkspaceKeyID: "workspace-1",
|
||||
"service.name": "api",
|
||||
metercollector.DimensionRetentionDays: "30",
|
||||
})
|
||||
|
||||
require.Equal(t, first, second)
|
||||
require.NotEmpty(t, first)
|
||||
}
|
||||
|
||||
func TestCollectRejectsInvalidWindowBeforeQuerying(t *testing.T) {
|
||||
readings, err := New(nil, nil).Collect(context.Background(), valuer.GenerateUUID(), meterreportertypes.Window{})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, readings)
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
// Package spansizemetercollector collects span size meters by workspace and
|
||||
// retention. Keep the query local to this meter.
|
||||
package spansizemetercollector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/metercollector/retention"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// MeterName is the typed registry key for this collector.
|
||||
var (
|
||||
MeterName = metercollectortypes.MustNewName("signoz.meter.span.size")
|
||||
meterUnit = metercollectortypes.UnitBytes
|
||||
meterAggregation = metercollectortypes.AggregationSum
|
||||
)
|
||||
|
||||
var _ metercollector.MeterCollector = (*Provider)(nil)
|
||||
|
||||
// Provider collects span size meters.
|
||||
type Provider struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
sqlStore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func New(telemetryStore telemetrystore.TelemetryStore, sqlStore sqlstore.SQLStore) *Provider {
|
||||
return &Provider{
|
||||
telemetryStore: telemetryStore,
|
||||
sqlStore: sqlStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() metercollectortypes.Name { return MeterName }
|
||||
func (p *Provider) Unit() metercollectortypes.Unit { return meterUnit }
|
||||
func (p *Provider) Aggregation() metercollectortypes.Aggregation {
|
||||
return meterAggregation
|
||||
}
|
||||
|
||||
// Collect aggregates span size for the window and emits an empty-day sentinel.
|
||||
func (p *Provider) Collect(ctx context.Context, orgID valuer.UUID, window meterreportertypes.Window) ([]meterreportertypes.Meter, error) {
|
||||
if !window.IsValid() {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, metercollector.ErrCodeCollectFailed, "invalid window [%d, %d)", window.StartUnixMilli, window.EndUnixMilli)
|
||||
}
|
||||
|
||||
meterName := MeterName.String()
|
||||
|
||||
slices, err := retention.LoadActiveSlices(
|
||||
ctx,
|
||||
p.sqlStore,
|
||||
orgID,
|
||||
telemetrytraces.DBName+"."+telemetrytraces.SpanIndexV3LocalTableName,
|
||||
retentiontypes.DefaultTracesRetentionDays,
|
||||
window.StartUnixMilli, window.EndUnixMilli,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "load retention slices for meter %q", meterName)
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
dimensions map[string]string
|
||||
value float64
|
||||
}
|
||||
accumulator := make(map[string]*bucket)
|
||||
|
||||
for _, slice := range slices {
|
||||
query, args, dimensionColumns, err := buildQuery(meterName, slice)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build retention query for meter %q", meterName)
|
||||
}
|
||||
|
||||
rows, err := p.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "query meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
|
||||
if err := func() error {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
dimensionValues := make([]string, len(dimensionColumns))
|
||||
var retentionDays int32
|
||||
var retentionRuleIndex int32
|
||||
var value float64
|
||||
|
||||
scanDest := make([]any, 0, len(dimensionValues)+3)
|
||||
for i := range dimensionValues {
|
||||
scanDest = append(scanDest, &dimensionValues[i])
|
||||
}
|
||||
scanDest = append(scanDest, &retentionDays, &retentionRuleIndex, &value)
|
||||
|
||||
if err := rows.Scan(scanDest...); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "scan meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
|
||||
dimensions, err := buildDimensions(orgID, int(retentionDays), int(retentionRuleIndex), dimensionColumns, dimensionValues, slice.Rules)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "build dimensions for meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
|
||||
key := bucketKey(dimensions)
|
||||
b, ok := accumulator[key]
|
||||
if !ok {
|
||||
b = &bucket{dimensions: dimensions}
|
||||
accumulator[key] = b
|
||||
}
|
||||
b.value += value
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, metercollector.ErrCodeCollectFailed, "iterate meter %q slice [%d, %d)", meterName, slice.StartMs, slice.EndMs)
|
||||
}
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
meters := make([]meterreportertypes.Meter, 0, len(accumulator))
|
||||
for _, b := range accumulator {
|
||||
meters = append(meters, meterreportertypes.NewMeter(MeterName, b.value, meterUnit, meterAggregation, window, b.dimensions))
|
||||
}
|
||||
|
||||
// Empty windows still emit a sentinel so checkpoints can advance.
|
||||
if len(meters) == 0 && len(slices) > 0 {
|
||||
meters = append(meters, meterreportertypes.NewMeter(MeterName, 0, meterUnit, meterAggregation, window, map[string]string{
|
||||
metercollector.DimensionOrganizationID: orgID.StringValue(),
|
||||
metercollector.DimensionRetentionDays: strconv.Itoa(slices[len(slices)-1].DefaultDays),
|
||||
}))
|
||||
}
|
||||
|
||||
return meters, nil
|
||||
}
|
||||
|
||||
// buildQuery stays local because each meter owns its billing query.
|
||||
func buildQuery(meterName string, slice retentiontypes.Slice) (string, []any, []dimensionColumn, error) {
|
||||
retentionExpr, err := retention.BuildMultiIfSQL(slice.Rules, slice.DefaultDays)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
retentionRuleIndexExpr, err := retention.BuildRuleIndexSQL(slice.Rules)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
columns, err := dimensionColumnsFor(slice.Rules)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
|
||||
selects := make([]string, 0, len(columns)+3)
|
||||
groupBy := make([]string, 0, len(columns)+2)
|
||||
for _, column := range columns {
|
||||
selects = append(selects, fmt.Sprintf("JSONExtractString(labels, '%s') AS %s", column.key, column.alias))
|
||||
groupBy = append(groupBy, column.alias)
|
||||
}
|
||||
selects = append(selects,
|
||||
retentionExpr+" AS retention_days",
|
||||
retentionRuleIndexExpr+" AS retention_rule_index",
|
||||
"ifNull(sum(value), 0) AS value",
|
||||
)
|
||||
groupBy = append(groupBy, "retention_days", "retention_rule_index")
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(selects...)
|
||||
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
|
||||
sb.Where(
|
||||
sb.Equal("metric_name", meterName),
|
||||
sb.GTE("unix_milli", slice.StartMs),
|
||||
sb.LT("unix_milli", slice.EndMs),
|
||||
)
|
||||
sb.GroupBy(groupBy...)
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return query, args, columns, nil
|
||||
}
|
||||
|
||||
type dimensionColumn struct {
|
||||
key string
|
||||
alias string
|
||||
}
|
||||
|
||||
func dimensionColumnsFor(rules []retentiontypes.CustomRetentionRule) ([]dimensionColumn, error) {
|
||||
dimensionKeys, err := retention.RuleDimensionKeys(rules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, 0, len(dimensionKeys)+1)
|
||||
keys = append(keys, metercollector.DimensionWorkspaceKeyID)
|
||||
for _, key := range dimensionKeys {
|
||||
if key == metercollector.DimensionWorkspaceKeyID {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
columns := make([]dimensionColumn, len(keys))
|
||||
for i, key := range keys {
|
||||
columns[i] = dimensionColumn{key: key, alias: fmt.Sprintf("dim_%d", i)}
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func buildDimensions(
|
||||
orgID valuer.UUID,
|
||||
retentionDays int,
|
||||
retentionRuleIndex int,
|
||||
columns []dimensionColumn,
|
||||
values []string,
|
||||
rules []retentiontypes.CustomRetentionRule,
|
||||
) (map[string]string, error) {
|
||||
if len(columns) != len(values) {
|
||||
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "dimension column/value count mismatch: %d columns, %d values", len(columns), len(values))
|
||||
}
|
||||
|
||||
valuesByKey := make(map[string]string, len(columns))
|
||||
for i, column := range columns {
|
||||
valuesByKey[column.key] = values[i]
|
||||
}
|
||||
|
||||
dimensions := map[string]string{
|
||||
metercollector.DimensionOrganizationID: orgID.StringValue(),
|
||||
metercollector.DimensionRetentionDays: strconv.Itoa(retentionDays),
|
||||
}
|
||||
addNonEmpty(dimensions, metercollector.DimensionWorkspaceKeyID, valuesByKey[metercollector.DimensionWorkspaceKeyID])
|
||||
|
||||
if retentionRuleIndex < 0 {
|
||||
return dimensions, nil
|
||||
}
|
||||
if retentionRuleIndex >= len(rules) {
|
||||
return nil, errors.Newf(errors.TypeInternal, metercollector.ErrCodeCollectFailed, "retention rule index %d out of range for %d rules", retentionRuleIndex, len(rules))
|
||||
}
|
||||
for _, filter := range rules[retentionRuleIndex].Filters {
|
||||
addNonEmpty(dimensions, filter.Key, valuesByKey[filter.Key])
|
||||
}
|
||||
return dimensions, nil
|
||||
}
|
||||
|
||||
func addNonEmpty(dimensions map[string]string, key, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
dimensions[key] = value
|
||||
}
|
||||
|
||||
func bucketKey(dimensions map[string]string) string {
|
||||
keys := make([]string, 0, len(dimensions))
|
||||
for key := range dimensions {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var b strings.Builder
|
||||
for _, key := range keys {
|
||||
value := dimensions[key]
|
||||
b.WriteString(strconv.Itoa(len(key)))
|
||||
b.WriteByte(':')
|
||||
b.WriteString(key)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(strconv.Itoa(len(value)))
|
||||
b.WriteByte(':')
|
||||
b.WriteString(value)
|
||||
b.WriteByte(';')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package spansizemetercollector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildDimensions(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
rules := []retentiontypes.CustomRetentionRule{{
|
||||
Filters: []retentiontypes.FilterCondition{{
|
||||
Key: "service.name",
|
||||
Values: []string{"api"},
|
||||
}},
|
||||
TTLDays: 7,
|
||||
}}
|
||||
columns := []dimensionColumn{
|
||||
{key: metercollector.DimensionWorkspaceKeyID, alias: "dim_0"},
|
||||
{key: "service.name", alias: "dim_1"},
|
||||
}
|
||||
|
||||
dimensions, err := buildDimensions(orgID, 30, 0, columns, []string{"workspace-1", "api"}, rules)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]string{
|
||||
metercollector.DimensionOrganizationID: orgID.StringValue(),
|
||||
metercollector.DimensionRetentionDays: "30",
|
||||
metercollector.DimensionWorkspaceKeyID: "workspace-1",
|
||||
"service.name": "api",
|
||||
}, dimensions)
|
||||
}
|
||||
|
||||
func TestProviderMetadata(t *testing.T) {
|
||||
provider := New(nil, nil)
|
||||
|
||||
require.Equal(t, "signoz.meter.span.size", provider.Name().String())
|
||||
require.Equal(t, metercollectortypes.UnitBytes, provider.Unit())
|
||||
require.Equal(t, metercollectortypes.AggregationSum, provider.Aggregation())
|
||||
}
|
||||
|
||||
func TestBucketKeyIsStable(t *testing.T) {
|
||||
first := bucketKey(map[string]string{
|
||||
"service.name": "api",
|
||||
metercollector.DimensionRetentionDays: "30",
|
||||
metercollector.DimensionWorkspaceKeyID: "workspace-1",
|
||||
})
|
||||
second := bucketKey(map[string]string{
|
||||
metercollector.DimensionWorkspaceKeyID: "workspace-1",
|
||||
"service.name": "api",
|
||||
metercollector.DimensionRetentionDays: "30",
|
||||
})
|
||||
|
||||
require.Equal(t, first, second)
|
||||
require.NotEmpty(t, first)
|
||||
}
|
||||
|
||||
func TestCollectRejectsInvalidWindowBeforeQuerying(t *testing.T) {
|
||||
readings, err := New(nil, nil).Collect(context.Background(), valuer.GenerateUUID(), meterreportertypes.Window{})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, readings)
|
||||
}
|
||||
@@ -1,630 +0,0 @@
|
||||
package httpmeterreporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/meterreporter"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var _ factory.ServiceWithHealthy = (*Provider)(nil)
|
||||
|
||||
var errCodeReportFailed = errors.MustNewCode("meterreporter_report_failed")
|
||||
|
||||
const (
|
||||
phaseSealed = "sealed"
|
||||
phaseToday = "today"
|
||||
|
||||
attrPhase = "phase"
|
||||
attrResult = "result"
|
||||
attrMeterReporterProvider = "meterreporter.provider"
|
||||
attrOrgID = "meterreporter.org_id"
|
||||
attrOrgCount = "meterreporter.org_count"
|
||||
attrMeter = "meterreporter.meter"
|
||||
attrDate = "meterreporter.date"
|
||||
attrReadings = "meterreporter.readings"
|
||||
attrReadingsCollected = "meterreporter.readings_collected"
|
||||
attrReadingsDropped = "meterreporter.readings_dropped"
|
||||
attrWindowStartUnixMilli = "meterreporter.window_start_unix_milli"
|
||||
attrWindowEndUnixMilli = "meterreporter.window_end_unix_milli"
|
||||
attrWindowCompleted = "meterreporter.window_completed"
|
||||
attrCatchupStart = "meterreporter.catchup_start"
|
||||
attrCatchupEnd = "meterreporter.catchup_end"
|
||||
attrDurationMs = "meterreporter.duration_ms"
|
||||
attrDryRun = "meterreporter.dry_run"
|
||||
attrIdempotencyKey = "meterreporter.idempotency_key"
|
||||
|
||||
resultSuccess = "success"
|
||||
resultFailure = "failure"
|
||||
|
||||
providerName = "http"
|
||||
)
|
||||
|
||||
// Provider collects registered meters and ships them to Zeus.
|
||||
type Provider struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
config meterreporter.Config
|
||||
collectors []metercollector.MeterCollector
|
||||
|
||||
licensing licensing.Licensing
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
orgGetter organization.Getter
|
||||
zeus zeus.Zeus
|
||||
|
||||
healthyC chan struct{}
|
||||
stopC chan struct{}
|
||||
goroutinesWg sync.WaitGroup
|
||||
metrics *reporterMetrics
|
||||
}
|
||||
|
||||
// NewFactory registers the HTTP meter reporter.
|
||||
func NewFactory(
|
||||
collectors map[metercollectortypes.Name]metercollector.MeterCollector,
|
||||
licensing licensing.Licensing,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
orgGetter organization.Getter,
|
||||
zeus zeus.Zeus,
|
||||
) factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName(providerName),
|
||||
func(ctx context.Context, providerSettings factory.ProviderSettings, config meterreporter.Config) (meterreporter.Reporter, error) {
|
||||
return newProvider(ctx, providerSettings, config, collectors, licensing, telemetryStore, orgGetter, zeus)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func newProvider(
|
||||
_ context.Context,
|
||||
providerSettings factory.ProviderSettings,
|
||||
config meterreporter.Config,
|
||||
collectors map[metercollectortypes.Name]metercollector.MeterCollector,
|
||||
licensing licensing.Licensing,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
orgGetter organization.Getter,
|
||||
zeus zeus.Zeus,
|
||||
) (*Provider, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/meterreporter/httpmeterreporter")
|
||||
|
||||
metrics, err := newReporterMetrics(settings.Meter())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orderedCollectors, err := validateCollectors(collectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Provider{
|
||||
settings: settings,
|
||||
config: config,
|
||||
collectors: orderedCollectors,
|
||||
licensing: licensing,
|
||||
telemetryStore: telemetryStore,
|
||||
orgGetter: orgGetter,
|
||||
zeus: zeus,
|
||||
healthyC: make(chan struct{}),
|
||||
stopC: make(chan struct{}),
|
||||
metrics: metrics,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateCollectors(collectors map[metercollectortypes.Name]metercollector.MeterCollector) ([]metercollector.MeterCollector, error) {
|
||||
ordered := make([]metercollector.MeterCollector, 0, len(collectors))
|
||||
for name, collector := range collectors {
|
||||
if name.IsZero() {
|
||||
return nil, errors.New(errors.TypeInvalidInput, meterreporter.ErrCodeInvalidInput, "empty meter name in collector registry")
|
||||
}
|
||||
if collector == nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, meterreporter.ErrCodeInvalidInput, "nil collector for meter %q", name.String())
|
||||
}
|
||||
if collector.Name() != name {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, meterreporter.ErrCodeInvalidInput, "registry key %q does not match collector.Name() %q", name.String(), collector.Name().String())
|
||||
}
|
||||
if collector.Unit().IsZero() {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, meterreporter.ErrCodeInvalidInput, "meter %q has empty unit", name.String())
|
||||
}
|
||||
if collector.Aggregation().IsZero() {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, meterreporter.ErrCodeInvalidInput, "meter %q has empty aggregation", name.String())
|
||||
}
|
||||
ordered = append(ordered, collector)
|
||||
}
|
||||
|
||||
sort.Slice(ordered, func(i, j int) bool {
|
||||
return ordered[i].Name().String() < ordered[j].Name().String()
|
||||
})
|
||||
|
||||
return ordered, nil
|
||||
}
|
||||
|
||||
// Start runs an immediate tick, then repeats on Config.Interval.
|
||||
func (provider *Provider) Start(ctx context.Context) error {
|
||||
close(provider.healthyC)
|
||||
|
||||
provider.settings.Logger().InfoContext(ctx, "meter reporter started",
|
||||
slog.Duration("interval", provider.config.Interval),
|
||||
slog.Duration("timeout", provider.config.Timeout),
|
||||
slog.Int("catchup_max_days_per_tick", provider.config.CatchupMaxDaysPerTick),
|
||||
slog.Int("meters", len(provider.collectors)),
|
||||
)
|
||||
|
||||
provider.goroutinesWg.Add(1)
|
||||
go func() {
|
||||
defer provider.goroutinesWg.Done()
|
||||
|
||||
provider.runTick(ctx)
|
||||
|
||||
ticker := time.NewTicker(provider.config.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-provider.stopC:
|
||||
return
|
||||
case <-ticker.C:
|
||||
provider.runTick(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
provider.goroutinesWg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop signals the tick loop and waits for any in-flight tick.
|
||||
func (provider *Provider) Stop(ctx context.Context) error {
|
||||
<-provider.healthyC
|
||||
provider.settings.Logger().InfoContext(ctx, "meter reporter stopping")
|
||||
select {
|
||||
case <-provider.stopC:
|
||||
// already closed
|
||||
default:
|
||||
close(provider.stopC)
|
||||
}
|
||||
provider.goroutinesWg.Wait()
|
||||
provider.settings.Logger().InfoContext(ctx, "meter reporter stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *Provider) Healthy() <-chan struct{} {
|
||||
return provider.healthyC
|
||||
}
|
||||
|
||||
// runTick executes one collect-and-ship cycle under Config.Timeout.
|
||||
func (provider *Provider) runTick(parentCtx context.Context) {
|
||||
tickStart := time.Now()
|
||||
ctx, span := provider.settings.Tracer().Start(parentCtx, "meterreporter.Tick", trace.WithAttributes(
|
||||
attribute.String(attrMeterReporterProvider, providerName),
|
||||
attribute.Int("meterreporter.meters", len(provider.collectors)),
|
||||
attribute.Int("meterreporter.catchup_max_days_per_tick", provider.config.CatchupMaxDaysPerTick),
|
||||
))
|
||||
defer span.End()
|
||||
|
||||
provider.metrics.ticks.Add(ctx, 1)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, provider.config.Timeout)
|
||||
defer cancel()
|
||||
|
||||
provider.settings.Logger().DebugContext(ctx, "meter reporter tick started",
|
||||
slog.Duration("timeout", provider.config.Timeout),
|
||||
slog.Int("meters", len(provider.collectors)),
|
||||
)
|
||||
|
||||
if err := provider.tick(ctx); err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
span.SetAttributes(
|
||||
attribute.String(attrResult, resultFailure),
|
||||
attribute.Int64(attrDurationMs, time.Since(tickStart).Milliseconds()),
|
||||
)
|
||||
provider.settings.Logger().ErrorContext(ctx, "meter reporter tick failed",
|
||||
errors.Attr(err),
|
||||
slog.Duration("timeout", provider.config.Timeout),
|
||||
slog.Duration("duration", time.Since(tickStart)),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.String(attrResult, resultSuccess),
|
||||
attribute.Int64(attrDurationMs, time.Since(tickStart).Milliseconds()),
|
||||
)
|
||||
provider.settings.Logger().DebugContext(ctx, "meter reporter tick completed", slog.Duration("duration", time.Since(tickStart)))
|
||||
}
|
||||
|
||||
// tick processes sealed catchup days, then today's partial window.
|
||||
func (provider *Provider) tick(ctx context.Context) error {
|
||||
now := time.Now().UTC()
|
||||
// Use one timestamp so a tick cannot straddle midnight.
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
yesterday := todayStart.AddDate(0, 0, -1)
|
||||
|
||||
orgs, err := provider.orgGetter.ListByOwnedKeyRange(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errCodeReportFailed, "failed to list organizations")
|
||||
}
|
||||
trace.SpanFromContext(ctx).SetAttributes(attribute.Int(attrOrgCount, len(orgs)))
|
||||
if len(orgs) == 0 {
|
||||
provider.settings.Logger().InfoContext(ctx, "skipping meter reporter tick; no organizations found")
|
||||
return nil
|
||||
}
|
||||
org := orgs[0]
|
||||
if len(orgs) > 1 {
|
||||
// signoz_meter samples have no org marker.
|
||||
provider.settings.Logger().WarnContext(ctx, "multiple orgs on a single instance; reporting only the first",
|
||||
slog.Int("org_count", len(orgs)),
|
||||
slog.String("selected_org_id", org.ID.StringValue()),
|
||||
)
|
||||
}
|
||||
trace.SpanFromContext(ctx).SetAttributes(attribute.String(attrOrgID, org.ID.StringValue()))
|
||||
|
||||
license, err := provider.licensing.GetActive(ctx, org.ID)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errCodeReportFailed, "failed to fetch active license for org %q", org.ID.StringValue())
|
||||
}
|
||||
if license == nil || license.Key == "" {
|
||||
provider.settings.Logger().WarnContext(ctx, "skipping tick, nil/empty license for org", slog.String("org_id", org.ID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: re-enable once /v2/meters/checkpoints is live in staging. Until
|
||||
// then we run with an empty checkpoint map; bootstrap floors are taken
|
||||
// from data and dropCheckpointed becomes a no-op for the sealed window.
|
||||
// checkpoints, err := provider.zeus.GetMeterCheckpoints(ctx, license.Key)
|
||||
// if err != nil {
|
||||
// provider.metrics.checkpointErrors.Add(ctx, 1)
|
||||
// provider.settings.Logger().ErrorContext(ctx, "skipping tick: meter checkpoints call failed", errors.Attr(err))
|
||||
// return nil
|
||||
// }
|
||||
// checkpointsByMeter := make(map[string]time.Time, len(checkpoints))
|
||||
// for _, checkpoint := range checkpoints {
|
||||
// checkpointsByMeter[checkpoint.Name] = checkpoint.Checkpoint.UTC()
|
||||
// }
|
||||
checkpointsByMeter := make(map[string]time.Time)
|
||||
|
||||
floor := provider.dataFloor(ctx, todayStart)
|
||||
catchupStart := provider.catchupStart(floor, todayStart, checkpointsByMeter)
|
||||
end := catchupStart.AddDate(0, 0, provider.config.CatchupMaxDaysPerTick-1)
|
||||
if end.After(yesterday) {
|
||||
end = yesterday
|
||||
}
|
||||
trace.SpanFromContext(ctx).SetAttributes(
|
||||
attribute.String(attrCatchupStart, catchupStart.Format("2006-01-02")),
|
||||
attribute.String(attrCatchupEnd, end.Format("2006-01-02")),
|
||||
)
|
||||
provider.settings.Logger().DebugContext(ctx, "meter reporter catchup window selected",
|
||||
slog.String("org_id", org.ID.StringValue()),
|
||||
slog.Time("data_floor", floor),
|
||||
slog.Time("catchup_start", catchupStart),
|
||||
slog.Time("catchup_end", end),
|
||||
slog.Int("catchup_max_days_per_tick", provider.config.CatchupMaxDaysPerTick),
|
||||
)
|
||||
for day := catchupStart; !day.After(end); day = day.AddDate(0, 0, 1) {
|
||||
window := meterreportertypes.Window{
|
||||
StartUnixMilli: day.UnixMilli(),
|
||||
EndUnixMilli: day.AddDate(0, 0, 1).UnixMilli(),
|
||||
IsCompleted: true,
|
||||
}
|
||||
err := provider.runPhase(ctx, org.ID, license.Key, window, checkpointsByMeter)
|
||||
result := resultSuccess
|
||||
if err != nil {
|
||||
result = resultFailure
|
||||
}
|
||||
provider.metrics.catchupDaysProcessed.Add(ctx, 1, metric.WithAttributes(attribute.String(attrResult, result)))
|
||||
if err != nil {
|
||||
provider.settings.Logger().WarnContext(ctx, "stopping sealed catchup after failed day",
|
||||
errors.Attr(err),
|
||||
slog.String("date", day.Format("2006-01-02")),
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Today's partial window runs every tick.
|
||||
todayWindow := meterreportertypes.Window{
|
||||
StartUnixMilli: todayStart.UnixMilli(),
|
||||
EndUnixMilli: now.UnixMilli(),
|
||||
IsCompleted: false,
|
||||
}
|
||||
_ = provider.runPhase(ctx, org.ID, license.Key, todayWindow, checkpointsByMeter)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runPhase collects all meters for one window and ships the batch.
|
||||
func (provider *Provider) runPhase(ctx context.Context, orgID valuer.UUID, licenseKey string, window meterreportertypes.Window, checkpointsByMeter map[string]time.Time) error {
|
||||
phaseLabel := phaseToday
|
||||
if window.IsCompleted {
|
||||
phaseLabel = phaseSealed
|
||||
}
|
||||
phaseAttr := metric.WithAttributes(attribute.String(attrPhase, phaseLabel))
|
||||
date := time.UnixMilli(window.StartUnixMilli).UTC().Format("2006-01-02")
|
||||
phaseStart := time.Now()
|
||||
ctx, span := provider.settings.Tracer().Start(ctx, "meterreporter.RunPhase", trace.WithAttributes(
|
||||
attribute.String(attrPhase, phaseLabel),
|
||||
attribute.String(attrOrgID, orgID.StringValue()),
|
||||
attribute.String(attrDate, date),
|
||||
attribute.Int64(attrWindowStartUnixMilli, window.StartUnixMilli),
|
||||
attribute.Int64(attrWindowEndUnixMilli, window.EndUnixMilli),
|
||||
attribute.Bool(attrWindowCompleted, window.IsCompleted),
|
||||
))
|
||||
defer span.End()
|
||||
|
||||
provider.settings.Logger().DebugContext(ctx, "meter reporter phase started",
|
||||
slog.String("org_id", orgID.StringValue()),
|
||||
slog.String("phase", phaseLabel),
|
||||
slog.String("date", date),
|
||||
slog.Int64("start_unix_milli", window.StartUnixMilli),
|
||||
slog.Int64("end_unix_milli", window.EndUnixMilli),
|
||||
slog.Int("meters", len(provider.collectors)),
|
||||
)
|
||||
|
||||
collectStart := time.Now()
|
||||
readings := make([]meterreportertypes.Meter, 0, len(provider.collectors))
|
||||
for _, collector := range provider.collectors {
|
||||
meterName := collector.Name().String()
|
||||
collectStart := time.Now()
|
||||
collectCtx, collectSpan := provider.settings.Tracer().Start(ctx, "meterreporter.CollectMeter", trace.WithAttributes(
|
||||
attribute.String(attrPhase, phaseLabel),
|
||||
attribute.String(attrOrgID, orgID.StringValue()),
|
||||
attribute.String(attrMeter, meterName),
|
||||
attribute.String(attrDate, date),
|
||||
attribute.Int64(attrWindowStartUnixMilli, window.StartUnixMilli),
|
||||
attribute.Int64(attrWindowEndUnixMilli, window.EndUnixMilli),
|
||||
attribute.Bool(attrWindowCompleted, window.IsCompleted),
|
||||
))
|
||||
collectedReadings, err := collector.Collect(collectCtx, orgID, window)
|
||||
if err != nil {
|
||||
collectSpan.RecordError(err)
|
||||
collectSpan.SetStatus(codes.Error, err.Error())
|
||||
collectSpan.SetAttributes(
|
||||
attribute.String(attrResult, resultFailure),
|
||||
attribute.Int64(attrDurationMs, time.Since(collectStart).Milliseconds()),
|
||||
)
|
||||
collectSpan.End()
|
||||
provider.metrics.collectErrors.Add(ctx, 1, phaseAttr)
|
||||
provider.settings.Logger().WarnContext(ctx, "meter collection failed",
|
||||
errors.Attr(err),
|
||||
slog.String("meter", meterName),
|
||||
slog.String("org_id", orgID.StringValue()),
|
||||
slog.String("phase", phaseLabel),
|
||||
slog.String("date", date),
|
||||
slog.Duration("duration", time.Since(collectStart)),
|
||||
)
|
||||
continue
|
||||
}
|
||||
collectSpan.SetAttributes(
|
||||
attribute.String(attrResult, resultSuccess),
|
||||
attribute.Int(attrReadings, len(collectedReadings)),
|
||||
attribute.Int64(attrDurationMs, time.Since(collectStart).Milliseconds()),
|
||||
)
|
||||
collectSpan.End()
|
||||
provider.settings.Logger().DebugContext(ctx, "meter collection completed",
|
||||
slog.String("meter", meterName),
|
||||
slog.String("org_id", orgID.StringValue()),
|
||||
slog.String("phase", phaseLabel),
|
||||
slog.String("date", date),
|
||||
slog.Int("readings", len(collectedReadings)),
|
||||
slog.Duration("duration", time.Since(collectStart)),
|
||||
)
|
||||
readings = append(readings, collectedReadings...)
|
||||
}
|
||||
collectDuration := time.Since(collectStart)
|
||||
provider.metrics.collectDuration.Add(ctx, collectDuration.Seconds(), phaseAttr)
|
||||
provider.metrics.collectOperations.Add(ctx, 1, phaseAttr)
|
||||
span.SetAttributes(attribute.Int(attrReadingsCollected, len(readings)))
|
||||
|
||||
if window.IsCompleted {
|
||||
beforeDrop := len(readings)
|
||||
readings = dropCheckpointed(readings, time.UnixMilli(window.StartUnixMilli).UTC(), checkpointsByMeter)
|
||||
dropped := beforeDrop - len(readings)
|
||||
span.SetAttributes(attribute.Int(attrReadingsDropped, dropped))
|
||||
if dropped > 0 {
|
||||
provider.settings.Logger().DebugContext(ctx, "dropped checkpointed meter readings",
|
||||
slog.String("org_id", orgID.StringValue()),
|
||||
slog.String("phase", phaseLabel),
|
||||
slog.String("date", date),
|
||||
slog.Int("dropped", dropped),
|
||||
slog.Int("remaining", len(readings)),
|
||||
)
|
||||
}
|
||||
}
|
||||
if len(readings) == 0 {
|
||||
span.SetAttributes(
|
||||
attribute.String(attrResult, resultSuccess),
|
||||
attribute.Int(attrReadings, 0),
|
||||
attribute.Int64(attrDurationMs, time.Since(phaseStart).Milliseconds()),
|
||||
)
|
||||
provider.settings.Logger().DebugContext(ctx, "meter reporter phase produced no readings",
|
||||
slog.String("org_id", orgID.StringValue()),
|
||||
slog.String("phase", phaseLabel),
|
||||
slog.String("date", date),
|
||||
slog.Duration("collect_duration", collectDuration),
|
||||
slog.Duration("duration", time.Since(phaseStart)),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
shipStart := time.Now()
|
||||
err := provider.shipReadings(ctx, licenseKey, date, readings)
|
||||
shipDuration := time.Since(shipStart)
|
||||
provider.metrics.shipDuration.Add(ctx, shipDuration.Seconds(), phaseAttr)
|
||||
provider.metrics.shipOperations.Add(ctx, 1, phaseAttr)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
span.SetAttributes(attribute.String(attrResult, resultFailure))
|
||||
provider.metrics.postErrors.Add(ctx, 1, phaseAttr)
|
||||
provider.settings.Logger().ErrorContext(ctx, "failed to ship meter readings",
|
||||
errors.Attr(err),
|
||||
slog.String("phase", phaseLabel),
|
||||
slog.String("date", date),
|
||||
slog.Int("readings", len(readings)),
|
||||
slog.Duration("ship_duration", shipDuration),
|
||||
)
|
||||
return err
|
||||
}
|
||||
provider.metrics.readingsEmitted.Add(ctx, int64(len(readings)), phaseAttr)
|
||||
span.SetAttributes(
|
||||
attribute.String(attrResult, resultSuccess),
|
||||
attribute.Int(attrReadings, len(readings)),
|
||||
attribute.Int64(attrDurationMs, time.Since(phaseStart).Milliseconds()),
|
||||
)
|
||||
provider.settings.Logger().InfoContext(ctx, "meter reporter phase shipped",
|
||||
slog.String("org_id", orgID.StringValue()),
|
||||
slog.String("phase", phaseLabel),
|
||||
slog.String("date", date),
|
||||
slog.Int("readings", len(readings)),
|
||||
slog.Duration("collect_duration", collectDuration),
|
||||
slog.Duration("ship_duration", shipDuration),
|
||||
slog.Duration("duration", time.Since(phaseStart)),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dropCheckpointed removes readings already covered by meter checkpoints.
|
||||
func dropCheckpointed(readings []meterreportertypes.Meter, windowDay time.Time, checkpointsByMeter map[string]time.Time) []meterreportertypes.Meter {
|
||||
if len(checkpointsByMeter) == 0 {
|
||||
return readings
|
||||
}
|
||||
kept := readings[:0]
|
||||
for _, reading := range readings {
|
||||
checkpoint, ok := checkpointsByMeter[reading.MeterName]
|
||||
if !ok || checkpoint.Before(windowDay) {
|
||||
kept = append(kept, reading)
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// catchupStart returns the earliest UTC day that still needs sealed reporting.
|
||||
func (provider *Provider) catchupStart(floor time.Time, todayStart time.Time, checkpointsByMeter map[string]time.Time) time.Time {
|
||||
catchupStart := todayStart
|
||||
|
||||
for _, collector := range provider.collectors {
|
||||
next := floor
|
||||
if checkpoint, ok := checkpointsByMeter[collector.Name().String()]; ok {
|
||||
next = checkpoint.AddDate(0, 0, 1)
|
||||
if next.Before(floor) {
|
||||
next = floor
|
||||
}
|
||||
}
|
||||
if next.Before(catchupStart) {
|
||||
catchupStart = next
|
||||
}
|
||||
}
|
||||
|
||||
yesterday := todayStart.AddDate(0, 0, -1)
|
||||
if catchupStart.After(yesterday) {
|
||||
catchupStart = yesterday
|
||||
}
|
||||
|
||||
return catchupStart
|
||||
}
|
||||
|
||||
// dataFloor returns the earliest signoz_meter sample day, or today on failure.
|
||||
func (provider *Provider) dataFloor(ctx context.Context, todayStart time.Time) time.Time {
|
||||
ctx, span := provider.settings.Tracer().Start(ctx, "meterreporter.DataFloor")
|
||||
defer span.End()
|
||||
|
||||
if provider.telemetryStore == nil {
|
||||
span.SetAttributes(attribute.String(attrResult, resultSuccess))
|
||||
return todayStart
|
||||
}
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("ifNull(min(unix_milli), 0)")
|
||||
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var minMs int64
|
||||
if err := provider.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&minMs); err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
span.SetAttributes(attribute.String(attrResult, resultFailure))
|
||||
provider.settings.Logger().WarnContext(ctx, "failed to read data floor; falling back to latest sealed day", errors.Attr(err))
|
||||
return todayStart
|
||||
}
|
||||
if minMs == 0 {
|
||||
span.SetAttributes(
|
||||
attribute.String(attrResult, resultSuccess),
|
||||
attribute.Int64("meterreporter.data_floor_unix_milli", 0),
|
||||
)
|
||||
return todayStart
|
||||
}
|
||||
|
||||
minDay := time.UnixMilli(minMs).UTC()
|
||||
floor := time.Date(minDay.Year(), minDay.Month(), minDay.Day(), 0, 0, 0, 0, time.UTC)
|
||||
span.SetAttributes(
|
||||
attribute.String(attrResult, resultSuccess),
|
||||
attribute.Int64("meterreporter.data_floor_unix_milli", floor.UnixMilli()),
|
||||
)
|
||||
provider.settings.Logger().DebugContext(ctx, "meter reporter data floor loaded", slog.Time("data_floor", floor))
|
||||
return floor
|
||||
}
|
||||
|
||||
// shipReadings sends one day's meter batch to Zeus.
|
||||
func (provider *Provider) shipReadings(ctx context.Context, licenseKey string, date string, readings []meterreportertypes.Meter) error {
|
||||
idempotencyKey := fmt.Sprintf("meter-cron:%s", date)
|
||||
ctx, span := provider.settings.Tracer().Start(ctx, "meterreporter.ShipReadings", trace.WithAttributes(
|
||||
attribute.String(attrDate, date),
|
||||
attribute.Int(attrReadings, len(readings)),
|
||||
attribute.String(attrIdempotencyKey, idempotencyKey),
|
||||
attribute.Bool(attrDryRun, true),
|
||||
))
|
||||
defer span.End()
|
||||
|
||||
provider.settings.Logger().InfoContext(ctx, "meter readings prepared for shipment",
|
||||
slog.String("date", date),
|
||||
slog.Int("readings", len(readings)),
|
||||
slog.String("idempotency_key", idempotencyKey),
|
||||
slog.Bool("dry_run", true),
|
||||
)
|
||||
|
||||
// Temporary visibility while /v2/meters is offline.
|
||||
for _, reading := range readings {
|
||||
provider.settings.Logger().InfoContext(ctx, "meter reading prepared for shipment",
|
||||
slog.String("meter", reading.MeterName),
|
||||
slog.Float64("value", reading.Value),
|
||||
slog.String("unit", reading.Unit.StringValue()),
|
||||
slog.String("aggregation", reading.Aggregation.StringValue()),
|
||||
slog.Int64("start_unix_milli", reading.StartUnixMilli),
|
||||
slog.Int64("end_unix_milli", reading.EndUnixMilli),
|
||||
slog.Bool("is_completed", reading.IsCompleted),
|
||||
slog.Any("dimensions", reading.Dimensions),
|
||||
slog.String("idempotency_key", idempotencyKey),
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: re-enable once /v2/meters is live in staging.
|
||||
// body, err := json.Marshal(meterreportertypes.PostableMeters{Meters: readings})
|
||||
// if err != nil {
|
||||
// return errors.Wrapf(err, errors.TypeInternal, errCodeReportFailed, "marshal meter readings for %s", date)
|
||||
// }
|
||||
// if err := provider.zeus.PutMetersV3(ctx, licenseKey, idempotencyKey, body); err != nil {
|
||||
// return errors.Wrapf(err, errors.TypeInternal, errCodeReportFailed, "ship meter readings for %s", date)
|
||||
// }
|
||||
_ = licenseKey
|
||||
span.SetAttributes(attribute.String(attrResult, resultSuccess))
|
||||
return nil
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package httpmeterreporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/metercollector"
|
||||
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateCollectorsRejectsBadRegistry(t *testing.T) {
|
||||
meterA := metercollectortypes.MustNewName("signoz.test.a")
|
||||
meterB := metercollectortypes.MustNewName("signoz.test.b")
|
||||
|
||||
t.Run("key name mismatch", func(t *testing.T) {
|
||||
_, err := validateCollectors(map[metercollectortypes.Name]metercollector.MeterCollector{
|
||||
meterA: testCollector{name: meterB},
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("nil collector", func(t *testing.T) {
|
||||
_, err := validateCollectors(map[metercollectortypes.Name]metercollector.MeterCollector{
|
||||
meterA: nil,
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDropCheckpointed(t *testing.T) {
|
||||
meterA := metercollectortypes.MustNewName("signoz.test.a")
|
||||
meterB := metercollectortypes.MustNewName("signoz.test.b")
|
||||
meterC := metercollectortypes.MustNewName("signoz.test.c")
|
||||
windowDay := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)
|
||||
window := meterreportertypes.Window{
|
||||
StartUnixMilli: windowDay.UnixMilli(),
|
||||
EndUnixMilli: windowDay.AddDate(0, 0, 1).UnixMilli(),
|
||||
IsCompleted: true,
|
||||
}
|
||||
readings := []meterreportertypes.Meter{
|
||||
meterreportertypes.NewMeter(meterA, 0, metercollectortypes.UnitCount, metercollectortypes.AggregationSum, window, nil),
|
||||
meterreportertypes.NewMeter(meterB, 0, metercollectortypes.UnitCount, metercollectortypes.AggregationSum, window, nil),
|
||||
meterreportertypes.NewMeter(meterC, 0, metercollectortypes.UnitCount, metercollectortypes.AggregationSum, window, nil),
|
||||
}
|
||||
|
||||
kept := dropCheckpointed(readings, windowDay, map[string]time.Time{
|
||||
meterA.String(): windowDay,
|
||||
meterB.String(): windowDay.AddDate(0, 0, -1),
|
||||
})
|
||||
|
||||
require.Equal(t, []meterreportertypes.Meter{
|
||||
meterreportertypes.NewMeter(meterB, 0, metercollectortypes.UnitCount, metercollectortypes.AggregationSum, window, nil),
|
||||
meterreportertypes.NewMeter(meterC, 0, metercollectortypes.UnitCount, metercollectortypes.AggregationSum, window, nil),
|
||||
}, kept)
|
||||
}
|
||||
|
||||
func TestCatchupStart(t *testing.T) {
|
||||
meterA := metercollectortypes.MustNewName("signoz.test.a")
|
||||
floor := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
todayStart := time.Date(2026, 5, 5, 0, 0, 0, 0, time.UTC)
|
||||
provider := &Provider{
|
||||
collectors: []metercollector.MeterCollector{
|
||||
testCollector{name: meterA},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("no checkpoint starts at floor", func(t *testing.T) {
|
||||
require.Equal(t, floor, provider.catchupStart(floor, todayStart, nil))
|
||||
})
|
||||
|
||||
t.Run("checkpoint advances by one day", func(t *testing.T) {
|
||||
require.Equal(t, floor.AddDate(0, 0, 2), provider.catchupStart(floor, todayStart, map[string]time.Time{
|
||||
meterA.String(): floor.AddDate(0, 0, 1),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
type testCollector struct {
|
||||
name metercollectortypes.Name
|
||||
unit metercollectortypes.Unit
|
||||
aggregation metercollectortypes.Aggregation
|
||||
}
|
||||
|
||||
func (c testCollector) Name() metercollectortypes.Name {
|
||||
return c.name
|
||||
}
|
||||
|
||||
func (c testCollector) Unit() metercollectortypes.Unit {
|
||||
if c.unit.IsZero() {
|
||||
return metercollectortypes.UnitCount
|
||||
}
|
||||
return c.unit
|
||||
}
|
||||
|
||||
func (c testCollector) Aggregation() metercollectortypes.Aggregation {
|
||||
if c.aggregation.IsZero() {
|
||||
return metercollectortypes.AggregationSum
|
||||
}
|
||||
return c.aggregation
|
||||
}
|
||||
|
||||
func (c testCollector) Collect(context.Context, valuer.UUID, meterreportertypes.Window) ([]meterreportertypes.Meter, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package httpmeterreporter
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
)
|
||||
|
||||
type reporterMetrics struct {
|
||||
ticks metric.Int64Counter
|
||||
readingsEmitted metric.Int64Counter
|
||||
collectErrors metric.Int64Counter
|
||||
postErrors metric.Int64Counter
|
||||
checkpointErrors metric.Int64Counter
|
||||
catchupDaysProcessed metric.Int64Counter
|
||||
collectDuration metric.Float64Counter
|
||||
collectOperations metric.Int64Counter
|
||||
shipDuration metric.Float64Counter
|
||||
shipOperations metric.Int64Counter
|
||||
}
|
||||
|
||||
func newReporterMetrics(meter metric.Meter) (*reporterMetrics, error) {
|
||||
var errs error
|
||||
|
||||
ticks, err := meter.Int64Counter("signoz.meterreporter.ticks", metric.WithDescription("Meter reporter ticks."))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
readingsEmitted, err := meter.Int64Counter("signoz.meterreporter.readings.emitted", metric.WithDescription("Meter readings shipped to Zeus."))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
collectErrors, err := meter.Int64Counter("signoz.meterreporter.collect.errors", metric.WithDescription("Meter collection errors."))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
postErrors, err := meter.Int64Counter("signoz.meterreporter.post.errors", metric.WithDescription("Zeus POST failures."))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
checkpointErrors, err := meter.Int64Counter("signoz.meterreporter.checkpoint.errors", metric.WithDescription("Zeus checkpoint read failures."))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
catchupDaysProcessed, err := meter.Int64Counter("signoz.meterreporter.catchup.days_processed", metric.WithDescription("Sealed catchup days processed."))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
collectDuration, err := meter.Float64Counter("signoz.meterreporter.collect.duration.seconds", metric.WithDescription("Cumulative collection duration."), metric.WithUnit("s"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
collectOperations, err := meter.Int64Counter("signoz.meterreporter.collect.operations", metric.WithDescription("Collection phases measured."))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
shipDuration, err := meter.Float64Counter("signoz.meterreporter.ship.duration.seconds", metric.WithDescription("Cumulative ship duration."), metric.WithUnit("s"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
shipOperations, err := meter.Int64Counter("signoz.meterreporter.ship.operations", metric.WithDescription("Ship phases measured."))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
if errs != nil {
|
||||
return nil, errs
|
||||
}
|
||||
|
||||
return &reporterMetrics{
|
||||
ticks: ticks,
|
||||
readingsEmitted: readingsEmitted,
|
||||
collectErrors: collectErrors,
|
||||
postErrors: postErrors,
|
||||
checkpointErrors: checkpointErrors,
|
||||
catchupDaysProcessed: catchupDaysProcessed,
|
||||
collectDuration: collectDuration,
|
||||
collectOperations: collectOperations,
|
||||
shipDuration: shipDuration,
|
||||
shipOperations: shipOperations,
|
||||
}, nil
|
||||
}
|
||||
@@ -150,72 +150,6 @@ func (provider *Provider) PutMetersV2(ctx context.Context, key string, data []by
|
||||
return err
|
||||
}
|
||||
|
||||
func (provider *Provider) PutMetersV3(ctx context.Context, key string, idempotencyKey string, data []byte) error {
|
||||
headers := http.Header{}
|
||||
if idempotencyKey != "" {
|
||||
headers.Set("X-Idempotency-Key", idempotencyKey)
|
||||
}
|
||||
|
||||
_, err := provider.doWithHeaders(
|
||||
ctx,
|
||||
provider.config.URL.JoinPath("/v2/meters"),
|
||||
http.MethodPost,
|
||||
key,
|
||||
data,
|
||||
headers,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (provider *Provider) GetMeterCheckpoints(ctx context.Context, key string) ([]zeustypes.MeterCheckpoint, error) {
|
||||
response, err := provider.do(
|
||||
ctx,
|
||||
provider.config.URL.JoinPath("/v2/meters/checkpoints"),
|
||||
http.MethodGet,
|
||||
key,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
checkpointValues := gjson.GetBytes(response, "data.checkpoints")
|
||||
if !checkpointValues.Exists() || checkpointValues.Type == gjson.Null {
|
||||
return nil, errors.Newf(errors.TypeInternal, zeus.ErrCodeResponseMalformed, "meter checkpoints are required")
|
||||
}
|
||||
|
||||
if !checkpointValues.IsArray() {
|
||||
return nil, errors.Newf(errors.TypeInternal, zeus.ErrCodeResponseMalformed, "meter checkpoints must be an array")
|
||||
}
|
||||
|
||||
checkpointResults := checkpointValues.Array()
|
||||
checkpoints := make([]zeustypes.MeterCheckpoint, 0, len(checkpointResults))
|
||||
for _, checkpointValue := range checkpointResults {
|
||||
name := checkpointValue.Get("name").String()
|
||||
if name == "" {
|
||||
return nil, errors.Newf(errors.TypeInternal, zeus.ErrCodeResponseMalformed, "meter checkpoint name is required")
|
||||
}
|
||||
|
||||
checkpointString := checkpointValue.Get("checkpoint").String()
|
||||
if checkpointString == "" {
|
||||
return nil, errors.Newf(errors.TypeInternal, zeus.ErrCodeResponseMalformed, "meter checkpoint is required for %q", name)
|
||||
}
|
||||
|
||||
checkpoint, err := time.Parse("2006-01-02", checkpointString)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, zeus.ErrCodeResponseMalformed, "parse meter checkpoint %q for %q", checkpointString, name)
|
||||
}
|
||||
|
||||
checkpoints = append(checkpoints, zeustypes.MeterCheckpoint{
|
||||
Name: name,
|
||||
Checkpoint: checkpoint,
|
||||
})
|
||||
}
|
||||
|
||||
return checkpoints, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) PutProfile(ctx context.Context, key string, profile *zeustypes.PostableProfile) error {
|
||||
body, err := json.Marshal(profile)
|
||||
if err != nil {
|
||||
@@ -251,21 +185,12 @@ func (provider *Provider) PutHost(ctx context.Context, key string, host *zeustyp
|
||||
}
|
||||
|
||||
func (provider *Provider) do(ctx context.Context, url *url.URL, method string, key string, requestBody []byte) ([]byte, error) {
|
||||
return provider.doWithHeaders(ctx, url, method, key, requestBody, nil)
|
||||
}
|
||||
|
||||
func (provider *Provider) doWithHeaders(ctx context.Context, url *url.URL, method string, key string, requestBody []byte, extraHeaders http.Header) ([]byte, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, method, url.String(), bytes.NewBuffer(requestBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("X-Signoz-Cloud-Api-Key", key)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
for k, vs := range extraHeaders {
|
||||
for _, v := range vs {
|
||||
request.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
response, err := provider.httpClient.Do(request)
|
||||
if err != nil {
|
||||
|
||||
@@ -19,8 +19,8 @@ import type {
|
||||
|
||||
import type {
|
||||
AuthtypesPostableAuthDomainDTO,
|
||||
AuthtypesUpdateableAuthDomainDTO,
|
||||
CreateAuthDomain200,
|
||||
AuthtypesUpdatableAuthDomainDTO,
|
||||
CreateAuthDomain201,
|
||||
DeleteAuthDomainPathParameters,
|
||||
GetAuthDomain200,
|
||||
GetAuthDomainPathParameters,
|
||||
@@ -126,7 +126,7 @@ export const createAuthDomain = (
|
||||
authtypesPostableAuthDomainDTO: BodyType<AuthtypesPostableAuthDomainDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateAuthDomain200>({
|
||||
return GeneratedAPIInstance<CreateAuthDomain201>({
|
||||
url: `/api/v1/domains`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -388,13 +388,13 @@ export const invalidateGetAuthDomain = async (
|
||||
*/
|
||||
export const updateAuthDomain = (
|
||||
{ id }: UpdateAuthDomainPathParameters,
|
||||
authtypesUpdateableAuthDomainDTO: BodyType<AuthtypesUpdateableAuthDomainDTO>,
|
||||
authtypesUpdatableAuthDomainDTO: BodyType<AuthtypesUpdatableAuthDomainDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/domains/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: authtypesUpdateableAuthDomainDTO,
|
||||
data: authtypesUpdatableAuthDomainDTO,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -407,7 +407,7 @@ export const getUpdateAuthDomainMutationOptions = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateAuthDomainPathParameters;
|
||||
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
|
||||
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
@@ -416,7 +416,7 @@ export const getUpdateAuthDomainMutationOptions = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateAuthDomainPathParameters;
|
||||
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
|
||||
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
@@ -433,7 +433,7 @@ export const getUpdateAuthDomainMutationOptions = <
|
||||
Awaited<ReturnType<typeof updateAuthDomain>>,
|
||||
{
|
||||
pathParams: UpdateAuthDomainPathParameters;
|
||||
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
|
||||
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
@@ -448,7 +448,7 @@ export type UpdateAuthDomainMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateAuthDomain>>
|
||||
>;
|
||||
export type UpdateAuthDomainMutationBody =
|
||||
BodyType<AuthtypesUpdateableAuthDomainDTO>;
|
||||
BodyType<AuthtypesUpdatableAuthDomainDTO>;
|
||||
export type UpdateAuthDomainMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
@@ -463,7 +463,7 @@ export const useUpdateAuthDomain = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateAuthDomainPathParameters;
|
||||
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
|
||||
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
@@ -472,7 +472,7 @@ export const useUpdateAuthDomain = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateAuthDomainPathParameters;
|
||||
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
|
||||
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
@@ -1641,109 +1641,32 @@ export interface AuthtypesCallbackAuthNSupportDTO {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export type AuthtypesGettableAuthDomainDTO =
|
||||
| (AuthtypesSamlConfigDTO & {
|
||||
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
googleAuthConfig?: AuthtypesGoogleConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
oidcConfig?: AuthtypesOIDCConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId?: string;
|
||||
roleMapping?: AuthtypesRoleMappingDTO;
|
||||
samlConfig?: AuthtypesSamlConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
ssoEnabled?: boolean;
|
||||
ssoType?: AuthtypesAuthNProviderDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
})
|
||||
| (AuthtypesGoogleConfigDTO & {
|
||||
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
googleAuthConfig?: AuthtypesGoogleConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
oidcConfig?: AuthtypesOIDCConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId?: string;
|
||||
roleMapping?: AuthtypesRoleMappingDTO;
|
||||
samlConfig?: AuthtypesSamlConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
ssoEnabled?: boolean;
|
||||
ssoType?: AuthtypesAuthNProviderDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
})
|
||||
| (AuthtypesOIDCConfigDTO & {
|
||||
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
googleAuthConfig?: AuthtypesGoogleConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
oidcConfig?: AuthtypesOIDCConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId?: string;
|
||||
roleMapping?: AuthtypesRoleMappingDTO;
|
||||
samlConfig?: AuthtypesSamlConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
ssoEnabled?: boolean;
|
||||
ssoType?: AuthtypesAuthNProviderDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
});
|
||||
export interface AuthtypesGettableAuthDomainDTO {
|
||||
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface AuthtypesGettableObjectsDTO {
|
||||
resource: AuthtypesResourceDTO;
|
||||
@@ -2067,7 +1990,7 @@ export interface AuthtypesTransactionDTO {
|
||||
relation: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesUpdateableAuthDomainDTO {
|
||||
export interface AuthtypesUpdatableAuthDomainDTO {
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
}
|
||||
|
||||
@@ -8432,8 +8355,8 @@ export type ListAuthDomains200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateAuthDomain200 = {
|
||||
data: AuthtypesGettableAuthDomainDTO;
|
||||
export type CreateAuthDomain201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useStore } from 'zustand';
|
||||
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
getUserExpressionFromCombined,
|
||||
} from '../utils';
|
||||
import { QuerySearchV2Context } from './context';
|
||||
import type { QuerySearchV2ContextValue } from './QuerySearchV2.store';
|
||||
import { createExpressionStore } from './QuerySearchV2.store';
|
||||
|
||||
export interface QuerySearchV2ProviderProps {
|
||||
queryParamKey: string;
|
||||
initialExpression?: string;
|
||||
/**
|
||||
* @default false
|
||||
*/
|
||||
persistOnUnmount?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider component that creates a scoped zustand store and exposes
|
||||
* expression state to children via context.
|
||||
*/
|
||||
export function QuerySearchV2Provider({
|
||||
initialExpression = '',
|
||||
persistOnUnmount = false,
|
||||
queryParamKey,
|
||||
children,
|
||||
}: QuerySearchV2ProviderProps): JSX.Element {
|
||||
const storeRef = useRef(createExpressionStore());
|
||||
const store = storeRef.current;
|
||||
|
||||
const [urlExpression, setUrlExpression] = useQueryState(
|
||||
queryParamKey,
|
||||
parseAsString,
|
||||
);
|
||||
|
||||
const committedExpression = useStore(store, (s) => s.committedExpression);
|
||||
const setInputExpression = useStore(store, (s) => s.setInputExpression);
|
||||
const commitExpression = useStore(store, (s) => s.commitExpression);
|
||||
const initializeFromUrl = useStore(store, (s) => s.initializeFromUrl);
|
||||
const resetExpression = useStore(store, (s) => s.resetExpression);
|
||||
|
||||
const isInitialized = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!isInitialized.current && urlExpression) {
|
||||
const cleanedExpression = getUserExpressionFromCombined(
|
||||
initialExpression,
|
||||
urlExpression,
|
||||
);
|
||||
initializeFromUrl(cleanedExpression);
|
||||
isInitialized.current = true;
|
||||
}
|
||||
}, [urlExpression, initialExpression, initializeFromUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized.current || !urlExpression) {
|
||||
setUrlExpression(committedExpression || null);
|
||||
}
|
||||
}, [committedExpression, setUrlExpression, urlExpression]);
|
||||
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
if (!persistOnUnmount) {
|
||||
setUrlExpression(null);
|
||||
resetExpression();
|
||||
}
|
||||
};
|
||||
}, [persistOnUnmount, setUrlExpression, resetExpression]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(expression: string): void => {
|
||||
const userOnly = getUserExpressionFromCombined(
|
||||
initialExpression,
|
||||
expression,
|
||||
);
|
||||
setInputExpression(userOnly);
|
||||
},
|
||||
[initialExpression, setInputExpression],
|
||||
);
|
||||
|
||||
const handleRun = useCallback(
|
||||
(expression: string): void => {
|
||||
const userOnly = getUserExpressionFromCombined(
|
||||
initialExpression,
|
||||
expression,
|
||||
);
|
||||
commitExpression(userOnly);
|
||||
},
|
||||
[initialExpression, commitExpression],
|
||||
);
|
||||
|
||||
const combinedExpression = useMemo(
|
||||
() => combineInitialAndUserExpression(initialExpression, committedExpression),
|
||||
[initialExpression, committedExpression],
|
||||
);
|
||||
|
||||
const contextValue = useMemo<QuerySearchV2ContextValue>(
|
||||
() => ({
|
||||
expression: combinedExpression,
|
||||
userExpression: committedExpression,
|
||||
initialExpression,
|
||||
querySearchProps: {
|
||||
initialExpression: initialExpression.trim() ? initialExpression : undefined,
|
||||
onChange: handleChange,
|
||||
onRun: handleRun,
|
||||
},
|
||||
}),
|
||||
[
|
||||
combinedExpression,
|
||||
committedExpression,
|
||||
initialExpression,
|
||||
handleChange,
|
||||
handleRun,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<QuerySearchV2Context.Provider value={contextValue}>
|
||||
{children}
|
||||
</QuerySearchV2Context.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createStore, StoreApi } from 'zustand';
|
||||
|
||||
export type QuerySearchV2Store = {
|
||||
/**
|
||||
* User-typed expression (local state, updates on typing)
|
||||
*/
|
||||
inputExpression: string;
|
||||
/**
|
||||
* Committed expression (synced to URL, updates on submit)
|
||||
*/
|
||||
committedExpression: string;
|
||||
setInputExpression: (expression: string) => void;
|
||||
commitExpression: (expression: string) => void;
|
||||
resetExpression: () => void;
|
||||
initializeFromUrl: (urlExpression: string) => void;
|
||||
};
|
||||
|
||||
export interface QuerySearchProps {
|
||||
initialExpression: string | undefined;
|
||||
onChange: (expression: string) => void;
|
||||
onRun: (expression: string) => void;
|
||||
}
|
||||
|
||||
export interface QuerySearchV2ContextValue {
|
||||
/**
|
||||
* Combined expression: "initialExpression AND (userExpression)"
|
||||
*/
|
||||
expression: string;
|
||||
userExpression: string;
|
||||
initialExpression: string;
|
||||
querySearchProps: QuerySearchProps;
|
||||
}
|
||||
|
||||
export function createExpressionStore(): StoreApi<QuerySearchV2Store> {
|
||||
return createStore<QuerySearchV2Store>((set) => ({
|
||||
inputExpression: '',
|
||||
committedExpression: '',
|
||||
setInputExpression: (expression: string): void => {
|
||||
set({ inputExpression: expression });
|
||||
},
|
||||
commitExpression: (expression: string): void => {
|
||||
set({
|
||||
inputExpression: expression,
|
||||
committedExpression: expression,
|
||||
});
|
||||
},
|
||||
resetExpression: (): void => {
|
||||
set({
|
||||
inputExpression: '',
|
||||
committedExpression: '',
|
||||
});
|
||||
},
|
||||
initializeFromUrl: (urlExpression: string): void => {
|
||||
set({
|
||||
inputExpression: urlExpression,
|
||||
committedExpression: urlExpression,
|
||||
});
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useQuerySearchV2Context } from '../context';
|
||||
import {
|
||||
QuerySearchV2Provider,
|
||||
QuerySearchV2ProviderProps,
|
||||
} from '../QuerySearchV2.provider';
|
||||
|
||||
const mockSetQueryState = jest.fn();
|
||||
let mockUrlValue: string | null = null;
|
||||
|
||||
jest.mock('nuqs', () => ({
|
||||
parseAsString: {},
|
||||
useQueryState: jest.fn(() => [mockUrlValue, mockSetQueryState]),
|
||||
}));
|
||||
|
||||
function createWrapper(
|
||||
props: Partial<QuerySearchV2ProviderProps> = {},
|
||||
): ({ children }: { children: ReactNode }) => JSX.Element {
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<QuerySearchV2Provider queryParamKey="testExpression" {...props}>
|
||||
{children}
|
||||
</QuerySearchV2Provider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe('QuerySearchExpressionProvider', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUrlValue = null;
|
||||
});
|
||||
|
||||
it('should provide initial context values', () => {
|
||||
const { result } = renderHook(() => useQuerySearchV2Context(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.expression).toBe('');
|
||||
expect(result.current.userExpression).toBe('');
|
||||
expect(result.current.initialExpression).toBe('');
|
||||
});
|
||||
|
||||
it('should combine initialExpression with userExpression', () => {
|
||||
const { result } = renderHook(() => useQuerySearchV2Context(), {
|
||||
wrapper: createWrapper({ initialExpression: 'k8s.pod.name = "my-pod"' }),
|
||||
});
|
||||
|
||||
expect(result.current.expression).toBe('k8s.pod.name = "my-pod"');
|
||||
expect(result.current.initialExpression).toBe('k8s.pod.name = "my-pod"');
|
||||
|
||||
act(() => {
|
||||
result.current.querySearchProps.onChange('service = "api"');
|
||||
});
|
||||
act(() => {
|
||||
result.current.querySearchProps.onRun('service = "api"');
|
||||
});
|
||||
|
||||
expect(result.current.expression).toBe(
|
||||
'k8s.pod.name = "my-pod" AND (service = "api")',
|
||||
);
|
||||
expect(result.current.userExpression).toBe('service = "api"');
|
||||
});
|
||||
|
||||
it('should provide querySearchProps with correct callbacks', () => {
|
||||
const { result } = renderHook(() => useQuerySearchV2Context(), {
|
||||
wrapper: createWrapper({ initialExpression: 'initial' }),
|
||||
});
|
||||
|
||||
expect(result.current.querySearchProps.initialExpression).toBe('initial');
|
||||
expect(typeof result.current.querySearchProps.onChange).toBe('function');
|
||||
expect(typeof result.current.querySearchProps.onRun).toBe('function');
|
||||
});
|
||||
|
||||
it('should initialize from URL value on mount', () => {
|
||||
mockUrlValue = 'status = 500';
|
||||
|
||||
const { result } = renderHook(() => useQuerySearchV2Context(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.userExpression).toBe('status = 500');
|
||||
expect(result.current.expression).toBe('status = 500');
|
||||
});
|
||||
|
||||
it('should throw error when used outside provider', () => {
|
||||
expect(() => {
|
||||
renderHook(() => useQuerySearchV2Context());
|
||||
}).toThrow(
|
||||
'useQuerySearchV2Context must be used within a QuerySearchV2Provider',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createExpressionStore } from '../QuerySearchV2.store';
|
||||
|
||||
describe('createExpressionStore', () => {
|
||||
it('should create a store with initial state', () => {
|
||||
const store = createExpressionStore();
|
||||
const state = store.getState();
|
||||
|
||||
expect(state.inputExpression).toBe('');
|
||||
expect(state.committedExpression).toBe('');
|
||||
});
|
||||
|
||||
it('should update inputExpression via setInputExpression', () => {
|
||||
const store = createExpressionStore();
|
||||
|
||||
store.getState().setInputExpression('service.name = "api"');
|
||||
|
||||
expect(store.getState().inputExpression).toBe('service.name = "api"');
|
||||
expect(store.getState().committedExpression).toBe('');
|
||||
});
|
||||
|
||||
it('should update both expressions via commitExpression', () => {
|
||||
const store = createExpressionStore();
|
||||
|
||||
store.getState().setInputExpression('service.name = "api"');
|
||||
store.getState().commitExpression('service.name = "api"');
|
||||
|
||||
expect(store.getState().inputExpression).toBe('service.name = "api"');
|
||||
expect(store.getState().committedExpression).toBe('service.name = "api"');
|
||||
});
|
||||
|
||||
it('should reset all state via resetExpression', () => {
|
||||
const store = createExpressionStore();
|
||||
|
||||
store.getState().setInputExpression('service.name = "api"');
|
||||
store.getState().commitExpression('service.name = "api"');
|
||||
store.getState().resetExpression();
|
||||
|
||||
expect(store.getState().inputExpression).toBe('');
|
||||
expect(store.getState().committedExpression).toBe('');
|
||||
});
|
||||
|
||||
it('should initialize from URL value', () => {
|
||||
const store = createExpressionStore();
|
||||
|
||||
store.getState().initializeFromUrl('status = 500');
|
||||
|
||||
expect(store.getState().inputExpression).toBe('status = 500');
|
||||
expect(store.getState().committedExpression).toBe('status = 500');
|
||||
});
|
||||
|
||||
it('should create isolated store instances', () => {
|
||||
const store1 = createExpressionStore();
|
||||
const store2 = createExpressionStore();
|
||||
|
||||
store1.getState().setInputExpression('expr1');
|
||||
store2.getState().setInputExpression('expr2');
|
||||
|
||||
expect(store1.getState().inputExpression).toBe('expr1');
|
||||
expect(store2.getState().inputExpression).toBe('expr2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
// eslint-disable-next-line no-restricted-imports -- React Context required for scoped store pattern
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { QuerySearchV2ContextValue } from './QuerySearchV2.store';
|
||||
|
||||
export const QuerySearchV2Context =
|
||||
createContext<QuerySearchV2ContextValue | null>(null);
|
||||
|
||||
export function useQuerySearchV2Context(): QuerySearchV2ContextValue {
|
||||
const context = useContext(QuerySearchV2Context);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useQuerySearchV2Context must be used within a QuerySearchV2Provider',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { useQuerySearchV2Context } from './context';
|
||||
export type { QuerySearchV2ProviderProps } from './QuerySearchV2.provider';
|
||||
export { QuerySearchV2Provider } from './QuerySearchV2.provider';
|
||||
export type {
|
||||
QuerySearchProps,
|
||||
QuerySearchV2ContextValue,
|
||||
QuerySearchV2Store,
|
||||
} from './QuerySearchV2.store';
|
||||
@@ -19,6 +19,13 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.query-search-initial-scope-label {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 10px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.query-where-clause-editor {
|
||||
flex: 1;
|
||||
min-width: 400px;
|
||||
@@ -53,6 +60,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.hasInitialExpression .cm-editor .cm-content {
|
||||
padding-left: 22px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
@@ -68,7 +79,6 @@
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
padding: 0px !important;
|
||||
background-color: var(--l1-background) !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--l1-border);
|
||||
|
||||
@@ -30,7 +30,7 @@ import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariabl
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { debounce, isNull } from 'lodash-es';
|
||||
import { Info, TriangleAlert } from 'lucide-react';
|
||||
import { Filter, Info, TriangleAlert } from 'lucide-react';
|
||||
import {
|
||||
IDetailedError,
|
||||
IQueryContext,
|
||||
@@ -47,6 +47,7 @@ import { validateQuery } from 'utils/queryValidationUtils';
|
||||
import { unquote } from 'utils/stringUtils';
|
||||
|
||||
import { queryExamples } from './constants';
|
||||
import { combineInitialAndUserExpression } from './utils';
|
||||
|
||||
import './QuerySearch.styles.scss';
|
||||
|
||||
@@ -85,6 +86,8 @@ interface QuerySearchProps {
|
||||
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
|
||||
onRun?: (query: string) => void;
|
||||
showFilterSuggestionsWithoutMetric?: boolean;
|
||||
/** When set, the editor shows only the user expression; API/filter uses `initial AND (user)`. */
|
||||
initialExpression?: string;
|
||||
}
|
||||
|
||||
function QuerySearch({
|
||||
@@ -96,6 +99,7 @@ function QuerySearch({
|
||||
signalSource,
|
||||
hardcodedAttributeKeys,
|
||||
showFilterSuggestionsWithoutMetric,
|
||||
initialExpression,
|
||||
}: QuerySearchProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
|
||||
@@ -112,18 +116,26 @@ function QuerySearch({
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
|
||||
const handleQueryValidation = useCallback((newExpression: string): void => {
|
||||
try {
|
||||
const validationResponse = validateQuery(newExpression);
|
||||
setValidation(validationResponse);
|
||||
} catch (error) {
|
||||
setValidation({
|
||||
isValid: false,
|
||||
message: 'Failed to process query',
|
||||
errors: [error as IDetailedError],
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const isScopedFilter = initialExpression !== undefined;
|
||||
|
||||
const validateExpressionForEditor = useCallback(
|
||||
(editorDoc: string): void => {
|
||||
const toValidate = isScopedFilter
|
||||
? combineInitialAndUserExpression(initialExpression ?? '', editorDoc)
|
||||
: editorDoc;
|
||||
try {
|
||||
const validationResponse = validateQuery(toValidate);
|
||||
setValidation(validationResponse);
|
||||
} catch (error) {
|
||||
setValidation({
|
||||
isValid: false,
|
||||
message: 'Failed to process query',
|
||||
errors: [error as IDetailedError],
|
||||
});
|
||||
}
|
||||
},
|
||||
[initialExpression, isScopedFilter],
|
||||
);
|
||||
|
||||
const getCurrentExpression = useCallback(
|
||||
(): string => editorRef.current?.state.doc.toString() || '',
|
||||
@@ -165,6 +177,8 @@ function QuerySearch({
|
||||
setIsEditorReady(true);
|
||||
}, []);
|
||||
|
||||
const prevQueryDataExpressionRef = useRef<string | undefined>();
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (!isEditorReady) {
|
||||
@@ -173,13 +187,22 @@ function QuerySearch({
|
||||
|
||||
const newExpression = queryData.filter?.expression || '';
|
||||
const currentExpression = getCurrentExpression();
|
||||
const prevExpression = prevQueryDataExpressionRef.current;
|
||||
|
||||
// Do not update codemirror editor if the expression is the same
|
||||
if (newExpression !== currentExpression && !isFocused) {
|
||||
// Only sync editor when queryData.filter?.expression actually changed from external source
|
||||
// Not when focus changed (which would reset uncommitted user input)
|
||||
const queryDataExpressionChanged = prevExpression !== newExpression;
|
||||
prevQueryDataExpressionRef.current = newExpression;
|
||||
|
||||
if (
|
||||
queryDataExpressionChanged &&
|
||||
newExpression !== currentExpression &&
|
||||
!isFocused
|
||||
) {
|
||||
updateEditorValue(newExpression, { skipOnChange: true });
|
||||
if (newExpression) {
|
||||
handleQueryValidation(newExpression);
|
||||
}
|
||||
}
|
||||
if (!isFocused) {
|
||||
validateExpressionForEditor(currentExpression);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -284,7 +307,7 @@ function QuerySearch({
|
||||
}
|
||||
});
|
||||
}
|
||||
setKeySuggestions(Array.from(merged.values()));
|
||||
setKeySuggestions([...merged.values()]);
|
||||
|
||||
// Force reopen the completion if editor is available and focused
|
||||
if (editorRef.current) {
|
||||
@@ -337,7 +360,7 @@ function QuerySearch({
|
||||
// If value contains single quotes, escape them and wrap in single quotes
|
||||
if (value.includes("'")) {
|
||||
// Replace single quotes with escaped single quotes
|
||||
const escapedValue = value.replace(/'/g, "\\'");
|
||||
const escapedValue = value.replaceAll(/'/g, "\\'");
|
||||
return `'${escapedValue}'`;
|
||||
}
|
||||
|
||||
@@ -614,7 +637,7 @@ function QuerySearch({
|
||||
|
||||
const handleBlur = (): void => {
|
||||
const currentExpression = getCurrentExpression();
|
||||
handleQueryValidation(currentExpression);
|
||||
validateExpressionForEditor(currentExpression);
|
||||
setIsFocused(false);
|
||||
};
|
||||
|
||||
@@ -632,7 +655,6 @@ function QuerySearch({
|
||||
);
|
||||
|
||||
const handleExampleClick = (exampleQuery: string): void => {
|
||||
// If there's an existing query, append the example with AND
|
||||
const currentExpression = getCurrentExpression();
|
||||
const newExpression = currentExpression
|
||||
? `${currentExpression} AND ${exampleQuery}`
|
||||
@@ -897,12 +919,12 @@ function QuerySearch({
|
||||
|
||||
// If we have previous pairs, we can prioritize keys that haven't been used yet
|
||||
if (queryContext.queryPairs && queryContext.queryPairs.length > 0) {
|
||||
const usedKeys = queryContext.queryPairs.map((pair) => pair.key);
|
||||
const usedKeys = new Set(queryContext.queryPairs.map((pair) => pair.key));
|
||||
|
||||
// Add boost to unused keys to prioritize them
|
||||
options = options.map((option) => ({
|
||||
...option,
|
||||
boost: usedKeys.includes(option.label) ? -10 : 10,
|
||||
boost: usedKeys.has(option.label) ? -10 : 10,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1317,6 +1339,19 @@ function QuerySearch({
|
||||
)}
|
||||
|
||||
<div className="query-where-clause-editor-container">
|
||||
{isScopedFilter ? (
|
||||
<Tooltip title={initialExpression || ''} placement="left">
|
||||
<div className="query-search-initial-scope-label">
|
||||
<Filter
|
||||
size={14}
|
||||
style={{
|
||||
opacity: 0.9,
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip
|
||||
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
|
||||
placement="left"
|
||||
@@ -1356,6 +1391,7 @@ function QuerySearch({
|
||||
className={cx('query-where-clause-editor', {
|
||||
isValid: validation.isValid === true,
|
||||
hasErrors: validation.errors.length > 0,
|
||||
hasInitialExpression: isScopedFilter,
|
||||
})}
|
||||
extensions={[
|
||||
autocompletion({
|
||||
@@ -1390,7 +1426,12 @@ function QuerySearch({
|
||||
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
|
||||
run: (): boolean => {
|
||||
if (onRun && typeof onRun === 'function') {
|
||||
onRun(getCurrentExpression());
|
||||
const user = getCurrentExpression();
|
||||
onRun(
|
||||
isScopedFilter
|
||||
? combineInitialAndUserExpression(initialExpression ?? '', user)
|
||||
: user,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
@@ -1555,6 +1596,7 @@ QuerySearch.defaultProps = {
|
||||
placeholder:
|
||||
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
|
||||
showFilterSuggestionsWithoutMetric: false,
|
||||
initialExpression: undefined,
|
||||
};
|
||||
|
||||
export default QuerySearch;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
getUserExpressionFromCombined,
|
||||
} from '../utils';
|
||||
|
||||
describe('entityLogsExpression', () => {
|
||||
describe('combineInitialAndUserExpression', () => {
|
||||
it('returns user when initial is empty', () => {
|
||||
expect(combineInitialAndUserExpression('', 'body contains error')).toBe(
|
||||
'body contains error',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns initial when user is empty', () => {
|
||||
expect(combineInitialAndUserExpression('k8s.pod.name = "x"', '')).toBe(
|
||||
'k8s.pod.name = "x"',
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps user in parentheses with AND', () => {
|
||||
expect(
|
||||
combineInitialAndUserExpression('k8s.pod.name = "x"', 'body = "a"'),
|
||||
).toBe('k8s.pod.name = "x" AND (body = "a")');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserExpressionFromCombined', () => {
|
||||
it('returns empty when combined equals initial', () => {
|
||||
expect(
|
||||
getUserExpressionFromCombined('k8s.pod.name = "x"', 'k8s.pod.name = "x"'),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('extracts user from wrapped form', () => {
|
||||
expect(
|
||||
getUserExpressionFromCombined(
|
||||
'k8s.pod.name = "x"',
|
||||
'k8s.pod.name = "x" AND (body = "a")',
|
||||
),
|
||||
).toBe('body = "a"');
|
||||
});
|
||||
|
||||
it('extracts user from legacy AND without parens', () => {
|
||||
expect(
|
||||
getUserExpressionFromCombined(
|
||||
'k8s.pod.name = "x"',
|
||||
'k8s.pod.name = "x" AND body = "a"',
|
||||
),
|
||||
).toBe('body = "a"');
|
||||
});
|
||||
|
||||
it('returns full combined when initial is empty', () => {
|
||||
expect(getUserExpressionFromCombined('', 'service.name = "a"')).toBe(
|
||||
'service.name = "a"',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
export function combineInitialAndUserExpression(
|
||||
initial: string,
|
||||
user: string,
|
||||
): string {
|
||||
const i = initial.trim();
|
||||
const u = user.trim();
|
||||
if (!i) {
|
||||
return u;
|
||||
}
|
||||
if (!u) {
|
||||
return i;
|
||||
}
|
||||
return `${i} AND (${u})`;
|
||||
}
|
||||
|
||||
export function getUserExpressionFromCombined(
|
||||
initial: string,
|
||||
combined: string | null | undefined,
|
||||
): string {
|
||||
const i = initial.trim();
|
||||
const c = (combined ?? '').trim();
|
||||
if (!c) {
|
||||
return '';
|
||||
}
|
||||
if (!i) {
|
||||
return c;
|
||||
}
|
||||
if (c === i) {
|
||||
return '';
|
||||
}
|
||||
const wrappedPrefix = `${i} AND (`;
|
||||
if (c.startsWith(wrappedPrefix) && c.endsWith(')')) {
|
||||
return c.slice(wrappedPrefix.length, -1);
|
||||
}
|
||||
const plainPrefix = `${i} AND `;
|
||||
if (c.startsWith(plainPrefix)) {
|
||||
return c.slice(plainPrefix.length);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
14
frontend/src/components/QueryBuilderV2/index.ts
Normal file
14
frontend/src/components/QueryBuilderV2/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type {
|
||||
QuerySearchProps,
|
||||
QuerySearchV2ContextValue,
|
||||
QuerySearchV2ProviderProps,
|
||||
} from './QueryV2/QuerySearch/Provider';
|
||||
export {
|
||||
QuerySearchV2Provider,
|
||||
useQuerySearchV2Context,
|
||||
} from './QueryV2/QuerySearch/Provider';
|
||||
export { QueryBuilderV2 } from './QueryBuilderV2';
|
||||
export {
|
||||
QueryBuilderV2Provider,
|
||||
useQueryBuilderV2Context,
|
||||
} from './QueryBuilderV2Context';
|
||||
@@ -6,6 +6,7 @@ export enum Events {
|
||||
TOOLTIP_PINNED = 'TOOLTIP_PINNED',
|
||||
TOOLTIP_UNPINNED = 'TOOLTIP_UNPINNED',
|
||||
TOOLTIP_CONTENT_SCROLLED = 'TOOLTIP_CONTENT_SCROLLED',
|
||||
TOOLTIP_SYNC_MODE_CHANGED = 'TOOLTIP_SYNC_MODE_CHANGED',
|
||||
}
|
||||
|
||||
export enum InfraMonitoringEvents {
|
||||
|
||||
@@ -38,4 +38,5 @@ export enum LOCALSTORAGE {
|
||||
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
|
||||
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
|
||||
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('BillingContainer', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await expect(
|
||||
screen.findByText('Cancel Subscription', { selector: 'span' }),
|
||||
screen.findByText('Cancel your subscription', { selector: 'span' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('BillingContainer', () => {
|
||||
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
|
||||
|
||||
await expect(
|
||||
screen.findByText('Cancel Subscription', { selector: 'span' }),
|
||||
screen.findByText('Cancel your subscription', { selector: 'span' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -162,7 +162,7 @@ describe('BillingContainer', () => {
|
||||
it('should render when license is ACTIVATED and platform is CLOUD', async () => {
|
||||
render(<BillingContainer />);
|
||||
await expect(
|
||||
screen.findByText('Cancel Subscription', { selector: 'span' }),
|
||||
screen.findByText('Cancel your subscription', { selector: 'span' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -186,7 +186,7 @@ describe('BillingContainer', () => {
|
||||
);
|
||||
await screen.findByText('billing');
|
||||
expect(
|
||||
screen.queryByText('Cancel Subscription', { selector: 'span' }),
|
||||
screen.queryByText('Cancel your subscription', { selector: 'span' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('BillingContainer', () => {
|
||||
render(<BillingContainer />, {}, { appContextOverrides: overrides });
|
||||
await screen.findByText('billing');
|
||||
expect(
|
||||
screen.queryByText('Cancel Subscription', { selector: 'span' }),
|
||||
screen.queryByText('Cancel your subscription', { selector: 'span' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
.banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
padding: var(--padding-4);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--callout-error-border);
|
||||
background-color: var(--callout-error-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l2-background);
|
||||
margin: var(--spacing-4) 0 var(--spacing-12);
|
||||
}
|
||||
|
||||
@@ -15,21 +15,55 @@
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
line-height: var(--paragraph-base-500-line-height);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
color: var(--callout-error-icon);
|
||||
color: var(--l2-foreground);
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.dialogBody {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-relaxed);
|
||||
color: var(--l2-foreground);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.dialogDescription {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
color: var(--l2-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dialogConfirmLabel {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
color: var(--l2-foreground);
|
||||
margin: 0;
|
||||
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.cancelButton {
|
||||
background: var(--secondary-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import CancelSubscriptionBanner from './CancelSubscriptionBanner';
|
||||
|
||||
@@ -13,14 +13,16 @@ describe('CancelSubscriptionBanner', () => {
|
||||
it('renders banner with title and subtitle', () => {
|
||||
render(<CancelSubscriptionBanner />);
|
||||
expect(
|
||||
screen.getByText('Cancel Subscription', { selector: 'span' }),
|
||||
screen.getByText('Cancel your subscription', { selector: 'span' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Cancel your SigNoz subscription.'),
|
||||
screen.getByText(
|
||||
/When you cancel your SigNoz subscription, all your data will be deleted/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens dialog with correct content when Cancel Subscription is clicked', async () => {
|
||||
it('opens dialog with content when Cancel Subscription is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
@@ -30,17 +32,62 @@ describe('CancelSubscriptionBanner', () => {
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/reach out to our support team/i),
|
||||
screen.getByText(/Cancelling your subscription would stop your data/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Type/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /keep subscription/i }),
|
||||
screen.getByPlaceholderText(/Enter the word cancel/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /go back/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /contact support/i }),
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends mailto to cloud-support with correct subject on Contact Support', async () => {
|
||||
it('keeps Cancel subscription button disabled until "cancel" is typed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
|
||||
const confirmButton = screen.getByRole('button', {
|
||||
name: /cancel subscription/i,
|
||||
});
|
||||
expect(confirmButton).toBeDisabled();
|
||||
|
||||
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
|
||||
await user.type(input, 'canc');
|
||||
expect(confirmButton).toBeDisabled();
|
||||
|
||||
await user.type(input, 'el');
|
||||
expect(confirmButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it('closes dialog and resets input when Go back is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
|
||||
await user.type(input, 'cancel');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /go back/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
expect(screen.getByPlaceholderText(/Enter the word cancel/i)).toHaveValue('');
|
||||
});
|
||||
|
||||
it('sends mailto to cloud-support with correct subject after typing "cancel"', async () => {
|
||||
const realCreateElement = document.createElement.bind(document);
|
||||
const mockClick = jest.fn();
|
||||
const mockAnchor = { href: '', click: mockClick };
|
||||
@@ -57,7 +104,13 @@ describe('CancelSubscriptionBanner', () => {
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /contact support/i }));
|
||||
|
||||
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
|
||||
await user.type(input, 'cancel');
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
|
||||
expect(mockAnchor.href).toContain('mailto:cloud-support@signoz.io');
|
||||
expect(mockAnchor.href).toContain('Cancel%20My%20SigNoz%20Subscription');
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Button, DialogWrapper } from '@signozhq/ui';
|
||||
import { SolidInfoCircle, Undo2, X } from '@signozhq/icons';
|
||||
import { Button, DialogWrapper, Input } from '@signozhq/ui';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { pick } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
import styles from './CancelSubscriptionBanner.module.scss';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
|
||||
function CancelSubscriptionBanner(): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const { user, org } = useAppContext();
|
||||
|
||||
const handleOpenCancelDialog = (): void => {
|
||||
@@ -53,6 +55,12 @@ function CancelSubscriptionBanner(): JSX.Element {
|
||||
link.href = `mailto:cloud-support@signoz.io?subject=${subject}&body=${body}`;
|
||||
link.click();
|
||||
setOpen(false);
|
||||
setConfirmText('');
|
||||
};
|
||||
|
||||
const handleClose = (): void => {
|
||||
setOpen(false);
|
||||
setConfirmText('');
|
||||
};
|
||||
|
||||
const footer = (
|
||||
@@ -60,12 +68,19 @@ function CancelSubscriptionBanner(): JSX.Element {
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={(): void => setOpen(false)}
|
||||
prefix={<Undo2 size={14} />}
|
||||
onClick={handleClose}
|
||||
>
|
||||
Keep Subscription
|
||||
Go back
|
||||
</Button>
|
||||
<Button variant="solid" color="destructive" onClick={handleContactSupport}>
|
||||
Contact Support
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
prefix={<X size={14} />}
|
||||
disabled={confirmText !== 'cancel'}
|
||||
onClick={handleContactSupport}
|
||||
>
|
||||
Cancel subscription
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
@@ -74,30 +89,47 @@ function CancelSubscriptionBanner(): JSX.Element {
|
||||
<>
|
||||
<div className={styles.banner}>
|
||||
<div className={styles.info}>
|
||||
<span className={styles.title}>Cancel Subscription</span>
|
||||
<span className={styles.subtitle}>Cancel your SigNoz subscription.</span>
|
||||
<div className={styles.titleRow}>
|
||||
<SolidInfoCircle color={Color.BG_SAKURA_500} size={12} />
|
||||
<span className={styles.title}>Cancel your subscription</span>
|
||||
</div>
|
||||
<span className={styles.subtitle}>
|
||||
When you cancel your SigNoz subscription, all your data will be deleted
|
||||
immediately and removed from our servers.
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
color="secondary"
|
||||
prefix={<X size={12} />}
|
||||
onClick={handleOpenCancelDialog}
|
||||
className={styles.cancelButton}
|
||||
>
|
||||
Cancel Subscription
|
||||
</Button>
|
||||
</div>
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Cancel your subscription"
|
||||
onOpenChange={handleClose}
|
||||
title="Cancel your subscription?"
|
||||
width="narrow"
|
||||
showCloseButton={false}
|
||||
footer={footer}
|
||||
>
|
||||
<p className={styles.dialogBody}>
|
||||
To cancel your SigNoz subscription, please reach out to our support team.
|
||||
We'll be happy to assist you.
|
||||
</p>
|
||||
<div className={styles.dialogBody}>
|
||||
<p className={styles.dialogDescription}>
|
||||
Cancelling your subscription would stop your data from being ingested to
|
||||
SigNoz. All the data that has been already sent will also be deleted.
|
||||
</p>
|
||||
<p className={styles.dialogConfirmLabel}>
|
||||
Type <code>cancel</code> to confirm the cancellation.
|
||||
</p>
|
||||
<Input
|
||||
placeholder="Enter the word cancel..."
|
||||
value={confirmText}
|
||||
onChange={(e): void => setConfirmText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
.overviewContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overviewSettings {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.crossPanelSyncGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
& + & {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.crossPanelSyncInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncDescription {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.nameIconInput {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dashboardImageInput {
|
||||
:global(.ant-select-selector) {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l3-background) !important;
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&:global(.ant-select-dropdown) {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
:global(.ant-select-item) {
|
||||
padding: 0px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
:global(.ant-select-item-option-content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listItemImage {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardNameInput {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.dashboardName {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.descriptionTextArea {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.overviewSettingsFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: -webkit-fill-available;
|
||||
padding: 12px 16px 12px 0px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.unsaved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.unsavedDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--primary-background);
|
||||
box-shadow: 0px 0px 6px 0px
|
||||
color-mix(in srgb, var(--primary-background) 40%, transparent);
|
||||
}
|
||||
|
||||
.unsavedChanges {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.footerActionBtns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.discardBtn {
|
||||
margin: '16px 0';
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
margin: 0px !important;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
.overview-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.overview-settings {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
padding: 16px !important;
|
||||
|
||||
.name-icon-input {
|
||||
display: flex;
|
||||
.dashboard-image-input {
|
||||
.ant-select-selector {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
|
||||
.ant-select-selection-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.list-item-image {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-name-input {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-name {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
}
|
||||
|
||||
.description-text-area {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
.overview-settings-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: -webkit-fill-available;
|
||||
padding: 12px 16px 12px 0px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
|
||||
.unsaved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.unsaved-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--primary-background);
|
||||
box-shadow: 0px 0px 6px 0px
|
||||
color-mix(in srgb, var(--primary-background) 40%, transparent);
|
||||
}
|
||||
.unsaved-changes {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px; /* 171.429% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
.footer-action-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.discard-btn {
|
||||
margin: '16px 0';
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
margin: 0px !important;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-image-input {
|
||||
&.ant-select-dropdown {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
.ant-select-item {
|
||||
padding: 0px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.ant-select-item-option-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.list-item-image {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Col, Input, Select, Space, Typography } from 'antd';
|
||||
import { Col, Input, Radio, Select, Space, Typography } from 'antd';
|
||||
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddTags';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
|
||||
import styles from './GeneralSettings.module.scss';
|
||||
import { Button } from './styles';
|
||||
import { Base64Icons } from './utils';
|
||||
|
||||
import './GeneralSettings.styles.scss';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Events } from 'constants/events';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
@@ -19,6 +27,13 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
|
||||
const updateDashboardMutation = useUpdateDashboard();
|
||||
|
||||
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(
|
||||
dashboardData?.id,
|
||||
);
|
||||
|
||||
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
|
||||
useSyncTooltipFilterMode(dashboardData?.id);
|
||||
|
||||
const selectedData = dashboardData?.data;
|
||||
|
||||
const {
|
||||
@@ -100,8 +115,8 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overview-content">
|
||||
<Col className="overview-settings">
|
||||
<div className={styles.overviewContent}>
|
||||
<Col className={styles.overviewSettings}>
|
||||
<Space
|
||||
direction="vertical"
|
||||
style={{
|
||||
@@ -112,27 +127,29 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
|
||||
Dashboard Name
|
||||
</Typography>
|
||||
<section className="name-icon-input">
|
||||
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
|
||||
<section className={styles.nameIconInput}>
|
||||
<Select
|
||||
defaultActiveFirstOption
|
||||
data-testid="dashboard-image"
|
||||
suffixIcon={null}
|
||||
rootClassName="dashboard-image-input"
|
||||
rootClassName={styles.dashboardImageInput}
|
||||
value={updatedImage}
|
||||
onChange={(value: string): void => setUpdatedImage(value)}
|
||||
>
|
||||
{Base64Icons.map((icon) => (
|
||||
<Option value={icon} key={icon}>
|
||||
<img src={icon} alt="dashboard-icon" className="list-item-image" />
|
||||
<img
|
||||
src={icon}
|
||||
alt="dashboard-icon"
|
||||
className={styles.listItemImage}
|
||||
/>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
data-testid="dashboard-name"
|
||||
className="dashboard-name-input"
|
||||
className={styles.dashboardNameInput}
|
||||
value={updatedTitle}
|
||||
onChange={(e): void => setUpdatedTitle(e.target.value)}
|
||||
/>
|
||||
@@ -140,41 +157,92 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
|
||||
Description
|
||||
</Typography>
|
||||
<Typography className={styles.dashboardName}>Description</Typography>
|
||||
<Input.TextArea
|
||||
data-testid="dashboard-desc"
|
||||
rows={6}
|
||||
value={updatedDescription}
|
||||
className="description-text-area"
|
||||
className={styles.descriptionTextArea}
|
||||
onChange={(e): void => setUpdatedDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
|
||||
Tags
|
||||
</Typography>
|
||||
<Typography className={styles.dashboardName}>Tags</Typography>
|
||||
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
|
||||
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
|
||||
Cross-Panel Sync
|
||||
</Typography.Text>
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
Sync Mode
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.crossPanelSyncDescription}>
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={cursorSyncMode}
|
||||
onChange={(e): void => {
|
||||
setCursorSyncMode(e.target.value as DashboardCursorSync);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Crosshair}>
|
||||
Crosshair
|
||||
</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
{cursorSyncMode === DashboardCursorSync.Tooltip && (
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
Synced Tooltip Series
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.crossPanelSyncDescription}>
|
||||
Show only series that intersect on group-by, or every series with the
|
||||
matching ones highlighted
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={syncTooltipFilterMode}
|
||||
onChange={(e): void => {
|
||||
logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
mode: e.target.value,
|
||||
});
|
||||
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
|
||||
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
|
||||
Filtered
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
{numberOfUnsavedChanges > 0 && (
|
||||
<div className="overview-settings-footer">
|
||||
<div className="unsaved">
|
||||
<div className="unsaved-dot" />
|
||||
<Typography.Text className="unsaved-changes">
|
||||
<div className={styles.overviewSettingsFooter}>
|
||||
<div className={styles.unsaved}>
|
||||
<div className={styles.unsavedDot} />
|
||||
<Typography.Text className={styles.unsavedChanges}>
|
||||
{numberOfUnsavedChanges} unsaved change
|
||||
{numberOfUnsavedChanges > 1 && 's'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="footer-action-btns">
|
||||
<div className={styles.footerActionBtns}>
|
||||
<Button
|
||||
disabled={updateDashboardMutation.isLoading}
|
||||
icon={<X size={14} />}
|
||||
onClick={discardHandler}
|
||||
type="text"
|
||||
className="discard-btn"
|
||||
className={styles.discardBtn}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
@@ -188,7 +256,7 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
data-testid="save-dashboard-config"
|
||||
onClick={onSaveHandler}
|
||||
type="primary"
|
||||
className="save-btn"
|
||||
className={styles.saveBtn}
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
|
||||
@@ -33,11 +33,13 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
}
|
||||
const tooltipProps: BarTooltipProps = {
|
||||
...props,
|
||||
id: config.getId(),
|
||||
timezone: rest.timezone,
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
isStackedBarChart: isStackedBarChart,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
renderTooltipFooter: rest.renderTooltipFooter,
|
||||
};
|
||||
return <BarChartTooltip {...tooltipProps} />;
|
||||
},
|
||||
@@ -48,6 +50,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
rest.decimalPrecision,
|
||||
isStackedBarChart,
|
||||
rest.canPinTooltip,
|
||||
rest.renderTooltipFooter,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -29,11 +29,12 @@ export default function ChartWrapper({
|
||||
onClick,
|
||||
syncMode,
|
||||
syncKey,
|
||||
syncFilterMode,
|
||||
onDestroy = noop,
|
||||
children,
|
||||
layoutChildren,
|
||||
yAxisUnit,
|
||||
groupBy,
|
||||
groupByPerQuery,
|
||||
customTooltip,
|
||||
pinnedTooltipElement,
|
||||
'data-testid': testId,
|
||||
@@ -69,9 +70,10 @@ export default function ChartWrapper({
|
||||
const syncMetadata = useMemo(
|
||||
() => ({
|
||||
yAxisUnit,
|
||||
groupBy,
|
||||
groupByPerQuery,
|
||||
filterMode: syncFilterMode,
|
||||
}),
|
||||
[yAxisUnit, groupBy],
|
||||
[yAxisUnit, groupByPerQuery, syncFilterMode],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -24,13 +24,21 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
|
||||
}
|
||||
const tooltipProps: HistogramTooltipProps = {
|
||||
...props,
|
||||
id: rest.config.getId(),
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
renderTooltipFooter: rest.renderTooltipFooter,
|
||||
};
|
||||
return <HistogramTooltip {...tooltipProps} />;
|
||||
},
|
||||
[customTooltip, rest.yAxisUnit, rest.decimalPrecision, rest.canPinTooltip],
|
||||
[
|
||||
customTooltip,
|
||||
rest.yAxisUnit,
|
||||
rest.decimalPrecision,
|
||||
rest.canPinTooltip,
|
||||
rest.renderTooltipFooter,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { TimeSeriesChartProps } from '../types';
|
||||
|
||||
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
|
||||
const { children, customTooltip, pinnedTooltipElement, ...rest } = props;
|
||||
const { children, customTooltip, ...rest } = props;
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(props: TooltipRenderArgs): React.ReactNode => {
|
||||
@@ -18,10 +18,12 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
|
||||
}
|
||||
const tooltipProps: TimeSeriesTooltipProps = {
|
||||
...props,
|
||||
id: rest.config.getId(),
|
||||
timezone: rest.timezone,
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
renderTooltipFooter: rest.renderTooltipFooter,
|
||||
};
|
||||
return <TimeSeriesTooltip {...tooltipProps} />;
|
||||
},
|
||||
@@ -31,15 +33,12 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
|
||||
rest.yAxisUnit,
|
||||
rest.decimalPrecision,
|
||||
rest.canPinTooltip,
|
||||
rest.renderTooltipFooter,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartWrapper
|
||||
{...rest}
|
||||
customTooltip={renderTooltip}
|
||||
pinnedTooltipElement={pinnedTooltipElement}
|
||||
>
|
||||
<ChartWrapper {...rest} customTooltip={renderTooltip}>
|
||||
{children}
|
||||
</ChartWrapper>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { LegendConfig, TooltipRenderArgs } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
IRenderTooltipFooterArgs,
|
||||
LegendConfig,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
TooltipClickData,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
@@ -21,6 +26,7 @@ interface BaseChartProps {
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
@@ -30,6 +36,7 @@ interface UPlotBasedChartProps {
|
||||
legendConfig: LegendConfig;
|
||||
syncMode?: DashboardCursorSync;
|
||||
syncKey?: string;
|
||||
syncFilterMode?: SyncTooltipFilterMode;
|
||||
plotRef?: (plot: uPlot | null) => void;
|
||||
onDestroy?: (plot: uPlot) => void;
|
||||
children?: React.ReactNode;
|
||||
@@ -39,7 +46,7 @@ interface UPlotBasedChartProps {
|
||||
interface UPlotChartDataProps {
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
|
||||
}
|
||||
|
||||
export interface TimeSeriesChartProps
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
IRenderTooltipFooterArgs,
|
||||
LegendPosition,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import uPlot from 'uplot';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
@@ -14,7 +20,7 @@ import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
|
||||
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
|
||||
|
||||
import '../Panel.styles.scss';
|
||||
import get from 'lodash/get';
|
||||
import TooltipFooter from '../components/TooltipFooter';
|
||||
|
||||
function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
@@ -24,6 +30,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
onDragSelect,
|
||||
isFullViewMode,
|
||||
onToggleModelHandler,
|
||||
groupByPerQuery,
|
||||
} = props;
|
||||
const uPlotRef = useRef<uPlot | null>(null);
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
@@ -34,6 +41,10 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
|
||||
const [syncMode] = useDashboardCursorSyncMode(dashboardId, panelMode);
|
||||
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
@@ -75,6 +86,11 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
maxTimeScale,
|
||||
timezone,
|
||||
panelMode,
|
||||
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
|
||||
// Rebuild it on syncMode changes so the new chart instance starts from a
|
||||
// clean config — otherwise switching to "No Sync" would inherit stale sync
|
||||
// settings from the previous mode.
|
||||
syncMode,
|
||||
]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
@@ -114,14 +130,20 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
uPlotRef.current = plot;
|
||||
}, []);
|
||||
|
||||
const groupBy = useMemo(() => {
|
||||
return get(widget, 'query.builder.queryData[0].groupBy', []);
|
||||
}, [widget.query]);
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
|
||||
return (
|
||||
<TooltipFooter id={widget.id} isPinned={isPinned} dismiss={dismiss} />
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="panel-container" ref={graphRef}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<BarChart
|
||||
key={`${syncMode}-${syncFilterMode}`}
|
||||
config={config}
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
@@ -133,11 +155,14 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
layoutChildren={layoutChildren}
|
||||
groupBy={groupBy}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
isStackedBarChart={widget.stackedBarChart ?? false}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
timezone={timezone}
|
||||
syncMode={syncMode}
|
||||
syncFilterMode={syncFilterMode}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
>
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
IRenderTooltipFooterArgs,
|
||||
LegendPosition,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import Histogram from '../../charts/Histogram/Histogram';
|
||||
@@ -13,6 +16,7 @@ import {
|
||||
} from './utils';
|
||||
|
||||
import '../Panel.styles.scss';
|
||||
import TooltipFooter from '../components/TooltipFooter';
|
||||
|
||||
function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
@@ -75,6 +79,20 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
widget.mergeAllActiveQueries,
|
||||
]);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
|
||||
return (
|
||||
<TooltipFooter
|
||||
id={widget.id}
|
||||
isPinned={isPinned}
|
||||
dismiss={dismiss}
|
||||
canDrilldown={false}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="panel-container" ref={graphRef}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
@@ -97,6 +115,7 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
layoutChildren={layoutChildren}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
|
||||
import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
IRenderTooltipFooterArgs,
|
||||
LegendPosition,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { ContextMenu } from 'periscope/components/ContextMenu';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import uPlot from 'uplot';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
import get from 'lodash/get';
|
||||
|
||||
import { prepareChartData, prepareUPlotConfig } from '../TimeSeriesPanel/utils';
|
||||
|
||||
import '../Panel.styles.scss';
|
||||
import TooltipFooter from '../components/TooltipFooter';
|
||||
|
||||
function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
@@ -24,6 +30,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
onDragSelect,
|
||||
isFullViewMode,
|
||||
onToggleModelHandler,
|
||||
groupByPerQuery,
|
||||
} = props;
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
@@ -33,6 +40,10 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
|
||||
const [syncMode] = useDashboardCursorSyncMode(dashboardId, panelMode);
|
||||
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
@@ -81,6 +92,11 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
timezone,
|
||||
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
|
||||
// Rebuild it on syncMode changes so the new chart instance starts from a
|
||||
// clean config — otherwise switching to "No Sync" would inherit stale sync
|
||||
// settings from the previous mode.
|
||||
syncMode,
|
||||
]);
|
||||
|
||||
const layoutChildren = useMemo(() => {
|
||||
@@ -105,14 +121,20 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
widget.decimalPrecision,
|
||||
]);
|
||||
|
||||
const groupBy = useMemo(() => {
|
||||
return get(widget, 'query.builder.queryData[0].groupBy', []);
|
||||
}, [widget.query]);
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
|
||||
return (
|
||||
<TooltipFooter id={widget.id} isPinned={isPinned} dismiss={dismiss} />
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="panel-container" ref={graphRef}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<TimeSeries
|
||||
key={`${syncMode}-${syncFilterMode}`}
|
||||
config={config}
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
@@ -122,10 +144,13 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
groupBy={groupBy}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
syncMode={syncMode}
|
||||
syncFilterMode={syncFilterMode}
|
||||
layoutChildren={layoutChildren}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
>
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
|
||||
@@ -7,22 +7,25 @@ import Styles from './TooltipFooter.module.scss';
|
||||
import { MousePointerClick } from '@signozhq/icons';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Events } from 'constants/events';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
interface TooltipFooterProps {
|
||||
id: string;
|
||||
pinKey?: string;
|
||||
isPinned: boolean;
|
||||
canDrilldown?: boolean;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
export default function TooltipFooter({
|
||||
id,
|
||||
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
|
||||
isPinned,
|
||||
canDrilldown = true,
|
||||
dismiss,
|
||||
}: TooltipFooterProps): JSX.Element {
|
||||
const handleUnpinClick = (): void => {
|
||||
logEvent(Events.TOOLTIP_UNPINNED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
id: id,
|
||||
});
|
||||
dismiss();
|
||||
};
|
||||
@@ -43,12 +46,14 @@ export default function TooltipFooter({
|
||||
</div>
|
||||
) : (
|
||||
<div className={Styles.hintList}>
|
||||
<div className={Styles.hint} data-active="false">
|
||||
<Kbd>
|
||||
<MousePointerClick size={12} />
|
||||
</Kbd>
|
||||
<span>Click to drilldown</span>
|
||||
</div>
|
||||
{canDrilldown && (
|
||||
<div className={Styles.hint} data-active="false">
|
||||
<Kbd>
|
||||
<MousePointerClick size={12} />
|
||||
</Kbd>
|
||||
<span>Click to drilldown</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={Styles.hint} data-active="false">
|
||||
<span>Press</span>
|
||||
<Kbd>{pinKey.toUpperCase()}</Kbd>
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Events } from 'constants/events';
|
||||
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import TooltipFooter from '../TooltipFooter';
|
||||
|
||||
const mockLogEvent = jest.fn();
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]): unknown => mockLogEvent(...args),
|
||||
}));
|
||||
|
||||
describe('TooltipFooter', () => {
|
||||
const defaultProps = {
|
||||
id: 'panel-123',
|
||||
isPinned: false,
|
||||
dismiss: jest.fn(),
|
||||
};
|
||||
|
||||
describe('when not pinned', () => {
|
||||
it('renders the drilldown and pin hints by default', () => {
|
||||
render(<TooltipFooter {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Click to drilldown')).toBeInTheDocument();
|
||||
expect(screen.getByText('to pin the tooltip')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(DEFAULT_PIN_TOOLTIP_KEY.toUpperCase()),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the drilldown hint when canDrilldown is false', () => {
|
||||
render(<TooltipFooter {...defaultProps} canDrilldown={false} />);
|
||||
|
||||
expect(screen.queryByText('Click to drilldown')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('to pin the tooltip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a custom pin key in uppercase', () => {
|
||||
render(<TooltipFooter {...defaultProps} pinKey="x" />);
|
||||
|
||||
expect(screen.getByText('X')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the unpin button', () => {
|
||||
render(<TooltipFooter {...defaultProps} />);
|
||||
|
||||
expect(screen.queryByTestId('uplot-tooltip-unpin')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when pinned', () => {
|
||||
it('renders the unpin hint with pin key and Esc', () => {
|
||||
render(<TooltipFooter {...defaultProps} isPinned />);
|
||||
|
||||
expect(screen.getByText('to unpin')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(DEFAULT_PIN_TOOLTIP_KEY.toUpperCase()),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Esc')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the unpin button', () => {
|
||||
render(<TooltipFooter {...defaultProps} isPinned />);
|
||||
|
||||
expect(screen.getByTestId('uplot-tooltip-unpin')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /unpin tooltip/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the drilldown and pin-instruction hints', () => {
|
||||
render(<TooltipFooter {...defaultProps} isPinned />);
|
||||
|
||||
expect(screen.queryByText('Click to drilldown')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('to pin the tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls dismiss and logs the unpin event when the unpin button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const dismiss = jest.fn();
|
||||
|
||||
render(<TooltipFooter {...defaultProps} dismiss={dismiss} isPinned />);
|
||||
|
||||
await user.click(screen.getByTestId('uplot-tooltip-unpin'));
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
|
||||
id: 'panel-123',
|
||||
});
|
||||
expect(dismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [authnProvider, setAuthnProvider] = useState<
|
||||
AuthtypesAuthNProviderDTO | ''
|
||||
>(record?.ssoType || '');
|
||||
>(record?.config?.ssoType || '');
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { featureFlags } = useAppContext();
|
||||
|
||||
@@ -112,21 +112,26 @@ export function prepareInitialValues(
|
||||
};
|
||||
}
|
||||
|
||||
const config = record.config ?? {};
|
||||
return {
|
||||
...record,
|
||||
googleAuthConfig: record.googleAuthConfig
|
||||
name: record.name,
|
||||
ssoEnabled: config.ssoEnabled,
|
||||
ssoType: config.ssoType,
|
||||
samlConfig: config.samlConfig ?? undefined,
|
||||
oidcConfig: config.oidcConfig ?? undefined,
|
||||
googleAuthConfig: config.googleAuthConfig
|
||||
? {
|
||||
...record.googleAuthConfig,
|
||||
...config.googleAuthConfig,
|
||||
domainToAdminEmailList: convertDomainMappingsToList(
|
||||
record.googleAuthConfig.domainToAdminEmail,
|
||||
config.googleAuthConfig.domainToAdminEmail,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
roleMapping: record.roleMapping
|
||||
roleMapping: config.roleMapping
|
||||
? {
|
||||
...record.roleMapping,
|
||||
...config.roleMapping,
|
||||
groupMappingsList: convertGroupMappingsToList(
|
||||
record.roleMapping.groupMappings,
|
||||
config.roleMapping.groupMappings,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -43,11 +43,11 @@ function SSOEnforcementToggle({
|
||||
data: {
|
||||
config: {
|
||||
ssoEnabled: checked,
|
||||
ssoType: record.ssoType,
|
||||
googleAuthConfig: record.googleAuthConfig,
|
||||
oidcConfig: record.oidcConfig,
|
||||
samlConfig: record.samlConfig,
|
||||
roleMapping: record.roleMapping,
|
||||
ssoType: record.config?.ssoType,
|
||||
googleAuthConfig: record.config?.googleAuthConfig,
|
||||
oidcConfig: record.config?.oidcConfig,
|
||||
samlConfig: record.config?.samlConfig,
|
||||
roleMapping: record.config?.roleMapping,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -55,7 +55,10 @@ describe('SSOEnforcementToggle', () => {
|
||||
render(
|
||||
<SSOEnforcementToggle
|
||||
isDefaultChecked={false}
|
||||
record={{ ...mockGoogleAuthDomain, ssoEnabled: false }}
|
||||
record={{
|
||||
...mockGoogleAuthDomain,
|
||||
config: { ...mockGoogleAuthDomain.config, ssoEnabled: false },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -13,11 +13,13 @@ export const AUTH_DOMAINS_DELETE_ENDPOINT = '*/api/v1/domains/:id';
|
||||
export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
|
||||
id: 'domain-1',
|
||||
name: 'signoz.io',
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.google_auth,
|
||||
googleAuthConfig: {
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.google_auth,
|
||||
googleAuthConfig: {
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
},
|
||||
},
|
||||
authNProviderInfo: {
|
||||
relayStatePath: 'api/v1/sso/relay/domain-1',
|
||||
@@ -28,12 +30,14 @@ export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
|
||||
export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
|
||||
id: 'domain-2',
|
||||
name: 'example.com',
|
||||
ssoEnabled: false,
|
||||
ssoType: AuthtypesAuthNProviderDTO.saml,
|
||||
samlConfig: {
|
||||
samlIdp: 'https://idp.example.com/sso',
|
||||
samlEntity: 'urn:example:idp',
|
||||
samlCert: 'MOCK_CERTIFICATE',
|
||||
config: {
|
||||
ssoEnabled: false,
|
||||
ssoType: AuthtypesAuthNProviderDTO.saml,
|
||||
samlConfig: {
|
||||
samlIdp: 'https://idp.example.com/sso',
|
||||
samlEntity: 'urn:example:idp',
|
||||
samlCert: 'MOCK_CERTIFICATE',
|
||||
},
|
||||
},
|
||||
authNProviderInfo: {
|
||||
relayStatePath: 'api/v1/sso/relay/domain-2',
|
||||
@@ -44,12 +48,14 @@ export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
|
||||
export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
|
||||
id: 'domain-3',
|
||||
name: 'corp.io',
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.oidc,
|
||||
oidcConfig: {
|
||||
issuer: 'https://oidc.corp.io',
|
||||
clientId: 'oidc-client-id',
|
||||
clientSecret: 'oidc-client-secret',
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.oidc,
|
||||
oidcConfig: {
|
||||
issuer: 'https://oidc.corp.io',
|
||||
clientId: 'oidc-client-id',
|
||||
clientSecret: 'oidc-client-secret',
|
||||
},
|
||||
},
|
||||
authNProviderInfo: {
|
||||
relayStatePath: 'api/v1/sso/relay/domain-3',
|
||||
@@ -60,20 +66,22 @@ export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
|
||||
export const mockDomainWithRoleMapping: AuthtypesGettableAuthDomainDTO = {
|
||||
id: 'domain-4',
|
||||
name: 'enterprise.com',
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.saml,
|
||||
samlConfig: {
|
||||
samlIdp: 'https://idp.enterprise.com/sso',
|
||||
samlEntity: 'urn:enterprise:idp',
|
||||
samlCert: 'MOCK_CERTIFICATE',
|
||||
},
|
||||
roleMapping: {
|
||||
defaultRole: 'EDITOR',
|
||||
useRoleAttribute: false,
|
||||
groupMappings: {
|
||||
'admin-group': 'ADMIN',
|
||||
'dev-team': 'EDITOR',
|
||||
viewers: 'VIEWER',
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.saml,
|
||||
samlConfig: {
|
||||
samlIdp: 'https://idp.enterprise.com/sso',
|
||||
samlEntity: 'urn:enterprise:idp',
|
||||
samlCert: 'MOCK_CERTIFICATE',
|
||||
},
|
||||
roleMapping: {
|
||||
defaultRole: 'EDITOR',
|
||||
useRoleAttribute: false,
|
||||
groupMappings: {
|
||||
'admin-group': 'ADMIN',
|
||||
'dev-team': 'EDITOR',
|
||||
viewers: 'VIEWER',
|
||||
},
|
||||
},
|
||||
},
|
||||
authNProviderInfo: {
|
||||
@@ -86,16 +94,18 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
|
||||
{
|
||||
id: 'domain-5',
|
||||
name: 'direct-role.com',
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.oidc,
|
||||
oidcConfig: {
|
||||
issuer: 'https://oidc.direct-role.com',
|
||||
clientId: 'direct-role-client-id',
|
||||
clientSecret: 'direct-role-client-secret',
|
||||
},
|
||||
roleMapping: {
|
||||
defaultRole: 'VIEWER',
|
||||
useRoleAttribute: true,
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.oidc,
|
||||
oidcConfig: {
|
||||
issuer: 'https://oidc.direct-role.com',
|
||||
clientId: 'direct-role-client-id',
|
||||
clientSecret: 'direct-role-client-secret',
|
||||
},
|
||||
roleMapping: {
|
||||
defaultRole: 'VIEWER',
|
||||
useRoleAttribute: true,
|
||||
},
|
||||
},
|
||||
authNProviderInfo: {
|
||||
relayStatePath: 'api/v1/sso/relay/domain-5',
|
||||
@@ -106,20 +116,22 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
|
||||
export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
|
||||
id: 'domain-6',
|
||||
name: 'oidc-claims.com',
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.oidc,
|
||||
oidcConfig: {
|
||||
issuer: 'https://oidc.claims.com',
|
||||
issuerAlias: 'https://alias.claims.com',
|
||||
clientId: 'claims-client-id',
|
||||
clientSecret: 'claims-client-secret',
|
||||
insecureSkipEmailVerified: true,
|
||||
getUserInfo: true,
|
||||
claimMapping: {
|
||||
email: 'user_email',
|
||||
name: 'display_name',
|
||||
groups: 'user_groups',
|
||||
role: 'user_role',
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.oidc,
|
||||
oidcConfig: {
|
||||
issuer: 'https://oidc.claims.com',
|
||||
issuerAlias: 'https://alias.claims.com',
|
||||
clientId: 'claims-client-id',
|
||||
clientSecret: 'claims-client-secret',
|
||||
insecureSkipEmailVerified: true,
|
||||
getUserInfo: true,
|
||||
claimMapping: {
|
||||
email: 'user_email',
|
||||
name: 'display_name',
|
||||
groups: 'user_groups',
|
||||
role: 'user_role',
|
||||
},
|
||||
},
|
||||
},
|
||||
authNProviderInfo: {
|
||||
@@ -131,17 +143,19 @@ export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
|
||||
export const mockSamlWithAttributeMapping: AuthtypesGettableAuthDomainDTO = {
|
||||
id: 'domain-7',
|
||||
name: 'saml-attrs.com',
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.saml,
|
||||
samlConfig: {
|
||||
samlIdp: 'https://idp.saml-attrs.com/sso',
|
||||
samlEntity: 'urn:saml-attrs:idp',
|
||||
samlCert: 'MOCK_CERTIFICATE_ATTRS',
|
||||
insecureSkipAuthNRequestsSigned: true,
|
||||
attributeMapping: {
|
||||
name: 'user_display_name',
|
||||
groups: 'member_of',
|
||||
role: 'signoz_role',
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.saml,
|
||||
samlConfig: {
|
||||
samlIdp: 'https://idp.saml-attrs.com/sso',
|
||||
samlEntity: 'urn:saml-attrs:idp',
|
||||
samlCert: 'MOCK_CERTIFICATE_ATTRS',
|
||||
insecureSkipAuthNRequestsSigned: true,
|
||||
attributeMapping: {
|
||||
name: 'user_display_name',
|
||||
groups: 'member_of',
|
||||
role: 'signoz_role',
|
||||
},
|
||||
},
|
||||
},
|
||||
authNProviderInfo: {
|
||||
@@ -154,19 +168,21 @@ export const mockGoogleAuthWithWorkspaceGroups: AuthtypesGettableAuthDomainDTO =
|
||||
{
|
||||
id: 'domain-8',
|
||||
name: 'google-groups.com',
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.google_auth,
|
||||
googleAuthConfig: {
|
||||
clientId: 'google-groups-client-id',
|
||||
clientSecret: 'google-groups-client-secret',
|
||||
insecureSkipEmailVerified: false,
|
||||
fetchGroups: true,
|
||||
serviceAccountJson: '{"type": "service_account"}',
|
||||
domainToAdminEmail: {
|
||||
'google-groups.com': 'admin@google-groups.com',
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.google_auth,
|
||||
googleAuthConfig: {
|
||||
clientId: 'google-groups-client-id',
|
||||
clientSecret: 'google-groups-client-secret',
|
||||
insecureSkipEmailVerified: false,
|
||||
fetchGroups: true,
|
||||
serviceAccountJson: '{"type": "service_account"}',
|
||||
domainToAdminEmail: {
|
||||
'google-groups.com': 'admin@google-groups.com',
|
||||
},
|
||||
fetchTransitiveGroupMembership: true,
|
||||
allowedGroups: ['allowed-group-1', 'allowed-group-2'],
|
||||
},
|
||||
fetchTransitiveGroupMembership: true,
|
||||
allowedGroups: ['allowed-group-1', 'allowed-group-2'],
|
||||
},
|
||||
authNProviderInfo: {
|
||||
relayStatePath: 'api/v1/sso/relay/domain-8',
|
||||
@@ -191,15 +207,19 @@ export const mockSingleDomainResponse = {
|
||||
data: [mockGoogleAuthDomain],
|
||||
};
|
||||
|
||||
// Mock success responses
|
||||
// Mock success responses. CreateAuthDomain returns just an Identifiable
|
||||
// (the new domain ID); clients re-Read to get the full domain.
|
||||
export const mockCreateSuccessResponse = {
|
||||
status: 'success',
|
||||
data: mockGoogleAuthDomain,
|
||||
data: { id: mockGoogleAuthDomain.id },
|
||||
};
|
||||
|
||||
export const mockUpdateSuccessResponse = {
|
||||
status: 'success',
|
||||
data: { ...mockGoogleAuthDomain, ssoEnabled: false },
|
||||
data: {
|
||||
...mockGoogleAuthDomain,
|
||||
config: { ...mockGoogleAuthDomain.config, ssoEnabled: false },
|
||||
},
|
||||
};
|
||||
|
||||
export const mockDeleteSuccessResponse = {
|
||||
|
||||
@@ -158,7 +158,7 @@ function AuthDomain(): JSX.Element {
|
||||
onClick={(): void => setRecord(record)}
|
||||
variant="link"
|
||||
>
|
||||
Configure {SSOType.get(record.ssoType || '')}
|
||||
Configure {SSOType.get(record.config?.ssoType || '')}
|
||||
</Button>
|
||||
<Button
|
||||
className="auth-domain-list-action-link delete"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FC } from 'react';
|
||||
import { FC, useMemo } from 'react';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { PanelTypeVsPanelWrapper } from './constants';
|
||||
import { PanelWrapperProps } from './panelWrapper.types';
|
||||
@@ -30,6 +31,20 @@ function PanelWrapper({
|
||||
selectedGraph || widget.panelTypes
|
||||
] as FC<PanelWrapperProps>;
|
||||
|
||||
const groupByPerQuery = useMemo<Record<string, BaseAutocompleteData[]>>(() => {
|
||||
if (!widget.query.builder) {
|
||||
return {};
|
||||
}
|
||||
const { queryData } = widget.query.builder;
|
||||
return queryData.reduce<Record<string, BaseAutocompleteData[]>>(
|
||||
(acc, query) => {
|
||||
acc[query.queryName] = query.groupBy ?? [];
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}, [widget]);
|
||||
|
||||
if (!Component) {
|
||||
return <></>;
|
||||
}
|
||||
@@ -60,6 +75,7 @@ function PanelWrapper({
|
||||
customSeries={customSeries}
|
||||
enableDrillDown={enableDrillDown}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
|
||||
import { WidgetGraphComponentProps } from 'container/GridCardLayout/GridCard/types';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
@@ -30,6 +31,7 @@ export type PanelWrapperProps = {
|
||||
enableDrillDown?: boolean;
|
||||
panelMode: PanelMode;
|
||||
onColumnWidthsChange?: (widths: Record<string, number>) => void;
|
||||
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
|
||||
};
|
||||
|
||||
export type TooltipData = {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { useDashboardCursorSyncMode } from '../useDashboardCursorSyncMode';
|
||||
import { useDashboardPreferencesStore } from '../useDashboardPreference';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.DASHBOARD_PREFERENCES;
|
||||
|
||||
describe('useDashboardCursorSyncMode', () => {
|
||||
beforeEach(() => {
|
||||
useDashboardPreferencesStore.setState({ preferences: {} });
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
describe('in DASHBOARD_VIEW mode', () => {
|
||||
it('uses Crosshair as the default cursor sync mode', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
|
||||
});
|
||||
|
||||
it('reads the stored cursor sync mode for the dashboard', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
it('writes the value under the cursorSyncMode key in the store', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.None);
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
});
|
||||
|
||||
it('persists the value to localStorage', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}');
|
||||
expect(persisted.state.preferences).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the default when dashboardId is undefined', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode(undefined, PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
|
||||
});
|
||||
|
||||
it('treats the setter as a no-op when dashboardId is undefined', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode(undefined, PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual(
|
||||
{},
|
||||
);
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
|
||||
});
|
||||
});
|
||||
|
||||
describe('without a panelMode (e.g. dashboard settings call site)', () => {
|
||||
it('reads the stored value just like DASHBOARD_VIEW does', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDashboardCursorSyncMode('dash-1'));
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
it('writes through the setter to the store', () => {
|
||||
const { result } = renderHook(() => useDashboardCursorSyncMode('dash-1'));
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.None);
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([[PanelMode.DASHBOARD_EDIT], [PanelMode.STANDALONE_VIEW]])(
|
||||
'in %s mode (cursor sync disabled)',
|
||||
(panelMode) => {
|
||||
it('returns None and ignores any stored value', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', panelMode),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
it('treats the setter as a no-op and does not write to the store', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', panelMode),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual(
|
||||
{},
|
||||
);
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.None);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import {
|
||||
useDashboardPreference,
|
||||
useDashboardPreferencesStore,
|
||||
} from '../useDashboardPreference';
|
||||
|
||||
const DEFAULT_MODE = DashboardCursorSync.Crosshair;
|
||||
|
||||
describe('useDashboardPreference', () => {
|
||||
beforeEach(() => {
|
||||
useDashboardPreferencesStore.setState({ preferences: {} });
|
||||
});
|
||||
|
||||
it('returns the default value when no preference is stored', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('returns the default value when dashboardId is undefined', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference(undefined, 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('returns the stored value for the given dashboardId', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: {
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
it('persists the new value via the setter', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.None);
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write when dashboardId is undefined', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference(undefined, 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({});
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('keeps multiple hook instances in sync after a write', () => {
|
||||
const { result: writer } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
const { result: reader } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
writer.current[1](DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
expect(writer.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
expect(reader.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
it('isolates preferences across different dashboardIds', () => {
|
||||
const { result: dashOne } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
const { result: dashTwo } = renderHook(() =>
|
||||
useDashboardPreference('dash-2', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
dashOne.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(dashOne.current[0]).toBe(DashboardCursorSync.None);
|
||||
expect(dashTwo.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('does not overwrite preferences for other dashboards when writing', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: { 'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDashboardPreferencesStore.removePreferences', () => {
|
||||
beforeEach(() => {
|
||||
useDashboardPreferencesStore.setState({ preferences: {} });
|
||||
});
|
||||
|
||||
it('removes the preferences for the given dashboardId', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: {
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
useDashboardPreferencesStore.getState().removePreferences('dash-1');
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('leaves other dashboards untouched', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: {
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
|
||||
},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
useDashboardPreferencesStore.getState().removePreferences('dash-1');
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
|
||||
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
});
|
||||
|
||||
it('is a no-op when the dashboardId is not present', () => {
|
||||
const initial = {
|
||||
'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
};
|
||||
useDashboardPreferencesStore.setState({ preferences: initial });
|
||||
const before = useDashboardPreferencesStore.getState().preferences;
|
||||
|
||||
act(() => {
|
||||
useDashboardPreferencesStore.getState().removePreferences('dash-1');
|
||||
});
|
||||
|
||||
// Identity-preserving so subscribers reading `preferences` don't re-render.
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toBe(before);
|
||||
});
|
||||
|
||||
it('causes subsequent reads via useDashboardPreference to fall back to the default', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: {
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
|
||||
act(() => {
|
||||
useDashboardPreferencesStore.getState().removePreferences('dash-1');
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
});
|
||||
26
frontend/src/hooks/dashboard/useDashboardCursorSyncMode.ts
Normal file
26
frontend/src/hooks/dashboard/useDashboardCursorSyncMode.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { useDashboardPreference } from './useDashboardPreference';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
|
||||
const NOOP = (): void => {};
|
||||
|
||||
export function useDashboardCursorSyncMode(
|
||||
dashboardId: string | undefined,
|
||||
panelMode?: PanelMode,
|
||||
): [DashboardCursorSync, (value: DashboardCursorSync) => void] {
|
||||
const [value, setValue] = useDashboardPreference(
|
||||
dashboardId,
|
||||
'cursorSyncMode',
|
||||
DashboardCursorSync.Crosshair,
|
||||
);
|
||||
|
||||
// Chart panels in edit / standalone modes don't participate in cross-panel
|
||||
// sync, so surface the default with a no-op setter for them. Callers without
|
||||
// a panelMode (e.g. dashboard settings) read/write the preference normally.
|
||||
if (panelMode && panelMode !== PanelMode.DASHBOARD_VIEW) {
|
||||
return [DashboardCursorSync.None, NOOP];
|
||||
}
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
88
frontend/src/hooks/dashboard/useDashboardPreference.ts
Normal file
88
frontend/src/hooks/dashboard/useDashboardPreference.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useCallback } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
// Per-dashboard preferences persisted in localStorage. Add new preference
|
||||
// fields here as they are introduced.
|
||||
export type DashboardPreferences = {
|
||||
cursorSyncMode?: DashboardCursorSync;
|
||||
syncTooltipFilterMode?: SyncTooltipFilterMode;
|
||||
};
|
||||
|
||||
interface DashboardPreferencesState {
|
||||
preferences: Record<string, DashboardPreferences>;
|
||||
setPreference: <K extends keyof DashboardPreferences>(
|
||||
dashboardId: string,
|
||||
key: K,
|
||||
value: NonNullable<DashboardPreferences[K]>,
|
||||
) => void;
|
||||
removePreferences: (dashboardId: string) => void;
|
||||
}
|
||||
|
||||
export const useDashboardPreferencesStore = create<DashboardPreferencesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
preferences: {},
|
||||
setPreference: (dashboardId, key, value): void => {
|
||||
set((state) => ({
|
||||
preferences: {
|
||||
...state.preferences,
|
||||
[dashboardId]: {
|
||||
...state.preferences[dashboardId],
|
||||
[key]: value,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
removePreferences: (dashboardId): void => {
|
||||
set((state) => {
|
||||
if (!(dashboardId in state.preferences)) {
|
||||
return state;
|
||||
}
|
||||
const { [dashboardId]: _, ...rest } = state.preferences;
|
||||
return { preferences: rest };
|
||||
});
|
||||
},
|
||||
}),
|
||||
{ name: LOCALSTORAGE.DASHBOARD_PREFERENCES },
|
||||
),
|
||||
);
|
||||
|
||||
export function useDashboardPreference<K extends keyof DashboardPreferences>(
|
||||
dashboardId: string | undefined,
|
||||
key: K,
|
||||
defaultValue: NonNullable<DashboardPreferences[K]>,
|
||||
): [
|
||||
NonNullable<DashboardPreferences[K]>,
|
||||
(value: NonNullable<DashboardPreferences[K]>) => void,
|
||||
] {
|
||||
type Value = NonNullable<DashboardPreferences[K]>;
|
||||
|
||||
const value = useDashboardPreferencesStore((state): Value => {
|
||||
if (!dashboardId) {
|
||||
return defaultValue;
|
||||
}
|
||||
return (
|
||||
(state.preferences[dashboardId]?.[key] as Value | undefined) ?? defaultValue
|
||||
);
|
||||
});
|
||||
|
||||
const setPreference = useDashboardPreferencesStore((s) => s.setPreference);
|
||||
|
||||
const updateValue = useCallback(
|
||||
(next: Value): void => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
setPreference(dashboardId, key, next);
|
||||
},
|
||||
[dashboardId, key, setPreference],
|
||||
);
|
||||
|
||||
return [value, updateValue];
|
||||
}
|
||||
@@ -5,10 +5,15 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useDashboardPreferencesStore } from './useDashboardPreference';
|
||||
|
||||
export const useDeleteDashboard = (
|
||||
id: string,
|
||||
): UseMutationResult<SuccessResponseV2<null>, APIError, void, unknown> => {
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const removePreferences = useDashboardPreferencesStore(
|
||||
(state) => state.removePreferences,
|
||||
);
|
||||
|
||||
return useMutation<SuccessResponseV2<null>, APIError>({
|
||||
mutationKey: REACT_QUERY_KEY.DELETE_DASHBOARD,
|
||||
@@ -16,6 +21,9 @@ export const useDeleteDashboard = (
|
||||
deleteDashboard({
|
||||
id,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
removePreferences(id);
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
showErrorModal(error);
|
||||
},
|
||||
|
||||
15
frontend/src/hooks/dashboard/useSyncTooltipFilterMode.ts
Normal file
15
frontend/src/hooks/dashboard/useSyncTooltipFilterMode.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SyncTooltipFilterMode } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { useDashboardPreference } from './useDashboardPreference';
|
||||
|
||||
const DEFAULT_SYNC_TOOLTIP_FILTER_MODE = SyncTooltipFilterMode.Filtered;
|
||||
|
||||
export function useSyncTooltipFilterMode(
|
||||
dashboardId: string | undefined,
|
||||
): [SyncTooltipFilterMode, (value: SyncTooltipFilterMode) => void] {
|
||||
return useDashboardPreference(
|
||||
dashboardId,
|
||||
'syncTooltipFilterMode',
|
||||
DEFAULT_SYNC_TOOLTIP_FILTER_MODE,
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
|
||||
decimalPrecision: props.decimalPrecision,
|
||||
isStackedBarChart: props.isStackedBarChart,
|
||||
syncedSeriesIndexes: props.syncedSeriesIndexes,
|
||||
syncFilterMode: props.syncFilterMode,
|
||||
}),
|
||||
[
|
||||
props.uPlotInstance,
|
||||
@@ -26,6 +27,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
|
||||
props.decimalPrecision,
|
||||
props.isStackedBarChart,
|
||||
props.syncedSeriesIndexes,
|
||||
props.syncFilterMode,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function HistogramTooltip(
|
||||
yAxisUnit: props.yAxisUnit ?? '',
|
||||
decimalPrecision: props.decimalPrecision,
|
||||
syncedSeriesIndexes: props.syncedSeriesIndexes,
|
||||
syncFilterMode: props.syncFilterMode,
|
||||
}),
|
||||
[
|
||||
props.uPlotInstance,
|
||||
@@ -26,6 +27,7 @@ export default function HistogramTooltip(
|
||||
props.yAxisUnit,
|
||||
props.decimalPrecision,
|
||||
props.syncedSeriesIndexes,
|
||||
props.syncFilterMode,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function TimeSeriesTooltip(
|
||||
yAxisUnit: props.yAxisUnit ?? '',
|
||||
decimalPrecision: props.decimalPrecision,
|
||||
syncedSeriesIndexes: props.syncedSeriesIndexes,
|
||||
syncFilterMode: props.syncFilterMode,
|
||||
}),
|
||||
[
|
||||
props.uPlotInstance,
|
||||
@@ -26,6 +27,7 @@ export default function TimeSeriesTooltip(
|
||||
props.yAxisUnit,
|
||||
props.decimalPrecision,
|
||||
props.syncedSeriesIndexes,
|
||||
props.syncFilterMode,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
border: 1px solid var(--l2-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&.pinned {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,19 @@ import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { TooltipProps } from '../types';
|
||||
import TooltipFooter from './components/TooltipFooter/TooltipFooter';
|
||||
import TooltipHeader from './components/TooltipHeader/TooltipHeader';
|
||||
import TooltipList from './components/TooltipList/TooltipList';
|
||||
|
||||
import Styles from './Tooltip.module.scss';
|
||||
|
||||
export default function Tooltip({
|
||||
id,
|
||||
uPlotInstance,
|
||||
timezone,
|
||||
content,
|
||||
showTooltipHeader = true,
|
||||
isPinned,
|
||||
canPinTooltip,
|
||||
renderTooltipFooter,
|
||||
dismiss,
|
||||
}: TooltipProps): JSX.Element {
|
||||
const tooltipContent = useMemo(() => content ?? [], [content]);
|
||||
@@ -31,7 +31,9 @@ export default function Tooltip({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.container, isPinned && Styles.pinned)}
|
||||
className={cx(Styles.container, {
|
||||
[Styles.pinned]: isPinned,
|
||||
})}
|
||||
data-testid="uplot-tooltip-container"
|
||||
>
|
||||
{showHeader && (
|
||||
@@ -46,9 +48,9 @@ export default function Tooltip({
|
||||
|
||||
{showDivider && <span className={Styles.divider} />}
|
||||
|
||||
{showList && <TooltipList content={tooltipContent} />}
|
||||
{showList && <TooltipList id={id} content={tooltipContent} />}
|
||||
|
||||
{canPinTooltip && <TooltipFooter isPinned={isPinned} dismiss={dismiss} />}
|
||||
{renderTooltipFooter && renderTooltipFooter({ isPinned, dismiss })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { render, RenderResult, screen } from 'tests/test-utils';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { TooltipContentItem } from '../../types';
|
||||
import { IRenderTooltipFooterArgs, TooltipContentItem } from '../../types';
|
||||
import Tooltip from '../Tooltip';
|
||||
|
||||
type MockVirtuosoProps = {
|
||||
@@ -83,6 +83,7 @@ function createUPlotInstance(cursorIdx: number | null): uPlot {
|
||||
|
||||
function renderTooltip(props: Partial<TooltipTestProps> = {}): RenderResult {
|
||||
const defaultProps: TooltipTestProps = {
|
||||
id: 'tooltip-1',
|
||||
uPlotInstance: createUPlotInstance(null),
|
||||
timezone: { value: 'UTC', name: 'UTC', offset: '0', searchIndex: '0' },
|
||||
content: [],
|
||||
@@ -192,63 +193,88 @@ describe('Tooltip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tooltip footer hint', () => {
|
||||
describe('Tooltip renderTooltipFooter', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseIsDarkMode.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('renders footer with "Press P to pin the tooltip" hint when not pinned', () => {
|
||||
renderTooltip({ isPinned: false, canPinTooltip: true });
|
||||
it('does not render footer content when renderTooltipFooter is not provided', () => {
|
||||
renderTooltip();
|
||||
|
||||
const footer = screen.getByTestId('uplot-tooltip-footer');
|
||||
expect(footer).toBeInTheDocument();
|
||||
expect(footer).toHaveTextContent('Press');
|
||||
expect(footer).toHaveTextContent('P');
|
||||
expect(footer).toHaveTextContent('to pin the tooltip');
|
||||
expect(screen.queryByTestId('custom-tooltip-footer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders footer with "Press P or Esc to unpin" hint when pinned', () => {
|
||||
renderTooltip({ isPinned: true, canPinTooltip: true });
|
||||
it('renders content returned by renderTooltipFooter', () => {
|
||||
const renderTooltipFooter = jest.fn(
|
||||
(): JSX.Element => <div data-testid="custom-tooltip-footer">Footer</div>,
|
||||
);
|
||||
|
||||
const footer = screen.getByTestId('uplot-tooltip-footer');
|
||||
expect(footer).toHaveTextContent('Press');
|
||||
expect(footer).toHaveTextContent('P');
|
||||
expect(footer).toHaveTextContent('Esc');
|
||||
expect(footer).toHaveTextContent('to unpin');
|
||||
renderTooltip({ renderTooltipFooter });
|
||||
|
||||
expect(screen.getByTestId('custom-tooltip-footer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render Unpin button when not pinned', () => {
|
||||
renderTooltip({ isPinned: false, canPinTooltip: true });
|
||||
it('calls renderTooltipFooter with isPinned=false when tooltip is not pinned', () => {
|
||||
const renderTooltipFooter = jest.fn(() => null);
|
||||
|
||||
expect(screen.queryByTestId('uplot-tooltip-unpin')).not.toBeInTheDocument();
|
||||
renderTooltip({ renderTooltipFooter, isPinned: false });
|
||||
|
||||
expect(renderTooltipFooter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isPinned: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders Unpin button when pinned', () => {
|
||||
renderTooltip({ isPinned: true, canPinTooltip: true });
|
||||
it('calls renderTooltipFooter with isPinned=true when tooltip is pinned', () => {
|
||||
const renderTooltipFooter = jest.fn(() => null);
|
||||
|
||||
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
|
||||
expect(unpinBtn).toBeInTheDocument();
|
||||
expect(unpinBtn).toHaveAttribute('aria-label', 'Unpin tooltip');
|
||||
renderTooltip({ renderTooltipFooter, isPinned: true });
|
||||
|
||||
expect(renderTooltipFooter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isPinned: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('calls dismiss when Unpin button is clicked', async () => {
|
||||
it('calls renderTooltipFooter with the dismiss callback', () => {
|
||||
const dismiss = jest.fn();
|
||||
renderTooltip({ isPinned: true, canPinTooltip: true, dismiss });
|
||||
const renderTooltipFooter = jest.fn(() => null);
|
||||
|
||||
renderTooltip({ renderTooltipFooter, dismiss });
|
||||
|
||||
expect(renderTooltipFooter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dismiss }),
|
||||
);
|
||||
});
|
||||
|
||||
it('footer content reflects pinned state via renderTooltipFooter args', () => {
|
||||
const renderTooltipFooter = jest.fn(
|
||||
({ isPinned }: IRenderTooltipFooterArgs): JSX.Element => (
|
||||
<div data-testid="footer-state">{isPinned ? 'Pinned' : 'Not pinned'}</div>
|
||||
),
|
||||
);
|
||||
|
||||
renderTooltip({ renderTooltipFooter, isPinned: true });
|
||||
|
||||
expect(screen.getByTestId('footer-state')).toHaveTextContent('Pinned');
|
||||
});
|
||||
|
||||
it('dismiss is callable when invoked from renderTooltipFooter', async () => {
|
||||
const dismiss = jest.fn();
|
||||
const renderTooltipFooter = jest.fn(
|
||||
({ dismiss: onDismiss }: IRenderTooltipFooterArgs): JSX.Element => (
|
||||
<button data-testid="dismiss-btn" onClick={onDismiss}>
|
||||
Dismiss
|
||||
</button>
|
||||
),
|
||||
);
|
||||
|
||||
renderTooltip({ renderTooltipFooter, isPinned: true, dismiss });
|
||||
|
||||
const user = userEvent.setup();
|
||||
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
|
||||
await user.click(unpinBtn);
|
||||
await user.click(screen.getByTestId('dismiss-btn'));
|
||||
|
||||
expect(dismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('footer has role="status" for screen reader announcements', () => {
|
||||
renderTooltip({ canPinTooltip: true });
|
||||
|
||||
const footer = screen.getByRole('status');
|
||||
expect(footer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tooltip header status pill', () => {
|
||||
|
||||
@@ -11,11 +11,10 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.pinnedItem {
|
||||
padding: var(--spacing-4) var(--spacing-4) 0 var(--spacing-4);
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.status {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
.container {
|
||||
padding-bottom: var(--spacing-6);
|
||||
}
|
||||
|
||||
.list {
|
||||
width: 100%;
|
||||
:global(div[data-viewport-type='element']) {
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 0px var(--spacing-2) 0 var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-2) var(--spacing-4) var(--spacing-4);
|
||||
|
||||
[data-test-id='virtuoso-item-list'] > * + * {
|
||||
margin-top: var(--spacing-2);
|
||||
|
||||
@@ -9,17 +9,18 @@ import logEvent from 'api/common/logEvent';
|
||||
import { Events } from 'constants/events';
|
||||
|
||||
import Styles from './TooltipList.module.scss';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
// Fallback per-item height before Virtuoso reports the real total.
|
||||
const TOOLTIP_ITEM_HEIGHT = 38;
|
||||
const LIST_MAX_HEIGHT = 300;
|
||||
|
||||
interface TooltipListProps {
|
||||
id: string;
|
||||
content: TooltipContentItem[];
|
||||
}
|
||||
|
||||
export default function TooltipList({
|
||||
id,
|
||||
content,
|
||||
}: TooltipListProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
@@ -41,23 +42,25 @@ export default function TooltipList({
|
||||
if (!isScrollEventTriggered.current) {
|
||||
// TODO: remove event in July 2026
|
||||
logEvent(Events.TOOLTIP_CONTENT_SCROLLED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
id,
|
||||
});
|
||||
isScrollEventTriggered.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
className={cx(Styles.list, !isDarkMode && Styles.listLightMode)}
|
||||
data-testid="uplot-tooltip-list"
|
||||
data={content}
|
||||
onScroll={handleScroll}
|
||||
style={{ height }}
|
||||
totalListHeightChanged={setTotalListHeight}
|
||||
itemContent={(_, item): JSX.Element => (
|
||||
<TooltipItem item={item} isItemActive={false} />
|
||||
)}
|
||||
/>
|
||||
<div className={Styles.container}>
|
||||
<Virtuoso
|
||||
className={cx(Styles.list, !isDarkMode && Styles.listLightMode)}
|
||||
data-testid="uplot-tooltip-list"
|
||||
data={content}
|
||||
onScroll={handleScroll}
|
||||
style={{ height }}
|
||||
totalListHeightChanged={setTotalListHeight}
|
||||
itemContent={(_, item): JSX.Element => (
|
||||
<TooltipItem item={item} isItemActive={item.isHighlighted === true} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import uPlot, { AlignedData, Series } from 'uplot';
|
||||
|
||||
import { SyncTooltipFilterMode } from '../../plugins/TooltipPlugin/types';
|
||||
import { TooltipContentItem } from '../types';
|
||||
|
||||
export const FALLBACK_SERIES_COLOR = '#000000';
|
||||
@@ -63,6 +64,7 @@ export function buildTooltipContent({
|
||||
decimalPrecision,
|
||||
isStackedBarChart,
|
||||
syncedSeriesIndexes,
|
||||
syncFilterMode,
|
||||
}: {
|
||||
data: AlignedData;
|
||||
series: Series[];
|
||||
@@ -73,10 +75,16 @@ export function buildTooltipContent({
|
||||
decimalPrecision?: PrecisionOption;
|
||||
isStackedBarChart?: boolean;
|
||||
syncedSeriesIndexes?: number[] | null;
|
||||
syncFilterMode?: SyncTooltipFilterMode;
|
||||
}): TooltipContentItem[] {
|
||||
const items: TooltipContentItem[] = [];
|
||||
const allowedIndexes =
|
||||
const matchedIndexes =
|
||||
syncedSeriesIndexes != null ? new Set(syncedSeriesIndexes) : null;
|
||||
const filterMode = syncFilterMode ?? SyncTooltipFilterMode.Filtered;
|
||||
// In Filtered mode the matched indexes act as a whitelist; in All mode every
|
||||
// series renders and matched indexes only drive row highlighting.
|
||||
const allowedIndexes =
|
||||
filterMode === SyncTooltipFilterMode.All ? null : matchedIndexes;
|
||||
|
||||
for (let seriesIndex = 1; seriesIndex < series.length; seriesIndex += 1) {
|
||||
const seriesItem = series[seriesIndex];
|
||||
@@ -89,6 +97,7 @@ export function buildTooltipContent({
|
||||
|
||||
const dataIndex = dataIndexes[seriesIndex];
|
||||
const isSync = allowedIndexes != null;
|
||||
const isHighlighted = matchedIndexes?.has(seriesIndex) ?? false;
|
||||
|
||||
if (dataIndex === null) {
|
||||
if (isSync) {
|
||||
@@ -98,6 +107,7 @@ export function buildTooltipContent({
|
||||
tooltipValue: 'No Data',
|
||||
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
|
||||
isActive: false,
|
||||
isHighlighted,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -118,6 +128,7 @@ export function buildTooltipContent({
|
||||
tooltipValue: getToolTipValue(baseValue, yAxisUnit, decimalPrecision),
|
||||
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
|
||||
isActive: seriesIndex === activeSeriesIndex,
|
||||
isHighlighted,
|
||||
});
|
||||
} else if (isSync) {
|
||||
items.push({
|
||||
@@ -126,6 +137,7 @@ export function buildTooltipContent({
|
||||
tooltipValue: 'No Data',
|
||||
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
|
||||
isActive: false,
|
||||
isHighlighted,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PrecisionOption } from 'components/Graph/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
|
||||
|
||||
/**
|
||||
* Props for the Plot component
|
||||
@@ -58,17 +59,31 @@ export interface TooltipRenderArgs {
|
||||
isPinned: boolean;
|
||||
dismiss: () => void;
|
||||
viaSync: boolean;
|
||||
/** In Tooltip sync mode, limits which series are rendered in the receiver tooltip.
|
||||
* null = no filtering; [] = no matches (tooltip hidden upstream); [...] = allowed indexes */
|
||||
/** In Tooltip sync mode, identifies receiver series that match the source's
|
||||
* focused series on the shared groupBy keys.
|
||||
* Filtered mode: limits which series are rendered (null = no filter,
|
||||
* [] = no matches/tooltip hidden upstream, [...] = allowed indexes).
|
||||
* All mode: same indexes are interpreted as a highlight set; non-matching
|
||||
* series still render. */
|
||||
syncedSeriesIndexes?: number[] | null;
|
||||
/** Receiver-side filter mode for the synced tooltip. Defaults to Filtered. */
|
||||
syncFilterMode?: SyncTooltipFilterMode;
|
||||
}
|
||||
|
||||
export interface IRenderTooltipFooterArgs {
|
||||
pinKey?: string;
|
||||
isPinned: boolean;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
export interface BaseTooltipProps {
|
||||
id: string;
|
||||
showTooltipHeader?: boolean;
|
||||
canPinTooltip?: boolean;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
content?: TooltipContentItem[];
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => ReactNode;
|
||||
timezone?: Timezone;
|
||||
}
|
||||
|
||||
@@ -106,4 +121,9 @@ export interface TooltipContentItem {
|
||||
tooltipValue: string;
|
||||
color: string;
|
||||
isActive: boolean;
|
||||
/** Synced receiver series whose metric matches the source's focused series
|
||||
* on the shared groupBy keys, in 'all' filter mode. List rendering uses this
|
||||
* to apply the active highlight to matching rows while non-matching rows
|
||||
* stay dimmed. */
|
||||
isHighlighted?: boolean;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
DEFAULT_PIN_TOOLTIP_KEY,
|
||||
SyncTooltipFilterMode,
|
||||
TooltipControllerContext,
|
||||
TooltipControllerState,
|
||||
TooltipLayoutInfo,
|
||||
@@ -32,7 +33,6 @@ import {
|
||||
import { Events } from 'constants/events';
|
||||
|
||||
import Styles from './TooltipPlugin.module.scss';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
// Delay before hiding an unpinned tooltip when the cursor briefly leaves
|
||||
// the plot – this avoids flicker when moving between nearby points.
|
||||
@@ -199,10 +199,14 @@ export default function TooltipPlugin({
|
||||
if (!controller.hoverActive || !plot) {
|
||||
return null;
|
||||
}
|
||||
// In Tooltip sync mode, suppress the receiver tooltip entirely when
|
||||
// no receiver series match the source panel's focused series.
|
||||
const filterMode =
|
||||
syncMetadata?.filterMode ?? SyncTooltipFilterMode.Filtered;
|
||||
// In Filtered Tooltip sync mode, suppress the receiver tooltip entirely
|
||||
// when no receiver series match the source panel's focused series. In
|
||||
// All mode the tooltip still renders with every series visible.
|
||||
if (
|
||||
syncTooltipWithDashboard &&
|
||||
filterMode === SyncTooltipFilterMode.Filtered &&
|
||||
controller.cursorDrivenBySync &&
|
||||
Array.isArray(controller.syncedSeriesIndexes) &&
|
||||
controller.syncedSeriesIndexes.length === 0
|
||||
@@ -217,6 +221,7 @@ export default function TooltipPlugin({
|
||||
dismiss: dismissTooltip,
|
||||
viaSync: controller.cursorDrivenBySync,
|
||||
syncedSeriesIndexes: controller.syncedSeriesIndexes,
|
||||
syncFilterMode: filterMode,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -304,7 +309,7 @@ export default function TooltipPlugin({
|
||||
if (event.key === 'Escape') {
|
||||
if (controller.pinned) {
|
||||
logEvent(Events.TOOLTIP_UNPINNED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
id: config.getId(),
|
||||
});
|
||||
dismissTooltip();
|
||||
}
|
||||
@@ -318,7 +323,7 @@ export default function TooltipPlugin({
|
||||
// Toggle off: P pressed while already pinned.
|
||||
if (controller.pinned) {
|
||||
logEvent(Events.TOOLTIP_UNPINNED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
id: config.getId(),
|
||||
});
|
||||
dismissTooltip();
|
||||
return;
|
||||
@@ -352,7 +357,7 @@ export default function TooltipPlugin({
|
||||
controller.clickData = buildClickData(syntheticEvent, plot);
|
||||
controller.pinned = true;
|
||||
logEvent(Events.TOOLTIP_PINNED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
id: config.getId(),
|
||||
});
|
||||
scheduleRender(true);
|
||||
};
|
||||
|
||||
@@ -2,10 +2,33 @@ import uPlot from 'uplot';
|
||||
|
||||
import type { ExtendedSeries } from '../../config/types';
|
||||
import { syncCursorRegistry } from './syncCursorRegistry';
|
||||
import type { TooltipControllerState, TooltipSyncMetadata } from './types';
|
||||
import {
|
||||
SyncTooltipFilterMode,
|
||||
type TooltipControllerState,
|
||||
type TooltipSyncMetadata,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Returns the dimension keys present in both groupBy arrays.
|
||||
* Flattens per-query groupBys into a deduped set of dimension keys.
|
||||
* A panel's effective groupBy is the union across all of its queries.
|
||||
*/
|
||||
function collectGroupByKeys(
|
||||
groupByPerQuery: TooltipSyncMetadata['groupByPerQuery'],
|
||||
): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
if (!groupByPerQuery) {
|
||||
return keys;
|
||||
}
|
||||
for (const groupBy of Object.values(groupByPerQuery)) {
|
||||
for (const dim of groupBy) {
|
||||
keys.add(dim.key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the dimension keys present in both panels' groupBys.
|
||||
* An empty result means no overlap — series highlighting should not run.
|
||||
*
|
||||
* exact [A, B] vs [A, B] → [A, B] one match
|
||||
@@ -14,24 +37,28 @@ import type { TooltipControllerState, TooltipSyncMetadata } from './types';
|
||||
* partial [A, B] vs [B, C] → [B]
|
||||
*/
|
||||
function getCommonGroupByKeys(
|
||||
a: TooltipSyncMetadata['groupBy'],
|
||||
b: TooltipSyncMetadata['groupBy'],
|
||||
a: TooltipSyncMetadata['groupByPerQuery'],
|
||||
b: TooltipSyncMetadata['groupByPerQuery'],
|
||||
): string[] {
|
||||
if (
|
||||
!Array.isArray(a) ||
|
||||
a.length === 0 ||
|
||||
!Array.isArray(b) ||
|
||||
b.length === 0
|
||||
) {
|
||||
const aKeys = collectGroupByKeys(a);
|
||||
const bKeys = collectGroupByKeys(b);
|
||||
if (aKeys.size === 0 || bKeys.size === 0) {
|
||||
return [];
|
||||
}
|
||||
const bKeys = new Set(b.map((g) => g.key));
|
||||
return a.filter((g) => bKeys.has(g.key)).map((g) => g.key);
|
||||
const common: string[] = [];
|
||||
aKeys.forEach((key) => {
|
||||
if (bKeys.has(key)) {
|
||||
common.push(key);
|
||||
}
|
||||
});
|
||||
return common;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the 1-based indexes of every series whose metric matches
|
||||
* sourceMetric on all commonKeys.
|
||||
* Returns the 1-based indexes of every visible series whose metric matches
|
||||
* sourceMetric on all commonKeys. Hidden series (toggled off in the legend)
|
||||
* are excluded so the synced tooltip is suppressed when no visible series
|
||||
* would match.
|
||||
*/
|
||||
function findMatchingSeriesIndexes(
|
||||
series: uPlot.Series[],
|
||||
@@ -39,7 +66,7 @@ function findMatchingSeriesIndexes(
|
||||
commonKeys: string[],
|
||||
): number[] {
|
||||
return series.reduce<number[]>((acc, s, i) => {
|
||||
if (i === 0) {
|
||||
if (i === 0 || s.show === false) {
|
||||
return acc;
|
||||
}
|
||||
const metric = (s as ExtendedSeries).metric;
|
||||
@@ -76,10 +103,15 @@ function applySourceSync({
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns:
|
||||
* null – no groupBy filtering configured or cursor off-chart (no-op for tooltip)
|
||||
* [] – groupBy configured but no receiver series match the source (hide synced tooltip)
|
||||
* number[] – 1-based indexes of matching receiver series (show only these)
|
||||
* Computes receiver-side series filtering / highlighting for Tooltip sync.
|
||||
*
|
||||
* Returns the indexes that the tooltip render path should treat per
|
||||
* `syncMetadata.filterMode`:
|
||||
* - Filtered (default): null = no filter, [] = no matches (suppress tooltip),
|
||||
* number[] = allowed indexes (show only these).
|
||||
* - All: null = no highlight (show all), number[] = highlight set (show all,
|
||||
* emphasize matching rows). Never returns [] in this mode so the synced
|
||||
* tooltip is not suppressed when matches are missing.
|
||||
*/
|
||||
function applyReceiverSync({
|
||||
uPlotInstance,
|
||||
@@ -99,8 +131,13 @@ function applyReceiverSync({
|
||||
yCrosshairEl.style.display =
|
||||
sourceMetadata?.yAxisUnit === syncMetadata?.yAxisUnit ? '' : 'none';
|
||||
|
||||
const filterMode = syncMetadata?.filterMode ?? SyncTooltipFilterMode.Filtered;
|
||||
const noMatchResult: number[] | null =
|
||||
filterMode === SyncTooltipFilterMode.All ? null : [];
|
||||
|
||||
if (commonKeys.length === 0) {
|
||||
return null;
|
||||
uPlotInstance.setSeries(null, { focus: false });
|
||||
return [];
|
||||
}
|
||||
|
||||
if ((uPlotInstance.cursor.left ?? -1) < 0) {
|
||||
@@ -111,7 +148,7 @@ function applyReceiverSync({
|
||||
const sourceSeriesMetric = syncCursorRegistry.getActiveSeriesMetric(syncKey);
|
||||
if (sourceSeriesMetric == null) {
|
||||
uPlotInstance.setSeries(null, { focus: false });
|
||||
return [];
|
||||
return noMatchResult;
|
||||
}
|
||||
|
||||
const matchingIdxs = findMatchingSeriesIndexes(
|
||||
@@ -122,7 +159,7 @@ function applyReceiverSync({
|
||||
|
||||
if (matchingIdxs.length === 0) {
|
||||
uPlotInstance.setSeries(null, { focus: false });
|
||||
return [];
|
||||
return noMatchResult;
|
||||
}
|
||||
|
||||
uPlotInstance.setSeries(matchingIdxs[0], { focus: true });
|
||||
@@ -140,7 +177,7 @@ export function createSyncDisplayHook(
|
||||
|
||||
// groupBy on both panels is stable (set at config time). Recompute the
|
||||
// intersection only when the source panel's groupBy reference changes.
|
||||
let lastSourceGroupBy: TooltipSyncMetadata['groupBy'];
|
||||
let lastSourceGroupBy: TooltipSyncMetadata['groupByPerQuery'];
|
||||
let cachedCommonKeys: string[] = [];
|
||||
|
||||
return (u: uPlot): void => {
|
||||
@@ -165,11 +202,11 @@ export function createSyncDisplayHook(
|
||||
// inside applyReceiverSync.
|
||||
const sourceMetadata = syncCursorRegistry.getMetadata(syncKey);
|
||||
|
||||
if (sourceMetadata?.groupBy !== lastSourceGroupBy) {
|
||||
lastSourceGroupBy = sourceMetadata?.groupBy;
|
||||
if (sourceMetadata?.groupByPerQuery !== lastSourceGroupBy) {
|
||||
lastSourceGroupBy = sourceMetadata?.groupByPerQuery;
|
||||
cachedCommonKeys = getCommonGroupByKeys(
|
||||
sourceMetadata?.groupBy,
|
||||
syncMetadata?.groupBy,
|
||||
sourceMetadata?.groupByPerQuery,
|
||||
syncMetadata?.groupByPerQuery,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,18 @@ export const TOOLTIP_OFFSET = 10;
|
||||
export const DEFAULT_PIN_TOOLTIP_KEY = 'p';
|
||||
|
||||
export enum DashboardCursorSync {
|
||||
Crosshair,
|
||||
None,
|
||||
Tooltip,
|
||||
Crosshair = 'crosshair',
|
||||
None = 'none',
|
||||
Tooltip = 'tooltip',
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls whether a synced tooltip filters series by groupBy intersection
|
||||
* or shows every series with the matching ones highlighted.
|
||||
*/
|
||||
export enum SyncTooltipFilterMode {
|
||||
Filtered = 'filtered',
|
||||
All = 'all',
|
||||
}
|
||||
|
||||
export interface TooltipViewState {
|
||||
@@ -40,7 +49,8 @@ export interface TooltipLayoutInfo {
|
||||
|
||||
export interface TooltipSyncMetadata {
|
||||
yAxisUnit?: string;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
|
||||
filterMode?: SyncTooltipFilterMode;
|
||||
}
|
||||
|
||||
export interface TooltipPluginProps {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import type { ExtendedSeries } from '../../config/types';
|
||||
import { syncCursorRegistry } from '../TooltipPlugin/syncCursorRegistry';
|
||||
import { createSyncDisplayHook } from '../TooltipPlugin/syncDisplayHook';
|
||||
import type {
|
||||
TooltipControllerState,
|
||||
TooltipSyncMetadata,
|
||||
} from '../TooltipPlugin/types';
|
||||
|
||||
const SYNC_KEY = 'test-sync';
|
||||
|
||||
function makeController(): TooltipControllerState {
|
||||
return {
|
||||
plot: null,
|
||||
hoverActive: false,
|
||||
isAnySeriesActive: false,
|
||||
pinned: false,
|
||||
clickData: null,
|
||||
style: {},
|
||||
horizontalOffset: 0,
|
||||
verticalOffset: 0,
|
||||
seriesIndexes: [],
|
||||
focusedSeriesIndex: null,
|
||||
syncedSeriesIndexes: null,
|
||||
cursorDrivenBySync: false,
|
||||
plotWithinViewport: true,
|
||||
windowWidth: 1024,
|
||||
windowHeight: 768,
|
||||
pendingPinnedUpdate: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakePlot(
|
||||
series: ExtendedSeries[],
|
||||
cursorEvent: Record<string, unknown> | null = null,
|
||||
): uPlot {
|
||||
const root = document.createElement('div');
|
||||
const yCrosshair = document.createElement('div');
|
||||
yCrosshair.className = 'u-cursor-y';
|
||||
root.appendChild(yCrosshair);
|
||||
|
||||
return {
|
||||
root,
|
||||
series,
|
||||
cursor: { event: cursorEvent, left: 50 },
|
||||
setSeries: jest.fn(),
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
const SERVICE_NAME_KEY: BaseAutocompleteData = {
|
||||
key: 'service.name',
|
||||
type: 'tag',
|
||||
};
|
||||
|
||||
const groupByService: TooltipSyncMetadata = {
|
||||
groupByPerQuery: { queryName: [SERVICE_NAME_KEY] },
|
||||
};
|
||||
|
||||
function seedSourcePanel(activeMetric: Record<string, string>): void {
|
||||
syncCursorRegistry.setMetadata(SYNC_KEY, groupByService);
|
||||
syncCursorRegistry.setActiveSeriesMetric(SYNC_KEY, activeMetric);
|
||||
}
|
||||
|
||||
function makeReceiverSeries(
|
||||
entries: { name: string; show?: boolean }[],
|
||||
): ExtendedSeries[] {
|
||||
return [
|
||||
{} as ExtendedSeries,
|
||||
...entries.map(
|
||||
(e) =>
|
||||
({
|
||||
show: e.show ?? true,
|
||||
metric: { 'service.name': e.name },
|
||||
}) as unknown as ExtendedSeries,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
describe('createSyncDisplayHook (receiver-side filtering)', () => {
|
||||
beforeEach(() => {
|
||||
syncCursorRegistry.setMetadata(SYNC_KEY, undefined);
|
||||
syncCursorRegistry.setActiveSeriesMetric(SYNC_KEY, null);
|
||||
});
|
||||
|
||||
it('returns indexes of visible matching series only', () => {
|
||||
seedSourcePanel({ 'service.name': 'flagd' });
|
||||
|
||||
const series = makeReceiverSeries([
|
||||
{ name: 'flagd', show: true },
|
||||
{ name: 'frontend', show: true },
|
||||
{ name: 'flagd', show: true },
|
||||
]);
|
||||
const plot = makeFakePlot(series, null);
|
||||
const controller = makeController();
|
||||
|
||||
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
|
||||
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('treats all matching series being hidden as no match → empty array', () => {
|
||||
seedSourcePanel({ 'service.name': 'frontendproxy' });
|
||||
|
||||
const series = makeReceiverSeries([
|
||||
{ name: 'flagd', show: true },
|
||||
{ name: 'frontendproxy', show: false },
|
||||
]);
|
||||
const plot = makeFakePlot(series, null);
|
||||
const controller = makeController();
|
||||
|
||||
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
|
||||
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(null, { focus: false });
|
||||
});
|
||||
|
||||
it('excludes hidden series and keeps the visible matches', () => {
|
||||
seedSourcePanel({ 'service.name': 'flagd' });
|
||||
|
||||
const series = makeReceiverSeries([
|
||||
{ name: 'flagd', show: false },
|
||||
{ name: 'frontend', show: true },
|
||||
{ name: 'flagd', show: true },
|
||||
]);
|
||||
const plot = makeFakePlot(series, null);
|
||||
const controller = makeController();
|
||||
|
||||
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
|
||||
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([3]);
|
||||
// Focuses the first visible match, not the hidden one at index 1.
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(3, { focus: true });
|
||||
});
|
||||
|
||||
it('returns null (no filtering) when the hook runs on the source panel', () => {
|
||||
const series = makeReceiverSeries([{ name: 'flagd', show: true }]);
|
||||
// cursor.event != null marks this invocation as the source panel.
|
||||
const plot = makeFakePlot(series, { type: 'mousemove' });
|
||||
const controller = makeController();
|
||||
controller.focusedSeriesIndex = 1;
|
||||
(series[1] as ExtendedSeries).metric = { 'service.name': 'flagd' };
|
||||
|
||||
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
|
||||
|
||||
expect(controller.syncedSeriesIndexes).toBeNull();
|
||||
expect(syncCursorRegistry.getActiveSeriesMetric(SYNC_KEY)).toStrictEqual({
|
||||
'service.name': 'flagd',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,9 +34,9 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
Description: "This endpoint creates an auth domain",
|
||||
Request: new(authtypes.PostableAuthDomain),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(authtypes.GettableAuthDomain),
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
@@ -66,7 +66,7 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
Tags: []string{"authdomains"},
|
||||
Summary: "Update auth domain",
|
||||
Description: "This endpoint updates an auth domain",
|
||||
Request: new(authtypes.UpdateableAuthDomain),
|
||||
Request: new(authtypes.UpdatableAuthDomain),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
|
||||
@@ -8,7 +8,6 @@ var (
|
||||
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
|
||||
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
|
||||
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
|
||||
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
|
||||
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
|
||||
)
|
||||
|
||||
@@ -54,14 +53,6 @@ func MustNewRegistry() featuretypes.Registry {
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
&featuretypes.Feature{
|
||||
Name: FeatureUseMeterReporter,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
Stage: featuretypes.StageExperimental,
|
||||
Description: "Controls whether the enterprise meter reporter runs instead of the noop reporter",
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
&featuretypes.Feature{
|
||||
Name: FeatureUseJSONBody,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package metercollector
|
||||
|
||||
const (
|
||||
// DimensionOrganizationID identifies the organization.
|
||||
DimensionOrganizationID = "signoz.billing.organization.id"
|
||||
|
||||
// DimensionRetentionDays identifies the retention bucket a meter belongs to.
|
||||
DimensionRetentionDays = "signoz.billing.retention.days"
|
||||
|
||||
// DimensionWorkspaceKeyID identifies the ingestion workspace key.
|
||||
DimensionWorkspaceKeyID = "signoz.workspace.key.id"
|
||||
)
|
||||
@@ -1,6 +0,0 @@
|
||||
package metercollector
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/errors"
|
||||
|
||||
// ErrCodeCollectFailed is the shared error code for collector failures.
|
||||
var ErrCodeCollectFailed = errors.MustNewCode("metercollector_collect_failed")
|
||||
@@ -1,19 +0,0 @@
|
||||
// Package metercollector defines the contract for billing meter collectors.
|
||||
package metercollector
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/metercollectortypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/meterreportertypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// MeterCollector owns one billing meter's metadata and collection query.
|
||||
// Collect stamps DimensionOrganizationID and returns errors instead of panics.
|
||||
type MeterCollector interface {
|
||||
Name() metercollectortypes.Name
|
||||
Unit() metercollectortypes.Unit
|
||||
Aggregation() metercollectortypes.Aggregation
|
||||
Collect(ctx context.Context, orgID valuer.UUID, window meterreportertypes.Window) ([]meterreportertypes.Meter, error)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package meterreporter
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
)
|
||||
|
||||
var _ factory.Config = (*Config)(nil)
|
||||
|
||||
type Config struct {
|
||||
// Interval is how often the reporter collects and ships meters.
|
||||
Interval time.Duration `mapstructure:"interval"`
|
||||
|
||||
// Timeout bounds one collect-and-ship tick.
|
||||
Timeout time.Duration `mapstructure:"timeout"`
|
||||
|
||||
// CatchupMaxDaysPerTick caps sealed-day catchup work per tick.
|
||||
CatchupMaxDaysPerTick int `mapstructure:"catchup_max_days_per_tick"`
|
||||
}
|
||||
|
||||
func newConfig() factory.Config {
|
||||
return Config{
|
||||
Interval: 6 * time.Hour,
|
||||
Timeout: 5 * time.Minute,
|
||||
CatchupMaxDaysPerTick: 180,
|
||||
}
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
return factory.NewConfigFactory(factory.MustNewName("meterreporter"), newConfig)
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.Interval < 5*time.Minute {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput, "meterreporter::interval must be at least 5m")
|
||||
}
|
||||
|
||||
if c.Timeout < 3*time.Minute {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput, "meterreporter::timeout must be at least 3m")
|
||||
}
|
||||
|
||||
if c.Timeout >= c.Interval {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput, "meterreporter::timeout must be less than meterreporter::interval")
|
||||
}
|
||||
|
||||
if c.CatchupMaxDaysPerTick < 1 || c.CatchupMaxDaysPerTick > 180 {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput, "meterreporter::catchup_max_days_per_tick must be between 1 and 180")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package meterreporter
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeInvalidInput = errors.MustNewCode("meterreporter_invalid_input")
|
||||
)
|
||||
|
||||
type Reporter interface {
|
||||
factory.ServiceWithHealthy
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package noopmeterreporter
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/meterreporter"
|
||||
)
|
||||
|
||||
type provider struct {
|
||||
healthyC chan struct{}
|
||||
stopC chan struct{}
|
||||
}
|
||||
|
||||
func NewFactory() factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("noop"), New)
|
||||
}
|
||||
|
||||
func New(_ context.Context, _ factory.ProviderSettings, _ meterreporter.Config) (meterreporter.Reporter, error) {
|
||||
return &provider{
|
||||
healthyC: make(chan struct{}),
|
||||
stopC: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *provider) Start(_ context.Context) error {
|
||||
close(p.healthyC)
|
||||
<-p.stopC
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *provider) Stop(_ context.Context) error {
|
||||
close(p.stopC)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *provider) Healthy() <-chan struct{} {
|
||||
return p.healthyC
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
body := new(authtypes.UpdateableAuthDomain)
|
||||
body := new(authtypes.UpdatableAuthDomain)
|
||||
if err := binding.JSON.BindBody(r.Body, body); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -21,9 +21,9 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils/timestamp"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
@@ -1342,7 +1342,7 @@ func getLocalTableName(tableName string) string {
|
||||
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) setTTLLogs(ctx context.Context, orgID string, params *retentiontypes.TTLParams) (*retentiontypes.SetTTLResponseItem, *model.ApiError) {
|
||||
func (r *ClickHouseReader) setTTLLogs(ctx context.Context, orgID string, params *model.TTLParams) (*model.SetTTLResponseItem, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
@@ -1377,7 +1377,7 @@ func (r *ClickHouseReader) setTTLLogs(ctx context.Context, orgID string, params
|
||||
if apiErr != nil {
|
||||
return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in processing ttl_status check sql query")}
|
||||
}
|
||||
if statusItem.Status == retentiontypes.TTLSettingStatusPending {
|
||||
if statusItem.Status == constants.StatusPending {
|
||||
return nil, &model.ApiError{Typ: model.ErrorConflict, Err: fmt.Errorf("TTL is already running")}
|
||||
}
|
||||
}
|
||||
@@ -1425,14 +1425,18 @@ func (r *ClickHouseReader) setTTLLogs(ctx context.Context, orgID string, params
|
||||
// we will change ttl for only the new parts and not the old ones
|
||||
query += " SETTINGS materialize_ttl_after_modify=0"
|
||||
|
||||
ttl := retentiontypes.TTLSetting{
|
||||
ID: valuer.GenerateUUID(),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
ttl := types.TTLSetting{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
TransactionID: uuid,
|
||||
TableName: tableName,
|
||||
TTL: int(params.DelDuration),
|
||||
Status: retentiontypes.TTLSettingStatusPending,
|
||||
Status: constants.StatusPending,
|
||||
ColdStorageTTL: coldStorageDuration,
|
||||
OrgID: orgID,
|
||||
}
|
||||
@@ -1456,9 +1460,9 @@ func (r *ClickHouseReader) setTTLLogs(ctx context.Context, orgID string, params
|
||||
sqlDB.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(new(retentiontypes.TTLSetting)).
|
||||
Model(new(types.TTLSetting)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Set("status = ?", retentiontypes.TTLSettingStatusFailed).
|
||||
Set("status = ?", constants.StatusFailed).
|
||||
Where("id = ?", statusItem.ID.StringValue()).
|
||||
Exec(ctx)
|
||||
if dbErr != nil {
|
||||
@@ -1476,9 +1480,9 @@ func (r *ClickHouseReader) setTTLLogs(ctx context.Context, orgID string, params
|
||||
sqlDB.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(new(retentiontypes.TTLSetting)).
|
||||
Model(new(types.TTLSetting)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Set("status = ?", retentiontypes.TTLSettingStatusFailed).
|
||||
Set("status = ?", constants.StatusFailed).
|
||||
Where("id = ?", statusItem.ID.StringValue()).
|
||||
Exec(ctx)
|
||||
if dbErr != nil {
|
||||
@@ -1491,9 +1495,9 @@ func (r *ClickHouseReader) setTTLLogs(ctx context.Context, orgID string, params
|
||||
sqlDB.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(new(retentiontypes.TTLSetting)).
|
||||
Model(new(types.TTLSetting)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Set("status = ?", retentiontypes.TTLSettingStatusSuccess).
|
||||
Set("status = ?", constants.StatusSuccess).
|
||||
Where("id = ?", statusItem.ID.StringValue()).
|
||||
Exec(ctx)
|
||||
if dbErr != nil {
|
||||
@@ -1503,10 +1507,10 @@ func (r *ClickHouseReader) setTTLLogs(ctx context.Context, orgID string, params
|
||||
}
|
||||
|
||||
}(ttlPayload)
|
||||
return &retentiontypes.SetTTLResponseItem{Message: "move ttl has been successfully set up"}, nil
|
||||
return &model.SetTTLResponseItem{Message: "move ttl has been successfully set up"}, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) setTTLTraces(ctx context.Context, orgID string, params *retentiontypes.TTLParams) (*retentiontypes.SetTTLResponseItem, *model.ApiError) {
|
||||
func (r *ClickHouseReader) setTTLTraces(ctx context.Context, orgID string, params *model.TTLParams) (*model.SetTTLResponseItem, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
@@ -1536,7 +1540,7 @@ func (r *ClickHouseReader) setTTLTraces(ctx context.Context, orgID string, param
|
||||
if apiErr != nil {
|
||||
return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in processing ttl_status check sql query")}
|
||||
}
|
||||
if statusItem.Status == retentiontypes.TTLSettingStatusPending {
|
||||
if statusItem.Status == constants.StatusPending {
|
||||
return nil, &model.ApiError{Typ: model.ErrorConflict, Err: fmt.Errorf("TTL is already running")}
|
||||
}
|
||||
}
|
||||
@@ -1559,14 +1563,18 @@ func (r *ClickHouseReader) setTTLTraces(ctx context.Context, orgID string, param
|
||||
timestamp = "end"
|
||||
}
|
||||
|
||||
ttl := retentiontypes.TTLSetting{
|
||||
ID: valuer.GenerateUUID(),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
ttl := types.TTLSetting{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
TransactionID: uuid,
|
||||
TableName: tableName,
|
||||
TTL: int(params.DelDuration),
|
||||
Status: retentiontypes.TTLSettingStatusPending,
|
||||
Status: constants.StatusPending,
|
||||
ColdStorageTTL: coldStorageDuration,
|
||||
OrgID: orgID,
|
||||
}
|
||||
@@ -1602,9 +1610,9 @@ func (r *ClickHouseReader) setTTLTraces(ctx context.Context, orgID string, param
|
||||
sqlDB.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(new(retentiontypes.TTLSetting)).
|
||||
Model(new(types.TTLSetting)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Set("status = ?", retentiontypes.TTLSettingStatusFailed).
|
||||
Set("status = ?", constants.StatusFailed).
|
||||
Where("id = ?", statusItem.ID.StringValue()).
|
||||
Exec(ctx)
|
||||
if dbErr != nil {
|
||||
@@ -1623,9 +1631,9 @@ func (r *ClickHouseReader) setTTLTraces(ctx context.Context, orgID string, param
|
||||
sqlDB.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(new(retentiontypes.TTLSetting)).
|
||||
Model(new(types.TTLSetting)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Set("status = ?", retentiontypes.TTLSettingStatusFailed).
|
||||
Set("status = ?", constants.StatusFailed).
|
||||
Where("id = ?", statusItem.ID.StringValue()).
|
||||
Exec(ctx)
|
||||
if dbErr != nil {
|
||||
@@ -1638,9 +1646,9 @@ func (r *ClickHouseReader) setTTLTraces(ctx context.Context, orgID string, param
|
||||
sqlDB.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(new(retentiontypes.TTLSetting)).
|
||||
Model(new(types.TTLSetting)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Set("status = ?", retentiontypes.TTLSettingStatusSuccess).
|
||||
Set("status = ?", constants.StatusSuccess).
|
||||
Where("id = ?", statusItem.ID.StringValue()).
|
||||
Exec(ctx)
|
||||
if dbErr != nil {
|
||||
@@ -1649,7 +1657,7 @@ func (r *ClickHouseReader) setTTLTraces(ctx context.Context, orgID string, param
|
||||
}
|
||||
}(distributedTableName)
|
||||
}
|
||||
return &retentiontypes.SetTTLResponseItem{Message: "move ttl has been successfully set up"}, nil
|
||||
return &model.SetTTLResponseItem{Message: "move ttl has been successfully set up"}, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) hasCustomRetentionColumn(ctx context.Context) (bool, error) {
|
||||
@@ -1678,7 +1686,7 @@ func (r *ClickHouseReader) hasCustomRetentionColumn(ctx context.Context) (bool,
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) SetTTLV2(ctx context.Context, orgID string, params *retentiontypes.CustomRetentionTTLParams) (*retentiontypes.CustomRetentionTTLResponse, error) {
|
||||
func (r *ClickHouseReader) SetTTLV2(ctx context.Context, orgID string, params *model.CustomRetentionTTLParams) (*model.CustomRetentionTTLResponse, error) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
|
||||
@@ -1693,7 +1701,7 @@ func (r *ClickHouseReader) SetTTLV2(ctx context.Context, orgID string, params *r
|
||||
if !hasCustomRetention {
|
||||
r.logger.Info("Custom retention not supported, falling back to standard TTL method", "orgID", orgID)
|
||||
|
||||
ttlParams := &retentiontypes.TTLParams{
|
||||
ttlParams := &model.TTLParams{
|
||||
Type: params.Type,
|
||||
DelDuration: int64(params.DefaultTTLDays * 24 * 3600),
|
||||
}
|
||||
@@ -1714,7 +1722,7 @@ func (r *ClickHouseReader) SetTTLV2(ctx context.Context, orgID string, params *r
|
||||
return nil, errorsV2.Wrapf(apiErr.Err, errorsV2.TypeInternal, errorsV2.CodeInternal, "failed to set standard TTL")
|
||||
}
|
||||
|
||||
return &retentiontypes.CustomRetentionTTLResponse{
|
||||
return &model.CustomRetentionTTLResponse{
|
||||
Message: fmt.Sprintf("Custom retention not supported, applied standard TTL of %d days. %s", params.DefaultTTLDays, ttlResult.Message),
|
||||
}, nil
|
||||
}
|
||||
@@ -1725,7 +1733,7 @@ func (r *ClickHouseReader) SetTTLV2(ctx context.Context, orgID string, params *r
|
||||
uuidWithHyphen := valuer.GenerateUUID()
|
||||
uuid := strings.Replace(uuidWithHyphen.String(), "-", "", -1)
|
||||
|
||||
if params.Type != retentiontypes.LogsTTL {
|
||||
if params.Type != constants.LogsTTL {
|
||||
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "custom retention TTL only supported for logs")
|
||||
}
|
||||
|
||||
@@ -1756,7 +1764,7 @@ func (r *ClickHouseReader) SetTTLV2(ctx context.Context, orgID string, params *r
|
||||
if apiErr != nil {
|
||||
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "error in processing custom_retention_ttl_status check sql query")
|
||||
}
|
||||
if statusItem.Status == retentiontypes.TTLSettingStatusPending {
|
||||
if statusItem.Status == constants.StatusPending {
|
||||
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "custom retention TTL is already running")
|
||||
}
|
||||
}
|
||||
@@ -1830,15 +1838,19 @@ func (r *ClickHouseReader) SetTTLV2(ctx context.Context, orgID string, params *r
|
||||
}
|
||||
|
||||
for tableName, queries := range ttlPayload {
|
||||
customTTL := retentiontypes.TTLSetting{
|
||||
ID: valuer.GenerateUUID(),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
customTTL := types.TTLSetting{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
TransactionID: uuid,
|
||||
TableName: tableName,
|
||||
TTL: params.DefaultTTLDays,
|
||||
Condition: string(ttlConditionsJSON),
|
||||
Status: retentiontypes.TTLSettingStatusPending,
|
||||
Status: constants.StatusPending,
|
||||
ColdStorageTTL: coldStorageDuration,
|
||||
OrgID: orgID,
|
||||
}
|
||||
@@ -1854,7 +1866,7 @@ func (r *ClickHouseReader) SetTTLV2(ctx context.Context, orgID string, params *r
|
||||
err := r.setColdStorage(ctx, tableName, params.ColdStorageVolume)
|
||||
if err != nil {
|
||||
r.logger.Error("error in setting cold storage", errorsV2.Attr(err))
|
||||
r.updateCustomRetentionTTLStatus(ctx, orgID, tableName, retentiontypes.TTLSettingStatusFailed)
|
||||
r.updateCustomRetentionTTLStatus(ctx, orgID, tableName, constants.StatusFailed)
|
||||
return nil, errorsV2.Wrapf(err.Err, errorsV2.TypeInternal, errorsV2.CodeInternal, "error setting cold storage for table %s", tableName)
|
||||
}
|
||||
}
|
||||
@@ -1863,21 +1875,21 @@ func (r *ClickHouseReader) SetTTLV2(ctx context.Context, orgID string, params *r
|
||||
r.logger.Debug("Executing custom retention TTL request: ", "request", query, "step", i+1)
|
||||
if err := r.db.Exec(ctx, query); err != nil {
|
||||
r.logger.Error("error while setting custom retention ttl", errorsV2.Attr(err))
|
||||
r.updateCustomRetentionTTLStatus(ctx, orgID, tableName, retentiontypes.TTLSettingStatusFailed)
|
||||
r.updateCustomRetentionTTLStatus(ctx, orgID, tableName, constants.StatusFailed)
|
||||
return nil, errorsV2.Wrapf(err, errorsV2.TypeInternal, errorsV2.CodeInternal, "error setting custom retention TTL for table %s, query: %s", tableName, query)
|
||||
}
|
||||
}
|
||||
|
||||
r.updateCustomRetentionTTLStatus(ctx, orgID, tableName, retentiontypes.TTLSettingStatusSuccess)
|
||||
r.updateCustomRetentionTTLStatus(ctx, orgID, tableName, constants.StatusSuccess)
|
||||
}
|
||||
|
||||
return &retentiontypes.CustomRetentionTTLResponse{
|
||||
return &model.CustomRetentionTTLResponse{
|
||||
Message: "custom retention TTL has been successfully set up",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// New method to build multiIf expressions with support for multiple AND conditions
|
||||
func (r *ClickHouseReader) buildMultiIfExpression(ttlConditions []retentiontypes.CustomRetentionRule, defaultTTLDays int, isResourceTable bool) string {
|
||||
func (r *ClickHouseReader) buildMultiIfExpression(ttlConditions []model.CustomRetentionRule, defaultTTLDays int, isResourceTable bool) string {
|
||||
var conditions []string
|
||||
|
||||
for i, rule := range ttlConditions {
|
||||
@@ -1949,7 +1961,7 @@ func (r *ClickHouseReader) buildMultiIfExpression(ttlConditions []retentiontypes
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetCustomRetentionTTL(ctx context.Context, orgID string) (*retentiontypes.GetCustomRetentionTTLResponse, error) {
|
||||
func (r *ClickHouseReader) GetCustomRetentionTTL(ctx context.Context, orgID string) (*model.GetCustomRetentionTTLResponse, error) {
|
||||
// Check if V2 (custom retention) is supported
|
||||
hasCustomRetention, err := r.hasCustomRetentionColumn(ctx)
|
||||
if err != nil {
|
||||
@@ -1958,14 +1970,14 @@ func (r *ClickHouseReader) GetCustomRetentionTTL(ctx context.Context, orgID stri
|
||||
hasCustomRetention = false
|
||||
}
|
||||
|
||||
response := &retentiontypes.GetCustomRetentionTTLResponse{}
|
||||
response := &model.GetCustomRetentionTTLResponse{}
|
||||
|
||||
if hasCustomRetention {
|
||||
// V2 - Custom retention is supported
|
||||
response.Version = "v2"
|
||||
|
||||
// Get the latest custom retention TTL setting
|
||||
customTTL := new(retentiontypes.TTLSetting)
|
||||
customTTL := new(types.TTLSetting)
|
||||
err := r.sqlDB.BunDB().NewSelect().
|
||||
Model(customTTL).
|
||||
Where("org_id = ?", orgID).
|
||||
@@ -1981,19 +1993,19 @@ func (r *ClickHouseReader) GetCustomRetentionTTL(ctx context.Context, orgID stri
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
// No V2 configuration found, return defaults
|
||||
response.DefaultTTLDays = retentiontypes.DefaultLogsRetentionDays
|
||||
response.TTLConditions = []retentiontypes.CustomRetentionRule{}
|
||||
response.Status = retentiontypes.TTLSettingStatusSuccess
|
||||
response.DefaultTTLDays = 15
|
||||
response.TTLConditions = []model.CustomRetentionRule{}
|
||||
response.Status = constants.StatusSuccess
|
||||
response.ColdStorageTTLDays = -1
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Parse TTL conditions from Condition
|
||||
var ttlConditions []retentiontypes.CustomRetentionRule
|
||||
var ttlConditions []model.CustomRetentionRule
|
||||
if customTTL.Condition != "" {
|
||||
if err := json.Unmarshal([]byte(customTTL.Condition), &ttlConditions); err != nil {
|
||||
r.logger.Error("Error parsing TTL conditions", errorsV2.Attr(err))
|
||||
ttlConditions = []retentiontypes.CustomRetentionRule{}
|
||||
ttlConditions = []model.CustomRetentionRule{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2007,8 +2019,8 @@ func (r *ClickHouseReader) GetCustomRetentionTTL(ctx context.Context, orgID stri
|
||||
response.Version = "v1"
|
||||
|
||||
// Get V1 TTL configuration
|
||||
ttlParams := &retentiontypes.GetTTLParams{
|
||||
Type: retentiontypes.LogsTTL,
|
||||
ttlParams := &model.GetTTLParams{
|
||||
Type: constants.LogsTTL,
|
||||
}
|
||||
|
||||
ttlResult, apiErr := r.GetTTL(ctx, orgID, ttlParams)
|
||||
@@ -2028,14 +2040,14 @@ func (r *ClickHouseReader) GetCustomRetentionTTL(ctx context.Context, orgID stri
|
||||
}
|
||||
|
||||
// For V1, we don't have TTL conditions
|
||||
response.TTLConditions = []retentiontypes.CustomRetentionRule{}
|
||||
response.TTLConditions = []model.CustomRetentionRule{}
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) checkCustomRetentionTTLStatusItem(ctx context.Context, orgID string, tableName string) (*retentiontypes.TTLSetting, error) {
|
||||
ttl := new(retentiontypes.TTLSetting)
|
||||
func (r *ClickHouseReader) checkCustomRetentionTTLStatusItem(ctx context.Context, orgID string, tableName string) (*types.TTLSetting, error) {
|
||||
ttl := new(types.TTLSetting)
|
||||
err := r.sqlDB.BunDB().NewSelect().
|
||||
Model(ttl).
|
||||
Where("table_name = ?", tableName).
|
||||
@@ -2056,7 +2068,7 @@ func (r *ClickHouseReader) updateCustomRetentionTTLStatus(ctx context.Context, o
|
||||
statusItem, apiErr := r.checkCustomRetentionTTLStatusItem(ctx, orgID, tableName)
|
||||
if apiErr == nil && statusItem != nil {
|
||||
_, dbErr := r.sqlDB.BunDB().NewUpdate().
|
||||
Model(new(retentiontypes.TTLSetting)).
|
||||
Model(new(types.TTLSetting)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Set("status = ?", status).
|
||||
Where("id = ?", statusItem.ID.StringValue()).
|
||||
@@ -2068,7 +2080,7 @@ func (r *ClickHouseReader) updateCustomRetentionTTLStatus(ctx context.Context, o
|
||||
}
|
||||
|
||||
// Enhanced validation function with duplicate detection and efficient key validation
|
||||
func (r *ClickHouseReader) validateTTLConditions(ctx context.Context, ttlConditions []retentiontypes.CustomRetentionRule) error {
|
||||
func (r *ClickHouseReader) validateTTLConditions(ctx context.Context, ttlConditions []model.CustomRetentionRule) error {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "validateTTLConditions",
|
||||
@@ -2172,16 +2184,16 @@ func (r *ClickHouseReader) validateTTLConditions(ctx context.Context, ttlConditi
|
||||
// SetTTL sets the TTL for traces or metrics or logs tables.
|
||||
// This is an async API which creates goroutines to set TTL.
|
||||
// Status of TTL update is tracked with ttl_status table in sqlite db.
|
||||
func (r *ClickHouseReader) SetTTL(ctx context.Context, orgID string, params *retentiontypes.TTLParams) (*retentiontypes.SetTTLResponseItem, *model.ApiError) {
|
||||
func (r *ClickHouseReader) SetTTL(ctx context.Context, orgID string, params *model.TTLParams) (*model.SetTTLResponseItem, *model.ApiError) {
|
||||
// Keep only latest 100 transactions/requests
|
||||
r.deleteTtlTransactions(ctx, orgID, 100)
|
||||
|
||||
switch params.Type {
|
||||
case retentiontypes.TraceTTL:
|
||||
case constants.TraceTTL:
|
||||
return r.setTTLTraces(ctx, orgID, params)
|
||||
case retentiontypes.MetricsTTL:
|
||||
case constants.MetricsTTL:
|
||||
return r.setTTLMetrics(ctx, orgID, params)
|
||||
case retentiontypes.LogsTTL:
|
||||
case constants.LogsTTL:
|
||||
return r.setTTLLogs(ctx, orgID, params)
|
||||
default:
|
||||
return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error while setting ttl. ttl type should be <metrics|traces>, got %v", params.Type)}
|
||||
@@ -2189,7 +2201,7 @@ func (r *ClickHouseReader) SetTTL(ctx context.Context, orgID string, params *ret
|
||||
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) setTTLMetrics(ctx context.Context, orgID string, params *retentiontypes.TTLParams) (*retentiontypes.SetTTLResponseItem, *model.ApiError) {
|
||||
func (r *ClickHouseReader) setTTLMetrics(ctx context.Context, orgID string, params *model.TTLParams) (*model.SetTTLResponseItem, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
@@ -2218,19 +2230,23 @@ func (r *ClickHouseReader) setTTLMetrics(ctx context.Context, orgID string, para
|
||||
if apiErr != nil {
|
||||
return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in processing ttl_status check sql query")}
|
||||
}
|
||||
if statusItem.Status == retentiontypes.TTLSettingStatusPending {
|
||||
if statusItem.Status == constants.StatusPending {
|
||||
return nil, &model.ApiError{Typ: model.ErrorConflict, Err: fmt.Errorf("TTL is already running")}
|
||||
}
|
||||
}
|
||||
metricTTL := func(tableName string) {
|
||||
ttl := retentiontypes.TTLSetting{
|
||||
ID: valuer.GenerateUUID(),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
ttl := types.TTLSetting{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
TransactionID: uuid,
|
||||
TableName: tableName,
|
||||
TTL: int(params.DelDuration),
|
||||
Status: retentiontypes.TTLSettingStatusPending,
|
||||
Status: constants.StatusPending,
|
||||
ColdStorageTTL: coldStorageDuration,
|
||||
OrgID: orgID,
|
||||
}
|
||||
@@ -2266,9 +2282,9 @@ func (r *ClickHouseReader) setTTLMetrics(ctx context.Context, orgID string, para
|
||||
sqlDB.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(new(retentiontypes.TTLSetting)).
|
||||
Model(new(types.TTLSetting)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Set("status = ?", retentiontypes.TTLSettingStatusFailed).
|
||||
Set("status = ?", constants.StatusFailed).
|
||||
Where("id = ?", statusItem.ID.StringValue()).
|
||||
Exec(ctx)
|
||||
if dbErr != nil {
|
||||
@@ -2287,9 +2303,9 @@ func (r *ClickHouseReader) setTTLMetrics(ctx context.Context, orgID string, para
|
||||
sqlDB.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(new(retentiontypes.TTLSetting)).
|
||||
Model(new(types.TTLSetting)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Set("status = ?", retentiontypes.TTLSettingStatusFailed).
|
||||
Set("status = ?", constants.StatusFailed).
|
||||
Where("id = ?", statusItem.ID.StringValue()).
|
||||
Exec(ctx)
|
||||
if dbErr != nil {
|
||||
@@ -2302,9 +2318,9 @@ func (r *ClickHouseReader) setTTLMetrics(ctx context.Context, orgID string, para
|
||||
sqlDB.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(new(retentiontypes.TTLSetting)).
|
||||
Model(new(types.TTLSetting)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Set("status = ?", retentiontypes.TTLSettingStatusSuccess).
|
||||
Set("status = ?", constants.StatusSuccess).
|
||||
Where("id = ?", statusItem.ID.StringValue()).
|
||||
Exec(ctx)
|
||||
if dbErr != nil {
|
||||
@@ -2315,7 +2331,7 @@ func (r *ClickHouseReader) setTTLMetrics(ctx context.Context, orgID string, para
|
||||
for _, tableName := range tableNames {
|
||||
go metricTTL(tableName)
|
||||
}
|
||||
return &retentiontypes.SetTTLResponseItem{Message: "move ttl has been successfully set up"}, nil
|
||||
return &model.SetTTLResponseItem{Message: "move ttl has been successfully set up"}, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) deleteTtlTransactions(ctx context.Context, orgID string, numberOfTransactionsStore int) {
|
||||
@@ -2325,7 +2341,7 @@ func (r *ClickHouseReader) deleteTtlTransactions(ctx context.Context, orgID stri
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Column("transaction_id").
|
||||
Model(new(retentiontypes.TTLSetting)).
|
||||
Model(new(types.TTLSetting)).
|
||||
Where("org_id = ?", orgID).
|
||||
Group("transaction_id").
|
||||
OrderExpr("MAX(created_at) DESC").
|
||||
@@ -2340,7 +2356,7 @@ func (r *ClickHouseReader) deleteTtlTransactions(ctx context.Context, orgID stri
|
||||
sqlDB.
|
||||
BunDB().
|
||||
NewDelete().
|
||||
Model(new(retentiontypes.TTLSetting)).
|
||||
Model(new(types.TTLSetting)).
|
||||
Where("transaction_id NOT IN (?)", bun.In(limitTransactions)).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
@@ -2349,9 +2365,9 @@ func (r *ClickHouseReader) deleteTtlTransactions(ctx context.Context, orgID stri
|
||||
}
|
||||
|
||||
// checkTTLStatusItem checks if ttl_status table has an entry for the given table name
|
||||
func (r *ClickHouseReader) checkTTLStatusItem(ctx context.Context, orgID string, tableName string) (*retentiontypes.TTLSetting, *model.ApiError) {
|
||||
func (r *ClickHouseReader) checkTTLStatusItem(ctx context.Context, orgID string, tableName string) (*types.TTLSetting, *model.ApiError) {
|
||||
r.logger.Info("checkTTLStatusItem query", "tableName", tableName)
|
||||
ttl := new(retentiontypes.TTLSetting)
|
||||
ttl := new(types.TTLSetting)
|
||||
err := r.
|
||||
sqlDB.
|
||||
BunDB().
|
||||
@@ -2372,26 +2388,26 @@ func (r *ClickHouseReader) checkTTLStatusItem(ctx context.Context, orgID string,
|
||||
// getTTLQueryStatus fetches ttl_status table status from DB
|
||||
func (r *ClickHouseReader) getTTLQueryStatus(ctx context.Context, orgID string, tableNameArray []string) (string, *model.ApiError) {
|
||||
failFlag := false
|
||||
status := retentiontypes.TTLSettingStatusSuccess
|
||||
status := constants.StatusSuccess
|
||||
for _, tableName := range tableNameArray {
|
||||
statusItem, apiErr := r.checkTTLStatusItem(ctx, orgID, tableName)
|
||||
emptyStatusStruct := new(retentiontypes.TTLSetting)
|
||||
emptyStatusStruct := new(types.TTLSetting)
|
||||
if statusItem == emptyStatusStruct {
|
||||
return "", nil
|
||||
}
|
||||
if apiErr != nil {
|
||||
return "", &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in processing ttl_status check sql query")}
|
||||
}
|
||||
if statusItem.Status == retentiontypes.TTLSettingStatusPending && statusItem.UpdatedAt.Unix()-time.Now().Unix() < 3600 {
|
||||
status = retentiontypes.TTLSettingStatusPending
|
||||
if statusItem.Status == constants.StatusPending && statusItem.UpdatedAt.Unix()-time.Now().Unix() < 3600 {
|
||||
status = constants.StatusPending
|
||||
return status, nil
|
||||
}
|
||||
if statusItem.Status == retentiontypes.TTLSettingStatusFailed {
|
||||
if statusItem.Status == constants.StatusFailed {
|
||||
failFlag = true
|
||||
}
|
||||
}
|
||||
if failFlag {
|
||||
status = retentiontypes.TTLSettingStatusFailed
|
||||
status = constants.StatusFailed
|
||||
}
|
||||
|
||||
return status, nil
|
||||
@@ -2444,7 +2460,7 @@ func getLocalTableNameArray(tableNames []string) []string {
|
||||
}
|
||||
|
||||
// GetTTL returns current ttl, expected ttl and past setTTL status for metrics/traces.
|
||||
func (r *ClickHouseReader) GetTTL(ctx context.Context, orgID string, ttlParams *retentiontypes.GetTTLParams) (*retentiontypes.GetTTLResponseItem, *model.ApiError) {
|
||||
func (r *ClickHouseReader) GetTTL(ctx context.Context, orgID string, ttlParams *model.GetTTLParams) (*model.GetTTLResponseItem, *model.ApiError) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
@@ -2479,8 +2495,8 @@ func (r *ClickHouseReader) GetTTL(ctx context.Context, orgID string, ttlParams *
|
||||
return delTTL, moveTTL
|
||||
}
|
||||
|
||||
getMetricsTTL := func() (*retentiontypes.DBResponseTTL, *model.ApiError) {
|
||||
var dbResp []retentiontypes.DBResponseTTL
|
||||
getMetricsTTL := func() (*model.DBResponseTTL, *model.ApiError) {
|
||||
var dbResp []model.DBResponseTTL
|
||||
|
||||
query := fmt.Sprintf("SELECT engine_full FROM system.tables WHERE name='%v'", signozSampleLocalTableName)
|
||||
|
||||
@@ -2497,8 +2513,8 @@ func (r *ClickHouseReader) GetTTL(ctx context.Context, orgID string, ttlParams *
|
||||
}
|
||||
}
|
||||
|
||||
getTracesTTL := func() (*retentiontypes.DBResponseTTL, *model.ApiError) {
|
||||
var dbResp []retentiontypes.DBResponseTTL
|
||||
getTracesTTL := func() (*model.DBResponseTTL, *model.ApiError) {
|
||||
var dbResp []model.DBResponseTTL
|
||||
|
||||
query := fmt.Sprintf("SELECT engine_full FROM system.tables WHERE name='%v' AND database='%v'", r.traceLocalTableName, signozTraceDBName)
|
||||
|
||||
@@ -2515,8 +2531,8 @@ func (r *ClickHouseReader) GetTTL(ctx context.Context, orgID string, ttlParams *
|
||||
}
|
||||
}
|
||||
|
||||
getLogsTTL := func() (*retentiontypes.DBResponseTTL, *model.ApiError) {
|
||||
var dbResp []retentiontypes.DBResponseTTL
|
||||
getLogsTTL := func() (*model.DBResponseTTL, *model.ApiError) {
|
||||
var dbResp []model.DBResponseTTL
|
||||
|
||||
query := fmt.Sprintf("SELECT engine_full FROM system.tables WHERE name='%v' AND database='%v'", r.logsLocalTableName, r.logsDB)
|
||||
|
||||
@@ -2534,7 +2550,7 @@ func (r *ClickHouseReader) GetTTL(ctx context.Context, orgID string, ttlParams *
|
||||
}
|
||||
|
||||
switch ttlParams.Type {
|
||||
case retentiontypes.TraceTTL:
|
||||
case constants.TraceTTL:
|
||||
tableNameArray := []string{
|
||||
r.TraceDB + "." + r.traceTableName,
|
||||
r.TraceDB + "." + r.traceResourceTableV3,
|
||||
@@ -2562,9 +2578,9 @@ func (r *ClickHouseReader) GetTTL(ctx context.Context, orgID string, ttlParams *
|
||||
}
|
||||
|
||||
delTTL, moveTTL := parseTTL(dbResp.EngineFull)
|
||||
return &retentiontypes.GetTTLResponseItem{TracesTime: delTTL, TracesMoveTime: moveTTL, ExpectedTracesTime: ttlQuery.TTL, ExpectedTracesMoveTime: ttlQuery.ColdStorageTTL, Status: status}, nil
|
||||
return &model.GetTTLResponseItem{TracesTime: delTTL, TracesMoveTime: moveTTL, ExpectedTracesTime: ttlQuery.TTL, ExpectedTracesMoveTime: ttlQuery.ColdStorageTTL, Status: status}, nil
|
||||
|
||||
case retentiontypes.MetricsTTL:
|
||||
case constants.MetricsTTL:
|
||||
tableNameArray := []string{signozMetricDBName + "." + signozSampleTableName}
|
||||
tableNameArray = getLocalTableNameArray(tableNameArray)
|
||||
status, apiErr := r.getTTLQueryStatus(ctx, orgID, tableNameArray)
|
||||
@@ -2585,9 +2601,9 @@ func (r *ClickHouseReader) GetTTL(ctx context.Context, orgID string, ttlParams *
|
||||
}
|
||||
|
||||
delTTL, moveTTL := parseTTL(dbResp.EngineFull)
|
||||
return &retentiontypes.GetTTLResponseItem{MetricsTime: delTTL, MetricsMoveTime: moveTTL, ExpectedMetricsTime: ttlQuery.TTL, ExpectedMetricsMoveTime: ttlQuery.ColdStorageTTL, Status: status}, nil
|
||||
return &model.GetTTLResponseItem{MetricsTime: delTTL, MetricsMoveTime: moveTTL, ExpectedMetricsTime: ttlQuery.TTL, ExpectedMetricsMoveTime: ttlQuery.ColdStorageTTL, Status: status}, nil
|
||||
|
||||
case retentiontypes.LogsTTL:
|
||||
case constants.LogsTTL:
|
||||
tableNameArray := []string{r.logsDB + "." + r.logsTableName}
|
||||
tableNameArray = getLocalTableNameArray(tableNameArray)
|
||||
status, apiErr := r.getTTLQueryStatus(ctx, orgID, tableNameArray)
|
||||
@@ -2608,7 +2624,7 @@ func (r *ClickHouseReader) GetTTL(ctx context.Context, orgID string, ttlParams *
|
||||
}
|
||||
|
||||
delTTL, moveTTL := parseTTL(dbResp.EngineFull)
|
||||
return &retentiontypes.GetTTLResponseItem{LogsTime: delTTL, LogsMoveTime: moveTTL, ExpectedLogsTime: ttlQuery.TTL, ExpectedLogsMoveTime: ttlQuery.ColdStorageTTL, Status: status}, nil
|
||||
return &model.GetTTLResponseItem{LogsTime: delTTL, LogsMoveTime: moveTTL, ExpectedLogsTime: ttlQuery.TTL, ExpectedLogsMoveTime: ttlQuery.ColdStorageTTL, Status: status}, nil
|
||||
|
||||
default:
|
||||
return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error while getting ttl. ttl type should be metrics|traces, got %v",
|
||||
|
||||
@@ -34,7 +34,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations/services"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@@ -1678,7 +1677,7 @@ func (aH *APIHandler) setCustomRetentionTTL(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
var params retentiontypes.CustomRetentionTTLParams
|
||||
var params model.CustomRetentionTTLParams
|
||||
if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
|
||||
render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, "Invalid data"))
|
||||
return
|
||||
|
||||
@@ -40,7 +40,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/postprocess"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
querytemplate "github.com/SigNoz/signoz/pkg/query-service/utils/queryTemplate"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
chVariables "github.com/SigNoz/signoz/pkg/variables/clickhouse"
|
||||
)
|
||||
|
||||
@@ -420,7 +419,7 @@ func parseTime(param string, r *http.Request) (*time.Time, error) {
|
||||
|
||||
}
|
||||
|
||||
func parseTTLParams(r *http.Request) (*retentiontypes.TTLParams, error) {
|
||||
func parseTTLParams(r *http.Request) (*model.TTLParams, error) {
|
||||
|
||||
// make sure either of the query params are present
|
||||
typeTTL := r.URL.Query().Get("type")
|
||||
@@ -433,7 +432,7 @@ func parseTTLParams(r *http.Request) (*retentiontypes.TTLParams, error) {
|
||||
}
|
||||
|
||||
// Validate the type parameter
|
||||
if typeTTL != retentiontypes.TraceTTL && typeTTL != retentiontypes.MetricsTTL && typeTTL != retentiontypes.LogsTTL {
|
||||
if typeTTL != baseconstants.TraceTTL && typeTTL != baseconstants.MetricsTTL && typeTTL != baseconstants.LogsTTL {
|
||||
return nil, fmt.Errorf("type param should be metrics|traces|logs, got %v", typeTTL)
|
||||
}
|
||||
|
||||
@@ -456,7 +455,7 @@ func parseTTLParams(r *http.Request) (*retentiontypes.TTLParams, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return &retentiontypes.TTLParams{
|
||||
return &model.TTLParams{
|
||||
Type: typeTTL,
|
||||
DelDuration: int64(durationParsed.Seconds()),
|
||||
ColdStorageVolume: coldStorage,
|
||||
@@ -464,7 +463,7 @@ func parseTTLParams(r *http.Request) (*retentiontypes.TTLParams, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseGetTTL(r *http.Request) (*retentiontypes.GetTTLParams, error) {
|
||||
func parseGetTTL(r *http.Request) (*model.GetTTLParams, error) {
|
||||
|
||||
typeTTL := r.URL.Query().Get("type")
|
||||
|
||||
@@ -472,12 +471,12 @@ func parseGetTTL(r *http.Request) (*retentiontypes.GetTTLParams, error) {
|
||||
return nil, fmt.Errorf("type param cannot be empty from the query")
|
||||
} else {
|
||||
// Validate the type parameter
|
||||
if typeTTL != retentiontypes.TraceTTL && typeTTL != retentiontypes.MetricsTTL && typeTTL != retentiontypes.LogsTTL {
|
||||
if typeTTL != baseconstants.TraceTTL && typeTTL != baseconstants.MetricsTTL && typeTTL != baseconstants.LogsTTL {
|
||||
return nil, fmt.Errorf("type param should be metrics|traces|logs, got %v", typeTTL)
|
||||
}
|
||||
}
|
||||
|
||||
return &retentiontypes.GetTTLParams{Type: typeTTL}, nil
|
||||
return &model.GetTTLParams{Type: typeTTL}, nil
|
||||
}
|
||||
|
||||
func parseAggregateAttributeRequest(r *http.Request) (*v3.AggregateAttributeRequest, error) {
|
||||
|
||||
@@ -19,6 +19,10 @@ const (
|
||||
|
||||
const MaxAllowedPointsInTimeSeries = 300
|
||||
|
||||
const TraceTTL = "traces"
|
||||
const MetricsTTL = "metrics"
|
||||
const LogsTTL = "logs"
|
||||
|
||||
const SpanSearchScopeRoot = "isroot"
|
||||
const SpanSearchScopeEntryPoint = "isentrypoint"
|
||||
const OrderBySpanCount = "span_count"
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/querycache"
|
||||
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
"github.com/prometheus/prometheus/util/stats"
|
||||
@@ -24,8 +23,8 @@ type Reader interface {
|
||||
GetServicesList(ctx context.Context) (*[]string, error)
|
||||
GetDependencyGraph(ctx context.Context, query *model.GetServicesParams) (*[]model.ServiceMapDependencyResponseItem, error)
|
||||
|
||||
GetTTL(ctx context.Context, orgID string, ttlParams *retentiontypes.GetTTLParams) (*retentiontypes.GetTTLResponseItem, *model.ApiError)
|
||||
GetCustomRetentionTTL(ctx context.Context, orgID string) (*retentiontypes.GetCustomRetentionTTLResponse, error)
|
||||
GetTTL(ctx context.Context, orgID string, ttlParams *model.GetTTLParams) (*model.GetTTLResponseItem, *model.ApiError)
|
||||
GetCustomRetentionTTL(ctx context.Context, orgID string) (*model.GetCustomRetentionTTLResponse, error)
|
||||
|
||||
// GetDisks returns a list of disks configured in the underlying DB. It is supported by
|
||||
// clickhouse only.
|
||||
@@ -47,8 +46,8 @@ type Reader interface {
|
||||
GetFlamegraphSpansForTrace(ctx context.Context, orgID valuer.UUID, traceID string, req *model.GetFlamegraphSpansForTraceParams) (*model.GetFlamegraphSpansForTraceResponse, error)
|
||||
|
||||
// Setter Interfaces
|
||||
SetTTL(ctx context.Context, orgID string, ttlParams *retentiontypes.TTLParams) (*retentiontypes.SetTTLResponseItem, *model.ApiError)
|
||||
SetTTLV2(ctx context.Context, orgID string, params *retentiontypes.CustomRetentionTTLParams) (*retentiontypes.CustomRetentionTTLResponse, error)
|
||||
SetTTL(ctx context.Context, orgID string, ttlParams *model.TTLParams) (*model.SetTTLResponseItem, *model.ApiError)
|
||||
SetTTLV2(ctx context.Context, orgID string, params *model.CustomRetentionTTLParams) (*model.CustomRetentionTTLResponse, error)
|
||||
|
||||
FetchTemporality(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string]map[v3.Temporality]bool, error)
|
||||
GetMetricAggregateAttributes(ctx context.Context, orgID valuer.UUID, req *v3.AggregateAttributeRequest, skipSignozMetrics bool) (*v3.AggregateAttributeResponse, error)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user