Compare commits

..

8 Commits

Author SHA1 Message Date
Srikanth Chekuri
bcea24f449 fix(querybuildertypesv5): keep subnormal values finite in roundToNonZeroDecimals (#12157)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
The sub-1 branch's scale (10^(-order+n-1)) overflows to +Inf for
subnormal inputs (order below ~-308), and Inf/Inf turned a finite stored
value into NaN — which then serializes as the string "NaN". Same guard
as the >=1 branch got in #12151; adds the unit coverage for both.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 09:49:08 +00:00
Pandey
22e55340d7 fix(dashboards-v2): round-trip zero-valued threshold + query fields (#12158)
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
Release Drafter / update_release_draft (push) Has been cancelled
* fix(dashboardtypes): accept threshold value of 0 on create

A NumberPanel/TimeSeries/Table threshold with `value: 0` (a legitimate
value the SigNoz UI emits by default) was rejected on create with
`dashboard_invalid_input` "Field validation for 'Value' failed on the
'required' tag".

go-playground/validator's `required` treats a numeric field equal to its
zero value as "missing", so `validate:"required"` on the float `Value`
wrongly rejected 0. Drop `validate:"required"` from `Value` on
ThresholdWithLabel and ComparisonThreshold; keep `required:"true"` since
the field is always present in the schema (0 is a valid present value, not
an absent one), so the OpenAPI/generated client are unaffected. `Color`
keeps both tags — an empty colour is genuinely invalid.

Drop the two "missing value" cases from TestValidateRequiredFields, which
asserted the removed invariant.

* fix(querybuildertypesv5): round-trip zero-valued query spec fields

A dashboard/alert query that sets a zero-valued field — `disabled: false`,
`legend: ""`, or an explicit empty `groupBy`/`order`/`selectFields`/etc. —
created fine but the GET response omitted it, so a typed client that echoes
what it sent (Terraform, SDKs, PUT-after-GET) read back `null`/absent and
reported drift. `,omitempty` dropped these zero values on the way out.

Fix the create -> GET asymmetry:

- Slice fields use `,omitzero` instead of `,omitempty`. `omitzero` omits a
  nil slice (field never set stays absent) but keeps an explicit non-nil
  `[]`, so an empty array round-trips as `[]` and there is no `null`
  regression. Applied to groupBy, order, selectFields, aggregations,
  functions, secondaryAggregations and function args across the builder,
  formula, trace-operator and join specs, plus ListPanelSpec.selectFields.
- Scalars `disabled` (bool) and `legend` (string) drop the tag entirely;
  `omitzero`/`omitempty` both suppress false/"", so the only way to
  round-trip them is to always serialize.

Result types in resp.go keep `,omitempty` — they are server-computed and
never round-tripped. Regenerate docs/api/openapi.yml and the frontend
client: the omitzero slices are now `nullable: true` in the schema (never
null on the wire, but the generated types gain `| null`, which existing
consumers already handle via `?? []`).

* test(dashboard): round-trip serialization for zero-valued fields

Add a v2 dashboards integration test that creates one minimal dashboard
(stripped from SigNoz/dashboards cicd-perses.json) and asserts the
create -> GET round-trip preserves every zero-valued field the fix targets:

- threshold value 0 (ComparisonThreshold + ThresholdWithLabel) is accepted
  on create and echoed back
- builder slices set to an explicit [] (groupBy/order/selectFields/functions)
  round-trip as [], while a bare builder's unset slices stay absent (never
  null) on read
- scalars disabled/legend always echo false/""

Table-driven: one equality table for round-tripped values and one absence
table for omitted slices.

* test(dashboard): fold round-trip test into 03_v2_dashboard

Move test_dashboard_v2_roundtrip_preserves_zero_values alongside the other
v2 dashboard tests (test_create_rejects_*, lifecycle, ...) instead of a
standalone file, with the dashboard payload inlined per this suite's style.
2026-07-18 19:52:54 +00:00
Tushar Vats
dd8cbe0844 fix: has function family revamp (#12089)
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
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-18 16:43:26 +00:00
Srikanth Chekuri
6b065aa54c fix(querybuildertypesv5): guard float overflow in roundToNonZeroDecimals (#12151)
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
Release Drafter / update_release_draft (push) Has been cancelled
math.Round(val*multiplier)/multiplier overflows to +Inf for finite values
near math.MaxFloat64, turning a valid series value into JSON-unmarshalable
Inf downstream. Return the value unrounded when the scaled intermediate
overflows.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 08:48:33 +00:00
Vikrant Gupta
ac5b5947eb feat(authz): enable FGA for telemetry resources (#12095)
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
Release Drafter / update_release_draft (push) Has been cancelled
* feat(authz): enable FGA for telemetry resources on v5 query_range

Authorize /api/v5/query_range and /preview at the telemetry-resource level,
derived from the request body:

- coretypes: ResourceWithID + ResourceExtractor as the resource-level analogue
  of the id extractors; NewResolvedResourceWithID/NewResolvedResourceWithError;
  telemetryresource selector regex widened to query-type selectors with up to
  two hashed segments (metric name, where clause) or wildcards
- telemetrytypes: QueryRangeResources maps each query to its telemetry
  resource (signal/source aware: audit-logs, meter-metrics) with a hierarchical
  selector id (query_type/<hash(metric)>/<hash(where)>); PrefixSelector expands
  the id into the grant ladder [exact, prefix/*..., *]
- handler: generic TelemetryResourceDef fans out an injected ResourceExtractor;
  fails closed when extraction errors or resolves nothing
- audit: log and skip resolved resources that carry a resolution error
- querier routes: ViewAccess -> CheckResources with telemetry read scopes;
  substitute_vars stays ViewAccess (no telemetry access)
- sqlmigration 099: backfill telemetry read tuples for existing orgs
  (admin: logs/traces/metrics/audit-logs/meter-metrics; editor/viewer:
  logs/traces/metrics)

* feat(authz): widen telemetry selector segments to 128 bits

64-bit truncation permits chosen-collision attacks at ~2^32 work; 128 bits
pushes this to 2^64. No hashed selector is persisted yet, so the change is
free.

* chore(docs): regenerate openapi spec with telemetry read scopes

* feat(telemetry): add where clause visitor

* refactor(telemetry): restructure normalizer file and quote bare values

* feat(authz): gate v5 query_range on service.name telemetry selectors

* feat(authz): encode telemetry grants as query-type qualified atom selectors

* feat(authz): move telemetry grant key to plaintext selector segment

* feat(authz): use escaped plaintext telemetry selectors with mechanical ladder

* revert(authz): restore transaction group diff in role update

* test(authz): add querierauthz integration suite for telemetry query_range gating

* test(authz): seed logs so service.name resolves in allowed querierauthz cases

* feat(authz): backfill telemetry read tuples for existing orgs

* chore(authz): reword empty composite query error message

* feat(authz): add meter metrics and audit logs to clickhouse sql

* Revert "feat(authz): add meter metrics and audit logs to clickhouse sql"

This reverts commit c9d870e0ee.

* feat(authz): grant meter-metrics to editor/viewer, keep clickhouse admin-only

* feat(authz): remove the audit logs from clickhouse check altogether until it's introduced
2026-07-17 10:59:58 +00:00
Vinicius Lourenço
c563d79ee0 feat(quick-filters): add support for /v1/fields/values (#11788)
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
Release Drafter / update_release_draft (push) Has been cancelled
* feat(quick-filters): add field values hook

* feat(quick-filters): add support to handle indeterminate state of checkbox

* feat(quick-filters): add checkbox header v2

* feat(quick-filters): add item rules to represent the states of checkbox

* feat(quick-filters): add checkbox row v2

* feat(quick-filters): add sectioned values hook to correctly calculate checkboxes to render

* feat(quick-filters): add hook to get existingQuery for field values

* feat(quick-filters): add checkbox filter v2

* test(quick-filters): add tests for checkbox filter v2

* feat(quick-filters): add flag to enable v2

* feat(infra-monitoring): enable quick filters v2

* fix(infra-monitoring): change the prefix of metric namespaces

* fix(checkbox-v2): update to improve ux

* fix(quick-filters): move from flat list to sections

* fix(quick-filters): render related/all values when searching

* Revert "fix(infra-monitoring): change the prefix of metric namespaces"

This reverts commit 176dc6d3bc.

* Revert "feat(infra-monitoring): enable quick filters v2"

This reverts commit 8902a157c1.

* fix(pr): address comments

* feat(infra-monitoring-v2): enable use new quick filters

* fix(pr): cleanup old file

* fix(quick-filters): avoid flick of old selected value on related value
2026-07-17 07:38:33 +00:00
Aditya Singh
b9b92f951b feat: table export in Logs and Traces explorer table tabs (4) (#12071)
* refactor(data-export): extract the withUnit header helper

Both the timeseries and the upcoming table serializers append units to headers (and skip display-only ids like 'short'/'none') — move the helper to its own module.

* feat(data-export): add generic table-model serializer

exportTableData serializes any prepared antd-style table model ({name, key, isValueColumn} columns + record rows) into a SerializedTable — raw values in display order, units on value columns only, blanks for missing cells. Surface-agnostic: QueryTable-based tables, dashboard tables and plain antd tables all adapt in a line or two.

* feat(data-export): add scalar queryRange serializer

exportScalarData takes the queryRange response object (formatForWeb webTables payload) + the builder query and serializes via createTableColumnsFromQuery — the exact preparer QueryTable renders from — so exports inherit the on-screen merge, naming and column order 1:1.

* feat(data-export): dispatch client exports from the queryRange response

useClientExport now takes the queryRange response object views already hold and picks the serializer from what it carries: rawV5Response (time_series) or the formatForWeb scalar payload (tables) — mounts pass their data without choosing serializers. TimeseriesExportMenu generalizes into components/ExportMenu with the same uniform inputs, so the timeseries views and the table tabs share one menu.

* feat(explorer): enable table export in Logs and Traces table tabs

Mounts ExportMenu on the Logs and Traces Table tabs, feeding the same query the rendered table uses (stagedQuery fallback parity) — exports match the on-screen table's merge, naming and column order. Header rows are class-driven, right-aligned, gated on data presence.

* test(data-export): assert scalar export filename via the naming helper

Same review cleanup as the foundation PR: frozen clock + delegation to getTimestampedFileName instead of a duplicated format regex.
2026-07-17 06:16:53 +00:00
Abhi kumar
6bcc2d66d9 feat(dashboards-v2): pre-populate alert condition from panel Reduce To + thresholds (#5291) (#12137)
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
Release Drafter / update_release_draft (push) Has been cancelled
* refactor(alerts-v2): extract alert condition normalizers to a leaf module

normalizeOperator/normalizeMatchType move from CreateAlertV2/utils.tsx into a
dependency-free context/conditionNormalizers.ts (re-exported from utils for existing
callers), so the CreateAlert context can consume them without an index<->utils cycle.

* feat(alerts-v2): apply alert-condition prefill declaratively from the URL

The CreateAlertV2 context now resolves a declarative prefill from the URL
(resolveUrlAlertPrefill) and applies each field iff present, instead of branching on
the request source. Producers own their semantics: ingestion settings now explicitly
declares matchType=in_total + evaluationWindowPreset=meter, so adding a new producer
needs no consumer change.

* feat(dashboards-v2): pre-populate alert condition from panel Reduce To + thresholds (#5291)

Creating an alert from a V2 panel now seeds the condition: Reduce To -> occurrence
type (sum->in total, avg->on average; Value panel only), and the highest-danger
threshold (Red>Orange>Green>Blue) -> operator, target value, and unit. Formula panels
apply the reduce only when all queries agree. The seed rides the /alerts/new URL and is
applied by the declarative CreateAlertV2 consumer.
2026-07-16 22:08:24 +00:00
102 changed files with 10677 additions and 744 deletions

View File

@@ -54,6 +54,7 @@ jobs:
- querierscalar
- queriercommon
- rawexportdata
- querierauthz
- role
- rootuser
- serviceaccount

View File

@@ -3039,6 +3039,7 @@ components:
selectFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
nullable: true
type: array
type: object
DashboardtypesListSort:
@@ -6552,6 +6553,7 @@ components:
args:
items:
$ref: '#/components/schemas/Querybuildertypesv5FunctionArg'
nullable: true
type: array
name:
$ref: '#/components/schemas/Querybuildertypesv5FunctionName'
@@ -6722,6 +6724,7 @@ components:
functions:
items:
$ref: '#/components/schemas/Querybuildertypesv5Function'
nullable: true
type: array
having:
$ref: '#/components/schemas/Querybuildertypesv5Having'
@@ -6734,6 +6737,7 @@ components:
order:
items:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
nullable: true
type: array
type: object
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregation:
@@ -6741,6 +6745,7 @@ components:
aggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5LogAggregation'
nullable: true
type: array
cursor:
type: string
@@ -6751,10 +6756,12 @@ components:
functions:
items:
$ref: '#/components/schemas/Querybuildertypesv5Function'
nullable: true
type: array
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
having:
$ref: '#/components/schemas/Querybuildertypesv5Having'
@@ -6771,14 +6778,17 @@ components:
order:
items:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
nullable: true
type: array
secondaryAggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5SecondaryAggregation'
nullable: true
type: array
selectFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
nullable: true
type: array
signal:
enum:
@@ -6796,6 +6806,7 @@ components:
aggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5MetricAggregation'
nullable: true
type: array
cursor:
type: string
@@ -6806,10 +6817,12 @@ components:
functions:
items:
$ref: '#/components/schemas/Querybuildertypesv5Function'
nullable: true
type: array
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
having:
$ref: '#/components/schemas/Querybuildertypesv5Having'
@@ -6826,14 +6839,17 @@ components:
order:
items:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
nullable: true
type: array
secondaryAggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5SecondaryAggregation'
nullable: true
type: array
selectFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
nullable: true
type: array
signal:
enum:
@@ -6851,6 +6867,7 @@ components:
aggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
nullable: true
type: array
cursor:
type: string
@@ -6861,10 +6878,12 @@ components:
functions:
items:
$ref: '#/components/schemas/Querybuildertypesv5Function'
nullable: true
type: array
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
having:
$ref: '#/components/schemas/Querybuildertypesv5Having'
@@ -6881,14 +6900,17 @@ components:
order:
items:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
nullable: true
type: array
secondaryAggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5SecondaryAggregation'
nullable: true
type: array
selectFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
nullable: true
type: array
signal:
enum:
@@ -6906,6 +6928,7 @@ components:
aggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
nullable: true
type: array
cursor:
type: string
@@ -6918,10 +6941,12 @@ components:
functions:
items:
$ref: '#/components/schemas/Querybuildertypesv5Function'
nullable: true
type: array
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
having:
$ref: '#/components/schemas/Querybuildertypesv5Having'
@@ -6936,12 +6961,14 @@ components:
order:
items:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
nullable: true
type: array
returnSpansFrom:
type: string
selectFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
nullable: true
type: array
stepInterval:
$ref: '#/components/schemas/Querybuildertypesv5Step'
@@ -7181,6 +7208,7 @@ components:
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
limit:
type: integer
@@ -7189,6 +7217,7 @@ components:
order:
items:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
nullable: true
type: array
stepInterval:
$ref: '#/components/schemas/Querybuildertypesv5Step'
@@ -7415,7 +7444,6 @@ components:
queries:
items:
$ref: '#/components/schemas/Querybuildertypesv5QueryEnvelope'
minItems: 1
nullable: true
type: array
queryType:
@@ -7603,28 +7631,35 @@ components:
type: string
disabled:
type: boolean
evalWindow:
type: string
evaluation:
$ref: '#/components/schemas/RuletypesEvaluationEnvelope'
frequency:
type: string
labels:
additionalProperties:
type: string
type: object
notificationSettings:
$ref: '#/components/schemas/RuletypesNotificationSettings'
preferredChannels:
items:
type: string
type: array
ruleType:
$ref: '#/components/schemas/RuletypesRuleType'
schemaVersion:
enum:
- v2alpha1
type: string
source:
type: string
version:
type: string
required:
- alert
- alertType
- ruleType
- condition
- schemaVersion
- evaluation
- notificationSettings
type: object
RuletypesQueryType:
enum:
@@ -7675,8 +7710,12 @@ components:
type: string
disabled:
type: boolean
evalWindow:
type: string
evaluation:
$ref: '#/components/schemas/RuletypesEvaluationEnvelope'
frequency:
type: string
id:
type: string
labels:
@@ -7685,11 +7724,15 @@ components:
type: object
notificationSettings:
$ref: '#/components/schemas/RuletypesNotificationSettings'
preferredChannels:
items:
type: string
type: array
ruleType:
$ref: '#/components/schemas/RuletypesRuleType'
schemaVersion:
enum:
- v2alpha1
type: string
source:
type: string
state:
$ref: '#/components/schemas/RuletypesAlertState'
@@ -7698,6 +7741,8 @@ components:
type: string
updatedBy:
type: string
version:
type: string
required:
- id
- state
@@ -7705,9 +7750,6 @@ components:
- alertType
- ruleType
- condition
- schemaVersion
- evaluation
- notificationSettings
type: object
RuletypesRuleCondition:
properties:
@@ -7720,6 +7762,10 @@ components:
type: string
compositeQuery:
$ref: '#/components/schemas/RuletypesAlertCompositeQuery'
matchType:
$ref: '#/components/schemas/RuletypesMatchType'
op:
$ref: '#/components/schemas/RuletypesCompareOperator'
requireMinPoints:
type: boolean
requiredNumPoints:
@@ -7728,12 +7774,16 @@ components:
$ref: '#/components/schemas/RuletypesSeasonality'
selectedQueryName:
type: string
target:
format: double
nullable: true
type: number
targetUnit:
type: string
thresholds:
$ref: '#/components/schemas/RuletypesRuleThresholdData'
required:
- compositeQuery
- thresholds
- selectedQueryName
type: object
RuletypesRuleThresholdData:
discriminator:
@@ -24445,9 +24495,17 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- VIEWER
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Query range
tags:
- querier
@@ -24514,9 +24572,17 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- VIEWER
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Query range preview
tags:
- querier

View File

@@ -3489,9 +3489,9 @@ export enum Querybuildertypesv5FunctionNameDTO {
}
export interface Querybuildertypesv5FunctionDTO {
/**
* @type array
* @type array,null
*/
args?: Querybuildertypesv5FunctionArgDTO[];
args?: Querybuildertypesv5FunctionArgDTO[] | null;
name?: Querybuildertypesv5FunctionNameDTO;
}
@@ -3593,18 +3593,18 @@ export interface Querybuildertypesv5SecondaryAggregationDTO {
*/
expression?: string;
/**
* @type array
* @type array,null
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[];
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
/**
* @type integer
*/
limit?: number;
limitBy?: Querybuildertypesv5LimitByDTO;
/**
* @type array
* @type array,null
*/
order?: Querybuildertypesv5OrderByDTO[];
order?: Querybuildertypesv5OrderByDTO[] | null;
stepInterval?: Querybuildertypesv5StepDTO;
}
@@ -3634,9 +3634,9 @@ export enum TelemetrytypesSourceDTO {
}
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTO {
/**
* @type array
* @type array,null
*/
aggregations?: Querybuildertypesv5LogAggregationDTO[];
aggregations?: Querybuildertypesv5LogAggregationDTO[] | null;
/**
* @type string
*/
@@ -3647,13 +3647,13 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
disabled?: boolean;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array
* @type array,null
*/
functions?: Querybuildertypesv5FunctionDTO[];
functions?: Querybuildertypesv5FunctionDTO[] | null;
/**
* @type array
* @type array,null
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[];
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
having?: Querybuildertypesv5HavingDTO;
/**
* @type string
@@ -3673,17 +3673,17 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
*/
offset?: number;
/**
* @type array
* @type array,null
*/
order?: Querybuildertypesv5OrderByDTO[];
order?: Querybuildertypesv5OrderByDTO[] | null;
/**
* @type array
* @type array,null
*/
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[];
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[] | null;
/**
* @type array
* @type array,null
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
/**
* @enum logs
* @type string
@@ -3759,9 +3759,9 @@ export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQue
}
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTO {
/**
* @type array
* @type array,null
*/
aggregations?: Querybuildertypesv5MetricAggregationDTO[];
aggregations?: Querybuildertypesv5MetricAggregationDTO[] | null;
/**
* @type string
*/
@@ -3772,13 +3772,13 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
disabled?: boolean;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array
* @type array,null
*/
functions?: Querybuildertypesv5FunctionDTO[];
functions?: Querybuildertypesv5FunctionDTO[] | null;
/**
* @type array
* @type array,null
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[];
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
having?: Querybuildertypesv5HavingDTO;
/**
* @type string
@@ -3798,17 +3798,17 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
*/
offset?: number;
/**
* @type array
* @type array,null
*/
order?: Querybuildertypesv5OrderByDTO[];
order?: Querybuildertypesv5OrderByDTO[] | null;
/**
* @type array
* @type array,null
*/
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[];
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[] | null;
/**
* @type array
* @type array,null
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
/**
* @enum metrics
* @type string
@@ -3834,9 +3834,9 @@ export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQue
}
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO {
/**
* @type array
* @type array,null
*/
aggregations?: Querybuildertypesv5TraceAggregationDTO[];
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
/**
* @type string
*/
@@ -3847,13 +3847,13 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
disabled?: boolean;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array
* @type array,null
*/
functions?: Querybuildertypesv5FunctionDTO[];
functions?: Querybuildertypesv5FunctionDTO[] | null;
/**
* @type array
* @type array,null
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[];
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
having?: Querybuildertypesv5HavingDTO;
/**
* @type string
@@ -3873,17 +3873,17 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
*/
offset?: number;
/**
* @type array
* @type array,null
*/
order?: Querybuildertypesv5OrderByDTO[];
order?: Querybuildertypesv5OrderByDTO[] | null;
/**
* @type array
* @type array,null
*/
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[];
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[] | null;
/**
* @type array
* @type array,null
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
/**
* @enum traces
* @type string
@@ -4272,9 +4272,9 @@ export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboa
}
export interface DashboardtypesListPanelSpecDTO {
/**
* @type array
* @type array,null
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO {
@@ -4344,9 +4344,9 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
*/
expression?: string;
/**
* @type array
* @type array,null
*/
functions?: Querybuildertypesv5FunctionDTO[];
functions?: Querybuildertypesv5FunctionDTO[] | null;
having?: Querybuildertypesv5HavingDTO;
/**
* @type string
@@ -4361,9 +4361,9 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
*/
name?: string;
/**
* @type array
* @type array,null
*/
order?: Querybuildertypesv5OrderByDTO[];
order?: Querybuildertypesv5OrderByDTO[] | null;
}
export enum Querybuildertypesv5QueryEnvelopeFormulaDTOType {
@@ -4380,9 +4380,9 @@ export interface Querybuildertypesv5QueryEnvelopeFormulaDTO {
export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
/**
* @type array
* @type array,null
*/
aggregations?: Querybuildertypesv5TraceAggregationDTO[];
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
/**
* @type string
*/
@@ -4397,13 +4397,13 @@ export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
expression?: string;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array
* @type array,null
*/
functions?: Querybuildertypesv5FunctionDTO[];
functions?: Querybuildertypesv5FunctionDTO[] | null;
/**
* @type array
* @type array,null
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[];
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
having?: Querybuildertypesv5HavingDTO;
/**
* @type string
@@ -4422,17 +4422,17 @@ export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
*/
offset?: number;
/**
* @type array
* @type array,null
*/
order?: Querybuildertypesv5OrderByDTO[];
order?: Querybuildertypesv5OrderByDTO[] | null;
/**
* @type string
*/
returnSpansFrom?: string;
/**
* @type array
* @type array,null
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
stepInterval?: Querybuildertypesv5StepDTO;
}

View File

@@ -1,4 +1,4 @@
.timeseries-export-popover {
.export-menu-popover {
width: 240px;
padding: 0 12px 12px 12px;

View File

@@ -4,42 +4,43 @@ import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { useClientExport } from 'hooks/useExportData/useClientExport';
import {
ClientExportData,
useClientExport,
} from 'hooks/useExportData/useClientExport';
import { ExportFormat } from 'lib/exportData/types';
import { useCallback, useState } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import './TimeseriesExportMenu.styles.scss';
import './ExportMenu.styles.scss';
interface TimeseriesExportMenuProps {
interface ExportMenuProps {
dataSource: DataSource;
queryResponse: QueryRangeResponseV5;
// The queryRange response object the view holds — the hook picks the
// serializer (timeseries / table) from what it carries.
data: ClientExportData;
query?: Query;
yAxisUnit?: string;
legendMap?: Record<string, string>;
fileName?: string;
}
// Download menu for in-memory timeseries data (client-side serialization).
// Download menu for in-memory query results (client-side serialization).
// The raw/list backend export keeps its own menu in DownloadOptionsMenu.
export default function TimeseriesExportMenu({
export default function ExportMenu({
dataSource,
queryResponse,
data,
query,
yAxisUnit,
legendMap,
fileName,
}: TimeseriesExportMenuProps): JSX.Element {
}: ExportMenuProps): JSX.Element {
const [exportFormat, setExportFormat] = useState<string>(ExportFormat.Csv);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const { isExporting, handleExport: handleClientExport } = useClientExport({
response: queryResponse,
data,
query,
yAxisUnit,
legendMap,
fileName,
});
@@ -57,7 +58,7 @@ export default function TimeseriesExportMenu({
color="secondary"
size="icon"
aria-label="Download"
data-testid={`timeseries-export-${dataSource}`}
data-testid={`export-menu-${dataSource}`}
disabled={isExporting}
loading={isExporting}
>
@@ -65,7 +66,7 @@ export default function TimeseriesExportMenu({
</Button>
</PopoverTrigger>
</TooltipSimple>
<PopoverContent align="end" className="timeseries-export-popover">
<PopoverContent align="end" className="export-menu-popover">
<div className="export-format">
<Typography.Text className="title">FORMAT</Typography.Text>
<RadioGroup value={exportFormat} onChange={setExportFormat}>

View File

@@ -1,8 +1,8 @@
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { fireEvent, render, screen } from 'tests/test-utils';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import TimeseriesExportMenu from '../TimeseriesExportMenu';
import ExportMenu from '../ExportMenu';
const mockHandleExport = jest.fn();
let mockIsExporting = false;
@@ -14,25 +14,26 @@ jest.mock('hooks/useExportData/useClientExport', () => ({
}),
}));
const response = {
type: 'time_series',
data: { results: [] },
meta: {},
} as unknown as QueryRangeResponseV5;
const data = {
statusCode: 200,
error: null,
message: '',
payload: { data: { result: [], resultType: 'time_series' } },
} as unknown as MetricQueryRangeSuccessResponse;
const TEST_ID = `timeseries-export-${DataSource.LOGS}`;
const TEST_ID = `export-menu-${DataSource.LOGS}`;
function renderMenu(): void {
render(
<TimeseriesExportMenu
<ExportMenu
dataSource={DataSource.LOGS}
queryResponse={response}
data={data}
fileName="logs-timeseries"
/>,
);
}
describe('TimeseriesExportMenu', () => {
describe('ExportMenu', () => {
beforeEach(() => {
mockHandleExport.mockReset();
mockIsExporting = false;

View File

@@ -11,6 +11,8 @@ import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
import { isKeyMatch } from './utils';
import { CheckedState } from '../../types';
import { SectionType } from './v2/itemRules';
export const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
export const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
@@ -148,6 +150,8 @@ export function applyCheckboxToggle({
value,
checked,
isOnlyOrAllClicked,
previousState,
sectionType,
}: {
currentQuery: Query;
activeQueryIndex: number;
@@ -157,6 +161,8 @@ export function applyCheckboxToggle({
value: string;
checked: boolean;
isOnlyOrAllClicked: boolean;
previousState?: CheckedState;
sectionType?: SectionType;
}): Query {
const activeItems =
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items;
@@ -216,6 +222,7 @@ export function applyCheckboxToggle({
);
if (currentFilter) {
const runningOperator = currentFilter?.op;
switch (runningOperator) {
case 'in':
if (checked) {
@@ -246,9 +253,29 @@ export function applyCheckboxToggle({
});
}
} else if (!checked) {
// if we are removing some value when the running operator is IN we filter.
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
if (isArray(currentFilter.value)) {
// Related section: clicking to exclude creates NOT_IN for just this value
if (sectionType === SectionType.RELATED) {
const newFilter: TagFilterItem = {
id: uuid(),
op: getNotInOperator(source),
key: filter.attributeKey,
value,
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
} else if (isArray(currentFilter.value)) {
// if we are removing some value when the running operator is IN we filter.
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
@@ -275,11 +302,38 @@ export function applyCheckboxToggle({
}
break;
case 'nin':
case 'not in':
// if the current running operator is NIN then when unchecking the value it gets
// added to the clause like key NIN [value1 , currentUnselectedValue]
if (!checked) {
// in case of array add the currentUnselectedValue to the list.
case 'not in': {
// NOT IN means "exclude these values"
// Check if value is currently in the exclusion list
const isValueInFilter = isArray(currentFilter.value)
? currentFilter.value.includes(value)
: currentFilter.value === value;
// When clicking unchecked "Other" item, user wants to SELECT it
// Replace NOT IN filter with IN [value]
if (previousState === 'unchecked' && checked) {
const newFilter: TagFilterItem = {
id: uuid(),
op: getOperatorValue(OPERATORS.IN),
key: filter.attributeKey,
value,
};
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter;
}
return item;
});
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
} else if (!checked || !isValueInFilter) {
// Add to NOT IN when:
// - checked=false (user explicitly unchecked to exclude)
// - checked=true but value not in filter (clicking "other" value to exclude)
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
@@ -292,7 +346,6 @@ export function applyCheckboxToggle({
return item;
});
} else {
// in case of not an array make it one!
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
@@ -304,8 +357,9 @@ export function applyCheckboxToggle({
return item;
});
}
} else if (checked) {
// opposite of above!
} else {
// Remove from NOT IN when value IS in filter and checked=true
// (user wants to include this value back)
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
@@ -346,6 +400,7 @@ export function applyCheckboxToggle({
}
}
break;
}
case '=':
if (checked) {
const newFilter = {
@@ -389,10 +444,11 @@ export function applyCheckboxToggle({
}
}
} else {
// case - when there is no filter for the current key that means all are selected right now.
// No filter for this key - all are visually selected.
// checked=true → user wants to select (IN), checked=false → exclude (NOT IN)
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getNotInOperator(source),
op: checked ? getOperatorValue(OPERATORS.IN) : getNotInOperator(source),
key: filter.attributeKey,
value,
};

View File

@@ -10,6 +10,8 @@ import {
applyCheckboxToggle,
clearFilterFromQuery,
} from './checkboxFilterQuery';
import { CheckedState } from '../../types';
import { SectionType } from './v2/itemRules';
interface UseCheckboxFilterActionsProps {
filter: IQuickFiltersConfig;
@@ -24,6 +26,8 @@ interface UseCheckboxFilterActionsReturn {
value: string,
checked: boolean,
isOnlyOrAllClicked: boolean,
previousState?: CheckedState,
sectionType?: SectionType,
) => void;
onClear: () => void;
}
@@ -53,6 +57,8 @@ function useCheckboxFilterActions({
value: string,
checked: boolean,
isOnlyOrAllClicked: boolean,
previousState?: CheckedState,
sectionType?: SectionType,
): void => {
dispatch(
applyCheckboxToggle({
@@ -64,6 +70,8 @@ function useCheckboxFilterActions({
value,
checked,
isOnlyOrAllClicked,
previousState,
sectionType,
}),
);
};

View File

@@ -0,0 +1,103 @@
.checkboxFilter {
display: flex;
flex-direction: column;
padding: var(--spacing-6);
gap: var(--spacing-6);
border-bottom: 1px solid var(--l1-border);
}
.search {
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-focus-border-color: var(--l2-border);
}
.searchSpinner {
color: var(--l2-foreground);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.values {
display: flex;
flex-direction: column;
}
.sectionValues {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.loadingValues {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-6) 0;
}
.loadingMore {
align-self: center;
}
.noData {
align-self: center;
}
.showMore {
display: flex;
align-items: center;
justify-content: center;
}
.showMoreText {
color: var(--accent-primary);
cursor: pointer;
}
.goToDocs {
display: flex;
flex-direction: column;
gap: 44px;
}
.goToDocsContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
}
.goToDocsMessage {
color: var(--l2-foreground);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
.goToDocsButton {
display: flex;
align-items: center;
gap: var(--spacing-2);
cursor: pointer;
margin: 0 0 var(--spacing-2);
padding: 0;
}
.goToDocsButtonText {
color: var(--bg-robin-400);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}

View File

@@ -0,0 +1,126 @@
import { render, RenderResult } from 'tests/test-utils';
import { server, rest } from 'mocks-server/server';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import {
FiltersType,
IQuickFiltersConfig,
QuickFilterCheckboxUseFieldApis,
QuickFiltersSource,
} from '../../../types';
import CheckboxFilterV2 from './CheckboxFilterV2';
export const DEFAULT_FILTER: IQuickFiltersConfig = {
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: 'deployment.environment',
dataType: DataTypes.String,
type: 'tag',
},
dataSource: DataSource.TRACES,
defaultOpen: true,
};
export const DEFAULT_USE_FIELD_APIS: QuickFilterCheckboxUseFieldApis = {
startUnixMilli: 1700000000000,
endUnixMilli: 1700003600000,
existingQuery: null,
};
export function mockFieldsValuesAPI(response: {
relatedValues?: (string | null)[];
stringValues?: (string | null)[];
numberValues?: (number | null)[];
}): void {
server.use(
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: response.relatedValues ?? [],
stringValues: response.stringValues ?? [],
numberValues: response.numberValues ?? [],
},
},
}),
),
),
);
}
export function mockFieldsValuesAPILoading(): void {
server.use(
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>
res(ctx.delay(10000)),
),
);
}
export function setupServer(): void {
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
}
export interface FilterItemConfig {
op: string;
value: string | string[];
}
export function renderWithFilter(
onFilterChange: jest.Mock,
filterItem?: FilterItemConfig,
): RenderResult {
const items: TagFilterItem[] = filterItem
? [
{
key: { key: 'deployment.environment' },
op: filterItem.op,
value: filterItem.value,
} as TagFilterItem,
]
: [];
return render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
onFilterChange={onFilterChange}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: { items, op: 'AND' },
filter: { expression: 'service.name = "api"' },
},
],
},
},
} as never,
},
);
}
export function getFilterFromCall(
onFilterChange: jest.Mock,
callIndex = 0,
): TagFilterItem | undefined {
const query = onFilterChange.mock.calls[callIndex]?.[0] as Query | undefined;
return query?.builder.queryData[0]?.filters?.items?.find(
(item) => item.key?.key === 'deployment.environment',
);
}

View File

@@ -0,0 +1,241 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { LoaderCircle } from '@signozhq/icons';
import {
IQuickFiltersConfig,
QuickFilterCheckboxUseFieldApis,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { NON_SELECTED_OPERATORS } from '../checkboxFilterQuery';
import useActiveQueryIndex from '../useActiveQueryIndex';
import useCheckboxDisclosure from '../useCheckboxDisclosure';
import useCheckboxFilterActions from '../useCheckboxFilterActions';
import useCheckboxFilterState from '../useCheckboxFilterState';
import { useFieldValues } from './useFieldValues';
import { useExistingQuery } from './useExistingQuery';
import { isKeyMatch } from '../utils';
import { CheckboxFilterV2Header } from './CheckboxFilterV2Header';
import { CheckboxFilterV2Section } from './CheckboxFilterV2Section';
import { buildSelectedSet, useSectionedValues } from './useSectionedValues';
import { useStaleRelatedExclusions } from './useStaleRelatedExclusions';
import styles from './CheckboxFilterV2.module.scss';
interface CheckboxFilterV2Props {
filter: IQuickFiltersConfig;
source: QuickFiltersSource;
onFilterChange?: (query: Query) => void;
useFieldApis: QuickFilterCheckboxUseFieldApis;
}
export default function CheckboxFilterV2(
props: CheckboxFilterV2Props,
): JSX.Element {
const { source, filter, onFilterChange, useFieldApis } = props;
const [searchText, setSearchText] = useState<string>('');
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
const { currentQuery } = useQueryBuilder();
const activeQueryIndex = useActiveQueryIndex(source);
const {
isOpen,
isSomeFilterPresentForCurrentAttribute,
visibleItemsCount,
onToggleOpen,
onShowMore,
} = useCheckboxDisclosure({ filter, activeQueryIndex });
// Auto-preserve open state when filter is present
useEffect(() => {
if (isSomeFilterPresentForCurrentAttribute && userToggleState === null) {
setUserToggleState(true);
}
}, [isSomeFilterPresentForCurrentAttribute, userToggleState]);
const { existingQuery, hasExistingQuery } = useExistingQuery({
useFieldApis,
activeQueryIndex,
});
const { relatedValues, allValues, isLoading, isFetching } = useFieldValues({
filter,
searchText,
existingQuery,
metricNamespace: useFieldApis.metricNamespace,
startUnixMilli: useFieldApis.startUnixMilli,
endUnixMilli: useFieldApis.endUnixMilli,
enabled: isOpen,
});
// Track if initial load completed (don't show skeleton after first load)
// Must track if loading ever started, otherwise hasLoadedOnce gets set
// immediately on first render when query is disabled (isLoading=false)
const hasLoadedOnce = useRef(false);
const wasLoading = useRef(false);
if (isLoading) {
wasLoading.current = true;
}
if (!isLoading && wasLoading.current && !hasLoadedOnce.current) {
hasLoadedOnce.current = true;
}
// Combine for state derivation
const attributeValues = useMemo(() => {
const combined = [...relatedValues, ...allValues];
return [...new Set(combined)];
}, [relatedValues, allValues]);
const { currentFilterState, isFilterDisabled, isMultipleValuesTrueForTheKey } =
useCheckboxFilterState({ filter, attributeValues, activeQueryIndex });
const { onChange, onClear } = useCheckboxFilterActions({
filter,
source,
attributeValues,
activeQueryIndex,
onFilterChange,
});
const setSearchTextDebounced = useDebouncedFn((...args) => {
setSearchText(args[0] as string);
}, DEBOUNCE_DELAY);
const currentFilterOp = useMemo(() => {
const filterSync = currentQuery?.builder.queryData?.[
activeQueryIndex
]?.filters?.items.find((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
);
return filterSync?.op;
}, [
currentQuery?.builder.queryData,
activeQueryIndex,
filter.attributeKey.key,
]);
const isNotInOperator = NON_SELECTED_OPERATORS.includes(currentFilterOp || '');
const selectedValues = useMemo(
() => [
...buildSelectedSet(
currentFilterState,
isSomeFilterPresentForCurrentAttribute,
isNotInOperator,
),
],
[currentFilterState, isSomeFilterPresentForCurrentAttribute, isNotInOperator],
);
const isRefreshing = isFetching && !isLoading && searchText === '';
const relatedExclusions = useStaleRelatedExclusions({
selectedValues,
isFetching,
isRefreshing,
});
const { sections, totalCount } = useSectionedValues({
relatedValues,
allValues,
currentFilterState,
isSomeFilterPresentForCurrentAttribute,
isNotInOperator,
hasExistingQuery,
visibleItemsCount,
relatedExclusions,
});
return (
<div className={styles.checkboxFilter} data-testid="checkbox-filter-v2">
<CheckboxFilterV2Header
title={filter.title}
isOpen={isOpen}
showClearAll={!!attributeValues.length}
onToggleOpen={onToggleOpen}
onClear={onClear}
isSomeFilterPresentForCurrentAttribute={
isSomeFilterPresentForCurrentAttribute
}
/>
{isOpen && isLoading && !hasLoadedOnce.current && (
<section>
<Skeleton paragraph={{ rows: 4 }} />
</section>
)}
{isOpen && (!isLoading || hasLoadedOnce.current) && (
<>
<section className={styles.search}>
<Input
placeholder="Filter values"
onChange={(e): void => setSearchTextDebounced(e.target.value)}
disabled={isFilterDisabled}
data-testid="checkbox-filter-search"
suffix={
isFetching ? (
<LoaderCircle
size={14}
className={styles.searchSpinner}
data-testid="checkbox-filter-search-loading"
/>
) : null
}
/>
</section>
{totalCount > 0 && (
<section className={styles.values}>
{sections.map((section, index) => (
<CheckboxFilterV2Section
key={section.type}
section={section}
index={index}
isFilterDisabled={isFilterDisabled}
filter={filter}
isSomeFilterPresentForCurrentAttribute={
isSomeFilterPresentForCurrentAttribute
}
isMultipleValuesTrueForTheKey={isMultipleValuesTrueForTheKey}
onChange={onChange}
/>
))}
</section>
)}
{totalCount === 0 && hasLoadedOnce.current && !isFetching && (
<section
className={styles.noData}
data-testid={
searchText
? 'checkbox-filter-no-search-results'
: 'checkbox-filter-empty'
}
>
<Typography.Text>No values found</Typography.Text>
</section>
)}
{visibleItemsCount < totalCount &&
!(searchText && (isLoading || isFetching)) && (
<section className={styles.showMore}>
<Typography.Text
className={styles.showMoreText}
onClick={onShowMore}
data-testid="checkbox-filter-show-more"
>
Show More...
</Typography.Text>
</section>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
}
.leftAction {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.title {
color: var(--l2-foreground);
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
text-transform: capitalize;
}
.rightAction {
display: flex;
align-items: center;
min-width: 48px;
}
.clearAll {
font-size: 12px;
color: var(--accent-primary);
cursor: pointer;
}

View File

@@ -0,0 +1,62 @@
import { Typography } from '@signozhq/ui/typography';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import styles from './CheckboxFilterV2Header.module.scss';
interface CheckboxFilterHeaderProps {
title: string;
isOpen: boolean;
showClearAll: boolean;
onToggleOpen: () => void;
onClear: () => void;
isSomeFilterPresentForCurrentAttribute: boolean;
}
export function CheckboxFilterV2Header({
title,
isOpen,
showClearAll,
onToggleOpen,
onClear,
isSomeFilterPresentForCurrentAttribute,
}: CheckboxFilterHeaderProps): JSX.Element {
return (
<section
role="button"
tabIndex={0}
className={styles.header}
onClick={onToggleOpen}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
onToggleOpen();
}
}}
data-testid="checkbox-filter-header"
data-state={isOpen ? 'open' : 'closed'}
>
<section className={styles.leftAction}>
{isOpen ? (
<ChevronDown size={13} cursor="pointer" />
) : (
<ChevronRight size={13} cursor="pointer" />
)}
<Typography.Text className={styles.title}>{title}</Typography.Text>
</section>
<section className={styles.rightAction}>
{isOpen && showClearAll && isSomeFilterPresentForCurrentAttribute && (
<Typography.Text
className={styles.clearAll}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
onClear();
}}
data-testid="checkbox-filter-clear-all"
>
Clear
</Typography.Text>
)}
</section>
</section>
);
}

View File

@@ -0,0 +1,106 @@
import {
IQuickFiltersConfig,
CheckedState,
} from 'components/QuickFilters/types';
import { CheckboxFilterV2ValueRow } from './CheckboxFilterV2ValueRow';
import { SectionDivider } from './SectionDivider';
import { Section } from './useSectionedValues';
import { SectionType } from './itemRules';
import styles from './CheckboxFilterV2.module.scss';
interface SectionConfig {
label: string;
tooltip?: string;
}
function getSectionConfig(type: SectionType): SectionConfig | null {
switch (type) {
case SectionType.SELECTED:
return { label: 'Selected' };
case SectionType.RELATED:
return {
label: 'Related',
tooltip: 'Values that are filtered by your current selection.',
};
case SectionType.ALL_VALUES:
return { label: 'All values' };
default:
return null;
}
}
interface CheckboxFilterV2SectionProps {
section: Section;
index: number;
isFilterDisabled: boolean;
filter: IQuickFiltersConfig;
isSomeFilterPresentForCurrentAttribute: boolean;
isMultipleValuesTrueForTheKey: boolean;
onChange: (
value: string,
checked: boolean,
isOnly: boolean,
previousState?: CheckedState,
sectionType?: SectionType,
) => void;
}
export function CheckboxFilterV2Section(
props: CheckboxFilterV2SectionProps,
): JSX.Element | null {
const {
section,
index,
isFilterDisabled,
filter,
isSomeFilterPresentForCurrentAttribute,
isMultipleValuesTrueForTheKey,
onChange,
} = props;
if (section.items.length === 0) {
return null;
}
const config = getSectionConfig(section.type);
// Show divider for all sections except first SELECTED section
const showDivider =
config !== null && (index > 0 || section.type !== SectionType.SELECTED);
return (
<div data-testid={`section-${section.type}`} className={styles.sectionValues}>
{showDivider && config && (
<SectionDivider label={config.label} tooltip={config.tooltip} />
)}
{section.items.map(({ value, badge, checkedState }) => {
const isChecked = checkedState === 'checked';
return (
<CheckboxFilterV2ValueRow
key={value}
value={value}
checkedState={checkedState}
disabled={isFilterDisabled}
title={filter.title}
badge={badge}
onlyButtonLabel={
isSomeFilterPresentForCurrentAttribute
? isChecked && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only'
}
customRendererForValue={filter.customRendererForValue}
onCheckboxChange={(checked, previousState): void =>
onChange(value, checked, false, previousState, section.type)
}
onOnlyOrAllClick={(): void => onChange(value, isChecked, true)}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,164 @@
.valueRow {
display: flex;
align-items: center;
gap: var(--spacing-4);
min-height: 24px;
}
.checkbox {
display: inline-flex;
align-items: center;
}
.valueButton {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--spacing-2);
width: calc(100% - 24px);
cursor: pointer;
}
.content {
display: flex;
align-items: center;
gap: var(--spacing-2);
min-width: 0;
}
.valueLabel {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.actions {
display: grid;
align-items: center;
justify-items: end;
// Stack badge / only / toggle in a single cell so the crossfade overlaps
// instead of laying them side-by-side mid-transition.
> * {
grid-area: 1 / 1;
}
}
.badge {
opacity: 1;
transition:
opacity 0.16s ease,
display 0.16s allow-discrete;
}
.onlyButton {
display: none;
align-items: center;
justify-content: center;
opacity: 0;
transform: translateX(4px);
transition:
opacity 0.16s ease,
transform 0.16s ease,
display 0.16s allow-discrete;
--button-height: 21px;
--button-padding: var(--spacing-5);
&:hover {
background-color: unset;
}
}
.toggleButton {
display: none;
align-items: center;
justify-content: center;
opacity: 0;
transform: translateX(4px);
transition:
opacity 0.16s ease,
transform 0.16s ease,
display 0.16s allow-discrete;
--button-height: 21px;
--button-padding: var(--spacing-5);
&:hover {
background-color: unset;
}
}
.isDisabled {
cursor: not-allowed;
.valueLabel {
color: var(--l3-foreground);
}
.onlyButton {
cursor: not-allowed;
color: var(--l3-foreground);
}
.toggleButton {
cursor: not-allowed;
color: var(--l3-foreground);
}
}
.valueButton:hover {
.onlyButton {
display: flex;
opacity: 1;
transform: translateX(0);
@starting-style {
opacity: 0;
transform: translateX(4px);
}
}
.badge {
display: none;
opacity: 0;
}
}
.checkbox:hover ~ .valueButton {
.toggleButton {
display: flex;
opacity: 1;
transform: translateX(0);
@starting-style {
opacity: 0;
transform: translateX(4px);
}
}
.badge {
display: none;
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.badge,
.onlyButton,
.toggleButton {
transition: none;
}
}
.indicatorFalse {
width: 2px;
height: 11px;
border-radius: 2px;
background: var(--danger-background);
}
.indicatorTrue {
width: 2px;
height: 11px;
border-radius: 2px;
background: var(--bg-forest-500);
}

View File

@@ -0,0 +1,118 @@
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { BadgeConfig } from './itemRules';
import { CheckedState } from '../../../types';
import styles from './CheckboxFilterV2ValueRow.module.scss';
interface ValueRowProps {
value: string;
checkedState: CheckedState;
disabled: boolean;
title: string;
onlyButtonLabel: string;
customRendererForValue?: (value: string) => JSX.Element;
onCheckboxChange: (checked: boolean, previousState: CheckedState) => void;
onOnlyOrAllClick: () => void;
badge: BadgeConfig | null;
}
function toCheckboxValue(state: CheckedState): boolean | 'indeterminate' {
if (state === 'indeterminate') {
return 'indeterminate';
}
return state === 'checked';
}
const INDICATOR_CLASS_MAP = {
false: styles.indicatorFalse,
true: styles.indicatorTrue,
} as Record<string, string>;
export function CheckboxFilterV2ValueRow({
value,
checkedState,
disabled,
title,
onlyButtonLabel,
customRendererForValue,
onCheckboxChange,
onOnlyOrAllClick,
badge,
}: ValueRowProps): JSX.Element {
const indicatorClass = INDICATOR_CLASS_MAP[value];
return (
<div
className={styles.valueRow}
data-testid={`checkbox-value-row-${value}`}
data-state={checkedState}
data-disabled={disabled}
>
<div className={styles.checkbox}>
<Checkbox
onChange={(isChecked): void =>
onCheckboxChange(isChecked === true, checkedState)
}
value={toCheckboxValue(checkedState)}
disabled={disabled}
color="primary"
testId={`checkbox-${title}-${value}`}
/>
</div>
<div
role="button"
tabIndex={disabled ? -1 : 0}
className={cx(styles.valueButton, disabled && styles.isDisabled)}
onClick={(): void => {
if (disabled) {
return;
}
onOnlyOrAllClick();
}}
onKeyDown={(e): void => {
if (disabled) {
return;
}
if (e.key === 'Enter' || e.key === ' ') {
onOnlyOrAllClick();
}
}}
>
<div className={styles.content}>
{indicatorClass && <div className={indicatorClass} />}
{customRendererForValue ? (
customRendererForValue(value)
) : (
<Typography.Text title={value} className={styles.valueLabel}>
{value}
</Typography.Text>
)}
</div>
<div className={styles.actions}>
{badge && (
<Badge
variant="outline"
color={badge.color}
className={styles.badge}
testId={`badge-${badge.key}`}
>
{badge.label}
</Badge>
)}
<Button variant="ghost" color="secondary" className={styles.onlyButton}>
{onlyButtonLabel}
</Button>
<Button variant="ghost" color="secondary" className={styles.toggleButton}>
Toggle
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
.divider {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-2) 0;
margin-bottom: calc(-1 * var(--spacing-4));
}
.line {
flex: 1;
height: 1px;
background: var(--l1-border);
}
.label {
font-size: var(--periscope-font-size-small);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--l3-foreground);
white-space: nowrap;
}
.infoIcon {
color: var(--l3-foreground);
cursor: help;
flex-shrink: 0;
}

View File

@@ -0,0 +1,33 @@
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Info } from '@signozhq/icons';
import styles from './SectionDivider.module.scss';
interface SectionDividerProps {
label: string;
tooltip?: string;
}
export function SectionDivider({
label,
tooltip,
}: SectionDividerProps): JSX.Element {
const testId = label.toLowerCase().replace(/\s+/g, '-');
return (
<div className={styles.divider} data-testid={`section-divider-${testId}`}>
<div className={styles.line} />
<Typography.Text className={styles.label}>{label}</Typography.Text>
{tooltip && (
<Tooltip title={tooltip}>
<Info
size={12}
className={styles.infoIcon}
data-testid="section-divider-info"
/>
</Tooltip>
)}
<div className={styles.line} />
</div>
);
}

View File

@@ -0,0 +1,302 @@
import { screen } from '@testing-library/react';
import { server, rest } from 'mocks-server/server';
import { render } from 'tests/test-utils';
import { QuickFiltersSource } from '../../../../types';
import CheckboxFilterV2 from '../CheckboxFilterV2';
import {
DEFAULT_FILTER,
DEFAULT_USE_FIELD_APIS,
setupServer,
} from '../CheckboxFilterV2.testUtils';
const USE_FIELD_APIS_AUTO_DERIVE = {
...DEFAULT_USE_FIELD_APIS,
existingQuery: undefined,
};
setupServer();
describe('CheckboxFilterV2 - existingQuery calculation', () => {
const captureExistingQuery = (): Promise<string | null> =>
new Promise((resolve) => {
server.use(
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
const existingQuery = req.url.searchParams.get('existingQuery');
resolve(existingQuery);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: [],
stringValues: ['test'],
numberValues: [],
},
},
}),
);
}),
);
});
describe('useFieldApis.existingQuery takes precedence', () => {
it('uses useFieldApis.existingQuery when provided', async () => {
const queryPromise = captureExistingQuery();
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'custom.query = "value"',
}}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: { items: [], op: 'AND' },
filter: { expression: 'should.be.ignored = "yes"' },
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-test');
const capturedQuery = await queryPromise;
expect(capturedQuery).toBe('custom.query = "value"');
});
it('returns undefined when useFieldApis.existingQuery is null', async () => {
const queryPromise = captureExistingQuery();
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: null,
}}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: { items: [], op: 'AND' },
filter: { expression: 'should.be.ignored = "yes"' },
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-test');
const capturedQuery = await queryPromise;
expect(capturedQuery).toBeNull();
});
});
describe('V5 filter.expression preferred over V3 filters.items', () => {
it('uses V5 filter.expression when both exist', async () => {
const queryPromise = captureExistingQuery();
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'service.name', dataType: 'string', type: 'tag' },
op: '=',
value: 'from-v3-items',
},
],
op: 'AND',
},
filter: { expression: 'v5.expression = "preferred"' },
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-test');
const capturedQuery = await queryPromise;
expect(capturedQuery).toBe('v5.expression = "preferred"');
});
it('uses V5 filter.expression when no V3 items exist', async () => {
const queryPromise = captureExistingQuery();
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: { items: [], op: 'AND' },
filter: { expression: 'only.v5 = "expression"' },
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-test');
const capturedQuery = await queryPromise;
expect(capturedQuery).toBe('only.v5 = "expression"');
});
});
describe('V3 filters.items fallback', () => {
it('converts V3 filters.items to expression when no V5 expression exists', async () => {
const queryPromise = captureExistingQuery();
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'service.name', dataType: 'string', type: 'tag' },
op: '=',
value: 'api-service',
},
],
op: 'AND',
},
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-test');
const capturedQuery = await queryPromise;
expect(capturedQuery).toBe("service.name = 'api-service'");
});
it('converts multiple V3 filters.items with AND operator', async () => {
const queryPromise = captureExistingQuery();
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'service.name', dataType: 'string', type: 'tag' },
op: '=',
value: 'api',
},
{
key: { key: 'env', dataType: 'string', type: 'tag' },
op: '=',
value: 'prod',
},
],
op: 'AND',
},
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-test');
const capturedQuery = await queryPromise;
expect(capturedQuery).toBe("service.name = 'api' AND env = 'prod'");
});
it('returns undefined when no filters exist', async () => {
const queryPromise = captureExistingQuery();
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: { items: [], op: 'AND' },
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-test');
const capturedQuery = await queryPromise;
expect(capturedQuery).toBeNull();
});
});
});

View File

@@ -0,0 +1,768 @@
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { server, rest } from 'mocks-server/server';
import { render } from 'tests/test-utils';
import { QuickFiltersSource } from '../../../../types';
import CheckboxFilterV2 from '../CheckboxFilterV2';
import {
DEFAULT_FILTER,
DEFAULT_USE_FIELD_APIS,
getFilterFromCall,
mockFieldsValuesAPI,
renderWithFilter,
setupServer,
} from '../CheckboxFilterV2.testUtils';
setupServer();
describe('CheckboxFilterV2 - interactions', () => {
describe('search functionality', () => {
it('filters values based on search text', async () => {
const user = userEvent.setup();
let searchTextReceived = '';
server.use(
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
searchTextReceived = req.url.searchParams.get('searchText') || '';
const values =
searchTextReceived === ''
? ['production', 'staging', 'development']
: ['production'];
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: [],
stringValues: values,
numberValues: [],
},
},
}),
);
}),
);
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-production');
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
await waitFor(() => {
expect(searchTextReceived).toBe('prod');
});
await waitFor(() => {
expect(
screen.queryByTestId('checkbox-value-row-staging'),
).not.toBeInTheDocument();
});
});
it('filters values via search while preserving existingQuery context', async () => {
const user = userEvent.setup();
let requestCount = 0;
server.use(
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
requestCount += 1;
const searchText = req.url.searchParams.get('searchText') || '';
if (requestCount === 1) {
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: ['production'],
stringValues: ['staging', 'development'],
numberValues: [],
},
},
}),
);
}
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: searchText === 'prod' ? ['production'] : [],
stringValues: searchText === 'prod' ? ['production'] : ['staging'],
numberValues: [],
},
},
}),
);
}),
);
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: { items: [], op: 'AND' },
filter: { expression: 'service.name = "api"' },
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-production');
// Related values now appear in "Related" section (no badge, uses divider instead)
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
await waitFor(() => {
expect(
screen.queryByTestId('checkbox-value-row-staging'),
).not.toBeInTheDocument();
expect(
screen.getByTestId('checkbox-value-row-production'),
).toBeInTheDocument();
});
});
it('shows filtered results in all_values section when searching with existingQuery', async () => {
const user = userEvent.setup();
server.use(
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
const searchText = req.url.searchParams.get('searchText') || '';
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: [],
stringValues: searchText === '' ? ['prod', 'staging'] : ['prod-match'],
numberValues: [],
},
},
}),
);
}),
);
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
);
await screen.findByTestId('checkbox-value-row-prod');
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
await waitFor(() => {
expect(screen.getByTestId('section-all_values')).toBeInTheDocument();
expect(
screen.getByTestId('checkbox-value-row-prod-match'),
).toBeInTheDocument();
});
});
it('shows empty search results message when no matches found', async () => {
const user = userEvent.setup();
server.use(
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
const searchText = req.url.searchParams.get('searchText') || '';
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: [],
stringValues: searchText === '' ? ['prod', 'staging'] : [],
numberValues: [],
},
},
}),
);
}),
);
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-prod');
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'xyz-no-match');
await waitFor(() => {
expect(
screen.getByTestId('checkbox-filter-no-search-results'),
).toBeInTheDocument();
});
});
it('shows both RELATED and ALL_VALUES sections with non-overlapping values', async () => {
// This tests the bug fix where different pod names in relatedValues vs stringValues
// caused all items to go to ALL_VALUES instead of showing RELATED section
server.use(
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
// Non-overlapping: different pod instances
relatedValues: ['pod-a-instance-1', 'pod-b-instance-1'],
stringValues: ['pod-a-instance-2', 'pod-b-instance-2'],
numberValues: [],
},
},
}),
),
),
);
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
);
// Wait for values to load
await screen.findByTestId('checkbox-value-row-pod-a-instance-1');
// RELATED section should exist with relatedValues
expect(screen.getByTestId('section-related')).toBeInTheDocument();
expect(
screen.getByTestId('checkbox-value-row-pod-a-instance-1'),
).toBeInTheDocument();
expect(
screen.getByTestId('checkbox-value-row-pod-b-instance-1'),
).toBeInTheDocument();
// ALL_VALUES section should exist with stringValues
expect(screen.getByTestId('section-all_values')).toBeInTheDocument();
expect(
screen.getByTestId('checkbox-value-row-pod-a-instance-2'),
).toBeInTheDocument();
expect(
screen.getByTestId('checkbox-value-row-pod-b-instance-2'),
).toBeInTheDocument();
});
it('shows both sections during search with filtered non-overlapping values', async () => {
const user = userEvent.setup();
server.use(
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
const searchText = req.url.searchParams.get('searchText') || '';
// Simulate API filtering - both arrays filtered but remain non-overlapping
const relatedValues =
searchText === '' ? ['pod-a-v1', 'pod-b-v1', 'pod-c-v1'] : ['pod-a-v1']; // filtered to match 'pod-a'
const stringValues =
searchText === '' ? ['pod-a-v2', 'pod-b-v2', 'pod-c-v2'] : ['pod-a-v2']; // filtered to match 'pod-a'
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues,
stringValues,
numberValues: [],
},
},
}),
);
}),
);
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
);
await screen.findByTestId('checkbox-value-row-pod-a-v1');
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'pod-a');
// After search, both sections should still appear with filtered results
await waitFor(() => {
// RELATED section with filtered relatedValues
expect(screen.getByTestId('section-related')).toBeInTheDocument();
expect(
screen.getByTestId('checkbox-value-row-pod-a-v1'),
).toBeInTheDocument();
// ALL_VALUES section with filtered stringValues
expect(screen.getByTestId('section-all_values')).toBeInTheDocument();
expect(
screen.getByTestId('checkbox-value-row-pod-a-v2'),
).toBeInTheDocument();
// Other values should be filtered out
expect(
screen.queryByTestId('checkbox-value-row-pod-b-v1'),
).not.toBeInTheDocument();
});
});
});
describe('header interactions', () => {
it('collapses when header clicked on open filter', async () => {
const user = userEvent.setup();
mockFieldsValuesAPI({
stringValues: ['production'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-production');
const header = screen.getByTestId('checkbox-filter-header');
expect(header).toHaveAttribute('data-state', 'open');
await user.click(header);
expect(header).toHaveAttribute('data-state', 'closed');
expect(
screen.queryByTestId('checkbox-value-row-production'),
).not.toBeInTheDocument();
});
it('expands when header clicked on closed filter', async () => {
const user = userEvent.setup();
mockFieldsValuesAPI({
stringValues: ['production'],
});
render(
<CheckboxFilterV2
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
const header = screen.getByTestId('checkbox-filter-header');
expect(header).toHaveAttribute('data-state', 'closed');
await user.click(header);
expect(header).toHaveAttribute('data-state', 'open');
await screen.findByTestId('checkbox-value-row-production');
});
});
describe('show more functionality', () => {
it('shows "Show More..." when more than 10 values', async () => {
const values = Array.from(
{ length: 15 },
(_, i) => `value-${String(i).padStart(2, '0')}`,
);
mockFieldsValuesAPI({ stringValues: values });
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-value-00');
expect(screen.getByTestId('checkbox-filter-show-more')).toBeInTheDocument();
expect(
screen.queryByTestId('checkbox-value-row-value-10'),
).not.toBeInTheDocument();
});
it('loads more values when "Show More..." clicked', async () => {
const user = userEvent.setup();
const values = Array.from(
{ length: 15 },
(_, i) => `value-${String(i).padStart(2, '0')}`,
);
mockFieldsValuesAPI({ stringValues: values });
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-value-00');
await user.click(screen.getByTestId('checkbox-filter-show-more'));
await screen.findByTestId('checkbox-value-row-value-10');
expect(
screen.getByTestId('checkbox-value-row-value-14'),
).toBeInTheDocument();
});
});
describe('clear functionality', () => {
it('shows clear button when filter is open and has filter applied', async () => {
mockFieldsValuesAPI({
stringValues: ['production'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'deployment.environment' },
op: 'in',
value: ['production'],
},
],
op: 'AND',
},
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-production');
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
});
it('hides clear button when no filter applied for attribute', async () => {
mockFieldsValuesAPI({
stringValues: ['production'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-production');
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
});
it('calls onFilterChange when clear clicked', async () => {
const user = userEvent.setup();
const onFilterChange = jest.fn();
mockFieldsValuesAPI({
stringValues: ['production'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
onFilterChange={onFilterChange}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'deployment.environment' },
op: 'in',
value: ['production'],
},
],
op: 'AND',
},
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-production');
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
expect(onFilterChange).toHaveBeenCalled();
});
});
describe('value row interactions', () => {
it('calls onFilterChange when checkbox value clicked', async () => {
const user = userEvent.setup();
const onFilterChange = jest.fn();
mockFieldsValuesAPI({
stringValues: ['production', 'staging'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
onFilterChange={onFilterChange}
/>,
);
const productionRow = await screen.findByTestId(
'checkbox-value-row-production',
);
await user.click(within(productionRow).getByText('production'));
expect(onFilterChange).toHaveBeenCalled();
});
it('creates NOT IN filter when unchecking related item with no existing filter', async () => {
const user = userEvent.setup();
const onFilterChange = jest.fn();
mockFieldsValuesAPI({
relatedValues: ['valueA'],
stringValues: ['valueB'],
});
// Start with no filter, related items show as checked
// Clicking unchecks them → creates NOT IN filter to exclude
renderWithFilter(onFilterChange);
const rowA = await screen.findByTestId('checkbox-value-row-valueA');
expect(rowA).toHaveAttribute('data-state', 'checked');
await user.click(within(rowA).getByRole('checkbox'));
expect(onFilterChange).toHaveBeenCalledTimes(1);
const filter = getFilterFromCall(onFilterChange);
expect(filter?.op).toBe('not in');
expect(filter?.value).toBe('valueA');
});
it('converts NOT IN to IN when toggling unchecked (other) item', async () => {
const user = userEvent.setup();
const onFilterChange = jest.fn();
mockFieldsValuesAPI({
relatedValues: ['valueA'],
stringValues: ['valueB'],
});
// Clicking unchecked "Other" item with NOT IN filter should convert to IN [B]
renderWithFilter(onFilterChange, { op: 'not in', value: ['valueA'] });
const rowB = await screen.findByTestId('checkbox-value-row-valueB');
expect(rowB).toHaveAttribute('data-state', 'unchecked');
await user.click(within(rowB).getByRole('checkbox'));
expect(onFilterChange).toHaveBeenCalledTimes(1);
const filter = getFilterFromCall(onFilterChange);
expect(filter?.op).toBe('in');
expect(filter?.value).toBe('valueB');
});
it('accumulates both values in IN when toggling checked (related) then unchecked (other)', async () => {
const user = userEvent.setup();
const onFilterChange = jest.fn();
mockFieldsValuesAPI({
relatedValues: ['valueA'],
stringValues: ['valueB'],
});
// Start with IN filter for valueA
renderWithFilter(onFilterChange, { op: 'in', value: ['valueA'] });
const rowA = await screen.findByTestId('checkbox-value-row-valueA');
expect(rowA).toHaveAttribute('data-state', 'checked');
const rowB = screen.getByTestId('checkbox-value-row-valueB');
expect(rowB).toHaveAttribute('data-state', 'unchecked');
// Toggle B (unchecked -> should add to IN)
await user.click(within(rowB).getByRole('checkbox'));
expect(onFilterChange).toHaveBeenCalledTimes(1);
const filter = getFilterFromCall(onFilterChange);
expect(filter?.op).toBe('in');
expect(filter?.value).toStrictEqual(['valueA', 'valueB']);
});
it('adds to NOT IN when toggling checked (related) with existing NOT IN filter', async () => {
const user = userEvent.setup();
const onFilterChange = jest.fn();
mockFieldsValuesAPI({
relatedValues: ['valueA'],
stringValues: ['valueB'],
});
// Start with NOT IN filter for valueB
renderWithFilter(onFilterChange, { op: 'not in', value: ['valueB'] });
const rowA = await screen.findByTestId('checkbox-value-row-valueA');
// Related values show as checked
expect(rowA).toHaveAttribute('data-state', 'checked');
// Toggle A (checked -> should add to NOT IN)
await user.click(within(rowA).getByRole('checkbox'));
expect(onFilterChange).toHaveBeenCalledTimes(1);
const filter = getFilterFromCall(onFilterChange);
expect(filter?.op).toBe('not in');
expect(filter?.value).toStrictEqual(['valueB', 'valueA']);
});
it('creates NOT IN for single value when toggling related item with existing IN filter', async () => {
const user = userEvent.setup();
const onFilterChange = jest.fn();
mockFieldsValuesAPI({
relatedValues: ['relatedValue'],
stringValues: ['otherValue'],
});
// Start with IN filter for otherValue (Selected section)
// relatedValue is in Related section (checked visually)
renderWithFilter(onFilterChange, { op: 'in', value: ['otherValue'] });
const selectedRow = await screen.findByTestId(
'checkbox-value-row-otherValue',
);
expect(selectedRow).toHaveAttribute('data-state', 'checked');
const relatedRow = screen.getByTestId('checkbox-value-row-relatedValue');
expect(relatedRow).toHaveAttribute('data-state', 'checked');
// Toggle related item (checked -> should create NOT IN with just this value)
await user.click(within(relatedRow).getByRole('checkbox'));
expect(onFilterChange).toHaveBeenCalledTimes(1);
const filter = getFilterFromCall(onFilterChange);
expect(filter?.op).toBe('not in');
expect(filter?.value).toBe('relatedValue');
});
});
describe('custom renderer', () => {
it('uses customRendererForValue when provided', async () => {
mockFieldsValuesAPI({
stringValues: ['production'],
});
const customRenderer = (value: string): JSX.Element => (
<span data-testid="custom-rendered">{`ENV: ${value}`}</span>
);
render(
<CheckboxFilterV2
filter={{ ...DEFAULT_FILTER, customRendererForValue: customRenderer }}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('custom-rendered');
expect(screen.getByText('ENV: production')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,499 @@
import { screen, within } from '@testing-library/react';
import { render } from 'tests/test-utils';
import { QuickFiltersSource } from '../../../../types';
import CheckboxFilterV2 from '../CheckboxFilterV2';
import {
DEFAULT_FILTER,
DEFAULT_USE_FIELD_APIS,
mockFieldsValuesAPI,
setupServer,
} from '../CheckboxFilterV2.testUtils';
setupServer();
describe('CheckboxFilterV2 - item rules', () => {
describe('no existing query', () => {
it('all values show as checked with no badge when no query exists', async () => {
mockFieldsValuesAPI({
stringValues: ['production', 'staging'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
const productionRow = await screen.findByTestId(
'checkbox-value-row-production',
);
expect(within(productionRow).getByText('production')).toBeInTheDocument();
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
expect(productionRow).toHaveAttribute('data-state', 'checked');
expect(stagingRow).toHaveAttribute('data-state', 'checked');
// No section dividers when no existing query (all in "selected" section)
expect(
screen.queryByTestId('section-divider-related'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('section-divider-all-values'),
).not.toBeInTheDocument();
});
});
describe('with existing query (related values)', () => {
it('shows related values in "Related" section with checked state', async () => {
mockFieldsValuesAPI({
relatedValues: ['production'],
stringValues: ['staging', 'development'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: { items: [], op: 'AND' },
filter: { expression: 'service.name = "api"' },
},
],
},
},
} as never,
},
);
const productionRow = await screen.findByTestId(
'checkbox-value-row-production',
);
expect(within(productionRow).getByText('production')).toBeInTheDocument();
// Related values show as checked in "Related" section
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
expect(productionRow).toHaveAttribute('data-state', 'checked');
// Other values show as unchecked in "All values" section
expect(screen.getByTestId('section-divider-all-values')).toBeInTheDocument();
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
});
it('shows no badge for values not in relatedValues (unchecked state in All values section)', async () => {
mockFieldsValuesAPI({
relatedValues: ['production'],
stringValues: ['staging'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: { items: [], op: 'AND' },
filter: { expression: 'service.name = "api"' },
},
],
},
},
} as never,
},
);
const stagingRow = await screen.findByTestId('checkbox-value-row-staging');
expect(within(stagingRow).getByText('staging')).toBeInTheDocument();
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
expect(within(stagingRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
});
it('shows related values as checked when hasFilterForThisKey=true and isInRelatedValues=true', async () => {
mockFieldsValuesAPI({
relatedValues: ['production', 'staging'],
stringValues: ['development'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'deployment.environment' },
op: 'in',
value: ['production'],
},
],
op: 'AND',
},
filter: { expression: 'service.name = "api"' },
},
],
},
},
} as never,
},
);
// production is selected - in selected section
const productionRow = await screen.findByTestId(
'checkbox-value-row-production',
);
expect(productionRow).toHaveAttribute('data-state', 'checked');
// staging is related - in related section with checked state
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
expect(stagingRow).toHaveAttribute('data-state', 'checked');
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
});
});
describe('selected values with IN operator', () => {
it('shows checked state with no badge for IN-selected values', async () => {
mockFieldsValuesAPI({
stringValues: ['production', 'staging'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'deployment.environment' },
op: 'in',
value: ['production'],
},
],
op: 'AND',
},
},
],
},
},
} as never,
},
);
const productionRow = await screen.findByTestId(
'checkbox-value-row-production',
);
expect(productionRow).toHaveAttribute('data-state', 'checked');
expect(
within(productionRow).queryByTestId(/^badge-/),
).not.toBeInTheDocument();
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
});
});
describe('selected values with NOT IN operator', () => {
it('shows unchecked state with no badge for NOT_IN-selected values', async () => {
mockFieldsValuesAPI({
stringValues: ['production', 'staging'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'deployment.environment' },
op: 'not in',
value: ['production'],
},
],
op: 'AND',
},
},
],
},
},
} as never,
},
);
const productionRow = await screen.findByTestId(
'checkbox-value-row-production',
);
expect(productionRow).toHaveAttribute('data-state', 'unchecked');
expect(
within(productionRow).queryByTestId(/^badge-/),
).not.toBeInTheDocument();
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
expect(within(stagingRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
});
});
describe('section ordering', () => {
it('orders selected section before related section before other section', async () => {
mockFieldsValuesAPI({
relatedValues: ['related-value'],
stringValues: ['other-value', 'selected-value'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'deployment.environment' },
op: 'in',
value: ['selected-value'],
},
],
op: 'AND',
},
filter: { expression: 'service.name = "api"' },
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-selected-value');
const allRows = screen.getAllByTestId(/^checkbox-value-row-/);
const values = allRows.map((row) =>
row.getAttribute('data-testid')?.replace('checkbox-value-row-', ''),
);
expect(values[0]).toBe('selected-value');
expect(values[1]).toBe('related-value');
expect(values[2]).toBe('other-value');
});
it('sorts alphabetically within same section', async () => {
mockFieldsValuesAPI({
relatedValues: ['zebra', 'alpha', 'mike'],
stringValues: [],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: { items: [], op: 'AND' },
filter: { expression: 'service.name = "api"' },
},
],
},
},
} as never,
},
);
await screen.findByTestId('checkbox-value-row-alpha');
const allRows = screen.getAllByTestId(/^checkbox-value-row-/);
const values = allRows.map((row) =>
row.getAttribute('data-testid')?.replace('checkbox-value-row-', ''),
);
expect(values).toStrictEqual(['alpha', 'mike', 'zebra']);
});
});
describe('mixed state scenarios', () => {
it('handles mixed state: IN-selected + related + other in same list', async () => {
mockFieldsValuesAPI({
relatedValues: ['related-env'],
stringValues: ['other-env', 'selected-env'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'deployment.environment' },
op: 'in',
value: ['selected-env'],
},
],
op: 'AND',
},
filter: { expression: 'service.name = "api"' },
},
],
},
},
} as never,
},
);
const selectedRow = await screen.findByTestId(
'checkbox-value-row-selected-env',
);
expect(selectedRow).toHaveAttribute('data-state', 'checked');
expect(within(selectedRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
// Related values now show as checked
const relatedRow = screen.getByTestId('checkbox-value-row-related-env');
expect(relatedRow).toHaveAttribute('data-state', 'checked');
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
const otherRow = screen.getByTestId('checkbox-value-row-other-env');
expect(otherRow).toHaveAttribute('data-state', 'unchecked');
expect(within(otherRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
});
it('handles NOT_IN-selected alongside related values', async () => {
mockFieldsValuesAPI({
relatedValues: ['related-env'],
stringValues: ['other-env', 'excluded-env'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={{
...DEFAULT_USE_FIELD_APIS,
existingQuery: 'service.name = "api"',
}}
/>,
undefined,
{
queryBuilderOverrides: {
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: { key: 'deployment.environment' },
op: 'not in',
value: ['excluded-env'],
},
],
op: 'AND',
},
filter: { expression: 'service.name = "api"' },
},
],
},
},
} as never,
},
);
const excludedRow = await screen.findByTestId(
'checkbox-value-row-excluded-env',
);
expect(excludedRow).toHaveAttribute('data-state', 'unchecked');
expect(within(excludedRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
// Related values show as checked
const relatedRow = screen.getByTestId('checkbox-value-row-related-env');
expect(relatedRow).toHaveAttribute('data-state', 'checked');
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,207 @@
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { server, rest } from 'mocks-server/server';
import { render } from 'tests/test-utils';
import { QuickFiltersSource } from '../../../../types';
import CheckboxFilterV2 from '../CheckboxFilterV2';
import {
DEFAULT_FILTER,
DEFAULT_USE_FIELD_APIS,
mockFieldsValuesAPI,
mockFieldsValuesAPILoading,
setupServer,
} from '../CheckboxFilterV2.testUtils';
setupServer();
describe('CheckboxFilterV2 - states', () => {
describe('loading states', () => {
it('shows skeleton while loading initial data', async () => {
mockFieldsValuesAPILoading();
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
expect(screen.getByTestId('checkbox-filter-v2')).toBeInTheDocument();
await waitFor(() => {
expect(
screen.getByTestId('checkbox-filter-v2').querySelector('.ant-skeleton'),
).toBeInTheDocument();
});
});
it('shows skeleton when initially closed filter is opened for the first time', async () => {
const user = userEvent.setup();
mockFieldsValuesAPILoading();
const closedFilter = { ...DEFAULT_FILTER, defaultOpen: false };
render(
<CheckboxFilterV2
filter={closedFilter}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
// Filter starts closed - no skeleton, no content
expect(
screen.getByTestId('checkbox-filter-v2').querySelector('.ant-skeleton'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('checkbox-filter-empty'),
).not.toBeInTheDocument();
// Click header to open
const header = screen.getByTestId('checkbox-filter-header');
await user.click(header);
// Should show skeleton while loading, NOT "No values found"
await waitFor(() => {
expect(
screen.getByTestId('checkbox-filter-v2').querySelector('.ant-skeleton'),
).toBeInTheDocument();
});
expect(
screen.queryByTestId('checkbox-filter-empty'),
).not.toBeInTheDocument();
});
it('shows search spinner when fetching after initial load', async () => {
const user = userEvent.setup();
let requestCount = 0;
server.use(
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
requestCount += 1;
if (requestCount === 1) {
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: [],
stringValues: ['production', 'staging'],
numberValues: [],
},
},
}),
);
}
return res(ctx.delay(10000));
}),
);
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-production');
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
await waitFor(() => {
expect(
screen.getByTestId('checkbox-filter-search-loading'),
).toBeInTheDocument();
});
});
});
describe('empty states', () => {
it('shows "No values found" when API returns empty arrays', async () => {
mockFieldsValuesAPI({
relatedValues: [],
stringValues: [],
numberValues: [],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
const emptySection = await screen.findByTestId('checkbox-filter-empty');
expect(emptySection).toBeInTheDocument();
});
});
describe('value rendering', () => {
it('renders values from API response', async () => {
mockFieldsValuesAPI({
stringValues: ['production', 'staging', 'development'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-production');
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
expect(
screen.getByTestId('checkbox-value-row-development'),
).toBeInTheDocument();
});
it('renders number values converted to strings', async () => {
mockFieldsValuesAPI({
numberValues: [200, 404, 500],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
const row200 = await screen.findByTestId('checkbox-value-row-200');
expect(within(row200).getByText('200')).toBeInTheDocument();
expect(
within(screen.getByTestId('checkbox-value-row-404')).getByText('404'),
).toBeInTheDocument();
expect(
within(screen.getByTestId('checkbox-value-row-500')).getByText('500'),
).toBeInTheDocument();
});
it('filters null/undefined values from response', async () => {
mockFieldsValuesAPI({
stringValues: ['valid', null, '', undefined as unknown as string],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
const validRow = await screen.findByTestId('checkbox-value-row-valid');
expect(within(validRow).getByText('valid')).toBeInTheDocument();
expect(screen.queryAllByTestId(/^checkbox-value-row-/)).toHaveLength(1);
});
});
});

View File

@@ -0,0 +1,156 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CheckboxFilterV2Header } from '../CheckboxFilterV2Header';
describe('CheckboxFilterV2Header', () => {
const defaultProps = {
title: 'Environment',
isOpen: false,
showClearAll: true,
isSomeFilterPresentForCurrentAttribute: true,
onToggleOpen: jest.fn(),
onClear: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('collapsed state', () => {
it('renders title', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
expect(screen.getByText('Environment')).toBeInTheDocument();
});
it('sets data-state="closed" when collapsed', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
const header = screen.getByTestId('checkbox-filter-header');
expect(header).toHaveAttribute('data-state', 'closed');
});
it('does not show clear button when collapsed', () => {
render(
<CheckboxFilterV2Header {...defaultProps} isOpen={false} showClearAll />,
);
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
});
});
describe('expanded state', () => {
it('sets data-state="open" when expanded', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen />);
const header = screen.getByTestId('checkbox-filter-header');
expect(header).toHaveAttribute('data-state', 'open');
});
it('shows clear button when expanded + showClearAll=true', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll />);
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
expect(screen.getByText('Clear')).toBeInTheDocument();
});
it('hides clear button when showClearAll=false', () => {
render(
<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll={false} />,
);
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
});
it('hides clear button when no filter present for attribute', () => {
render(
<CheckboxFilterV2Header
{...defaultProps}
isOpen
showClearAll
isSomeFilterPresentForCurrentAttribute={false}
/>,
);
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
});
});
describe('interactions', () => {
it('calls onToggleOpen on header click', async () => {
const user = userEvent.setup();
const onToggleOpen = jest.fn();
render(
<CheckboxFilterV2Header {...defaultProps} onToggleOpen={onToggleOpen} />,
);
await user.click(screen.getByTestId('checkbox-filter-header'));
expect(onToggleOpen).toHaveBeenCalledTimes(1);
});
it('calls onToggleOpen on Enter key', async () => {
const user = userEvent.setup();
const onToggleOpen = jest.fn();
render(
<CheckboxFilterV2Header {...defaultProps} onToggleOpen={onToggleOpen} />,
);
screen.getByTestId('checkbox-filter-header').focus();
await user.keyboard('{Enter}');
expect(onToggleOpen).toHaveBeenCalledTimes(1);
});
it('calls onToggleOpen on Space key', async () => {
const user = userEvent.setup();
const onToggleOpen = jest.fn();
render(
<CheckboxFilterV2Header {...defaultProps} onToggleOpen={onToggleOpen} />,
);
screen.getByTestId('checkbox-filter-header').focus();
await user.keyboard(' ');
expect(onToggleOpen).toHaveBeenCalledTimes(1);
});
it('calls onClear on clear button click', async () => {
const user = userEvent.setup();
const onClear = jest.fn();
render(
<CheckboxFilterV2Header {...defaultProps} isOpen onClear={onClear} />,
);
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
expect(onClear).toHaveBeenCalledTimes(1);
});
it('clear button click does not trigger onToggleOpen', async () => {
const user = userEvent.setup();
const onToggleOpen = jest.fn();
const onClear = jest.fn();
render(
<CheckboxFilterV2Header
{...defaultProps}
isOpen
onToggleOpen={onToggleOpen}
onClear={onClear}
/>,
);
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
expect(onClear).toHaveBeenCalledTimes(1);
expect(onToggleOpen).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,297 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BadgeConfig } from '../itemRules';
import { CheckedState } from '../../../../types';
import { CheckboxFilterV2ValueRow } from '../CheckboxFilterV2ValueRow';
describe('CheckboxFilterV2ValueRow', () => {
const defaultProps = {
value: 'production',
checkedState: 'unchecked' as CheckedState,
disabled: false,
title: 'Environment',
onlyButtonLabel: 'Only',
onCheckboxChange: jest.fn(),
onOnlyOrAllClick: jest.fn(),
badge: null as BadgeConfig | null,
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('checked states', () => {
it('sets data-state="unchecked" for unchecked state', () => {
render(
<CheckboxFilterV2ValueRow {...defaultProps} checkedState="unchecked" />,
);
const row = screen.getByTestId('checkbox-value-row-production');
expect(row).toHaveAttribute('data-state', 'unchecked');
});
it('sets data-state="checked" for checked state', () => {
render(
<CheckboxFilterV2ValueRow {...defaultProps} checkedState="checked" />,
);
const row = screen.getByTestId('checkbox-value-row-production');
expect(row).toHaveAttribute('data-state', 'checked');
});
});
describe('badge variations', () => {
it('renders no badge when badge=null', () => {
render(<CheckboxFilterV2ValueRow {...defaultProps} badge={null} />);
expect(screen.queryByTestId(/^badge-/)).not.toBeInTheDocument();
});
it('renders "Not in" warning badge', () => {
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
badge={{ key: 'not_in', label: 'Not in', color: 'warning' }}
/>,
);
expect(screen.getByTestId('badge-not_in')).toBeInTheDocument();
expect(screen.getByText('Not in')).toBeInTheDocument();
});
it('renders "Related" robin badge', () => {
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
badge={{ key: 'related', label: 'Related', color: 'robin' }}
/>,
);
expect(screen.getByTestId('badge-related')).toBeInTheDocument();
expect(screen.getByText('Related')).toBeInTheDocument();
});
it('renders "Other" secondary badge', () => {
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
badge={{ key: 'other', label: 'Other', color: 'secondary' }}
/>,
);
expect(screen.getByTestId('badge-other')).toBeInTheDocument();
expect(screen.getByText('Other')).toBeInTheDocument();
});
});
describe('only/all button label', () => {
it('shows "Only" label by default', () => {
render(
<CheckboxFilterV2ValueRow {...defaultProps} onlyButtonLabel="Only" />,
);
expect(screen.getByText('Only')).toBeInTheDocument();
});
it('shows "All" label when appropriate', () => {
render(<CheckboxFilterV2ValueRow {...defaultProps} onlyButtonLabel="All" />);
expect(screen.getByText('All')).toBeInTheDocument();
});
});
describe('disabled state', () => {
it('sets data-disabled=true when disabled', () => {
render(<CheckboxFilterV2ValueRow {...defaultProps} disabled />);
const row = screen.getByTestId('checkbox-value-row-production');
expect(row).toHaveAttribute('data-disabled', 'true');
});
it('does not call onOnlyOrAllClick when disabled + clicked', async () => {
const user = userEvent.setup();
const onOnlyOrAllClick = jest.fn();
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
disabled
onOnlyOrAllClick={onOnlyOrAllClick}
/>,
);
await user.click(screen.getByText('production'));
expect(onOnlyOrAllClick).not.toHaveBeenCalled();
});
it('does not call onOnlyOrAllClick on keydown when disabled', async () => {
const user = userEvent.setup();
const onOnlyOrAllClick = jest.fn();
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
disabled
onOnlyOrAllClick={onOnlyOrAllClick}
/>,
);
screen.getByText('production').focus();
await user.keyboard('{Enter}');
expect(onOnlyOrAllClick).not.toHaveBeenCalled();
});
});
describe('special value indicators', () => {
it('renders row for "true" value', () => {
render(<CheckboxFilterV2ValueRow {...defaultProps} value="true" />);
expect(screen.getByTestId('checkbox-value-row-true')).toBeInTheDocument();
expect(screen.getByText('true')).toBeInTheDocument();
});
it('renders row for "false" value', () => {
render(<CheckboxFilterV2ValueRow {...defaultProps} value="false" />);
expect(screen.getByTestId('checkbox-value-row-false')).toBeInTheDocument();
expect(screen.getByText('false')).toBeInTheDocument();
});
it('renders row for regular values', () => {
render(<CheckboxFilterV2ValueRow {...defaultProps} value="production" />);
expect(
screen.getByTestId('checkbox-value-row-production'),
).toBeInTheDocument();
expect(screen.getByText('production')).toBeInTheDocument();
});
});
describe('interactions', () => {
it('renders checkbox with correct testId', () => {
render(
<CheckboxFilterV2ValueRow {...defaultProps} checkedState="unchecked" />,
);
expect(
screen.getByTestId('checkbox-Environment-production'),
).toBeInTheDocument();
});
it('calls onOnlyOrAllClick on value text click', async () => {
const user = userEvent.setup();
const onOnlyOrAllClick = jest.fn();
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
onOnlyOrAllClick={onOnlyOrAllClick}
/>,
);
await user.click(screen.getByText('production'));
expect(onOnlyOrAllClick).toHaveBeenCalledTimes(1);
});
it('calls onOnlyOrAllClick on Enter key', async () => {
const user = userEvent.setup();
const onOnlyOrAllClick = jest.fn();
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
onOnlyOrAllClick={onOnlyOrAllClick}
/>,
);
const valueButton = screen
.getByText('production')
.closest('[role="button"]');
await user.tab();
await user.tab();
if (valueButton && document.activeElement === valueButton) {
await user.keyboard('{Enter}');
}
expect(onOnlyOrAllClick).toHaveBeenCalled();
});
it('calls onOnlyOrAllClick on Space key', async () => {
const user = userEvent.setup();
const onOnlyOrAllClick = jest.fn();
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
onOnlyOrAllClick={onOnlyOrAllClick}
/>,
);
const valueButton = screen
.getByText('production')
.closest('[role="button"]');
await user.tab();
await user.tab();
if (valueButton && document.activeElement === valueButton) {
await user.keyboard(' ');
}
expect(onOnlyOrAllClick).toHaveBeenCalled();
});
it('shows Toggle button', () => {
render(<CheckboxFilterV2ValueRow {...defaultProps} />);
expect(screen.getByText('Toggle')).toBeInTheDocument();
});
});
describe('custom renderer', () => {
it('uses customRendererForValue when provided', () => {
const customRenderer = (value: string): JSX.Element => (
<span data-testid="custom-render">{`Custom: ${value}`}</span>
);
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
customRendererForValue={customRenderer}
/>,
);
expect(screen.getByTestId('custom-render')).toBeInTheDocument();
expect(screen.getByText('Custom: production')).toBeInTheDocument();
});
it('shows default value text when no custom renderer', () => {
render(<CheckboxFilterV2ValueRow {...defaultProps} />);
expect(screen.getByText('production')).toBeInTheDocument();
});
});
describe('state combinations', () => {
it('checked + not_in badge', () => {
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
checkedState="unchecked"
badge={{ key: 'not_in', label: 'Not in', color: 'warning' }}
/>,
);
expect(screen.getByTestId('badge-not_in')).toBeInTheDocument();
});
it('disabled + badge still shows badge', () => {
render(
<CheckboxFilterV2ValueRow
{...defaultProps}
disabled
badge={{ key: 'other', label: 'Other', color: 'secondary' }}
/>,
);
expect(screen.getByTestId('badge-other')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,131 @@
import { deriveItemConfig, ItemContext, SectionType } from '../itemRules';
describe('itemRules', () => {
describe('deriveItemConfig', () => {
it('no query at all → section selected, no badge', () => {
const ctx: ItemContext = {
isSelectedOnFilter: false,
isInRelatedValues: true,
isNotInOperator: false,
hasExistingQuery: false,
hasFilterForThisKey: false,
};
const result = deriveItemConfig(ctx);
expect(result.section).toBe(SectionType.SELECTED);
expect(result.badge).toBeNull();
});
it('selected + IN operator → section selected, no badge', () => {
const ctx: ItemContext = {
isSelectedOnFilter: true,
isInRelatedValues: true,
isNotInOperator: false,
hasExistingQuery: true,
hasFilterForThisKey: true,
};
const result = deriveItemConfig(ctx);
expect(result.section).toBe(SectionType.SELECTED);
expect(result.badge).toBeNull();
});
it('selected + NOT IN operator → section selected, no badge, unchecked', () => {
const ctx: ItemContext = {
isSelectedOnFilter: true,
isInRelatedValues: false,
isNotInOperator: true,
hasExistingQuery: true,
hasFilterForThisKey: true,
};
const result = deriveItemConfig(ctx);
expect(result.section).toBe(SectionType.SELECTED);
expect(result.badge).toBeNull();
expect(result.checkedState).toBe('unchecked');
});
it('has query, not selected, in related → section related, checked', () => {
const ctx: ItemContext = {
isSelectedOnFilter: false,
isInRelatedValues: true,
isNotInOperator: false,
hasExistingQuery: true,
hasFilterForThisKey: false,
};
const result = deriveItemConfig(ctx);
expect(result.section).toBe(SectionType.RELATED);
expect(result.badge).toBeNull();
expect(result.checkedState).toBe('checked');
});
it('has query, has filter for this key, in related → section related, checked', () => {
const ctx: ItemContext = {
isSelectedOnFilter: false,
isInRelatedValues: true,
isNotInOperator: false,
hasExistingQuery: true,
hasFilterForThisKey: true,
};
const result = deriveItemConfig(ctx);
expect(result.section).toBe(SectionType.RELATED);
expect(result.badge).toBeNull();
expect(result.checkedState).toBe('checked');
});
it('has query, not in related → section all_values, unchecked', () => {
const ctx: ItemContext = {
isSelectedOnFilter: false,
isInRelatedValues: false,
isNotInOperator: false,
hasExistingQuery: true,
hasFilterForThisKey: false,
};
const result = deriveItemConfig(ctx);
expect(result.section).toBe(SectionType.ALL_VALUES);
expect(result.badge).toBeNull();
expect(result.checkedState).toBe('unchecked');
});
it('has query + filter for key, not selected, not in related → section all_values, unchecked', () => {
const ctx: ItemContext = {
isSelectedOnFilter: false,
isInRelatedValues: false,
isNotInOperator: false,
hasExistingQuery: true,
hasFilterForThisKey: true,
};
const result = deriveItemConfig(ctx);
expect(result.section).toBe(SectionType.ALL_VALUES);
expect(result.badge).toBeNull();
expect(result.checkedState).toBe('unchecked');
});
it('no query but has filter for key, not selected → fallback to checked (DEFAULT_CONFIG)', () => {
const ctx: ItemContext = {
isSelectedOnFilter: false,
isInRelatedValues: false,
isNotInOperator: false,
hasExistingQuery: false,
hasFilterForThisKey: true,
};
const result = deriveItemConfig(ctx);
expect(result.section).toBe(SectionType.SELECTED);
expect(result.badge).toBeNull();
expect(result.checkedState).toBe('checked');
});
});
});

View File

@@ -0,0 +1,382 @@
import { renderHook } from '@testing-library/react';
import { SectionType } from '../itemRules';
import { useSectionedValues, SectionedItem } from '../useSectionedValues';
function flattenSections(
sections: { type: SectionType; items: SectionedItem[] }[],
): SectionedItem[] {
return sections.flatMap((s) => s.items);
}
describe('useSectionedValues', () => {
const baseInput = {
relatedValues: ['val1', 'val2'],
allValues: ['val1', 'val2', 'val3'],
currentFilterState: {},
isSomeFilterPresentForCurrentAttribute: false,
isNotInOperator: false,
hasExistingQuery: false,
visibleItemsCount: 10,
relatedExclusions: [] as string[],
};
it('no query at all → all items in selected section, no badges', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
hasExistingQuery: false,
isSomeFilterPresentForCurrentAttribute: false,
}),
);
expect(result.current.sections).toHaveLength(1);
expect(result.current.sections[0].type).toBe(SectionType.SELECTED);
expect(result.current.sections[0].items).toHaveLength(3);
result.current.sections[0].items.forEach((item) => {
expect(item.badge).toBeNull();
});
});
it('has query, no filter for key → related and all_values sections', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
hasExistingQuery: true,
isSomeFilterPresentForCurrentAttribute: false,
}),
);
const allItems = flattenSections(result.current.sections);
const relatedItems = allItems.filter(
(item) => item.value === 'val1' || item.value === 'val2',
);
const otherItems = allItems.filter((item) => item.value === 'val3');
// Related values should be in related section, checked
relatedItems.forEach((item) => {
expect(item.section).toBe(SectionType.RELATED);
expect(item.checkedState).toBe('checked');
});
// Other values should be in all_values section, unchecked
otherItems.forEach((item) => {
expect(item.section).toBe(SectionType.ALL_VALUES);
expect(item.checkedState).toBe('unchecked');
});
});
it('has query + filter for key, selected value → in selected section', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
hasExistingQuery: true,
isSomeFilterPresentForCurrentAttribute: true,
currentFilterState: { val1: true, val2: false, val3: false },
}),
);
const allItems = flattenSections(result.current.sections);
const selectedItem = allItems.find((item) => item.value === 'val1');
expect(selectedItem?.section).toBe(SectionType.SELECTED);
expect(selectedItem?.badge).toBeNull();
});
it('has query + filter for key, NOT IN operator → excluded values in selected section, no badge', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
hasExistingQuery: true,
isSomeFilterPresentForCurrentAttribute: true,
isNotInOperator: true,
currentFilterState: { val1: false, val2: true, val3: true },
}),
);
const allItems = flattenSections(result.current.sections);
// val1 is unchecked + NOT IN = excluded
const excludedItem = allItems.find((item) => item.value === 'val1');
expect(excludedItem?.section).toBe(SectionType.SELECTED);
expect(excludedItem?.badge).toBeNull();
expect(excludedItem?.checkedState).toBe('unchecked');
});
it('items within same section sorted alphabetically', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
relatedValues: ['zebra', 'apple', 'mango'],
allValues: ['zebra', 'apple', 'mango'],
hasExistingQuery: false,
isSomeFilterPresentForCurrentAttribute: false,
}),
);
// All items have section selected, should be sorted alphabetically
const allItems = flattenSections(result.current.sections);
const values = allItems.map((item) => item.value);
expect(values).toStrictEqual(['apple', 'mango', 'zebra']);
});
describe('filtered results from API', () => {
it('keeps items in their natural sections with filtered API results', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
hasExistingQuery: true,
isSomeFilterPresentForCurrentAttribute: true,
currentFilterState: { val1: true },
}),
);
const sectionTypes = result.current.sections.map((s) => s.type);
expect(sectionTypes).toContain(SectionType.SELECTED);
expect(sectionTypes).toContain(SectionType.RELATED);
});
it('returns empty sections array when no values', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
relatedValues: [],
allValues: [],
hasExistingQuery: true,
isSomeFilterPresentForCurrentAttribute: false,
currentFilterState: {},
}),
);
expect(result.current.sections).toHaveLength(0);
expect(result.current.totalCount).toBe(0);
});
it('non-related items go to ALL_VALUES section', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
relatedValues: [],
allValues: ['other1', 'other2', 'other3'],
hasExistingQuery: true,
isSomeFilterPresentForCurrentAttribute: false,
}),
);
const allValuesSection = result.current.sections.find(
(s) => s.type === SectionType.ALL_VALUES,
);
expect(allValuesSection?.items).toHaveLength(3);
});
it('handles non-overlapping relatedValues and allValues correctly', () => {
// This tests the bug where different pod names in relatedValues vs allValues
// caused all items to go to ALL_VALUES instead of RELATED
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
relatedValues: ['pod-a-1', 'pod-b-1', 'pod-c-1'],
allValues: ['pod-a-2', 'pod-b-2', 'pod-c-2'],
hasExistingQuery: true,
isSomeFilterPresentForCurrentAttribute: false,
}),
);
const sectionTypes = result.current.sections.map((s) => s.type);
// RELATED section should exist with relatedValues items
expect(sectionTypes).toContain(SectionType.RELATED);
const relatedSection = result.current.sections.find(
(s) => s.type === SectionType.RELATED,
);
expect(relatedSection?.items).toHaveLength(3);
expect(relatedSection?.items.map((i) => i.value)).toStrictEqual(
expect.arrayContaining(['pod-a-1', 'pod-b-1', 'pod-c-1']),
);
// ALL_VALUES section should exist with allValues items
expect(sectionTypes).toContain(SectionType.ALL_VALUES);
const allValuesSection = result.current.sections.find(
(s) => s.type === SectionType.ALL_VALUES,
);
expect(allValuesSection?.items).toHaveLength(3);
expect(allValuesSection?.items.map((i) => i.value)).toStrictEqual(
expect.arrayContaining(['pod-a-2', 'pod-b-2', 'pod-c-2']),
);
});
});
describe('stale related values during refresh (relatedExclusions)', () => {
// Scenario: key A selected (existing query) + key B had value "oldSelected".
// User selects a different value on key B -> "oldSelected" is de-selected.
// keepPreviousData keeps the stale response where "oldSelected" is still in
// relatedValues, so it would wrongly render as "related". It is excluded.
it('subtracts only the excluded value from the RELATED section', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
currentFilterState: { newValue: true },
isSomeFilterPresentForCurrentAttribute: true,
hasExistingQuery: true,
// stale API data kept via keepPreviousData
relatedValues: ['oldSelected', 'otherRelated'],
allValues: ['newValue'],
relatedExclusions: ['oldSelected'],
}),
);
const allItems = flattenSections(result.current.sections);
// oldSelected must NOT render as related (dropped entirely - not in
// allValues nor selected)
expect(
allItems.find((item) => item.value === 'oldSelected'),
).toBeUndefined();
// other related values are kept
expect(allItems.find((item) => item.value === 'otherRelated')?.section).toBe(
SectionType.RELATED,
);
});
it('preserves every related value that is not the excluded (previously selected) one', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
currentFilterState: { newValue: true },
isSomeFilterPresentForCurrentAttribute: true,
hasExistingQuery: true,
// oldSelected was just de-selected; the rest are genuinely related
relatedValues: ['oldSelected', 'relatedA', 'relatedB', 'relatedC'],
allValues: ['newValue'],
relatedExclusions: ['oldSelected'],
}),
);
const relatedSection = result.current.sections.find(
(s) => s.type === SectionType.RELATED,
);
const relatedValues = relatedSection?.items.map((i) => i.value) ?? [];
// all non-excluded related values are kept
expect(relatedValues).toStrictEqual(['relatedA', 'relatedB', 'relatedC']);
// and they stay checked
relatedSection?.items.forEach((item) => {
expect(item.checkedState).toBe('checked');
});
// the excluded value is gone
expect(relatedValues).not.toContain('oldSelected');
});
it('keeps the RELATED section when other related values remain', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
currentFilterState: { newValue: true },
isSomeFilterPresentForCurrentAttribute: true,
hasExistingQuery: true,
relatedValues: ['oldSelected', 'otherRelated'],
allValues: ['newValue'],
relatedExclusions: ['oldSelected'],
}),
);
const sectionTypes = result.current.sections.map((s) => s.type);
expect(sectionTypes).toContain(SectionType.RELATED);
expect(sectionTypes).toContain(SectionType.SELECTED);
});
it('shows the excluded value normally when exclusions are empty', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
currentFilterState: { newValue: true },
isSomeFilterPresentForCurrentAttribute: true,
hasExistingQuery: true,
relatedValues: ['oldSelected', 'otherRelated'],
allValues: ['newValue'],
relatedExclusions: [],
}),
);
const allItems = flattenSections(result.current.sections);
expect(allItems.find((item) => item.value === 'oldSelected')?.section).toBe(
SectionType.RELATED,
);
});
});
describe('section ordering', () => {
it('sections appear in order: SELECTED → RELATED → ALL_VALUES', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
relatedValues: ['related1'],
allValues: ['all1'],
hasExistingQuery: true,
isSomeFilterPresentForCurrentAttribute: true,
currentFilterState: { selected1: true },
}),
);
const sectionTypes = result.current.sections.map((s) => s.type);
// Verify order
const selectedIdx = sectionTypes.indexOf(SectionType.SELECTED);
const relatedIdx = sectionTypes.indexOf(SectionType.RELATED);
const allValuesIdx = sectionTypes.indexOf(SectionType.ALL_VALUES);
expect(selectedIdx).toBeLessThan(relatedIdx);
expect(relatedIdx).toBeLessThan(allValuesIdx);
});
it('RELATED section appears before ALL_VALUES even with many items', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
relatedValues: ['r1', 'r2', 'r3', 'r4', 'r5'],
allValues: ['a1', 'a2', 'a3', 'a4', 'a5'],
hasExistingQuery: true,
isSomeFilterPresentForCurrentAttribute: false,
visibleItemsCount: 100,
}),
);
const sectionTypes = result.current.sections.map((s) => s.type);
expect(sectionTypes[0]).toBe(SectionType.RELATED);
expect(sectionTypes[1]).toBe(SectionType.ALL_VALUES);
});
it('visibleItemsCount limits total items across sections', () => {
const { result } = renderHook(() =>
useSectionedValues({
...baseInput,
relatedValues: ['r1', 'r2', 'r3'],
allValues: ['a1', 'a2', 'a3'],
hasExistingQuery: true,
isSomeFilterPresentForCurrentAttribute: false,
visibleItemsCount: 4,
}),
);
const totalItems = result.current.sections.reduce(
(sum, s) => sum + s.items.length,
0,
);
expect(totalItems).toBe(4);
// RELATED section should get priority (3 items)
const relatedSection = result.current.sections.find(
(s) => s.type === SectionType.RELATED,
);
expect(relatedSection?.items).toHaveLength(3);
// ALL_VALUES gets remaining (1 item)
const allValuesSection = result.current.sections.find(
(s) => s.type === SectionType.ALL_VALUES,
);
expect(allValuesSection?.items).toHaveLength(1);
});
});
});

View File

@@ -0,0 +1,118 @@
import { CheckedState } from '../../../types';
export enum SectionType {
SELECTED = 'selected',
RELATED = 'related',
ALL_VALUES = 'all_values',
}
export interface BadgeConfig {
key: string;
label: string;
color: 'robin' | 'warning' | 'secondary';
}
export interface ItemConfig {
section: SectionType;
badge: BadgeConfig | null;
checkedState: CheckedState;
}
export interface ItemContext {
isSelectedOnFilter: boolean;
isInRelatedValues: boolean;
isNotInOperator: boolean;
hasExistingQuery: boolean;
hasFilterForThisKey: boolean;
}
export interface DerivedItem extends ItemConfig {
value: string;
}
interface ItemRule {
condition: (ctx: ItemContext) => boolean;
config: ItemConfig;
}
const ITEM_RULES: ItemRule[] = [
// No existing query and no filter → all checked (selected section)
{
condition: (ctx): boolean =>
!ctx.hasExistingQuery && !ctx.hasFilterForThisKey,
config: {
section: SectionType.SELECTED,
badge: null,
checkedState: 'checked',
},
},
// Selected with NOT IN operator → unchecked, no badge
{
condition: (ctx): boolean => ctx.isSelectedOnFilter && ctx.isNotInOperator,
config: {
section: SectionType.SELECTED,
badge: null,
checkedState: 'unchecked',
},
},
// Selected with IN operator → checked
{
condition: (ctx): boolean => ctx.isSelectedOnFilter && !ctx.isNotInOperator,
config: {
section: SectionType.SELECTED,
badge: null,
checkedState: 'checked',
},
},
// Related values (from existing query) → checked
{
condition: (ctx): boolean => ctx.hasExistingQuery && ctx.isInRelatedValues,
config: {
section: SectionType.RELATED,
badge: null,
checkedState: 'checked',
},
},
// All values (has existing query but not related) → unchecked
{
condition: (ctx): boolean => ctx.hasExistingQuery,
config: {
section: SectionType.ALL_VALUES,
badge: null,
checkedState: 'unchecked',
},
},
];
// Fallback when no rule matches
const DEFAULT_CONFIG: ItemConfig = {
section: SectionType.SELECTED,
badge: null,
checkedState: 'checked',
};
export function deriveItemConfig(ctx: ItemContext): ItemConfig {
for (const rule of ITEM_RULES) {
if (rule.condition(ctx)) {
return rule.config;
}
}
return DEFAULT_CONFIG;
}
export function deriveItems(
values: string[],
relatedSet: Set<string>,
selectedOnFilterSet: Set<string>,
ctx: Omit<ItemContext, 'isSelectedOnFilter' | 'isInRelatedValues'>,
): DerivedItem[] {
return values.map((value) => {
const itemCtx: ItemContext = {
...ctx,
isSelectedOnFilter: selectedOnFilterSet.has(value),
isInRelatedValues: relatedSet.has(value),
};
const config = deriveItemConfig(itemCtx);
return { value, ...config };
});
}

View File

@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { QuickFilterCheckboxUseFieldApis } from 'components/QuickFilters/types';
interface UseExistingQueryParams {
useFieldApis: QuickFilterCheckboxUseFieldApis;
activeQueryIndex: number;
}
interface UseExistingQueryResult {
existingQuery: string | undefined;
hasExistingQuery: boolean;
}
export function useExistingQuery({
useFieldApis,
activeQueryIndex,
}: UseExistingQueryParams): UseExistingQueryResult {
const { currentQuery } = useQueryBuilder();
const existingQuery = useMemo(() => {
if (useFieldApis.existingQuery === null) {
return undefined;
}
if (useFieldApis.existingQuery) {
return useFieldApis.existingQuery;
}
const queryData = currentQuery.builder.queryData?.[activeQueryIndex];
// Prefer V5 filter.expression
if (queryData?.filter?.expression) {
return queryData.filter.expression;
}
// Fall back to V3 filters.items
if (queryData?.filters?.items?.length) {
return convertFiltersToExpression(queryData.filters).expression;
}
return undefined;
}, [
useFieldApis.existingQuery,
currentQuery.builder.queryData,
activeQueryIndex,
]);
// Check if ANY filters exist in query (V3 items or V5 expression)
// This is separate from existingQuery because existingQuery can be explicitly
// disabled (null) while filters still exist in the query for UI purposes
const hasExistingQuery = useMemo(() => {
const queryData = currentQuery.builder.queryData?.[activeQueryIndex];
const hasV3Items = (queryData?.filters?.items?.length ?? 0) > 0;
const hasV5Expression = !!queryData?.filter?.expression;
return hasV3Items || hasV5Expression || !!existingQuery;
}, [currentQuery.builder.queryData, activeQueryIndex, existingQuery]);
return { existingQuery, hasExistingQuery };
}

View File

@@ -0,0 +1,97 @@
import { useMemo } from 'react';
import { useGetFieldsValues } from 'api/generated/services/fields';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
import { DataSource } from 'types/common/queryBuilder';
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
interface UseFieldValuesProps {
filter: IQuickFiltersConfig;
searchText: string;
existingQuery?: string;
metricNamespace?: string;
startUnixMilli?: number;
endUnixMilli?: number;
enabled: boolean;
}
interface UseFieldValuesReturn {
relatedValues: string[];
allValues: string[];
isLoading: boolean;
isFetching: boolean;
}
const DATA_SOURCE_TO_SIGNAL: Record<DataSource, TelemetrytypesSignalDTO> = {
[DataSource.METRICS]: TelemetrytypesSignalDTO.metrics,
[DataSource.TRACES]: TelemetrytypesSignalDTO.traces,
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
};
export function useFieldValues({
filter,
searchText,
existingQuery,
metricNamespace,
startUnixMilli,
endUnixMilli,
enabled,
}: UseFieldValuesProps): UseFieldValuesReturn {
const { data, isLoading, isFetching } = useGetFieldsValues(
{
signal: filter.dataSource
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
: undefined,
name: filter.attributeKey.key,
searchText,
existingQuery,
metricNamespace,
startUnixMilli,
// This field does not affect the backend but I wanted to keep it here
// in case we add the support in the future
endUnixMilli,
},
{
query: {
enabled,
cacheTime: FIELD_API_CACHE_TIME,
keepPreviousData: true,
},
},
);
const relatedValues: string[] = useMemo(() => {
const values = data?.data?.values;
if (!values) {
return [];
}
return (
values.relatedValues?.filter(
(value): value is string =>
value !== null && value !== undefined && value !== '',
) || []
);
}, [data]);
const allValues: string[] = useMemo(() => {
const values = data?.data?.values;
if (!values) {
return [];
}
const stringValues =
values.stringValues?.filter(
(value): value is string =>
value !== null && value !== undefined && value !== '',
) || [];
const numberValues =
values.numberValues
?.filter((value): value is number => value !== null && value !== undefined)
.map((value) => value.toString()) || [];
return [...stringValues, ...numberValues];
}, [data]);
return { relatedValues, allValues, isLoading, isFetching };
}

View File

@@ -0,0 +1,152 @@
import { useMemo } from 'react';
import { BadgeConfig, deriveItems, SectionType } from './itemRules';
import { CheckedState } from '../../../types';
interface SectionedValuesInput {
relatedValues: string[];
allValues: string[];
currentFilterState: Record<string, boolean>;
isSomeFilterPresentForCurrentAttribute: boolean;
isNotInOperator: boolean;
hasExistingQuery: boolean;
visibleItemsCount: number;
relatedExclusions: string[];
}
export interface SectionedItem {
value: string;
section: SectionType;
badge: BadgeConfig | null;
checkedState: CheckedState;
}
export interface Section {
type: SectionType;
items: SectionedItem[];
}
interface SectionedValuesOutput {
sections: Section[];
totalCount: number;
}
const SECTION_ORDER: SectionType[] = [
SectionType.SELECTED,
SectionType.RELATED,
SectionType.ALL_VALUES,
];
export function buildSelectedSet(
currentFilterState: Record<string, boolean>,
isSomeFilterPresentForCurrentAttribute: boolean,
isNotInOperator: boolean,
): Set<string> {
const selectedSet = new Set<string>();
if (!isSomeFilterPresentForCurrentAttribute) {
return selectedSet;
}
for (const [val, isChecked] of Object.entries(currentFilterState)) {
// NOT IN: unchecked = explicitly excluded
// IN: checked = explicitly selected
const shouldAdd = isNotInOperator ? !isChecked : isChecked;
if (shouldAdd) {
selectedSet.add(val);
}
}
return selectedSet;
}
export function useSectionedValues({
relatedValues,
allValues,
currentFilterState,
isSomeFilterPresentForCurrentAttribute,
isNotInOperator,
hasExistingQuery,
visibleItemsCount,
relatedExclusions,
}: SectionedValuesInput): SectionedValuesOutput {
const items = useMemo(() => {
const selectedSet = buildSelectedSet(
currentFilterState,
isSomeFilterPresentForCurrentAttribute,
isNotInOperator,
);
const exclusionSet = new Set(relatedExclusions);
const effectiveRelatedValues = relatedValues.filter(
(value) => !exclusionSet.has(value),
);
// Combine all values - API already filters both arrays by searchText
const allUniqueValues = Array.from(
new Set([...effectiveRelatedValues, ...allValues]),
);
// Include selected values at top - may not be in API response
const finalValues = [
...new Set([...Array.from(selectedSet), ...allUniqueValues]),
];
const relatedSet = new Set(effectiveRelatedValues);
return deriveItems(finalValues, relatedSet, selectedSet, {
isNotInOperator,
hasExistingQuery,
hasFilterForThisKey: isSomeFilterPresentForCurrentAttribute,
});
}, [
relatedValues,
allValues,
currentFilterState,
isSomeFilterPresentForCurrentAttribute,
isNotInOperator,
hasExistingQuery,
relatedExclusions,
]);
const sections = useMemo(() => {
// Group items by section
const sectionMap = new Map<SectionType, SectionedItem[]>();
for (const sectionType of SECTION_ORDER) {
sectionMap.set(sectionType, []);
}
for (const item of items) {
sectionMap.get(item.section)?.push(item);
}
// Sort items within each section alphabetically
for (const sectionItems of sectionMap.values()) {
sectionItems.sort((a, b) => a.value.localeCompare(b.value));
}
// Apply visibleItemsCount across all sections
let remaining = visibleItemsCount;
const result: Section[] = [];
for (const sectionType of SECTION_ORDER) {
const sectionItems = sectionMap.get(sectionType) || [];
if (sectionItems.length === 0) {
continue;
}
const itemsToTake = Math.min(sectionItems.length, remaining);
if (itemsToTake === 0) {
break;
}
result.push({
type: sectionType,
items: sectionItems.slice(0, itemsToTake),
});
remaining -= itemsToTake;
}
return result;
}, [items, visibleItemsCount]);
return { sections, totalCount: items.length };
}

View File

@@ -0,0 +1,36 @@
import { useEffect, useMemo, useRef } from 'react';
const EMPTY: string[] = [];
interface UseStaleRelatedExclusionsParams {
selectedValues: string[];
isFetching: boolean;
isRefreshing: boolean;
}
export function useStaleRelatedExclusions({
selectedValues,
isFetching,
isRefreshing,
}: UseStaleRelatedExclusionsParams): string[] {
const selectionAtLastFetchRef = useRef<string[]>(selectedValues);
useEffect(() => {
if (!isFetching) {
selectionAtLastFetchRef.current = selectedValues;
}
}, [isFetching, selectedValues]);
return useMemo(() => {
if (!isRefreshing) {
return EMPTY;
}
const current = new Set(selectedValues);
const removed = selectionAtLastFetchRef.current.filter(
(value) => !current.has(value),
);
return removed.length ? removed : EMPTY;
}, [isRefreshing, selectedValues]);
}

View File

@@ -32,6 +32,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { USER_ROLES } from 'types/roles';
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
import CheckboxV2 from './FilterRenderers/Checkbox/v2/CheckboxFilterV2';
import Duration from './FilterRenderers/Duration/Duration';
import Slider from './FilterRenderers/Slider/Slider';
import useFilterConfig from './hooks/useFilterConfig';
@@ -51,6 +52,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
signal,
showFilterCollapse = true,
showQueryName = true,
useFieldApis,
} = props;
const { user } = useAppContext();
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
@@ -297,21 +299,45 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
{filterConfig.map((filter) => {
switch (filter.type) {
case FiltersType.CHECKBOX:
return (
return useFieldApis ? (
<CheckboxV2
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
useFieldApis={useFieldApis}
/>
) : (
<Checkbox
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
/>
);
case FiltersType.DURATION:
return <Duration filter={filter} onFilterChange={onFilterChange} />;
return (
<Duration
key={filter.attributeKey.key}
filter={filter}
onFilterChange={onFilterChange}
/>
);
case FiltersType.SLIDER:
return <Slider />;
return <Slider key={filter.attributeKey.key} />;
// eslint-disable-next-line sonarjs/no-duplicated-branches
default:
return (
return useFieldApis ? (
<CheckboxV2
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
useFieldApis={useFieldApis}
/>
) : (
<Checkbox
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
@@ -381,4 +407,5 @@ QuickFilters.defaultProps = {
config: [],
showFilterCollapse: true,
showQueryName: true,
useFieldApis: undefined,
};

View File

@@ -26,6 +26,11 @@ export enum SignalType {
METER_EXPLORER = 'meter',
}
/**
* Missing export from signozhq/ui/checkbox, TODO(H4ad): Add and remove this type definition
*/
export type CheckedState = 'checked' | 'unchecked' | 'indeterminate';
export interface IQuickFiltersConfig {
type: FiltersType;
title: string;
@@ -46,6 +51,7 @@ export interface IQuickFiltersProps {
className?: string;
showFilterCollapse?: boolean;
showQueryName?: boolean;
useFieldApis?: QuickFilterCheckboxUseFieldApis;
}
export enum QuickFiltersSource {
@@ -56,3 +62,19 @@ export enum QuickFiltersSource {
EXCEPTIONS = 'exceptions',
METER_EXPLORER = 'meter',
}
/**
* Opt-in: fetch values from the /v1/fields/values API instead of /v3/autocomplete/attribute_values
*/
export type QuickFilterCheckboxUseFieldApis = {
startUnixMilli: number;
endUnixMilli: number;
/**
* If you didn't specify a string, we automatically try to extract this from the currentQuery,
* from the filter.expression or filter.items.
*
* Use null to ignore/disable this behavior.
*/
existingQuery?: string | null;
metricNamespace?: string;
};

View File

@@ -57,4 +57,7 @@ export enum QueryParams {
isTestAlert = 'isTestAlert',
yAxisUnit = 'yAxisUnit',
ruleName = 'ruleName',
matchType = 'matchType',
compareOp = 'compareOp',
evaluationWindowPreset = 'evaluationWindowPreset',
}

View File

@@ -1,3 +1,5 @@
export const DASHBOARD_CACHE_TIME = 30_000;
// keep it low or zero, otherwise, when enabled auto-refresh, this causes OOM due to accumulated queries in cache
export const DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED = 0;
export const FIELD_API_CACHE_TIME = 60_000;

View File

@@ -0,0 +1,110 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { CreateAlertProvider, useCreateAlertState } from '../index';
// The provider only needs a query with a unit + empty builder for these assertions.
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({
currentQuery: {
unit: 'bytes',
builder: { queryData: [], queryFormulas: [] },
},
redirectWithQueryBuilderData: jest.fn(),
}),
}));
const mutation = { mutate: jest.fn(), isLoading: false };
jest.mock('api/generated/services/rules', () => ({
useCreateRule: (): unknown => mutation,
useTestRule: (): unknown => mutation,
useUpdateRuleByID: (): unknown => mutation,
}));
function Probe(): JSX.Element {
const { thresholdState, evaluationWindow } = useCreateAlertState();
return (
<div>
<span data-testid="match-type">{thresholdState.matchType}</span>
<span data-testid="operator">{thresholdState.operator}</span>
<span data-testid="threshold-count">{thresholdState.thresholds.length}</span>
<span data-testid="threshold-value">
{thresholdState.thresholds[0]?.thresholdValue}
</span>
<span data-testid="window-type">{evaluationWindow.windowType}</span>
</div>
);
}
function renderWithSearch(search: string): void {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<MemoryRouter initialEntries={[`/alerts/new${search}`]}>
<QueryClientProvider client={queryClient}>
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<Probe />
</CreateAlertProvider>
</QueryClientProvider>
</MemoryRouter>,
);
}
function serializeThreshold(thresholdValue: number): string {
return encodeURIComponent(
JSON.stringify([
{
id: 't1',
label: 'critical',
thresholdValue,
recoveryThresholdValue: null,
unit: 'bytes',
channels: [],
color: '#F1575F',
},
]),
);
}
describe('CreateAlertProvider — URL-declared prefill (issue #5291)', () => {
it('applies matchType, operator, and thresholds without the meter window', () => {
// Dashboard-style prefill: no evaluationWindowPreset → default window.
renderWithSearch(
`?matchType=on_average&compareOp=below&thresholds=${serializeThreshold(90)}`,
);
expect(screen.getByTestId('match-type')).toHaveTextContent('on_average');
expect(screen.getByTestId('operator')).toHaveTextContent('below');
expect(screen.getByTestId('threshold-count')).toHaveTextContent('1');
expect(screen.getByTestId('threshold-value')).toHaveTextContent('90');
// The meter evaluation-window preset must NOT fire.
expect(screen.getByTestId('window-type')).toHaveTextContent('rolling');
});
it('adjusts only the occurrence type when no thresholds are supplied', () => {
renderWithSearch(`?matchType=in_total`);
expect(screen.getByTestId('match-type')).toHaveTextContent('in_total');
// The default single critical threshold and operator are retained.
expect(screen.getByTestId('threshold-count')).toHaveTextContent('1');
expect(screen.getByTestId('threshold-value')).toHaveTextContent('0');
expect(screen.getByTestId('operator')).toHaveTextContent('above');
expect(screen.getByTestId('window-type')).toHaveTextContent('rolling');
});
it('applies the meter evaluation window when the preset is declared', () => {
// Ingestion-style prefill: explicit in_total + meter preset.
renderWithSearch(
`?matchType=in_total&evaluationWindowPreset=meter&thresholds=${serializeThreshold(
500,
)}`,
);
expect(screen.getByTestId('match-type')).toHaveTextContent('in_total');
expect(screen.getByTestId('threshold-value')).toHaveTextContent('500');
expect(screen.getByTestId('window-type')).toHaveTextContent('cumulative');
});
});

View File

@@ -0,0 +1,71 @@
import { AlertThresholdMatchType, AlertThresholdOperator } from '../types';
import {
EvaluationWindowPreset,
resolveUrlAlertPrefill,
} from '../resolveUrlAlertPrefill';
function resolve(search: string): ReturnType<typeof resolveUrlAlertPrefill> {
return resolveUrlAlertPrefill(new URLSearchParams(search));
}
describe('resolveUrlAlertPrefill', () => {
it('returns an empty plan when no params are present', () => {
expect(resolve('')).toStrictEqual({
thresholds: undefined,
matchType: undefined,
operator: undefined,
evaluationWindowPreset: undefined,
});
});
it('parses a thresholds array', () => {
const thresholds = [{ id: 't1', thresholdValue: 90 }];
const { thresholds: parsed } = resolve(
`thresholds=${encodeURIComponent(JSON.stringify(thresholds))}`,
);
expect(parsed).toStrictEqual(thresholds);
});
it('ignores malformed or non-array thresholds without throwing', () => {
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
expect(resolve('thresholds=not-json').thresholds).toBeUndefined();
expect(
resolve(`thresholds=${encodeURIComponent('{"a":1}')}`).thresholds,
).toBeUndefined();
consoleError.mockRestore();
});
it('normalizes matchType aliases', () => {
expect(resolve('matchType=on_average').matchType).toBe(
AlertThresholdMatchType.ON_AVERAGE,
);
expect(resolve('matchType=sum').matchType).toBe(
AlertThresholdMatchType.IN_TOTAL,
);
expect(resolve('matchType=nonsense').matchType).toBeUndefined();
});
it('normalizes operator aliases', () => {
expect(resolve('compareOp=above').operator).toBe(
AlertThresholdOperator.IS_ABOVE,
);
expect(resolve('compareOp=%3E').operator).toBe(
AlertThresholdOperator.IS_ABOVE,
);
expect(resolve('compareOp=nonsense').operator).toBeUndefined();
});
it('recognizes the meter evaluation-window preset only', () => {
expect(resolve('evaluationWindowPreset=meter').evaluationWindowPreset).toBe(
EvaluationWindowPreset.METER,
);
expect(
resolve('evaluationWindowPreset=rolling').evaluationWindowPreset,
).toBeUndefined();
expect(resolve('').evaluationWindowPreset).toBeUndefined();
});
});

View File

@@ -0,0 +1,63 @@
import { AlertThresholdMatchType, AlertThresholdOperator } from './types';
// Mirrors the backend's CompareOperator.Normalize() in
// pkg/types/ruletypes/compare.go. Maps any accepted alias to the enum value
// the dropdown understands. Returns undefined for aliases the UI does not
// expose (e.g. above_or_equal, below_or_equal) so callers can keep the raw
// value on screen instead of silently rewriting it.
export function normalizeOperator(
raw: string | undefined,
): AlertThresholdOperator | undefined {
switch (raw) {
case '1':
case 'above':
case '>':
return AlertThresholdOperator.IS_ABOVE;
case '2':
case 'below':
case '<':
return AlertThresholdOperator.IS_BELOW;
case '3':
case 'equal':
case 'eq':
case '=':
return AlertThresholdOperator.IS_EQUAL_TO;
case '4':
case 'not_equal':
case 'not_eq':
case '!=':
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
case '7':
case 'outside_bounds':
return AlertThresholdOperator.ABOVE_BELOW;
default:
return undefined;
}
}
// Mirrors the backend's MatchType.Normalize() in pkg/types/ruletypes/match.go.
export function normalizeMatchType(
raw: string | undefined,
): AlertThresholdMatchType | undefined {
switch (raw) {
case '1':
case 'at_least_once':
return AlertThresholdMatchType.AT_LEAST_ONCE;
case '2':
case 'all_the_times':
return AlertThresholdMatchType.ALL_THE_TIME;
case '3':
case 'on_average':
case 'avg':
return AlertThresholdMatchType.ON_AVERAGE;
case '4':
case 'in_total':
case 'sum':
return AlertThresholdMatchType.IN_TOTAL;
case '5':
case 'last':
return AlertThresholdMatchType.LAST;
default:
return undefined;
}
}

View File

@@ -23,10 +23,13 @@ import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/map
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { INITIAL_CREATE_ALERT_STATE } from './constants';
import {
EvaluationWindowPreset,
resolveUrlAlertPrefill,
} from './resolveUrlAlertPrefill';
import {
AdvancedOptionsAction,
AlertThresholdAction,
AlertThresholdMatchType,
CreateAlertAction,
CreateAlertSlice,
EvaluationWindowAction,
@@ -123,9 +126,13 @@ export function CreateAlertProvider(
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const thresholdsFromURL = queryParams.get(QueryParams.thresholds);
const ruleNameFromURL = queryParams.get(QueryParams.ruleName);
const yAxisUnitFromURL = queryParams.get(QueryParams.yAxisUnit);
// Prefill declared in the URL; applied verbatim, agnostic of the producer.
const urlPrefill = useMemo(
() => resolveUrlAlertPrefill(new URLSearchParams(location.search)),
[location.search],
);
const [alertType, setAlertType] = useState<AlertTypes>(() => {
if (isEditMode) {
@@ -168,32 +175,41 @@ export function CreateAlertProvider(
},
});
if (thresholdsFromURL) {
try {
const thresholds = JSON.parse(thresholdsFromURL);
setCreateAlertState({
slice: CreateAlertSlice.THRESHOLD,
action: {
type: 'SET_THRESHOLDS',
payload: thresholds,
},
});
} catch (error) {
console.error('Error parsing thresholds from URL:', error);
}
if (urlPrefill.thresholds) {
setCreateAlertState({
slice: CreateAlertSlice.EVALUATION_WINDOW,
slice: CreateAlertSlice.THRESHOLD,
action: {
type: 'SET_INITIAL_STATE_FOR_METER',
type: 'SET_THRESHOLDS',
payload: urlPrefill.thresholds,
},
});
}
if (urlPrefill.matchType) {
setCreateAlertState({
slice: CreateAlertSlice.THRESHOLD,
action: {
type: 'SET_MATCH_TYPE',
payload: AlertThresholdMatchType.IN_TOTAL,
payload: urlPrefill.matchType,
},
});
}
if (urlPrefill.operator) {
setCreateAlertState({
slice: CreateAlertSlice.THRESHOLD,
action: {
type: 'SET_OPERATOR',
payload: urlPrefill.operator,
},
});
}
if (urlPrefill.evaluationWindowPreset === EvaluationWindowPreset.METER) {
setCreateAlertState({
slice: CreateAlertSlice.EVALUATION_WINDOW,
action: {
type: 'SET_INITIAL_STATE_FOR_METER',
},
});
}
@@ -219,7 +235,7 @@ export function CreateAlertProvider(
},
});
}
}, [alertType, thresholdsFromURL, ruleNameFromURL, yAxisUnitFromURL]);
}, [alertType, urlPrefill, ruleNameFromURL, yAxisUnitFromURL]);
useEffect(() => {
if (isEditMode && initialAlertState) {

View File

@@ -0,0 +1,54 @@
import { QueryParams } from 'constants/query';
import { normalizeMatchType, normalizeOperator } from './conditionNormalizers';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
Threshold,
} from './types';
export enum EvaluationWindowPreset {
METER = 'meter',
}
export interface ResolvedAlertPrefill {
thresholds?: Threshold[];
matchType?: AlertThresholdMatchType;
operator?: AlertThresholdOperator;
evaluationWindowPreset?: EvaluationWindowPreset;
}
function parseThresholds(raw: string | null): Threshold[] | undefined {
if (!raw) {
return undefined;
}
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as Threshold[]) : undefined;
} catch (error) {
console.error('Error parsing thresholds from URL:', error);
return undefined;
}
}
function parseEvaluationWindowPreset(
raw: string | null,
): EvaluationWindowPreset | undefined {
return raw === EvaluationWindowPreset.METER
? EvaluationWindowPreset.METER
: undefined;
}
/** URL → declarative prefill plan; the consumer applies it without knowing the producer. */
export function resolveUrlAlertPrefill(
params: URLSearchParams,
): ResolvedAlertPrefill {
return {
thresholds: parseThresholds(params.get(QueryParams.thresholds)),
matchType: normalizeMatchType(params.get(QueryParams.matchType) ?? undefined),
operator: normalizeOperator(params.get(QueryParams.compareOp) ?? undefined),
evaluationWindowPreset: parseEvaluationWindowPreset(
params.get(QueryParams.evaluationWindowPreset),
),
};
}

View File

@@ -238,67 +238,12 @@ export function getAdvancedOptionsStateFromAlertDef(
};
}
// Mirrors the backend's CompareOperator.Normalize() in
// pkg/types/ruletypes/compare.go. Maps any accepted alias to the enum value
// the dropdown understands. Returns undefined for aliases the UI does not
// expose (e.g. above_or_equal, below_or_equal) so callers can keep the raw
// value on screen instead of silently rewriting it.
export function normalizeOperator(
raw: string | undefined,
): AlertThresholdOperator | undefined {
switch (raw) {
case '1':
case 'above':
case '>':
return AlertThresholdOperator.IS_ABOVE;
case '2':
case 'below':
case '<':
return AlertThresholdOperator.IS_BELOW;
case '3':
case 'equal':
case 'eq':
case '=':
return AlertThresholdOperator.IS_EQUAL_TO;
case '4':
case 'not_equal':
case 'not_eq':
case '!=':
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
case '7':
case 'outside_bounds':
return AlertThresholdOperator.ABOVE_BELOW;
default:
return undefined;
}
}
// Mirrors the backend's MatchType.Normalize() in pkg/types/ruletypes/match.go.
export function normalizeMatchType(
raw: string | undefined,
): AlertThresholdMatchType | undefined {
switch (raw) {
case '1':
case 'at_least_once':
return AlertThresholdMatchType.AT_LEAST_ONCE;
case '2':
case 'all_the_times':
return AlertThresholdMatchType.ALL_THE_TIME;
case '3':
case 'on_average':
case 'avg':
return AlertThresholdMatchType.ON_AVERAGE;
case '4':
case 'in_total':
case 'sum':
return AlertThresholdMatchType.IN_TOTAL;
case '5':
case 'last':
return AlertThresholdMatchType.LAST;
default:
return undefined;
}
}
// Condition-alias normalizers live in a leaf module (they depend only on the
// enums) so `context/index` can consume them without an index↔utils cycle.
export {
normalizeOperator,
normalizeMatchType,
} from './context/conditionNormalizers';
export function getThresholdStateFromAlertDef(
alertDef: PostableAlertRuleV2,

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
@@ -23,7 +23,10 @@ import K8sBaseDetails, {
import { K8sBaseList } from 'container/InfraMonitoringK8sV2/Base/K8sBaseList';
import StatusFilter from 'container/InfraMonitoringHostsV2/StatusFilter';
import { K8sBaseFilters } from 'container/InfraMonitoringK8sV2/Base/types';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import {
InfraMonitoringEntity,
METRIC_NAMESPACE_BY_ENTITY,
} from 'container/InfraMonitoringK8sV2/constants';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useAppContext } from 'providers/App/App';
@@ -47,6 +50,7 @@ import { getHostsQuickFiltersConfig } from './utils';
import styles from './InfraMonitoringHosts.module.scss';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
import { NANO_SECOND_MULTIPLIER, useGlobalTimeStore } from 'store/globalTime';
function Hosts(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
@@ -55,6 +59,17 @@ function Hosts(): JSX.Element {
const { redirectWithQueryBuilderData } = useQueryBuilder();
const isInitialized = useRef(false);
const selectedTime = useGlobalTimeStore((state) => state.selectedTime);
const getMinMaxTime = useGlobalTimeStore((state) => state.getMinMaxTime);
const { startUnixMilli, endUnixMilli } = useMemo(() => {
const { minTime, maxTime } = getMinMaxTime();
return {
startUnixMilli: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
endUnixMilli: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTime, getMinMaxTime]);
useEffect(() => {
if (isInitialized.current) {
return;
@@ -210,6 +225,12 @@ function Hosts(): JSX.Element {
source={QuickFiltersSource.INFRA_MONITORING}
config={getHostsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={{
metricNamespace:
METRIC_NAMESPACE_BY_ENTITY[InfraMonitoringEntity.HOSTS],
startUnixMilli,
endUnixMilli,
}}
/>
</div>
)}

View File

@@ -3,7 +3,10 @@ import * as Sentry from '@sentry/react';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import {
QuickFilterCheckboxUseFieldApis,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -36,7 +39,9 @@ import {
GetPodsQuickFiltersConfig,
GetStatefulsetsQuickFiltersConfig,
GetVolumesQuickFiltersConfig,
InfraMonitoringEntity,
K8sCategories,
METRIC_NAMESPACE_BY_ENTITY,
} from './constants';
import K8sDaemonSetsList from './DaemonSets/K8sDaemonSetsList';
import K8sDeploymentsList from './Deployments/K8sDeploymentsList';
@@ -56,6 +61,7 @@ import K8sVolumesList from './Volumes/K8sVolumesList';
import styles from './InfraMonitoringK8s.module.scss';
import { InfraMonitoringEvents } from 'constants/events';
import logEvent from 'api/common/logEvent';
import { NANO_SECOND_MULTIPLIER, useGlobalTimeStore } from 'store/globalTime';
export default function InfraMonitoringK8s(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
@@ -69,6 +75,31 @@ export default function InfraMonitoringK8s(): JSX.Element {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isInitialized = useRef(false);
const selectedTime = useGlobalTimeStore((state) => state.selectedTime);
const getMinMaxTime = useGlobalTimeStore((state) => state.getMinMaxTime);
const { startUnixMilli, endUnixMilli } = useMemo(() => {
const { minTime, maxTime } = getMinMaxTime();
return {
startUnixMilli: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
endUnixMilli: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTime, getMinMaxTime]);
const getUseFieldApis = useCallback(
(entity: InfraMonitoringEntity): QuickFilterCheckboxUseFieldApis => ({
metricNamespace: METRIC_NAMESPACE_BY_ENTITY[entity],
startUnixMilli,
endUnixMilli,
}),
[startUnixMilli, endUnixMilli],
);
const selectedCategoryUseFieldApis = useMemo(
() => getUseFieldApis(selectedCategory as InfraMonitoringEntity),
[getUseFieldApis, selectedCategory],
);
useEffect(() => {
if (isInitialized.current) {
return;
@@ -269,6 +300,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
source={QuickFiltersSource.INFRA_MONITORING}
config={selectedCategoryConfig}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={selectedCategoryUseFieldApis}
/>
)}
</div>

View File

@@ -48,6 +48,8 @@ import { QueryParams } from 'constants/query';
import { initialQueryMeterWithType } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { INITIAL_ALERT_THRESHOLD_STATE } from 'container/CreateAlertV2/context/constants';
import { EvaluationWindowPreset } from 'container/CreateAlertV2/context/resolveUrlAlertPrefill';
import { AlertThresholdMatchType } from 'container/CreateAlertV2/context/types';
import dayjs from 'dayjs';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useGetGlobalConfig } from 'api/generated/services/global';
@@ -907,6 +909,7 @@ function MultiIngestionSettings(): JSX.Element {
? `[ingestion][${signal.signal}] ${keyName} has exceeded daily ingestion limit`
: `[ingestion][${signal.signal}] ${signal.signal} has exceeded daily ingestion limit`;
// Declare the metering prefill explicitly: "in total" + the meter window.
const URL = `${ROUTES.ALERTS_NEW}?${
QueryParams.compositeQuery
}=${encodeURIComponent(stringifiedQuery)}&${
@@ -915,7 +918,9 @@ function MultiIngestionSettings(): JSX.Element {
QueryParams.ruleName
}=${encodeURIComponent(ruleName)}&${
QueryParams.yAxisUnit
}=${encodeURIComponent(yAxisUnit)}`;
}=${encodeURIComponent(yAxisUnit)}&${QueryParams.matchType}=${
AlertThresholdMatchType.IN_TOTAL
}&${QueryParams.evaluationWindowPreset}=${EvaluationWindowPreset.METER}`;
history.push(URL);
};

View File

@@ -182,6 +182,14 @@
flex: 1;
min-height: 0;
overflow-y: visible;
.table-view-container-header {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 12px;
flex-shrink: 0;
}
}
.time-series-view-container {

View File

@@ -18,13 +18,18 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import { QueryParams } from 'constants/query';
import { initialFilters, PANEL_TYPES } from 'constants/queryBuilder';
import {
initialFilters,
initialQueriesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import GoToTop from 'container/GoToTop';
import LogsExplorerChart from 'container/LogsExplorerChart';
import LogsExplorerList from 'container/LogsExplorerList';
import ExportMenu from 'components/ExportMenu/ExportMenu';
import LogsExplorerTable from 'container/LogsExplorerTable';
import {
getExportQueryData,
@@ -484,6 +489,16 @@ function LogsExplorerViewsContainer({
)}
{selectedPanelType === PANEL_TYPES.TABLE && !showLiveLogs && (
<div className="table-view-container">
{data && !isError && (
<div className="table-view-container-header">
<ExportMenu
dataSource={DataSource.LOGS}
data={data}
query={stagedQuery || initialQueriesMap.metrics}
fileName="logs-table"
/>
</div>
)}
<LogsExplorerTable
data={
(data?.payload?.data?.newResult?.data?.result ||

View File

@@ -48,7 +48,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import TimeseriesExportMenu from './TimeseriesExportMenu';
import ExportMenu from 'components/ExportMenu/ExportMenu';
import './TimeSeriesView.styles.scss';
@@ -265,12 +265,11 @@ function TimeSeriesView({
)}
</div>
{showExport && data?.rawV5Response && (
<TimeseriesExportMenu
<ExportMenu
dataSource={dataSource}
yAxisUnit={yAxisUnit}
queryResponse={data.rawV5Response}
data={data}
query={currentQuery}
legendMap={data.legendMap}
fileName={`${dataSource}-timeseries`}
/>
)}

View File

@@ -13,7 +13,7 @@ jest.mock('components/Uplot', () => ({
default: (): JSX.Element => <div data-testid="uplot-chart" />,
}));
jest.mock('../TimeseriesExportMenu', () => ({
jest.mock('components/ExportMenu/ExportMenu', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="timeseries-export-menu" />,
}));

View File

@@ -0,0 +1,7 @@
.traces-table-view-header {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 12px;
flex-shrink: 0;
}

View File

@@ -10,6 +10,7 @@ import {
import { useSelector } from 'react-redux';
import { Space } from 'antd';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ExportMenu from 'components/ExportMenu/ExportMenu';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
@@ -20,8 +21,11 @@ import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import APIError from 'types/api/error';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import './TableView.styles.scss';
function TableView({
setWarning,
setIsLoadingQueries,
@@ -97,6 +101,16 @@ function TableView({
return (
<Space.Compact block direction="vertical">
{isError && error && <ErrorInPlace error={error as APIError} />}
{!isError && data && (
<div className="traces-table-view-header">
<ExportMenu
dataSource={DataSource.TRACES}
data={data}
query={stagedQuery || initialQueriesMap.traces}
fileName="traces-table"
/>
</div>
)}
{!isError && (
<QueryTable
query={stagedQuery || initialQueriesMap.traces}

View File

@@ -4,7 +4,8 @@ import {
getTimestampedFileName,
} from 'lib/exportData/downloadFile';
import { ExportFormat } from 'lib/exportData/types';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useClientExport } from '../useClientExport';
@@ -24,31 +25,87 @@ jest.mock('antd', () => {
const mockDownloadFile = downloadFile as jest.Mock;
function timeSeriesResponse(): QueryRangeResponseV5 {
const query = {
queryType: 'builder',
builder: {
queryData: [
{
queryName: 'A',
dataSource: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [],
legend: '',
},
],
queryFormulas: [],
},
} as unknown as Query;
function timeSeriesData(): MetricQueryRangeSuccessResponse {
return {
type: 'time_series',
data: {
results: [
{
queryName: 'A',
aggregations: [
{
index: 0,
alias: '',
meta: {},
series: [
{
labels: [{ key: { name: 'service' }, value: 'a' }],
values: [{ timestamp: 1000, value: 12 }],
},
],
},
],
},
],
statusCode: 200,
error: null,
message: '',
payload: { data: { result: [], resultType: 'time_series' } },
legendMap: { A: '{{service}}' },
rawV5Response: {
type: 'time_series',
data: {
results: [
{
queryName: 'A',
aggregations: [
{
index: 0,
alias: '',
meta: {},
series: [
{
labels: [{ key: { name: 'service' }, value: 'a' }],
values: [{ timestamp: 1000, value: 12 }],
},
],
},
],
},
],
},
meta: {},
},
meta: {},
} as unknown as QueryRangeResponseV5;
} as unknown as MetricQueryRangeSuccessResponse;
}
function scalarData(): MetricQueryRangeSuccessResponse {
return {
statusCode: 200,
error: null,
message: '',
payload: {
data: {
resultType: 'scalar',
result: [
{
queryName: 'A',
legend: '',
series: null,
list: null,
table: {
columns: [
{
name: 'service.name',
id: 'service.name',
queryName: 'A',
isValueColumn: false,
},
{ name: 'count()', id: 'A', queryName: 'A', isValueColumn: true },
],
rows: [{ data: { 'service.name': 'frontend', A: 120 } }],
},
},
],
},
},
} as unknown as MetricQueryRangeSuccessResponse;
}
describe('useClientExport', () => {
@@ -64,13 +121,9 @@ describe('useClientExport', () => {
jest.useRealTimers();
});
it('exports time_series as CSV to a timestamped <fileName>.csv', () => {
it('dispatches timeseries data to the timeseries serializer (csv)', () => {
const { result } = renderHook(() =>
useClientExport({
response: timeSeriesResponse(),
fileName: 'chart',
legendMap: { A: '{{service}}' },
}),
useClientExport({ data: timeSeriesData(), query, fileName: 'chart' }),
);
act(() => {
@@ -83,12 +136,28 @@ describe('useClientExport', () => {
expect(name).toBe(getTimestampedFileName('chart', 'csv'));
expect(mime).toContain('text/csv');
expect(content).toContain('service');
expect(content).toContain('a');
});
it('exports as JSONL to a timestamped <fileName>.jsonl with the ndjson mime', () => {
it('dispatches scalar (table) data to the table serializer', () => {
const { result } = renderHook(() =>
useClientExport({ response: timeSeriesResponse() }),
useClientExport({ data: scalarData(), query, fileName: 'table' }),
);
act(() => {
result.current.handleExport({ format: ExportFormat.Csv });
});
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
const [content, name] = mockDownloadFile.mock.calls[0];
expect(name).toBe(getTimestampedFileName('table', 'csv'));
expect(content).toContain('service.name');
expect(content).toContain('frontend');
expect(content).toContain('120');
});
it('exports as JSONL with the ndjson mime', () => {
const { result } = renderHook(() =>
useClientExport({ data: timeSeriesData(), query }),
);
act(() => {
@@ -101,8 +170,8 @@ describe('useClientExport', () => {
expect(content).toContain('"series"');
});
it('does nothing when there is no response', () => {
const { result } = renderHook(() => useClientExport({}));
it('does nothing when there is no data', () => {
const { result } = renderHook(() => useClientExport({ query }));
act(() => {
result.current.handleExport({ format: ExportFormat.Csv });
@@ -112,13 +181,14 @@ describe('useClientExport', () => {
expect(mockMessageError).not.toHaveBeenCalled();
});
it('shows an error and does not download for unsupported result types', () => {
it('shows an error for unsupported result types', () => {
const raw = {
type: 'raw',
data: { results: [] },
meta: {},
} as unknown as QueryRangeResponseV5;
const { result } = renderHook(() => useClientExport({ response: raw }));
statusCode: 200,
error: null,
message: '',
payload: { data: { result: [], resultType: '' } },
} as unknown as MetricQueryRangeSuccessResponse;
const { result } = renderHook(() => useClientExport({ data: raw, query }));
act(() => {
result.current.handleExport({ format: ExportFormat.Csv });

View File

@@ -4,11 +4,14 @@ import {
downloadFile,
getTimestampedFileName,
} from 'lib/exportData/downloadFile';
import { exportScalarData } from 'lib/exportData/exportScalarData';
import { exportTimeseriesData } from 'lib/exportData/exportTimeseriesData';
import { toCsv } from 'lib/exportData/toCsv';
import { toJsonl } from 'lib/exportData/toJsonl';
import { ExportFormat, SerializedTable } from 'lib/exportData/types';
import { useCallback, useState } from 'react';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeResponseV5, TimeSeriesData } from 'types/api/v5/queryRange';
@@ -20,33 +23,44 @@ const FORMAT_META: Record<ExportFormat, { mime: string; extension: string }> = {
},
};
// Picks the serializer for the response's request type. Narrows the results
// union via the response discriminant. scalar lands with #5591; raw/trace are
// server-exported, distribution is never emitted.
/** The queryRange response object views hold — structural (params left
* unconstrained) so both explorer variants assign cleanly. */
export type ClientExportData = SuccessResponse<MetricRangePayloadProps> & {
rawV5Response?: QueryRangeResponseV5;
legendMap?: Record<string, string>;
};
// Picks the serializer from what the queryRange response carries: timeseries
// queries surface the raw V5 tree (rawV5Response); table queries carry the
// formatForWeb webTables payload (resultType 'scalar'). raw/trace stay
// server-exported via useServerExport.
function serialize(
response: QueryRangeResponseV5,
data: ClientExportData,
yAxisUnit?: string,
legendMap?: Record<string, string>,
query?: Query,
): SerializedTable {
if (response.type === REQUEST_TYPES.TIME_SERIES) {
if (data.rawV5Response?.type === REQUEST_TYPES.TIME_SERIES) {
return exportTimeseriesData({
data: response.data.results as TimeSeriesData[],
data: data.rawV5Response.data.results as TimeSeriesData[],
yAxisUnit,
legendMap,
legendMap: data.legendMap,
query,
});
}
throw new Error(`Export is not supported for "${response.type}" results`);
if (data.payload?.data?.resultType === 'scalar' && query) {
return exportScalarData({ data, query });
}
throw new Error('Export is not supported for this result type');
}
interface UseClientExportProps {
response?: QueryRangeResponseV5;
// currently supports only qb v5 responses. Can extend to support future responses.
data?: ClientExportData;
query?: Query;
yAxisUnit?: string;
fileName?: string;
legendMap?: Record<string, string>;
}
interface ClientExportOptions {
@@ -59,23 +73,22 @@ interface UseClientExportReturn {
}
export function useClientExport({
response, // currently supports only qb v5 response. Can extend to support future responses.
data,
query,
yAxisUnit,
fileName = 'export',
legendMap,
}: UseClientExportProps): UseClientExportReturn {
const [isExporting, setIsExporting] = useState<boolean>(false);
const handleExport = useCallback(
({ format }: ClientExportOptions): void => {
if (!response) {
if (!data) {
return;
}
setIsExporting(true);
try {
const table = serialize(response, yAxisUnit, legendMap, query);
const table = serialize(data, yAxisUnit, query);
const content =
format === ExportFormat.Jsonl ? toJsonl(table) : toCsv(table);
const { mime, extension } = FORMAT_META[format];
@@ -86,7 +99,7 @@ export function useClientExport({
setIsExporting(false);
}
},
[response, query, yAxisUnit, fileName, legendMap],
[data, query, yAxisUnit, fileName],
);
return { isExporting, handleExport };

View File

@@ -37,12 +37,22 @@ jest.mock('api/generated/services/querier', () => ({
}));
// Stub the builders so this asserts only the hook's orchestration.
const mockBuildAlertUrl = jest.fn(
(..._args: unknown[]) => '/alerts/new?composite=substituted',
);
jest.mock('../../utils/buildCreateAlertUrl', () => ({
buildCreateAlertUrl: (): string => '/alerts/new?composite=sync',
buildAlertUrl: (): string => '/alerts/new?composite=substituted',
buildAlertUrl: (...args: unknown[]): string => mockBuildAlertUrl(...args),
readPanelUnit: (): string | undefined => undefined,
}));
// Prefill derivation has its own coverage; return a sentinel so the hook test can
// assert it is threaded into the resolved-alert URL.
const mockPrefill = { matchType: 'in_total' };
jest.mock('../../utils/deriveAlertPrefill', () => ({
deriveAlertPrefill: (): unknown => mockPrefill,
}));
// Keep the real exports (getPanelQueryType reads them); stub only the builder.
const mockBuildQueryRangeRequest = jest.fn((_args?: unknown) => ({
request: 'payload',
@@ -155,6 +165,13 @@ describe('useCreateAlertFromPanel', () => {
const { onSuccess } = mockSubstituteVars.mock.calls[0][1];
onSuccess({ data: { compositeQuery: { queries: [{ type: 'builder' }] } } });
// The resolved query is seeded with the panel-derived alert prefill.
expect(mockBuildAlertUrl).toHaveBeenCalledWith(
{ resolved: 'query' },
PANEL_TYPES.TIME_SERIES,
undefined,
mockPrefill,
);
expect(mockSafeNavigate).toHaveBeenCalledWith(
'/alerts/new?composite=substituted',
{ newTab: true },

View File

@@ -16,6 +16,7 @@ import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/stor
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { deriveAlertPrefill } from '../utils/deriveAlertPrefill';
import {
buildAlertUrl,
buildCreateAlertUrl,
@@ -75,10 +76,12 @@ export function useCreateAlertFromPanel(): (
response.data.compositeQuery?.queries ?? [],
panelType,
);
const unit = readPanelUnit(panel.spec.plugin);
const url = buildAlertUrl(
query,
panelType,
readPanelUnit(panel.spec.plugin),
unit,
deriveAlertPrefill(panel, query, unit),
);
safeNavigate(url, { newTab: true });
},

View File

@@ -100,4 +100,47 @@ describe('buildCreateAlertUrl', () => {
);
expect(decoded.unit).toBeUndefined();
});
it('omits the alert-condition params when the panel has no reduceTo or thresholds', () => {
const params = parse(buildCreateAlertUrl(makePanel()));
expect(params.get(QueryParams.thresholds)).toBeNull();
expect(params.get(QueryParams.matchType)).toBeNull();
expect(params.get(QueryParams.compareOp)).toBeNull();
});
it('seeds the alert condition from the panel Reduce To + highest-danger threshold', () => {
mockFromPerses.mockReturnValue({
...translatedQuery,
builder: {
queryData: [{ aggregations: [{ reduceTo: 'sum' }] }],
queryFormulas: [],
queryTraceOperator: [],
},
});
const numberPanel = {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: {
kind: 'signoz/NumberPanel',
spec: {
thresholds: [{ color: '#F1575F', value: 90, operator: 'above' }],
},
},
queries: [{ some: 'query' }],
},
} as unknown as DashboardtypesPanelDTO;
const params = parse(buildCreateAlertUrl(numberPanel));
expect(params.get(QueryParams.matchType)).toBe('in_total');
expect(params.get(QueryParams.compareOp)).toBe('above');
// The alert page parses this param directly (no decodeURIComponent), so it
// must be single-encoded — reading it the same way guards against
// double-encoding regressions.
const thresholds = JSON.parse(params.get(QueryParams.thresholds) as string);
expect(thresholds).toHaveLength(1);
expect(thresholds[0].thresholdValue).toBe(90);
});
});

View File

@@ -0,0 +1,226 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
} from 'container/CreateAlertV2/context/types';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { ReduceOperators } from 'types/common/queryBuilder';
import { deriveAlertPrefill } from '../deriveAlertPrefill';
const RED = '#F1575F';
const ORANGE = '#F5B225';
const GREEN = '#2BB673';
/** Query with one metric builder query per supplied reduceTo (on the V5 aggregation). */
function makeQuery(...reduceTos: (ReduceOperators | undefined)[]): Query {
return {
builder: {
queryData: reduceTos.map((reduceTo) => ({
aggregations: [{ reduceTo }],
})),
queryFormulas: [],
},
} as unknown as Query;
}
/** Query carrying reduceTo on the legacy V1 field instead of the aggregation. */
function makeLegacyQuery(reduceTo: ReduceOperators): Query {
return {
builder: {
queryData: [{ reduceTo }],
queryFormulas: [],
},
} as unknown as Query;
}
type ComparisonThreshold = {
color: string;
value: number;
unit?: string;
operator?: string;
};
type LabelThreshold = { color: string; value: number; unit?: string };
function makeNumberPanel(
thresholds: ComparisonThreshold[],
): DashboardtypesPanelDTO {
return {
spec: { plugin: { kind: 'signoz/NumberPanel', spec: { thresholds } } },
} as unknown as DashboardtypesPanelDTO;
}
function makeTimeSeriesPanel(
thresholds: LabelThreshold[],
): DashboardtypesPanelDTO {
return {
spec: { plugin: { kind: 'signoz/TimeSeriesPanel', spec: { thresholds } } },
} as unknown as DashboardtypesPanelDTO;
}
const emptyNumberPanel = makeNumberPanel([]);
describe('deriveAlertPrefill', () => {
describe('Reduce To → matchType', () => {
it('maps sum → in total', () => {
expect(
deriveAlertPrefill(emptyNumberPanel, makeQuery(ReduceOperators.SUM))
.matchType,
).toBe(AlertThresholdMatchType.IN_TOTAL);
});
it('maps avg → on average', () => {
expect(
deriveAlertPrefill(emptyNumberPanel, makeQuery(ReduceOperators.AVG))
.matchType,
).toBe(AlertThresholdMatchType.ON_AVERAGE);
});
it.each([ReduceOperators.MAX, ReduceOperators.MIN, ReduceOperators.LAST])(
'leaves matchType undefined for %s (no occurrence-type equivalent per issue #5291)',
(reduceTo) => {
expect(
deriveAlertPrefill(emptyNumberPanel, makeQuery(reduceTo)).matchType,
).toBeUndefined();
},
);
it('ignores reduceTo on non-Value panels (only the Value panel drives occurrence)', () => {
const { matchType } = deriveAlertPrefill(
makeTimeSeriesPanel([{ color: RED, value: 100 }]),
makeQuery(ReduceOperators.AVG),
);
expect(matchType).toBeUndefined();
});
it('reads reduceTo from the legacy V1 field too', () => {
expect(
deriveAlertPrefill(emptyNumberPanel, makeLegacyQuery(ReduceOperators.SUM))
.matchType,
).toBe(AlertThresholdMatchType.IN_TOTAL);
});
it('falls back to the legacy field when the aggregation reduceTo is empty', () => {
const query = {
builder: {
queryData: [
{ aggregations: [{ reduceTo: '' }], reduceTo: ReduceOperators.AVG },
],
queryFormulas: [],
},
} as unknown as Query;
expect(deriveAlertPrefill(emptyNumberPanel, query).matchType).toBe(
AlertThresholdMatchType.ON_AVERAGE,
);
});
});
describe('formula panels (multiple builder queries)', () => {
it('applies the reduce value when every query agrees', () => {
expect(
deriveAlertPrefill(
emptyNumberPanel,
makeQuery(ReduceOperators.AVG, ReduceOperators.AVG),
).matchType,
).toBe(AlertThresholdMatchType.ON_AVERAGE);
});
it('keeps the default when queries disagree', () => {
expect(
deriveAlertPrefill(
emptyNumberPanel,
makeQuery(ReduceOperators.AVG, ReduceOperators.SUM),
).matchType,
).toBeUndefined();
});
});
describe('thresholds → operator + target + unit', () => {
it('picks the highest-danger threshold (red over orange over green)', () => {
const { threshold, operator } = deriveAlertPrefill(
makeNumberPanel([
{ color: GREEN, value: 10, operator: 'below' },
{ color: RED, value: 90, operator: 'above' },
{ color: ORANGE, value: 80, operator: 'above' },
]),
makeQuery(),
);
expect(threshold?.thresholdValue).toBe(90);
expect(threshold?.color).toBe(RED);
expect(operator).toBe(AlertThresholdOperator.IS_ABOVE);
});
it('sorts unknown/custom colors last', () => {
const { threshold } = deriveAlertPrefill(
makeNumberPanel([
{ color: '#123456', value: 5, operator: 'above' },
{ color: ORANGE, value: 80, operator: 'above' },
]),
makeQuery(),
);
expect(threshold?.thresholdValue).toBe(80);
});
it.each([
['above', AlertThresholdOperator.IS_ABOVE],
['above_or_equal', AlertThresholdOperator.IS_ABOVE],
['below', AlertThresholdOperator.IS_BELOW],
['below_or_equal', AlertThresholdOperator.IS_BELOW],
['equal', AlertThresholdOperator.IS_EQUAL_TO],
['not_equal', AlertThresholdOperator.IS_NOT_EQUAL_TO],
])('maps panel operator %s → %s', (op, expected) => {
const { operator } = deriveAlertPrefill(
makeNumberPanel([{ color: RED, value: 1, operator: op }]),
makeQuery(),
);
expect(operator).toBe(expected);
});
it('builds an alert-shaped critical threshold', () => {
const { threshold } = deriveAlertPrefill(
makeNumberPanel([{ color: RED, value: 90, unit: 'ms', operator: 'above' }]),
makeQuery(),
);
expect(threshold).toMatchObject({
label: 'critical',
thresholdValue: 90,
recoveryThresholdValue: null,
unit: 'ms',
channels: [],
color: RED,
});
expect(typeof threshold?.id).toBe('string');
});
it('falls back to the panel unit when the threshold has none', () => {
const { threshold } = deriveAlertPrefill(
makeNumberPanel([{ color: RED, value: 90, operator: 'above' }]),
makeQuery(),
'bytes',
);
expect(threshold?.unit).toBe('bytes');
});
});
describe('label-variant thresholds (TimeSeries/Bar)', () => {
it('seeds the target but leaves operator/matchType default (no operator, no reduceTo)', () => {
const { threshold, operator, matchType } = deriveAlertPrefill(
makeTimeSeriesPanel([{ color: RED, value: 100 }]),
makeQuery(),
);
expect(threshold?.thresholdValue).toBe(100);
expect(operator).toBeUndefined();
expect(matchType).toBeUndefined();
});
});
it('returns an empty prefill for a panel with neither reduceTo nor thresholds', () => {
expect(deriveAlertPrefill(emptyNumberPanel, makeQuery())).toStrictEqual({});
});
});

View File

@@ -11,6 +11,8 @@ import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContain
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { deriveAlertPrefill, PanelAlertPrefill } from './deriveAlertPrefill';
/** The panel's configured y-axis unit, for the kinds that carry one. */
export function readPanelUnit(
plugin: DashboardtypesPanelPluginDTO,
@@ -27,14 +29,14 @@ export function readPanelUnit(
}
/**
* Assembles the `/alerts/new` URL from a ready V1 `Query`: the alert page reads it
* from `compositeQuery`, tagged with the panel type, entity version, and a
* `dashboards` source.
* Assembles the `/alerts/new` URL from a ready V1 `Query`. An optional `prefill`
* (issue #5291) also seeds the alert condition — occurrence, operator, threshold.
*/
export function buildAlertUrl(
query: Query,
panelType: PANEL_TYPES,
unit?: string,
prefill?: PanelAlertPrefill,
): string {
if (unit) {
// eslint-disable-next-line no-param-reassign
@@ -50,6 +52,17 @@ export function buildAlertUrl(
params.set(QueryParams.version, ENTITY_VERSION_V5);
params.set(QueryParams.source, YAxisSource.DASHBOARDS);
if (prefill?.threshold) {
// Raw JSON: URLSearchParams encodes once; the alert page uses a bare JSON.parse.
params.set(QueryParams.thresholds, JSON.stringify([prefill.threshold]));
}
if (prefill?.matchType) {
params.set(QueryParams.matchType, prefill.matchType);
}
if (prefill?.operator) {
params.set(QueryParams.compareOp, prefill.operator);
}
return `${ROUTES.ALERTS_NEW}?${params.toString()}`;
}
@@ -62,5 +75,11 @@ export function buildAlertUrl(
export function buildCreateAlertUrl(panel: DashboardtypesPanelDTO): string {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const query = fromPerses(panel.spec.queries, panelType);
return buildAlertUrl(query, panelType, readPanelUnit(panel.spec.plugin));
const unit = readPanelUnit(panel.spec.plugin);
return buildAlertUrl(
query,
panelType,
unit,
deriveAlertPrefill(panel, query, unit),
);
}

View File

@@ -0,0 +1,158 @@
/** Derives an alert-condition seed from a panel's Reduce To + Thresholds (issue #5291). */
import type {
DashboardtypesComparisonOperatorDTO,
DashboardtypesPanelDTO,
DashboardtypesPanelPluginDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
Threshold,
} from 'container/CreateAlertV2/context/types';
import type { MetricAggregation } from 'types/api/v5/queryRange';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { ReduceOperators } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
export interface PanelAlertPrefill {
matchType?: AlertThresholdMatchType;
operator?: AlertThresholdOperator;
threshold?: Threshold;
}
// Most-dangerous first, matching the panel editor palette; unknown colors sort last.
const THRESHOLD_COLOR_DANGER_ORDER = [
'#f1575f',
'#f5b225',
'#2bb673',
'#4e74f8',
];
interface NormalizedPanelThreshold {
color: string;
value: number;
unit?: string;
operator?: DashboardtypesComparisonOperatorDTO;
}
export function reduceToMatchType(
reduceTo: ReduceOperators | undefined,
): AlertThresholdMatchType | undefined {
switch (reduceTo) {
case ReduceOperators.SUM:
return AlertThresholdMatchType.IN_TOTAL;
case ReduceOperators.AVG:
return AlertThresholdMatchType.ON_AVERAGE;
default:
return undefined;
}
}
function readReduceTo(
queryData: Query['builder']['queryData'][number],
): ReduceOperators | undefined {
const aggregationReduceTo = (
queryData.aggregations?.[0] as MetricAggregation | undefined
)?.reduceTo;
// `||` not `??`: the aggregation often holds an empty-string placeholder.
return aggregationReduceTo || queryData.reduceTo || undefined;
}
export function uniformReduceTo(query: Query): ReduceOperators | undefined {
const { queryData } = query.builder;
const first = queryData[0] && readReduceTo(queryData[0]);
if (!first) {
return undefined;
}
return queryData.every((data) => readReduceTo(data) === first)
? first
: undefined;
}
function readPanelThresholds(
plugin: DashboardtypesPanelPluginDTO,
): NormalizedPanelThreshold[] {
switch (plugin.kind) {
case 'signoz/TimeSeriesPanel':
case 'signoz/BarChartPanel':
return (plugin.spec.thresholds ?? []).map((t) => ({
color: t.color,
value: t.value,
unit: t.unit,
}));
case 'signoz/NumberPanel':
return (plugin.spec.thresholds ?? []).map((t) => ({
color: t.color,
value: t.value,
unit: t.unit,
operator: t.operator,
}));
default:
return [];
}
}
function colorRank(color: string): number {
const index = THRESHOLD_COLOR_DANGER_ORDER.indexOf(color.toLowerCase());
return index === -1 ? THRESHOLD_COLOR_DANGER_ORDER.length : index;
}
function pickHighestDanger(
thresholds: NormalizedPanelThreshold[],
): NormalizedPanelThreshold | undefined {
return [...thresholds].sort(
(a, b) => colorRank(a.color) - colorRank(b.color),
)[0];
}
function panelOperatorToAlertOperator(
operator: DashboardtypesComparisonOperatorDTO | undefined,
): AlertThresholdOperator | undefined {
switch (operator) {
case 'above':
case 'above_or_equal':
return AlertThresholdOperator.IS_ABOVE;
case 'below':
case 'below_or_equal':
return AlertThresholdOperator.IS_BELOW;
case 'equal':
return AlertThresholdOperator.IS_EQUAL_TO;
case 'not_equal':
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
default:
return undefined;
}
}
export function deriveAlertPrefill(
panel: DashboardtypesPanelDTO,
query: Query,
panelUnit?: string,
): PanelAlertPrefill {
const prefill: PanelAlertPrefill = {};
// Reduce To is user-facing only on the Value panel; elsewhere it's a default.
if (panel.spec.plugin.kind === 'signoz/NumberPanel') {
const matchType = reduceToMatchType(uniformReduceTo(query));
if (matchType) {
prefill.matchType = matchType;
}
}
const top = pickHighestDanger(readPanelThresholds(panel.spec.plugin));
if (top) {
prefill.operator = panelOperatorToAlertOperator(top.operator);
prefill.threshold = {
id: uuid(),
label: 'critical',
thresholdValue: top.value,
recoveryThresholdValue: null,
unit: top.unit ?? panelUnit ?? '',
channels: [],
color: top.color,
};
}
return prefill;
}

View File

@@ -4,13 +4,26 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/gorilla/mux"
)
func telemetryReadScopes() []string {
return []string{
coretypes.ResourceTelemetryResourceLogs.Scope(coretypes.VerbRead),
coretypes.ResourceTelemetryResourceTraces.Scope(coretypes.VerbRead),
coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead),
coretypes.ResourceTelemetryResourceAuditLogs.Scope(coretypes.VerbRead),
coretypes.ResourceTelemetryResourceMeterMetrics.Scope(coretypes.VerbRead),
}
}
func (provider *provider) addQuerierRoutes(router *mux.Router) error {
if err := router.Handle("/api/v5/query_range", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.QueryRange), handler.OpenAPIDef{
if err := router.Handle("/api/v5/query_range", handler.New(provider.authzMiddleware.CheckResources(provider.querierHandler.QueryRange, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName), handler.OpenAPIDef{
ID: "QueryRangeV5",
Tags: []string{"querier"},
Summary: "Query range",
@@ -446,12 +459,17 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes(telemetryReadScopes()),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.QueryRangeResources,
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v5/query_range/preview", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.QueryRangePreview), handler.OpenAPIDef{
if err := router.Handle("/api/v5/query_range/preview", handler.New(provider.authzMiddleware.CheckResources(provider.querierHandler.QueryRangePreview, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName), handler.OpenAPIDef{
ID: "QueryRangePreviewV5",
Tags: []string{"querier"},
Summary: "Query range preview",
@@ -463,8 +481,13 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes(telemetryReadScopes()),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.QueryRangeResources,
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}

View File

@@ -1,6 +1,9 @@
package handler
import "github.com/SigNoz/signoz/pkg/types/coretypes"
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/coretypes"
)
type ResourceDef interface {
// resolveRequest is unexported to seal the interface. It returns a slice so a
@@ -97,3 +100,31 @@ func (def AttachDetachParentChildResourceDef) resolveRequest(ec coretypes.Extrac
),
}
}
type TelemetryResourceDef struct {
Verb coretypes.Verb
Category coretypes.ActionCategory
Selector coretypes.SelectorFunc
Resources coretypes.ResourceExtractor
}
func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
refs, err := def.Resources(ec)
if err != nil {
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(def.Verb, def.Category, err)}
}
if len(refs) == 0 {
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(
def.Verb,
def.Category,
errors.NewInvalidInputf(errors.CodeInvalidInput, "request resolved to no resources"),
)}
}
resolved := make([]coretypes.ResolvedResource, 0, len(refs))
for _, ref := range refs {
resolved = append(resolved, coretypes.NewResolvedResourceWithID(def.Verb, def.Category, ref.Resource, ref.ID, def.Selector))
}
return resolved
}

View File

@@ -118,6 +118,10 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
extractorCtx := coretypes.ExtractorContext{Request: req, ResponseBody: writer.BodyBytes()}
for _, resource := range resolved {
if err := resource.Err(); err != nil {
continue
}
resource.ResolveResponse(extractorCtx)
verb, category := resource.Verb(), resource.Category()

View File

@@ -0,0 +1,220 @@
package querybuilder
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/coretypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/tidwall/gjson"
)
var telemetryGrantKeys = map[string]struct{}{
"service.name": {},
}
const telemetryValueSafeBytes = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-"
func EscapeTelemetryValue(value string) string {
var escaped strings.Builder
for _, character := range []byte(value) {
if strings.IndexByte(telemetryValueSafeBytes, character) >= 0 {
escaped.WriteByte(character)
continue
}
escaped.WriteString(fmt.Sprintf("%%%02X", character))
}
return escaped.String()
}
func TelemetrySelector(_ context.Context, resource coretypes.Resource, id string, _ valuer.UUID) ([]coretypes.Selector, error) {
values := []string{id}
segments := strings.Split(id, "/")
for level := len(segments) - 1; level >= 1; level-- {
value := strings.Join(segments[:level], "/") + "/" + coretypes.WildCardSelectorString
if value == id {
continue
}
values = append(values, value)
}
if id != coretypes.WildCardSelectorString {
values = append(values, coretypes.WildCardSelectorString)
}
selectors := make([]coretypes.Selector, 0, len(values))
for _, value := range values {
selector, err := resource.Type().Selector(value)
if err != nil {
return nil, err
}
selectors = append(selectors, selector)
}
return selectors, nil
}
func QueryRangeResources(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
queries := gjson.GetBytes(ec.RequestBody, "compositeQuery.queries")
if !queries.IsArray() || len(queries.Array()) == 0 {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "atleast one query is required")
}
variables, err := queryRangeVariables(ec.RequestBody)
if err != nil {
return nil, err
}
refs := make([]coretypes.ResourceWithID, 0, len(queries.Array()))
seen := make(map[string]struct{})
for _, query := range queries.Array() {
queryRefs, err := resourcesForQuery(query, variables)
if err != nil {
return nil, err
}
for _, ref := range queryRefs {
key := ref.Resource.Kind().String() + ":" + ref.ID
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
refs = append(refs, ref)
}
}
return refs, nil
}
func queryRangeVariables(body []byte) (map[string]qbtypes.VariableItem, error) {
variables := make(map[string]qbtypes.VariableItem)
raw := gjson.GetBytes(body, "variables")
if !raw.Exists() {
return variables, nil
}
if err := json.Unmarshal([]byte(raw.Raw), &variables); err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid variables in query range request")
}
return variables, nil
}
func resourcesForQuery(query gjson.Result, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
queryType := query.Get("type").String()
typeWildcard := queryType + "/" + coretypes.WildCardSelectorString
switch queryType {
case qbtypes.QueryTypeBuilder.StringValue(), qbtypes.QueryTypeSubQuery.StringValue():
return resourcesForBuilderQuery(queryType, query.Get("spec"), variables)
case qbtypes.QueryTypePromQL.StringValue():
return []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: typeWildcard}}, nil
case qbtypes.QueryTypeClickHouseSQL.StringValue():
return []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: typeWildcard},
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: typeWildcard},
{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: typeWildcard},
{Resource: coretypes.ResourceTelemetryResourceMeterMetrics, ID: typeWildcard},
}, nil
case qbtypes.QueryTypeFormula.StringValue(), qbtypes.QueryTypeJoin.StringValue(), qbtypes.QueryTypeTraceOperator.StringValue():
return nil, nil
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported query type %q", queryType)
}
}
func resourcesForBuilderQuery(queryType string, spec gjson.Result, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
resource, err := builderQueryResource(spec)
if err != nil {
return nil, err
}
ids, err := builderQuerySelectors(queryType, spec.Get("filter.expression").String(), variables)
if err != nil {
return nil, err
}
refs := make([]coretypes.ResourceWithID, 0, len(ids))
for _, id := range ids {
refs = append(refs, coretypes.ResourceWithID{Resource: resource, ID: id})
}
return refs, nil
}
func builderQueryResource(spec gjson.Result) (coretypes.Resource, error) {
source := spec.Get("source").String()
switch spec.Get("signal").String() {
case telemetrytypes.SignalTraces.StringValue():
return coretypes.ResourceTelemetryResourceTraces, nil
case telemetrytypes.SignalLogs.StringValue():
if source == telemetrytypes.SourceAudit.StringValue() {
return coretypes.ResourceTelemetryResourceAuditLogs, nil
}
return coretypes.ResourceTelemetryResourceLogs, nil
case telemetrytypes.SignalMetrics.StringValue():
if source == telemetrytypes.SourceMeter.StringValue() {
return coretypes.ResourceTelemetryResourceMeterMetrics, nil
}
return coretypes.ResourceTelemetryResourceMetrics, nil
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported signal %q", spec.Get("signal").String())
}
}
func builderQuerySelectors(queryType, expression string, variables map[string]qbtypes.VariableItem) ([]string, error) {
typeWildcard := queryType + "/" + coretypes.WildCardSelectorString
if strings.TrimSpace(expression) == "" {
return []string{typeWildcard}, nil
}
normalized, err := NormalizeWhereClause(expression, variables)
if err != nil {
return nil, err
}
ids := make([]string, 0)
for _, condition := range normalized.Conditions {
if !condition.TopLevel {
continue
}
key, ok := canonicalTelemetryGrantKey(condition.Key)
if !ok {
continue
}
if condition.Operator == "=" || condition.Operator == "IN" {
for _, value := range condition.Values {
ids = append(ids, queryType+"/"+key+"/"+EscapeTelemetryValue(value))
}
}
}
if len(ids) == 0 {
return []string{typeWildcard}, nil
}
return ids, nil
}
func canonicalTelemetryGrantKey(keyText string) (string, bool) {
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(keyText)
if fieldKey.FieldContext != telemetrytypes.FieldContextUnspecified && fieldKey.FieldContext != telemetrytypes.FieldContextResource {
return "", false
}
if _, ok := telemetryGrantKeys[fieldKey.Name]; !ok {
return "", false
}
return fieldKey.Name, true
}

View File

@@ -0,0 +1,184 @@
package querybuilder
import (
"context"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func builderQueryBody(signal, filterExpression string) string {
return `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"` + signal + `","filter":{"expression":"` + filterExpression + `"}}}]}}`
}
func TestQueryRangeResources(t *testing.T) {
testCases := []struct {
name string
body string
expected []coretypes.ResourceWithID
}{
{
name: "top level service equality",
body: builderQueryBody("logs", "service.name = 'checkout' AND status = 500"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/checkout"},
},
},
{
name: "resource prefixed service key",
body: builderQueryBody("traces", "resource.service.name = 'checkout'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/service.name/checkout"},
},
},
{
name: "in atom requires every value",
body: builderQueryBody("logs", "service.name IN ('b', 'a')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/b"},
},
},
{
name: "multiple equality atoms each require a grant",
body: builderQueryBody("logs", "service.name = 'b' AND service.name = 'a'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/b"},
},
},
{
name: "no filter expression",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs"}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "service atom under or does not qualify",
body: builderQueryBody("logs", "service.name = 'a' OR status = 500"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "negated service atom does not qualify",
body: builderQueryBody("logs", "NOT service.name = 'a'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "service inequality does not qualify",
body: builderQueryBody("logs", "service.name != 'a'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "unsafe value bytes are escaped",
body: builderQueryBody("logs", "service.name = 'check out/2'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/check%20out%2F2"},
},
},
{
name: "audit source maps to audit logs resource",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit","filter":{"expression":"service.name = 'a'"}}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/service.name/a"},
},
},
{
name: "promql is wildcard only",
body: `{"compositeQuery":{"queries":[{"type":"promql","spec":{"query":"up"}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: "promql/*"},
},
},
{
name: "clickhouse sql covers all signals",
body: `{"compositeQuery":{"queries":[{"type":"clickhouse_sql","spec":{"query":"SELECT 1"}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "clickhouse_sql/*"},
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "clickhouse_sql/*"},
{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: "clickhouse_sql/*"},
{Resource: coretypes.ResourceTelemetryResourceMeterMetrics, ID: "clickhouse_sql/*"},
},
},
{
name: "formula produces no resources",
body: `{"compositeQuery":{"queries":[{"type":"builder_formula","spec":{"expression":"A/B"}}]}}`,
expected: []coretypes.ResourceWithID{},
},
{
name: "trace operator rides on its referenced queries",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"name":"A","signal":"traces","disabled":true,"filter":{"expression":"service.name = 'checkout'"}}},{"type":"builder_query","spec":{"name":"B","signal":"traces","disabled":true,"filter":{"expression":"service.name = 'checkout' AND has_error = true"}}},{"type":"builder_trace_operator","spec":{"name":"T1","expression":"A => B","returnSpansFrom":"A"}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/service.name/checkout"},
},
},
{
name: "variable substitution qualifies",
body: `{"variables":{"svc":{"value":"checkout"}},"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name = $svc"}}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/checkout"},
},
},
{
name: "duplicate queries dedupe",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name = 'a'"}}},{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name='a'"}}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
refs, err := QueryRangeResources(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
require.NoError(t, err)
assert.Equal(t, testCase.expected, refs)
})
}
}
func TestQueryRangeResourcesErrors(t *testing.T) {
bodies := []string{
`{"compositeQuery":{"queries":[]}}`,
`{}`,
builderQueryBody("logs", "service.name = "),
`{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"unknown"}}]}}`,
`{"compositeQuery":{"queries":[{"type":"unknown_type"}]}}`,
}
for _, body := range bodies {
_, err := QueryRangeResources(coretypes.ExtractorContext{RequestBody: []byte(body)})
assert.Error(t, err, "body %s", body)
}
}
func TestTelemetrySelector(t *testing.T) {
orgID := valuer.GenerateUUID()
selectorValues := func(id string) []string {
selectors, err := TelemetrySelector(context.Background(), coretypes.ResourceTelemetryResourceLogs, id, orgID)
require.NoError(t, err)
values := make([]string, 0, len(selectors))
for _, selector := range selectors {
values = append(values, selector.String())
}
return values
}
assert.Equal(t, []string{"builder_query/service.name/a", "builder_query/service.name/*", "builder_query/*", "*"}, selectorValues("builder_query/service.name/a"))
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query/*"))
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql/*"))
_, err := TelemetrySelector(context.Background(), coretypes.ResourceTelemetryResourceLogs, strings.Repeat("a", 256), orgID)
assert.Error(t, err)
}

View File

@@ -0,0 +1,558 @@
package querybuilder
import (
"fmt"
"sort"
"strconv"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/antlr4-go/antlr/v4"
)
const WhereClauseOperatorFullText = "FULLTEXT"
type NormalizedWhereClause struct {
Expression string
Conditions []WhereClauseCondition
}
type WhereClauseCondition struct {
Key string
Operator string
Values []string
Negated bool
TopLevel bool
}
type joinKind int
const (
joinKindNone joinKind = iota
joinKindAnd
joinKindOr
)
type normalizedPart struct {
text string
join joinKind
skipped bool
}
type normalizedValue struct {
text string
raw string
}
type whereClauseNormalizer struct {
variables map[string]qbtypes.VariableItem
conditions []WhereClauseCondition
negated bool
orDepth int
errors []string
}
func NormalizeWhereClause(expression string, variables map[string]qbtypes.VariableItem) (*NormalizedWhereClause, error) {
input := antlr.NewInputStream(expression)
lexer := grammar.NewFilterQueryLexer(input)
lexerErrorListener := NewErrorListener()
lexer.RemoveErrorListeners()
lexer.AddErrorListener(lexerErrorListener)
tokens := antlr.NewCommonTokenStream(lexer, 0)
parser := grammar.NewFilterQueryParser(tokens)
parserErrorListener := NewErrorListener()
parser.RemoveErrorListeners()
parser.AddErrorListener(parserErrorListener)
tree := parser.Query()
syntaxErrors := append(lexerErrorListener.SyntaxErrors, parserErrorListener.SyntaxErrors...)
if len(syntaxErrors) > 0 {
combinedErrors := errors.Newf(
errors.TypeInvalidInput,
errors.CodeInvalidInput,
"Found %d syntax errors while parsing the filter expression.",
len(syntaxErrors),
)
additionals := make([]string, 0, len(syntaxErrors))
for _, syntaxError := range syntaxErrors {
if syntaxError.Error() != "" {
additionals = append(additionals, syntaxError.Error())
}
}
return nil, combinedErrors.WithAdditional(additionals...).WithUrl(searchTroubleshootingGuideURL)
}
visitor := &whereClauseNormalizer{
variables: variables,
conditions: make([]WhereClauseCondition, 0),
}
part := visitor.visitQuery(tree)
if len(visitor.errors) > 0 {
combinedErrors := errors.Newf(
errors.TypeInvalidInput,
errors.CodeInvalidInput,
"Found %d errors while parsing the filter expression.",
len(visitor.errors),
)
return nil, combinedErrors.WithAdditional(visitor.errors...).WithUrl(searchTroubleshootingGuideURL)
}
if part.skipped {
return &NormalizedWhereClause{Expression: "", Conditions: make([]WhereClauseCondition, 0)}, nil
}
sort.Slice(visitor.conditions, func(i, j int) bool {
return visitor.conditions[i].sortKey() < visitor.conditions[j].sortKey()
})
return &NormalizedWhereClause{Expression: part.text, Conditions: visitor.conditions}, nil
}
func (condition WhereClauseCondition) sortKey() string {
return condition.Key + "|" + condition.Operator + "|" + strings.Join(condition.Values, ",") + "|" + strconv.FormatBool(condition.Negated) + "|" + strconv.FormatBool(condition.TopLevel)
}
func (visitor *whereClauseNormalizer) visitQuery(ctx grammar.IQueryContext) normalizedPart {
if ctx.Expression() == nil {
return normalizedPart{skipped: true}
}
return visitor.visitOrExpression(ctx.Expression().OrExpression())
}
func (visitor *whereClauseNormalizer) visitOrExpression(ctx grammar.IOrExpressionContext) normalizedPart {
andExpressions := ctx.AllAndExpression()
if len(andExpressions) > 1 {
visitor.orDepth++
defer func() { visitor.orDepth-- }()
}
parts := make([]normalizedPart, 0, len(andExpressions))
for _, andExpression := range andExpressions {
part := visitor.visitAndExpression(andExpression)
if part.skipped {
continue
}
parts = append(parts, part)
}
if len(parts) == 0 {
return normalizedPart{skipped: true}
}
parts = sortAndDedupeNormalizedParts(parts)
if len(parts) == 1 {
return parts[0]
}
texts := make([]string, len(parts))
for index, part := range parts {
texts[index] = part.text
}
return normalizedPart{text: strings.Join(texts, " OR "), join: joinKindOr}
}
func (visitor *whereClauseNormalizer) visitAndExpression(ctx grammar.IAndExpressionContext) normalizedPart {
unaryExpressions := ctx.AllUnaryExpression()
parts := make([]normalizedPart, 0, len(unaryExpressions))
for _, unaryExpression := range unaryExpressions {
part := visitor.visitUnaryExpression(unaryExpression)
if part.skipped {
continue
}
if part.join == joinKindOr {
part = normalizedPart{text: "(" + part.text + ")", join: joinKindNone}
}
parts = append(parts, part)
}
if len(parts) == 0 {
return normalizedPart{skipped: true}
}
parts = sortAndDedupeNormalizedParts(parts)
if len(parts) == 1 {
return parts[0]
}
texts := make([]string, len(parts))
for index, part := range parts {
texts[index] = part.text
}
return normalizedPart{text: strings.Join(texts, " AND "), join: joinKindAnd}
}
func (visitor *whereClauseNormalizer) visitUnaryExpression(ctx grammar.IUnaryExpressionContext) normalizedPart {
negated := ctx.NOT() != nil
if negated {
visitor.negated = !visitor.negated
}
part := visitor.visitPrimary(ctx.Primary())
if negated {
visitor.negated = !visitor.negated
if part.skipped {
return part
}
if part.join != joinKindNone {
return normalizedPart{text: "NOT (" + part.text + ")", join: joinKindNone}
}
return normalizedPart{text: "NOT " + part.text, join: joinKindNone}
}
return part
}
func (visitor *whereClauseNormalizer) visitPrimary(ctx grammar.IPrimaryContext) normalizedPart {
if ctx.OrExpression() != nil {
return visitor.visitOrExpression(ctx.OrExpression())
}
if ctx.Comparison() != nil {
return visitor.visitComparison(ctx.Comparison())
}
if ctx.FunctionCall() != nil {
return normalizedPart{text: visitor.visitFunctionCall(ctx.FunctionCall())}
}
if ctx.FullText() != nil {
return normalizedPart{text: visitor.visitFullText(ctx.FullText())}
}
if ctx.Key() != nil {
return normalizedPart{text: visitor.fullTextTerm(ctx.Key().GetText())}
}
if ctx.Value() != nil {
value := visitor.normalizeValue(ctx.Value())
return normalizedPart{text: visitor.fullTextTerm(value.raw)}
}
return normalizedPart{skipped: true}
}
func (visitor *whereClauseNormalizer) visitComparison(ctx grammar.IComparisonContext) normalizedPart {
key := normalizeKeyText(ctx.Key().GetText())
if ctx.EXISTS() != nil {
operator := "EXISTS"
if ctx.NOT() != nil {
operator = "NOT EXISTS"
}
visitor.appendCondition(key, operator, nil)
return normalizedPart{text: key + " " + operator}
}
if ctx.InClause() != nil {
return visitor.visitInComparison(key, "IN", visitor.visitInValues(ctx.InClause().ValueList(), ctx.InClause().Value()))
}
if ctx.NotInClause() != nil {
return visitor.visitInComparison(key, "NOT IN", visitor.visitInValues(ctx.NotInClause().ValueList(), ctx.NotInClause().Value()))
}
if ctx.BETWEEN() != nil {
operator := "BETWEEN"
if ctx.NOT() != nil {
operator = "NOT BETWEEN"
}
values := ctx.AllValue()
low := visitor.normalizeValue(values[0])
high := visitor.normalizeValue(values[1])
visitor.appendCondition(key, operator, []string{low.raw, high.raw})
return normalizedPart{text: key + " " + operator + " " + low.text + " AND " + high.text}
}
operator := ""
switch {
case ctx.EQUALS() != nil:
operator = "="
case ctx.NOT_EQUALS() != nil, ctx.NEQ() != nil:
operator = "!="
case ctx.LT() != nil:
operator = "<"
case ctx.LE() != nil:
operator = "<="
case ctx.GT() != nil:
operator = ">"
case ctx.GE() != nil:
operator = ">="
case ctx.LIKE() != nil:
operator = "LIKE"
case ctx.ILIKE() != nil:
operator = "ILIKE"
case ctx.REGEXP() != nil:
operator = "REGEXP"
case ctx.CONTAINS() != nil:
operator = "CONTAINS"
}
if ctx.NOT() != nil {
operator = "NOT " + operator
}
value, skipped := visitor.substituteScalarVariable(visitor.normalizeValue(ctx.AllValue()[0]))
if skipped {
return normalizedPart{skipped: true}
}
visitor.appendCondition(key, operator, []string{value.raw})
return normalizedPart{text: key + " " + operator + " " + value.text}
}
func (visitor *whereClauseNormalizer) visitInComparison(key, operator string, values []normalizedValue) normalizedPart {
values, skipped := visitor.substituteListVariable(values)
if skipped {
return normalizedPart{skipped: true}
}
sort.Slice(values, func(i, j int) bool { return values[i].text < values[j].text })
texts := make([]string, 0, len(values))
raws := make([]string, 0, len(values))
for index, value := range values {
if index > 0 && value.text == values[index-1].text {
continue
}
texts = append(texts, value.text)
raws = append(raws, value.raw)
}
visitor.appendCondition(key, operator, raws)
return normalizedPart{text: key + " " + operator + " (" + strings.Join(texts, ", ") + ")"}
}
func (visitor *whereClauseNormalizer) visitInValues(valueList grammar.IValueListContext, value grammar.IValueContext) []normalizedValue {
values := make([]normalizedValue, 0)
if valueList != nil {
for _, valueCtx := range valueList.AllValue() {
values = append(values, visitor.normalizeValue(valueCtx))
}
return values
}
return append(values, visitor.normalizeValue(value))
}
func (visitor *whereClauseNormalizer) visitFunctionCall(ctx grammar.IFunctionCallContext) string {
functionName := ""
switch {
case ctx.HAS() != nil:
functionName = "has"
case ctx.HASANY() != nil:
functionName = "hasAny"
case ctx.HASALL() != nil:
functionName = "hasAll"
case ctx.HASTOKEN() != nil:
functionName = "hasToken"
}
key := ""
texts := make([]string, 0)
raws := make([]string, 0)
for index, param := range ctx.FunctionParamList().AllFunctionParam() {
switch {
case param.Key() != nil:
keyText := normalizeKeyText(param.Key().GetText())
if index == 0 {
key = keyText
} else {
raws = append(raws, keyText)
}
texts = append(texts, keyText)
case param.Value() != nil:
value := visitor.normalizeValue(param.Value())
texts = append(texts, value.text)
raws = append(raws, value.raw)
case param.Array() != nil:
arrayText, arrayRaws := visitor.visitArray(param.Array())
texts = append(texts, arrayText)
raws = append(raws, arrayRaws...)
}
}
visitor.appendCondition(key, functionName, raws)
return functionName + "(" + strings.Join(texts, ", ") + ")"
}
func (visitor *whereClauseNormalizer) visitArray(ctx grammar.IArrayContext) (string, []string) {
texts := make([]string, 0)
raws := make([]string, 0)
for _, valueCtx := range ctx.ValueList().AllValue() {
value := visitor.normalizeValue(valueCtx)
texts = append(texts, value.text)
raws = append(raws, value.raw)
}
return "[" + strings.Join(texts, ", ") + "]", raws
}
func (visitor *whereClauseNormalizer) visitFullText(ctx grammar.IFullTextContext) string {
if ctx.QUOTED_TEXT() != nil {
return visitor.fullTextTerm(trimQuotes(ctx.QUOTED_TEXT().GetText()))
}
return visitor.fullTextTerm(ctx.FREETEXT().GetText())
}
func (visitor *whereClauseNormalizer) fullTextTerm(term string) string {
visitor.appendCondition("", WhereClauseOperatorFullText, []string{term})
return quoteValue(term)
}
func (visitor *whereClauseNormalizer) normalizeValue(ctx grammar.IValueContext) normalizedValue {
switch {
case ctx.QUOTED_TEXT() != nil:
raw := trimQuotes(ctx.QUOTED_TEXT().GetText())
return normalizedValue{text: quoteValue(raw), raw: raw}
case ctx.NUMBER() != nil:
text := ctx.NUMBER().GetText()
return normalizedValue{text: text, raw: text}
case ctx.BOOL() != nil:
text := strings.ToLower(ctx.BOOL().GetText())
return normalizedValue{text: text, raw: text}
default:
raw := ctx.KEY().GetText()
if strings.HasPrefix(raw, "$") {
return normalizedValue{text: raw, raw: raw}
}
return normalizedValue{text: quoteValue(raw), raw: raw}
}
}
func (visitor *whereClauseNormalizer) appendCondition(key, operator string, values []string) {
if values == nil {
values = make([]string, 0)
}
visitor.conditions = append(visitor.conditions, WhereClauseCondition{
Key: key,
Operator: operator,
Values: values,
Negated: visitor.negated,
TopLevel: visitor.orDepth == 0 && !visitor.negated,
})
}
func (visitor *whereClauseNormalizer) substituteScalarVariable(value normalizedValue) (normalizedValue, bool) {
variableItem, ok := visitor.resolveVariable(value.raw)
if !ok {
return value, false
}
if skipped := visitor.errIfSkippedOrEmpty(variableItem, value.raw); skipped {
return normalizedValue{}, true
}
switch variableValues := variableItem.Value.(type) {
case []any:
return formatVariableValue(variableValues[0]), false
case any:
return formatVariableValue(variableValues), false
}
return value, false
}
func (visitor *whereClauseNormalizer) substituteListVariable(values []normalizedValue) ([]normalizedValue, bool) {
if len(values) != 1 {
return values, false
}
variableItem, ok := visitor.resolveVariable(values[0].raw)
if !ok {
return values, false
}
if skipped := visitor.errIfSkippedOrEmpty(variableItem, values[0].raw); skipped {
return nil, true
}
switch variableValues := variableItem.Value.(type) {
case []any:
substituted := make([]normalizedValue, 0, len(variableValues))
for _, variableValue := range variableValues {
substituted = append(substituted, formatVariableValue(variableValue))
}
return substituted, false
case any:
return []normalizedValue{formatVariableValue(variableValues)}, false
}
return values, false
}
func (visitor *whereClauseNormalizer) errIfSkippedOrEmpty(variableItem qbtypes.VariableItem, raw string) bool {
if variableItem.Type == qbtypes.DynamicVariableType {
if allValue, ok := variableItem.Value.(string); ok && allValue == "__all__" {
return true
}
}
if variableValues, ok := variableItem.Value.([]any); ok && len(variableValues) == 0 {
visitor.errors = append(visitor.errors, fmt.Sprintf("malformed request payload: variable `%s` used in expression has an empty list value", strings.TrimPrefix(raw, "$")))
return true
}
return false
}
func (visitor *whereClauseNormalizer) resolveVariable(raw string) (qbtypes.VariableItem, bool) {
if len(visitor.variables) == 0 {
return qbtypes.VariableItem{}, false
}
variableItem, ok := visitor.variables[raw]
if !ok && len(raw) > 0 {
variableItem, ok = visitor.variables[raw[1:]]
}
return variableItem, ok
}
func formatVariableValue(value any) normalizedValue {
switch typed := value.(type) {
case string:
return normalizedValue{text: quoteValue(typed), raw: typed}
case bool:
text := strconv.FormatBool(typed)
return normalizedValue{text: text, raw: text}
default:
text := fmt.Sprintf("%v", typed)
return normalizedValue{text: text, raw: text}
}
}
func normalizeKeyText(keyText string) string {
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(keyText)
return telemetrytypes.TelemetryFieldKeyToText(&fieldKey)
}
func quoteValue(value string) string {
escaped := strings.ReplaceAll(value, `\`, `\\`)
escaped = strings.ReplaceAll(escaped, `'`, `\'`)
return "'" + escaped + "'"
}
func sortAndDedupeNormalizedParts(parts []normalizedPart) []normalizedPart {
sort.Slice(parts, func(i, j int) bool { return parts[i].text < parts[j].text })
deduped := parts[:0]
for index, part := range parts {
if index > 0 && part.text == parts[index-1].text {
continue
}
deduped = append(deduped, part)
}
return deduped
}

View File

@@ -0,0 +1,421 @@
package querybuilder
import (
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNormalizeWhereClauseEquivalenceClasses(t *testing.T) {
testCases := []struct {
name string
expressions []string
expected string
}{
{
name: "spacing and keyword case",
expressions: []string{
"service.name = 'frontend' AND status = 200",
"service.name='frontend' and status=200",
"service.name = 'frontend' AND status = 200",
"service.name = frontend AND status = 200",
},
expected: "service.name = 'frontend' AND status = 200",
},
{
name: "operand order",
expressions: []string{
"a = 1 AND b = 2",
"b = 2 AND a = 1",
},
expected: "a = 1 AND b = 2",
},
{
name: "implicit and explicit AND",
expressions: []string{
"a = 1 b = 2",
"a = 1 AND b = 2",
},
expected: "a = 1 AND b = 2",
},
{
name: "quote styles",
expressions: []string{
`a = "frontend"`,
"a = 'frontend'",
},
expected: "a = 'frontend'",
},
{
name: "redundant parentheses",
expressions: []string{
"(a = 1)",
"a = 1",
"((a = 1))",
},
expected: "a = 1",
},
{
name: "in clause forms and value order",
expressions: []string{
"a IN (1, 2)",
"a IN [2, 1]",
"a in (2, 1, 1)",
},
expected: "a IN (1, 2)",
},
{
name: "single value in",
expressions: []string{
"a IN 1",
"a IN (1)",
"a IN [1]",
},
expected: "a IN (1)",
},
{
name: "operator aliases",
expressions: []string{
"a == 1",
"a = 1",
},
expected: "a = 1",
},
{
name: "not equals aliases",
expressions: []string{
"a <> 1",
"a != 1",
},
expected: "a != 1",
},
{
name: "duplicate siblings",
expressions: []string{
"a = 1 AND a = 1",
"a = 1",
},
expected: "a = 1",
},
{
name: "grouped or under and",
expressions: []string{
"a = 1 AND (b = 2 OR c = 3)",
"(c = 3 OR b = 2) AND a = 1",
},
expected: "(b = 2 OR c = 3) AND a = 1",
},
{
name: "exists spellings",
expressions: []string{
"service.name EXISTS",
"service.name exists",
"service.name EXIST",
},
expected: "service.name EXISTS",
},
{
name: "contains spellings",
expressions: []string{
"body CONTAINS 'error'",
"body contain 'error'",
},
expected: "body CONTAINS 'error'",
},
{
name: "full text term forms",
expressions: []string{
`"panic"`,
"'panic'",
"panic",
},
expected: "'panic'",
},
{
name: "not without parens",
expressions: []string{
"NOT a = 1",
"not (a = 1)",
},
expected: "NOT a = 1",
},
{
name: "not over grouped or",
expressions: []string{
"NOT (b = 2 OR a = 1)",
"not (a = 1 or b = 2)",
},
expected: "NOT (a = 1 OR b = 2)",
},
{
name: "function name case",
expressions: []string{
"HAS(tags, 'x')",
"has(tags, 'x')",
},
expected: "has(tags, 'x')",
},
{
name: "boolean case",
expressions: []string{
"a = TRUE",
"a = true",
},
expected: "a = true",
},
{
name: "between",
expressions: []string{
"duration BETWEEN 1 AND 10",
"duration between 1 and 10",
},
expected: "duration BETWEEN 1 AND 10",
},
{
name: "not in",
expressions: []string{
"a NOT IN (2, 1)",
"a not in [1, 2]",
},
expected: "a NOT IN (1, 2)",
},
{
name: "key with datatype annotation",
expressions: []string{
"resource.service.name:string = 'x'",
},
expected: "resource.service.name:string = 'x'",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
for _, expression := range testCase.expressions {
canonical, err := NormalizeWhereClause(expression, nil)
require.NoError(t, err, "expression %q", expression)
assert.Equal(t, testCase.expected, canonical.Expression, "expression %q", expression)
}
})
}
}
func TestNormalizeWhereClauseNonEquivalence(t *testing.T) {
testCases := []struct {
name string
left string
right string
}{
{name: "different values", left: "a = 1", right: "a = 2"},
{name: "different keys", left: "a = 1", right: "b = 1"},
{name: "different operators", left: "a = 1", right: "a != 1"},
{name: "no semantic rewrite of not", left: "NOT a = 1", right: "a != 1"},
{name: "number literals as authored", left: "a = 1.0", right: "a = 1"},
{name: "between bounds are ordered", left: "a BETWEEN 1 AND 10", right: "a BETWEEN 10 AND 1"},
{name: "function params are ordered", left: "has(tags, 'x')", right: "has('x', tags)"},
{name: "and vs or", left: "a = 1 AND b = 2", right: "a = 1 OR b = 2"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
left, err := NormalizeWhereClause(testCase.left, nil)
require.NoError(t, err)
right, err := NormalizeWhereClause(testCase.right, nil)
require.NoError(t, err)
assert.NotEqual(t, left.Expression, right.Expression)
})
}
}
func TestNormalizeWhereClauseAtoms(t *testing.T) {
canonical, err := NormalizeWhereClause("NOT (a = 1 OR b IN ('y', 'x')) AND service.name EXISTS AND hasAny(tags, ['p', 'q']) AND \"panic\"", nil)
require.NoError(t, err)
expected := []WhereClauseCondition{
{Key: "a", Operator: "=", Values: []string{"1"}, Negated: true},
{Key: "b", Operator: "IN", Values: []string{"x", "y"}, Negated: true},
{Key: "service.name", Operator: "EXISTS", Values: []string{}, Negated: false, TopLevel: true},
{Key: "tags", Operator: "hasAny", Values: []string{"p", "q"}, Negated: false, TopLevel: true},
{Key: "", Operator: WhereClauseOperatorFullText, Values: []string{"panic"}, Negated: false, TopLevel: true},
}
assert.ElementsMatch(t, expected, canonical.Conditions)
}
func TestNormalizeWhereClauseTopLevel(t *testing.T) {
testCases := []struct {
name string
expression string
expected map[string]bool
}{
{
name: "and siblings are top level",
expression: "service.name = 'a' AND status = 500",
expected: map[string]bool{"service.name": true, "status": true},
},
{
name: "or branches are not top level",
expression: "service.name = 'a' OR status = 500",
expected: map[string]bool{"service.name": false, "status": false},
},
{
name: "and sibling stays top level next to a grouped or",
expression: "service.name = 'a' AND (x = 1 OR y = 2)",
expected: map[string]bool{"service.name": true, "x": false, "y": false},
},
{
name: "parenthesized pure and group stays top level",
expression: "(service.name = 'a' AND b = 2) AND c = 3",
expected: map[string]bool{"service.name": true, "b": true, "c": true},
},
{
name: "negated condition is not top level",
expression: "NOT service.name = 'a' AND status = 500",
expected: map[string]bool{"service.name": false, "status": true},
},
{
name: "double negation restores top level",
expression: "NOT (NOT (service.name = 'a'))",
expected: map[string]bool{"service.name": true},
},
{
name: "in condition under and is top level",
expression: "service.name IN ('a', 'b') AND x = 1",
expected: map[string]bool{"service.name": true, "x": true},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
normalized, err := NormalizeWhereClause(testCase.expression, nil)
require.NoError(t, err)
actual := make(map[string]bool)
for _, condition := range normalized.Conditions {
actual[condition.Key] = condition.TopLevel
}
assert.Equal(t, testCase.expected, actual)
})
}
}
func TestNormalizeWhereClauseEscaping(t *testing.T) {
canonical, err := NormalizeWhereClause(`a = "it's fine"`, nil)
require.NoError(t, err)
assert.Equal(t, `a = 'it\'s fine'`, canonical.Expression)
require.Len(t, canonical.Conditions, 1)
assert.Equal(t, []string{"it's fine"}, canonical.Conditions[0].Values)
equivalent, err := NormalizeWhereClause(canonical.Expression, nil)
require.NoError(t, err)
assert.Equal(t, canonical.Expression, equivalent.Expression)
assert.Equal(t, canonical.Conditions, equivalent.Conditions)
}
func TestNormalizeWhereClauseSyntaxError(t *testing.T) {
_, err := NormalizeWhereClause("a = ", nil)
require.Error(t, err)
_, err = NormalizeWhereClause("AND a = 1", nil)
require.Error(t, err)
}
func TestNormalizeWhereClauseIdempotence(t *testing.T) {
expressions := []string{
"service.name='frontend' and (status = 500 or status=502) not retired k8s.pod.name exists",
"a IN [3, 1, 2] AND hasAll(tags, ['a', 'b']) AND body CONTAINS 'x'",
"duration BETWEEN 1 AND 10 OR duration > 100",
`msg = 'with \'escapes\' and "quotes"'`,
}
for _, expression := range expressions {
first, err := NormalizeWhereClause(expression, nil)
require.NoError(t, err, "expression %q", expression)
second, err := NormalizeWhereClause(first.Expression, nil)
require.NoError(t, err, "canonical output %q must re-parse", first.Expression)
assert.Equal(t, first.Expression, second.Expression, "canonicalization must be idempotent for %q", expression)
}
}
func TestNormalizeWhereClauseVariables(t *testing.T) {
variables := map[string]qbtypes.VariableItem{
"service": {Value: "frontend"},
"statuses": {Value: []any{float64(502), float64(500)}},
"env": {Type: qbtypes.DynamicVariableType, Value: "__all__"},
"limit": {Value: float64(100)},
}
testCases := []struct {
name string
expression string
expected string
}{
{
name: "scalar substitution",
expression: "service.name = $service",
expected: "service.name = 'frontend'",
},
{
name: "array substitution in IN is sorted",
expression: "status IN $statuses",
expected: "status IN (500, 502)",
},
{
name: "numeric substitution",
expression: "duration > $limit",
expected: "duration > 100",
},
{
name: "dynamic all prunes the condition",
expression: "a = 1 AND deployment.environment IN $env",
expected: "a = 1",
},
{
name: "unknown variable stays a token",
expression: "service.name = $unknown",
expected: "service.name = $unknown",
},
{
name: "substituted forms hash-equal to concrete forms",
expression: "status IN (502, 500) AND service.name = 'frontend'",
expected: "service.name = 'frontend' AND status IN (500, 502)",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
normalized, err := NormalizeWhereClause(testCase.expression, variables)
require.NoError(t, err, "expression %q", testCase.expression)
assert.Equal(t, testCase.expected, normalized.Expression)
})
}
substituted, err := NormalizeWhereClause("service.name = $service AND status IN $statuses", variables)
require.NoError(t, err)
concrete, err := NormalizeWhereClause("status IN (500, 502) AND service.name = 'frontend'", nil)
require.NoError(t, err)
assert.Equal(t, concrete.Expression, substituted.Expression)
assert.Equal(t, concrete.Conditions, substituted.Conditions)
}
func TestNormalizeWhereClauseVariablesFullyPruned(t *testing.T) {
variables := map[string]qbtypes.VariableItem{
"env": {Type: qbtypes.DynamicVariableType, Value: "__all__"},
}
normalized, err := NormalizeWhereClause("deployment.environment IN $env", variables)
require.NoError(t, err)
assert.Equal(t, "", normalized.Expression)
assert.Empty(t, normalized.Conditions)
}
func TestNormalizeWhereClauseVariablesEmptyList(t *testing.T) {
variables := map[string]qbtypes.VariableItem{
"statuses": {Value: []any{}},
}
_, err := NormalizeWhereClause("status IN $statuses", variables)
require.Error(t, err)
}

View File

@@ -732,7 +732,11 @@ func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallCon
return ErrorConditionLiteral
}
value := params[1:]
value, err := normalizeFunctionValue(operator, functionName, params[1:])
if err != nil {
v.errors = append(v.errors, err.Error())
return ErrorConditionLiteral
}
conds, ok := v.buildConditions(key, MatchingFieldKeys(key, v.fieldKeys), operator, value)
if !ok {
@@ -748,6 +752,44 @@ func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallCon
return v.builder.Or(conds...)
}
// normalizeFunctionValue validates and normalizes the value argument(s) of a has-family
// function call, returning them in the wrapper slice the condition builder unwraps.
//
// - has/hasToken take exactly one scalar value. More than one argument, or an array
// argument, is rejected rather than silently dropping the extras.
// - hasAny/hasAll take a set of values, supplied either as a single array literal
// (hasAny(k, ['a','b'])) or as several scalar arguments (hasAny(k, 'a', 'b')); the
// latter are folded into one list so no argument is silently ignored.
func normalizeFunctionValue(operator qbtypes.FilterOperator, functionName string, valueParams []any) (any, error) {
switch operator {
case qbtypes.FilterOperatorHas, qbtypes.FilterOperatorHasToken:
if len(valueParams) != 1 {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "function `%s` expects exactly one value argument", functionName)
}
if _, isArray := valueParams[0].([]any); isArray {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "function `%s` expects a single scalar value, not an array", functionName)
}
return valueParams, nil
case qbtypes.FilterOperatorHasAny, qbtypes.FilterOperatorHasAll:
// A single array literal is already the value set.
if len(valueParams) == 1 {
if _, isArray := valueParams[0].([]any); isArray {
return valueParams, nil
}
}
// Otherwise fold the positional scalar arguments into one list.
values := make([]any, 0, len(valueParams))
for _, p := range valueParams {
if _, isArray := p.([]any); isArray {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "function `%s` expects either a single array literal or scalar values, not a mix of the two", functionName)
}
values = append(values, p)
}
return []any{values}, nil
}
return valueParams, nil
}
// VisitFunctionParamList handles the parameter list for function calls.
func (v *filterExpressionVisitor) VisitFunctionParamList(ctx *grammar.FunctionParamListContext) any {
params := ctx.AllFunctionParam()

View File

@@ -220,6 +220,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewRemoveOrganizationTuplesFactory(sqlstore),
sqlmigration.NewAddRoleTransactionGroupsFactory(sqlstore, sqlschema),
sqlmigration.NewAddTagUniqueIndexFactory(sqlstore, sqlschema),
sqlmigration.NewAddTelemetryTuplesFactory(sqlstore),
)
}

View File

@@ -0,0 +1,146 @@
package sqlmigration
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addTelemetryTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddTelemetryTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_telemetry_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addTelemetryTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addTelemetryTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addTelemetryTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
var orgIDs []string
rows, err := tx.QueryContext(ctx, `SELECT id FROM organizations`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var orgID string
if err := rows.Scan(&orgID); err != nil {
return err
}
orgIDs = append(orgIDs, orgID)
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
tuples := []migrationTuple{
{authtypes.SigNozAdminRoleName, "telemetryresource", "logs", "read"},
{authtypes.SigNozAdminRoleName, "telemetryresource", "traces", "read"},
{authtypes.SigNozAdminRoleName, "telemetryresource", "metrics", "read"},
{authtypes.SigNozAdminRoleName, "telemetryresource", "audit-logs", "read"},
{authtypes.SigNozAdminRoleName, "telemetryresource", "meter-metrics", "read"},
{authtypes.SigNozEditorRoleName, "telemetryresource", "logs", "read"},
{authtypes.SigNozEditorRoleName, "telemetryresource", "traces", "read"},
{authtypes.SigNozEditorRoleName, "telemetryresource", "metrics", "read"},
{authtypes.SigNozEditorRoleName, "telemetryresource", "meter-metrics", "read"},
{authtypes.SigNozViewerRoleName, "telemetryresource", "logs", "read"},
{authtypes.SigNozViewerRoleName, "telemetryresource", "traces", "read"},
{authtypes.SigNozViewerRoleName, "telemetryresource", "metrics", "read"},
{authtypes.SigNozViewerRoleName, "telemetryresource", "meter-metrics", "read"},
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "TUPLE_OPERATION_WRITE", tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
return tx.Commit()
}
func (migration *addTelemetryTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -40,13 +40,11 @@ func isBodyJSONSearch(key *telemetrytypes.TelemetryFieldKey, columns []*schema.C
return false
}
// conditionForArrayFunction builds `has/hasAny/hasAll(<arrayFieldExpr>, value)` over a
// body JSON array field. The field expression uses the JSON accessor (flag on) or
// legacy string extraction (flag off); value[0] is the needle.
// conditionForArrayFunction builds has/hasAny/hasAll over a body JSON path — via the JSON
// access plan (flag on) or legacy typed extraction (flag off).
func (c *conditionBuilder) conditionForArrayFunction(
ctx context.Context,
orgID valuer.UUID,
startNs, endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
@@ -63,24 +61,48 @@ func (c *conditionBuilder) conditionForArrayFunction(
needle = args[0]
}
var fieldExpr string
if c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
fe, err := c.fm.FieldFor(ctx, orgID, startNs, endNs, key)
if err != nil {
return "", err
}
fieldExpr = fe
} else {
// legacy string-body path; value drives array-type inference (e.g. `[*]` paths)
fieldExpr, _ = GetBodyJSONKey(ctx, key, qbtypes.FilterOperatorUnknown, value)
// JSON access plan: data-type collision handling, nested array paths.
valueType, needle := InferDataType(needle, operator, key)
return NewJSONConditionBuilder(key, valueType).buildArrayFunctionCondition(operator, needle, sb)
}
return fmt.Sprintf("%s(%s, %s)", operator.FunctionName(), fieldExpr, sb.Var(needle)), nil
// legacy string-body path: type-matched array extraction, OR-ed with a scalar comparison
// for a scalar body value (coalesced to false so NOT has() matches missing-key rows).
elemType := legacyElemType(needle)
arrayExpr := getBodyJSONArrayKey(key, elemType)
scalarExpr, scalarGuard, hasScalar := getBodyJSONScalarKey(key, elemType)
if list, ok := needle.([]any); ok {
vals := make([]any, len(list))
for i, v := range list {
vals[i] = legacyCoerceNeedle(v, elemType)
}
arrayCond := fmt.Sprintf("%s(%s, %s)", operator.FunctionName(), arrayExpr, sb.Var(vals))
if !hasScalar {
return arrayCond, nil
}
var membership string
if operator == qbtypes.FilterOperatorHasAll {
eqs := make([]string, len(vals))
for i, v := range vals {
eqs[i] = sb.E(scalarExpr, v)
}
membership = sb.And(eqs...)
} else {
membership = sb.In(scalarExpr, vals...)
}
return fmt.Sprintf("(%s OR ifNull(%s, false))", arrayCond, sb.And(membership, scalarGuard)), nil
}
typedNeedle := legacyCoerceNeedle(needle, elemType)
arrayCond := fmt.Sprintf("%s(%s, %s)", operator.FunctionName(), arrayExpr, sb.Var(typedNeedle))
if !hasScalar {
return arrayCond, nil
}
return fmt.Sprintf("(%s OR ifNull(%s, false))", arrayCond, sb.And(sb.E(scalarExpr, typedNeedle), scalarGuard)), nil
}
// conditionForHasToken builds `hasToken(LOWER(<bodyColumn>), LOWER(<needle>))`, a
// full-text token search over the body column. It resolves the column from the key
// name + use_json_body flag, validates the field/value, and tags errors with the doc URL.
// conditionForHasToken builds a hasToken full-text search over the body column, resolving the
// column from the key name + use_json_body flag.
func (c *conditionBuilder) conditionForHasToken(
ctx context.Context,
orgID valuer.UUID,
@@ -94,27 +116,36 @@ func (c *conditionBuilder) conditionForHasToken(
needle = args[0]
}
bodyJSONEnabled := c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID))
columnName := LogsV2BodyColumn
if bodyJSONEnabled {
if key.Name != LogsV2BodyColumn && key.Name != bodyMessageField {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"function `hasToken` only supports body/body.message field as first parameter").WithUrl(hasTokenFunctionDocURL)
}
columnName = bodyMessageField
} else if key.Name != LogsV2BodyColumn {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"function `hasToken` only supports body field as first parameter").WithUrl(hasTokenFunctionDocURL)
}
// hasToken matches string tokens only.
if _, ok := needle.(string); !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"function `hasToken` expects value parameter to be a string").WithUrl(hasTokenFunctionDocURL)
}
return fmt.Sprintf("hasToken(LOWER(%s), LOWER(%s))", columnName, sb.Var(needle)), nil
bodyJSONEnabled := c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID))
if !bodyJSONEnabled {
// legacy: token search over the plain body string column only.
if key.Name != LogsV2BodyColumn {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"function `hasToken` only supports body field as first parameter").WithUrl(hasTokenFunctionDocURL)
}
return fmt.Sprintf("hasToken(LOWER(%s), LOWER(%s))", LogsV2BodyColumn, sb.Var(needle)), nil
}
// JSON mode: a bare body/body.message key searches the body.message column; any other body
// field is a token search over its JSON string field, incl. strings nested in arrays.
// `body.message` resolves to a body-context key named `message`, so match that too — else it
// falls through and emits dynamicElement over the already-typed String column, which errors.
if key.Name == LogsV2BodyColumn || key.Name == bodyMessageField ||
(key.FieldContext == telemetrytypes.FieldContextBody && key.Name == messageSubField) {
return fmt.Sprintf("hasToken(LOWER(%s), LOWER(%s))", bodyMessageField, sb.Var(needle)), nil
}
if key.FieldContext == telemetrytypes.FieldContextBody {
return NewJSONConditionBuilder(key, telemetrytypes.FieldDataTypeString).buildTokenFunctionCondition(needle, sb)
}
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"function `hasToken` only supports the body field or a body JSON string field as first parameter").WithUrl(hasTokenFunctionDocURL)
}
func (c *conditionBuilder) conditionFor(
@@ -126,8 +157,7 @@ func (c *conditionBuilder) conditionFor(
value any,
sb *sqlbuilder.SelectBuilder,
) (string, error) {
// hasToken is a token search over the body column resolved purely from the key
// name + flag, independent of column resolution, so handle it before anything else.
// hasToken resolves from the key name + flag alone (no column resolution), so handle it first.
if operator == qbtypes.FilterOperatorHasToken {
return c.conditionForHasToken(ctx, orgID, key, value, sb)
}
@@ -137,10 +167,9 @@ func (c *conditionBuilder) conditionFor(
return "", err
}
// has/hasAny/hasAll build `has(<arrayFieldExpr>, value)` over body JSON arrays
// rather than going through the normal operator paths, so handle them up front.
// has/hasAny/hasAll take the body-JSON path, not the normal operator paths.
if operator.IsArrayFunctionOperator() {
return c.conditionForArrayFunction(ctx, orgID, startNs, endNs, key, operator, value, columns, sb)
return c.conditionForArrayFunction(ctx, orgID, key, operator, value, columns, sb)
}
// TODO(Piyush): Update this to support multiple JSON columns based on evolutions
@@ -401,23 +430,6 @@ func (c *conditionBuilder) ConditionFor(
}
}
// has/hasAny/hasAll need an array field: in JSON-body mode drop non-array matches so a
// scalar errors clearly instead of failing at ClickHouse runtime (legacy mode skips this).
if operator.IsArrayFunctionOperator() &&
c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
arrayKeys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(keys))
for _, k := range keys {
if k.FieldDataType.IsArray() {
arrayKeys = append(arrayKeys, k)
}
}
if len(arrayKeys) == 0 {
return nil, warnings, errors.NewInvalidInputf(errors.CodeInvalidInput,
"function `%s` expects key parameter to be an array field; no array fields found", operator.FunctionName())
}
keys = arrayKeys
}
conds := make([]string, 0, len(keys))
for _, k := range keys {
cond, err := c.conditionForKey(ctx, orgID, startNs, endNs, k, operator, value, sb)

View File

@@ -44,34 +44,66 @@ func TestFilterExprLogsBodyJSON(t *testing.T) {
category: "json",
query: "has(body.requestor_list[*], 'index_service')",
shouldPass: true,
expectedQuery: `WHERE has(JSONExtract(JSON_QUERY(body, '$."requestor_list"[*]'), 'Array(String)'), ?)`,
expectedArgs: []any{"index_service"},
expectedQuery: `WHERE (has(JSONExtract(JSON_QUERY(body, '$."requestor_list"[*]'), 'Array(Nullable(String))'), ?) OR ifNull((JSON_VALUE(body, '$."requestor_list"') = ? AND JSONType(body, 'requestor_list') NOT IN ('Array', 'Object', 'Null')), false))`,
expectedArgs: []any{"index_service", "index_service"},
expectedErrorContains: "",
},
{
category: "json",
query: "has(body.int_numbers[*], 2)",
shouldPass: true,
expectedQuery: `WHERE has(JSONExtract(JSON_QUERY(body, '$."int_numbers"[*]'), 'Array(Float64)'), ?)`,
expectedArgs: []any{float64(2)},
expectedQuery: `WHERE (has(JSONExtract(JSON_QUERY(body, '$."int_numbers"[*]'), 'Array(Nullable(Float64))'), ?) OR ifNull((JSONExtract(JSON_VALUE(body, '$."int_numbers"'), 'Nullable(Float64)') = ? AND JSONType(body, 'int_numbers') NOT IN ('Array', 'Object', 'Null')), false))`,
expectedArgs: []any{float64(2), float64(2)},
expectedErrorContains: "",
},
{
category: "json",
query: "has(body.bool[*], true)",
shouldPass: true,
expectedQuery: `WHERE has(JSONExtract(JSON_QUERY(body, '$."bool"[*]'), 'Array(Bool)'), ?)`,
expectedArgs: []any{true},
expectedQuery: `WHERE (has(JSONExtract(JSON_QUERY(body, '$."bool"[*]'), 'Array(Nullable(String))'), ?) OR ifNull((JSON_VALUE(body, '$."bool"') = ? AND JSONType(body, 'bool') NOT IN ('Array', 'Object', 'Null')), false))`,
expectedArgs: []any{"true", "true"},
expectedErrorContains: "",
},
{
category: "json",
query: "NOT has(body.nested_num[*].float_nums[*], 2.2)",
shouldPass: true,
expectedQuery: `WHERE NOT (has(JSONExtract(JSON_QUERY(body, '$."nested_num"[*]."float_nums"[*]'), 'Array(Float64)'), ?))`,
expectedQuery: `WHERE NOT (has(JSONExtract(JSON_QUERY(body, '$."nested_num"[*]."float_nums"[*]'), 'Array(Nullable(Float64))'), ?))`,
expectedArgs: []any{float64(2.2)},
expectedErrorContains: "",
},
{
category: "json",
query: "has(body.tags, 'production')",
shouldPass: true,
expectedQuery: `WHERE (has(JSONExtract(JSON_QUERY(body, '$."tags"[*]'), 'Array(Nullable(String))'), ?) OR ifNull((JSON_VALUE(body, '$."tags"') = ? AND JSONType(body, 'tags') NOT IN ('Array', 'Object', 'Null')), false))`,
expectedArgs: []any{"production", "production"},
expectedErrorContains: "",
},
{
category: "json",
query: "hasAny(body.tags, ['critical', 'test'])",
shouldPass: true,
expectedQuery: `WHERE (hasAny(JSONExtract(JSON_QUERY(body, '$."tags"[*]'), 'Array(Nullable(String))'), ?) OR ifNull((JSON_VALUE(body, '$."tags"') IN (?, ?) AND JSONType(body, 'tags') NOT IN ('Array', 'Object', 'Null')), false))`,
expectedArgs: []any{[]any{"critical", "test"}, "critical", "test"},
expectedErrorContains: "",
},
{
category: "json",
query: "hasAll(body.tags, ['production', 'web'])",
shouldPass: true,
expectedQuery: `WHERE (hasAll(JSONExtract(JSON_QUERY(body, '$."tags"[*]'), 'Array(Nullable(String))'), ?) OR ifNull(((JSON_VALUE(body, '$."tags"') = ? AND JSON_VALUE(body, '$."tags"') = ?) AND JSONType(body, 'tags') NOT IN ('Array', 'Object', 'Null')), false))`,
expectedArgs: []any{[]any{"production", "web"}, "production", "web"},
expectedErrorContains: "",
},
{
category: "json",
query: "has(body.ids, \"200\")",
shouldPass: true,
expectedQuery: `WHERE (has(JSONExtract(JSON_QUERY(body, '$."ids"[*]'), 'Array(Nullable(Int64))'), ?) OR ifNull((JSONExtract(JSON_VALUE(body, '$."ids"'), 'Nullable(Int64)') = ? AND JSONType(body, 'ids') NOT IN ('Array', 'Object', 'Null')), false))`,
expectedArgs: []any{int64(200), int64(200)},
expectedErrorContains: "",
},
{
category: "json",
query: "body.message = hello",

View File

@@ -1561,6 +1561,25 @@ func TestFilterExprLogs(t *testing.T) {
expectedArgs: []any{"download"},
expectedErrorContains: "function `hasToken` expects value parameter to be a string",
},
// extra / mis-shaped value arguments are rejected, not silently dropped.
{
category: "hasExtraArgs",
query: "has(body.tags[*], \"a\", \"b\")",
shouldPass: false,
expectedErrorContains: "function `has` expects exactly one value argument",
},
{
category: "hasArrayArg",
query: "has(body.tags[*], [\"a\", \"b\"])",
shouldPass: false,
expectedErrorContains: "function `has` expects a single scalar value, not an array",
},
{
category: "hasTokenExtraArgs",
query: "hasToken(body, \"a\", \"b\")",
shouldPass: false,
expectedErrorContains: "function `hasToken` expects exactly one value argument",
},
// Basic materialized key
{

View File

@@ -96,6 +96,18 @@ func applyNotCondition(operator qbtypes.FilterOperator) (bool, qbtypes.FilterOpe
return false, operator
}
// branchArrayExpr returns the ClickHouse array expression for a given array-type branch
// at this hop. The JSON branch reads Array(JSON(...)) directly; the Dynamic branch filters
// the Array(Dynamic) down to its JSON elements and maps them to JSON.
func (c *jsonConditionBuilder) branchArrayExpr(node *telemetrytypes.JSONAccessNode, branch telemetrytypes.JSONAccessBranchType) string {
fieldPath := node.FieldPath()
if branch == telemetrytypes.BranchDynamic {
dynBaseExpr := fmt.Sprintf("dynamicElement(%s, 'Array(Dynamic)')", fieldPath)
return fmt.Sprintf("arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), %s))", dynBaseExpr)
}
return fmt.Sprintf("dynamicElement(%s, 'Array(JSON(max_dynamic_types=%d, max_dynamic_paths=%d))')", fieldPath, node.MaxDynamicTypes, node.MaxDynamicPaths)
}
// buildAccessNodeBranches builds conditions for each branch of the access node.
func (c *jsonConditionBuilder) buildAccessNodeBranches(current *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
if current == nil {
@@ -103,31 +115,15 @@ func (c *jsonConditionBuilder) buildAccessNodeBranches(current *telemetrytypes.J
}
currAlias := current.Alias()
fieldPath := current.FieldPath()
// Determine availability of Array(JSON) and Array(Dynamic) at this hop
hasArrayJSON := current.Branches[telemetrytypes.BranchJSON] != nil
hasArrayDynamic := current.Branches[telemetrytypes.BranchDynamic] != nil
// Then, at this hop, compute child per branch and wrap
// At this hop, compute the child condition per array branch (JSON before Dynamic) and
// wrap each in arrayExists over the corresponding array expression.
branches := make([]string, 0, 2)
if hasArrayJSON {
jsonArrayExpr := fmt.Sprintf("dynamicElement(%s, 'Array(JSON(max_dynamic_types=%d, max_dynamic_paths=%d))')", fieldPath, current.MaxDynamicTypes, current.MaxDynamicPaths)
childGroupJSON, err := c.recurseArrayHops(current.Branches[telemetrytypes.BranchJSON], operator, value, sb)
for _, branch := range current.BranchesInOrder() {
childGroup, err := c.recurseArrayHops(current.Branches[branch], operator, value, sb)
if err != nil {
return "", err
}
branches = append(branches, fmt.Sprintf("arrayExists(%s-> %s, %s)", currAlias, childGroupJSON, jsonArrayExpr))
}
if hasArrayDynamic {
dynBaseExpr := fmt.Sprintf("dynamicElement(%s, 'Array(Dynamic)')", fieldPath)
dynFilteredExpr := fmt.Sprintf("arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), %s))", dynBaseExpr)
// Create the Query for Dynamic array
childGroupDyn, err := c.recurseArrayHops(current.Branches[telemetrytypes.BranchDynamic], operator, value, sb)
if err != nil {
return "", err
}
branches = append(branches, fmt.Sprintf("arrayExists(%s-> %s, %s)", currAlias, childGroupDyn, dynFilteredExpr))
branches = append(branches, fmt.Sprintf("arrayExists(%s-> %s, %s)", currAlias, childGroup, c.branchArrayExpr(current, branch)))
}
if len(branches) == 1 {
@@ -309,6 +305,174 @@ func (c *jsonConditionBuilder) buildArrayMembershipCondition(node *telemetrytype
return fmt.Sprintf("arrayExists(%s -> %s, %s)", key, op, arrayExpr), nil
}
// buildArrayFunctionCondition builds a has/hasAny/hasAll condition over a body JSON path,
// with contains-all semantics uniform across every leaf shape:
// - has(v) = the path HAS v
// - hasAny([v...]) = the path has ANY listed value (OR of has)
// - hasAll([v...]) = the path has ALL listed values (AND of has)
//
// "the path has v" is an existential match resolved per leaf shape: for an array-typed leaf
// (top-level `body.tags`, or nested `body.education[].scores`) it is native membership; for a
// scalar leaf — whether reached through an array hop (`body.items[].sku`) or a plain scalar
// path (`body.level`) — it is `<elem> = v`, wrapped in arrayExists over any array hops. So
// `hasAll(body.education[].name, ['a','b'])` = "some element is a AND some element is b", and
// for a plain scalar hasAll collapses to has (a one-element set can hold at most one value).
//
// Element comparisons reuse DataTypeCollisionHandledFieldName so a numeric literal against an
// Int64 array (or a numeric literal against a String array) no longer silently misses.
func (c *jsonConditionBuilder) buildArrayFunctionCondition(operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
if len(c.key.JSONPlan) == 0 {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "function `%s` could not resolve a JSON access plan for field `%s`", operator.FunctionName(), c.key.Name)
}
switch operator {
case qbtypes.FilterOperatorHas, qbtypes.FilterOperatorHasAny:
return c.buildOredRootChains(func(node *telemetrytypes.JSONAccessNode) (string, error) {
return c.arrayFunctionLeaf(node, operator, value, sb)
}, sb)
case qbtypes.FilterOperatorHasAll:
// contains-all: AND of a per-value "has" so the AND sits outside the array hops.
values := toAnyList(value)
conditions := make([]string, 0, len(values))
for _, v := range values {
v := v
cond, err := c.buildOredRootChains(func(node *telemetrytypes.JSONAccessNode) (string, error) {
return c.arrayFunctionLeaf(node, qbtypes.FilterOperatorHas, v, sb)
}, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
}
if len(conditions) == 1 {
return conditions[0], nil
}
return sb.And(conditions...), nil
}
return "", qbtypes.ErrUnsupportedOperator
}
// buildOredRootChains applies leafFn down every JSONPlan root (base + promoted), wrapping each
// in its arrayExists chain, and ORs the per-root results.
func (c *jsonConditionBuilder) buildOredRootChains(leafFn func(*telemetrytypes.JSONAccessNode) (string, error), sb *sqlbuilder.SelectBuilder) (string, error) {
conditions := make([]string, 0, len(c.key.JSONPlan))
for _, root := range c.key.JSONPlan {
cond, err := c.buildArrayExistsChain(root, leafFn, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
}
if len(conditions) == 1 {
return conditions[0], nil
}
return sb.Or(conditions...), nil
}
// buildArrayExistsChain wraps the terminal condition (produced by leafFn) in an arrayExists
// over every array hop between the root and the terminal. For a terminal root (a top-level
// array leaf) it simply returns leafFn(root).
func (c *jsonConditionBuilder) buildArrayExistsChain(node *telemetrytypes.JSONAccessNode, leafFn func(*telemetrytypes.JSONAccessNode) (string, error), sb *sqlbuilder.SelectBuilder) (string, error) {
if node == nil {
return "", errors.NewInternalf(CodeArrayNavigationFailed, "navigation failed, current node is nil")
}
if node.IsTerminal {
return leafFn(node)
}
branches := make([]string, 0, 2)
for _, branch := range node.BranchesInOrder() {
childCond, err := c.buildArrayExistsChain(node.Branches[branch], leafFn, sb)
if err != nil {
return "", err
}
branches = append(branches, fmt.Sprintf("arrayExists(%s-> %s, %s)", node.Alias(), childCond, c.branchArrayExpr(node, branch)))
}
if len(branches) == 1 {
return branches[0], nil
}
return sb.Or(branches...), nil
}
// arrayFunctionLeaf builds the existential comparison for has/hasAny at a terminal node (hasAll
// composes from has in buildArrayFunctionCondition). For an array leaf it delegates to native
// membership; for a scalar leaf it compares the element directly.
func (c *jsonConditionBuilder) arrayFunctionLeaf(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
if node.TerminalConfig.ElemType.IsArray {
return c.arrayLeafMembership(node, operator, value, sb)
}
switch operator {
case qbtypes.FilterOperatorHas:
return c.arrayFuncScalarLeaf(node, qbtypes.FilterOperatorEqual, value, sb)
case qbtypes.FilterOperatorHasAny:
return c.arrayFuncScalarLeaf(node, qbtypes.FilterOperatorIn, toAnyList(value), sb)
}
return "", qbtypes.ErrUnsupportedOperator
}
// arrayLeafMembership builds native membership for an array-typed leaf, reusing
// buildArrayMembershipCondition (which handles data-type collisions on each element).
func (c *jsonConditionBuilder) arrayLeafMembership(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
switch operator {
case qbtypes.FilterOperatorHas:
return c.buildArrayMembershipCondition(node, qbtypes.FilterOperatorEqual, value, sb)
case qbtypes.FilterOperatorHasAny:
return c.buildArrayMembershipCondition(node, qbtypes.FilterOperatorIn, toAnyList(value), sb)
}
return "", qbtypes.ErrUnsupportedOperator
}
// arrayFuncScalarLeaf builds `<elemExpr> <op> value` for a scalar leaf reached through an
// array hop, applying data-type collision handling like the standard primitive path.
// Coalesced to false so a missing key is a non-match, not NULL (NOT has() must match it).
func (c *jsonConditionBuilder) arrayFuncScalarLeaf(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
fieldExpr := fmt.Sprintf("dynamicElement(%s, '%s')", node.FieldPath(), node.TerminalConfig.ElemType.StringValue())
fieldExpr, value = querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, value, fieldExpr, operator)
cond, err := c.applyOperator(sb, fieldExpr, operator, value)
if err != nil {
return "", err
}
return fmt.Sprintf("ifNull(%s, false)", cond), nil
}
// buildTokenFunctionCondition builds a hasToken search over a body JSON string field:
// hasToken(LOWER(<elem>), LOWER(?)) wrapped in arrayExists over any array hops between the
// root and the terminal. The field must resolve to a String leaf or a String array.
func (c *jsonConditionBuilder) buildTokenFunctionCondition(needle any, sb *sqlbuilder.SelectBuilder) (string, error) {
if len(c.key.JSONPlan) == 0 {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "function `hasToken` could not resolve a JSON access plan for field `%s`", c.key.Name)
}
return c.buildOredRootChains(func(node *telemetrytypes.JSONAccessNode) (string, error) {
return c.tokenLeaf(node, needle, sb)
}, sb)
}
// tokenLeaf builds the hasToken match at a terminal node: a direct match for a String leaf
// (coalesced to false, as in arrayFuncScalarLeaf), or an arrayExists over the elements for a
// String array leaf. hasToken is string-only, so any other element type is rejected.
func (c *jsonConditionBuilder) tokenLeaf(node *telemetrytypes.JSONAccessNode, needle any, sb *sqlbuilder.SelectBuilder) (string, error) {
switch node.TerminalConfig.ElemType {
case telemetrytypes.String:
fieldExpr := fmt.Sprintf("dynamicElement(%s, 'String')", node.FieldPath())
return fmt.Sprintf("ifNull(hasToken(LOWER(%s), LOWER(%s)), false)", fieldExpr, sb.Var(needle)), nil
case telemetrytypes.ArrayString:
arrayExpr := fmt.Sprintf("dynamicElement(%s, '%s')", node.FieldPath(), node.TerminalConfig.ElemType.StringValue())
return fmt.Sprintf("arrayExists(x -> hasToken(LOWER(x), LOWER(%s)), %s)", sb.Var(needle), arrayExpr), nil
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "function `hasToken` only supports string fields; field `%s` is `%s`", c.key.Name, node.TerminalConfig.Key.FieldDataType.StringValue())
}
}
// toAnyList normalizes a has-family value into a slice; a scalar becomes a one-element list.
func toAnyList(value any) []any {
if list, ok := value.([]any); ok {
return list
}
return []any{value}
}
func (c *jsonConditionBuilder) applyOperator(sb *sqlbuilder.SelectBuilder, fieldExpr string, operator qbtypes.FilterOperator, value any) (string, error) {
switch operator {
case qbtypes.FilterOperatorEqual:

View File

@@ -603,7 +603,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
name: "Simple has filter",
filter: "has(body.education[].parameters, 1.65)",
expected: TestExpected{
WhereClause: "(has(arrayFlatten(arrayConcat(arrayMap(`body_v2.education`->dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))'), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?) OR has(arrayFlatten(arrayConcat(arrayMap(`body_v2.education`->dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)'), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?))",
WhereClause: "(arrayExists(`body_v2.education`-> arrayExists(x -> toFloat64(x) = ?, dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))')), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_v2.education`-> arrayExists(x -> accurateCastOrNull(x, 'Float64') = ?, arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)'))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))",
Args: []any{1.65, 1.65, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{
"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64 name=education[].parameters,context=body,datatype=[]dynamic].",
@@ -614,8 +614,8 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
name: "Flat path hasAll filter",
filter: "hasAll(body.user.permissions, ['read', 'write'])",
expected: TestExpected{
WhereClause: "hasAll(dynamicElement(body_v2.`user.permissions`, 'Array(Nullable(String))'), ?)",
Args: []any{[]any{"read", "write"}, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
WhereClause: "(arrayExists(x -> x = ?, dynamicElement(body_v2.`user.permissions`, 'Array(Nullable(String))')) AND arrayExists(x -> x = ?, dynamicElement(body_v2.`user.permissions`, 'Array(Nullable(String))')))",
Args: []any{"read", "write", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
@@ -740,8 +740,8 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
name: "Nested path hasAny filter",
filter: "hasAny(education[].awards[].participated[].members, ['Piyush', 'Tushar'])",
expected: TestExpected{
WhereClause: "hasAny(arrayFlatten(arrayConcat(arrayMap(`body_v2.education`->arrayConcat(arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), dynamicElement(`body_v2.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?)",
Args: []any{[]any{"Piyush", "Tushar"}, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
WhereClause: "arrayExists(`body_v2.education`-> (arrayExists(`body_v2.education[].awards`-> (arrayExists(`body_v2.education[].awards[].participated`-> arrayExists(x -> x IN (?, ?), dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))')), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')) OR arrayExists(`body_v2.education[].awards[].participated`-> arrayExists(x -> x IN (?, ?), dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))')), arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), dynamicElement(`body_v2.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')) OR arrayExists(`body_v2.education[].awards`-> (arrayExists(`body_v2.education[].awards[].participated`-> arrayExists(x -> x IN (?, ?), dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))')), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')) OR arrayExists(`body_v2.education[].awards[].participated`-> arrayExists(x -> x IN (?, ?), dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))')), arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))",
Args: []any{"Piyush", "Tushar", "Piyush", "Tushar", "Piyush", "Tushar", "Piyush", "Tushar", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
{
@@ -756,8 +756,8 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
name: "dynamic_array_element_compare_HAS_STRING",
filter: "has(interests[].entities[].product_codes, '2002')",
expected: TestExpected{
WhereClause: "has(arrayFlatten(arrayConcat(arrayMap(`body_v2.interests`->arrayMap(`body_v2.interests[].entities`->dynamicElement(`body_v2.interests[].entities`.`product_codes`, 'Array(Dynamic)'), dynamicElement(`body_v2.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_v2.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?)",
Args: []any{"2002", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
WhereClause: "arrayExists(`body_v2.interests`-> arrayExists(`body_v2.interests[].entities`-> arrayExists(x -> accurateCastOrNull(x, 'Float64') = ?, arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.interests[].entities`.`product_codes`, 'Array(Dynamic)'))), dynamicElement(`body_v2.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_v2.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))",
Args: []any{int64(2002), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
{
@@ -772,10 +772,146 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
name: "dynamic_array_element_compare_HAS_INT",
filter: "has(interests[].entities[].product_codes, 1001)",
expected: TestExpected{
WhereClause: "has(arrayFlatten(arrayConcat(arrayMap(`body_v2.interests`->arrayMap(`body_v2.interests[].entities`->dynamicElement(`body_v2.interests[].entities`.`product_codes`, 'Array(Dynamic)'), dynamicElement(`body_v2.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_v2.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?)",
WhereClause: "arrayExists(`body_v2.interests`-> arrayExists(`body_v2.interests[].entities`-> arrayExists(x -> accurateCastOrNull(x, 'Float64') = ?, arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.interests[].entities`.`product_codes`, 'Array(Dynamic)'))), dynamicElement(`body_v2.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_v2.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))",
Args: []any{float64(1001), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
// ── scalar leaf reached through an array ───
{
name: "Nested primitive leaf has",
filter: "has(body.education[].name, 'IIT')",
expected: TestExpected{
WhereClause: "arrayExists(`body_v2.education`-> ifNull(dynamicElement(`body_v2.education`.`name`, 'String') = ?, false), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))",
Args: []any{"IIT", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
{
name: "Nested primitive leaf hasAny",
filter: "hasAny(body.education[].name, ['IIT', 'MIT'])",
expected: TestExpected{
WhereClause: "arrayExists(`body_v2.education`-> ifNull(dynamicElement(`body_v2.education`.`name`, 'String') IN (?, ?), false), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))",
Args: []any{"IIT", "MIT", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
{
// contains-all: single-value hasAll over a nested leaf collapses to has.
name: "Nested primitive leaf hasAll single collapses to has",
filter: "hasAll(body.education[].name, 'IIT')",
expected: TestExpected{
WhereClause: "arrayExists(`body_v2.education`-> ifNull(dynamicElement(`body_v2.education`.`name`, 'String') = ?, false), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))",
Args: []any{"IIT", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
{
// contains-all: "some element is IIT AND some element is MIT" (AND of per-value has).
name: "Nested primitive leaf hasAll multi (contains-all)",
filter: "hasAll(body.education[].name, ['IIT', 'MIT'])",
expected: TestExpected{
WhereClause: "(arrayExists(`body_v2.education`-> ifNull(dynamicElement(`body_v2.education`.`name`, 'String') = ?, false), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) AND arrayExists(`body_v2.education`-> ifNull(dynamicElement(`body_v2.education`.`name`, 'String') = ?, false), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))",
Args: []any{"IIT", "MIT", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
// ── numeric literal against an Int64 array is collision-handled ───
{
name: "Nested Int64 array has collision",
filter: "has(body.education[].scores, 90)",
expected: TestExpected{
WhereClause: "arrayExists(`body_v2.education`-> arrayExists(x -> toFloat64(x) = ?, dynamicElement(`body_v2.education`.`scores`, 'Array(Nullable(Int64))')), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))",
Args: []any{float64(90), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
// ── hasAny folds multiple scalar arguments into one value set ─────
{
name: "hasAny folds multiple scalar args",
filter: "hasAny(body.user.permissions, 'read', 'write')",
expected: TestExpected{
WhereClause: "arrayExists(x -> x IN (?, ?), dynamicElement(body_v2.`user.permissions`, 'Array(Nullable(String))'))",
Args: []any{"read", "write", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
// ── hasToken over JSON string fields (nested leaf, top-level array, nested array) ──
{
name: "hasToken nested string leaf",
filter: "hasToken(body.education[].name, 'harvard')",
expected: TestExpected{
WhereClause: "arrayExists(`body_v2.education`-> ifNull(hasToken(LOWER(dynamicElement(`body_v2.education`.`name`, 'String')), LOWER(?)), false), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))",
Args: []any{"harvard", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
{
name: "hasToken top-level string array",
filter: "hasToken(body.user.permissions, 'admin')",
expected: TestExpected{
WhereClause: "arrayExists(x -> hasToken(LOWER(x), LOWER(?)), dynamicElement(body_v2.`user.permissions`, 'Array(Nullable(String))'))",
Args: []any{"admin", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
{
name: "hasToken nested string array",
filter: "hasToken(body.education[].awards[].participated[].members, 'piyush')",
expected: TestExpected{
WhereClause: "arrayExists(`body_v2.education`-> (arrayExists(`body_v2.education[].awards`-> (arrayExists(`body_v2.education[].awards[].participated`-> arrayExists(x -> hasToken(LOWER(x), LOWER(?)), dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))')), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')) OR arrayExists(`body_v2.education[].awards[].participated`-> arrayExists(x -> hasToken(LOWER(x), LOWER(?)), dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))')), arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), dynamicElement(`body_v2.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')) OR arrayExists(`body_v2.education[].awards`-> (arrayExists(`body_v2.education[].awards[].participated`-> arrayExists(x -> hasToken(LOWER(x), LOWER(?)), dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))')), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')) OR arrayExists(`body_v2.education[].awards[].participated`-> arrayExists(x -> hasToken(LOWER(x), LOWER(?)), dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))')), arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))",
Args: []any{"piyush", "piyush", "piyush", "piyush", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
// ── hasToken over the message field: bare body and explicit body.message are
// equivalent, both target the body.message column directly (no dynamicElement) ──
{
name: "hasToken bare body",
filter: "hasToken(body, 'production')",
expected: TestExpected{
WhereClause: "hasToken(LOWER(body.message), LOWER(?))",
Args: []any{"production", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{bodySearchDefaultWarning},
},
},
{
name: "hasToken explicit body.message",
filter: "hasToken(body.message, 'production')",
expected: TestExpected{
WhereClause: "hasToken(LOWER(body.message), LOWER(?))",
Args: []any{"production", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
// ── scalar (non-array) leaf: treated as a single-element set ─────────────
{
name: "Scalar leaf has",
filter: "has(body.user.name, 'alice')",
expected: TestExpected{
WhereClause: "ifNull(dynamicElement(body_v2.`user.name`, 'String') = ?, false)",
Args: []any{"alice", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
{
name: "Scalar leaf hasAny",
filter: "hasAny(body.user.name, ['alice', 'bob'])",
expected: TestExpected{
WhereClause: "ifNull(dynamicElement(body_v2.`user.name`, 'String') IN (?, ?), false)",
Args: []any{"alice", "bob", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
{
// contains-all: single-value hasAll over a plain scalar collapses to has.
name: "Scalar leaf hasAll single collapses to has",
filter: "hasAll(body.user.name, 'alice')",
expected: TestExpected{
WhereClause: "ifNull(dynamicElement(body_v2.`user.name`, 'String') = ?, false)",
Args: []any{"alice", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
{
// contains-all over a one-element set: the scalar must equal every value, so
// distinct values never match.
name: "Scalar leaf hasAll multi (contains-all)",
filter: "hasAll(body.user.name, ['alice', 'bob'])",
expected: TestExpected{
WhereClause: "(ifNull(dynamicElement(body_v2.`user.name`, 'String') = ?, false) AND ifNull(dynamicElement(body_v2.`user.name`, 'String') = ?, false))",
Args: []any{"alice", "bob", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
}
for _, c := range cases {

View File

@@ -130,3 +130,121 @@ func GetBodyJSONKey(_ context.Context, key *telemetrytypes.TelemetryFieldKey, op
func GetBodyJSONKeyForExists(_ context.Context, key *telemetrytypes.TelemetryFieldKey, _ qbtypes.FilterOperator, _ any) string {
return fmt.Sprintf("JSON_EXISTS(body, '$.%s')", getBodyJSONPath(key))
}
// legacyElemType infers the has-family element type from the needle (legacy has no schema). It
// scans EVERY value so the chosen array type and all coerced needles agree — else ClickHouse
// raises "no supertype ... String" (code 386). Int64 stays distinct from Float64 so a quoted
// integer is exact past 2^53 (unquoted literals already arrive as float64, parsed upstream).
func legacyElemType(needle any) telemetrytypes.FieldDataType {
list, ok := needle.([]any)
if !ok {
list = []any{needle}
}
if len(list) == 0 {
return telemetrytypes.FieldDataTypeString
}
allInt, allNumeric := true, true
for _, v := range list {
switch t := v.(type) {
case float32, float64:
allInt = false
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
// integer Go types stay int-exact
case string:
if _, err := strconv.ParseInt(t, 10, 64); err != nil {
allInt = false
}
if _, err := strconv.ParseFloat(t, 64); err != nil {
allNumeric = false
}
default:
// booleans (and anything else) -> String; a bool renders to 'true'/'false', so a
// bool needle only matches genuine JSON booleans, not truthy numbers/strings.
allInt, allNumeric = false, false
}
}
switch {
case allInt:
return telemetrytypes.FieldDataTypeInt64
case allNumeric:
return telemetrytypes.FieldDataTypeFloat64
default:
return telemetrytypes.FieldDataTypeString
}
}
// legacyCoerceNeedle coerces a needle to elem type dt so its bound-arg type matches the
// extracted column (legacyElemType guarantees it's coercible).
func legacyCoerceNeedle(v any, dt telemetrytypes.FieldDataType) any {
switch dt {
case telemetrytypes.FieldDataTypeInt64:
if s, ok := v.(string); ok {
if i, err := strconv.ParseInt(s, 10, 64); err == nil {
return i
}
}
return v
case telemetrytypes.FieldDataTypeFloat64:
if s, ok := v.(string); ok {
f, _ := strconv.ParseFloat(s, 64)
return f
}
return v
default:
return bodyArrayNeedleString(v)
}
}
// getBodyJSONArrayKey extracts the leaf as Array(Nullable(<dt>)) — Nullable so a value of a
// different JSON type maps to NULL instead of corrupting (e.g. a non-numeric string → 0).
func getBodyJSONArrayKey(key *telemetrytypes.TelemetryFieldKey, dt telemetrytypes.FieldDataType) string {
arrKey := *key
if !strings.HasSuffix(arrKey.Name, "[*]") && !strings.HasSuffix(arrKey.Name, "[]") {
arrKey.Name += "[*]"
}
return fmt.Sprintf("JSONExtract(JSON_QUERY(body, '$.%s'), 'Array(Nullable(%s))')", getBodyJSONPath(&arrKey), dt.CHDataType())
}
// getBodyJSONScalarKey builds the single-element-set fallback for a scalar body value: the leaf
// extracted as a scalar of type dt, plus a guard restricting it to a genuinely scalar body. The
// guard is required because JSON_VALUE returns '' for an array/object/missing value, which would
// otherwise zero-value match (has(x,0) / has(x,false) / has(x,'') on any array). ok=false when
// the path still traverses an array ([*]/[]).
func getBodyJSONScalarKey(key *telemetrytypes.TelemetryFieldKey, dt telemetrytypes.FieldDataType) (expr string, guard string, ok bool) {
name := strings.TrimSuffix(strings.TrimSuffix(key.Name, "[*]"), "[]")
if strings.Contains(name, "[") {
return "", "", false
}
scalarKey := *key
scalarKey.Name = name
path := getBodyJSONPath(&scalarKey)
if dt == telemetrytypes.FieldDataTypeString {
expr = fmt.Sprintf("JSON_VALUE(body, '$.%s')", path)
} else {
// Nullable so a scalar of a different type (e.g. a bool/string where a number is
// searched) extracts to NULL rather than the type's default (0/false), which would
// otherwise zero-value match has(x, 0).
expr = fmt.Sprintf("JSONExtract(JSON_VALUE(body, '$.%s'), 'Nullable(%s)')", path, dt.CHDataType())
}
keys := strings.Split(name, ".")
for i, k := range keys {
keys[i] = "'" + k + "'"
}
guard = fmt.Sprintf("JSONType(body, %s) NOT IN ('Array', 'Object', 'Null')", strings.Join(keys, ", "))
return expr, guard, true
}
func bodyArrayNeedleString(v any) string {
switch t := v.(type) {
case string:
return t
case bool:
return strconv.FormatBool(t)
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
case float32:
return strconv.FormatFloat(float64(t), 'f', -1, 64)
default:
return fmt.Sprintf("%v", t)
}
}

View File

@@ -496,8 +496,8 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE has(JSONExtract(JSON_QUERY(body, '$.\"user_names\"[*]'), 'Array(String)'), ?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"john_doe", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE (has(JSONExtract(JSON_QUERY(body, '$.\"user_names\"[*]'), 'Array(Nullable(String))'), ?) OR ifNull((JSON_VALUE(body, '$.\"user_names\"') = ? AND JSONType(body, 'user_names') NOT IN ('Array', 'Object', 'Null')), false)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"john_doe", "john_doe", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},

View File

@@ -55,6 +55,13 @@ func OneID(extractor ResourceIDExtractor) ResourceIDsExtractor {
}}
}
type ResourceWithID struct {
Resource Resource
ID string
}
type ResourceExtractor func(ExtractorContext) ([]ResourceWithID, error)
func PathParam(name string) ResourceIDExtractor {
return ResourceIDExtractor{Phase: PhaseRequest, Fn: func(ec ExtractorContext) (string, error) {
if ec.Request == nil {

View File

@@ -66,7 +66,7 @@ func MustNewObjectFromString(input string) *Object {
return &Object{Resource: resource, Selector: typed.MustSelector(orgParts[1])}
}
parts := strings.Split(input, "/")
parts := strings.SplitN(input, "/", 4)
if len(parts) != 4 {
panic(errors.Newf(errors.TypeInternal, errors.CodeInternal, "invalid input format: %s", input))
}

View File

@@ -289,6 +289,7 @@ var ManagedRoleToTransactions = map[string][]Transaction{
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeTelemetryResource, Kind: KindLogs}, WildCardSelectorString)},
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeTelemetryResource, Kind: KindTraces}, WildCardSelectorString)},
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeTelemetryResource, Kind: KindMetrics}, WildCardSelectorString)},
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeTelemetryResource, Kind: KindMeterMetrics}, WildCardSelectorString)},
// logs-field — editor reads+updates
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLogsField}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLogsField}, WildCardSelectorString)},
@@ -346,6 +347,7 @@ var ManagedRoleToTransactions = map[string][]Transaction{
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeTelemetryResource, Kind: KindLogs}, WildCardSelectorString)},
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeTelemetryResource, Kind: KindTraces}, WildCardSelectorString)},
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeTelemetryResource, Kind: KindMetrics}, WildCardSelectorString)},
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeTelemetryResource, Kind: KindMeterMetrics}, WildCardSelectorString)},
// logs-field — viewer reads
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLogsField}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindLogsField}, WildCardSelectorString)},

View File

@@ -23,5 +23,5 @@ var (
TypeRole = Type{valuer.NewString("role"), regexp.MustCompile(`^([a-z-]{1,50}|\*)$`), []Verb{VerbAssignee, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete, VerbAttach, VerbDetach}}
TypeOrganization = Type{valuer.NewString("organization"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbRead, VerbUpdate}}
TypeMetaResource = Type{valuer.NewString("metaresource"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete, VerbAttach, VerbDetach}}
TypeTelemetryResource = Type{valuer.NewString("telemetryresource"), regexp.MustCompile(`^\*$`), []Verb{VerbRead}}
TypeTelemetryResource = Type{valuer.NewString("telemetryresource"), regexp.MustCompile(`^(\*|[a-z_]{1,32}(/(\*|[A-Za-z0-9._%-]{1,128})){0,2})$`), []Verb{VerbRead}}
)

View File

@@ -30,6 +30,19 @@ func NewResolvedResource(
return resolved
}
func NewResolvedResourceWithID(verb Verb, category ActionCategory, resource Resource, id string, selector SelectorFunc) ResolvedResource {
resolved := &resolvedResource{verb: verb, category: category, resource: resource, selector: selector}
if id != "" {
resolved.ids = []string{id}
}
return resolved
}
func NewResolvedResourceWithError(verb Verb, category ActionCategory, err error) ResolvedResource {
return &resolvedResource{verb: verb, category: category, err: err}
}
func (resolved *resolvedResource) fill(phase ExtractPhase, ec ExtractorContext) {
if !resolved.idExtractor.IsPhase(phase) {
return

View File

@@ -1018,21 +1018,14 @@ func TestValidateRequiredFields(t *testing.T) {
data: wrapVariable("signoz/CustomVariable", `{}`),
wantContain: "CustomValue",
},
{
name: "ThresholdWithLabel missing value",
data: wrapPanel("signoz/TimeSeriesPanel", `{"thresholds": [{"color": "Red", "label": "high"}]}`),
wantContain: "Value",
},
// Value is intentionally not validate:"required" — 0 is a legitimate threshold
// (go-playground's required rejects a zero float), so a missing/zero value is
// accepted and only Color remains required on these threshold structs.
{
name: "ThresholdWithLabel missing color",
data: wrapPanel("signoz/TimeSeriesPanel", `{"thresholds": [{"value": 100, "label": "high", "color": ""}]}`),
wantContain: "Color",
},
{
name: "ComparisonThreshold missing value",
data: wrapPanel("signoz/NumberPanel", `{"thresholds": [{"operator": "above", "format": "text", "color": "Red"}]}`),
wantContain: "Value",
},
{
name: "ComparisonThreshold missing color",
data: wrapPanel("signoz/NumberPanel", `{"thresholds": [{"value": 100, "operator": "above", "format": "text", "color": ""}]}`),

View File

@@ -207,7 +207,7 @@ type HistogramBuckets struct {
}
type ListPanelSpec struct {
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitempty" validate:"dive"`
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitzero" validate:"dive"`
}
// ══════════════════════════════════════════════
@@ -252,14 +252,20 @@ type Legend struct {
}
type ThresholdWithLabel struct {
Value float64 `json:"value" validate:"required" required:"true"`
// Value is always present in the schema (required:"true"), but 0 is a legitimate
// threshold, so it drops validate:"required" — go-playground's required treats a
// zero float as unset and would wrongly reject value: 0.
Value float64 `json:"value" required:"true"`
Unit string `json:"unit"`
Color string `json:"color" validate:"required" required:"true"`
Label string `json:"label"`
}
type ComparisonThreshold struct {
Value float64 `json:"value" validate:"required" required:"true"`
// Value is always present in the schema (required:"true"), but 0 is a legitimate
// threshold, so it drops validate:"required" — go-playground's required treats a
// zero float as unset and would wrongly reject value: 0.
Value float64 `json:"value" required:"true"`
Operator ComparisonOperator `json:"operator"`
Unit string `json:"unit"`
Color string `json:"color" validate:"required" required:"true"`

View File

@@ -619,9 +619,9 @@ type SecondaryAggregation struct {
// if any, it will be used as the alias of the aggregation in the result
Alias string `json:"alias,omitempty"`
// groupBy fields to group by
GroupBy []GroupByKey `json:"groupBy,omitempty"`
GroupBy []GroupByKey `json:"groupBy,omitzero"`
// order by keys and directions
Order []OrderBy `json:"order,omitempty"`
Order []OrderBy `json:"order,omitzero"`
// limit the maximum number of rows to return
Limit int `json:"limit,omitempty"`
// limitBy fields to limit by
@@ -691,7 +691,7 @@ type Function struct {
Name FunctionName `json:"name"`
// args is the arguments to the function
Args []FunctionArg `json:"args,omitempty"`
Args []FunctionArg `json:"args,omitzero"`
}
// Copy creates a deep copy of Function.

View File

@@ -26,22 +26,22 @@ type QueryBuilderQuery[T any] struct {
// we want to support multiple aggregations
// currently supported: []Aggregation, []MetricAggregation
Aggregations []T `json:"aggregations,omitempty"`
Aggregations []T `json:"aggregations,omitzero"`
// disabled if true, the query will not be executed
Disabled bool `json:"disabled,omitempty"`
Disabled bool `json:"disabled"`
// search query is simple string
Filter *Filter `json:"filter,omitempty"`
// group by keys to group by
GroupBy []GroupByKey `json:"groupBy,omitempty"`
GroupBy []GroupByKey `json:"groupBy,omitzero"`
// order by keys and directions
Order []OrderBy `json:"order,omitempty"`
Order []OrderBy `json:"order,omitzero"`
// select columns to select
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitempty"`
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitzero"`
// limit the maximum number of rows to return
Limit int `json:"limit,omitempty"`
@@ -61,12 +61,12 @@ type QueryBuilderQuery[T any] struct {
// secondary aggregation to apply to the query
// on top of the primary aggregation
SecondaryAggregations []SecondaryAggregation `json:"secondaryAggregations,omitempty"`
SecondaryAggregations []SecondaryAggregation `json:"secondaryAggregations,omitzero"`
// functions to apply to the query
Functions []Function `json:"functions,omitempty"`
Functions []Function `json:"functions,omitzero"`
Legend string `json:"legend,omitempty"`
Legend string `json:"legend"`
// ShiftBy is extracted from timeShift function for internal use
// This field is not serialized to JSON

View File

@@ -8,7 +8,7 @@ type ClickHouseQuery struct {
// disabled if true, the query will not be executed
Disabled bool `json:"disabled"`
Legend string `json:"legend,omitempty"`
Legend string `json:"legend"`
}
// Copy creates a deep copy of the ClickHouseQuery.

View File

@@ -22,10 +22,10 @@ type QueryBuilderFormula struct {
// expression to apply to the query
Expression string `json:"expression"`
Disabled bool `json:"disabled,omitempty"`
Disabled bool `json:"disabled"`
// order by keys and directions
Order []OrderBy `json:"order,omitempty"`
Order []OrderBy `json:"order,omitzero"`
// limit the maximum number of rows to return
Limit int `json:"limit,omitempty"`
@@ -34,9 +34,9 @@ type QueryBuilderFormula struct {
Having *Having `json:"having,omitempty"`
// functions to apply to the formula result
Functions []Function `json:"functions,omitempty"`
Functions []Function `json:"functions,omitzero"`
Legend string `json:"legend,omitempty"`
Legend string `json:"legend"`
}
// Copy creates a deep copy of the QueryBuilderFormula.

View File

@@ -38,7 +38,7 @@ func (q QueryRef) Copy() QueryRef {
type QueryBuilderJoin struct {
Name string `json:"name"`
Disabled bool `json:"disabled,omitempty"`
Disabled bool `json:"disabled"`
// references into flat registry of queries
Left QueryRef `json:"left"`
@@ -50,18 +50,18 @@ type QueryBuilderJoin struct {
// primary aggregations: if empty ⇒ raw columns. Untyped — joins are deferred
// (see the commented JoinAggregation below).
Aggregations []any `json:"aggregations,omitempty"`
Aggregations []any `json:"aggregations,omitzero"`
// select columns to select
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitempty"`
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitzero"`
// post-join clauses (also used for aggregated joins)
Filter *Filter `json:"filter,omitempty"`
GroupBy []GroupByKey `json:"groupBy,omitempty"`
GroupBy []GroupByKey `json:"groupBy,omitzero"`
Having *Having `json:"having,omitempty"`
Order []OrderBy `json:"order,omitempty"`
Order []OrderBy `json:"order,omitzero"`
Limit int `json:"limit,omitempty"`
SecondaryAggregations []SecondaryAggregation `json:"secondaryAggregations,omitempty"`
Functions []Function `json:"functions,omitempty"`
SecondaryAggregations []SecondaryAggregation `json:"secondaryAggregations,omitzero"`
Functions []Function `json:"functions,omitzero"`
}
// JoinAggregation modelled a join aggregation as a trace/log/metric oneOf. Deferred:

View File

@@ -12,7 +12,7 @@ type PromQuery struct {
// stats if true, the query will return stats
Stats bool `json:"stats"`
Legend string `json:"legend,omitempty"`
Legend string `json:"legend"`
}
// Copy creates a deep copy of the PromQuery.

View File

@@ -328,6 +328,11 @@ func roundToNonZeroDecimals(val float64, n int) float64 {
// Round to n decimal places
multiplier := math.Pow(10, float64(n))
rounded := math.Round(val*multiplier) / multiplier
if math.IsInf(rounded, 0) {
// val*multiplier overflowed for near-max float64 values; the
// finite input must stay finite or the JSON encoder rejects it.
return val
}
// If the result is a whole number, return it as such
if rounded == math.Trunc(rounded) {
@@ -344,6 +349,11 @@ func roundToNonZeroDecimals(val float64, n int) float64 {
order := math.Floor(math.Log10(absVal))
scale := math.Pow(10, -order+float64(n)-1)
rounded := math.Round(val*scale) / scale
if math.IsNaN(rounded) || math.IsInf(rounded, 0) {
// scale overflowed for subnormal values (order below ~-308); the
// finite input must stay finite or it serializes as "NaN".
return val
}
// Clean up floating point precision
str := strconv.FormatFloat(rounded, 'f', -1, 64)

View File

@@ -395,3 +395,36 @@ func TestRawData_MarshalJSON(t *testing.T) {
})
}
}
func TestRoundToNonZeroDecimals(t *testing.T) {
cases := []struct {
name string
in float64
n int
want float64
}{
{"whole number", 42, 3, 42},
{"ge one rounds to three decimals", 123.45678, 3, 123.457},
{"ge one rounds to two decimals", 123.45678, 2, 123.46},
{"trailing zeros trimmed", 1.5, 3, 1.5},
{"lt one keeps three significant digits", 0.0001234567, 3, 0.000123},
{"lt one keeps two significant digits", 0.0001234567, 2, 0.00012},
{"negative", -123.45678, 3, -123.457},
{"zero", 0, 3, 0},
// val*multiplier overflows to +Inf for near-max values (#12151).
{"near max stays finite", 1.6e308, 3, 1.6e308},
{"negative near max stays finite", -1.6e308, 3, -1.6e308},
// scale overflows to +Inf for subnormal values; Inf/Inf made NaN.
{"smallest subnormal stays finite", 5e-324, 3, 5e-324},
{"subnormal stays finite", 1e-310, 3, 1e-310},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, roundToNonZeroDecimals(tc.in, tc.n))
})
}
assert.True(t, math.IsNaN(roundToNonZeroDecimals(math.NaN(), 3)))
assert.Equal(t, math.Inf(1), roundToNonZeroDecimals(math.Inf(1), 3))
assert.Equal(t, math.Inf(-1), roundToNonZeroDecimals(math.Inf(-1), 3))
}

View File

@@ -38,7 +38,7 @@ const (
type QueryBuilderTraceOperator struct {
Name string `json:"name"`
Disabled bool `json:"disabled,omitempty"`
Disabled bool `json:"disabled"`
Expression string `json:"expression"`
@@ -48,11 +48,11 @@ type QueryBuilderTraceOperator struct {
ReturnSpansFrom string `json:"returnSpansFrom,omitempty"`
// Trace-specific ordering (only span_count and trace_duration allowed)
Order []OrderBy `json:"order,omitempty"`
Order []OrderBy `json:"order,omitzero"`
Aggregations []TraceAggregation `json:"aggregations,omitempty"`
Aggregations []TraceAggregation `json:"aggregations,omitzero"`
StepInterval Step `json:"stepInterval,omitempty"`
GroupBy []GroupByKey `json:"groupBy,omitempty"`
GroupBy []GroupByKey `json:"groupBy,omitzero"`
// having clause to apply to the aggregated query results
Having *Having `json:"having,omitempty"`
@@ -61,11 +61,11 @@ type QueryBuilderTraceOperator struct {
Offset int `json:"offset,omitempty"`
Cursor string `json:"cursor,omitempty"`
Legend string `json:"legend,omitempty"`
Legend string `json:"legend"`
// Other post-processing options
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitempty"`
Functions []Function `json:"functions,omitempty"`
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitzero"`
Functions []Function `json:"functions,omitzero"`
// Internal parsed representation (not exposed in JSON)
ParsedExpression *TraceOperand `json:"-"`

View File

@@ -100,7 +100,7 @@ func (QueryType) Enum() []any {
}
type AlertCompositeQuery struct {
Queries []qbtypes.QueryEnvelope `json:"queries" required:"true" minItems:"1"`
Queries []qbtypes.QueryEnvelope `json:"queries" required:"true"`
PanelType PanelType `json:"panelType" required:"true"`
QueryType QueryType `json:"queryType" required:"true"`

View File

@@ -1,46 +0,0 @@
package ruletypes
import (
"github.com/swaggest/jsonschema-go"
)
var (
_ jsonschema.Preparer = (*PostableRule)(nil)
_ jsonschema.Preparer = (*RuleCondition)(nil)
)
// PrepareJSONSchema restricts the published rule schema to the v2alpha1
// contract: the v1-only fields (evalWindow, frequency, preferredChannels) and
// the query version (always "v5", defaulted by the server) are omitted, and
// the fields the server requires for schemaVersion v2alpha1 are marked
// required. Runtime JSON handling is unchanged and continues to load stored
// v1 rules until schemaVersion v1 is removed.
//
// Rule and GettableRule embed PostableRule, so this also applies to the
// published read models through method promotion.
func (r *PostableRule) PrepareJSONSchema(schema *jsonschema.Schema) error {
for _, name := range []string{"evalWindow", "frequency", "preferredChannels", "version", "source"} {
delete(schema.Properties, name)
}
schema.Required = append(schema.Required, "schemaVersion", "evaluation", "notificationSettings")
if prop, ok := schema.Properties["schemaVersion"]; ok && prop.TypeObject != nil {
prop.TypeObject.WithEnum(SchemaVersionV2Alpha1)
}
return nil
}
// PrepareJSONSchema removes the v1-only condition fields (target, op,
// matchType, targetUnit — expressed per threshold entry in v2alpha1) from the
// published schema and marks thresholds and selectedQueryName required.
func (rc *RuleCondition) PrepareJSONSchema(schema *jsonschema.Schema) error {
for _, name := range []string{"target", "op", "matchType", "targetUnit"} {
delete(schema.Properties, name)
}
schema.Required = append(schema.Required, "thresholds", "selectedQueryName")
return nil
}

View File

@@ -526,6 +526,13 @@
"read"
]
},
{
"type": "telemetryresource",
"kind": "meter-metrics",
"verbs": [
"read"
]
},
{
"type": "metaresource",
"kind": "logs-field",
@@ -675,6 +682,13 @@
"read"
]
},
{
"type": "telemetryresource",
"kind": "meter-metrics",
"verbs": [
"read"
]
},
{
"type": "metaresource",
"kind": "logs-field",

View File

@@ -1561,3 +1561,192 @@ def test_dashboard_v2_rejects_comma_separated_aggregation(
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
# ─── round-trip serialization of zero-valued fields ──────────────────────────
# A minimal dashboard stripped from SigNoz/dashboards (cicd-perses.json), whose
# NumberPanel carries a real `threshold value: 0`. It packs every field whose zero
# value the create -> GET round-trip must preserve: thresholds with value 0 (which
# the required-tag validation used to reject on create), builder slices set to an
# explicit [] that must echo back as [] (yet stay absent, never null, when unset),
# and scalars disabled/legend that must always echo false/"".
def test_dashboard_v2_roundtrip_preserves_zero_values(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {token}"}
dashboard = {
"schemaVersion": "v6",
"name": "roundtrip-zero-values",
"tags": [],
"spec": {
"display": {"name": "Roundtrip Zero Values"},
"panels": {
# NumberPanel: ComparisonThreshold value 0 + a builder query whose
# zero-valued slices/scalars are all set explicitly.
"number": {
"kind": "Panel",
"spec": {
"display": {"name": "number"},
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 0, "operator": "above_or_equal", "color": "#c2780b", "format": "background"}]},
},
"queries": [
{
"kind": "scalar",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "logs",
"aggregations": [{"expression": "count()"}],
"disabled": False,
"legend": "",
"groupBy": [],
"order": [],
"selectFields": [],
"functions": [],
},
}
},
}
],
},
},
# TimeSeriesPanel: ThresholdWithLabel value 0 + a bare builder query
# whose unset slices must be omitted (not null) on read-back.
"timeseries": {
"kind": "Panel",
"spec": {
"display": {"name": "timeseries"},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"thresholds": [{"value": 0, "color": "#c2780b"}]},
},
"queries": [
{
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]},
}
},
}
],
},
},
# PromQL query: legend/disabled must echo "" / false.
"promql": {
"kind": "Panel",
"spec": {
"display": {"name": "promql"},
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/PromQLQuery",
"spec": {"name": "A", "query": "up", "disabled": False, "legend": ""},
}
},
}
],
},
},
# ClickHouse query: legend/disabled must echo "" / false.
"clickhouse": {
"kind": "Panel",
"spec": {
"display": {"name": "clickhouse"},
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
"kind": "time_series",
"spec": {
"plugin": {
"kind": "signoz/ClickHouseSQL",
"spec": {"name": "A", "query": "SELECT 1", "disabled": False, "legend": ""},
}
},
}
],
},
},
},
},
}
# Create also asserts Bug 1: a threshold with value 0 is no longer rejected.
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=dashboard,
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
dashboard_id = response.json()["data"]["id"]
try:
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
panels = response.json()["data"]["spec"]["panels"]
def query_spec(panel_id: str) -> dict:
return panels[panel_id]["spec"]["queries"][0]["spec"]["plugin"]["spec"]
def threshold(panel_id: str) -> dict:
return panels[panel_id]["spec"]["plugin"]["spec"]["thresholds"][0]
number = query_spec("number")
timeseries = query_spec("timeseries")
promql = query_spec("promql")
clickhouse = query_spec("clickhouse")
# A value the create round-trips back verbatim: (description, actual, expected).
roundtrip_cases = [
("comparison threshold value 0", threshold("number")["value"], 0),
("threshold-with-label value 0", threshold("timeseries")["value"], 0),
("builder disabled false", number["disabled"], False),
("builder legend empty", number["legend"], ""),
("builder empty groupBy", number["groupBy"], []),
("builder empty order", number["order"], []),
("builder empty selectFields", number["selectFields"], []),
("builder empty functions", number["functions"], []),
("bare builder disabled false", timeseries["disabled"], False),
("bare builder legend empty", timeseries["legend"], ""),
("promql disabled false", promql["disabled"], False),
("promql legend empty", promql["legend"], ""),
("clickhouse disabled false", clickhouse["disabled"], False),
("clickhouse legend empty", clickhouse["legend"], ""),
]
for description, actual, expected in roundtrip_cases:
assert actual == expected, description
# An unset slice is omitted, never serialized as null: (description, spec, key).
absent_cases = [
("bare builder omits groupBy", timeseries, "groupBy"),
("bare builder omits order", timeseries, "order"),
("bare builder omits selectFields", timeseries, "selectFields"),
("bare builder omits functions", timeseries, "functions"),
]
for description, spec, key in absent_cases:
assert key not in spec, description
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
headers=headers,
timeout=5,
)

View File

@@ -0,0 +1,208 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
import requests
from wiremock.resources.mappings import Mapping
from fixtures import types
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
add_license,
change_user_role,
create_active_user,
)
from fixtures.logs import Logs
from fixtures.querier import build_raw_query, get_column_data_from_response, make_query_request
from fixtures.role import transaction_group
user_password = "password123Z$"
scoped_role = "telemetry-scope-svc-a"
scoped_email = "scope-svc-a@telemetry.test"
def test_setup(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
get_token: Callable[[str, str], str],
create_role: Callable[..., str],
) -> None:
add_license(signoz, make_http_mocks, get_token)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
create_role(
admin_token,
scoped_role,
[
transaction_group("read", "telemetryresource", "logs", ["builder_query/service.name/service-a"]),
transaction_group("read", "telemetryresource", "traces", ["builder_query/service.name/service-a"]),
],
)
user_id = create_active_user(signoz, admin_token, email=scoped_email, role="VIEWER", password=user_password)
change_user_role(signoz, admin_token, user_id, "signoz-viewer", scoped_role)
@pytest.mark.parametrize(
"selector",
[
"service.name = 'service-a'", # expression form, not the wire form
"builder_query/service.name/check out", # raw space
"builder_query/service.name/'quoted'", # quote
"builder_query/service.name/a/b/c", # too deep
],
)
def test_invalid_telemetry_selector_rejected(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
selector: str,
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/roles"),
json={
"name": "telemetry-invalid-selector",
"transactionGroups": [transaction_group("read", "telemetryresource", "logs", [selector])],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
@pytest.mark.parametrize(
"expression",
[
"service.name = 'service-a'",
"service.name IN ('service-a')",
"resource.service.name = 'service-a'",
"service.name = 'service-a' AND severity_text = 'ERROR'",
],
)
def test_allowed(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
expression: str,
) -> None:
now = datetime.now(tz=UTC)
# Seed a service-a log so the resource-attribute key resolves; without any
# ingested data the querier rejects the filter with "key not found".
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-a"}, body="service-a-0")])
response = make_query_request(
signoz,
get_token(scoped_email, user_password),
int((now - timedelta(minutes=10)).timestamp() * 1000),
int(now.timestamp() * 1000),
[build_raw_query("A", "logs", limit=50, filter_expression=expression)],
request_type="raw",
)
assert response.status_code == HTTPStatus.OK, response.text
@pytest.mark.parametrize(
"expression",
[
None, # no filter
"service.name = 'service-b'",
"service.name IN ('service-a', 'service-b')",
"service.name = 'service-a' OR severity_text = 'ERROR'",
"NOT service.name = 'service-a'",
"service.name != 'service-b'",
# Same result set as IN ('service-a','service-b'), but the OR spelling is not
# yet recognized as a bounded set, so it is denied today. This flips to
# allowed-with-both-grants once the where-clause bound evaluation lands.
"service.name = 'service-a' OR service.name = 'service-b'",
],
)
def test_denied(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
expression: str | None,
) -> None:
now = datetime.now(tz=UTC)
response = make_query_request(
signoz,
get_token(scoped_email, user_password),
int((now - timedelta(minutes=10)).timestamp() * 1000),
int(now.timestamp() * 1000),
[build_raw_query("A", "logs", limit=50, filter_expression=expression)],
request_type="raw",
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
assert "not authorized" in response.text
def test_denied_message_names_resource(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
) -> None:
now = datetime.now(tz=UTC)
response = make_query_request(
signoz,
get_token(scoped_email, user_password),
int((now - timedelta(minutes=10)).timestamp() * 1000),
int(now.timestamp() * 1000),
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'service-b'")],
request_type="raw",
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
assert "builder_query/service.name/service-b" in response.text
def test_variables_resolve_into_gate(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC)
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-a"}, body="service-a-0")])
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
token = get_token(scoped_email, user_password)
allowed = make_query_request(
signoz,
token,
start,
end,
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = $svc")],
request_type="raw",
variables={"svc": {"value": "service-a"}},
)
assert allowed.status_code == HTTPStatus.OK, allowed.text
denied = make_query_request(
signoz,
token,
start,
end,
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = $svc")],
request_type="raw",
variables={"svc": {"value": "service-b"}},
)
assert denied.status_code == HTTPStatus.FORBIDDEN, denied.text
def test_returns_only_scoped_rows(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC)
insert_logs([Logs(timestamp=now - timedelta(seconds=i + 1), resources={"service.name": "service-a"}, body=f"service-a-{i}") for i in range(3)] + [Logs(timestamp=now - timedelta(seconds=i + 1), resources={"service.name": "service-b"}, body=f"service-b-{i}") for i in range(3)])
response = make_query_request(
signoz,
get_token(scoped_email, user_password),
int((now - timedelta(minutes=10)).timestamp() * 1000),
int(now.timestamp() * 1000),
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'service-a'")],
request_type="raw",
)
assert response.status_code == HTTPStatus.OK, response.text
bodies = get_column_data_from_response(response.json(), "body")
assert bodies, "expected rows for service-a"
assert all(body.startswith("service-a") for body in bodies), bodies

View File

@@ -0,0 +1,112 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, change_user_role, create_active_user
from fixtures.logs import Logs
from fixtures.querier import build_raw_query, get_column_data_from_response, make_query_request
from fixtures.role import transaction_group
user_password = "password123Z$"
any_service_role = "telemetry-scope-any-service"
any_service_email = "scope-any-service@telemetry.test"
builder_all_role = "telemetry-scope-builder-all"
builder_all_email = "scope-builder-all@telemetry.test"
def test_setup(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_role: Callable[..., str],
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
create_role(admin_token, any_service_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/service.name/*"])])
any_user = create_active_user(signoz, admin_token, email=any_service_email, role="VIEWER", password=user_password)
change_user_role(signoz, admin_token, any_user, "signoz-viewer", any_service_role)
create_role(admin_token, builder_all_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/*"])])
all_user = create_active_user(signoz, admin_token, email=builder_all_email, role="VIEWER", password=user_password)
change_user_role(signoz, admin_token, all_user, "signoz-viewer", builder_all_role)
def test_service_wildcard_allows_any_single_service(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC)
insert_logs(
[
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-a"}, body="service-a-0"),
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-b"}, body="service-b-0"),
]
)
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
token = get_token(any_service_email, user_password)
service_a = make_query_request(signoz, token, start, end, [build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'service-a'")], request_type="raw")
assert service_a.status_code == HTTPStatus.OK, service_a.text
service_b = make_query_request(signoz, token, start, end, [build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'service-b'")], request_type="raw")
assert service_b.status_code == HTTPStatus.OK, service_b.text
def test_service_wildcard_denies_unfiltered(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
) -> None:
now = datetime.now(tz=UTC)
response = make_query_request(
signoz,
get_token(any_service_email, user_password),
int((now - timedelta(minutes=10)).timestamp() * 1000),
int(now.timestamp() * 1000),
[build_raw_query("A", "logs", limit=50)],
request_type="raw",
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
def test_builder_wildcard_allows_unfiltered(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
) -> None:
now = datetime.now(tz=UTC)
response = make_query_request(
signoz,
get_token(builder_all_email, user_password),
int((now - timedelta(minutes=10)).timestamp() * 1000),
int(now.timestamp() * 1000),
[build_raw_query("A", "logs", limit=50)],
request_type="raw",
)
assert response.status_code == HTTPStatus.OK, response.text
def test_admin_allows_unfiltered_across_services(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC)
insert_logs(
[
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-a"}, body="service-a-0"),
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-b"}, body="service-b-0"),
]
)
response = make_query_request(
signoz,
get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD),
int((now - timedelta(minutes=10)).timestamp() * 1000),
int(now.timestamp() * 1000),
[build_raw_query("A", "logs", limit=50)],
request_type="raw",
)
assert response.status_code == HTTPStatus.OK, response.text
bodies = get_column_data_from_response(response.json(), "body")
assert any(body.startswith("service-b") for body in bodies), bodies

View File

@@ -0,0 +1,152 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import querier, types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, change_user_role, create_active_user
from fixtures.querier import make_query_request
from fixtures.role import transaction_group
user_password = "password123Z$"
chsql_role = "telemetry-scope-chsql"
chsql_email = "scope-chsql@telemetry.test"
svc_a_role = "telemetry-qt-svc-a"
svc_a_email = "qt-svc-a@telemetry.test"
viewer_email = "qt-managed-viewer@telemetry.test"
clickhouse_query = [{"type": "clickhouse_sql", "spec": {"name": "A", "query": "SELECT toFloat64(1.5) AS `__result_0`", "disabled": False}}]
promql_query = [{"type": "promql", "spec": {"name": "A", "query": "up", "disabled": False}}]
meter_query = [{"type": "builder_query", "spec": {"name": "A", "signal": "metrics", "source": "meter", "disabled": False, "aggregations": [{"metricName": "signoz_metering", "timeAggregation": "sum", "spaceAggregation": "sum"}]}}]
audit_query = [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "source": "audit", "disabled": False}}]
def test_setup(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_role: Callable[..., str],
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
create_role(
admin_token,
chsql_role,
[
transaction_group("read", "telemetryresource", "logs", ["clickhouse_sql/*"]),
transaction_group("read", "telemetryresource", "traces", ["clickhouse_sql/*"]),
transaction_group("read", "telemetryresource", "metrics", ["clickhouse_sql/*"]),
transaction_group("read", "telemetryresource", "meter-metrics", ["clickhouse_sql/*"]),
],
)
chsql_user = create_active_user(signoz, admin_token, email=chsql_email, role="VIEWER", password=user_password)
change_user_role(signoz, admin_token, chsql_user, "signoz-viewer", chsql_role)
create_role(admin_token, svc_a_role, [transaction_group("read", "telemetryresource", "traces", ["builder_query/service.name/service-a"])])
svc_a_user = create_active_user(signoz, admin_token, email=svc_a_email, role="VIEWER", password=user_password)
change_user_role(signoz, admin_token, svc_a_user, "signoz-viewer", svc_a_role)
# A plain managed viewer (signoz-viewer) — for the meter-metrics/audit-logs policy checks.
create_active_user(signoz, admin_token, email=viewer_email, role="VIEWER", password=user_password)
def test_clickhouse_sql_requires_chsql_grant(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
) -> None:
now = datetime.now(tz=UTC)
start, end = int((now - timedelta(hours=1)).timestamp() * 1000), int(now.timestamp() * 1000)
granted = make_query_request(signoz, get_token(chsql_email, user_password), start, end, clickhouse_query, request_type=querier.RequestType.SCALAR)
assert granted.status_code == HTTPStatus.OK, granted.text
scoped = make_query_request(signoz, get_token(svc_a_email, user_password), start, end, clickhouse_query, request_type=querier.RequestType.SCALAR)
assert scoped.status_code == HTTPStatus.FORBIDDEN, scoped.text
admin = make_query_request(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), start, end, clickhouse_query, request_type=querier.RequestType.SCALAR)
assert admin.status_code == HTTPStatus.OK, admin.text
def test_promql_requires_promql_grant(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
) -> None:
now = datetime.now(tz=UTC)
start, end = int((now - timedelta(hours=1)).timestamp() * 1000), int(now.timestamp() * 1000)
# Neither the chsql grant nor a builder-service grant covers promql.
scoped = make_query_request(signoz, get_token(svc_a_email, user_password), start, end, promql_query, request_type=querier.RequestType.TIME_SERIES)
assert scoped.status_code == HTTPStatus.FORBIDDEN, scoped.text
# Admin holds the wildcard; authz passes (the handler may still 2xx/4xx, never 403).
admin = make_query_request(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), start, end, promql_query, request_type=querier.RequestType.TIME_SERIES)
assert admin.status_code != HTTPStatus.FORBIDDEN, admin.text
def test_trace_operator_rides_on_referenced_queries(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
) -> None:
now = datetime.now(tz=UTC)
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
token = get_token(svc_a_email, user_password)
def operator_queries(b_service: str) -> list[dict]:
return [
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": True, "filter": {"expression": "service.name = 'service-a'"}, "aggregations": [{"expression": "count()"}]}},
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "disabled": True, "filter": {"expression": f"service.name = '{b_service}'"}, "aggregations": [{"expression": "count()"}]}},
{"type": "builder_trace_operator", "spec": {"name": "T1", "expression": "A => B", "returnSpansFrom": "A", "disabled": False}},
]
allowed = make_query_request(signoz, token, start, end, operator_queries("service-a"), request_type=querier.RequestType.RAW)
assert allowed.status_code == HTTPStatus.OK, allowed.text
denied = make_query_request(signoz, token, start, end, operator_queries("service-b"), request_type=querier.RequestType.RAW)
assert denied.status_code == HTTPStatus.FORBIDDEN, denied.text
def test_formula_rides_on_referenced_queries(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
) -> None:
now = datetime.now(tz=UTC)
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
token = get_token(svc_a_email, user_password)
def formula_queries(b_filtered: bool) -> list[dict]:
b_spec = {"name": "B", "signal": "traces", "disabled": True, "aggregations": [{"expression": "count()"}]}
if b_filtered:
b_spec["filter"] = {"expression": "service.name = 'service-a'"}
return [
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": True, "filter": {"expression": "service.name = 'service-a'"}, "aggregations": [{"expression": "count()"}]}},
{"type": "builder_query", "spec": b_spec},
{"type": "builder_formula", "spec": {"name": "F1", "expression": "A/B", "disabled": False}},
]
allowed = make_query_request(signoz, token, start, end, formula_queries(b_filtered=True), request_type=querier.RequestType.SCALAR)
assert allowed.status_code == HTTPStatus.OK, allowed.text
denied = make_query_request(signoz, token, start, end, formula_queries(b_filtered=False), request_type=querier.RequestType.SCALAR)
assert denied.status_code == HTTPStatus.FORBIDDEN, denied.text
def test_managed_viewer_meter_and_clickhouse_allowed_audit_denied(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
) -> None:
now = datetime.now(tz=UTC)
start, end = int((now - timedelta(hours=1)).timestamp() * 1000), int(now.timestamp() * 1000)
token = get_token(viewer_email, user_password)
# meter-metrics is dashboard-able usage metrics, granted to viewers (authz passes;
# the query itself may still 4xx on data, but never 403).
meter = make_query_request(signoz, token, start, end, meter_query, request_type=querier.RequestType.SCALAR)
assert meter.status_code != HTTPStatus.FORBIDDEN, meter.text
# clickhouse_sql omits audit-logs until audit logs ship, so a viewer (logs/traces/
# metrics/meter-metrics) satisfies its grants.
clickhouse = make_query_request(signoz, token, start, end, clickhouse_query, request_type=querier.RequestType.SCALAR)
assert clickhouse.status_code != HTTPStatus.FORBIDDEN, clickhouse.text
# audit-logs builder queries remain admin-only.
audit = make_query_request(signoz, token, start, end, audit_query, request_type=querier.RequestType.RAW)
assert audit.status_code == HTTPStatus.FORBIDDEN, audit.text

View File

@@ -0,0 +1,62 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, change_user_role, create_active_user
from fixtures.logs import Logs
from fixtures.querier import build_raw_query, make_query_request
from fixtures.role import transaction_group
user_password = "password123Z$"
spacey_role = "telemetry-scope-spacey"
spacey_email = "scope-spacey@telemetry.test"
# The service name has a space; its canonical selector escapes it to %20.
spacey_service = "check out"
def test_setup(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_role: Callable[..., str],
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
create_role(admin_token, spacey_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/service.name/check%20out"])])
user_id = create_active_user(signoz, admin_token, email=spacey_email, role="VIEWER", password=user_password)
change_user_role(signoz, admin_token, user_id, "signoz-viewer", spacey_role)
def test_escaped_value_parity_allows_matching_service(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC)
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": spacey_service}, body="spacey-0")])
response = make_query_request(
signoz,
get_token(spacey_email, user_password),
int((now - timedelta(minutes=10)).timestamp() * 1000),
int(now.timestamp() * 1000),
[build_raw_query("A", "logs", limit=50, filter_expression=f"service.name = '{spacey_service}'")],
request_type="raw",
)
assert response.status_code == HTTPStatus.OK, response.text
def test_escaped_value_denies_other_service(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
) -> None:
now = datetime.now(tz=UTC)
response = make_query_request(
signoz,
get_token(spacey_email, user_password),
int((now - timedelta(minutes=10)).timestamp() * 1000),
int(now.timestamp() * 1000),
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'checkout'")],
request_type="raw",
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text

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