Compare commits

...

3 Commits

Author SHA1 Message Date
Naman Verma
1c3b981179 chore: add api to retry migration for a dashboard 2026-08-04 00:09:40 +05:30
Naman Verma
cfaa7de165 fix: enforce the required tag on dashboard spec fields (#12381)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: enforace the required tag on dashboard spec fields

* test: add empty list and objects for required fields in integration tests
2026-08-03 18:22:43 +00:00
Naman Verma
5b3cc2400f chore: delta temporality metrics should always be considered as a sum metric (#12313)
* chore: delta temporality metrics should always be considered as a sum metric

* test: add integration tests

* test: parametrise the non-reduced test for rate and increase both

* test: add comment explaining last samples in test

* chore: remove unneeded comments

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-08-03 16:08:45 +00:00
31 changed files with 922 additions and 61 deletions

View File

@@ -15477,6 +15477,72 @@ paths:
summary: Lock dashboard (v2)
tags:
- dashboard
/api/v2/dashboards/{id}/migrate:
post:
deprecated: false
description: 'This endpoint retries the v1→v2 (Perses) migration on a dashboard
still stored in the v1 schema and returns the v2-shape result. It is idempotent:
a dashboard already in the v2 schema is returned unchanged.'
operationId: MigrateDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Migrate dashboard to v2
tags:
- dashboard
/api/v2/factor_password/forgot:
post:
deprecated: false

View File

@@ -276,6 +276,10 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
}
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.MigrateV2(ctx, orgID, id)
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updatable)
}

View File

@@ -52,6 +52,8 @@ import type {
ListDashboardsV2200,
ListDashboardsV2Params,
LockDashboardV2PathParameters,
MigrateDashboardV2200,
MigrateDashboardV2PathParameters,
PatchDashboardV2200,
PatchDashboardV2PathParameters,
PinDashboardV2PathParameters,
@@ -1804,6 +1806,85 @@ export const useLockDashboardV2 = <
> => {
return useMutation(getLockDashboardV2MutationOptions(options));
};
/**
* This endpoint retries the v1→v2 (Perses) migration on a dashboard still stored in the v1 schema and returns the v2-shape result. It is idempotent: a dashboard already in the v2 schema is returned unchanged.
* @summary Migrate dashboard to v2
*/
export const migrateDashboardV2 = (
{ id }: MigrateDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<MigrateDashboardV2200>({
url: `/api/v2/dashboards/${id}/migrate`,
method: 'POST',
signal,
});
};
export const getMigrateDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof migrateDashboardV2>>,
TError,
{ pathParams: MigrateDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof migrateDashboardV2>>,
TError,
{ pathParams: MigrateDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['migrateDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof migrateDashboardV2>>,
{ pathParams: MigrateDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return migrateDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type MigrateDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof migrateDashboardV2>>
>;
export type MigrateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Migrate dashboard to v2
*/
export const useMigrateDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof migrateDashboardV2>>,
TError,
{ pathParams: MigrateDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof migrateDashboardV2>>,
TError,
{ pathParams: MigrateDashboardV2PathParameters },
TContext
> => {
return useMutation(getMigrateDashboardV2MutationOptions(options));
};
/**
* This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.
* @summary Get public dashboard data (v2)

View File

@@ -11164,6 +11164,17 @@ export type UnlockDashboardV2PathParameters = {
export type LockDashboardV2PathParameters = {
id: string;
};
export type MigrateDashboardV2PathParameters = {
id: string;
};
export type MigrateDashboardV2200 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type GetFeatures200 = {
/**
* @type array

View File

@@ -1,5 +1,8 @@
// ** Helpers
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
@@ -389,11 +392,17 @@ const METRIC_TYPE_TO_ATTRIBUTE_TYPE: Record<
export function toAttributeType(
metricType: MetrictypesTypeDTO | undefined,
isMonotonic?: boolean,
temporality?: MetrictypesTemporalityDTO,
): ATTRIBUTE_TYPES | '' {
if (!metricType) {
return '';
}
if (metricType === MetrictypesTypeDTO.sum && isMonotonic === false) {
// Only non-monotonic cumulative sums are treated as gauges; delta sums stay Sum
if (
metricType === MetrictypesTypeDTO.sum &&
isMonotonic === false &&
temporality === MetrictypesTemporalityDTO.cumulative
) {
return ATTRIBUTE_TYPES.GAUGE;
}
return METRIC_TYPE_TO_ATTRIBUTE_TYPE[metricType] || '';

View File

@@ -33,6 +33,7 @@ function AllAttributes({
metricName,
metricType,
isMonotonic,
temporality,
minTime,
maxTime,
}: AllAttributesProps): JSX.Element {
@@ -71,6 +72,7 @@ function AllAttributes({
groupBy,
limit,
isMonotonic,
temporality,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
@@ -89,7 +91,7 @@ function AllAttributes({
[MetricsExplorerEventKeys.AttributeKey]: groupBy,
});
},
[metricName, metricType, isMonotonic, handleExplorerTabChange],
[metricName, metricType, isMonotonic, temporality, handleExplorerTabChange],
);
const goToMetricsExploreWithAppliedAttribute = useCallback(
@@ -101,6 +103,7 @@ function AllAttributes({
undefined,
undefined,
isMonotonic,
temporality,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
@@ -120,7 +123,7 @@ function AllAttributes({
[MetricsExplorerEventKeys.AttributeValue]: value,
});
},
[metricName, metricType, isMonotonic, handleExplorerTabChange],
[metricName, metricType, isMonotonic, temporality, handleExplorerTabChange],
);
const handleKeyMenuItemClick = useCallback(

View File

@@ -86,6 +86,7 @@ function MetricDetails({
undefined,
undefined,
metadata?.isMonotonic,
metadata?.temporality,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
@@ -108,6 +109,7 @@ function MetricDetails({
handleExplorerTabChange,
metadata?.type,
metadata?.isMonotonic,
metadata?.temporality,
]);
useEffect(() => {
@@ -196,6 +198,7 @@ function MetricDetails({
metricName={metricName}
metricType={metadata?.type}
isMonotonic={metadata?.isMonotonic}
temporality={metadata?.temporality}
minTime={minTime}
maxTime={maxTime}
/>

View File

@@ -147,6 +147,44 @@ describe('MetricDetails utils', () => {
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
});
it('treats a cumulative non-monotonic Sum as a Gauge', () => {
const query = getMetricDetailsQuery(
TEST_METRIC_NAME,
MetrictypesTypeDTO.sum,
undefined,
undefined,
undefined,
false,
MetrictypesTemporalityDTO.cumulative,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
ATTRIBUTE_TYPES.GAUGE,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('avg');
expect(query.builder.queryData[0]?.timeAggregation).toBe('avg');
expect(query.builder.queryData[0]?.spaceAggregation).toBe('avg');
});
it('treats a delta non-monotonic Sum as a Sum', () => {
const query = getMetricDetailsQuery(
TEST_METRIC_NAME,
MetrictypesTypeDTO.sum,
undefined,
undefined,
undefined,
false,
MetrictypesTemporalityDTO.delta,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
ATTRIBUTE_TYPES.SUM,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('rate');
expect(query.builder.queryData[0]?.timeAggregation).toBe('rate');
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
});
it('should create correct query for GAUGE metric type', () => {
const query = getMetricDetailsQuery(
TEST_METRIC_NAME,

View File

@@ -35,6 +35,7 @@ export interface AllAttributesProps {
metricName: string;
metricType: MetrictypesTypeDTO | undefined;
isMonotonic?: boolean;
temporality?: MetrictypesTemporalityDTO;
minTime?: number;
maxTime?: number;
}

View File

@@ -89,12 +89,16 @@ export function getMetricDetailsQuery(
groupBy?: string,
limit?: number,
isMonotonic?: boolean,
temporality?: MetrictypesTemporalityDTO,
): Query {
let timeAggregation;
let spaceAggregation;
let aggregateOperator;
// Only non-monotonic cumulative sums are treated as gauges; delta sums stay Sum
const isNonMonotonicSum =
metricType === MetrictypesTypeDTO.sum && isMonotonic === false;
metricType === MetrictypesTypeDTO.sum &&
isMonotonic === false &&
temporality === MetrictypesTemporalityDTO.cumulative;
switch (metricType) {
case MetrictypesTypeDTO.sum:
@@ -131,7 +135,7 @@ export function getMetricDetailsQuery(
break;
}
const attributeType = toAttributeType(metricType, isMonotonic);
const attributeType = toAttributeType(metricType, isMonotonic, temporality);
return {
...initialQueriesMap[DataSource.METRICS],

View File

@@ -393,12 +393,13 @@ describe('selecting a metric type updates the aggregation options', () => {
]);
});
it('non-monotonic Sum metric is treated as Gauge', () => {
it('cumulative non-monotonic Sum metric is treated as Gauge', () => {
returnMetrics([
makeMetric({
metricName: 'active_connections',
type: MetrictypesTypeDTO.sum,
isMonotonic: false,
temporality: 'cumulative' as never,
}),
]);
@@ -427,6 +428,36 @@ describe('selecting a metric type updates the aggregation options', () => {
]);
});
it('delta non-monotonic Sum metric is treated as Sum', () => {
returnMetrics([
makeMetric({
metricName: 'queue_depth_delta',
type: MetrictypesTypeDTO.sum,
isMonotonic: false,
temporality: 'delta' as never,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'queue_depth_delta' },
});
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toStrictEqual([
'Rate',
'Increase',
]);
expect(getOptionLabels('space-agg-options')).toStrictEqual([
'Sum',
'Avg',
'Min',
'Max',
]);
});
it('Histogram metric shows no time options and P50P99 space options', () => {
returnMetrics([
makeMetric({

View File

@@ -34,7 +34,7 @@ export type MetricNameSelectorProps = {
function getAttributeType(
metric: MetricsexplorertypesListMetricDTO,
): ATTRIBUTE_TYPES | '' {
return toAttributeType(metric.type, metric.isMonotonic);
return toAttributeType(metric.type, metric.isMonotonic, metric.temporality);
}
function createAutocompleteData(

View File

@@ -85,6 +85,23 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/migrate", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.MigrateV2), handler.OpenAPIDef{
ID: "MigrateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Migrate dashboard to v2",
Description: "This endpoint retries the v1→v2 (Perses) migration on a dashboard still stored in the v1 schema and returns the v2-shape result. It is idempotent: a dashboard already in the v2 schema is returned unchanged.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.GetV2), handler.OpenAPIDef{
ID: "GetDashboardV2",
Tags: []string{"dashboard"},

View File

@@ -63,6 +63,9 @@ type Module interface {
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
// MigrateV2 retries the v1→v2 migration on a dashboard still stored in the v1 schema.
MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
ListV2(ctx context.Context, orgID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error)
ListForUserV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardForUserV2, error)
@@ -132,6 +135,8 @@ type Handler interface {
GetV2(http.ResponseWriter, *http.Request)
MigrateV2(http.ResponseWriter, *http.Request)
ListV2(http.ResponseWriter, *http.Request)
ListForUserV2(http.ResponseWriter, *http.Request)

View File

@@ -207,6 +207,38 @@ func (handler *handler) GetV2(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}
func (handler *handler) MigrateV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.MigrateV2(ctx, orgID, dashboardID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}
func (handler *handler) LockV2(rw http.ResponseWriter, r *http.Request) {
handler.lockUnlockV2(rw, r, true)
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
@@ -121,6 +122,51 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return storable.ToDashboardV2(tags)
}
// MigrateV2 retries the v1→v2 migration on a dashboard still stored as v1 (one the
// bulk 103 migration skipped or failed). Idempotent: an already-v2 one is unchanged.
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
storable, err := module.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
// Already migrated: return as-is.
if storable.IsV2() {
tags, err := module.tagModule.ListForResource(ctx, orgID, coretypes.KindDashboard, id)
if err != nil {
return nil, err
}
return storable.ToDashboardV2(tags)
}
// v1→v2 needs v5-shaped queries; run v4→v5 in place first.
transition.NewDashboardMigrateV5(module.settings.Logger(), nil, nil).Migrate(ctx, storable.Data)
v2, err := storable.ConvertV1ToV2()
if err != nil {
return nil, err
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, v2.ID, tagtypes.NewPostableTagsFromTags(v2.Tags))
if err != nil {
return err
}
v2.Tags = resolvedTags
storableV2, err := v2.ToStorableDashboard()
if err != nil {
return err
}
return module.store.Update(ctx, orgID, storableV2)
})
if err != nil {
return nil, err
}
return v2, nil
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
if err := updatable.Validate(); err != nil {
return nil, err

View File

@@ -232,6 +232,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewMigrateDashboardsV1ToV2Factory(sqlstore, sqlschema, dashboardStore, tagModule),
sqlmigration.NewFillDashboardMeterSourceFactory(sqlstore, dashboardStore),
sqlmigration.NewUpdateRoleTransactionGroupsFactory(),
sqlmigration.NewFillDashboardSpecCollectionsFactory(sqlstore, dashboardStore),
)
}

View File

@@ -0,0 +1,124 @@
package sqlmigration
import (
"context"
"log/slog"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
// Required, non-nullable v2 spec fields, mapped to their empty value.
var nullableSpecCollections = map[string]any{
"variables": []any{},
"panels": map[string]any{},
"layouts": []any{},
}
type fillDashboardSpecCollections struct {
sqlstore sqlstore.SQLStore
dashboardStore dashboardtypes.Store
settings factory.ProviderSettings
}
func NewFillDashboardSpecCollectionsFactory(sqlstore sqlstore.SQLStore, dashboardStore dashboardtypes.Store) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("fill_dashboard_spec_collections"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &fillDashboardSpecCollections{sqlstore: sqlstore, dashboardStore: dashboardStore, settings: ps}, nil
},
)
}
func (migration *fillDashboardSpecCollections) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
// Up replaces a missing or null spec.variables / spec.panels / spec.layouts with the
// empty collection. One transaction; v1 dashboards are skipped.
func (migration *fillDashboardSpecCollections) Up(ctx context.Context, _ *bun.DB) error {
return migration.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
var orgIDs []string
if err := migration.sqlstore.BunDBCtx(ctx).NewSelect().Model((*types.Organization)(nil)).Column("id").Scan(ctx, &orgIDs); err != nil {
return err
}
for _, id := range orgIDs {
orgID, err := valuer.NewUUID(id)
if err != nil {
return err
}
if err := migration.fillOrg(ctx, orgID); err != nil {
return err
}
}
return nil
})
}
// fillOrg fills every v2 dashboard in the org that needs it, inside the caller's transaction.
func (migration *fillDashboardSpecCollections) fillOrg(ctx context.Context, orgID valuer.UUID) error {
// List, not ListV2: ListV2 paginates and excludes system dashboards; a migration needs every row.
storables, err := migration.dashboardStore.List(ctx, orgID)
if err != nil {
return err
}
logger := migration.settings.Logger
var stillInV1, malformedSpec, skippedNoNulls, migrated int
for _, storable := range storables {
if !storable.IsV2() {
stillInV1++
continue
}
// Raw data, not ToDashboardV2: decoding validates, and these are the rows it rejects.
spec, ok := storable.Data["spec"].(map[string]any)
if !ok {
malformedSpec++
logger.WarnContext(ctx, "v2 dashboard has no spec object; leaving it untouched", slog.String("org_id", orgID.String()), slog.String("dashboard_id", storable.ID.String()))
continue
}
if !fillSpecCollections(spec) {
skippedNoNulls++
continue
}
if err := migration.dashboardStore.Update(ctx, orgID, storable); err != nil {
return err
}
migrated++
}
logger.InfoContext(ctx, "filled required collections on v2 dashboards",
slog.String("org_id", orgID.String()),
slog.Int("total", len(storables)),
slog.Int("still_in_v1", stillInV1),
slog.Int("malformed_spec", malformedSpec),
slog.Int("skipped_no_nulls", skippedNoNulls),
slog.Int("migrated", migrated),
)
return nil
}
// fillSpecCollections empties each absent or null required collection, reporting whether
// anything changed. A present value is left alone whatever its shape, so a malformed one
// still surfaces as a validation error.
func fillSpecCollections(spec map[string]any) bool {
changed := false
for field, empty := range nullableSpecCollections {
if value, present := spec[field]; !present || value == nil {
spec[field] = empty
changed = true
}
}
return changed
}
func (migration *fillDashboardSpecCollections) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -2303,6 +2303,15 @@ func unionTemporalities(existing, additional []metrictypes.Temporality) []metric
return existing
}
// resolveMetricType applies the non-monotonic-cumulative-sum-as-gauge rule.
// Monotonicity is only meaningful for cumulative sums; delta sums always stay Sum.
func resolveMetricType(metricType metrictypes.Type, isMonotonic bool, temporality metrictypes.Temporality) metrictypes.Type {
if metricType == metrictypes.SumType && !isMonotonic && temporality == metrictypes.Cumulative {
return metrictypes.GaugeType
}
return metricType
}
func (t *telemetryMetaStore) fetchTemporalityTypeForTable(ctx context.Context, tableName string, adjustedStartTs, adjustedEndTs uint64, metricNames []string, extraConds ...string) (map[string][]metrictypes.Temporality, map[string]metrictypes.Type, error) {
temporalities := make(map[string][]metrictypes.Temporality)
types := make(map[string]metrictypes.Type)
@@ -2339,9 +2348,7 @@ func (t *telemetryMetaStore) fetchTemporalityTypeForTable(ctx context.Context, t
if temporality != metrictypes.Unknown {
temporalities[metricName] = append(temporalities[metricName], temporality)
}
if metricType == metrictypes.SumType && !isMonotonic {
metricType = metrictypes.GaugeType
}
metricType = resolveMetricType(metricType, isMonotonic, temporality)
types[metricName] = metricType
}
if err := rows.Err(); err != nil {
@@ -2392,9 +2399,7 @@ func (t *telemetryMetaStore) fetchMeterSourceMetricsTemporalityAndType(ctx conte
if err := rows.Scan(&metricName, &temporality, &metricType, &isMonotonic); err != nil {
return nil, nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to scan temporality result")
}
if metricType == metrictypes.SumType && !isMonotonic {
metricType = metrictypes.GaugeType
}
metricType = resolveMetricType(metricType, isMonotonic, temporality)
temporalities[metricName] = temporality
types[metricName] = metricType
}

View File

@@ -0,0 +1,64 @@
package telemetrymetadata
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/stretchr/testify/assert"
)
func TestResolveMetricType(t *testing.T) {
testCases := []struct {
description string
inputMetricType metrictypes.Type
inputIsMonotonic bool
inputTemporality metrictypes.Temporality
expectedMetricType metrictypes.Type
}{
{
description: "delta non-monotonic sum stays a sum",
inputMetricType: metrictypes.SumType,
inputIsMonotonic: false,
inputTemporality: metrictypes.Delta,
expectedMetricType: metrictypes.SumType,
},
{
description: "cumulative non-monotonic sum becomes a gauge",
inputMetricType: metrictypes.SumType,
inputIsMonotonic: false,
inputTemporality: metrictypes.Cumulative,
expectedMetricType: metrictypes.GaugeType,
},
{
description: "cumulative monotonic sum stays a sum",
inputMetricType: metrictypes.SumType,
inputIsMonotonic: true,
inputTemporality: metrictypes.Cumulative,
expectedMetricType: metrictypes.SumType,
},
{
description: "delta monotonic sum stays a sum",
inputMetricType: metrictypes.SumType,
inputIsMonotonic: true,
inputTemporality: metrictypes.Delta,
expectedMetricType: metrictypes.SumType,
},
{
description: "gauge is unaffected by monotonicity",
inputMetricType: metrictypes.GaugeType,
inputIsMonotonic: false,
inputTemporality: metrictypes.Unspecified,
expectedMetricType: metrictypes.GaugeType,
},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
assert.Equal(
t,
testCase.expectedMetricType,
resolveMetricType(testCase.inputMetricType, testCase.inputIsMonotonic, testCase.inputTemporality),
)
})
}
}

View File

@@ -22,7 +22,8 @@ func newTestDashboardV2(t *testing.T, orgID valuer.UUID, source Source) *Dashboa
updatedAt := time.Date(2026, time.January, 2, 12, 0, 0, 0, time.UTC)
spec := DashboardSpec{
Display: Display{Name: "Test Dashboard"},
Display: Display{Name: "Test Dashboard"},
Variables: []Variable{},
Panels: map[string]*Panel{
"p1": {
Kind: "Panel",

View File

@@ -63,8 +63,13 @@ func (d *DashboardSpec) Validate() error {
return d.validateLayouts()
}
// validateVariables rejects two variables sharing the same name.
// validateVariables rejects an absent or null list, and duplicate variable names.
func (d *DashboardSpec) validateVariables() error {
// Nil is an absent or explicitly null field; `[]` decodes non-nil. The schema
// declares it required and non-nullable, so both are rejected.
if d.Variables == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.variables: is required and must not be null; use [] for a dashboard with no variables")
}
seen := make(map[string]struct{}, len(d.Variables))
for i, v := range d.Variables {
var name string
@@ -94,6 +99,9 @@ func (d *DashboardSpec) validateVariables() error {
}
func (d *DashboardSpec) validatePanels() error {
if d.Panels == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.panels: is required and must not be null; use {} for a dashboard with no panels")
}
for key, panel := range d.Panels {
if err := common.ValidateID(key); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "spec.panels: %s", err.Error())
@@ -252,6 +260,9 @@ const maxLayoutsPerDashboard = 500
// Geometry (validateGridLayoutGeometry) needs only each layout's own data but
// runs here so its errors can name the layout by index.
func (d *DashboardSpec) validateLayouts() error {
if d.Layouts == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts: is required and must not be null; use [] for a dashboard with no layouts")
}
if len(d.Layouts) > maxLayoutsPerDashboard {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts: dashboard has %d layouts; maximum is %d", len(d.Layouts), maxLayoutsPerDashboard)
}

View File

@@ -47,6 +47,7 @@ func TestInvalidateNotAJSON(t *testing.T) {
// UnmarshalJSON methods (panel/query/variable plugin envelopes).
func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -77,14 +78,15 @@ func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
}
func TestValidateEmptySpec(t *testing.T) {
// no variables no panels no links
data := []byte(`{}`)
// The three required collections must be present, but may be empty.
data := []byte(`{"variables": [], "panels": {}, "layouts": []}`)
_, err := unmarshalDashboard(data)
assert.NoError(t, err, "expected valid")
}
func TestValidateOnlyVariables(t *testing.T) {
data := []byte(`{
"panels": {},
"variables": [
{
"kind": "ListVariable",
@@ -116,8 +118,60 @@ func TestValidateOnlyVariables(t *testing.T) {
assert.NoError(t, err, "expected valid")
}
// TestInvalidateAbsentOrNullRequiredCollections pins the strict reading of the
// schema on the three required, non-nullable collections: an absent key breaks
// `required`, an explicit null breaks the array/object type, and both are
// rejected. Only the empty collection is accepted.
func TestInvalidateAbsentOrNullRequiredCollections(t *testing.T) {
cases := []struct {
description string
specJSON string
expectedPath string
}{
{
description: "variables absent",
specJSON: `{"panels": {}, "layouts": []}`,
expectedPath: "spec.variables",
},
{
description: "variables null",
specJSON: `{"variables": null, "panels": {}, "layouts": []}`,
expectedPath: "spec.variables",
},
{
description: "panels absent",
specJSON: `{"variables": [], "layouts": []}`,
expectedPath: "spec.panels",
},
{
description: "panels null",
specJSON: `{"variables": [], "panels": null, "layouts": []}`,
expectedPath: "spec.panels",
},
{
description: "layouts absent",
specJSON: `{"variables": [], "panels": {}}`,
expectedPath: "spec.layouts",
},
{
description: "layouts null",
specJSON: `{"variables": [], "panels": {}, "layouts": null}`,
expectedPath: "spec.layouts",
},
}
for _, c := range cases {
t.Run(c.description, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(c.specJSON))
require.Error(t, err)
assert.Contains(t, err.Error(), c.expectedPath+": is required and must not be null")
})
}
}
func TestInvalidateDuplicateVariableNames(t *testing.T) {
data := []byte(`{
"panels": {},
"variables": [
{
"kind": "TextVariable",
@@ -147,6 +201,7 @@ func TestInvalidateDuplicateVariableNames(t *testing.T) {
func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
listVarWithName := func(name string) []byte {
return []byte(`{
"panels": {},
"variables": [
{
"kind": "ListVariable",
@@ -187,6 +242,7 @@ func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
func TestInvalidatePanelKey(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"bad key!": {
"kind": "Panel",
@@ -213,6 +269,7 @@ func TestInvalidatePanelKey(t *testing.T) {
func TestInvalidateListVariableCrossFields(t *testing.T) {
listVar := func(specFields string) []byte {
return []byte(`{
"panels": {},
"variables": [
{
"kind": "ListVariable",
@@ -295,11 +352,13 @@ func TestInvalidateListVariableCrossFields(t *testing.T) {
func TestInvalidateEmptyVariableName(t *testing.T) {
cases := map[string][]byte{
"text variable": []byte(`{
"panels": {},
"variables": [{"kind": "TextVariable", "spec": {"name": "", "value": "x"}}],
"links": [],
"layouts": []
}`),
"list variable": []byte(`{
"panels": {},
"variables": [{
"kind": "ListVariable",
"spec": {
@@ -331,6 +390,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
{
name: "unknown panel plugin",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -348,6 +408,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
{
name: "unknown panel envelope kind",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Row",
@@ -364,6 +425,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
{
name: "unknown query plugin",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -387,6 +449,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
{
name: "unknown query envelope kind",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -410,6 +473,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
{
name: "empty query envelope kind",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -433,6 +497,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
{
name: "unknown variable plugin",
data: `{
"panels": {},
"variables": [{
"kind": "ListVariable",
"spec": {
@@ -460,6 +525,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
func TestInvalidateOneInvalidPanel(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"good": {
"kind": "Panel",
@@ -495,7 +561,7 @@ func TestInvalidateLayoutPanelReferences(t *testing.T) {
}
}`
layout := func(items string) []byte {
return []byte(`{` + validPanels + `, "links": [], "layouts": [{"kind": "Grid", "spec": {"items": [` + items + `]}}]}`)
return []byte(`{"variables": [], ` + validPanels + `, "links": [], "layouts": [{"kind": "Grid", "spec": {"items": [` + items + `]}}]}`)
}
tests := []struct {
@@ -547,6 +613,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
{
name: "unknown field in panel spec",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -567,6 +634,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
{
name: "unknown field in query spec",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -593,6 +661,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
{
name: "unknown field in variable spec",
data: `{
"panels": {},
"variables": [{
"kind": "ListVariable",
"spec": {
@@ -630,6 +699,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
{
name: "wrong type on panel plugin field",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -650,6 +720,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
{
name: "wrong type on query plugin field",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -676,6 +747,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
{
name: "wrong type on variable plugin field",
data: `{
"panels": {},
"variables": [{
"kind": "ListVariable",
"spec": {
@@ -715,6 +787,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad signal in builder query",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -744,6 +817,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad line interpolation",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -764,6 +838,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad line style",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -784,6 +859,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad fill mode",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -804,6 +880,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad spanGaps fillLessThan",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -824,6 +901,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad time preference",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -844,6 +922,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad legend position",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -864,6 +943,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad legend mode",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -884,6 +964,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad threshold format",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -904,6 +985,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad comparison operator",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -924,6 +1006,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad precision",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -964,6 +1047,7 @@ func TestThresholdLabelOptional(t *testing.T) {
} {
t.Run(tt.name, func(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -989,6 +1073,7 @@ func TestThresholdLabelOptional(t *testing.T) {
func TestInvalidatePanelWithoutQueries(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -1005,6 +1090,7 @@ func TestInvalidatePanelWithoutQueries(t *testing.T) {
func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -1027,6 +1113,7 @@ func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
// signoz/CompositeQuery, not by listing multiple top-level queries.
func TestInvalidatePanelWithMultipleDirectQueries(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -1136,6 +1223,7 @@ func TestValidateRequiredFields(t *testing.T) {
func TestTimeSeriesPanelDefaults(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -1188,6 +1276,7 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
func TestNumberPanelDefaults(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -1251,6 +1340,7 @@ func TestPersesFixtureStorageRoundTrip(t *testing.T) {
// then unmarshal it back (what would be read from DB), and verify defaults survive.
func TestStorageRoundTrip(t *testing.T) {
input := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -1336,7 +1426,7 @@ func TestStorageRoundTrip(t *testing.T) {
}
func TestPostableDashboardV2GenerateNameFlag(t *testing.T) {
const validSpec = `"spec": {"panels": {}, "layouts": [], "links": []}`
const validSpec = `"spec": {"variables": [], "panels": {}, "layouts": [], "links": []}`
tests := []struct {
scenario string
@@ -1348,13 +1438,13 @@ func TestPostableDashboardV2GenerateNameFlag(t *testing.T) {
}{
{
scenario: "flag true with display.name derives name on conversion",
body: `{"schemaVersion":"` + SchemaVersion + `","generateName":true,"spec":{"display":{"name":"My Dashboard!"},"panels":{},"layouts":[],"links":[]}}`,
body: `{"schemaVersion":"` + SchemaVersion + `","generateName":true,"spec":{"display":{"name":"My Dashboard!"},"variables":[],"panels":{},"layouts":[],"links":[]}}`,
wantName: "",
wantDisplay: "My Dashboard!",
},
{
scenario: "flag true with non-empty name is rejected",
body: `{"schemaVersion":"` + SchemaVersion + `","name":"already-set","generateName":true,"spec":{"display":{"name":"My Dashboard"},"panels":{},"layouts":[],"links":[]}}`,
body: `{"schemaVersion":"` + SchemaVersion + `","name":"already-set","generateName":true,"spec":{"display":{"name":"My Dashboard"},"variables":[],"panels":{},"layouts":[],"links":[]}}`,
wantErr: true,
wantErrMatch: "name must be empty when generateName is true",
},
@@ -1513,6 +1603,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
}
mkQuery := func(panelKind, queryKind, querySpec string) []byte {
return []byte(`{
"variables": [],
"panels": {"p1": {"kind": "Panel", "spec": {
"links": [],
"plugin": {"kind": "` + panelKind + `", "spec": {}},
@@ -1524,6 +1615,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
}
mkComposite := func(panelKind, subType, subSpec string) []byte {
return []byte(`{
"variables": [],
"panels": {"p1": {"kind": "Panel", "spec": {
"links": [],
"plugin": {"kind": "` + panelKind + `", "spec": {}},
@@ -1574,6 +1666,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
func TestCommaSeparatedAggregationRejectedOnWrite(t *testing.T) {
buildDashboardWithLogsAggregation := func(aggregationsJSON string) []byte {
return []byte(`{
"variables": [],
"panels": {"p1": {"kind": "Panel", "spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
@@ -1694,6 +1787,7 @@ func TestValidateGridItemLimit(t *testing.T) {
// the unmarshal path — it does, via DashboardSpec.Validate -> validateLayouts.
func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {"kind": "Panel", "spec": {"links": [],"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}},
"p2": {"kind": "Panel", "spec": {"links": [],"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
@@ -1714,6 +1808,7 @@ func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
// two items are side by side so they clear the overlap check first.
func TestInvalidateDuplicatePanelReference(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {"kind": "Panel", "spec": {"links": [],"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
},
@@ -1745,39 +1840,43 @@ func TestInvalidateDisplayNameTooLong(t *testing.T) {
expectedLabel string
}{
{
scenario: "dashboard display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{"display": {"name": "%s"}, "links": [], "layouts": []}`,
expectedLabel: "dashboard",
expectedPath: "spec.display.name",
scenario: "dashboard display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{
"variables": [],
"panels": {},"display": {"name": "%s"}, "links": [], "layouts": []}`,
expectedLabel: "dashboard",
expectedPath: "spec.display.name",
},
{
scenario: "panel display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{"panels": {"p1": {"kind": "Panel", "spec": {"links": [], "display": {"name": "%s"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "links": [], "layouts": []}`,
dashboardJSONFmt: `{"variables": [], "panels": {"p1": {"kind": "Panel", "spec": {"links": [], "display": {"name": "%s"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "links": [], "layouts": []}`,
expectedLabel: "panel",
expectedPath: "spec.panels.p1.spec.display.name",
},
{
scenario: "list variable display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "%s"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "links": [], "layouts": []}`,
dashboardJSONFmt: `{"panels": {}, "variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "%s"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "links": [], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
},
{
scenario: "text variable display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "%s"}}}], "links": [], "layouts": []}`,
dashboardJSONFmt: `{"panels": {}, "variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "%s"}}}], "links": [], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
},
{
scenario: "layout title",
limit: MaxLayoutTitleLen,
dashboardJSONFmt: `{"links": [], "layouts": [{"kind": "Grid", "spec": {"display": {"title": "%s"}, "items": []}}]}`,
expectedLabel: "layout",
expectedPath: "spec.layouts[0].spec.display.title",
scenario: "layout title",
limit: MaxLayoutTitleLen,
dashboardJSONFmt: `{
"variables": [],
"panels": {},"links": [], "layouts": [{"kind": "Grid", "spec": {"display": {"title": "%s"}, "items": []}}]}`,
expectedLabel: "layout",
expectedPath: "spec.layouts[0].spec.display.title",
},
}
@@ -1797,7 +1896,9 @@ func TestInvalidateDisplayNameTooLong(t *testing.T) {
// A display name at exactly the limit is accepted.
func TestValidateDisplayNameAtMaxLength(t *testing.T) {
atLimit := strings.Repeat("x", MaxDisplayNameLen)
_, err := unmarshalDashboard([]byte(`{"display": {"name": "` + atLimit + `"}, "links": [], "layouts": []}`))
_, err := unmarshalDashboard([]byte(`{
"variables": [],
"panels": {},"display": {"name": "` + atLimit + `"}, "links": [], "layouts": []}`))
assert.NoError(t, err)
}

View File

@@ -179,6 +179,7 @@ func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind Pan
normalizePreV5GroupBy(q)
normalizePreV5PageSize(q, rowLimitPanel)
normalizeQueryLimit(q)
normalizeQueryOffset(q)
if needsAggregation {
ensureDefaultAggregation(q)
}
@@ -198,6 +199,7 @@ func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind Pan
assignMissingFormulaNames(formulas)
for _, f := range formulas {
normalizePreV5QueryData(f, widgetType, panelKind)
normalizeQueryLimit(f)
name := d.readString(f, "queryName")
env := qb.WrapInV5Envelope(name, f, string(qb.QueryTypeFormula.StringValue()))
backfillFormulaFields(env, f)
@@ -219,6 +221,8 @@ func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind Pan
normalizePreV5QueryData(op, widgetType, panelKind)
normalizePreV5GroupBy(op)
normalizeOrderByKeys(op)
normalizeQueryLimit(op)
normalizeQueryOffset(op)
name := d.readString(op, "queryName")
out = append(out, traceOperatorEnvelope(name, expression, op))
}

View File

@@ -618,15 +618,32 @@ func normalizePreV5PageSize(query map[string]any, rowLimitPanel bool) {
}
}
// normalizeQueryLimit drops a limit above the v5 maximum (MaxQueryLimit); v1 allowed
// larger/unbounded limits, and an over-max value fails validation. Removing it leaves
// the query unlimited (the field is optional).
// normalizeQueryLimit coerces limit to the int the v5 decode expects: v1 stored it
// as a string ("5") or float, both of which fail the typed decode. An unparseable
// value or one above the v5 maximum (MaxQueryLimit) is dropped, leaving the query
// unlimited (the field is optional).
func normalizeQueryLimit(query map[string]any) {
limit, ok := coerceFloat(query["limit"])
if !ok {
if query["limit"] == nil {
return
}
if limit > qb.MaxQueryLimit {
limit, ok := coerceFloat(query["limit"])
if !ok || limit > qb.MaxQueryLimit {
delete(query, "limit")
return
}
query["limit"] = int(limit)
}
// normalizeQueryOffset coerces offset to the int the v5 decode expects; v1 could
// store it as a string. An unparseable value is dropped (offset defaults to 0).
func normalizeQueryOffset(query map[string]any) {
if query["offset"] == nil {
return
}
offset, ok := coerceFloat(query["offset"])
if !ok {
delete(query, "offset")
return
}
query["offset"] = int(offset)
}

View File

@@ -1,4 +1,5 @@
{
"variables": [],
"display": {
"name": "NV dashboard with sections",
"description": ""

View File

@@ -34,7 +34,7 @@ def test_create_and_get_public_dashboard(
json={
"schemaVersion": "v6",
"name": "sample-title",
"spec": {"display": {"name": "Sample Title"}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Sample Title"}, "links": []},
"tags": [],
},
headers={"Authorization": f"Bearer {admin_token}"},

View File

@@ -91,7 +91,7 @@ def test_create_rejects_non_dns_name(
json={
"schemaVersion": "v6",
"name": "Not A Label",
"spec": {"display": {"name": "Not A Label"}},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Not A Label"}},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -114,7 +114,7 @@ def test_create_rejects_unknown_field(
json={
"schemaVersion": "v6",
"name": "rejects-unknown",
"spec": {"display": {"name": "Rejects Unknown"}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Rejects Unknown"}, "links": []},
"tags": [],
"unknownfield": "boom",
},
@@ -139,7 +139,7 @@ def test_create_rejects_reserved_tag_key(
json={
"schemaVersion": "v6",
"name": "rejects-reserved",
"spec": {"display": {"name": "Rejects Reserved"}},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Rejects Reserved"}},
"tags": [{"key": "source", "value": "x"}],
},
headers={"Authorization": f"Bearer {token}"},
@@ -163,7 +163,7 @@ def test_create_rejects_too_many_tags(
json={
"schemaVersion": "v6",
"name": "too-many-tags",
"spec": {"display": {"name": "Too Many"}},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Too Many"}},
"tags": tags,
},
headers={"Authorization": f"Bearer {token}"},
@@ -187,7 +187,7 @@ def test_create_rejects_long_display_name(
json={
"schemaVersion": "v6",
"name": "long-display-name",
"spec": {"display": {"name": "x" * 129}},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "x" * 129}},
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
@@ -205,6 +205,8 @@ def test_create_rejects_long_display_name(
"schemaVersion": "v6",
"name": "long-layout-title",
"spec": {
"variables": [],
"panels": {},
"display": {"name": "Long Layout Title"},
"links": [],
"layouts": [{"kind": "Grid", "spec": {"display": {"title": "x" * 257}, "items": []}}],
@@ -234,6 +236,8 @@ def test_create_rejects_all_value_without_multiselect(
"schemaVersion": "v6",
"name": "all-without-multi",
"spec": {
"panels": {},
"layouts": [],
"display": {"name": "All Without Multi"},
"links": [],
"variables": [
@@ -302,6 +306,7 @@ def test_create_rejects_invalid_grid_layout(
"schemaVersion": "v6",
"name": "rejects-overlap",
"spec": {
"variables": [],
"display": {"name": "Rejects Overlap"},
"panels": {"p1": panel("P1"), "p2": panel("P2")},
"layouts": [
@@ -334,6 +339,7 @@ def test_create_rejects_invalid_grid_layout(
"schemaVersion": "v6",
"name": "rejects-multiref",
"spec": {
"variables": [],
"display": {"name": "Rejects Multiref"},
"panels": {"p1": panel("P1")},
"layouts": [
@@ -366,6 +372,8 @@ def test_create_rejects_invalid_grid_layout(
"schemaVersion": "v6",
"name": "rejects-too-many-items",
"spec": {
"variables": [],
"panels": {},
"display": {"name": "Rejects Too Many"},
"layouts": [
{
@@ -457,7 +465,7 @@ def test_update_rejects_malformed_id(
json={
"schemaVersion": "v6",
"name": "malformed-id",
"spec": {"display": {"name": "Malformed Id"}},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Malformed Id"}},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -479,7 +487,7 @@ def test_update_missing_dashboard_returns_not_found(
json={
"schemaVersion": "v6",
"name": "missing-dashboard",
"spec": {"display": {"name": "Missing Dashboard"}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Missing Dashboard"}, "links": []},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -675,7 +683,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
json={
"schemaVersion": "v6",
"name": name,
"spec": {"display": {"name": display}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": display}, "links": []},
"tags": tags,
},
headers={"Authorization": f"Bearer {token}"},
@@ -1037,7 +1045,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
update_body = {
"schemaVersion": "v6",
"name": "lc-alpha",
"spec": {"display": {"name": "Alpha Overview"}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Alpha Overview"}, "links": []},
"tags": [
{"key": "team", "value": "pulse"},
{"key": "env", "value": "prod"},
@@ -1081,7 +1089,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
beta_body = {
"schemaVersion": "v6",
"name": "lc-beta",
"spec": {"display": {"name": "Beta Overview"}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Beta Overview"}, "links": []},
"tags": [{"key": "team", "value": "pulse"}, {"key": "env", "value": "dev"}],
}
response = requests.put(
@@ -1185,7 +1193,7 @@ def test_dashboard_v2_tag_order_round_trips(
]
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}, "links": []}, "tags": created_order},
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Tag Order"}, "links": []}, "tags": created_order},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
@@ -1211,7 +1219,7 @@ def test_dashboard_v2_tag_order_round_trips(
]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}, "links": []}, "tags": reordered},
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Tag Order"}, "links": []}, "tags": reordered},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
@@ -1240,7 +1248,7 @@ def test_dashboard_v2_tag_order_round_trips(
]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}, "links": []}, "tags": new_order},
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Tag Order"}, "links": []}, "tags": new_order},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
@@ -1277,7 +1285,7 @@ def test_dashboard_v2_pin_limit(
json={
"schemaVersion": "v6",
"name": f"pl-{i}",
"spec": {"display": {"name": f"Pin Limit {i}"}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": f"Pin Limit {i}"}, "links": []},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -1375,7 +1383,7 @@ def test_dashboard_v2_like_escaping(
json={
"schemaVersion": "v6",
"name": name,
"spec": {"display": {"name": display}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": display}, "links": []},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -1465,6 +1473,8 @@ def test_dashboard_v2_get_by_metric_name(
"schemaVersion": "v6",
"name": "by-metric-builder",
"spec": {
"variables": [],
"layouts": [],
"display": {"name": "by-metric-builder"},
"links": [],
"panels": {
@@ -1516,6 +1526,8 @@ def test_dashboard_v2_get_by_metric_name(
"schemaVersion": "v6",
"name": "by-metric-ch-promql",
"spec": {
"variables": [],
"layouts": [],
"display": {"name": "by-metric-ch-promql"},
"links": [],
"panels": {
@@ -1581,6 +1593,8 @@ def test_dashboard_v2_get_by_metric_name(
"schemaVersion": "v6",
"name": "by-metric-promql",
"spec": {
"variables": [],
"layouts": [],
"display": {"name": "by-metric-promql"},
"links": [],
"panels": {
@@ -1626,6 +1640,8 @@ def test_dashboard_v2_get_by_metric_name(
"schemaVersion": "v6",
"name": "by-metric-false-positive",
"spec": {
"variables": [],
"layouts": [],
"display": {"name": "by-metric-false-positive"},
"links": [],
"panels": {
@@ -1757,6 +1773,8 @@ def test_dashboard_v2_rejects_comma_separated_aggregation(
"name": f"agg-{uuid.uuid4().hex[:8]}",
"tags": [],
"spec": {
"variables": [],
"layouts": [],
"display": {"name": "Aggregation"},
"links": [],
"panels": {
@@ -1850,6 +1868,7 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
"name": "roundtrip-zero-values",
"tags": [],
"spec": {
"layouts": [],
"display": {"name": "Roundtrip Zero Values", "description": ""},
"duration": "",
"refreshInterval": "",
@@ -2089,6 +2108,8 @@ def test_dashboard_v2_omitted_enums_apply_defaults(
"name": f"enum-{uuid.uuid4().hex[:8]}",
"tags": [],
"spec": {
"variables": [],
"layouts": [],
"display": {"name": "Enum"},
"panels": {
"ts": {
@@ -2196,6 +2217,8 @@ def test_dashboard_v2_rejects_explicit_empty_enum(
"name": f"enum-{uuid.uuid4().hex[:8]}",
"tags": [],
"spec": {
"variables": [],
"layouts": [],
"display": {"name": "Enum"},
"panels": {
"p": {
@@ -2225,6 +2248,8 @@ def test_dashboard_v2_rejects_explicit_empty_enum(
"name": f"enum-{uuid.uuid4().hex[:8]}",
"tags": [],
"spec": {
"panels": {},
"layouts": [],
"display": {"name": "Enum"},
"variables": [
{

View File

@@ -0,0 +1,94 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import (
MetricsReducedSampleLast60s,
MetricsReducedSampleSum60s,
MetricsReducedTimeSeries,
)
from fixtures.querier import aligned_epoch, query_metric_values
# Same setup and expected values as 02_reduced_counter, but the metric is a
# delta, non-monotonic Sum. It must still be treated as a Sum (read from the
# sum_60s table), so the values match. The last_60s rows are decoys: a gauge
# misclassification would read them (999.0) instead of the sum_60s counter.
@pytest.mark.parametrize(
"time_agg, expected",
[
# 2 groups x 5 minutes x 30.0 per 300s step
("rate", 1.0), # 300 / 300s
("increase", 300.0),
],
)
def test_delta_nonmonotonic_sum_rate_and_increase(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_reduced_metrics: Callable[..., None],
time_agg: str,
expected: float,
) -> None:
metric_name = f"test_reduction_delta_nonmonotonic_sum_{time_agg}"
base_epoch = aligned_epoch(timedelta(hours=30), step_seconds=300)
# delta non-monotonic sum: MetricsReducedTimeSeries keeps the Delta temporality as-is
time_series = [
MetricsReducedTimeSeries(
metric_name=metric_name,
kept_labels={"service": service},
timestamp=datetime.fromtimestamp(base_epoch, tz=UTC),
temporality="Delta",
type_="Sum",
is_monotonic=False,
)
for service in ("a", "b")
]
assert all(ts.temporality == "Delta" for ts in time_series)
insert_reduced_metrics(
time_series,
sum_samples=[
MetricsReducedSampleSum60s(
metric_name=metric_name,
reduced_fingerprint=ts.fingerprint,
timestamp=datetime.fromtimestamp(base_epoch + minute * 60, tz=UTC),
sum_value=30.0,
count_series=2,
count_samples=2,
temporality="Delta",
)
for ts in time_series
for minute in range(20)
],
# Decoy last_60s rows: WhichReducedSamplesTableToUse reads this table only
# if the metric is (mis)classified as a Gauge. The 999.0 values are chosen
# to differ from the sum_60s result, so a regression that treats this delta
# sum as a Gauge makes the assertion below fail instead of silently passing.
last_samples=[
MetricsReducedSampleLast60s(
metric_name=metric_name,
reduced_fingerprint=ts.fingerprint,
timestamp=datetime.fromtimestamp(base_epoch + minute * 60, tz=UTC),
sum_last=999.0,
min_value=999.0,
max_value=999.0,
sum_values=999.0,
count_series=2,
count_samples=2,
temporality="Delta",
)
for ts in time_series
for minute in range(20)
],
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
values = query_metric_values(signoz, token, metric_name, base_epoch, base_epoch + 20 * 60, time_agg, "sum", step_interval=300)
assert [v["timestamp"] for v in values] == [(base_epoch + step * 300) * 1000 for step in range(4)]
assert [v["value"] for v in values] == [expected] * 4

View File

@@ -0,0 +1,62 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
from fixtures.querier import build_builder_query, get_series_values, make_query_request
# A delta, non-monotonic Sum queried without an explicit type must be treated as
# a Sum: the server resolves the type and the delta rate/increase values must be
# correct. Non-reduced delta values are temporality-driven, so this is a
# forward-looking guard against a future change routing the delta path by type
# (e.g. gauge -> avg/last).
@pytest.mark.parametrize(
"time_aggregation, expected",
[
("rate", 1.0), # 60 per 60s bucket / 60s
("increase", 60.0),
],
)
def test_delta_nonmonotonic_sum_is_treated_as_sum(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
time_aggregation: str,
expected: float,
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
start_ms = int((now - timedelta(minutes=6)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
metric_name = f"test_delta_nonmonotonic_sum_{time_aggregation}"
metrics = [
Metrics(
metric_name=metric_name,
labels={"service": "a"},
timestamp=now - timedelta(minutes=minute),
value=60.0,
temporality="Delta",
type_="Sum",
is_monotonic=False,
)
for minute in range(1, 6)
]
insert_metrics(metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# No type and no temporality: the server resolves both from the seeded series.
query = build_builder_query("A", metric_name, time_aggregation, "sum")
response = make_query_request(signoz, token, start_ms, end_ms, [query])
assert response.status_code == HTTPStatus.OK, response.text
values = get_series_values(response.json(), "A")
assert len(values) == 5, f"Expected 5 buckets, got {values}"
for value in values:
assert value["value"] == expected, f"Expected {expected}, got {value['value']}"

View File

@@ -94,7 +94,7 @@ def test_service_account_role_access_admin(
json={
"schemaVersion": "v6",
"name": "admin-sa-dash",
"spec": {"display": {"name": "admin-sa-dash"}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "admin-sa-dash"}, "links": []},
"tags": [],
},
headers={"SIGNOZ-API-KEY": api_key},
@@ -134,7 +134,7 @@ def test_service_account_role_access_editor(
json={
"schemaVersion": "v6",
"name": "editor-sa-dash",
"spec": {"display": {"name": "editor-sa-dash"}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "editor-sa-dash"}, "links": []},
"tags": [],
},
headers={"SIGNOZ-API-KEY": api_key},
@@ -174,7 +174,7 @@ def test_service_account_role_access_viewer(
json={
"schemaVersion": "v6",
"name": "viewer-sa-dash",
"spec": {"display": {"name": "viewer-sa-dash"}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "viewer-sa-dash"}, "links": []},
"tags": [],
},
headers={"SIGNOZ-API-KEY": api_key},