Compare commits

..

4 Commits

Author SHA1 Message Date
nityanandagohain
a849eac876 fix: lint 2026-09-09 14:22:07 +05:30
nityanandagohain
5258f5e8e3 fix: more cleanup 2026-09-09 14:13:27 +05:30
nityanandagohain
8292988751 Merge remote-tracking branch 'origin/main' into issue_5329 2026-09-09 13:28:46 +05:30
nityanandagohain
6e96a5ce37 feat: support for default attribute mapping 2026-09-09 13:27:25 +05:30
75 changed files with 2862 additions and 4678 deletions

View File

@@ -8616,98 +8616,6 @@ components:
message:
type: string
type: object
RuletypesLabelPair:
properties:
key:
type: string
value:
type: string
required:
- key
- value
type: object
RuletypesListOrder:
enum:
- asc
- desc
type: string
RuletypesListSort:
enum:
- updated_at
- created_at
- name
- state
- severity
type: string
RuletypesListableRule:
properties:
alert:
type: string
alertType:
$ref: '#/components/schemas/RuletypesAlertType'
createdAt:
format: date-time
type: string
createdBy:
type: string
description:
type: string
disabled:
type: boolean
id:
type: string
labels:
additionalProperties:
type: string
type: object
ruleType:
$ref: '#/components/schemas/RuletypesRuleType'
state:
$ref: '#/components/schemas/RuletypesAlertState'
updatedAt:
format: date-time
type: string
updatedBy:
type: string
required:
- id
- state
- alert
- alertType
- ruleType
type: object
RuletypesListableRuleViews:
properties:
views:
items:
$ref: '#/components/schemas/RuletypesRuleView'
type: array
required:
- views
type: object
RuletypesListableRules:
properties:
labels:
items:
$ref: '#/components/schemas/RuletypesLabelPair'
type: array
reservedKeywords:
items:
type: string
type: array
rules:
items:
$ref: '#/components/schemas/RuletypesListableRule'
type: array
total:
format: int64
type: integer
required:
- rules
- total
- labels
- reservedKeywords
type: object
RuletypesMatchType:
enum:
- at_least_once
@@ -8782,16 +8690,6 @@ components:
- ruleType
- condition
type: object
RuletypesPostableRuleView:
properties:
data:
$ref: '#/components/schemas/RuletypesRuleViewData'
name:
type: string
required:
- name
- data
type: object
RuletypesQueryType:
enum:
- builder
@@ -8930,46 +8828,6 @@ components:
- promql_rule
- anomaly_rule
type: string
RuletypesRuleView:
properties:
createdAt:
format: date-time
type: string
data:
$ref: '#/components/schemas/RuletypesRuleViewData'
id:
type: string
name:
type: string
orgId:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- data
- orgId
type: object
RuletypesRuleViewData:
properties:
order:
$ref: '#/components/schemas/RuletypesListOrder'
query:
type: string
sort:
$ref: '#/components/schemas/RuletypesListSort'
states:
items:
type: string
nullable: true
type: array
version:
type: string
required:
- version
type: object
RuletypesScheduleType:
enum:
- hourly
@@ -9569,6 +9427,8 @@ components:
type: string
name:
type: string
origin:
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
updatedAt:
format: date-time
type: string
@@ -9581,6 +9441,7 @@ components:
- fieldContext
- config
- enabled
- origin
type: object
SpantypesSpanMapperConfig:
properties:
@@ -9609,48 +9470,75 @@ components:
type: string
orgId:
type: string
origin:
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
updatedAt:
format: date-time
type: string
updatedBy:
type: string
version:
type: integer
required:
- id
- orgId
- name
- condition
- enabled
- origin
- version
type: object
SpantypesSpanMapperGroupCondition:
nullable: true
properties:
attributes:
items:
type: string
$ref: '#/components/schemas/SpantypesSpanMapperGroupConditionKey'
nullable: true
type: array
resource:
items:
type: string
$ref: '#/components/schemas/SpantypesSpanMapperGroupConditionKey'
nullable: true
type: array
required:
- attributes
- resource
type: object
SpantypesSpanMapperGroupConditionKey:
properties:
enabled:
type: boolean
origin:
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
value:
type: string
required:
- value
- enabled
type: object
SpantypesSpanMapperOperation:
enum:
- move
- copy
type: string
SpantypesSpanMapperOrigin:
enum:
- user
- system
type: string
SpantypesSpanMapperSource:
properties:
context:
$ref: '#/components/schemas/SpantypesFieldContext'
enabled:
type: boolean
key:
type: string
operation:
$ref: '#/components/schemas/SpantypesSpanMapperOperation'
origin:
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
priority:
type: integer
required:
@@ -9658,6 +9546,7 @@ components:
- context
- operation
- priority
- enabled
type: object
SpantypesSpanMapperTestSpan:
properties:
@@ -20456,237 +20345,11 @@ paths:
summary: Get users by role id
tags:
- users
/api/v2/rule_views:
get:
deprecated: false
description: Returns every saved view in the calling user's org. Saved views
are shared org-wide.
operationId: ListRuleViews
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesListableRuleViews'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List rule saved views
tags:
- rules
post:
deprecated: false
description: Persists the calling user's rule listing state (query, states,
sort, order) as a named, reusable view shared across the org.
operationId: CreateRuleView
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RuletypesPostableRuleView'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesRuleView'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Create rule saved view
tags:
- rules
/api/v2/rule_views/{id}:
delete:
deprecated: false
description: Removes a saved view. Saved views are shared org-wide. Deleting
a non-existent view returns 404.
operationId: DeleteRuleView
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Delete rule saved view
tags:
- rules
put:
deprecated: false
description: Replaces a saved view's name and data. Saved views are shared org-wide.
operationId: UpdateRuleView
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RuletypesPostableRuleView'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesRuleView'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Update rule saved view
tags:
- rules
/api/v2/rules:
get:
deprecated: true
description: 'This endpoint lists all alert rules with their current evaluation
state. Deprecated: use ListRulesV3, which supports filtering, sorting and
pagination.'
deprecated: false
description: This endpoint lists all alert rules with their current evaluation
state
operationId: ListRules
responses:
"200":
@@ -26468,90 +26131,6 @@ paths:
summary: Get metric dashboards (v2)
tags:
- metrics
/api/v3/rules:
get:
deprecated: false
description: Returns a page of alert rules with their current evaluation state,
trimmed to the fields the list page renders. Supports a filter DSL (`query`),
a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`),
order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). The
response also carries the org's label pairs and the reserved filter keys for
building filter suggestions.
operationId: ListRulesV3
parameters:
- in: query
name: query
schema:
type: string
- in: query
name: states
schema:
items:
type: string
type: array
- in: query
name: sort
schema:
$ref: '#/components/schemas/RuletypesListSort'
- in: query
name: order
schema:
$ref: '#/components/schemas/RuletypesListOrder'
- in: query
name: limit
schema:
type: integer
- in: query
name: offset
schema:
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesListableRules'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List alert rules (v3)
tags:
- rules
/api/v3/traces/{traceID}/flamegraph:
post:
deprecated: false

View File

@@ -23,15 +23,6 @@ func (f *formatter) JSONExtractString(column, path string) []byte {
return append(f.TextToJsonColumn(column), ops...)
}
func (f *formatter) JSONExtractMapValue(column, mapField, key string) []byte {
sql := f.TextToJsonColumn(column)
sql = append(sql, "->"...)
sql = schema.Append(f.bunf, sql, mapField)
sql = append(sql, "->>"...)
sql = schema.Append(f.bunf, sql, key)
return sql
}
func (f *formatter) JSONType(column, path string) []byte {
var sql []byte
sql = append(sql, "jsonb_typeof("...)

View File

@@ -55,39 +55,6 @@ func TestJSONExtractString(t *testing.T) {
}
}
func TestJSONExtractMapValue(t *testing.T) {
tests := []struct {
name string
column string
mapField string
key string
expected string
}{
{
name: "plain key",
column: "data",
mapField: "labels",
key: "team",
expected: `"data"::jsonb->'labels'->>'team'`,
},
{
name: "dotted key stays one map entry",
column: "data",
mapField: "labels",
key: "k8s.cluster",
expected: `"data"::jsonb->'labels'->>'k8s.cluster'`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got := string(f.JSONExtractMapValue(tt.column, tt.mapField, tt.key))
assert.Equal(t, tt.expected, got)
})
}
}
func TestJSONType(t *testing.T) {
tests := []struct {
name string

View File

@@ -41,8 +41,6 @@ import type {
GetRuleHistoryTopContributorsParams,
GetRuleHistoryTopContributorsPathParameters,
ListRules200,
ListRulesV3200,
ListRulesV3Params,
PatchRuleByID200,
PatchRuleByIDPathParameters,
RenderErrorResponseDTO,
@@ -55,8 +53,7 @@ import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.
* @deprecated
* This endpoint lists all alert rules with their current evaluation state
* @summary List alert rules
*/
export const listRules = (signal?: AbortSignal) => {
@@ -98,7 +95,6 @@ export type ListRulesQueryResult = NonNullable<
export type ListRulesQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary List alert rules
*/
@@ -118,7 +114,6 @@ export function useListRules<
}
/**
* @deprecated
* @summary List alert rules
*/
export const invalidateListRules = async (
@@ -1373,97 +1368,3 @@ export const useTestRule = <
> => {
return useMutation(getTestRuleMutationOptions(options));
};
/**
* Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.
* @summary List alert rules (v3)
*/
export const listRulesV3 = (
params?: ListRulesV3Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListRulesV3200>({
url: `/api/v3/rules`,
method: 'GET',
params,
signal,
});
};
export const getListRulesV3QueryKey = (params?: ListRulesV3Params) => {
return [`/api/v3/rules`, ...(params ? [params] : [])] as const;
};
export const getListRulesV3QueryOptions = <
TData = Awaited<ReturnType<typeof listRulesV3>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListRulesV3Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listRulesV3>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListRulesV3QueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRulesV3>>> = ({
signal,
}) => listRulesV3(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listRulesV3>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListRulesV3QueryResult = NonNullable<
Awaited<ReturnType<typeof listRulesV3>>
>;
export type ListRulesV3QueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List alert rules (v3)
*/
export function useListRulesV3<
TData = Awaited<ReturnType<typeof listRulesV3>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListRulesV3Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listRulesV3>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListRulesV3QueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List alert rules (v3)
*/
export const invalidateListRulesV3 = async (
queryClient: QueryClient,
params?: ListRulesV3Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListRulesV3QueryKey(params) },
options,
);
return queryClient;
};

View File

@@ -9896,99 +9896,6 @@ export interface RuletypesGettableTestRuleDTO {
message?: string;
}
export interface RuletypesLabelPairDTO {
/**
* @type string
*/
key: string;
/**
* @type string
*/
value: string;
}
export enum RuletypesListOrderDTO {
asc = 'asc',
desc = 'desc',
}
export enum RuletypesListSortDTO {
updated_at = 'updated_at',
created_at = 'created_at',
name = 'name',
state = 'state',
severity = 'severity',
}
export type RuletypesListableRuleDTOLabels = { [key: string]: string };
export enum RuletypesRuleTypeDTO {
threshold_rule = 'threshold_rule',
promql_rule = 'promql_rule',
anomaly_rule = 'anomaly_rule',
}
export interface RuletypesListableRuleDTO {
/**
* @type string
*/
alert: string;
alertType: RuletypesAlertTypeDTO;
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
description?: string;
/**
* @type boolean
*/
disabled?: boolean;
/**
* @type string
*/
id: string;
/**
* @type object
*/
labels?: RuletypesListableRuleDTOLabels;
ruleType: RuletypesRuleTypeDTO;
state: RuletypesAlertStateDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface RuletypesListableRulesDTO {
/**
* @type array
*/
labels: RuletypesLabelPairDTO[];
/**
* @type array
*/
reservedKeywords: string[];
/**
* @type array
*/
rules: RuletypesListableRuleDTO[];
/**
* @type integer
* @format int64
*/
total: number;
}
export interface RuletypesRenotifyDTO {
/**
* @type array,null
@@ -10085,6 +9992,11 @@ export interface RuletypesRuleConditionDTO {
thresholds?: RuletypesRuleThresholdDataDTO;
}
export enum RuletypesRuleTypeDTO {
threshold_rule = 'threshold_rule',
promql_rule = 'promql_rule',
anomaly_rule = 'anomaly_rule',
}
export interface RuletypesPostableRuleDTO {
/**
* @type string
@@ -10606,15 +10518,31 @@ export interface SpantypesGettableFlamegraphTraceDTO {
startTimestampMillis: number;
}
export enum SpantypesSpanMapperOriginDTO {
user = 'user',
system = 'system',
}
export interface SpantypesSpanMapperGroupConditionKeyDTO {
/**
* @type boolean
*/
enabled: boolean;
origin?: SpantypesSpanMapperOriginDTO;
/**
* @type string
*/
value: string;
}
export type SpantypesSpanMapperGroupConditionDTOAnyOf = {
/**
* @type array,null
*/
attributes: string[] | null;
attributes: SpantypesSpanMapperGroupConditionKeyDTO[] | null;
/**
* @type array,null
*/
resource: string[] | null;
resource: SpantypesSpanMapperGroupConditionKeyDTO[] | null;
};
/**
@@ -10650,6 +10578,7 @@ export interface SpantypesSpanMapperGroupDTO {
* @type string
*/
orgId: string;
origin: SpantypesSpanMapperOriginDTO;
/**
* @type string
* @format date-time
@@ -10659,6 +10588,10 @@ export interface SpantypesSpanMapperGroupDTO {
* @type string
*/
updatedBy?: string;
/**
* @type integer
*/
version: number;
}
export interface SpantypesGettableSpanMapperGroupsDTO {
@@ -10716,11 +10649,16 @@ export enum SpantypesSpanMapperOperationDTO {
}
export interface SpantypesSpanMapperSourceDTO {
context: SpantypesFieldContextDTO;
/**
* @type boolean
*/
enabled: boolean;
/**
* @type string
*/
key: string;
operation: SpantypesSpanMapperOperationDTO;
origin?: SpantypesSpanMapperOriginDTO;
/**
* @type integer
*/
@@ -10762,6 +10700,7 @@ export interface SpantypesSpanMapperDTO {
* @type string
*/
name: string;
origin: SpantypesSpanMapperOriginDTO;
/**
* @type string
* @format date-time
@@ -13918,45 +13857,6 @@ export type GetMetricDashboardsV2200 = {
status: string;
};
export type ListRulesV3Params = {
/**
* @type string
* @description undefined
*/
query?: string;
/**
* @type array
* @description undefined
*/
states?: string[];
/**
* @description undefined
*/
sort?: RuletypesListSortDTO;
/**
* @description undefined
*/
order?: RuletypesListOrderDTO;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @description undefined
*/
offset?: number;
};
export type ListRulesV3200 = {
data: RuletypesListableRulesDTO;
/**
* @type string
*/
status: string;
};
export type GetFlamegraphPathParameters = {
traceID: string;
};

View File

@@ -333,6 +333,7 @@ describe('AttributeMappingsTab (integration)', () => {
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority,
enabled: true,
})),
},
}),

View File

@@ -1,10 +1,12 @@
import { Typography } from '@signozhq/ui/typography';
import { ConditionKey } from 'container/LLMObservability/AttributeMapping/types';
import styles from './ConditionsTooltip.module.scss';
interface ConditionsTooltipProps {
attributes: string[];
resource: string[];
attributes: ConditionKey[];
resource: ConditionKey[];
}
function ConditionsTooltip({
@@ -33,8 +35,8 @@ function ConditionsTooltip({
</Typography.Text>
<div className={styles.keyList}>
{attributes.map((key) => (
<code key={key} className={styles.key}>
{key}
<code key={`${key.origin}-${key.value}`} className={styles.key}>
{key.value}
</code>
))}
</div>
@@ -47,8 +49,8 @@ function ConditionsTooltip({
</Typography.Text>
<div className={styles.keyList}>
{resource.map((key) => (
<code key={key} className={styles.key}>
{key}
<code key={`${key.origin}-${key.value}`} className={styles.key}>
{key.value}
</code>
))}
</div>

View File

@@ -3,6 +3,7 @@ import {
SpantypesSpanMapperDTO as Mapper,
SpantypesSpanMapperGroupDTO as MapperGroup,
SpantypesSpanMapperOperationDTO as MapperOperation,
SpantypesSpanMapperOriginDTO as MapperOrigin,
SpantypesSpanMapperTestSpanDTO as TestSpan,
} from 'api/generated/services/sigNoz.schemas';
@@ -21,9 +22,15 @@ export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
orgId: 'org-1',
name: 'demo',
enabled: true,
origin: MapperOrigin.user,
version: 0,
condition: {
attributes: ['ai.embeddings'],
resource: ['cloud.account.id'],
attributes: [
{ value: 'ai.embeddings', enabled: true, origin: MapperOrigin.user },
],
resource: [
{ value: 'cloud.account.id', enabled: true, origin: MapperOrigin.user },
],
},
...overrides,
};
@@ -35,6 +42,7 @@ export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
groupId: 'group-1',
name: 'gen_ai.request.model',
enabled: true,
origin: MapperOrigin.user,
fieldContext: FieldContext.attribute,
config: {
sources: [
@@ -43,12 +51,16 @@ export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority: 2,
enabled: true,
origin: MapperOrigin.user,
},
{
key: 'llm.model',
context: FieldContext.attribute,
operation: MapperOperation.move,
priority: 1,
enabled: true,
origin: MapperOrigin.user,
},
],
},
@@ -85,8 +97,12 @@ export const mockGroups: MapperGroup[] = [
id: 'group-1',
name: 'demo',
condition: {
attributes: ['ai.embeddings'],
resource: ['cloud.account.id'],
attributes: [
{ value: 'ai.embeddings', enabled: true, origin: MapperOrigin.user },
],
resource: [
{ value: 'cloud.account.id', enabled: true, origin: MapperOrigin.user },
],
},
}),
makeGroup({

View File

@@ -1,19 +1,23 @@
import { Button } from '@signozhq/ui/button';
import { Plus, X } from '@signozhq/icons';
import { FieldContextValue } from 'container/LLMObservability/AttributeMapping/types';
import {
ConditionKey,
FieldContextValue,
} from 'container/LLMObservability/AttributeMapping/types';
import { createConditionKey } from 'container/LLMObservability/AttributeMapping/utils';
import KeySearchInput from '../../../KeySearchInput/KeySearchInput';
import styles from './ConditionKeyList.module.scss';
interface ConditionKeyListProps {
label: string;
labelHint?: string;
keys: string[];
keys: ConditionKey[];
placeholder: string;
addLabel: string;
testIdPrefix: string;
fieldContext: FieldContextValue;
onChange: (keys: string[]) => void;
onChange: (keys: ConditionKey[]) => void;
}
function ConditionKeyList({
@@ -27,11 +31,11 @@ function ConditionKeyList({
onChange,
}: ConditionKeyListProps): JSX.Element {
const updateKey = (index: number, value: string): void => {
onChange(keys.map((key, i) => (i === index ? value : key)));
onChange(keys.map((key, i) => (i === index ? { ...key, value } : key)));
};
const addKey = (): void => {
onChange([...keys, '']);
onChange([...keys, createConditionKey()]);
};
const removeKey = (index: number): void => {
@@ -53,7 +57,7 @@ function ConditionKeyList({
<KeySearchInput
className={styles.keyInput}
placeholder={placeholder}
value={key}
value={key.value}
fieldContext={fieldContext}
onChange={(next): void => updateKey(index, next)}
testId={`${testIdPrefix}-${index}`}

View File

@@ -42,7 +42,9 @@ function sourcesEqual(a: SourceConfig[], b: SourceConfig[]): boolean {
(source, index) =>
source.key === b[index].key &&
source.context === b[index].context &&
source.operation === b[index].operation,
source.operation === b[index].operation &&
source.enabled === b[index].enabled &&
source.origin === b[index].origin,
)
);
}

View File

@@ -3,6 +3,7 @@ import {
SpantypesSpanMapperDTO,
SpantypesSpanMapperGroupDTO,
SpantypesSpanMapperOperationDTO,
SpantypesSpanMapperOriginDTO,
} from 'api/generated/services/sigNoz.schemas';
export type MapperGroup = SpantypesSpanMapperGroupDTO;
@@ -11,6 +12,16 @@ export const FieldContext = SpantypesFieldContextDTO;
export type FieldContextValue = SpantypesFieldContextDTO;
export const MapperOperation = SpantypesSpanMapperOperationDTO;
export type MapperOperationValue = SpantypesSpanMapperOperationDTO;
export const MapperOrigin = SpantypesSpanMapperOriginDTO;
export type MapperOriginValue = SpantypesSpanMapperOriginDTO;
// One condition substring. Shipped (system) keys are read-only apart from
// `enabled`; user keys are fully editable.
export interface ConditionKey {
value: string;
enabled: boolean;
origin: MapperOriginValue;
}
export type MapperDraftMode = 'add' | 'edit';
@@ -18,6 +29,8 @@ export interface SourceConfig {
key: string;
context: SpantypesFieldContextDTO;
operation: SpantypesSpanMapperOperationDTO;
enabled: boolean;
origin: MapperOriginValue;
}
// Editable form state for a mapper. `sources` is ordered highest priority
@@ -33,8 +46,8 @@ export interface MapperDraft {
export interface GroupDraft {
id: string | null;
name: string;
attributes: string[];
resource: string[];
attributes: ConditionKey[];
resource: ConditionKey[];
enabled: boolean;
}
@@ -51,8 +64,8 @@ export interface DraftGroup {
localId: string;
serverId: string | null;
name: string;
attributes: string[];
resource: string[];
attributes: ConditionKey[];
resource: ConditionKey[];
enabled: boolean;
mappers: DraftMapper[];
}

View File

@@ -1,12 +1,14 @@
import {
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesSpanMapperGroupConditionKeyDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { v4 as uuid } from 'uuid';
import {
ConditionKey,
DraftGroup,
DraftMapper,
FieldContext,
@@ -15,6 +17,7 @@ import {
MapperDraft,
MapperGroup,
MapperOperation,
MapperOrigin,
SourceConfig,
} from './types';
@@ -24,20 +27,36 @@ function genLocalId(prefix: 'group' | 'mapper'): string {
return `local-${prefix}-${uuid()}`;
}
// Trimmed, de-duplicated, non-empty keys preserving input order.
function cleanKeys(keys: string[]): string[] {
export function createConditionKey(value = ''): ConditionKey {
return { value, enabled: true, origin: MapperOrigin.user };
}
// Trimmed, de-duplicated, non-empty keys preserving input order. A shipped and
// a user key may share a value, so the origin is part of the identity.
function cleanKeys(keys: ConditionKey[]): ConditionKey[] {
const seen = new Set<string>();
const result: string[] = [];
const result: ConditionKey[] = [];
keys.forEach((raw) => {
const key = raw.trim();
if (key && !seen.has(key)) {
seen.add(key);
result.push(key);
const value = raw.value.trim();
const dedupeKey = `${raw.origin}:${value}`;
if (value && !seen.has(dedupeKey)) {
seen.add(dedupeKey);
result.push({ ...raw, value });
}
});
return result;
}
function fromConditionKeys(
keys: SpantypesSpanMapperGroupConditionKeyDTO[] | null | undefined,
): ConditionKey[] {
return (keys ?? []).map((key) => ({
value: key.value,
enabled: key.enabled,
origin: key.origin ?? MapperOrigin.user,
}));
}
// Source configs for a mapper, highest priority first (first match wins at
// evaluation time).
function getMapperSources(mapper: Mapper): SourceConfig[] {
@@ -48,6 +67,8 @@ function getMapperSources(mapper: Mapper): SourceConfig[] {
key: source.key,
context: source.context,
operation: source.operation,
enabled: source.enabled,
origin: source.origin ?? MapperOrigin.user,
}));
}
@@ -56,6 +77,8 @@ export function createEmptySource(): SourceConfig {
key: '',
context: FieldContext.attribute,
operation: MapperOperation.copy,
enabled: true,
origin: MapperOrigin.user,
};
}
@@ -72,7 +95,7 @@ function getCleanSources(draft: MapperDraft): SourceConfig[] {
const result: SourceConfig[] = [];
draft.sources.forEach((source) => {
const key = source.key.trim();
const dedupeKey = `${source.context}:${key}`;
const dedupeKey = `${source.origin}:${source.context}:${key}`;
if (key && !seen.has(dedupeKey)) {
seen.add(dedupeKey);
result.push({ ...source, key });
@@ -95,6 +118,8 @@ function buildSources(
context: source.context,
operation: source.operation,
priority: sources.length - index,
enabled: source.enabled,
origin: source.origin,
}));
}
@@ -123,7 +148,7 @@ export function buildUpdatableMapper(
export const EMPTY_GROUP_DRAFT: GroupDraft = {
id: null,
name: '',
attributes: [''],
attributes: [createConditionKey()],
resource: [],
enabled: true,
};
@@ -170,8 +195,8 @@ export function buildDraftGroup(
localId: group.id,
serverId: group.id,
name: group.name,
attributes: group.condition?.attributes ?? [],
resource: group.condition?.resource ?? [],
attributes: fromConditionKeys(group.condition?.attributes),
resource: fromConditionKeys(group.condition?.resource),
enabled: group.enabled,
mappers: mappers.map(buildDraftMapper),
};
@@ -182,7 +207,8 @@ export function groupDraftFromNode(group: DraftGroup): GroupDraft {
return {
id: group.localId,
name: group.name,
attributes: group.attributes.length > 0 ? group.attributes : [''],
attributes:
group.attributes.length > 0 ? group.attributes : [createConditionKey()],
resource: group.resource,
enabled: group.enabled,
};

View File

@@ -15,26 +15,10 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
ID: "ListRules",
Tags: []string{"rules"},
Summary: "List alert rules",
Description: "This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.",
Description: "This endpoint lists all alert rules with their current evaluation state",
Response: make([]*ruletypes.Rule, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
Deprecated: true,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v3/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRulesV3), handler.OpenAPIDef{
ID: "ListRulesV3",
Tags: []string{"rules"},
Summary: "List alert rules (v3)",
Description: "Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.",
RequestQuery: new(ruletypes.ListRulesParams),
Response: new(ruletypes.ListableRules),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
@@ -132,66 +116,6 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
return err
}
// Saved views hold rule listing state (query, states, sort, order) rather than any
// one rule, so all four routes are open to every role including viewer.
if err := router.Handle("/api/v2/rule_views", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRuleViews), handler.OpenAPIDef{
ID: "ListRuleViews",
Tags: []string{"rules"},
Summary: "List rule saved views",
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide.",
Response: new(ruletypes.ListableRuleViews),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rule_views", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.CreateRuleView), handler.OpenAPIDef{
ID: "CreateRuleView",
Tags: []string{"rules"},
Summary: "Create rule saved view",
Description: "Persists the calling user's rule listing state (query, states, sort, order) as a named, reusable view shared across the org.",
Request: new(ruletypes.PostableRuleView),
RequestContentType: "application/json",
Response: new(ruletypes.RuleView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.UpdateRuleView), handler.OpenAPIDef{
ID: "UpdateRuleView",
Tags: []string{"rules"},
Summary: "Update rule saved view",
Description: "Replaces a saved view's name and data. Saved views are shared org-wide.",
Request: new(ruletypes.UpdatableRuleView),
RequestContentType: "application/json",
Response: new(ruletypes.RuleView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.DeleteRuleView), handler.OpenAPIDef{
ID: "DeleteRuleView",
Tags: []string{"rules"},
Summary: "Delete rule saved view",
Description: "Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.",
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListDowntimeSchedules), handler.OpenAPIDef{
ID: "ListDowntimeSchedules",
Tags: []string{"downtimeschedules"},

View File

@@ -4,17 +4,41 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/parser/filterquery/sqlcompiler"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
)
// Compile wraps compiler errors in the dashboard list filter error code.
func Compile(query string, formatter sqlstore.SQLFormatter) (*sqlcompiler.Compiled, error) {
compiled, errs := sqlcompiler.Compile(query, formatter, dashboardFieldResolver{})
type Compiled struct {
SQL string
Args []any
}
func (c Compiled) IsEmpty() bool {
return c.SQL == ""
}
// Compile always returns a non-nil *Compiled. An empty query (or one that
// produces no SQL) yields a Compiled with an empty SQL — callers gate on
// SQL != "" rather than a nil check.
//
// A `key OP value` term compiles to a DSL predicate; a bare word is a
// case-insensitive substring search over the dashboard name, description, and tag
// keys/values. They compose through AND/OR/NOT, so `prod payment` matches both
// words (implicit AND) and `prod OR name = 'x'` mixes free text with a filter. A
// quoted token matches literally, e.g. `"prod payment"`.
func Compile(query string, formatter sqlstore.SQLFormatter) (*Compiled, error) {
if len(strings.TrimSpace(query)) == 0 {
return &Compiled{}, nil
}
sql, args, errs := newVisitor(formatter).compile(query)
if len(errs) > 0 {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(errs, "; "))
}
return compiled, nil
return &Compiled{
SQL: sql,
Args: args,
}, nil
}

View File

@@ -1,125 +0,0 @@
package impldashboard
import (
"strings"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
"github.com/SigNoz/signoz/pkg/parser/filterquery/sqlcompiler"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
sqlbuilder "github.com/huandu/go-sqlbuilder"
)
// dashboardFieldResolver maps dashboard list DSL keys; a non-reserved key is a tag key matched case-insensitively.
type dashboardFieldResolver struct{}
func (r dashboardFieldResolver) ResolveComparison(b *sqlcompiler.Builder, rawKey string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
key := strings.ToLower(rawKey)
if allowedOperations, isReserved := dashboardtypes.ReservedOps[dashboardtypes.DSLKey(key)]; isReserved {
return r.resolveReservedKey(b, ctx, operation, dashboardtypes.DSLKey(key), allowedOperations)
}
if _, allowed := dashboardtypes.TagKeyOps[operation]; !allowed {
b.AddError("operator %s is not allowed on a tag-key filter", sqlcompiler.OperationName(operation))
return ""
}
return r.tagComparison(b, ctx, operation, key)
}
func (r dashboardFieldResolver) resolveReservedKey(b *sqlcompiler.Builder, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key dashboardtypes.DSLKey, allowedOperations map[qbtypesv5.FilterOperator]struct{}) string {
if _, allowed := allowedOperations[operation]; !allowed {
b.AddError("operator %s is not allowed for key %q", sqlcompiler.OperationName(operation), key)
return ""
}
switch key {
case dashboardtypes.DSLKeyName:
columnExpression := string(b.Formatter().JSONExtractString("dashboard.data", "$.spec.display.name"))
return b.StringOperation(b.SelectBuilder(), ctx, operation, columnExpression, string(key))
case dashboardtypes.DSLKeyDescription:
columnExpression := string(b.Formatter().JSONExtractString("dashboard.data", "$.spec.display.description"))
return b.StringOperation(b.SelectBuilder(), ctx, operation, columnExpression, string(key))
case dashboardtypes.DSLKeyCreatedAt:
return b.TimestampComparison(ctx, operation, "dashboard.created_at")
case dashboardtypes.DSLKeyUpdatedAt:
return b.TimestampComparison(ctx, operation, "dashboard.updated_at")
case dashboardtypes.DSLKeyCreatedBy:
return b.StringOperation(b.SelectBuilder(), ctx, operation, "dashboard.created_by", string(key))
case dashboardtypes.DSLKeyLocked:
return b.BoolComparison(ctx, operation, "dashboard.locked")
}
b.AddError("no handler for reserved key %q", key)
return ""
}
func (dashboardFieldResolver) tagComparison(b *sqlcompiler.Builder, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, tagKey string) string {
subqueryBuilder := sqlbuilder.NewSelectBuilder()
if operation == qbtypesv5.FilterOperatorExists || operation == qbtypesv5.FilterOperatorNotExists {
buildSubqueryForTagKey(subqueryBuilder, tagKey)
} else {
// Value predicates take the positive operator; negation toggles the EXISTS wrapper.
positiveOperation := operation
if operation.IsNegativeOperator() {
positiveOperation = operation.Inverse()
}
valuePredicate := b.StringOperation(subqueryBuilder, ctx, positiveOperation, "t.value", tagKey)
if valuePredicate == "" {
return ""
}
buildSubqueryForTagKeyAndValue(subqueryBuilder, tagKey, valuePredicate)
}
if operation.IsNegativeOperator() {
return b.SelectBuilder().NotExists(subqueryBuilder)
}
return b.SelectBuilder().Exists(subqueryBuilder)
}
func buildSubqueryForTagKey(subqueryBuilder *sqlbuilder.SelectBuilder, tagKey string) *sqlbuilder.SelectBuilder {
const dashboardTagKind = `"dashboard"`
return subqueryBuilder.
Select("1").
From("tag_relation tr").
Join("tag t", "t.id = tr.tag_id").
Where(
subqueryBuilder.Equal("tr.kind", dashboardTagKind),
"tr.resource_id = dashboard.id",
"LOWER(t.key) = LOWER("+subqueryBuilder.Var(tagKey)+")",
)
}
func buildSubqueryForTagKeyAndValue(subqueryBuilder *sqlbuilder.SelectBuilder, tagKey, valuePredicate string) *sqlbuilder.SelectBuilder {
return buildSubqueryForTagKey(subqueryBuilder, tagKey).Where(valuePredicate)
}
// FreeText searches name, description and tag keys/values.
func (dashboardFieldResolver) FreeText(b *sqlcompiler.Builder, value string) string {
nameColumn := string(b.Formatter().JSONExtractString("dashboard.data", "$.spec.display.name"))
descriptionColumn := string(b.Formatter().JSONExtractString("dashboard.data", "$.spec.display.description"))
namePredicate := b.FreeTextContains(b.SelectBuilder(), nameColumn, value)
descriptionPredicate := b.FreeTextContains(b.SelectBuilder(), descriptionColumn, value)
subqueryBuilder := sqlbuilder.NewSelectBuilder()
keyPredicate := b.FreeTextContains(subqueryBuilder, "t.key", value)
valuePredicate := b.FreeTextContains(subqueryBuilder, "t.value", value)
buildSubqueryForFreeTextTag(subqueryBuilder, keyPredicate, valuePredicate)
tagPredicate := b.SelectBuilder().Exists(subqueryBuilder)
return b.SelectBuilder().Or(namePredicate, descriptionPredicate, tagPredicate)
}
func buildSubqueryForFreeTextTag(subqueryBuilder *sqlbuilder.SelectBuilder, keyPredicate, valuePredicate string) *sqlbuilder.SelectBuilder {
const dashboardTagKind = `"dashboard"`
return subqueryBuilder.
Select("1").
From("tag_relation tr").
Join("tag t", "t.id = tr.tag_id").
Where(
subqueryBuilder.Equal("tr.kind", dashboardTagKind),
"tr.resource_id = dashboard.id",
subqueryBuilder.Or(keyPredicate, valuePredicate),
)
}

View File

@@ -559,11 +559,6 @@ func TestCompile_Rejections(t *testing.T) {
dslQueryToCompile: `created_at >= 'not-a-date'`,
expectedErrShouldContain: "RFC3339",
},
{
subtestName: "rejects LIKE pattern ending in an unescaped backslash",
dslQueryToCompile: `name LIKE 'prod\\'`,
expectedErrShouldContain: "must not end with an unescaped backslash",
},
{
subtestName: "rejects REGEXP — not yet supported",
dslQueryToCompile: `name REGEXP '.*'`,
@@ -578,7 +573,7 @@ func TestCompile_Rejections(t *testing.T) {
}
// Every key in dashboardtypes.ReservedOps must have a matching case in
// resolveReservedKey; a key that's reserved but unhandled falls
// visitComparisonForReservedKeys; a key that's reserved but unhandled falls
// through to the "no handler for reserved key" error. Equal is accepted by all
// reserved keys, so `key = 'x'` always reaches the dispatch switch — a missing
// handler surfaces as that error regardless of whether the value type-checks.
@@ -588,7 +583,7 @@ func TestCompileReservedKeysAllHandled(t *testing.T) {
_, err := Compile(string(key)+` = 'x'`, formatter(t))
if err != nil {
assert.NotContains(t, err.Error(), "no handler for reserved key",
"reserved key %q has no handler in resolveReservedKey", key)
"reserved key %q has no handler in visitComparisonForReservedKeys", key)
}
})
}

View File

@@ -0,0 +1,631 @@
package impldashboard
import (
"fmt"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/parser/filterquery"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/antlr4-go/antlr/v4"
sqlbuilder "github.com/huandu/go-sqlbuilder"
)
// bunPlaceholderFlavor is any flavor that renders `?` placeholders, which bun
// re-binds to the actual backend (e.g. `$1` for Postgres) at query time.
const bunPlaceholderFlavor = sqlbuilder.SQLite
type visitor struct {
grammar.BaseFilterQueryVisitor
selectBuilder *sqlbuilder.SelectBuilder
formatter sqlstore.SQLFormatter
errors []string
}
func newVisitor(formatter sqlstore.SQLFormatter) *visitor {
return &visitor{
selectBuilder: sqlbuilder.NewSelectBuilder(),
formatter: formatter,
}
}
// compile builds `?`-placeholder WHERE SQL + args for bun. Each term is either a
// `key OP value` comparison or a bare token that becomes a free-text search; the
// two compose through the boolean grammar (AND/OR/NOT). Malformed input is
// returned as errors.
func (v *visitor) compile(query string) (string, []any, []string) {
tree, _, collector := filterquery.Parse(query)
if len(collector.Errors) > 0 {
return "", nil, collector.Errors
}
condition, _ := v.visit(tree).(string)
if len(v.errors) > 0 {
return "", nil, v.errors
}
if condition == "" {
return "", nil, nil
}
sql, arguments := v.selectBuilder.Args.CompileWithFlavor(condition, bunPlaceholderFlavor)
return sql, arguments, nil
}
func (v *visitor) visit(tree antlr.ParseTree) any {
if tree == nil {
return nil
}
return tree.Accept(v)
}
// ════════════════════════════════════════════════════════════════════════
// methods from grammar.BaseFilterQueryVisitor that are overridden
// ════════════════════════════════════════════════════════════════════════
func (v *visitor) VisitQuery(ctx *grammar.QueryContext) any {
return v.visit(ctx.Expression())
}
func (v *visitor) VisitExpression(ctx *grammar.ExpressionContext) any {
return v.visit(ctx.OrExpression())
}
func (v *visitor) VisitOrExpression(ctx *grammar.OrExpressionContext) any {
parts := ctx.AllAndExpression()
conditions := make([]string, 0, len(parts))
for _, part := range parts {
if condition, ok := v.visit(part).(string); ok && condition != "" {
conditions = append(conditions, condition)
}
}
switch len(conditions) {
case 0:
return ""
case 1:
return conditions[0]
default:
return v.selectBuilder.Or(conditions...)
}
}
func (v *visitor) VisitAndExpression(ctx *grammar.AndExpressionContext) any {
parts := ctx.AllUnaryExpression()
conditions := make([]string, 0, len(parts))
for _, part := range parts {
if condition, ok := v.visit(part).(string); ok && condition != "" {
conditions = append(conditions, condition)
}
}
switch len(conditions) {
case 0:
return ""
case 1:
return conditions[0]
default:
return v.selectBuilder.And(conditions...)
}
}
func (v *visitor) VisitUnaryExpression(ctx *grammar.UnaryExpressionContext) any {
condition, _ := v.visit(ctx.Primary()).(string)
if condition == "" {
return ""
}
if ctx.NOT() != nil {
return fmt.Sprintf("NOT (%s)", condition)
}
return condition
}
func (v *visitor) VisitPrimary(ctx *grammar.PrimaryContext) any {
if ctx.OrExpression() != nil {
return v.visit(ctx.OrExpression())
}
if ctx.Comparison() != nil {
return v.visit(ctx.Comparison())
}
// A lone key/value/full-text token is a free-text term, composed with any
// comparisons through the boolean grammar. A quoted token matches its contents
// literally — the escape hatch for a phrase or a term that looks like DSL.
return v.buildFreeTextTerm(trimQuotes(ctx.GetText()))
}
// VisitComparison dispatches a single `key OP value` term. A key that matches
// a reserved DSL key (name, description, etc.) becomes a column-level
// predicate; any other identifier is treated as a tag key — the operator
// applies to the tag's value, with a case-insensitive match on the tag's key.
func (v *visitor) VisitComparison(ctx *grammar.ComparisonContext) any {
key := strings.ToLower(strings.TrimSpace(ctx.Key().GetText()))
operation, ok := v.extractOperation(ctx)
if !ok {
return ""
}
if allowedOperations, isReserved := dashboardtypes.ReservedOps[dashboardtypes.DSLKey(key)]; isReserved {
return v.visitComparisonForReservedKeys(ctx, operation, dashboardtypes.DSLKey(key), allowedOperations)
}
return v.visitComparisonForTags(ctx, operation, key)
}
func (v *visitor) visitComparisonForReservedKeys(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key dashboardtypes.DSLKey, allowedOperations map[qbtypesv5.FilterOperator]struct{}) string {
if _, allowed := allowedOperations[operation]; !allowed {
v.addError("operator %s is not allowed for key %q", operationName(operation), key)
return ""
}
switch key {
case dashboardtypes.DSLKeyName:
return v.buildJSONStringComparison(ctx, operation, dashboardtypes.DSLKeyName, "$.spec.display.name")
case dashboardtypes.DSLKeyDescription:
return v.buildJSONStringComparison(ctx, operation, dashboardtypes.DSLKeyDescription, "$.spec.display.description")
case dashboardtypes.DSLKeyCreatedAt:
return v.buildTimestampComparison(ctx, operation, "dashboard.created_at")
case dashboardtypes.DSLKeyUpdatedAt:
return v.buildTimestampComparison(ctx, operation, "dashboard.updated_at")
case dashboardtypes.DSLKeyCreatedBy:
return v.buildStringComparison(ctx, operation, dashboardtypes.DSLKeyCreatedBy, "dashboard.created_by")
case dashboardtypes.DSLKeyLocked:
return v.buildBoolComparison(ctx, operation, "dashboard.locked")
}
// Unreachable for real input: every dashboardtypes.ReservedOps key has a case above, and
// TestCompileReservedKeysAllHandled guards that the two stay in sync.
v.addError("no handler for reserved key %q", key)
return ""
}
func (v *visitor) visitComparisonForTags(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, tagKey string) string {
if _, allowed := dashboardtypes.TagKeyOps[operation]; !allowed {
v.addError("operator %s is not allowed on a tag-key filter", operationName(operation))
return ""
}
return v.buildTagComparison(ctx, operation, tagKey)
}
func (v *visitor) extractOperation(ctx *grammar.ComparisonContext) (qbtypesv5.FilterOperator, bool) {
// For operators that take an optional leading NOT, Inverse() maps each to
// its Not<X> counterpart.
maybeNot := func(operation qbtypesv5.FilterOperator) qbtypesv5.FilterOperator {
if ctx.NOT() != nil {
return operation.Inverse()
}
return operation
}
switch {
case ctx.EQUALS() != nil:
return qbtypesv5.FilterOperatorEqual, true
case ctx.NOT_EQUALS() != nil, ctx.NEQ() != nil:
return qbtypesv5.FilterOperatorNotEqual, true
case ctx.LT() != nil:
return qbtypesv5.FilterOperatorLessThan, true
case ctx.LE() != nil:
return qbtypesv5.FilterOperatorLessThanOrEq, true
case ctx.GT() != nil:
return qbtypesv5.FilterOperatorGreaterThan, true
case ctx.GE() != nil:
return qbtypesv5.FilterOperatorGreaterThanOrEq, true
case ctx.BETWEEN() != nil:
return maybeNot(qbtypesv5.FilterOperatorBetween), true
case ctx.LIKE() != nil:
return maybeNot(qbtypesv5.FilterOperatorLike), true
case ctx.ILIKE() != nil:
return maybeNot(qbtypesv5.FilterOperatorILike), true
case ctx.CONTAINS() != nil:
return maybeNot(qbtypesv5.FilterOperatorContains), true
case ctx.REGEXP() != nil:
return maybeNot(qbtypesv5.FilterOperatorRegexp), true
case ctx.InClause() != nil:
return qbtypesv5.FilterOperatorIn, true
case ctx.NotInClause() != nil:
return qbtypesv5.FilterOperatorNotIn, true
case ctx.EXISTS() != nil:
return maybeNot(qbtypesv5.FilterOperatorExists), true
}
v.addError("could not determine operator in expression %q", ctx.GetText())
return qbtypesv5.FilterOperatorUnknown, false
}
// ─── per-key emitters ────────────────────────────────────────────────────────
func (v *visitor) buildJSONStringComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key dashboardtypes.DSLKey, jsonPath string) string {
columnExpression := string(v.formatter.JSONExtractString("dashboard.data", jsonPath))
return v.buildStringOperation(v.selectBuilder, ctx, operation, columnExpression, string(key))
}
func (v *visitor) buildStringComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key dashboardtypes.DSLKey, columnExpression string) string {
return v.buildStringOperation(v.selectBuilder, ctx, operation, columnExpression, string(key))
}
// buildStringOperation covers all the operators the spec allows on text-shaped keys
// (name, description, created_by, and a tag's value). Placeholders are interned
// into builder — the outer builder for column predicates, the subquery builder for
// tag-value predicates — so nested EXISTS arguments thread correctly.
func (v *visitor) buildStringOperation(builder *sqlbuilder.SelectBuilder, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, columnExpression, keyForError string) string {
switch operation {
case qbtypesv5.FilterOperatorEqual:
val, ok := v.extractSingleStringValue(ctx, keyForError)
if !ok {
return ""
}
return builder.Equal(columnExpression, val)
case qbtypesv5.FilterOperatorNotEqual:
val, ok := v.extractSingleStringValue(ctx, keyForError)
if !ok {
return ""
}
return builder.NotEqual(columnExpression, val)
case qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike:
val, ok := v.extractSingleStringValue(ctx, keyForError)
if !ok {
return ""
}
like := "LIKE"
if operation == qbtypesv5.FilterOperatorNotLike {
like = "NOT LIKE"
}
// The user's % and _ stay as wildcards; ESCAPE pins backslash as the escape
// char so a literal `\` in the pattern is read the same on both dialects —
// Postgres defaults to `\`, SQLite has no default escape.
return fmt.Sprintf("%s %s %s ESCAPE '\\'", columnExpression, like, builder.Var(val))
case qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike:
val, ok := v.extractSingleStringValue(ctx, keyForError)
if !ok {
return ""
}
// SQLite has no ILIKE keyword and Postgres LIKE is case-sensitive — emit
// LOWER(col) LIKE LOWER(?) so behavior is identical on both dialects. ESCAPE
// pins backslash as the escape char (Postgres default; SQLite has none).
lowerColumn := string(v.formatter.LowerExpression(columnExpression))
like := "LIKE"
if operation == qbtypesv5.FilterOperatorNotILike {
like = "NOT LIKE"
}
return fmt.Sprintf("%s %s LOWER(%s) ESCAPE '\\'", lowerColumn, like, builder.Var(val))
case qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains:
val, ok := v.extractSingleStringValue(ctx, keyForError)
if !ok {
return ""
}
like := "LIKE"
if operation == qbtypesv5.FilterOperatorNotContains {
like = "NOT LIKE"
}
// Escape the user's % and _ so they match literally, then wrap in wildcards.
// ESCAPE declares the backslash the escaper injected as the escape char —
// needed on SQLite (no default) and a harmless restatement of the Postgres default.
escaped := v.formatter.EscapeLikePattern(val)
return fmt.Sprintf("%s %s %s ESCAPE '\\'", columnExpression, like, builder.Var("%"+escaped+"%"))
case qbtypesv5.FilterOperatorRegexp, qbtypesv5.FilterOperatorNotRegexp:
v.addError("REGEXP filtering on %q is not yet supported", keyForError)
return ""
case qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn:
values, ok := v.extractStringValueList(ctx, keyForError)
if !ok {
return ""
}
arguments := make([]any, len(values))
for i, s := range values {
arguments[i] = s
}
if operation == qbtypesv5.FilterOperatorNotIn {
return builder.NotIn(columnExpression, arguments...)
}
return builder.In(columnExpression, arguments...)
}
v.addError("operator %s on %q is not implemented", operationName(operation), keyForError)
return ""
}
func (v *visitor) buildTimestampComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, columnExpression string) string {
switch operation {
case qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLessThan, qbtypesv5.FilterOperatorLessThanOrEq,
qbtypesv5.FilterOperatorGreaterThan, qbtypesv5.FilterOperatorGreaterThanOrEq:
t, ok := v.extractSingleTimestampValue(ctx)
if !ok {
return ""
}
switch operation {
case qbtypesv5.FilterOperatorEqual:
return v.selectBuilder.Equal(columnExpression, t)
case qbtypesv5.FilterOperatorNotEqual:
return v.selectBuilder.NotEqual(columnExpression, t)
case qbtypesv5.FilterOperatorLessThan:
return v.selectBuilder.LessThan(columnExpression, t)
case qbtypesv5.FilterOperatorLessThanOrEq:
return v.selectBuilder.LessEqualThan(columnExpression, t)
case qbtypesv5.FilterOperatorGreaterThan:
return v.selectBuilder.GreaterThan(columnExpression, t)
case qbtypesv5.FilterOperatorGreaterThanOrEq:
return v.selectBuilder.GreaterEqualThan(columnExpression, t)
}
case qbtypesv5.FilterOperatorBetween, qbtypesv5.FilterOperatorNotBetween:
timestamps, ok := v.extractTwoTimestampValues(ctx)
if !ok {
return ""
}
if operation == qbtypesv5.FilterOperatorNotBetween {
return v.selectBuilder.NotBetween(columnExpression, timestamps[0], timestamps[1])
}
return v.selectBuilder.Between(columnExpression, timestamps[0], timestamps[1])
}
v.addError("operator %s on timestamp is not implemented", operationName(operation))
return ""
}
func (v *visitor) buildBoolComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, columnExpression string) string {
b, ok := v.extractSingleBoolValue(ctx)
if !ok {
return ""
}
if operation == qbtypesv5.FilterOperatorNotEqual {
return v.selectBuilder.NotEqual(columnExpression, b)
}
return v.selectBuilder.Equal(columnExpression, b)
}
func (v *visitor) buildTagComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, tagKey string) string {
subqueryBuilder := sqlbuilder.NewSelectBuilder()
if operation == qbtypesv5.FilterOperatorExists || operation == qbtypesv5.FilterOperatorNotExists {
buildSubqueryForTagKey(subqueryBuilder, tagKey)
} else {
// All other tag operators take the positive form of the value predicate
// and toggle the EXISTS wrapper for negation. Inverse() flips Not<X> → <X>.
positiveOperation := operation
if operation.IsNegativeOperator() {
positiveOperation = operation.Inverse()
}
valuePredicate := v.buildStringOperation(subqueryBuilder, ctx, positiveOperation, "t.value", tagKey)
if valuePredicate == "" {
return ""
}
buildSubqueryForTagKeyAndValue(subqueryBuilder, tagKey, valuePredicate)
}
if operation.IsNegativeOperator() {
return v.selectBuilder.NotExists(subqueryBuilder)
}
return v.selectBuilder.Exists(subqueryBuilder)
}
func buildSubqueryForTagKey(subqueryBuilder *sqlbuilder.SelectBuilder, tagKey string) *sqlbuilder.SelectBuilder {
const dashboardTagKind = `"dashboard"`
return subqueryBuilder.
Select("1").
From("tag_relation tr").
Join("tag t", "t.id = tr.tag_id").
Where(
subqueryBuilder.Equal("tr.kind", dashboardTagKind),
"tr.resource_id = dashboard.id",
"LOWER(t.key) = LOWER("+subqueryBuilder.Var(tagKey)+")",
)
}
func buildSubqueryForTagKeyAndValue(subqueryBuilder *sqlbuilder.SelectBuilder, tagKey, valuePredicate string) *sqlbuilder.SelectBuilder {
return buildSubqueryForTagKey(subqueryBuilder, tagKey).Where(valuePredicate)
}
// ─── free-text search ────────────────────────────────────────────────────────
// buildFreeTextTerm matches value as a case-insensitive substring of the
// dashboard name, description, or any tag key/value.
func (v *visitor) buildFreeTextTerm(value string) string {
nameColumn := string(v.formatter.JSONExtractString("dashboard.data", "$.spec.display.name"))
descriptionColumn := string(v.formatter.JSONExtractString("dashboard.data", "$.spec.display.description"))
namePredicate := v.buildFreeTextContains(v.selectBuilder, nameColumn, value)
descriptionPredicate := v.buildFreeTextContains(v.selectBuilder, descriptionColumn, value)
subqueryBuilder := sqlbuilder.NewSelectBuilder()
keyPredicate := v.buildFreeTextContains(subqueryBuilder, "t.key", value)
valuePredicate := v.buildFreeTextContains(subqueryBuilder, "t.value", value)
buildSubqueryForFreeTextTag(subqueryBuilder, keyPredicate, valuePredicate)
tagPredicate := v.selectBuilder.Exists(subqueryBuilder)
return v.selectBuilder.Or(namePredicate, descriptionPredicate, tagPredicate)
}
// buildFreeTextContains emits a case-insensitive contains as
// LOWER(COALESCE(col, '')) LIKE LOWER(?), identical on SQLite and Postgres.
// COALESCE keeps a NULL column (an absent description) false rather than NULL —
// otherwise `NOT (…)` goes NULL and drops every description-less dashboard. The
// value's % and _ are escaped, and ESCAPE pins backslash as the escape char.
func (v *visitor) buildFreeTextContains(builder *sqlbuilder.SelectBuilder, columnExpression, value string) string {
lowerColumn := string(v.formatter.LowerExpression("COALESCE(" + columnExpression + ", '')"))
pattern := "%" + v.formatter.EscapeLikePattern(value) + "%"
return fmt.Sprintf("%s LIKE LOWER(%s) ESCAPE '\\'", lowerColumn, builder.Var(pattern))
}
func buildSubqueryForFreeTextTag(subqueryBuilder *sqlbuilder.SelectBuilder, keyPredicate, valuePredicate string) *sqlbuilder.SelectBuilder {
const dashboardTagKind = `"dashboard"`
return subqueryBuilder.
Select("1").
From("tag_relation tr").
Join("tag t", "t.id = tr.tag_id").
Where(
subqueryBuilder.Equal("tr.kind", dashboardTagKind),
"tr.resource_id = dashboard.id",
subqueryBuilder.Or(keyPredicate, valuePredicate),
)
}
// ─── value extraction helpers ───────────────────────────────────────────────
func (v *visitor) addError(format string, arguments ...any) {
v.errors = append(v.errors, fmt.Sprintf(format, arguments...))
}
func (v *visitor) extractSingleStringValue(ctx *grammar.ComparisonContext, keyForError string) (string, bool) {
values := ctx.AllValue()
if len(values) != 1 {
v.addError("expected exactly one value for %q", keyForError)
return "", false
}
return v.extractStringValue(values[0], keyForError)
}
func (v *visitor) extractSingleBoolValue(ctx *grammar.ComparisonContext) (bool, bool) {
values := ctx.AllValue()
if len(values) != 1 {
v.addError("expected a single boolean (true/false)")
return false, false
}
return v.extractBoolValue(values[0])
}
func (v *visitor) extractSingleTimestampValue(ctx *grammar.ComparisonContext) (time.Time, bool) {
values := ctx.AllValue()
if len(values) != 1 {
v.addError("expected a single RFC3339 timestamp")
return time.Time{}, false
}
return v.extractTimestampValue(values[0])
}
func (v *visitor) extractTwoTimestampValues(ctx *grammar.ComparisonContext) ([2]time.Time, bool) {
values := ctx.AllValue()
if len(values) != 2 {
v.addError("BETWEEN expects two RFC3339 timestamps")
return [2]time.Time{}, false
}
a, ok1 := v.extractTimestampValue(values[0])
b, ok2 := v.extractTimestampValue(values[1])
if !ok1 || !ok2 {
return [2]time.Time{}, false
}
return [2]time.Time{a, b}, true
}
func (v *visitor) extractStringValueList(ctx *grammar.ComparisonContext, keyForError string) ([]string, bool) {
var valuesCtx []grammar.IValueContext
switch {
case ctx.InClause() != nil:
inClause := ctx.InClause()
if inClause.ValueList() != nil {
valuesCtx = inClause.ValueList().AllValue()
} else {
valuesCtx = []grammar.IValueContext{inClause.Value()}
}
case ctx.NotInClause() != nil:
notInClause := ctx.NotInClause()
if notInClause.ValueList() != nil {
valuesCtx = notInClause.ValueList().AllValue()
} else {
valuesCtx = []grammar.IValueContext{notInClause.Value()}
}
default:
v.addError("IN clause is missing for %q", keyForError)
return nil, false
}
if len(valuesCtx) == 0 {
v.addError("IN list for %q is empty", keyForError)
return nil, false
}
out := make([]string, 0, len(valuesCtx))
for _, valueContext := range valuesCtx {
s, ok := v.extractStringValue(valueContext, keyForError)
if !ok {
return nil, false
}
out = append(out, s)
}
return out, true
}
func (v *visitor) extractStringValue(ctx grammar.IValueContext, keyForError string) (string, bool) {
if ctx.QUOTED_TEXT() != nil {
return trimQuotes(ctx.QUOTED_TEXT().GetText()), true
}
if ctx.KEY() != nil {
// Bare tokens are accepted as strings, mirroring the FilterQuery lexer's
// treatment of unquoted identifiers on the value side.
return ctx.KEY().GetText(), true
}
v.addError("expected a string value for %q, got %q", keyForError, ctx.GetText())
return "", false
}
func (v *visitor) extractBoolValue(ctx grammar.IValueContext) (bool, bool) {
if ctx.BOOL() == nil {
v.addError("expected a boolean (true/false), got %q", ctx.GetText())
return false, false
}
return strings.EqualFold(ctx.BOOL().GetText(), "true"), true
}
func (v *visitor) extractTimestampValue(ctx grammar.IValueContext) (time.Time, bool) {
if ctx.QUOTED_TEXT() == nil {
v.addError("expected an RFC3339 timestamp string, got %q", ctx.GetText())
return time.Time{}, false
}
raw := trimQuotes(ctx.QUOTED_TEXT().GetText())
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
v.addError("invalid RFC3339 timestamp %q: %s", raw, err.Error())
return time.Time{}, false
}
return t, true
}
// ─── operator spelling ───────────────────────────────────────────────────────
// operationName returns the user-facing spelling of a FilterOperator, used only in
// error messages — go-sqlbuilder's Cond helpers emit the SQL keywords.
func operationName(operation qbtypesv5.FilterOperator) string {
switch operation {
case qbtypesv5.FilterOperatorEqual:
return "="
case qbtypesv5.FilterOperatorNotEqual:
return "!="
case qbtypesv5.FilterOperatorLessThan:
return "<"
case qbtypesv5.FilterOperatorLessThanOrEq:
return "<="
case qbtypesv5.FilterOperatorGreaterThan:
return ">"
case qbtypesv5.FilterOperatorGreaterThanOrEq:
return ">="
case qbtypesv5.FilterOperatorBetween:
return "BETWEEN"
case qbtypesv5.FilterOperatorNotBetween:
return "NOT BETWEEN"
case qbtypesv5.FilterOperatorLike:
return "LIKE"
case qbtypesv5.FilterOperatorNotLike:
return "NOT LIKE"
case qbtypesv5.FilterOperatorILike:
return "ILIKE"
case qbtypesv5.FilterOperatorNotILike:
return "NOT ILIKE"
case qbtypesv5.FilterOperatorContains:
return "CONTAINS"
case qbtypesv5.FilterOperatorNotContains:
return "NOT CONTAINS"
case qbtypesv5.FilterOperatorRegexp:
return "REGEXP"
case qbtypesv5.FilterOperatorNotRegexp:
return "NOT REGEXP"
case qbtypesv5.FilterOperatorIn:
return "IN"
case qbtypesv5.FilterOperatorNotIn:
return "NOT IN"
case qbtypesv5.FilterOperatorExists:
return "EXISTS"
case qbtypesv5.FilterOperatorNotExists:
return "NOT EXISTS"
}
return "?"
}
func trimQuotes(s string) string {
if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
s = s[1 : len(s)-1]
}
}
s = strings.ReplaceAll(s, `\\`, `\`)
s = strings.ReplaceAll(s, `\'`, `'`)
return s
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -16,10 +17,11 @@ type setter struct {
alertmanager alertmanager.Alertmanager
quickfilter quickfilter.Module
dashboard dashboard.Module
spanMapper spanmapper.Module
}
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module, dashboard dashboard.Module) organization.Setter {
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter, dashboard: dashboard}
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module, dashboard dashboard.Module, spanMapper spanmapper.Module) organization.Setter {
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter, dashboard: dashboard, spanMapper: spanMapper}
}
func (module *setter) Create(ctx context.Context, organization *types.Organization, createManagedRoles func(context.Context, valuer.UUID) error) error {
@@ -43,6 +45,10 @@ func (module *setter) Create(ctx context.Context, organization *types.Organizati
return err
}
if err := module.spanMapper.ReconcileSystemGroups(ctx, organization.ID); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,46 @@
package implspanmapper
import (
"embed"
"io/fs"
"path"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/spantypes"
)
const definitionsRoot = "fs/definitions"
//go:embed fs/definitions/*.json
var definitionFiles embed.FS
// NewSystemGroupRegistry parses every embedded definition. Definitions are
// build-time assets validated by a test, so a failure here means the binary
// shipped broken JSON.
func NewSystemGroupRegistry() (spantypes.SpanMapperGroupRegistry, error) {
entries, err := fs.ReadDir(definitionFiles, definitionsRoot)
if err != nil {
return spantypes.SpanMapperGroupRegistry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read span mapper group definitions")
}
definitions := make([]spantypes.SpanMapperGroupDefinition, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
file := path.Join(definitionsRoot, entry.Name())
raw, err := definitionFiles.ReadFile(file)
if err != nil {
return spantypes.SpanMapperGroupRegistry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read %s", file)
}
definition, err := spantypes.NewSpanMapperGroupDefinition(raw)
if err != nil {
return spantypes.SpanMapperGroupRegistry{}, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't parse %s", file)
}
definitions = append(definitions, definition)
}
return spantypes.NewSpanMapperGroupRegistry(definitions)
}

View File

@@ -0,0 +1,79 @@
{
"version": 1,
"definition": {
"name": "agent",
"condition": {
"attributes": [
{
"value": "agent"
}
],
"resource": []
},
"enabled": true,
"mappers": [
{
"name": "gen_ai.agent.name",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "agent.name",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "agent_name",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.agent.id",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "agent.id",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.agent.description",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "agent.description",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.output.messages",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "final_result",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
}
]
}
}

View File

@@ -0,0 +1,347 @@
{
"version": 1,
"definition": {
"name": "llm",
"condition": {
"attributes": [
{
"value": "model"
}
],
"resource": []
},
"enabled": true,
"mappers": [
{
"name": "gen_ai.request.model",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "llm.model_name",
"context": "attribute",
"operation": "copy",
"priority": 60
},
{
"key": "llm.request.model",
"context": "attribute",
"operation": "copy",
"priority": 50
},
{
"key": "ai.model.id",
"context": "attribute",
"operation": "copy",
"priority": 40
},
{
"key": "langfuse.observation.model.name",
"context": "attribute",
"operation": "copy",
"priority": 30
},
{
"key": "embedding.model_name",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "model",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.response.model",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "llm.response.model",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "ai.response.model",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.provider.name",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "gen_ai.system",
"context": "attribute",
"operation": "copy",
"priority": 50
},
{
"key": "llm.vendor",
"context": "attribute",
"operation": "copy",
"priority": 40
},
{
"key": "llm.provider",
"context": "attribute",
"operation": "copy",
"priority": 30
},
{
"key": "llm.system",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "ai.model.provider",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.operation.name",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "llm.request.type",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.usage.input_tokens",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "gen_ai.usage.prompt_tokens",
"context": "attribute",
"operation": "copy",
"priority": 50
},
{
"key": "llm.usage.prompt_tokens",
"context": "attribute",
"operation": "copy",
"priority": 40
},
{
"key": "llm.token_count.prompt",
"context": "attribute",
"operation": "copy",
"priority": 30
},
{
"key": "ai.usage.inputTokens",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "ai.usage.promptTokens",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.usage.output_tokens",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "gen_ai.usage.completion_tokens",
"context": "attribute",
"operation": "copy",
"priority": 50
},
{
"key": "llm.usage.completion_tokens",
"context": "attribute",
"operation": "copy",
"priority": 40
},
{
"key": "llm.token_count.completion",
"context": "attribute",
"operation": "copy",
"priority": 30
},
{
"key": "ai.usage.outputTokens",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "ai.usage.completionTokens",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.usage.cache_read.input_tokens",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "gen_ai.usage.cache_read_input_tokens",
"context": "attribute",
"operation": "copy",
"priority": 30
},
{
"key": "llm.token_count.prompt_details.cache_read",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "ai.usage.cachedInputTokens",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.usage.cache_creation.input_tokens",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "gen_ai.usage.cache_write.input_tokens",
"context": "attribute",
"operation": "copy",
"priority": 30
},
{
"key": "gen_ai.usage.cache_creation_input_tokens",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "llm.token_count.prompt_details.cache_write",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.input.messages",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "gen_ai.prompt",
"context": "attribute",
"operation": "copy",
"priority": 30
},
{
"key": "ai.prompt.messages",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "input.value",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.output.messages",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "gen_ai.completion",
"context": "attribute",
"operation": "copy",
"priority": 30
},
{
"key": "ai.response.text",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "output.value",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.conversation.id",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "session.id",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "langfuse.session.id",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.response.finish_reason",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "ai.response.finishReason",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
}
]
}
}

View File

@@ -0,0 +1,135 @@
{
"version": 1,
"definition": {
"name": "tool",
"condition": {
"attributes": [
{
"value": "tool"
}
],
"resource": []
},
"enabled": true,
"mappers": [
{
"name": "gen_ai.tool.name",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "tool.name",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "ai.toolCall.name",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.tool.call.id",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "tool.id",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "ai.toolCall.id",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.tool.description",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "tool.description",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.tool.call.arguments",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "ai.toolCall.args",
"context": "attribute",
"operation": "copy",
"priority": 40
},
{
"key": "traceloop.entity.input",
"context": "attribute",
"operation": "copy",
"priority": 30
},
{
"key": "gcp.vertex.agent.tool_call_args",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "input.value",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.tool.call.result",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "ai.toolCall.result",
"context": "attribute",
"operation": "copy",
"priority": 40
},
{
"key": "traceloop.entity.output",
"context": "attribute",
"operation": "copy",
"priority": 30
},
{
"key": "gcp.vertex.agent.tool_response",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "output.value",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
}
]
}
}

View File

@@ -69,6 +69,10 @@ func (h *handler) CreateGroup(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, err)
return
}
if err := req.Validate(); err != nil {
render.Error(rw, err)
return
}
group := spantypes.NewSpanMapperGroup(orgID, claims.Email, req)
@@ -191,6 +195,10 @@ func (h *handler) CreateMapper(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, err)
return
}
if err := req.Validate(); err != nil {
render.Error(rw, err)
return
}
mapper := spantypes.NewSpanMapper(groupID, claims.Email, req)
if err := h.module.CreateMapper(ctx, orgID, groupID, mapper); err != nil {

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
@@ -14,13 +15,24 @@ import (
"github.com/SigNoz/signoz/pkg/valuer"
)
// maxTestSpans bounds the input size: every test request boots a full
// in-memory collector pipeline and is reachable with viewer access.
const maxTestSpans = 100
type module struct {
store spantypes.SpanMapperStore
flagger flagger.Flagger
store spantypes.SpanMapperStore
flagger flagger.Flagger
registry spantypes.SpanMapperGroupRegistry
settings factory.ScopedProviderSettings
}
func NewModule(store spantypes.SpanMapperStore, flagger flagger.Flagger) spanmapper.Module {
return &module{store: store, flagger: flagger}
func NewModule(store spantypes.SpanMapperStore, flagger flagger.Flagger, registry spantypes.SpanMapperGroupRegistry, providerSettings factory.ProviderSettings) spanmapper.Module {
return &module{
store: store,
flagger: flagger,
registry: registry,
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"),
}
}
func (module *module) ListGroups(ctx context.Context, orgID valuer.UUID, q *spantypes.ListSpanMapperGroupsQuery) ([]*spantypes.SpanMapperGroup, error) {
@@ -32,6 +44,9 @@ func (module *module) GetGroup(ctx context.Context, orgID, id valuer.UUID) (*spa
}
func (module *module) CreateGroup(ctx context.Context, orgID valuer.UUID, group *spantypes.SpanMapperGroup) error {
if module.registry.IsReserved(group.Name) {
return errors.Newf(errors.TypeInvalidInput, spantypes.ErrCodeMappingGroupNameReserved, "group name %q is reserved for a default group", group.Name)
}
return module.store.CreateGroup(ctx, group)
}
@@ -40,10 +55,14 @@ func (module *module) UpdateGroup(ctx context.Context, orgID, id valuer.UUID, na
if err != nil {
return err
}
group.Update(name, condition, enabled, updatedBy)
if name != nil && *name != group.Name && module.registry.IsReserved(*name) {
return errors.Newf(errors.TypeInvalidInput, spantypes.ErrCodeMappingGroupNameReserved, "group name %q is reserved for a default group", *name)
}
if err := group.Update(name, condition, enabled, updatedBy); err != nil {
return err
}
err = module.store.UpdateGroup(ctx, group)
if err != nil {
if err := module.store.UpdateGroup(ctx, group); err != nil {
return err
}
agentConf.NotifyConfigUpdate(ctx)
@@ -51,10 +70,16 @@ func (module *module) UpdateGroup(ctx context.Context, orgID, id valuer.UUID, na
}
func (module *module) DeleteGroup(ctx context.Context, orgID, id valuer.UUID) error {
err := module.store.DeleteGroup(ctx, orgID, id)
group, err := module.store.GetGroup(ctx, orgID, id)
if err != nil {
return err
}
if err := group.ErrIfNotDeletable(); err != nil {
return err
}
if err := module.store.DeleteGroup(ctx, orgID, id); err != nil {
return err
}
agentConf.NotifyConfigUpdate(ctx)
return nil
}
@@ -81,14 +106,13 @@ func (module *module) CreateMapper(ctx context.Context, orgID, groupID valuer.UU
}
func (module *module) UpdateMapper(ctx context.Context, orgID, groupID, id valuer.UUID, fieldContext spantypes.FieldContext, config *spantypes.SpanMapperConfig, enabled *bool, updatedBy string) error {
if _, err := module.store.GetGroup(ctx, orgID, groupID); err != nil {
return err
}
mapper, err := module.store.GetMapper(ctx, orgID, groupID, id)
if err != nil {
return err
}
mapper.Update(fieldContext, config, enabled, updatedBy)
if err := mapper.Update(fieldContext, config, enabled, updatedBy); err != nil {
return err
}
err = module.store.UpdateMapper(ctx, mapper)
if err != nil {
return err
@@ -98,7 +122,14 @@ func (module *module) UpdateMapper(ctx context.Context, orgID, groupID, id value
}
func (module *module) DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error {
err := module.store.DeleteMapper(ctx, orgID, groupID, id)
mapper, err := module.store.GetMapper(ctx, orgID, groupID, id)
if err != nil {
return err
}
if err := mapper.ErrIfNotDeletable(); err != nil {
return err
}
err = module.store.DeleteMapper(ctx, orgID, groupID, id)
if err != nil {
return err
}
@@ -106,10 +137,6 @@ func (module *module) DeleteMapper(ctx context.Context, orgID, groupID, id value
return nil
}
// maxTestSpans bounds the input size: every test request boots a full
// in-memory collector pipeline and is reachable with viewer access.
const maxTestSpans = 100
func (module *module) TestMappers(ctx context.Context, orgID valuer.UUID, spans []spantypes.SpanMapperTestSpan, groups []*spantypes.SpanMapperGroupWithMappers) ([]spantypes.SpanMapperTestSpan, []string, error) {
if len(spans) == 0 {
return nil, nil, errors.New(errors.TypeInvalidInput, spantypes.ErrCodeMappingInvalidInput, "'spans' must contain at least one span")
@@ -130,37 +157,6 @@ func (module *module) TestMappers(ctx context.Context, orgID valuer.UUID, spans
return out, collectorLogs, nil
}
// backfillMappers loads saved mappers for any enabled group whose Mappers is
// nil. Disabled groups are skipped: the simulation filters them out anyway,
// so there is no point loading their mappers or failing on their names.
func (module *module) backfillMappers(ctx context.Context, orgID valuer.UUID, groups []*spantypes.SpanMapperGroupWithMappers) ([]*spantypes.SpanMapperGroupWithMappers, error) {
savedGroups, err := module.store.ListGroups(ctx, orgID, nil)
if err != nil {
return nil, err
}
savedByName := make(map[string]*spantypes.SpanMapperGroup, len(savedGroups))
for _, g := range savedGroups {
savedByName[g.Name] = g
}
// For each group in the request, if Mappers is nil, load the saved mappers for that group name.
for _, g := range groups {
if g.Mappers != nil || !g.Group.Enabled {
continue
}
saved, ok := savedByName[g.Group.Name]
if !ok {
return nil, errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "no saved group named %q to load mappers from; send 'mappers' for new or edited groups", g.Group.Name)
}
loaded, err := module.store.ListMappers(ctx, orgID, saved.ID)
if err != nil {
return nil, err
}
g.Mappers = loaded
}
return groups, nil
}
func (module *module) AgentFeatureType() agentConf.AgentFeatureType {
return spantypes.SpanAttrMappingFeatureType
}
@@ -196,6 +192,37 @@ func (module *module) RecommendAgentConfig(orgID valuer.UUID, currentConfYaml []
return updatedConf, string(serialized), nil
}
// backfillMappers loads saved mappers for any enabled group whose Mappers is
// nil. Disabled groups are skipped: the simulation filters them out anyway,
// so there is no point loading their mappers or failing on their names.
func (module *module) backfillMappers(ctx context.Context, orgID valuer.UUID, groups []*spantypes.SpanMapperGroupWithMappers) ([]*spantypes.SpanMapperGroupWithMappers, error) {
savedGroups, err := module.store.ListGroups(ctx, orgID, nil)
if err != nil {
return nil, err
}
savedByName := make(map[string]*spantypes.SpanMapperGroup, len(savedGroups))
for _, g := range savedGroups {
savedByName[g.Name] = g
}
// For each group in the request, if Mappers is nil, load the saved mappers for that group name.
for _, g := range groups {
if g.Mappers != nil || !g.Group.Enabled {
continue
}
saved, ok := savedByName[g.Group.Name]
if !ok {
return nil, errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "no saved group named %q to load mappers from; send 'mappers' for new or edited groups", g.Group.Name)
}
loaded, err := module.store.ListMappers(ctx, orgID, saved.ID)
if err != nil {
return nil, err
}
g.Mappers = loaded
}
return groups, nil
}
// listEnabledGroupsWithMappers returns groups with their mappers.
func (module *module) listEnabledGroupsWithMappers(ctx context.Context, orgID valuer.UUID) ([]*spantypes.SpanMapperGroupWithMappers, error) {
enabled := true

View File

@@ -0,0 +1,228 @@
package implspanmapper
import (
"context"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testUser = "user@signoz.io"
func newTestSQLStore(t *testing.T) sqlstore.SQLStore {
t.Helper()
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
Provider: "sqlite",
Connection: sqlstore.ConnectionConfig{MaxOpenConns: 10},
Sqlite: sqlstore.SqliteConfig{
Path: filepath.Join(t.TempDir(), "test.db"),
Mode: "wal",
BusyTimeout: 5 * time.Second,
TransactionMode: "deferred",
},
})
require.NoError(t, err)
for _, model := range []any{
(*spantypes.StorableSpanMapperGroup)(nil),
(*spantypes.StorableSpanMapper)(nil),
} {
_, err := store.BunDB().NewCreateTable().Model(model).IfNotExists().Exec(context.Background())
require.NoError(t, err)
}
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_span_mapper_group_org_name ON span_mapper_group (org_id, name)`)
require.NoError(t, err)
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_span_mapper_group_name ON span_mapper (group_id, name)`)
require.NoError(t, err)
return store
}
func newTestModule(t *testing.T, sqlStore sqlstore.SQLStore, definitions ...spantypes.SpanMapperGroupDefinition) *module {
t.Helper()
registry, err := spantypes.NewSpanMapperGroupRegistry(definitions)
require.NoError(t, err)
return NewModule(NewStore(sqlStore), nil, registry, factorytest.NewSettings()).(*module)
}
func newTestDefinition(t *testing.T, version int, body string) spantypes.SpanMapperGroupDefinition {
t.Helper()
definition, err := spantypes.NewSpanMapperGroupDefinition([]byte(`{"version": ` + strconv.Itoa(version) + `, "definition": ` + body + `}`))
require.NoError(t, err)
return definition
}
// llmV1 ships two mappers; llmV2 renames a source, adds a mapper and drops one.
const llmV1 = `{
"name": "llm",
"condition": {"attributes": [{"value": "model"}], "resource": []},
"enabled": true,
"mappers": [
{"name": "gen_ai.request.model", "fieldContext": "attribute", "config": {"sources": [
{"key": "llm.model_name", "context": "attribute", "operation": "copy", "priority": 20},
{"key": "ai.model.id", "context": "attribute", "operation": "copy", "priority": 10}
]}},
{"name": "gen_ai.input.messages", "fieldContext": "attribute", "config": {"sources": [
{"key": "gen_ai.prompt", "context": "attribute", "operation": "copy", "priority": 10}
]}}
]
}`
const llmV2 = `{
"name": "llm",
"condition": {"attributes": [{"value": "model"}, {"value": "llm."}], "resource": []},
"enabled": true,
"mappers": [
{"name": "gen_ai.request.model", "fieldContext": "attribute", "config": {"sources": [
{"key": "llm.model_name", "context": "attribute", "operation": "copy", "priority": 20},
{"key": "langfuse.observation.model.name", "context": "attribute", "operation": "copy", "priority": 10}
]}},
{"name": "gen_ai.provider.name", "fieldContext": "attribute", "config": {"sources": [
{"key": "llm.vendor", "context": "attribute", "operation": "copy", "priority": 10}
]}}
]
}`
func findMapper(t *testing.T, mappers []*spantypes.SpanMapper, name string) *spantypes.SpanMapper {
t.Helper()
for _, m := range mappers {
if m.Name == name {
return m
}
}
require.Failf(t, "mapper not found", "no mapper named %q", name)
return nil
}
func findSource(t *testing.T, sources []spantypes.SpanMapperSource, key string, origin spantypes.SpanMapperOrigin) spantypes.SpanMapperSource {
t.Helper()
for _, s := range sources {
if s.Key == key && s.Origin == origin {
return s
}
}
require.Failf(t, "source not found", "no %s source with key %q", origin.StringValue(), key)
return spantypes.SpanMapperSource{}
}
func TestReconcileUpgradeKeepsTogglesAndUserItems(t *testing.T) {
ctx := context.Background()
orgID := valuer.GenerateUUID()
sqlStore := newTestSQLStore(t)
v1 := newTestModule(t, sqlStore, newTestDefinition(t, 1, llmV1))
require.NoError(t, v1.ReconcileSystemGroups(ctx, orgID))
group, err := v1.store.GetGroupByName(ctx, orgID, "llm")
require.NoError(t, err)
mappers, err := v1.ListMappers(ctx, orgID, group.ID)
require.NoError(t, err)
// Switch the shipped substring off and add a user one.
off := false
require.NoError(t, v1.UpdateGroup(ctx, orgID, group.ID, nil, &spantypes.SpanMapperGroupCondition{
Attributes: []spantypes.SpanMapperGroupConditionKey{
{Value: "model", Enabled: false, Origin: spantypes.SpanMapperOriginSystem},
{Value: "gen_ai.request.model", Enabled: true, Origin: spantypes.SpanMapperOriginUser},
},
Resource: []spantypes.SpanMapperGroupConditionKey{},
}, &off, testUser))
// Switch a shipped source off, add a user override, and switch the mapper off.
model := findMapper(t, mappers, "gen_ai.request.model")
require.NoError(t, v1.UpdateMapper(ctx, orgID, group.ID, model.ID, spantypes.FieldContext{}, &spantypes.SpanMapperConfig{Sources: []spantypes.SpanMapperSource{
{Key: "llm.model_name", Context: spantypes.FieldContextSpanAttribute, Operation: spantypes.SpanMapperOperationCopy, Priority: 20, Enabled: false, Origin: spantypes.SpanMapperOriginSystem},
{Key: "llm.model_name", Context: spantypes.FieldContextSpanAttribute, Operation: spantypes.SpanMapperOperationMove, Priority: 1, Enabled: true, Origin: spantypes.SpanMapperOriginUser},
}}, &off, testUser))
// Add a user source to the mapper v2 stops shipping, so it must survive.
messages := findMapper(t, mappers, "gen_ai.input.messages")
require.NoError(t, v1.UpdateMapper(ctx, orgID, group.ID, messages.ID, spantypes.FieldContext{}, &spantypes.SpanMapperConfig{Sources: []spantypes.SpanMapperSource{
{Key: "input.value", Context: spantypes.FieldContextSpanAttribute, Operation: spantypes.SpanMapperOperationCopy, Priority: 1, Enabled: true},
}}, nil, testUser))
// A user mapper in the shipped group.
require.NoError(t, v1.CreateMapper(ctx, orgID, group.ID, spantypes.NewSpanMapper(group.ID, testUser, &spantypes.PostableSpanMapper{
Name: "gen_ai.custom", FieldContext: spantypes.FieldContextSpanAttribute, Enabled: true,
Config: spantypes.SpanMapperConfig{Sources: []spantypes.SpanMapperSource{{Key: "custom", Context: spantypes.FieldContextSpanAttribute, Operation: spantypes.SpanMapperOperationCopy, Priority: 1, Enabled: true}}},
})))
v2 := newTestModule(t, sqlStore, newTestDefinition(t, 2, llmV2))
require.NoError(t, v2.ReconcileSystemGroups(ctx, orgID))
upgraded, err := v2.GetGroup(ctx, orgID, group.ID)
require.NoError(t, err)
assert.Equal(t, 2, upgraded.Version)
assert.False(t, upgraded.Enabled)
assert.Equal(t, spantypes.ProvisionerIdentity, upgraded.UpdatedBy)
assert.Equal(t, []spantypes.SpanMapperGroupConditionKey{
{Value: "model", Enabled: false, Origin: spantypes.SpanMapperOriginSystem},
{Value: "llm.", Enabled: true, Origin: spantypes.SpanMapperOriginSystem},
{Value: "gen_ai.request.model", Enabled: true, Origin: spantypes.SpanMapperOriginUser},
}, upgraded.Condition.Attributes)
mappers, err = v2.ListMappers(ctx, orgID, group.ID)
require.NoError(t, err)
require.Len(t, mappers, 4)
model = findMapper(t, mappers, "gen_ai.request.model")
assert.False(t, model.Enabled)
assert.Equal(t, spantypes.SpanMapperOriginSystem, model.Origin)
assert.False(t, findSource(t, model.Config.Sources, "llm.model_name", spantypes.SpanMapperOriginSystem).Enabled)
assert.True(t, findSource(t, model.Config.Sources, "langfuse.observation.model.name", spantypes.SpanMapperOriginSystem).Enabled)
assert.Equal(t, spantypes.SpanMapperOperationMove, findSource(t, model.Config.Sources, "llm.model_name", spantypes.SpanMapperOriginUser).Operation)
assert.Len(t, model.Config.Sources, 3)
messages = findMapper(t, mappers, "gen_ai.input.messages")
assert.Equal(t, spantypes.SpanMapperOriginUser, messages.Origin)
require.Len(t, messages.Config.Sources, 1)
assert.Equal(t, "input.value", messages.Config.Sources[0].Key)
assert.Equal(t, spantypes.SpanMapperOriginSystem, findMapper(t, mappers, "gen_ai.provider.name").Origin)
assert.Equal(t, spantypes.SpanMapperOriginUser, findMapper(t, mappers, "gen_ai.custom").Origin)
// Shipping v1 again drops provider.name outright (no user sources) and
// re-adopts the surviving user mapper input.messages as a shipped one.
v3 := newTestModule(t, sqlStore, newTestDefinition(t, 3, llmV1))
require.NoError(t, v3.ReconcileSystemGroups(ctx, orgID))
mappers, err = v3.ListMappers(ctx, orgID, group.ID)
require.NoError(t, err)
require.Len(t, mappers, 3)
for _, m := range mappers {
assert.NotEqual(t, "gen_ai.provider.name", m.Name)
}
messages = findMapper(t, mappers, "gen_ai.input.messages")
assert.Equal(t, spantypes.SpanMapperOriginSystem, messages.Origin)
assert.Len(t, messages.Config.Sources, 2)
}
func TestReconcileDoesNotDowngrade(t *testing.T) {
ctx := context.Background()
orgID := valuer.GenerateUUID()
sqlStore := newTestSQLStore(t)
require.NoError(t, newTestModule(t, sqlStore, newTestDefinition(t, 2, llmV2)).ReconcileSystemGroups(ctx, orgID))
older := newTestModule(t, sqlStore, newTestDefinition(t, 1, llmV1))
require.NoError(t, older.ReconcileSystemGroups(ctx, orgID))
group, err := older.store.GetGroupByName(ctx, orgID, "llm")
require.NoError(t, err)
assert.Equal(t, 2, group.Version)
mappers, err := older.ListMappers(ctx, orgID, group.ID)
require.NoError(t, err)
findMapper(t, mappers, "gen_ai.provider.name")
}

View File

@@ -0,0 +1,187 @@
package implspanmapper
import (
"context"
"log/slog"
"slices"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func (module *module) ReconcileSystemGroups(ctx context.Context, orgID valuer.UUID) error {
for _, definition := range module.registry.List() {
if err := module.reconcileSystemGroup(ctx, orgID, definition); err != nil {
return err
}
}
agentConf.NotifyConfigUpdate(ctx)
return nil
}
// reconcileSystemGroup brings one org's copy of a definition to the shipped
// version in a single transaction. A concurrent provisioner (another replica,
// or the org-creation hook racing the startup sweep) loses on the group's
// unique (org_id, name) index and is treated as a no-op.
func (module *module) reconcileSystemGroup(ctx context.Context, orgID valuer.UUID, definition spantypes.SpanMapperGroupDefinition) error {
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
group, err := module.store.GetGroupByName(ctx, orgID, definition.Name())
if err != nil && errors.Ast(err, errors.TypeNotFound) {
group = newSystemGroup(orgID, definition)
err = module.store.CreateGroup(ctx, group)
}
if err != nil {
return err
}
if group.Origin != spantypes.SpanMapperOriginSystem {
module.settings.Logger().WarnContext(ctx, "skipping default span mapper group: a user group holds its name", slog.String("name", definition.Name()), slog.String("org_id", orgID.StringValue()))
return nil
}
if group.Version >= definition.Version {
return nil
}
return module.applyDefinition(ctx, orgID, group, definition)
})
if err != nil && errors.Ast(err, errors.TypeAlreadyExists) {
module.settings.Logger().DebugContext(ctx, "default span mapper group provisioned concurrently", slog.String("name", definition.Name()), slog.String("org_id", orgID.StringValue()))
return nil
}
return err
}
// applyDefinition replaces every shipped item with the definition, carrying each
// enabled flag over by identity, and leaves user items untouched. A mapper that
// is no longer shipped is deleted unless the user added sources to it, in which
// case it survives as a user mapper.
func (module *module) applyDefinition(ctx context.Context, orgID valuer.UUID, group *spantypes.SpanMapperGroup, definition spantypes.SpanMapperGroupDefinition) error {
mappers, err := module.store.ListMappers(ctx, orgID, group.ID)
if err != nil {
return err
}
byName := make(map[string]*spantypes.SpanMapper, len(mappers))
for _, m := range mappers {
byName[m.Name] = m
}
now := time.Now()
for i := range definition.Definition.Mappers {
pm := &definition.Definition.Mappers[i]
mapper, exists := byName[pm.Name]
delete(byName, pm.Name)
if !exists {
if err := module.store.CreateMapper(ctx, newSystemMapper(group.ID, pm)); err != nil {
return err
}
continue
}
mapper.Config.Sources = mergeShippedSources(mapper.Config.Sources, pm.Config.Sources)
mapper.FieldContext = pm.FieldContext
mapper.Origin = spantypes.SpanMapperOriginSystem
mapper.UpdatedAt = now
mapper.UpdatedBy = spantypes.ProvisionerIdentity
if err := module.store.UpdateMapper(ctx, mapper); err != nil {
return err
}
}
// Whatever is left in byName is not shipped any more.
for _, mapper := range byName {
if mapper.Origin != spantypes.SpanMapperOriginSystem {
continue
}
mapper.Config.Sources = mergeShippedSources(mapper.Config.Sources, nil)
if len(mapper.Config.Sources) == 0 {
if err := module.store.DeleteMapper(ctx, orgID, group.ID, mapper.ID); err != nil {
return err
}
continue
}
mapper.Origin = spantypes.SpanMapperOriginUser
mapper.UpdatedAt = now
mapper.UpdatedBy = spantypes.ProvisionerIdentity
if err := module.store.UpdateMapper(ctx, mapper); err != nil {
return err
}
}
shipped := definition.Definition.Condition
group.Condition = spantypes.SpanMapperGroupCondition{
Attributes: mergeShippedConditionKeys(group.Condition.Attributes, shipped.Attributes),
Resource: mergeShippedConditionKeys(group.Condition.Resource, shipped.Resource),
}
group.Version = definition.Version
group.UpdatedAt = now
group.UpdatedBy = spantypes.ProvisionerIdentity
if err := module.store.UpdateGroup(ctx, group); err != nil {
return err
}
module.settings.Logger().InfoContext(ctx, "applied default span mapper group", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
return nil
}
// newSystemGroup is the empty shell applyDefinition fills: version 0 so the
// definition is applied right after the row exists.
func newSystemGroup(orgID valuer.UUID, definition spantypes.SpanMapperGroupDefinition) *spantypes.SpanMapperGroup {
group := spantypes.NewSpanMapperGroup(orgID, spantypes.ProvisionerIdentity, &definition.Definition.PostableSpanMapperGroup)
group.Condition = definition.Definition.Condition
group.Enabled = true
group.Origin = spantypes.SpanMapperOriginSystem
return group
}
func newSystemMapper(groupID valuer.UUID, pm *spantypes.PostableSpanMapper) *spantypes.SpanMapper {
mapper := spantypes.NewSpanMapper(groupID, spantypes.ProvisionerIdentity, pm)
mapper.Config = pm.Config
mapper.Enabled = true
mapper.Origin = spantypes.SpanMapperOriginSystem
return mapper
}
// mergeShippedConditionKeys returns the shipped keys, each keeping the enabled
// flag of the stored system key with the same value, followed by the stored
// user keys.
func mergeShippedConditionKeys(stored, shipped []spantypes.SpanMapperGroupConditionKey) []spantypes.SpanMapperGroupConditionKey {
out := make([]spantypes.SpanMapperGroupConditionKey, 0, len(stored)+len(shipped))
for _, k := range shipped {
idx := slices.IndexFunc(stored, func(s spantypes.SpanMapperGroupConditionKey) bool {
return s.Origin == spantypes.SpanMapperOriginSystem && s.Value == k.Value
})
if idx != -1 {
k.Enabled = stored[idx].Enabled
}
out = append(out, k)
}
for _, k := range stored {
if k.Origin != spantypes.SpanMapperOriginSystem {
out = append(out, k)
}
}
return out
}
// mergeShippedSources returns the shipped sources, each keeping the enabled
// flag of the stored system source with the same key and context, followed by
// the stored user sources.
func mergeShippedSources(stored, shipped []spantypes.SpanMapperSource) []spantypes.SpanMapperSource {
out := make([]spantypes.SpanMapperSource, 0, len(stored)+len(shipped))
for _, s := range shipped {
idx := slices.IndexFunc(stored, func(o spantypes.SpanMapperSource) bool {
return o.Origin == spantypes.SpanMapperOriginSystem && o.Key == s.Key && o.Context == s.Context
})
if idx != -1 {
s.Enabled = stored[idx].Enabled
}
out = append(out, s)
}
for _, s := range stored {
if s.Origin != spantypes.SpanMapperOriginSystem {
out = append(out, s)
}
}
return out
}

View File

@@ -0,0 +1,81 @@
package implspanmapper
import (
"context"
"log/slog"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
)
const reconcileRetryInterval = 30 * time.Second
type service struct {
settings factory.ScopedProviderSettings
module spanmapper.Module
orgGetter organization.Getter
stopC chan struct{}
healthyC chan struct{}
}
// NewService reconciles every org's default mapping groups once at startup.
// Orgs created later are reconciled by the organization setter instead.
func NewService(providerSettings factory.ProviderSettings, module spanmapper.Module, orgGetter organization.Getter) factory.Service {
return &service{
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"),
module: module,
orgGetter: orgGetter,
stopC: make(chan struct{}),
healthyC: make(chan struct{}),
}
}
func (service *service) Start(ctx context.Context) error {
ticker := time.NewTicker(reconcileRetryInterval)
defer ticker.Stop()
for {
err := service.reconcile(ctx)
if err == nil {
close(service.healthyC)
<-service.stopC
return nil
}
service.settings.Logger().WarnContext(ctx, "default span mapper group reconciliation failed, retrying", errors.Attr(err))
select {
case <-service.stopC:
return nil
case <-ticker.C:
}
}
}
func (service *service) Healthy() <-chan struct{} {
return service.healthyC
}
func (service *service) Stop(_ context.Context) error {
close(service.stopC)
return nil
}
func (service *service) reconcile(ctx context.Context) error {
orgs, err := service.orgGetter.ListByOwnedKeyRange(ctx)
if err != nil {
return err
}
for _, org := range orgs {
if err := service.module.ReconcileSystemGroups(ctx, org.ID); err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't reconcile default span mapper groups for org %s", org.ID.StringValue())
}
}
service.settings.Logger().InfoContext(ctx, "default span mapper group reconciliation completed", slog.Int("orgs", len(orgs)))
return nil
}

View File

@@ -17,6 +17,10 @@ func NewStore(sqlstore sqlstore.SQLStore) spantypes.SpanMapperStore {
return &store{sqlstore: sqlstore}
}
func (s *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
return s.sqlstore.RunInTxCtx(ctx, nil, cb)
}
func (s *store) CreateGroup(ctx context.Context, group *spantypes.SpanMapperGroup) error {
storable := group.ToStorable()
_, err := s.sqlstore.
@@ -34,7 +38,7 @@ func (s *store) GetGroup(ctx context.Context, orgID, id valuer.UUID) (*spantypes
storable := new(spantypes.StorableSpanMapperGroup)
err := s.sqlstore.
BunDB().
BunDBCtx(ctx).
NewSelect().
Model(storable).
Where("org_id = ?", orgID).
@@ -46,11 +50,27 @@ func (s *store) GetGroup(ctx context.Context, orgID, id valuer.UUID) (*spantypes
return storable.ToSpanMapperGroup(), nil
}
func (s *store) GetGroupByName(ctx context.Context, orgID valuer.UUID, name string) (*spantypes.SpanMapperGroup, error) {
storable := new(spantypes.StorableSpanMapperGroup)
err := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(storable).
Where("org_id = ?", orgID).
Where("name = ?", name).
Scan(ctx)
if err != nil {
return nil, s.sqlstore.WrapNotFoundErrf(err, spantypes.ErrCodeMappingGroupNotFound, "span mapper group %q not found", name)
}
return storable.ToSpanMapperGroup(), nil
}
func (s *store) ListGroups(ctx context.Context, orgID valuer.UUID, q *spantypes.ListSpanMapperGroupsQuery) ([]*spantypes.SpanMapperGroup, error) {
storables := make([]*spantypes.StorableSpanMapperGroup, 0)
sel := s.sqlstore.
BunDB().
BunDBCtx(ctx).
NewSelect().
Model(&storables).
Where("org_id = ?", orgID)
@@ -91,38 +111,35 @@ func (s *store) UpdateGroup(ctx context.Context, group *spantypes.SpanMapperGrou
}
func (s *store) DeleteGroup(ctx context.Context, orgID, id valuer.UUID) error {
tx, err := s.sqlstore.BunDBCtx(ctx).BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
return s.RunInTx(ctx, func(ctx context.Context) error {
db := s.sqlstore.BunDBCtx(ctx)
// Cascade: remove mappers belonging to this group first.
if _, err := tx.NewDelete().
Model((*spantypes.StorableSpanMapper)(nil)).
Where("group_id = ?", id).
Exec(ctx); err != nil {
return err
}
// Cascade: remove mappers belonging to this group first.
if _, err := db.NewDelete().
Model((*spantypes.StorableSpanMapper)(nil)).
Where("group_id = ?", id).
Exec(ctx); err != nil {
return err
}
res, err := tx.NewDelete().
Model((*spantypes.StorableSpanMapperGroup)(nil)).
Where("org_id = ?", orgID).
Where("id = ?", id).
Exec(ctx)
if err != nil {
return err
}
res, err := db.NewDelete().
Model((*spantypes.StorableSpanMapperGroup)(nil)).
Where("org_id = ?", orgID).
Where("id = ?", id).
Exec(ctx)
if err != nil {
return err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "span mapper group %s not found", id)
}
return tx.Commit()
rowsAffected, err := res.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "span mapper group %s not found", id)
}
return nil
})
}
func (s *store) CreateMapper(ctx context.Context, mapper *spantypes.SpanMapper) error {
@@ -146,7 +163,7 @@ func (s *store) GetMapper(ctx context.Context, orgID, groupID, id valuer.UUID) (
storable := new(spantypes.StorableSpanMapper)
err := s.sqlstore.
BunDB().
BunDBCtx(ctx).
NewSelect().
Model(storable).
Where("group_id = ?", groupID).
@@ -166,7 +183,7 @@ func (s *store) ListMappers(ctx context.Context, orgID, groupID valuer.UUID) ([]
storables := make([]*spantypes.StorableSpanMapper, 0)
if err := s.sqlstore.
BunDB().
BunDBCtx(ctx).
NewSelect().
Model(&storables).
Where("group_id = ?", groupID).

View File

@@ -28,6 +28,10 @@ type Module interface {
UpdateMapper(ctx context.Context, orgID, groupID, id valuer.UUID, fieldContext spantypes.FieldContext, config *spantypes.SpanMapperConfig, enabled *bool, updatedBy string) error
DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error
TestMappers(ctx context.Context, orgID valuer.UUID, spans []spantypes.SpanMapperTestSpan, groups []*spantypes.SpanMapperGroupWithMappers) ([]spantypes.SpanMapperTestSpan, []string, error)
// ReconcileSystemGroups provisions or upgrades the shipped mapping groups
// for one org. It runs at startup for every org and again on org creation.
ReconcileSystemGroups(ctx context.Context, orgID valuer.UUID) error
}
// Handler defines the HTTP handler interface for mapping group and mapper endpoints.

View File

@@ -1,521 +0,0 @@
// Package sqlcompiler compiles list-page filter queries to relational-store WHERE clauses; telemetry queries stay on querybuilder's ClickHouse visitor.
package sqlcompiler
import (
"fmt"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/parser/filterquery"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
"github.com/SigNoz/signoz/pkg/sqlstore"
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/antlr4-go/antlr/v4"
sqlbuilder "github.com/huandu/go-sqlbuilder"
)
// bunPlaceholderFlavor is any flavor that renders the `?` placeholders bun expects.
const bunPlaceholderFlavor = sqlbuilder.SQLite
// FieldResolver is the per-feature policy: which keys exist and what each maps to.
type FieldResolver interface {
// ResolveComparison builds the predicate for one `key OP value` term; key keeps the user's casing.
ResolveComparison(b *Builder, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string
// FreeText builds the predicate for a bare token.
FreeText(b *Builder, value string) string
}
// Compiled is a `?`-placeholder WHERE clause with its bun bind args.
type Compiled struct {
SQL string
Args []any
}
func (c Compiled) IsEmpty() bool {
return c.SQL == ""
}
// Compile on success returns a non-nil *Compiled, empty for an empty query; callers gate on IsEmpty, not nil.
func Compile(query string, formatter sqlstore.SQLFormatter, resolver FieldResolver) (*Compiled, []string) {
if len(strings.TrimSpace(query)) == 0 {
return &Compiled{}, nil
}
v := &visitor{
builder: &Builder{
selectBuilder: sqlbuilder.NewSelectBuilder(),
formatter: formatter,
},
resolver: resolver,
}
tree, _, collector := filterquery.Parse(query)
if len(collector.Errors) > 0 {
return nil, collector.Errors
}
condition, _ := v.visit(tree).(string)
if len(v.builder.errors) > 0 {
return nil, v.builder.errors
}
if condition == "" {
return &Compiled{}, nil
}
sql, arguments := v.builder.selectBuilder.Args.CompileWithFlavor(condition, bunPlaceholderFlavor)
return &Compiled{SQL: sql, Args: arguments}, nil
}
// Builder is the per-compile toolbox handed to a FieldResolver.
type Builder struct {
selectBuilder *sqlbuilder.SelectBuilder
formatter sqlstore.SQLFormatter
errors []string
}
func (b *Builder) SelectBuilder() *sqlbuilder.SelectBuilder {
return b.selectBuilder
}
func (b *Builder) Formatter() sqlstore.SQLFormatter {
return b.formatter
}
func (b *Builder) AddError(format string, arguments ...any) {
b.errors = append(b.errors, fmt.Sprintf(format, arguments...))
}
// StringOperation interns placeholders into sb so nested subquery arguments thread correctly.
func (b *Builder) StringOperation(sb *sqlbuilder.SelectBuilder, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, columnExpression, keyForError string) string {
switch operation {
case qbtypesv5.FilterOperatorEqual:
val, ok := b.ExtractSingleStringValue(ctx, keyForError)
if !ok {
return ""
}
return sb.Equal(columnExpression, val)
case qbtypesv5.FilterOperatorNotEqual:
val, ok := b.ExtractSingleStringValue(ctx, keyForError)
if !ok {
return ""
}
return sb.NotEqual(columnExpression, val)
case qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike:
val, ok := b.ExtractSingleStringValue(ctx, keyForError)
if !ok {
return ""
}
if endsWithDanglingEscape(val) {
b.AddError("LIKE pattern for %q must not end with an unescaped backslash, use \\\\ to match a literal backslash", keyForError)
return ""
}
like := "LIKE"
if operation == qbtypesv5.FilterOperatorNotLike {
like = "NOT LIKE"
}
// ESCAPE pins backslash as the escape char (the Postgres default, SQLite has none).
return fmt.Sprintf("%s %s %s ESCAPE '\\'", columnExpression, like, sb.Var(val))
case qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike:
val, ok := b.ExtractSingleStringValue(ctx, keyForError)
if !ok {
return ""
}
if endsWithDanglingEscape(val) {
b.AddError("ILIKE pattern for %q must not end with an unescaped backslash, use \\\\ to match a literal backslash", keyForError)
return ""
}
// SQLite has no ILIKE and Postgres LIKE is case-sensitive, so LOWER both sides.
lowerColumn := string(b.formatter.LowerExpression(columnExpression))
like := "LIKE"
if operation == qbtypesv5.FilterOperatorNotILike {
like = "NOT LIKE"
}
return fmt.Sprintf("%s %s LOWER(%s) ESCAPE '\\'", lowerColumn, like, sb.Var(val))
case qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains:
val, ok := b.ExtractSingleStringValue(ctx, keyForError)
if !ok {
return ""
}
like := "LIKE"
if operation == qbtypesv5.FilterOperatorNotContains {
like = "NOT LIKE"
}
// Escape the user's % and _ so they match literally, then wrap in wildcards.
escaped := b.formatter.EscapeLikePattern(val)
return fmt.Sprintf("%s %s %s ESCAPE '\\'", columnExpression, like, sb.Var(fmt.Sprintf("%%%s%%", escaped)))
case qbtypesv5.FilterOperatorRegexp, qbtypesv5.FilterOperatorNotRegexp:
b.AddError("REGEXP filtering on %q is not supported", keyForError)
return ""
case qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn:
values, ok := b.ExtractStringValueList(ctx, keyForError)
if !ok {
return ""
}
arguments := make([]any, len(values))
for i, s := range values {
arguments[i] = s
}
if operation == qbtypesv5.FilterOperatorNotIn {
return sb.NotIn(columnExpression, arguments...)
}
return sb.In(columnExpression, arguments...)
}
b.AddError("operator %s on %q is not implemented", OperationName(operation), keyForError)
return ""
}
func (b *Builder) TimestampComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, columnExpression string) string {
switch operation {
case qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLessThan, qbtypesv5.FilterOperatorLessThanOrEq,
qbtypesv5.FilterOperatorGreaterThan, qbtypesv5.FilterOperatorGreaterThanOrEq:
t, ok := b.extractSingleTimestampValue(ctx)
if !ok {
return ""
}
switch operation {
case qbtypesv5.FilterOperatorEqual:
return b.selectBuilder.Equal(columnExpression, t)
case qbtypesv5.FilterOperatorNotEqual:
return b.selectBuilder.NotEqual(columnExpression, t)
case qbtypesv5.FilterOperatorLessThan:
return b.selectBuilder.LessThan(columnExpression, t)
case qbtypesv5.FilterOperatorLessThanOrEq:
return b.selectBuilder.LessEqualThan(columnExpression, t)
case qbtypesv5.FilterOperatorGreaterThan:
return b.selectBuilder.GreaterThan(columnExpression, t)
case qbtypesv5.FilterOperatorGreaterThanOrEq:
return b.selectBuilder.GreaterEqualThan(columnExpression, t)
}
case qbtypesv5.FilterOperatorBetween, qbtypesv5.FilterOperatorNotBetween:
timestamps, ok := b.extractTwoTimestampValues(ctx)
if !ok {
return ""
}
if operation == qbtypesv5.FilterOperatorNotBetween {
return b.selectBuilder.NotBetween(columnExpression, timestamps[0], timestamps[1])
}
return b.selectBuilder.Between(columnExpression, timestamps[0], timestamps[1])
}
b.AddError("operator %s on timestamp is not implemented", OperationName(operation))
return ""
}
func (b *Builder) BoolComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, columnExpression string) string {
value, ok := b.extractSingleBoolValue(ctx)
if !ok {
return ""
}
if operation == qbtypesv5.FilterOperatorNotEqual {
return b.selectBuilder.NotEqual(columnExpression, value)
}
return b.selectBuilder.Equal(columnExpression, value)
}
// A pattern ending in an unescaped backslash never matches on sqlite and errors on Postgres.
func endsWithDanglingEscape(value string) bool {
trailing := len(value) - len(strings.TrimRight(value, `\`))
return trailing%2 == 1
}
// FreeTextContains COALESCEs the column so NOT (...) does not go NULL and drop rows where it is absent.
func (b *Builder) FreeTextContains(sb *sqlbuilder.SelectBuilder, columnExpression, value string) string {
lowerColumn := string(b.formatter.LowerExpression(fmt.Sprintf("COALESCE(%s, '')", columnExpression)))
pattern := fmt.Sprintf("%%%s%%", b.formatter.EscapeLikePattern(value))
return fmt.Sprintf("%s LIKE LOWER(%s) ESCAPE '\\'", lowerColumn, sb.Var(pattern))
}
func (b *Builder) ExtractSingleStringValue(ctx *grammar.ComparisonContext, keyForError string) (string, bool) {
values := ctx.AllValue()
if len(values) != 1 {
b.AddError("expected exactly one value for %q", keyForError)
return "", false
}
return b.extractStringValue(values[0], keyForError)
}
func (b *Builder) ExtractStringValueList(ctx *grammar.ComparisonContext, keyForError string) ([]string, bool) {
var valuesCtx []grammar.IValueContext
switch {
case ctx.InClause() != nil:
inClause := ctx.InClause()
if inClause.ValueList() != nil {
valuesCtx = inClause.ValueList().AllValue()
} else {
valuesCtx = []grammar.IValueContext{inClause.Value()}
}
case ctx.NotInClause() != nil:
notInClause := ctx.NotInClause()
if notInClause.ValueList() != nil {
valuesCtx = notInClause.ValueList().AllValue()
} else {
valuesCtx = []grammar.IValueContext{notInClause.Value()}
}
default:
b.AddError("IN clause is missing for %q", keyForError)
return nil, false
}
if len(valuesCtx) == 0 {
b.AddError("IN list for %q is empty", keyForError)
return nil, false
}
out := make([]string, 0, len(valuesCtx))
for _, valueContext := range valuesCtx {
s, ok := b.extractStringValue(valueContext, keyForError)
if !ok {
return nil, false
}
out = append(out, s)
}
return out, true
}
func (b *Builder) extractSingleBoolValue(ctx *grammar.ComparisonContext) (bool, bool) {
values := ctx.AllValue()
if len(values) != 1 {
b.AddError("expected a single boolean (true/false)")
return false, false
}
return b.extractBoolValue(values[0])
}
func (b *Builder) extractSingleTimestampValue(ctx *grammar.ComparisonContext) (time.Time, bool) {
values := ctx.AllValue()
if len(values) != 1 {
b.AddError("expected a single RFC3339 timestamp")
return time.Time{}, false
}
return b.extractTimestampValue(values[0])
}
func (b *Builder) extractTwoTimestampValues(ctx *grammar.ComparisonContext) ([2]time.Time, bool) {
values := ctx.AllValue()
if len(values) != 2 {
b.AddError("BETWEEN expects two RFC3339 timestamps")
return [2]time.Time{}, false
}
first, ok1 := b.extractTimestampValue(values[0])
second, ok2 := b.extractTimestampValue(values[1])
if !ok1 || !ok2 {
return [2]time.Time{}, false
}
return [2]time.Time{first, second}, true
}
func (b *Builder) extractStringValue(ctx grammar.IValueContext, keyForError string) (string, bool) {
if ctx.QUOTED_TEXT() != nil {
return trimQuotes(ctx.QUOTED_TEXT().GetText()), true
}
if ctx.KEY() != nil {
return ctx.KEY().GetText(), true
}
b.AddError("expected a string value for %q, got %q", keyForError, ctx.GetText())
return "", false
}
func (b *Builder) extractBoolValue(ctx grammar.IValueContext) (bool, bool) {
if ctx.BOOL() == nil {
b.AddError("expected a boolean (true/false), got %q", ctx.GetText())
return false, false
}
return strings.EqualFold(ctx.BOOL().GetText(), "true"), true
}
func (b *Builder) extractTimestampValue(ctx grammar.IValueContext) (time.Time, bool) {
if ctx.QUOTED_TEXT() == nil {
b.AddError("expected an RFC3339 timestamp string, got %q", ctx.GetText())
return time.Time{}, false
}
raw := trimQuotes(ctx.QUOTED_TEXT().GetText())
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
b.AddError("invalid RFC3339 timestamp %q: %s", raw, err.Error())
return time.Time{}, false
}
return t, true
}
type visitor struct {
grammar.BaseFilterQueryVisitor
builder *Builder
resolver FieldResolver
}
func (v *visitor) visit(tree antlr.ParseTree) any {
if tree == nil {
return nil
}
return tree.Accept(v)
}
func (v *visitor) VisitQuery(ctx *grammar.QueryContext) any {
return v.visit(ctx.Expression())
}
func (v *visitor) VisitExpression(ctx *grammar.ExpressionContext) any {
return v.visit(ctx.OrExpression())
}
func (v *visitor) VisitOrExpression(ctx *grammar.OrExpressionContext) any {
parts := ctx.AllAndExpression()
conditions := make([]string, 0, len(parts))
for _, part := range parts {
if condition, ok := v.visit(part).(string); ok && condition != "" {
conditions = append(conditions, condition)
}
}
switch len(conditions) {
case 0:
return ""
case 1:
return conditions[0]
default:
return v.builder.selectBuilder.Or(conditions...)
}
}
func (v *visitor) VisitAndExpression(ctx *grammar.AndExpressionContext) any {
parts := ctx.AllUnaryExpression()
conditions := make([]string, 0, len(parts))
for _, part := range parts {
if condition, ok := v.visit(part).(string); ok && condition != "" {
conditions = append(conditions, condition)
}
}
switch len(conditions) {
case 0:
return ""
case 1:
return conditions[0]
default:
return v.builder.selectBuilder.And(conditions...)
}
}
func (v *visitor) VisitUnaryExpression(ctx *grammar.UnaryExpressionContext) any {
condition, _ := v.visit(ctx.Primary()).(string)
if condition == "" {
return ""
}
if ctx.NOT() != nil {
return fmt.Sprintf("NOT (%s)", condition)
}
return condition
}
func (v *visitor) VisitPrimary(ctx *grammar.PrimaryContext) any {
if ctx.OrExpression() != nil {
return v.visit(ctx.OrExpression())
}
if ctx.Comparison() != nil {
return v.visit(ctx.Comparison())
}
// A quoted lone token matches its contents literally, the escape hatch for a phrase that looks like DSL.
return v.resolver.FreeText(v.builder, trimQuotes(ctx.GetText()))
}
func (v *visitor) VisitComparison(ctx *grammar.ComparisonContext) any {
key := strings.TrimSpace(ctx.Key().GetText())
operation, ok := v.extractOperation(ctx)
if !ok {
return ""
}
return v.resolver.ResolveComparison(v.builder, key, operation, ctx)
}
func (v *visitor) extractOperation(ctx *grammar.ComparisonContext) (qbtypesv5.FilterOperator, bool) {
maybeNot := func(operation qbtypesv5.FilterOperator) qbtypesv5.FilterOperator {
if ctx.NOT() != nil {
return operation.Inverse()
}
return operation
}
switch {
case ctx.EQUALS() != nil:
return qbtypesv5.FilterOperatorEqual, true
case ctx.NOT_EQUALS() != nil, ctx.NEQ() != nil:
return qbtypesv5.FilterOperatorNotEqual, true
case ctx.LT() != nil:
return qbtypesv5.FilterOperatorLessThan, true
case ctx.LE() != nil:
return qbtypesv5.FilterOperatorLessThanOrEq, true
case ctx.GT() != nil:
return qbtypesv5.FilterOperatorGreaterThan, true
case ctx.GE() != nil:
return qbtypesv5.FilterOperatorGreaterThanOrEq, true
case ctx.BETWEEN() != nil:
return maybeNot(qbtypesv5.FilterOperatorBetween), true
case ctx.LIKE() != nil:
return maybeNot(qbtypesv5.FilterOperatorLike), true
case ctx.ILIKE() != nil:
return maybeNot(qbtypesv5.FilterOperatorILike), true
case ctx.CONTAINS() != nil:
return maybeNot(qbtypesv5.FilterOperatorContains), true
case ctx.REGEXP() != nil:
return maybeNot(qbtypesv5.FilterOperatorRegexp), true
case ctx.InClause() != nil:
return qbtypesv5.FilterOperatorIn, true
case ctx.NotInClause() != nil:
return qbtypesv5.FilterOperatorNotIn, true
case ctx.EXISTS() != nil:
return maybeNot(qbtypesv5.FilterOperatorExists), true
}
v.builder.AddError("could not determine operator in expression %q", ctx.GetText())
return qbtypesv5.FilterOperatorUnknown, false
}
// OperationName is the user-facing spelling, used only in error messages.
func OperationName(operation qbtypesv5.FilterOperator) string {
switch operation {
case qbtypesv5.FilterOperatorEqual:
return "="
case qbtypesv5.FilterOperatorNotEqual:
return "!="
case qbtypesv5.FilterOperatorLessThan:
return "<"
case qbtypesv5.FilterOperatorLessThanOrEq:
return "<="
case qbtypesv5.FilterOperatorGreaterThan:
return ">"
case qbtypesv5.FilterOperatorGreaterThanOrEq:
return ">="
case qbtypesv5.FilterOperatorBetween:
return "BETWEEN"
case qbtypesv5.FilterOperatorNotBetween:
return "NOT BETWEEN"
case qbtypesv5.FilterOperatorLike:
return "LIKE"
case qbtypesv5.FilterOperatorNotLike:
return "NOT LIKE"
case qbtypesv5.FilterOperatorILike:
return "ILIKE"
case qbtypesv5.FilterOperatorNotILike:
return "NOT ILIKE"
case qbtypesv5.FilterOperatorContains:
return "CONTAINS"
case qbtypesv5.FilterOperatorNotContains:
return "NOT CONTAINS"
case qbtypesv5.FilterOperatorRegexp:
return "REGEXP"
case qbtypesv5.FilterOperatorNotRegexp:
return "NOT REGEXP"
case qbtypesv5.FilterOperatorIn:
return "IN"
case qbtypesv5.FilterOperatorNotIn:
return "NOT IN"
case qbtypesv5.FilterOperatorExists:
return "EXISTS"
case qbtypesv5.FilterOperatorNotExists:
return "NOT EXISTS"
}
return "?"
}
func trimQuotes(s string) string {
if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
s = s[1 : len(s)-1]
}
}
s = strings.ReplaceAll(s, `\\`, `\`)
s = strings.ReplaceAll(s, `\'`, `'`)
return s
}

View File

@@ -718,8 +718,8 @@ func (m *Manager) Rules() []Rule {
// TriggeredAlerts returns the list of the manager's rules.
func (m *Manager) TriggeredAlerts() []*ruletypes.NamedAlert {
m.mtx.RLock()
defer m.mtx.RUnlock()
// m.mtx.RLock()
// defer m.mtx.RUnlock()
namedAlerts := []*ruletypes.NamedAlert{}
@@ -851,8 +851,6 @@ func (m *Manager) ListRuleStates(ctx context.Context) (*ruletypes.GettableRules,
// initiate response object
resp := make([]*ruletypes.GettableRule, 0)
stateByRuleID := m.snapshotRuleStates()
for _, s := range storedRules {
ruleResponse := ruletypes.GettableRule{}
@@ -865,11 +863,11 @@ func (m *Manager) ListRuleStates(ctx context.Context) (*ruletypes.GettableRules,
ruleResponse.Id = s.ID.StringValue()
// fetch state of rule from memory
if state, ok := stateByRuleID[ruleResponse.Id]; !ok {
if rm, ok := m.rules[ruleResponse.Id]; !ok {
ruleResponse.State = ruletypes.StateDisabled
ruleResponse.Disabled = true
} else {
ruleResponse.State = state
ruleResponse.State = rm.State()
}
ruleResponse.CreatedAt = s.CreatedAt
ruleResponse.CreatedBy = &s.CreatedBy
@@ -881,84 +879,6 @@ func (m *Manager) ListRuleStates(ctx context.Context) (*ruletypes.GettableRules,
return &ruletypes.GettableRules{Rules: resp}, nil
}
// ListRules' total counts what is pageable after corrupt-row drops and the states filter.
func (m *Manager) ListRules(ctx context.Context, params *ruletypes.ListRulesParams) (*ruletypes.ListableRules, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, err
}
states, err := params.AlertStates()
if err != nil {
return nil, err
}
stateFilter := make(map[ruletypes.AlertState]struct{}, len(states))
for _, state := range states {
stateFilter[state] = struct{}{}
}
storedRules, err := m.ruleStore.GetStoredRulesMatching(ctx, claims.OrgID, params.Query)
if err != nil {
return nil, err
}
stateByRuleID := m.snapshotRuleStates()
listableRules := make([]*ruletypes.ListableRule, 0, len(storedRules))
for _, s := range storedRules {
gettable := ruletypes.GettableRule{}
if err := json.Unmarshal([]byte(s.Data), &gettable); err != nil {
m.logger.ErrorContext(ctx, "failed to unmarshal rule from db", slog.String("rule.id", s.ID.StringValue()), errors.Attr(err))
continue
}
gettable.Id = s.ID.StringValue()
if state, ok := stateByRuleID[gettable.Id]; ok {
gettable.State = state
} else {
gettable.State = ruletypes.StateDisabled
gettable.Disabled = true
}
if len(stateFilter) > 0 {
if _, ok := stateFilter[gettable.State]; !ok {
continue
}
}
gettable.CreatedAt = s.CreatedAt
gettable.CreatedBy = &s.CreatedBy
gettable.UpdatedAt = s.UpdatedAt
gettable.UpdatedBy = &s.UpdatedBy
listableRules = append(listableRules, ruletypes.NewListableRule(&gettable))
}
total := int64(len(listableRules))
ruletypes.SortListableRules(listableRules, params.Sort, params.Order)
start := min(params.Offset, len(listableRules))
end := min(start+params.Limit, len(listableRules))
currentPageRules := listableRules[start:end]
rawLabels, err := m.ruleStore.GetStoredRuleLabels(ctx, claims.OrgID)
if err != nil {
return nil, err
}
labelPairs := ruletypes.NewLabelPairsFromRawJSON(rawLabels, ruletypes.MaxListLabelPairs)
return ruletypes.NewListableRules(currentPageRules, total, labelPairs), nil
}
func (m *Manager) snapshotRuleStates() map[string]ruletypes.AlertState {
m.mtx.RLock()
defer m.mtx.RUnlock()
states := make(map[string]ruletypes.AlertState, len(m.rules))
for id, rule := range m.rules {
states[id] = rule.State()
}
return states
}
func (m *Manager) GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
@@ -979,10 +899,7 @@ func (m *Manager) GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.Getta
}
r.Id = id.StringValue()
// fetch state of rule from memory
m.mtx.RLock()
rm, ok := m.rules[r.Id]
m.mtx.RUnlock()
if !ok {
if rm, ok := m.rules[r.Id]; !ok {
r.State = ruletypes.StateDisabled
r.Disabled = true
} else {

View File

@@ -4,7 +4,6 @@ import "net/http"
type Handler interface {
ListRules(http.ResponseWriter, *http.Request)
ListRulesV3(http.ResponseWriter, *http.Request)
GetRuleByID(http.ResponseWriter, *http.Request)
CreateRule(http.ResponseWriter, *http.Request)
UpdateRuleByID(http.ResponseWriter, *http.Request)
@@ -12,11 +11,6 @@ type Handler interface {
PatchRuleByID(http.ResponseWriter, *http.Request)
TestRule(http.ResponseWriter, *http.Request)
ListRuleViews(http.ResponseWriter, *http.Request)
CreateRuleView(http.ResponseWriter, *http.Request)
UpdateRuleView(http.ResponseWriter, *http.Request)
DeleteRuleView(http.ResponseWriter, *http.Request)
ListDowntimeSchedules(http.ResponseWriter, *http.Request)
GetDowntimeScheduleByID(http.ResponseWriter, *http.Request)
CreateDowntimeSchedule(http.ResponseWriter, *http.Request)

View File

@@ -17,9 +17,6 @@ type Ruler interface {
// ListRuleStates returns all rules with their current evaluation state.
ListRuleStates(ctx context.Context) (*ruletypes.GettableRules, error)
// ListRules returns a filtered, sorted page of rules with state, plus label pairs and reserved filter keys.
ListRules(ctx context.Context, params *ruletypes.ListRulesParams) (*ruletypes.ListableRules, error)
// GetRule returns a single rule by ID.
GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error)
@@ -49,16 +46,4 @@ type Ruler interface {
// TODO: expose downtime CRUD as methods on Ruler directly instead of leaking the
// store interface. The handler should not call store methods directly.
MaintenanceStore() alertmanagertypes.MaintenanceStore
// CreateRuleView persists the given rule listing state as a named view shared org-wide.
CreateRuleView(ctx context.Context, orgID valuer.UUID, postable ruletypes.PostableRuleView) (*ruletypes.RuleView, error)
// ListRuleViews returns all of the org's saved rule views.
ListRuleViews(ctx context.Context, orgID valuer.UUID) (*ruletypes.ListableRuleViews, error)
// UpdateRuleView replaces the view's name and data.
UpdateRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatable ruletypes.UpdatableRuleView) (*ruletypes.RuleView, error)
// DeleteRuleView removes the view identified by id.
DeleteRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
}

View File

@@ -64,16 +64,6 @@ func (m *MockSQLRuleStore) GetStoredRules(ctx context.Context, orgID string) ([]
return m.ruleStore.GetStoredRules(ctx, orgID)
}
// GetStoredRulesMatching implements ruletypes.RuleStore - delegates to underlying ruleStore to trigger SQL.
func (m *MockSQLRuleStore) GetStoredRulesMatching(ctx context.Context, orgID string, query string) ([]*ruletypes.StorableRule, error) {
return m.ruleStore.GetStoredRulesMatching(ctx, orgID, query)
}
// GetStoredRuleLabels implements ruletypes.RuleStore - delegates to underlying ruleStore to trigger SQL.
func (m *MockSQLRuleStore) GetStoredRuleLabels(ctx context.Context, orgID string) ([]string, error) {
return m.ruleStore.GetStoredRuleLabels(ctx, orgID)
}
// GetStoredRulesByMetricName implements ruletypes.RuleStore - delegates to underlying ruleStore.
func (m *MockSQLRuleStore) GetStoredRulesByMetricName(ctx context.Context, orgID string, metricName string) ([]ruletypes.RuleAlert, error) {
return m.ruleStore.GetStoredRulesByMetricName(ctx, orgID, metricName)

View File

@@ -1,20 +0,0 @@
package sqlrulestore
import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/parser/filterquery/sqlcompiler"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
)
// Compile wraps compiler errors in the rules list filter error code.
func Compile(query string, formatter sqlstore.SQLFormatter) (*sqlcompiler.Compiled, error) {
compiled, errs := sqlcompiler.Compile(query, formatter, ruleFieldResolver{})
if len(errs) > 0 {
return nil, errors.NewInvalidInputf(ruletypes.ErrCodeRuleListFilterInvalid,
"invalid filter query: %s", strings.Join(errs, "; "))
}
return compiled, nil
}

View File

@@ -1,168 +0,0 @@
package sqlrulestore
import (
"fmt"
"slices"
"strings"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
"github.com/SigNoz/signoz/pkg/parser/filterquery/sqlcompiler"
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
)
const (
ruleDataColumn = "rule.data"
ruleLabelsField = "labels"
nameJSONPath = "$.alert"
descriptionPath = "$.description"
labelsJSONPath = "$.labels"
alertTypePath = "$.alertType"
ruleTypePath = "$.ruleType"
)
// ruleFieldResolver maps rule list DSL keys; label keys are case-sensitive and unknown keys are rejected.
type ruleFieldResolver struct{}
func (r ruleFieldResolver) ResolveComparison(b *sqlcompiler.Builder, rawKey string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
key := strings.ToLower(rawKey)
if allowedOperations, isReserved := ruletypes.ReservedOps[ruletypes.DSLKey(key)]; isReserved {
return r.resolveReservedKey(b, ctx, operation, ruletypes.DSLKey(key), allowedOperations)
}
if strings.HasPrefix(key, ruletypes.DSLLabelsKeyPrefix) {
labelKey := rawKey[len(ruletypes.DSLLabelsKeyPrefix):]
if labelKey == "" {
b.AddError("labels filter is missing a key, use labels.<key>")
return ""
}
if _, allowed := ruletypes.LabelsKeyOps[operation]; !allowed {
b.AddError("operator %s is not allowed on a labels.<key> filter", sqlcompiler.OperationName(operation))
return ""
}
return r.labelComparison(b, ctx, operation, labelKey)
}
b.AddError("unknown filter key %q, use one of the reserved keys or labels.<key>", rawKey)
return ""
}
func (r ruleFieldResolver) resolveReservedKey(b *sqlcompiler.Builder, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key ruletypes.DSLKey, allowedOperations map[qbtypesv5.FilterOperator]struct{}) string {
if _, allowed := allowedOperations[operation]; !allowed {
b.AddError("operator %s is not allowed for key %q", sqlcompiler.OperationName(operation), key)
return ""
}
switch key {
case ruletypes.DSLKeyName:
columnExpression := string(b.Formatter().JSONExtractString(ruleDataColumn, nameJSONPath))
return b.StringOperation(b.SelectBuilder(), ctx, operation, columnExpression, string(key))
case ruletypes.DSLKeySeverity:
// severity is an alias for labels.severity, sharing its missing-label semantics.
return r.labelComparison(b, ctx, operation, "severity")
case ruletypes.DSLKeyCreatedBy:
return b.StringOperation(b.SelectBuilder(), ctx, operation, "rule.created_by", string(key))
case ruletypes.DSLKeyUpdatedBy:
return b.StringOperation(b.SelectBuilder(), ctx, operation, "rule.updated_by", string(key))
case ruletypes.DSLKeyCreatedAt:
return b.TimestampComparison(ctx, operation, "rule.created_at")
case ruletypes.DSLKeyUpdatedAt:
return b.TimestampComparison(ctx, operation, "rule.updated_at")
case ruletypes.DSLKeyAlertType:
return r.enumComparison(b, ctx, operation, key, alertTypePath, alertTypeValues)
case ruletypes.DSLKeyRuleType:
return r.enumComparison(b, ctx, operation, key, ruleTypePath, ruleTypeValues)
}
b.AddError("no handler for reserved key %q", key)
return ""
}
// A missing label evaluates as the empty string for every value operator; EXISTS/NOT EXISTS test the raw extraction.
func (ruleFieldResolver) labelComparison(b *sqlcompiler.Builder, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, labelKey string) string {
columnExpression := string(b.Formatter().JSONExtractMapValue(ruleDataColumn, ruleLabelsField, labelKey))
switch operation {
case qbtypesv5.FilterOperatorExists:
return fmt.Sprintf("%s IS NOT NULL", columnExpression)
case qbtypesv5.FilterOperatorNotExists:
return fmt.Sprintf("%s IS NULL", columnExpression)
}
keyForError := ruletypes.DSLLabelsKeyPrefix + labelKey
columnExpression = fmt.Sprintf("COALESCE(%s, '')", columnExpression)
return b.StringOperation(b.SelectBuilder(), ctx, operation, columnExpression, keyForError)
}
func (ruleFieldResolver) enumComparison(b *sqlcompiler.Builder, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key ruletypes.DSLKey, jsonPath string, allowedValues []string) string {
columnExpression := string(b.Formatter().JSONExtractString(ruleDataColumn, jsonPath))
var values []string
switch operation {
case qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual:
value, ok := b.ExtractSingleStringValue(ctx, string(key))
if !ok {
return ""
}
values = []string{value}
case qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn:
list, ok := b.ExtractStringValueList(ctx, string(key))
if !ok {
return ""
}
values = list
default:
b.AddError("operator %s on %q is not implemented", sqlcompiler.OperationName(operation), key)
return ""
}
for _, value := range values {
if !slices.Contains(allowedValues, value) {
b.AddError("invalid value %q for %q, expected one of: %s", value, key, strings.Join(allowedValues, ", "))
return ""
}
}
arguments := make([]any, len(values))
for i, s := range values {
arguments[i] = s
}
switch operation {
case qbtypesv5.FilterOperatorEqual:
return b.SelectBuilder().Equal(columnExpression, arguments[0])
case qbtypesv5.FilterOperatorNotEqual:
return b.SelectBuilder().NotEqual(columnExpression, arguments[0])
case qbtypesv5.FilterOperatorNotIn:
return b.SelectBuilder().NotIn(columnExpression, arguments...)
default:
return b.SelectBuilder().In(columnExpression, arguments...)
}
}
// FreeText searches name, description and the raw labels JSON (which also matches label keys).
func (ruleFieldResolver) FreeText(b *sqlcompiler.Builder, value string) string {
nameColumn := string(b.Formatter().JSONExtractString(ruleDataColumn, nameJSONPath))
descriptionColumn := string(b.Formatter().JSONExtractString(ruleDataColumn, descriptionPath))
labelsColumn := string(b.Formatter().JSONExtractString(ruleDataColumn, labelsJSONPath))
return b.SelectBuilder().Or(
b.FreeTextContains(b.SelectBuilder(), nameColumn, value),
b.FreeTextContains(b.SelectBuilder(), descriptionColumn, value),
b.FreeTextContains(b.SelectBuilder(), labelsColumn, value),
)
}
var alertTypeValues = func() []string {
values := make([]string, 0, 4)
for _, value := range (ruletypes.AlertType("")).Enum() {
values = append(values, string(value.(ruletypes.AlertType)))
}
return values
}()
var ruleTypeValues = func() []string {
values := make([]string, 0, 3)
for _, value := range (ruletypes.RuleType{}).Enum() {
values = append(values, value.(ruletypes.RuleType).StringValue())
}
return values
}()

View File

@@ -1,407 +0,0 @@
package sqlrulestore
import (
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
)
type compileCase struct {
subtestName string
dslQueryToCompile string
emptyQueryExpected bool
expectedSQL string
expectedArgs []any
expectedErrShouldContain string
}
func runCompileCases(t *testing.T, cases []compileCase) {
t.Helper()
for _, c := range cases {
t.Run(c.subtestName, func(t *testing.T) {
out, err := Compile(c.dslQueryToCompile, formatter(t))
if c.expectedErrShouldContain != "" {
require.Error(t, err)
assert.Contains(t, strings.ToLower(err.Error()), strings.ToLower(c.expectedErrShouldContain))
return
}
require.NoError(t, err)
if c.emptyQueryExpected {
assert.True(t, out.IsEmpty())
return
}
require.NotNil(t, out)
if c.expectedSQL != "" {
assert.Equal(t, normalizeSQL(c.expectedSQL), normalizeSQL(out.SQL))
}
if c.expectedArgs != nil {
require.Len(t, out.Args, len(c.expectedArgs))
for i, want := range c.expectedArgs {
// Equal instants can differ in *Location, so compare via .Equal() instead of DeepEqual.
if wantT, ok := want.(time.Time); ok {
gotT, ok := out.Args[i].(time.Time)
require.True(t, ok, "arg[%d]: want time.Time, got %T", i, out.Args[i])
assert.True(t, wantT.Equal(gotT), "arg[%d]: want %s, got %s", i, wantT, gotT)
continue
}
assert.Equal(t, want, out.Args[i], "arg[%d]", i)
}
}
})
}
}
func TestCompileEmpty(t *testing.T) {
runCompileCases(t, []compileCase{
{subtestName: "empty query yields nil", dslQueryToCompile: "", emptyQueryExpected: true},
{subtestName: "whitespace query yields nil", dslQueryToCompile: " ", emptyQueryExpected: true},
})
}
func TestCompileName(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "name equals",
dslQueryToCompile: "name = 'payment latency'",
expectedSQL: `json_extract("rule"."data", '$.alert') = ?`,
expectedArgs: []any{"payment latency"},
},
{
subtestName: "name contains escapes wildcards",
dslQueryToCompile: "name CONTAINS '50%'",
expectedSQL: `json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\'`,
expectedArgs: []any{`%50\%%`},
},
{
subtestName: "name ilike",
dslQueryToCompile: "name ILIKE 'Prod%'",
expectedSQL: `lower(json_extract("rule"."data", '$.alert')) LIKE LOWER(?) ESCAPE '\'`,
expectedArgs: []any{"Prod%"},
},
{
subtestName: "name in list",
dslQueryToCompile: "name IN ['a', 'b']",
expectedSQL: `json_extract("rule"."data", '$.alert') IN (?, ?)`,
expectedArgs: []any{"a", "b"},
},
{
subtestName: "range operator rejected on name",
dslQueryToCompile: "name > 'x'",
expectedErrShouldContain: `operator > is not allowed for key "name"`,
},
{
subtestName: "regexp rejected on name",
dslQueryToCompile: "name REGEXP 'x.*'",
expectedErrShouldContain: `operator REGEXP is not allowed for key "name"`,
},
})
}
func TestCompileSeverityAndLabels(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "severity equals targets labels map",
dslQueryToCompile: "severity = 'critical'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') = ?`,
expectedArgs: []any{"critical"},
},
{
subtestName: "severity negation treats a missing label as empty string",
dslQueryToCompile: "severity != 'critical'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') <> ?`,
expectedArgs: []any{"critical"},
},
{
subtestName: "severity != empty string excludes rules without severity",
dslQueryToCompile: "severity != ''",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') <> ?`,
expectedArgs: []any{""},
},
{
subtestName: "severity exists through the alias",
dslQueryToCompile: "severity EXISTS",
expectedSQL: `json_extract("rule"."data", '$.labels."severity"') IS NOT NULL`,
},
{
subtestName: "severity not exists through the alias",
dslQueryToCompile: "severity NOT EXISTS",
expectedSQL: `json_extract("rule"."data", '$.labels."severity"') IS NULL`,
},
{
subtestName: "label equals",
dslQueryToCompile: "labels.team = 'infra'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ?`,
expectedArgs: []any{"infra"},
},
{
subtestName: "dotted label key is one map entry",
dslQueryToCompile: "labels.k8s.cluster = 'prod-1'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."k8s.cluster"'), '') = ?`,
expectedArgs: []any{"prod-1"},
},
{
subtestName: "label key keeps its case",
dslQueryToCompile: "labels.Team = 'infra'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."Team"'), '') = ?`,
expectedArgs: []any{"infra"},
},
{
subtestName: "label exists",
dslQueryToCompile: "labels.team EXISTS",
expectedSQL: `json_extract("rule"."data", '$.labels."team"') IS NOT NULL`,
},
{
subtestName: "label not exists",
dslQueryToCompile: "labels.team NOT EXISTS",
expectedSQL: `json_extract("rule"."data", '$.labels."team"') IS NULL`,
},
{
subtestName: "label not contains includes label-less rules",
dslQueryToCompile: "labels.team NOT CONTAINS 'infra'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."team"'), '') NOT LIKE ? ESCAPE '\'`,
expectedArgs: []any{"%infra%"},
},
{
subtestName: "label not in includes label-less rules",
dslQueryToCompile: "labels.team NOT IN ['a', 'b']",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."team"'), '') NOT IN (?, ?)`,
expectedArgs: []any{"a", "b"},
},
})
}
func TestCompileEnums(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "alert_type equals",
dslQueryToCompile: "alert_type = 'LOGS_BASED_ALERT'",
expectedSQL: `json_extract("rule"."data", '$.alertType') = ?`,
expectedArgs: []any{"LOGS_BASED_ALERT"},
},
{
subtestName: "rule_type in list",
dslQueryToCompile: "rule_type IN ['threshold_rule', 'promql_rule']",
expectedSQL: `json_extract("rule"."data", '$.ruleType') IN (?, ?)`,
expectedArgs: []any{"threshold_rule", "promql_rule"},
},
{
subtestName: "invalid alert_type value rejected",
dslQueryToCompile: "alert_type = 'bogus'",
expectedErrShouldContain: `invalid value "bogus" for "alert_type"`,
},
{
subtestName: "contains rejected on rule_type",
dslQueryToCompile: "rule_type CONTAINS 'thresh'",
expectedErrShouldContain: `operator CONTAINS is not allowed for key "rule_type"`,
},
})
}
func TestCompileAuditColumns(t *testing.T) {
createdAt, err := time.Parse(time.RFC3339, "2026-01-02T15:04:05Z")
require.NoError(t, err)
updatedFrom, err := time.Parse(time.RFC3339, "2026-02-01T00:00:00Z")
require.NoError(t, err)
updatedTo, err := time.Parse(time.RFC3339, "2026-03-01T00:00:00Z")
require.NoError(t, err)
runCompileCases(t, []compileCase{
{
subtestName: "created_by equals",
dslQueryToCompile: "created_by = 'nikhil@signoz.io'",
expectedSQL: `rule.created_by = ?`,
expectedArgs: []any{"nikhil@signoz.io"},
},
{
subtestName: "created_at range",
dslQueryToCompile: "created_at >= '2026-01-02T15:04:05Z'",
expectedSQL: `rule.created_at >= ?`,
expectedArgs: []any{createdAt},
},
{
subtestName: "updated_at between",
dslQueryToCompile: "updated_at BETWEEN '2026-02-01T00:00:00Z' AND '2026-03-01T00:00:00Z'",
expectedSQL: `rule.updated_at BETWEEN ? AND ?`,
expectedArgs: []any{updatedFrom, updatedTo},
},
{
subtestName: "non-timestamp rejected on created_at",
dslQueryToCompile: "created_at >= 'yesterday'",
expectedErrShouldContain: "invalid RFC3339 timestamp",
},
})
}
func TestCompileFreeText(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "bare word searches name, description and labels",
dslQueryToCompile: "payment",
expectedSQL: `(lower(COALESCE(json_extract("rule"."data", '$.alert'), '')) LIKE LOWER(?) ESCAPE '\' ` +
`OR lower(COALESCE(json_extract("rule"."data", '$.description'), '')) LIKE LOWER(?) ESCAPE '\' ` +
`OR lower(COALESCE(json_extract("rule"."data", '$.labels'), '')) LIKE LOWER(?) ESCAPE '\')`,
expectedArgs: []any{"%payment%", "%payment%", "%payment%"},
},
})
}
func TestCompileComposition(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "and of label and column",
dslQueryToCompile: "labels.team = 'infra' AND created_by = 'x'",
expectedSQL: `(COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ? AND rule.created_by = ?)`,
expectedArgs: []any{"infra", "x"},
},
{
subtestName: "not wraps the inner predicate",
dslQueryToCompile: "NOT (name = 'x')",
expectedSQL: `NOT (json_extract("rule"."data", '$.alert') = ?)`,
expectedArgs: []any{"x"},
},
{
subtestName: "or of name and severity",
dslQueryToCompile: "name CONTAINS 'pay' OR severity = 'critical'",
expectedSQL: `(json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\' OR COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') = ?)`,
expectedArgs: []any{"%pay%", "critical"},
},
})
}
func TestCompileComplexExamples(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "name CONTAINS + label = + severity IN + created_by !=",
dslQueryToCompile: `name CONTAINS 'latency' AND labels.team = 'payments' ` +
`AND severity IN ['critical', 'error'] AND created_by != 'ops@signoz.io'`,
expectedSQL: `(json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\' ` +
`AND COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ? ` +
`AND COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') IN (?, ?) ` +
`AND rule.created_by <> ?)`,
expectedArgs: []any{"%latency%", "payments", "critical", "error", "ops@signoz.io"},
},
{
subtestName: "nested OR / AND with parens",
dslQueryToCompile: `(labels.env IN ['prod', 'staging'] OR name LIKE '%prod%') ` +
`AND (severity = 'critical' OR labels.team EXISTS)`,
expectedSQL: `((COALESCE(json_extract("rule"."data", '$.labels."env"'), '') IN (?, ?) ` +
`OR json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\') ` +
`AND (COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') = ? ` +
`OR json_extract("rule"."data", '$.labels."team"') IS NOT NULL))`,
expectedArgs: []any{"prod", "staging", "%prod%", "critical"},
},
{
subtestName: "NOT over a group ANDed with an enum",
dslQueryToCompile: `NOT (labels.team = 'infra' OR name CONTAINS 'cpu') AND alert_type = 'METRIC_BASED_ALERT'`,
expectedSQL: `(NOT ((COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ? ` +
`OR json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\')) ` +
`AND json_extract("rule"."data", '$.alertType') = ?)`,
expectedArgs: []any{"infra", "%cpu%", "METRIC_BASED_ALERT"},
},
{
subtestName: "free text with three-level nesting and a timestamp",
dslQueryToCompile: `prod AND (name ILIKE '%pay%' ` +
`OR (labels.team != 'infra' AND updated_at > '2026-01-02T15:04:05Z'))`,
expectedSQL: `((lower(COALESCE(json_extract("rule"."data", '$.alert'), '')) LIKE LOWER(?) ESCAPE '\' ` +
`OR lower(COALESCE(json_extract("rule"."data", '$.description'), '')) LIKE LOWER(?) ESCAPE '\' ` +
`OR lower(COALESCE(json_extract("rule"."data", '$.labels'), '')) LIKE LOWER(?) ESCAPE '\') ` +
`AND (lower(json_extract("rule"."data", '$.alert')) LIKE LOWER(?) ESCAPE '\' ` +
`OR (COALESCE(json_extract("rule"."data", '$.labels."team"'), '') <> ? AND rule.updated_at > ?)))`,
expectedArgs: []any{"%prod%", "%prod%", "%prod%", "%pay%", "infra",
time.Date(2026, 1, 2, 15, 4, 5, 0, time.UTC)},
},
})
}
func TestCompileErrors(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "unknown key rejected instead of matching nothing",
dslQueryToCompile: "team = 'infra'",
expectedErrShouldContain: `unknown filter key "team"`,
},
{
subtestName: "state is not a DSL key",
dslQueryToCompile: "state = 'firing'",
expectedErrShouldContain: `unknown filter key "state"`,
},
{
subtestName: "syntax error surfaces position",
dslQueryToCompile: "created_by ==== (((",
expectedErrShouldContain: "syntax error",
},
{
subtestName: "like pattern with dangling escape rejected",
dslQueryToCompile: `name LIKE 'prod\\'`,
expectedErrShouldContain: "must not end with an unescaped backslash",
},
{
subtestName: "ilike pattern with dangling escape rejected",
dslQueryToCompile: `name ILIKE '%\\'`,
expectedErrShouldContain: "must not end with an unescaped backslash",
},
{
subtestName: "label like pattern with dangling escape rejected",
dslQueryToCompile: `labels.team NOT LIKE 'infra\\'`,
expectedErrShouldContain: "must not end with an unescaped backslash",
},
})
}
func TestCompileTrailingLiteralBackslash(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "escaped trailing backslash compiles",
dslQueryToCompile: `name LIKE '%\\\\'`,
expectedSQL: `json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\'`,
expectedArgs: []any{`%\\`},
},
})
}
// Guards that every ruletypes.ReservedOps key has a case in resolveReservedKey.
func TestCompileReservedKeysAllHandled(t *testing.T) {
sampleQueries := map[ruletypes.DSLKey]string{
ruletypes.DSLKeyName: "name = 'x'",
ruletypes.DSLKeySeverity: "severity = 'critical'",
ruletypes.DSLKeyCreatedBy: "created_by = 'x'",
ruletypes.DSLKeyUpdatedBy: "updated_by = 'x'",
ruletypes.DSLKeyCreatedAt: "created_at >= '2026-01-02T15:04:05Z'",
ruletypes.DSLKeyUpdatedAt: "updated_at >= '2026-01-02T15:04:05Z'",
ruletypes.DSLKeyAlertType: "alert_type = 'METRIC_BASED_ALERT'",
ruletypes.DSLKeyRuleType: "rule_type = 'threshold_rule'",
}
for key := range ruletypes.ReservedOps {
query, ok := sampleQueries[key]
require.True(t, ok, "no sample query for reserved key %q, add one", key)
out, err := Compile(query, formatter(t))
require.NoError(t, err, "reserved key %q failed to compile", key)
assert.False(t, out.IsEmpty(), "reserved key %q compiled to empty SQL", key)
}
}
func formatter(t *testing.T) sqlstore.SQLFormatter {
t.Helper()
p := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
return p.Formatter()
}
func normalizeSQL(s string) string {
s = strings.Join(strings.Fields(s), " ")
s = strings.ReplaceAll(s, "( ", "(")
s = strings.ReplaceAll(s, " )", ")")
return s
}

View File

@@ -3,7 +3,6 @@ package sqlrulestore
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"slices"
@@ -90,46 +89,6 @@ func (r *rule) DeleteRule(ctx context.Context, orgID valuer.UUID, id valuer.UUID
return nil
}
func (r *rule) GetStoredRulesMatching(ctx context.Context, orgID string, query string) ([]*ruletypes.StorableRule, error) {
compiled, err := Compile(query, r.sqlstore.Formatter())
if err != nil {
return nil, err
}
rules := make([]*ruletypes.StorableRule, 0)
q := r.sqlstore.
BunDB().
NewSelect().
Model(&rules).
Where("org_id = ?", orgID)
if !compiled.IsEmpty() {
q = q.Where(compiled.SQL, compiled.Args...)
}
if err := q.Scan(ctx); err != nil {
return nil, err
}
return rules, nil
}
func (r *rule) GetStoredRuleLabels(ctx context.Context, orgID string) ([]string, error) {
labelsExpression := string(r.sqlstore.Formatter().JSONExtractString("rule.data", "$.labels"))
labels := make([]string, 0)
err := r.sqlstore.
BunDB().
NewSelect().
Model((*ruletypes.StorableRule)(nil)).
ColumnExpr(fmt.Sprintf("COALESCE(%s, '')", labelsExpression)).
Where("org_id = ?", orgID).
Scan(ctx, &labels)
if err != nil {
return nil, err
}
return labels, nil
}
func (r *rule) GetStoredRules(ctx context.Context, orgID string) ([]*ruletypes.StorableRule, error) {
rules := make([]*ruletypes.StorableRule, 0)
err := r.sqlstore.

View File

@@ -1,93 +0,0 @@
package sqlrulestore
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func (r *rule) CreateRuleView(ctx context.Context, view *ruletypes.RuleView) error {
_, err := r.sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(view).
Exec(ctx)
if err != nil {
return r.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "rule view with id %s already exists", view.ID)
}
return nil
}
func (r *rule) GetRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*ruletypes.RuleView, error) {
view := new(ruletypes.RuleView)
err := r.sqlstore.
BunDB().
NewSelect().
Model(view).
Where("id = ?", id).
Where("org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, r.sqlstore.WrapNotFoundErrf(err, ruletypes.ErrCodeRuleViewNotFound, "rule view with id %s doesn't exist", id)
}
return view, nil
}
func (r *rule) ListRuleViews(ctx context.Context, orgID valuer.UUID) ([]*ruletypes.RuleView, error) {
views := make([]*ruletypes.RuleView, 0)
err := r.sqlstore.
BunDB().
NewSelect().
Model(&views).
Where("org_id = ?", orgID).
OrderExpr("updated_at DESC").
Scan(ctx)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't list rule views")
}
return views, nil
}
func (r *rule) UpdateRuleView(ctx context.Context, view *ruletypes.RuleView) error {
res, err := r.sqlstore.
BunDBCtx(ctx).
NewUpdate().
Model(view).
WherePK().
Where("org_id = ?", view.OrgID).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't update rule view")
}
rows, err := res.RowsAffected()
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't read rule view update result")
}
if rows == 0 {
return errors.Newf(errors.TypeNotFound, ruletypes.ErrCodeRuleViewNotFound, "rule view with id %s doesn't exist", view.ID)
}
return nil
}
func (r *rule) DeleteRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
res, err := r.sqlstore.
BunDBCtx(ctx).
NewDelete().
Model(new(ruletypes.RuleView)).
Where("id = ?", id).
Where("org_id = ?", orgID).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't delete rule view")
}
rows, err := res.RowsAffected()
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't read rule view delete result")
}
if rows == 0 {
return errors.Newf(errors.TypeNotFound, ruletypes.ErrCodeRuleViewNotFound, "rule view with id %s doesn't exist", id)
}
return nil
}

View File

@@ -43,29 +43,6 @@ func (handler *handler) ListRules(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, view)
}
func (handler *handler) ListRulesV3(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
params := new(ruletypes.ListRulesParams)
if err := binding.Query.BindQuery(req.URL.Query(), params); err != nil {
render.Error(rw, err)
return
}
if err := params.Validate(); err != nil {
render.Error(rw, err)
return
}
listableRules, err := handler.ruler.ListRules(ctx, params)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, listableRules)
}
func (handler *handler) GetRuleByID(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
@@ -345,122 +322,3 @@ func (handler *handler) DeleteDowntimeScheduleByID(rw http.ResponseWriter, req *
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) ListRuleViews(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
views, err := handler.ruler.ListRuleViews(ctx, orgID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, views)
}
func (handler *handler) CreateRuleView(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
var postable ruletypes.PostableRuleView
if err := binding.JSON.BindBody(req.Body, &postable); err != nil {
render.Error(rw, err)
return
}
view, err := handler.ruler.CreateRuleView(ctx, orgID, postable)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, view)
}
func (handler *handler) UpdateRuleView(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
var updatable ruletypes.UpdatableRuleView
if err := binding.JSON.BindBody(req.Body, &updatable); err != nil {
render.Error(rw, err)
return
}
view, err := handler.ruler.UpdateRuleView(ctx, orgID, id, updatable)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, view)
}
func (handler *handler) DeleteRuleView(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
if err := handler.ruler.DeleteRuleView(ctx, orgID, id); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}

View File

@@ -116,10 +116,6 @@ func (provider *provider) ListRuleStates(ctx context.Context) (*ruletypes.Gettab
return provider.manager.ListRuleStates(ctx)
}
func (provider *provider) ListRules(ctx context.Context, params *ruletypes.ListRulesParams) (*ruletypes.ListableRules, error) {
return provider.manager.ListRules(ctx, params)
}
func (provider *provider) GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error) {
return provider.manager.GetRule(ctx, id)
}
@@ -147,41 +143,3 @@ func (provider *provider) TestNotification(ctx context.Context, orgID valuer.UUI
func (provider *provider) MaintenanceStore() alertmanagertypes.MaintenanceStore {
return provider.manager.MaintenanceStore()
}
func (provider *provider) CreateRuleView(ctx context.Context, orgID valuer.UUID, postable ruletypes.PostableRuleView) (*ruletypes.RuleView, error) {
if err := postable.Validate(); err != nil {
return nil, err
}
view := postable.NewRuleView(orgID)
if err := provider.ruleStore.CreateRuleView(ctx, view); err != nil {
return nil, err
}
return view, nil
}
func (provider *provider) ListRuleViews(ctx context.Context, orgID valuer.UUID) (*ruletypes.ListableRuleViews, error) {
views, err := provider.ruleStore.ListRuleViews(ctx, orgID)
if err != nil {
return nil, err
}
return &ruletypes.ListableRuleViews{Views: views}, nil
}
func (provider *provider) UpdateRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatable ruletypes.UpdatableRuleView) (*ruletypes.RuleView, error) {
if err := updatable.Validate(); err != nil {
return nil, err
}
view, err := provider.ruleStore.GetRuleView(ctx, orgID, id)
if err != nil {
return nil, err
}
view.Update(updatable)
if err := provider.ruleStore.UpdateRuleView(ctx, view); err != nil {
return nil, err
}
return view, nil
}
func (provider *provider) DeleteRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
return provider.ruleStore.DeleteRuleView(ctx, orgID, id)
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/querier"
@@ -61,7 +62,10 @@ func TestNewHandlers(t *testing.T) {
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil)
spanMapperRegistry, err := implspanmapper.NewSystemGroupRegistry()
require.NoError(t, err)
spanMapperModule := implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), flagger, spanMapperRegistry, providerSettings)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil, spanMapperModule)
querierHandler := querier.NewHandler(providerSettings, nil, nil)
registryHandler := factory.NewHandler(nil)

View File

@@ -45,7 +45,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/session/implsession"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
"github.com/SigNoz/signoz/pkg/modules/tag"
@@ -124,9 +123,10 @@ func NewModules(
fl flagger.Flagger,
tagModule tag.Module,
metricReductionRule metricreductionrule.Module,
spanMapper spanmapper.Module,
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter, dashboard)
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter, dashboard, spanMapper)
// Cleanup callbacks from other modules, invoked when a user is deleted.
onDeleteUser := []user.OnDeleteUser{
dashboard.DeletePreferencesForUser,
@@ -162,7 +162,7 @@ func NewModules(
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
SpanMapper: spanMapper,
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
Tag: tagModule,
}

View File

@@ -21,6 +21,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/queryparser"
@@ -68,7 +69,11 @@ func TestNewModules(t *testing.T) {
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule())
spanMapperRegistry, err := implspanmapper.NewSystemGroupRegistry()
require.NoError(t, err)
spanMapperModule := implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), flagger, spanMapperRegistry, providerSettings)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule(), spanMapperModule)
reflectVal := reflect.ValueOf(modules)
for i := 0; i < reflectVal.NumField(); i++ {

View File

@@ -254,7 +254,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddIngestionTuplesFactory(sqlstore),
sqlmigration.NewAddSubscriptionTuplesFactory(sqlstore),
sqlmigration.NewNormalizeQuickFilterFieldsFactory(sqlstore),
sqlmigration.NewAddRuleViewFactory(sqlstore, sqlschema),
sqlmigration.NewAddSpanMapperOriginFactory(sqlstore, sqlschema),
)
}

View File

@@ -36,6 +36,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
@@ -549,7 +550,15 @@ func New(
metricReductionRuleModule := metricReductionRuleModuleCallback(sqlstore, telemetrystore, dashboard, queryParser, licensing, flagger, telemetryMetadataStore, providerSettings, config.MetricsExplorer.TelemetryStore.Threads)
// Initialize all modules
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule)
// The default mapping group registry is parsed here so a malformed embedded
// definition fails startup instead of a request.
spanMapperRegistry, err := implspanmapper.NewSystemGroupRegistry()
if err != nil {
return nil, err
}
spanMapperModule := implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), flagger, spanMapperRegistry, providerSettings)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule, spanMapperModule)
// Initialize ruler from the variant-specific provider factories
rulerInstance, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Ruler, rulerProviderFactories(cache, alertmanager, sqlstore, telemetrystore, telemetryMetadataStore, prometheus, orgGetter, modules.RuleStateHistory, querier, queryParser), "signoz")
@@ -619,6 +628,7 @@ func New(
factory.NewNamedService(factory.MustNewName("meterreporter"), meterReporter, factory.MustNewName("licensing")),
factory.NewNamedService(factory.MustNewName("ruler"), rulerInstance),
factory.NewNamedService(factory.MustNewName("systemdashboard"), impldashboard.NewService(providerSettings, dashboard, orgGetter)),
factory.NewNamedService(factory.MustNewName("spanmappergroup"), implspanmapper.NewService(providerSettings, spanMapperModule, orgGetter)),
)
if err != nil {
return nil, err

View File

@@ -1,72 +0,0 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addRuleView struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddRuleViewFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_rule_view"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addRuleView{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *addRuleView) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addRuleView) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
sqls := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "rule_view",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "data", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"id"}},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("org_id"),
ReferencedTableName: sqlschema.TableName("organizations"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
// Neither Postgres nor SQLite indexes an FK's referencing column; every read is org-scoped.
sqls = append(sqls, []byte(`CREATE INDEX IF NOT EXISTS ix_rule_view_org_id ON rule_view (org_id)`))
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addRuleView) Down(_ context.Context, _ *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,93 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addSpanMapperOrigin struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddSpanMapperOriginFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("add_span_mapper_origin"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addSpanMapperOrigin{sqlstore: sqlstore, sqlschema: sqlschema}, nil
},
)
}
func (migration *addSpanMapperOrigin) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
// Up adds the ownership columns that let SigNoz ship default mapping groups
// alongside user ones.
func (migration *addSpanMapperOrigin) Up(ctx context.Context, db *bun.DB) error {
// span_mapper references span_mapper_group and both have foreign keys, so
// enforcement must be off for the SQLite recreate-table fallback.
if err := migration.sqlschema.ToggleFKEnforcement(ctx, db, false); err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
groupTable, groupUniqueConstraints, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("span_mapper_group"))
if err != nil {
return err
}
sqls := migration.sqlschema.Operator().AddColumn(groupTable, groupUniqueConstraints, &sqlschema.Column{
Name: sqlschema.ColumnName("origin"),
DataType: sqlschema.DataTypeText,
Nullable: false,
Default: "'user'",
}, "user")
sqls = append(sqls, migration.sqlschema.Operator().AddColumn(groupTable, groupUniqueConstraints, &sqlschema.Column{
Name: sqlschema.ColumnName("version"),
DataType: sqlschema.DataTypeBigInt,
Nullable: false,
Default: "0",
}, 0)...)
mapperTable, mapperUniqueConstraints, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("span_mapper"))
if err != nil {
return err
}
sqls = append(sqls, migration.sqlschema.Operator().AddColumn(mapperTable, mapperUniqueConstraints, &sqlschema.Column{
Name: sqlschema.ColumnName("origin"),
DataType: sqlschema.DataTypeText,
Nullable: false,
Default: "'user'",
}, "user")...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return migration.sqlschema.ToggleFKEnforcement(ctx, db, true)
}
func (migration *addSpanMapperOrigin) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -1,7 +1,6 @@
package sqlitesqlstore
import (
"fmt"
"strings"
"github.com/SigNoz/signoz/pkg/sqlstore"
@@ -26,12 +25,6 @@ func (f *formatter) JSONExtractString(column, path string) []byte {
return sql
}
func (f *formatter) JSONExtractMapValue(column, mapField, key string) []byte {
// Quote the key as one path segment; a double quote in it is inexpressible in sqlite JSON paths.
escapedKey := strings.NewReplacer(`\`, `\\`).Replace(key)
return f.JSONExtractString(column, fmt.Sprintf(`$.%s."%s"`, mapField, escapedKey))
}
func (f *formatter) JSONType(column, path string) []byte {
var sql []byte
sql = append(sql, "json_type("...)

View File

@@ -114,9 +114,6 @@ type SQLFormatter interface {
// JSONKeys return extracted key from json as well as alias to be used for select and where clause
JSONKeys(column, path, alias string) ([]byte, []byte)
// JSONExtractMapValue extracts one key's value from a JSON object field; dots in the key are not path nesting.
JSONExtractMapValue(column, mapField, key string) []byte
// TextToJsonColumn converts a text column to JSON type
TextToJsonColumn(column string) []byte

View File

@@ -1,7 +1,6 @@
package sqlstoretest
import (
"fmt"
"strings"
"github.com/SigNoz/signoz/pkg/sqlstore"
@@ -26,11 +25,6 @@ func (f *formatter) JSONExtractString(column, path string) []byte {
return sql
}
func (f *formatter) JSONExtractMapValue(column, mapField, key string) []byte {
escapedKey := strings.NewReplacer(`\`, `\\`).Replace(key)
return f.JSONExtractString(column, fmt.Sprintf(`$.%s."%s"`, mapField, escapedKey))
}
func (f *formatter) JSONType(column, path string) []byte {
var sql []byte
sql = append(sql, "json_type("...)

View File

@@ -1,129 +0,0 @@
package ruletypes
import (
"slices"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
DefaultListLimit = 20
MaxListLimit = 5000
MaxListQueryLen = 1024
)
var ErrCodeRuleListInvalid = errors.MustNewCode("rule_list_invalid")
type ListSort struct{ valuer.String }
var (
ListSortUpdatedAt = ListSort{valuer.NewString("updated_at")}
ListSortCreatedAt = ListSort{valuer.NewString("created_at")}
ListSortName = ListSort{valuer.NewString("name")}
ListSortState = ListSort{valuer.NewString("state")}
ListSortSeverity = ListSort{valuer.NewString("severity")}
)
func (ListSort) Enum() []any {
return []any{ListSortUpdatedAt, ListSortCreatedAt, ListSortName, ListSortState, ListSortSeverity}
}
func (s ListSort) IsValid() bool {
return slices.ContainsFunc(s.Enum(), func(v any) bool { return v == s })
}
type ListOrder struct{ valuer.String }
var (
ListOrderAsc = ListOrder{valuer.NewString("asc")}
ListOrderDesc = ListOrder{valuer.NewString("desc")}
)
func (ListOrder) Enum() []any {
return []any{ListOrderAsc, ListOrderDesc}
}
func (o ListOrder) IsValid() bool {
return slices.ContainsFunc(o.Enum(), func(v any) bool { return v == o })
}
type ListRulesParams struct {
Query string `query:"query"`
// gin cannot bind a slice of valuer enums; AlertStates converts these.
States []string `query:"states"`
Sort ListSort `query:"sort"`
Order ListOrder `query:"order"`
Limit int `query:"limit"`
Offset int `query:"offset"`
}
// Validate normalizes in place; an over-max limit is clamped, not rejected.
func (p *ListRulesParams) Validate() error {
if n := utf8.RuneCountInString(p.Query); n > MaxListQueryLen {
return errors.NewInvalidInputf(ErrCodeRuleListInvalid,
"query cannot be longer than %d characters, got %d", MaxListQueryLen, n)
}
if p.Sort.IsZero() {
p.Sort = ListSortUpdatedAt
} else if !p.Sort.IsValid() {
return errors.NewInvalidInputf(ErrCodeRuleListInvalid,
"invalid sort %q, expected one of: `updated_at`, `created_at`, `name`, `state`, `severity`", p.Sort)
}
if p.Order.IsZero() {
p.Order = ListOrderDesc
} else if !p.Order.IsValid() {
return errors.NewInvalidInputf(ErrCodeRuleListInvalid,
"invalid order %q, expected `asc` or `desc`", p.Order)
}
if p.Limit == 0 {
p.Limit = DefaultListLimit
} else if p.Limit < 0 {
return errors.NewInvalidInputf(ErrCodeRuleListInvalid,
"invalid limit %d, must be a positive integer", p.Limit)
} else if p.Limit > MaxListLimit {
p.Limit = MaxListLimit
}
if p.Offset < 0 {
return errors.NewInvalidInputf(ErrCodeRuleListInvalid,
"invalid offset %d, must be a non-negative integer", p.Offset)
}
if _, err := p.AlertStates(); err != nil {
return err
}
return nil
}
// AlertStates parses States; empty means no state filtering.
func (p *ListRulesParams) AlertStates() ([]AlertState, error) {
if len(p.States) == 0 {
return nil, nil
}
states := make([]AlertState, 0, len(p.States))
for _, raw := range p.States {
state, err := parseAlertState(raw)
if err != nil {
return nil, err
}
states = append(states, state)
}
return states, nil
}
func parseAlertState(raw string) (AlertState, error) {
state := AlertState{valuer.NewString(raw)}
if !slices.Contains(state.Enum(), any(state)) {
return AlertState{}, errors.NewInvalidInputf(ErrCodeRuleListInvalid,
"invalid state %q, expected one of: `firing`, `pending`, `recovering`, `inactive`, `nodata`, `disabled`", raw)
}
return state, nil
}

View File

@@ -1,100 +0,0 @@
package ruletypes
import (
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
var ErrCodeRuleListFilterInvalid = errors.MustNewCode("rule_list_filter_invalid")
// DSLKey is a reserved (column-level) key in the rule list filter DSL.
type DSLKey string
const (
DSLKeyName DSLKey = "name"
DSLKeySeverity DSLKey = "severity"
DSLKeyCreatedBy DSLKey = "created_by"
DSLKeyUpdatedBy DSLKey = "updated_by"
DSLKeyCreatedAt DSLKey = "created_at"
DSLKeyUpdatedAt DSLKey = "updated_at"
DSLKeyAlertType DSLKey = "alert_type"
DSLKeyRuleType DSLKey = "rule_type"
// Label keys under this prefix are matched exactly (case-sensitive).
DSLLabelsKeyPrefix = "labels."
// Advertised in reservedKeywords; not itself a filterable key.
DSLKeyLabelsPlaceholder DSLKey = "labels.<key>"
)
func ReservedFilterKeys() []DSLKey {
keys := make([]DSLKey, 0, len(ReservedOps)+1)
for key := range ReservedOps {
keys = append(keys, key)
}
keys = append(keys, DSLKeyLabelsPlaceholder)
slices.SortFunc(keys, func(a, b DSLKey) int {
return strings.Compare(string(a), string(b))
})
return keys
}
// ReservedOps lists the operators each reserved DSL key accepts; `labels.<key>` terms use LabelsKeyOps.
var ReservedOps = map[DSLKey]map[qbtypesv5.FilterOperator]struct{}{
DSLKeyName: stringSearchOps(),
// severity aliases labels.severity, so it takes the labels operator set.
DSLKeySeverity: LabelsKeyOps,
DSLKeyCreatedBy: stringSearchOps(),
DSLKeyUpdatedBy: stringSearchOps(),
DSLKeyCreatedAt: numericRangeOps(),
DSLKeyUpdatedAt: numericRangeOps(),
DSLKeyAlertType: enumOps(),
DSLKeyRuleType: enumOps(),
}
// LabelsKeyOps operators target the label's value; EXISTS/NOT EXISTS test its presence.
var LabelsKeyOps = opsSet(
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike,
qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike,
qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains,
qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn,
qbtypesv5.FilterOperatorExists, qbtypesv5.FilterOperatorNotExists,
)
func stringSearchOps() map[qbtypesv5.FilterOperator]struct{} {
return opsSet(
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike,
qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike,
qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains,
qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn,
)
}
func numericRangeOps() map[qbtypesv5.FilterOperator]struct{} {
return opsSet(
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLessThan, qbtypesv5.FilterOperatorLessThanOrEq,
qbtypesv5.FilterOperatorGreaterThan, qbtypesv5.FilterOperatorGreaterThanOrEq,
qbtypesv5.FilterOperatorBetween, qbtypesv5.FilterOperatorNotBetween,
)
}
func enumOps() map[qbtypesv5.FilterOperator]struct{} {
return opsSet(
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn,
)
}
func opsSet(ops ...qbtypesv5.FilterOperator) map[qbtypesv5.FilterOperator]struct{} {
m := make(map[qbtypesv5.FilterOperator]struct{}, len(ops))
for _, op := range ops {
m[op] = struct{}{}
}
return m
}

View File

@@ -1,34 +0,0 @@
package ruletypes
import (
"testing"
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/assert"
)
func TestReservedFilterKeys(t *testing.T) {
assert.Equal(t, []DSLKey{
DSLKeyAlertType,
DSLKeyCreatedAt,
DSLKeyCreatedBy,
DSLKeyLabelsPlaceholder,
DSLKeyName,
DSLKeyRuleType,
DSLKeySeverity,
DSLKeyUpdatedAt,
DSLKeyUpdatedBy,
}, ReservedFilterKeys())
}
func TestFilterOpsExcludeRegexp(t *testing.T) {
for key, ops := range ReservedOps {
assert.NotEmpty(t, ops, "key %q has no operators", key)
assert.NotContains(t, ops, qbtypesv5.FilterOperatorRegexp, "key %q allows REGEXP", key)
assert.NotContains(t, ops, qbtypesv5.FilterOperatorNotRegexp, "key %q allows NOT REGEXP", key)
}
assert.NotContains(t, LabelsKeyOps, qbtypesv5.FilterOperatorRegexp)
assert.NotContains(t, LabelsKeyOps, qbtypesv5.FilterOperatorNotRegexp)
assert.Contains(t, LabelsKeyOps, qbtypesv5.FilterOperatorExists)
assert.Contains(t, LabelsKeyOps, qbtypesv5.FilterOperatorNotExists)
}

View File

@@ -1,126 +0,0 @@
package ruletypes
import (
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestListRulesParamsValidate(t *testing.T) {
testCases := []struct {
name string
params ListRulesParams
wantErr string
wantSort ListSort
wantOrder ListOrder
wantLimit int
}{
{
name: "empty params get defaults",
params: ListRulesParams{},
wantSort: ListSortUpdatedAt,
wantOrder: ListOrderDesc,
wantLimit: DefaultListLimit,
},
{
name: "explicit values kept",
params: ListRulesParams{Sort: ListSortSeverity, Order: ListOrderAsc, Limit: 50, Offset: 100},
wantSort: ListSortSeverity,
wantOrder: ListOrderAsc,
wantLimit: 50,
},
{
name: "over-max limit clamped",
params: ListRulesParams{Limit: MaxListLimit + 1},
wantSort: ListSortUpdatedAt,
wantOrder: ListOrderDesc,
wantLimit: MaxListLimit,
},
{
name: "invalid state rejected",
params: ListRulesParams{States: []string{"bogus"}},
wantErr: `invalid state "bogus"`,
},
{
name: "invalid sort rejected",
params: ListRulesParams{Sort: ListSort{valuer.NewString("bogus")}},
wantErr: "invalid sort",
},
{
name: "invalid order rejected",
params: ListRulesParams{Order: ListOrder{valuer.NewString("bogus")}},
wantErr: "invalid order",
},
{
name: "negative limit rejected",
params: ListRulesParams{Limit: -1},
wantErr: "invalid limit",
},
{
name: "negative offset rejected",
params: ListRulesParams{Offset: -1},
wantErr: "invalid offset",
},
{
name: "over-long query rejected",
params: ListRulesParams{Query: strings.Repeat("a", MaxListQueryLen+1)},
wantErr: "query cannot be longer",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := tc.params.Validate()
if tc.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tc.wantSort, tc.params.Sort)
assert.Equal(t, tc.wantOrder, tc.params.Order)
assert.Equal(t, tc.wantLimit, tc.params.Limit)
})
}
}
func TestListRulesParamsAlertStates(t *testing.T) {
testCases := []struct {
name string
states []string
wantErr string
wantStates []AlertState
}{
{
name: "valid states parsed to typed values",
states: []string{"firing", "pending"},
wantStates: []AlertState{StateFiring, StatePending},
},
{
name: "absent states mean no filtering",
states: nil,
},
{
name: "invalid state rejected",
states: []string{"bogus"},
wantErr: `invalid state "bogus"`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
params := ListRulesParams{States: tc.states}
states, err := params.AlertStates()
if tc.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tc.wantStates, states)
})
}
}

View File

@@ -1,162 +0,0 @@
package ruletypes
import (
"cmp"
"encoding/json"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/types"
)
const MaxListLabelPairs = 1000
// ListableRule is the slim per-row shape of the list endpoint; the full rule stays behind get-by-id.
type ListableRule struct {
Id string `json:"id" required:"true"`
State AlertState `json:"state" required:"true"`
AlertName string `json:"alert" required:"true"`
Description string `json:"description,omitempty"`
AlertType AlertType `json:"alertType" required:"true"`
RuleType RuleType `json:"ruleType" required:"true"`
Disabled bool `json:"disabled"`
Labels map[string]string `json:"labels,omitempty"`
types.TimeAuditable
types.UserAuditable
}
func NewListableRule(rule *GettableRule) *ListableRule {
listable := &ListableRule{
Id: rule.Id,
State: rule.State,
AlertName: rule.AlertName,
Description: rule.Description,
AlertType: rule.AlertType,
RuleType: rule.RuleType,
Disabled: rule.Disabled,
Labels: rule.Labels,
TimeAuditable: types.TimeAuditable{
CreatedAt: rule.CreatedAt,
UpdatedAt: rule.UpdatedAt,
},
}
if rule.CreatedBy != nil {
listable.CreatedBy = *rule.CreatedBy
}
if rule.UpdatedBy != nil {
listable.UpdatedBy = *rule.UpdatedBy
}
return listable
}
// LabelPair is one distinct label key/value observed on the org's rules.
type LabelPair struct {
Key string `json:"key" required:"true"`
Value string `json:"value" required:"true"`
}
type ListableRules struct {
Rules []*ListableRule `json:"rules" required:"true" nullable:"false"`
Total int64 `json:"total" required:"true"`
Labels []LabelPair `json:"labels" required:"true" nullable:"false"`
ReservedKeywords []DSLKey `json:"reservedKeywords" required:"true" nullable:"false"`
}
func NewListableRules(rules []*ListableRule, total int64, labels []LabelPair) *ListableRules {
return &ListableRules{
Rules: rules,
Total: total,
Labels: labels,
ReservedKeywords: ReservedFilterKeys(),
}
}
// Display priority, worst first; NOT AlertState.Severity(), which ranks disabled/nodata above firing.
var stateDisplayRank = map[AlertState]int{
StateFiring: 5,
StatePending: 4,
StateRecovering: 3,
StateNoData: 2,
StateInactive: 1,
StateDisabled: 0,
}
var severityDisplayRank = map[string]int{
"critical": 4,
"error": 3,
"warning": 2,
"info": 1,
}
// Ties break on name then id ascending (order applies to the primary key only) so pages stay stable.
func SortListableRules(rules []*ListableRule, sortBy ListSort, order ListOrder) {
direction := 1
if order == ListOrderDesc {
direction = -1
}
slices.SortStableFunc(rules, func(a, b *ListableRule) int {
if c := direction * compareListableRules(a, b, sortBy); c != 0 {
return c
}
if c := strings.Compare(strings.ToLower(a.AlertName), strings.ToLower(b.AlertName)); c != 0 {
return c
}
return strings.Compare(a.Id, b.Id)
})
}
func compareListableRules(a, b *ListableRule, sortBy ListSort) int {
switch sortBy {
case ListSortName:
return strings.Compare(strings.ToLower(a.AlertName), strings.ToLower(b.AlertName))
case ListSortCreatedAt:
return a.CreatedAt.Compare(b.CreatedAt)
case ListSortState:
return cmp.Compare(stateDisplayRank[a.State], stateDisplayRank[b.State])
case ListSortSeverity:
severityA := a.Labels["severity"]
severityB := b.Labels["severity"]
rankA := severityDisplayRank[strings.ToLower(severityA)]
rankB := severityDisplayRank[strings.ToLower(severityB)]
if rankA != rankB {
return cmp.Compare(rankA, rankB)
}
if rankA == 0 {
return strings.Compare(strings.ToLower(severityA), strings.ToLower(severityB))
}
return 0
}
return a.UpdatedAt.Compare(b.UpdatedAt)
}
// NewLabelPairsFromRawJSON skips blank or malformed entries and caps the result at limit.
func NewLabelPairsFromRawJSON(raws []string, limit int) []LabelPair {
set := make(map[LabelPair]struct{})
for _, raw := range raws {
if raw == "" || raw == "null" {
continue
}
labels := make(map[string]string)
if err := json.Unmarshal([]byte(raw), &labels); err != nil {
continue
}
for key, value := range labels {
set[LabelPair{Key: key, Value: value}] = struct{}{}
}
}
pairs := make([]LabelPair, 0, len(set))
for pair := range set {
pairs = append(pairs, pair)
}
slices.SortFunc(pairs, func(a, b LabelPair) int {
if c := strings.Compare(a.Key, b.Key); c != 0 {
return c
}
return strings.Compare(a.Value, b.Value)
})
if len(pairs) > limit {
pairs = pairs[:limit]
}
return pairs
}

View File

@@ -1,163 +0,0 @@
package ruletypes
import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/types"
"github.com/stretchr/testify/assert"
)
func listableRule(name string, state AlertState, severity string, updatedAt time.Time) *ListableRule {
rule := &ListableRule{
AlertName: name,
State: state,
TimeAuditable: types.TimeAuditable{
UpdatedAt: updatedAt,
},
}
if severity != "" {
rule.Labels = map[string]string{"severity": severity}
}
return rule
}
func names(rules []*ListableRule) []string {
out := make([]string, 0, len(rules))
for _, rule := range rules {
out = append(out, rule.AlertName)
}
return out
}
func TestSortListableRules(t *testing.T) {
base := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
testCases := []struct {
name string
rules []*ListableRule
sortBy ListSort
order ListOrder
wantNames []string
}{
{
name: "state desc is display priority firing first",
rules: []*ListableRule{
listableRule("disabled", StateDisabled, "", base),
listableRule("nodata", StateNoData, "", base),
listableRule("firing", StateFiring, "", base),
listableRule("inactive", StateInactive, "", base),
listableRule("pending", StatePending, "", base),
listableRule("recovering", StateRecovering, "", base),
},
sortBy: ListSortState,
order: ListOrderDesc,
wantNames: []string{"firing", "pending", "recovering", "nodata", "inactive", "disabled"},
},
{
name: "severity desc ranks known values then custom ones lexically",
rules: []*ListableRule{
listableRule("warn", StateInactive, "warning", base),
listableRule("custom-b", StateInactive, "bbb", base),
listableRule("crit", StateInactive, "critical", base),
listableRule("custom-a", StateInactive, "aaa", base),
listableRule("none", StateInactive, "", base),
},
sortBy: ListSortSeverity,
order: ListOrderDesc,
// desc flips the lexical compare between custom values too
wantNames: []string{"crit", "warn", "custom-b", "custom-a", "none"},
},
{
name: "name asc is case-insensitive",
rules: []*ListableRule{
listableRule("banana", StateInactive, "", base),
listableRule("Apple", StateInactive, "", base),
listableRule("cherry", StateInactive, "", base),
},
sortBy: ListSortName,
order: ListOrderAsc,
wantNames: []string{"Apple", "banana", "cherry"},
},
{
name: "updated_at desc puts newest first",
rules: []*ListableRule{
listableRule("old", StateInactive, "", base),
listableRule("new", StateInactive, "", base.Add(time.Hour)),
},
sortBy: ListSortUpdatedAt,
order: ListOrderDesc,
wantNames: []string{"new", "old"},
},
{
name: "state desc ties break on name asc",
rules: []*ListableRule{
listableRule("banana", StateFiring, "", base),
listableRule("zebra", StateDisabled, "", base),
listableRule("Apple", StateFiring, "", base),
listableRule("cherry", StateFiring, "", base),
},
sortBy: ListSortState,
order: ListOrderDesc,
wantNames: []string{"Apple", "banana", "cherry", "zebra"},
},
{
name: "state asc flips buckets but tiebreak stays name asc",
rules: []*ListableRule{
listableRule("banana", StateFiring, "", base),
listableRule("zebra", StateDisabled, "", base),
listableRule("Apple", StateFiring, "", base),
listableRule("cherry", StateFiring, "", base),
},
sortBy: ListSortState,
order: ListOrderAsc,
wantNames: []string{"zebra", "Apple", "banana", "cherry"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
SortListableRules(tc.rules, tc.sortBy, tc.order)
assert.Equal(t, tc.wantNames, names(tc.rules))
})
}
}
func TestSortListableRulesIdTiebreak(t *testing.T) {
base := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
for _, order := range []ListOrder{ListOrderAsc, ListOrderDesc} {
t.Run(order.StringValue(), func(t *testing.T) {
older := listableRule("dup", StateFiring, "", base)
older.Id = "01aaa"
newer := listableRule("dup", StateFiring, "", base)
newer.Id = "01bbb"
rules := []*ListableRule{newer, older}
SortListableRules(rules, ListSortState, order)
assert.Equal(t, []string{"01aaa", "01bbb"}, []string{rules[0].Id, rules[1].Id})
})
}
}
func TestNewLabelPairsFromRawJSON(t *testing.T) {
pairs := NewLabelPairsFromRawJSON([]string{
`{"team":"infra","severity":"critical"}`,
`{"team":"infra"}`,
`{"team":"payments"}`,
"",
"null",
"not-json",
}, MaxListLabelPairs)
assert.Equal(t, []LabelPair{
{Key: "severity", Value: "critical"},
{Key: "team", Value: "infra"},
{Key: "team", Value: "payments"},
}, pairs)
}
func TestNewLabelPairsFromRawJSONCap(t *testing.T) {
pairs := NewLabelPairsFromRawJSON([]string{`{"a":"1","b":"2","c":"3"}`}, 2)
assert.Len(t, pairs, 2)
}

View File

@@ -11,8 +11,7 @@ import (
)
type StorableRule struct {
// The alias must stay rule: the list filter compiler emits rule.<col> refs.
bun.BaseModel `bun:"table:rule,alias:rule"`
bun.BaseModel `bun:"table:rule"`
types.Identifiable
types.TimeAuditable
types.UserAuditable
@@ -59,16 +58,6 @@ type RuleStore interface {
EditRule(context.Context, *StorableRule, func(context.Context) error) error
DeleteRule(context.Context, valuer.UUID, valuer.UUID, func(context.Context) error) error
GetStoredRules(context.Context, string) ([]*StorableRule, error)
// GetStoredRulesMatching returns the org's rules matching a list filter query; an empty query matches all.
GetStoredRulesMatching(context.Context, string, string) ([]*StorableRule, error)
// GetStoredRuleLabels returns each rule's labels as raw JSON text, empty string when absent.
GetStoredRuleLabels(context.Context, string) ([]string, error)
GetStoredRule(context.Context, valuer.UUID, valuer.UUID) (*StorableRule, error)
GetStoredRulesByMetricName(context.Context, string, string) ([]RuleAlert, error)
CreateRuleView(context.Context, *RuleView) error
GetRuleView(context.Context, valuer.UUID, valuer.UUID) (*RuleView, error)
ListRuleViews(context.Context, valuer.UUID) ([]*RuleView, error)
UpdateRuleView(context.Context, *RuleView) error
DeleteRuleView(context.Context, valuer.UUID, valuer.UUID) error
}

View File

@@ -1,140 +0,0 @@
package ruletypes
import (
"bytes"
"encoding/json"
"strings"
"time"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
const (
RuleViewSchemaVersion = "v1"
MaxRuleViewNameLen = 64
)
var (
ErrCodeRuleViewInvalidInput = errors.MustNewCode("rule_view_invalid_input")
ErrCodeRuleViewNotFound = errors.MustNewCode("rule_view_not_found")
)
type RuleView struct {
bun.BaseModel `bun:"table:rule_view,alias:rule_view"`
types.Identifiable
types.TimeAuditable
Name string `bun:"name,type:text,notnull" json:"name" required:"true"`
Data RuleViewData `bun:"data,type:text,notnull" json:"data" required:"true"`
OrgID valuer.UUID `bun:"org_id,type:text,notnull" json:"orgId" required:"true"`
}
// RuleViewData holds the rule listing state (ListRulesParams minus pagination) a view replays.
type RuleViewData struct {
Version string `json:"version" required:"true"`
Query string `json:"query"`
States []string `json:"states"`
Sort ListSort `json:"sort"`
Order ListOrder `json:"order"`
}
// Validate normalizes in place; zero sort/order get the list defaults.
func (d *RuleViewData) Validate() error {
if d.Version != RuleViewSchemaVersion {
return errors.NewInvalidInputf(ErrCodeRuleViewInvalidInput,
"version must be %q, got %q", RuleViewSchemaVersion, d.Version)
}
if n := utf8.RuneCountInString(d.Query); n > MaxListQueryLen {
return errors.NewInvalidInputf(ErrCodeRuleViewInvalidInput,
"query cannot be longer than %d characters, got %d", MaxListQueryLen, n)
}
for _, raw := range d.States {
if _, err := parseAlertState(raw); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeRuleViewInvalidInput, "invalid states")
}
}
if d.Sort.IsZero() {
d.Sort = ListSortUpdatedAt
} else if !d.Sort.IsValid() {
return errors.NewInvalidInputf(ErrCodeRuleViewInvalidInput,
"invalid sort %q, expected one of: `updated_at`, `created_at`, `name`, `state`, `severity`", d.Sort)
}
if d.Order.IsZero() {
d.Order = ListOrderDesc
} else if !d.Order.IsValid() {
return errors.NewInvalidInputf(ErrCodeRuleViewInvalidInput,
"invalid order %q, expected `asc` or `desc`", d.Order)
}
return nil
}
type PostableRuleView struct {
Name string `json:"name" required:"true"`
Data RuleViewData `json:"data" required:"true"`
}
func (p *PostableRuleView) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
type alias PostableRuleView
var tmp alias
if err := dec.Decode(&tmp); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeRuleViewInvalidInput, "invalid saved view request body").WithAdditional(err.Error())
}
*p = PostableRuleView(tmp)
return p.Validate()
}
func (p *PostableRuleView) Validate() error {
if err := validateRuleViewName(p.Name); err != nil {
return err
}
return p.Data.Validate()
}
func (p PostableRuleView) NewRuleView(orgID valuer.UUID) *RuleView {
now := time.Now()
return &RuleView{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
OrgID: orgID,
Name: p.Name,
Data: p.Data,
}
}
type UpdatableRuleView = PostableRuleView
func (v *RuleView) Update(updateable UpdatableRuleView) {
v.Name = updateable.Name
v.Data = updateable.Data
v.UpdatedAt = time.Now()
}
type ListableRuleViews struct {
Views []*RuleView `json:"views" required:"true" nullable:"false"`
}
func validateRuleViewName(name string) error {
if strings.TrimSpace(name) == "" {
return errors.NewInvalidInputf(ErrCodeRuleViewInvalidInput, "name is required")
}
if name != strings.TrimSpace(name) {
return errors.NewInvalidInputf(ErrCodeRuleViewInvalidInput, "name must not have leading or trailing whitespace")
}
if n := utf8.RuneCountInString(name); n > MaxRuleViewNameLen {
return errors.NewInvalidInputf(ErrCodeRuleViewInvalidInput,
"name must be at most %d characters, got %d", MaxRuleViewNameLen, n)
}
return nil
}

View File

@@ -1,183 +0,0 @@
package ruletypes
import (
"encoding/json"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRuleViewDataValidate(t *testing.T) {
cases := []struct {
description string
data RuleViewData
expectError bool
}{
{
description: "valid with all fields set",
data: RuleViewData{Version: RuleViewSchemaVersion, Query: "name CONTAINS 'prod'", States: []string{"firing", "pending"}, Sort: ListSortName, Order: ListOrderAsc},
expectError: false,
},
{
description: "valid with zero states, sort and order",
data: RuleViewData{Version: RuleViewSchemaVersion},
expectError: false,
},
{
description: "query over the cap is rejected",
data: RuleViewData{Version: RuleViewSchemaVersion, Query: strings.Repeat("x", MaxListQueryLen+1)},
expectError: true,
},
{
description: "wrong version is rejected",
data: RuleViewData{Version: "v2"},
expectError: true,
},
{
description: "empty version is rejected",
data: RuleViewData{},
expectError: true,
},
{
description: "unknown state is rejected",
data: RuleViewData{Version: RuleViewSchemaVersion, States: []string{"exploding"}},
expectError: true,
},
{
description: "unknown sort is rejected",
data: RuleViewData{Version: RuleViewSchemaVersion, Sort: ListSort{valuer.NewString("bogus")}},
expectError: true,
},
{
description: "unknown order is rejected",
data: RuleViewData{Version: RuleViewSchemaVersion, Order: ListOrder{valuer.NewString("sideways")}},
expectError: true,
},
}
for _, c := range cases {
t.Run(c.description, func(t *testing.T) {
err := c.data.Validate()
if c.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestRuleViewDataValidateDefaults(t *testing.T) {
data := RuleViewData{Version: RuleViewSchemaVersion}
require.NoError(t, data.Validate())
assert.Equal(t, ListSortUpdatedAt, data.Sort)
assert.Equal(t, ListOrderDesc, data.Order)
}
func TestPostableRuleViewUnmarshalJSON(t *testing.T) {
cases := []struct {
description string
body string
expectError bool
expectedErrMsg string
expectedName string
}{
{
description: "valid body keeps name as-is",
body: `{"name":"my view","data":{"version":"v1","query":"severity = 'critical'","states":["firing"],"sort":"name","order":"asc"}}`,
expectError: false,
expectedName: "my view",
},
{
description: "name with surrounding whitespace is rejected",
body: `{"name":" my view ","data":{"version":"v1"}}`,
expectError: true,
expectedErrMsg: "name must not have leading or trailing whitespace",
},
{
description: "unknown field is rejected",
body: `{"name":"my view","data":{"version":"v1"},"extra":true}`,
expectError: true,
},
{
description: "blank name is rejected",
body: `{"name":" ","data":{"version":"v1"}}`,
expectError: true,
expectedErrMsg: "name is required",
},
{
description: "name over max length is rejected",
body: `{"name":"` + strings.Repeat("x", MaxRuleViewNameLen+1) + `","data":{"version":"v1"}}`,
expectError: true,
expectedErrMsg: "name must be at most",
},
{
description: "invalid data version is rejected",
body: `{"name":"my view","data":{"version":"v9"}}`,
expectError: true,
},
{
description: "invalid state is rejected",
body: `{"name":"my view","data":{"version":"v1","states":["exploding"]}}`,
expectError: true,
expectedErrMsg: "invalid state",
},
}
for _, c := range cases {
t.Run(c.description, func(t *testing.T) {
var p PostableRuleView
err := json.Unmarshal([]byte(c.body), &p)
if c.expectError {
assert.Error(t, err)
if c.expectedErrMsg != "" {
assert.ErrorContains(t, err, c.expectedErrMsg)
}
return
}
require.NoError(t, err)
assert.Equal(t, c.expectedName, p.Name)
})
}
}
func TestPostableRuleViewNewRuleView(t *testing.T) {
orgID := valuer.GenerateUUID()
postable := PostableRuleView{
Name: "my view",
Data: RuleViewData{Version: RuleViewSchemaVersion, States: []string{"firing"}, Sort: ListSortName, Order: ListOrderAsc},
}
view := postable.NewRuleView(orgID)
assert.Equal(t, orgID, view.OrgID)
assert.Equal(t, "my view", view.Name)
assert.Equal(t, postable.Data, view.Data)
assert.False(t, view.ID.IsZero())
assert.False(t, view.CreatedAt.IsZero())
assert.Equal(t, view.CreatedAt, view.UpdatedAt)
}
func TestRuleViewUpdate(t *testing.T) {
orgID := valuer.GenerateUUID()
view := PostableRuleView{
Name: "original",
Data: RuleViewData{Version: RuleViewSchemaVersion, Sort: ListSortName, Order: ListOrderAsc},
}.NewRuleView(orgID)
createdAt := view.CreatedAt
view.Update(UpdatableRuleView{
Name: "renamed",
Data: RuleViewData{Version: RuleViewSchemaVersion, States: []string{"disabled"}, Sort: ListSortCreatedAt, Order: ListOrderDesc},
})
assert.Equal(t, "renamed", view.Name)
assert.Equal(t, []string{"disabled"}, view.Data.States)
assert.Equal(t, ListSortCreatedAt, view.Data.Sort)
assert.Equal(t, ListOrderDesc, view.Data.Order)
assert.Equal(t, createdAt, view.CreatedAt)
assert.True(t, view.UpdatedAt.After(createdAt) || view.UpdatedAt.Equal(createdAt))
}

View File

@@ -1,6 +1,8 @@
package spantypes
import (
"slices"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -11,6 +13,7 @@ import (
var (
ErrCodeMapperNotFound = errors.MustNewCode("span_attribute_mapper_not_found")
ErrCodeMapperAlreadyExists = errors.MustNewCode("span_attribute_mapper_already_exists")
ErrCodeMapperNotDeletable = errors.MustNewCode("span_attribute_mapper_not_deletable")
ErrCodeMappingInvalidInput = errors.MustNewCode("span_attribute_mapping_invalid_input")
)
@@ -34,12 +37,25 @@ var (
SpanMapperOperationCopy = SpanMapperOperation{valuer.NewString("copy")}
)
// SpanMapperOrigin tells shipped (system) items apart from user-created ones.
// System items are read-only apart from their enabled toggle.
type SpanMapperOrigin struct {
valuer.String
}
var (
SpanMapperOriginUser = SpanMapperOrigin{valuer.NewString("user")}
SpanMapperOriginSystem = SpanMapperOrigin{valuer.NewString("system")}
)
// MapperSource describes one candidate source for a target attribute.
type SpanMapperSource struct {
Key string `json:"key" required:"true"`
Context FieldContext `json:"context" required:"true"`
Operation SpanMapperOperation `json:"operation" required:"true"`
Priority int `json:"priority" required:"true"`
Enabled bool `json:"enabled" required:"true"`
Origin SpanMapperOrigin `json:"origin"`
}
// MapperConfig holds the mapping logic for a single target attribute.
@@ -59,6 +75,7 @@ type SpanMapper struct {
FieldContext FieldContext `json:"fieldContext" required:"true"`
Config SpanMapperConfig `json:"config" required:"true"`
Enabled bool `json:"enabled" required:"true"`
Origin SpanMapperOrigin `json:"origin" required:"true"`
}
type PostableSpanMapper struct {
@@ -90,6 +107,63 @@ func (SpanMapperOperation) Enum() []any {
return []any{SpanMapperOperationMove, SpanMapperOperationCopy}
}
func (SpanMapperOrigin) Enum() []any {
return []any{SpanMapperOriginUser, SpanMapperOriginSystem}
}
func (p *PostableSpanMapper) Validate() error {
if strings.TrimSpace(p.Name) == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "mapper name must not be blank")
}
if err := p.FieldContext.Validate(); err != nil {
return err
}
return p.Config.Validate()
}
func (f FieldContext) Validate() error {
if f != FieldContextSpanAttribute && f != FieldContextResource {
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "field context must be one of %q or %q, got %q", FieldContextSpanAttribute, FieldContextResource, f.StringValue())
}
return nil
}
// Validate checks every source and rejects duplicate priorities within an
// origin. Shipped and user sources are never compared with each other: a user
// re-adding a shipped key with another operation is the supported override.
func (c *SpanMapperConfig) Validate() error {
if len(c.Sources) == 0 {
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "config.sources must contain at least one source")
}
seen := map[SpanMapperOrigin]map[int]struct{}{}
for _, s := range c.Sources {
if strings.TrimSpace(s.Key) == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "source key must not be blank")
}
if err := s.Context.Validate(); err != nil {
return err
}
if s.Operation != SpanMapperOperationCopy && s.Operation != SpanMapperOperationMove {
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "source operation must be one of %q or %q, got %q", SpanMapperOperationCopy, SpanMapperOperationMove, s.Operation.StringValue())
}
if !s.Origin.IsZero() && s.Origin != SpanMapperOriginUser && s.Origin != SpanMapperOriginSystem {
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "source origin must be one of %q or %q, got %q", SpanMapperOriginUser, SpanMapperOriginSystem, s.Origin.StringValue())
}
origin := s.Origin
if origin.IsZero() {
origin = SpanMapperOriginUser
}
if seen[origin] == nil {
seen[origin] = map[int]struct{}{}
}
if _, dup := seen[origin][s.Priority]; dup {
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "source priority %d is used more than once", s.Priority)
}
seen[origin][s.Priority] = struct{}{}
}
return nil
}
func NewSpanMapper(groupID valuer.UUID, createdBy string, p *PostableSpanMapper) *SpanMapper {
now := time.Now()
return &SpanMapper{
@@ -97,8 +171,9 @@ func NewSpanMapper(groupID valuer.UUID, createdBy string, p *PostableSpanMapper)
GroupID: groupID,
Name: p.Name,
FieldContext: p.FieldContext,
Config: p.Config,
Config: SpanMapperConfig{Sources: withOrigin(p.Config.Sources, SpanMapperOriginUser)},
Enabled: p.Enabled,
Origin: SpanMapperOriginUser,
TimeAuditable: types.TimeAuditable{
CreatedAt: now,
UpdatedAt: now,
@@ -110,16 +185,42 @@ func NewSpanMapper(groupID valuer.UUID, createdBy string, p *PostableSpanMapper)
}
}
func (m *SpanMapper) Update(fieldContext FieldContext, config *SpanMapperConfig, enabled *bool, updatedBy string) {
m.FieldContext = fieldContext
// Update applies a user edit; a zero fieldContext means it was omitted. On a
// system mapper the field context is fixed and the stored system sources are
// kept; see nextSources.
func (m *SpanMapper) Update(fieldContext FieldContext, config *SpanMapperConfig, enabled *bool, updatedBy string) error {
if !fieldContext.IsZero() {
if m.Origin == SpanMapperOriginSystem && fieldContext != m.FieldContext {
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "field context of system mapper %q cannot be changed", m.Name)
}
if err := fieldContext.Validate(); err != nil {
return err
}
m.FieldContext = fieldContext
}
if config != nil {
m.Config = *config
sources, err := m.nextSources(config.Sources)
if err != nil {
return err
}
m.Config = SpanMapperConfig{Sources: sources}
if err := m.Config.Validate(); err != nil {
return err
}
}
if enabled != nil {
m.Enabled = *enabled
}
m.UpdatedAt = time.Now()
m.UpdatedBy = updatedBy
return nil
}
func (m *SpanMapper) ErrIfNotDeletable() error {
if m.Origin == SpanMapperOriginSystem {
return errors.Newf(errors.TypeInvalidInput, ErrCodeMapperNotDeletable, "system mapper %q cannot be deleted, disable it instead", m.Name)
}
return nil
}
func (m *SpanMapper) ToStorable() *StorableSpanMapper {
@@ -132,6 +233,7 @@ func (m *SpanMapper) ToStorable() *StorableSpanMapper {
FieldContext: m.FieldContext,
Config: m.Config,
Enabled: m.Enabled,
Origin: m.Origin,
}
}
@@ -145,6 +247,7 @@ func (s *StorableSpanMapper) ToSpanMapper() *SpanMapper {
FieldContext: s.FieldContext,
Config: s.Config,
Enabled: s.Enabled,
Origin: s.Origin,
}
}
@@ -159,3 +262,39 @@ func NewSpanMappersFromStorable(ss []*StorableSpanMapper) []*SpanMapper {
func NewGettableSpanMappers(m []*SpanMapper) *GettableSpanMappers {
return &GettableSpanMappers{Items: m}
}
// nextSources builds the source list from an edit: user sources are taken from
// the edit as sent, system sources stay as stored and the edit can only flip
// their enabled flag.
func (m *SpanMapper) nextSources(edit []SpanMapperSource) ([]SpanMapperSource, error) {
var systemSources, userSources []SpanMapperSource
for _, s := range m.Config.Sources {
if s.Origin == SpanMapperOriginSystem {
systemSources = append(systemSources, s)
}
}
for _, s := range edit {
if s.Origin != SpanMapperOriginSystem {
s.Origin = SpanMapperOriginUser
userSources = append(userSources, s)
continue
}
idx := slices.IndexFunc(systemSources, func(o SpanMapperSource) bool { return o.Key == s.Key && o.Context == s.Context })
if idx == -1 {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "system source %q does not exist on this mapper; only its enabled flag can change", s.Key)
}
systemSources[idx].Enabled = s.Enabled
}
return append(systemSources, userSources...), nil
}
func withOrigin(sources []SpanMapperSource, origin SpanMapperOrigin) []SpanMapperSource {
out := make([]SpanMapperSource, len(sources))
for i, s := range sources {
s.Origin = origin
out[i] = s
}
return out
}

View File

@@ -1,6 +1,8 @@
package spantypes
import (
"slices"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -11,17 +13,28 @@ import (
var (
ErrCodeMappingGroupNotFound = errors.MustNewCode("span_attribute_mapping_group_not_found")
ErrCodeMappingGroupAlreadyExists = errors.MustNewCode("span_attribute_mapping_group_already_exists")
ErrCodeMappingGroupNameReserved = errors.MustNewCode("span_attribute_mapping_group_name_reserved")
ErrCodeMappingGroupNotDeletable = errors.MustNewCode("span_attribute_mapping_group_not_deletable")
)
// SpanMapperGroupConditionKey is one substring a span's attribute or resource
// keys are matched against.
type SpanMapperGroupConditionKey struct {
Value string `json:"value" required:"true"`
Enabled bool `json:"enabled" required:"true"`
Origin SpanMapperOrigin `json:"origin"`
}
// SpanMapperGroupCondition gates whether a group's rules run for a given span.
// A group runs when any attribute or resource key on the span CONTAINS one of
// the listed substrings (plain substring match — no glob syntax).
type SpanMapperGroupCondition struct {
Attributes []string `json:"attributes" required:"true" nullable:"true"`
Resource []string `json:"resource" required:"true" nullable:"true"`
Attributes []SpanMapperGroupConditionKey `json:"attributes" required:"true" nullable:"true"`
Resource []SpanMapperGroupConditionKey `json:"resource" required:"true" nullable:"true"`
}
// SpanMapperGroup is the domain model for a span attribute mapping group.
// Version is the shipped definition version for system groups and 0 otherwise.
type SpanMapperGroup struct {
types.TimeAuditable
types.UserAuditable
@@ -31,6 +44,8 @@ type SpanMapperGroup struct {
Name string `json:"name" required:"true"`
Condition SpanMapperGroupCondition `json:"condition" required:"true"`
Enabled bool `json:"enabled" required:"true"`
Origin SpanMapperOrigin `json:"origin" required:"true"`
Version int `json:"version" required:"true"`
}
// GettableSpanMapperGroup is the HTTP response representation of a mapping group.
@@ -58,14 +73,42 @@ type GettableSpanMapperGroups struct {
Items []*GettableSpanMapperGroup `json:"items" required:"true" nullable:"false"`
}
// Validate requires at least one substring overall and rejects blank ones.
// All-off is allowed: a group with every substring disabled simply never runs.
func (c *SpanMapperGroupCondition) Validate() error {
if len(c.Attributes)+len(c.Resource) == 0 {
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "condition must list at least one attribute or resource substring")
}
for _, k := range slices.Concat(c.Attributes, c.Resource) {
if strings.TrimSpace(k.Value) == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "condition substrings must not be blank")
}
if !k.Origin.IsZero() && k.Origin != SpanMapperOriginUser && k.Origin != SpanMapperOriginSystem {
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "condition origin must be one of %q or %q, got %q", SpanMapperOriginUser, SpanMapperOriginSystem, k.Origin.StringValue())
}
}
return nil
}
func (p *PostableSpanMapperGroup) Validate() error {
if strings.TrimSpace(p.Name) == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "group name must not be blank")
}
return p.Condition.Validate()
}
func NewSpanMapperGroup(orgID valuer.UUID, createdBy string, p *PostableSpanMapperGroup) *SpanMapperGroup {
now := time.Now()
return &SpanMapperGroup{
ID: valuer.GenerateUUID(),
OrgID: orgID,
Name: p.Name,
Condition: p.Condition,
Enabled: p.Enabled,
ID: valuer.GenerateUUID(),
OrgID: orgID,
Name: p.Name,
Condition: SpanMapperGroupCondition{
Attributes: conditionKeysWithOrigin(p.Condition.Attributes, SpanMapperOriginUser),
Resource: conditionKeysWithOrigin(p.Condition.Resource, SpanMapperOriginUser),
},
Enabled: p.Enabled,
Origin: SpanMapperOriginUser,
TimeAuditable: types.TimeAuditable{
CreatedAt: now,
UpdatedAt: now,
@@ -77,18 +120,45 @@ func NewSpanMapperGroup(orgID valuer.UUID, createdBy string, p *PostableSpanMapp
}
}
func (g *SpanMapperGroup) Update(name *string, condition *SpanMapperGroupCondition, enabled *bool, updatedBy string) {
// Update applies a user edit. A system group keeps its name and its system
// substrings; see nextConditionKeys.
func (g *SpanMapperGroup) Update(name *string, condition *SpanMapperGroupCondition, enabled *bool, updatedBy string) error {
if name != nil {
if g.Origin == SpanMapperOriginSystem && *name != g.Name {
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "system group %q cannot be renamed", g.Name)
}
if strings.TrimSpace(*name) == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "group name must not be blank")
}
g.Name = *name
}
if condition != nil {
g.Condition = *condition
attrs, err := nextConditionKeys(g.Condition.Attributes, condition.Attributes)
if err != nil {
return err
}
res, err := nextConditionKeys(g.Condition.Resource, condition.Resource)
if err != nil {
return err
}
g.Condition = SpanMapperGroupCondition{Attributes: attrs, Resource: res}
if err := g.Condition.Validate(); err != nil {
return err
}
}
if enabled != nil {
g.Enabled = *enabled
}
g.UpdatedAt = time.Now()
g.UpdatedBy = updatedBy
return nil
}
func (g *SpanMapperGroup) ErrIfNotDeletable() error {
if g.Origin == SpanMapperOriginSystem {
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingGroupNotDeletable, "system group %q cannot be deleted, disable it instead", g.Name)
}
return nil
}
func (g *SpanMapperGroup) ToStorable() *StorableSpanMapperGroup {
@@ -100,6 +170,8 @@ func (g *SpanMapperGroup) ToStorable() *StorableSpanMapperGroup {
Name: g.Name,
Condition: g.Condition,
Enabled: g.Enabled,
Origin: g.Origin,
Version: g.Version,
}
}
@@ -112,6 +184,8 @@ func (s *StorableSpanMapperGroup) ToSpanMapperGroup() *SpanMapperGroup {
Name: s.Name,
Condition: s.Condition,
Enabled: s.Enabled,
Origin: s.Origin,
Version: s.Version,
}
}
@@ -126,3 +200,39 @@ func NewSpanMapperGroupsFromStorable(ss []*StorableSpanMapperGroup) []*SpanMappe
func NewGettableSpanMapperGroups(g []*SpanMapperGroup) *GettableSpanMapperGroups {
return &GettableSpanMapperGroups{Items: g}
}
// nextConditionKeys builds a substring list from an edit: user substrings are
// taken from the edit as sent, system substrings stay as stored and the edit
// can only flip their enabled flag.
func nextConditionKeys(stored, edit []SpanMapperGroupConditionKey) ([]SpanMapperGroupConditionKey, error) {
var systemKeys, userKeys []SpanMapperGroupConditionKey
for _, k := range stored {
if k.Origin == SpanMapperOriginSystem {
systemKeys = append(systemKeys, k)
}
}
for _, k := range edit {
if k.Origin != SpanMapperOriginSystem {
k.Origin = SpanMapperOriginUser
userKeys = append(userKeys, k)
continue
}
idx := slices.IndexFunc(systemKeys, func(s SpanMapperGroupConditionKey) bool { return s.Value == k.Value })
if idx == -1 {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "system substring %q does not exist on this group; only its enabled flag can change", k.Value)
}
systemKeys[idx].Enabled = k.Enabled
}
return append(systemKeys, userKeys...), nil
}
func conditionKeysWithOrigin(keys []SpanMapperGroupConditionKey, origin SpanMapperOrigin) []SpanMapperGroupConditionKey {
out := make([]SpanMapperGroupConditionKey, len(keys))
for i, k := range keys {
k.Origin = origin
out[i] = k
}
return out
}

View File

@@ -0,0 +1,121 @@
package spantypes
import (
"bytes"
"encoding/json"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
)
var ErrCodeMappingDefinitionInvalid = errors.MustNewCode("span_attribute_mapping_definition_invalid")
// ProvisionerIdentity is stamped into created_by/updated_by by the reconciler.
const ProvisionerIdentity = "signoz"
// SpanMapperGroupDefinition is one shipped mapping group. Version is bumped on
// every content change and drives upgrades; the group name is the stable key
// and never changes. Once parsed, every substring and source carries the
// system origin and is enabled.
type SpanMapperGroupDefinition struct {
Version int `json:"version"`
Definition PostableSpanMapperTestGroup `json:"definition"`
}
func (d SpanMapperGroupDefinition) Name() string {
return d.Definition.Name
}
func NewSpanMapperGroupDefinition(raw []byte) (SpanMapperGroupDefinition, error) {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
var d SpanMapperGroupDefinition
if err := decoder.Decode(&d); err != nil {
return SpanMapperGroupDefinition{}, errors.WrapInvalidInputf(err, ErrCodeMappingDefinitionInvalid, "%s", err.Error())
}
if err := d.validate(); err != nil {
return SpanMapperGroupDefinition{}, err
}
for _, keys := range [][]SpanMapperGroupConditionKey{d.Definition.Condition.Attributes, d.Definition.Condition.Resource} {
for i := range keys {
keys[i].Enabled = true
keys[i].Origin = SpanMapperOriginSystem
}
}
for i := range d.Definition.Mappers {
sources := d.Definition.Mappers[i].Config.Sources
for j := range sources {
sources[j].Enabled = true
sources[j].Origin = SpanMapperOriginSystem
}
}
return d, nil
}
// SpanMapperGroupRegistry holds every definition embedded in the binary, keyed by name.
type SpanMapperGroupRegistry struct {
definitions map[string]SpanMapperGroupDefinition
}
func NewSpanMapperGroupRegistry(definitions []SpanMapperGroupDefinition) (SpanMapperGroupRegistry, error) {
byName := make(map[string]SpanMapperGroupDefinition, len(definitions))
for _, d := range definitions {
if _, dup := byName[d.Name()]; dup {
return SpanMapperGroupRegistry{}, errors.NewInvalidInputf(ErrCodeMappingDefinitionInvalid, "duplicate span mapper group name %q", d.Name())
}
byName[d.Name()] = d
}
return SpanMapperGroupRegistry{definitions: byName}, nil
}
func (r SpanMapperGroupRegistry) IsReserved(name string) bool {
_, ok := r.definitions[name]
return ok
}
// List returns the definitions sorted by name so provisioning order is stable.
func (r SpanMapperGroupRegistry) List() []SpanMapperGroupDefinition {
out := make([]SpanMapperGroupDefinition, 0, len(r.definitions))
for _, d := range r.definitions {
out = append(out, d)
}
slices.SortFunc(out, func(a, b SpanMapperGroupDefinition) int { return strings.Compare(a.Name(), b.Name()) })
return out
}
func (d SpanMapperGroupDefinition) validate() error {
if d.Version < 1 {
return errors.NewInvalidInputf(ErrCodeMappingDefinitionInvalid, "version must be at least 1, got %d", d.Version)
}
if err := d.Definition.Validate(); err != nil {
return errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeMappingDefinitionInvalid, "%s", d.Name())
}
if len(d.Definition.Mappers) == 0 {
return errors.NewInvalidInputf(ErrCodeMappingDefinitionInvalid, "%s: at least one mapper is required", d.Name())
}
names := make(map[string]struct{}, len(d.Definition.Mappers))
for i := range d.Definition.Mappers {
m := &d.Definition.Mappers[i]
if err := m.Validate(); err != nil {
return errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeMappingDefinitionInvalid, "%s: mapper %q", d.Name(), m.Name)
}
if _, dup := names[m.Name]; dup {
return errors.NewInvalidInputf(ErrCodeMappingDefinitionInvalid, "%s: duplicate mapper %q", d.Name(), m.Name)
}
names[m.Name] = struct{}{}
for _, s := range m.Config.Sources {
if !s.Origin.IsZero() || s.Enabled {
return errors.NewInvalidInputf(ErrCodeMappingDefinitionInvalid, "%s: mapper %q: sources must not set origin or enabled", d.Name(), m.Name)
}
}
}
for _, k := range slices.Concat(d.Definition.Condition.Attributes, d.Definition.Condition.Resource) {
if !k.Origin.IsZero() || k.Enabled {
return errors.NewInvalidInputf(ErrCodeMappingDefinitionInvalid, "%s: condition substrings must not set origin or enabled", d.Name())
}
}
return nil
}

View File

@@ -86,17 +86,31 @@ func buildProcessorConfig(groups []*SpanMapperGroupWithMappers) *spanMapperProce
out := make([]spanMapperProcessorGroup, 0, len(groups))
for _, gm := range groups {
existsAny := spanMapperProcessorExistsAny{
Attributes: enabledConditionValues(gm.Group.Condition.Attributes),
Resource: enabledConditionValues(gm.Group.Condition.Resource),
}
// The collector rejects an empty exists_any and empty sources; with
// per-item toggles, all-off is valid stored state and means "never runs".
if len(existsAny.Attributes)+len(existsAny.Resource) == 0 {
continue
}
rules := make([]spanMapperProcessorAttribute, 0, len(gm.Mappers))
for _, m := range gm.Mappers {
rules = append(rules, buildAttributeRule(m))
rule := buildAttributeRule(m)
if len(rule.Sources) == 0 {
continue
}
rules = append(rules, rule)
}
if len(rules) == 0 {
continue
}
out = append(out, spanMapperProcessorGroup{
ID: gm.Group.Name,
ExistsAny: spanMapperProcessorExistsAny{
Attributes: gm.Group.Condition.Attributes,
Resource: gm.Group.Condition.Resource,
},
ID: gm.Group.Name,
ExistsAny: existsAny,
Attributes: rules,
})
}
@@ -104,14 +118,31 @@ func buildProcessorConfig(groups []*SpanMapperGroupWithMappers) *spanMapperProce
return &spanMapperProcessorConfig{Groups: out}
}
func enabledConditionValues(keys []SpanMapperGroupConditionKey) []string {
out := make([]string, 0, len(keys))
for _, k := range keys {
if k.Enabled {
out = append(out, k.Value)
}
}
if len(out) == 0 {
return nil
}
return out
}
// buildAttributeRule maps a single SpanMapper to a collector attribute rule.
// Sources are sorted by Priority DESC (highest-priority first); read-from-
// resource sources are encoded via the "resource." prefix on the key. Each
// source carries its own action — "copy" is omitted to keep the emitted YAML
// compact, and only "move" is set explicitly.
// Disabled sources are skipped and the rest are sorted by Priority DESC
// (highest-priority first); read-from-resource sources are encoded via the
// "resource." prefix on the key. Each source carries its own action — "copy"
// is omitted to keep the emitted YAML compact, and only "move" is set explicitly.
func buildAttributeRule(m *SpanMapper) spanMapperProcessorAttribute {
sources := make([]SpanMapperSource, len(m.Config.Sources))
copy(sources, m.Config.Sources)
sources := make([]SpanMapperSource, 0, len(m.Config.Sources))
for _, s := range m.Config.Sources {
if s.Enabled {
sources = append(sources, s)
}
}
sort.SliceStable(sources, func(i, j int) bool { return sources[i].Priority > sources[j].Priority })
out := make([]spanMapperProcessorSource, 0, len(sources))

View File

@@ -145,6 +145,22 @@ func TestBuildAttributeRule(t *testing.T) {
},
},
},
{
name: "disabled_sources_skipped",
mapper: newMapper("gen_ai.input.messages", FieldContextSpanAttribute,
systemSrc("gen_ai.prompt", SpanMapperOperationCopy, 30, false),
systemSrc("input.value", SpanMapperOperationCopy, 20, true),
attrSrc("gen_ai.prompt", SpanMapperOperationMove, 40),
),
want: spanMapperProcessorAttribute{
Target: "gen_ai.input.messages",
Context: FieldContextSpanAttribute.StringValue(),
Sources: []spanMapperProcessorSource{
{Key: "gen_ai.prompt", Action: SpanMapperOperationMove.StringValue()},
{Key: "input.value"},
},
},
},
}
for _, tc := range tests {
@@ -155,6 +171,33 @@ func TestBuildAttributeRule(t *testing.T) {
}
}
func TestBuildProcessorConfigDropsAllOffItems(t *testing.T) {
t.Parallel()
offGroup := newGroup("all-off", nil, nil)
offGroup.Condition.Attributes = []SpanMapperGroupConditionKey{{Value: "model", Enabled: false, Origin: SpanMapperOriginSystem}}
mixed := newGroup("llm", nil, nil)
mixed.Condition.Attributes = []SpanMapperGroupConditionKey{
{Value: "model", Enabled: false, Origin: SpanMapperOriginSystem},
{Value: "gen_ai.request.model", Enabled: true, Origin: SpanMapperOriginUser},
}
got := buildProcessorConfig([]*SpanMapperGroupWithMappers{
{Group: offGroup, Mappers: []*SpanMapper{newMapper("gen_ai.request.model", FieldContextSpanAttribute, attrSrc("llm.model", SpanMapperOperationCopy, 1))}},
{Group: mixed, Mappers: []*SpanMapper{
newMapper("gen_ai.request.model", FieldContextSpanAttribute, systemSrc("llm.model", SpanMapperOperationCopy, 10, false)),
newMapper("gen_ai.provider.name", FieldContextSpanAttribute, systemSrc("llm.vendor", SpanMapperOperationCopy, 10, true)),
}},
})
require.Len(t, got.Groups, 1)
assert.Equal(t, "llm", got.Groups[0].ID)
assert.Equal(t, []string{"gen_ai.request.model"}, got.Groups[0].ExistsAny.Attributes)
require.Len(t, got.Groups[0].Attributes, 1)
assert.Equal(t, "gen_ai.provider.name", got.Groups[0].Attributes[0].Target)
}
func loadFixture(t *testing.T, name string) []byte {
t.Helper()
b, err := os.ReadFile(filepath.Join("testdata", name))
@@ -174,12 +217,23 @@ func assertYAMLEqual(t *testing.T, want, got []byte) {
func newGroup(name string, attrs, res []string) *SpanMapperGroup {
return &SpanMapperGroup{
Name: name,
Condition: SpanMapperGroupCondition{Attributes: attrs, Resource: res},
Enabled: true,
Name: name,
Condition: SpanMapperGroupCondition{
Attributes: userConditionKeys(attrs),
Resource: userConditionKeys(res),
},
Enabled: true,
}
}
func userConditionKeys(values []string) []SpanMapperGroupConditionKey {
out := make([]SpanMapperGroupConditionKey, len(values))
for i, v := range values {
out[i] = SpanMapperGroupConditionKey{Value: v, Enabled: true, Origin: SpanMapperOriginUser}
}
return out
}
func newMapper(name string, target FieldContext, sources ...SpanMapperSource) *SpanMapper {
return &SpanMapper{
Name: name,
@@ -190,9 +244,13 @@ func newMapper(name string, target FieldContext, sources ...SpanMapperSource) *S
}
func attrSrc(key string, op SpanMapperOperation, priority int) SpanMapperSource {
return SpanMapperSource{Key: key, Context: FieldContextSpanAttribute, Operation: op, Priority: priority}
return SpanMapperSource{Key: key, Context: FieldContextSpanAttribute, Operation: op, Priority: priority, Enabled: true, Origin: SpanMapperOriginUser}
}
func resSrc(key string, op SpanMapperOperation, priority int) SpanMapperSource {
return SpanMapperSource{Key: key, Context: FieldContextResource, Operation: op, Priority: priority}
return SpanMapperSource{Key: key, Context: FieldContextResource, Operation: op, Priority: priority, Enabled: true, Origin: SpanMapperOriginUser}
}
func systemSrc(key string, op SpanMapperOperation, priority int, enabled bool) SpanMapperSource {
return SpanMapperSource{Key: key, Context: FieldContextSpanAttribute, Operation: op, Priority: priority, Enabled: enabled, Origin: SpanMapperOriginSystem}
}

View File

@@ -15,14 +15,14 @@ func TestSimulateSpanMappersProcessing_EndToEnd(t *testing.T) {
groups := []*SpanMapperGroupWithMappers{{
Group: &SpanMapperGroup{
Name: "llm",
Condition: SpanMapperGroupCondition{Attributes: []string{"model"}},
Condition: SpanMapperGroupCondition{Attributes: userConditionKeys([]string{"model"})},
Enabled: true,
},
Mappers: []*SpanMapper{{
Name: "gen_ai.request.model",
FieldContext: FieldContextSpanAttribute,
Config: SpanMapperConfig{Sources: []SpanMapperSource{
{Key: "llm.model", Context: FieldContextSpanAttribute, Operation: SpanMapperOperationCopy, Priority: 1},
{Key: "llm.model", Context: FieldContextSpanAttribute, Operation: SpanMapperOperationCopy, Priority: 1, Enabled: true, Origin: SpanMapperOriginUser},
}},
Enabled: true,
}},

View File

@@ -20,7 +20,9 @@ type StorableSpanMapperGroup struct {
OrgID valuer.UUID `bun:"org_id,type:text,notnull"`
Name string `bun:"name,type:text,notnull"`
Condition SpanMapperGroupCondition `bun:"condition,type:jsonb,notnull"`
Enabled bool `bun:"enabled,notnull,default:true"`
Enabled bool `bun:"enabled,notnull"`
Origin SpanMapperOrigin `bun:"origin,type:text,notnull"`
Version int `bun:"version,notnull"`
}
type StorableSpanMapper struct {
@@ -34,7 +36,8 @@ type StorableSpanMapper struct {
Name string `bun:"name,type:text,notnull"`
FieldContext FieldContext `bun:"field_context,type:text,notnull"`
Config SpanMapperConfig `bun:"config,type:jsonb,notnull"`
Enabled bool `bun:"enabled,notnull,default:true"`
Enabled bool `bun:"enabled,notnull"`
Origin SpanMapperOrigin `bun:"origin,type:text,notnull"`
}
func (c SpanMapperGroupCondition) Value() (driver.Value, error) {

View File

@@ -9,9 +9,14 @@ import (
)
type SpanMapperStore interface {
// RunInTx runs cb in one transaction; every store call made with the
// callback's ctx joins it.
RunInTx(ctx context.Context, cb func(ctx context.Context) error) error
// Group operations
ListGroups(ctx context.Context, orgID valuer.UUID, q *ListSpanMapperGroupsQuery) ([]*SpanMapperGroup, error)
GetGroup(ctx context.Context, orgID, id valuer.UUID) (*SpanMapperGroup, error)
GetGroupByName(ctx context.Context, orgID valuer.UUID, name string) (*SpanMapperGroup, error)
CreateGroup(ctx context.Context, group *SpanMapperGroup) error
UpdateGroup(ctx context.Context, group *SpanMapperGroup) error
DeleteGroup(ctx context.Context, orgID, id valuer.UUID) error

View File

@@ -19,7 +19,6 @@ from fixtures.logger import setup_logger
from fixtures.logs import Logs
from fixtures.maildev import get_all_mails, verify_email_received
from fixtures.metrics import Metrics
from fixtures.notification_channel import ensure_notification_channel
from fixtures.traces import Traces
logger = setup_logger(__name__)
@@ -89,40 +88,6 @@ def create_alert_rule_with_channel(
return _create_alert_rule_with_channel
def delete_all_rules(signoz: types.SigNoz, token: str) -> None:
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/rules"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
for rule in response.json()["data"]:
delete_response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/rules/{rule['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert delete_response.status_code == HTTPStatus.OK, f"failed to delete rule {rule['id']}: {delete_response.text}"
@pytest.fixture(name="seed_alert_rules", scope="function")
def seed_alert_rules(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
create_alert_rule: Callable[[dict], str],
) -> Callable[[dict, list[dict]], None]:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Cleanup is owned by create_alert_rule, which deletes the rules it created.
def _seed_alert_rules(channel_config: dict, rules: list[dict]) -> None:
delete_all_rules(signoz, admin_token)
ensure_notification_channel(signoz, admin_token, channel_config)
for rule in rules:
create_alert_rule(rule)
return _seed_alert_rules
def labels_to_map(labels: list[dict]) -> dict[str, str]:
"""Converts the label list shape of the v2 rule history APIs to a plain map."""
return {label["key"]["name"]: label["value"] for label in labels or []}

View File

@@ -35,26 +35,6 @@ EMAIL_TRANSPORT_KEYS = [
]
def ensure_notification_channel(signoz: types.SigNoz, token: str, channel_config: dict) -> None:
# Deliberately no teardown: the stock channel fixture's teardown is broken, so channels are reused idempotently.
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/channels"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
if channel_config["name"] in [channel["name"] for channel in response.json()["data"] or []]:
return
create_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels"),
json=channel_config,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert create_response.status_code == HTTPStatus.CREATED, f"failed to create channel: {create_response.text}"
def assert_email_channel_payload_clean(payload: str) -> None:
receiver = json.loads(payload)
for email_config in receiver["email_configs"]:

View File

@@ -10,7 +10,6 @@ from fixtures import types
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
create_active_user,
)
TIMEOUT = 10

View File

@@ -1,542 +0,0 @@
from collections.abc import Callable
from http import HTTPStatus
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import Operation, SigNoz
BASE_URL = "/api/v3/rules"
SEED_CHANNEL = {"name": "list-rules-v3-channel", "email_configs": [{"to": "list-rules-v3@integration.test"}]}
EVALUATION = {"kind": "rolling", "spec": {"evalWindow": "5m0s", "frequency": "1m"}}
NOTIFICATION_SETTINGS = {
"groupBy": [],
"usePolicy": False,
"renotify": {"enabled": False, "interval": "30m", "alertStates": []},
}
METRIC_CONDITION = {
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": ["list-rules-v3-channel"]}],
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "list_rules_v3_cpu", "timeAggregation": "avg", "spaceAggregation": "max"}],
},
}
],
},
"selectedQueryName": "A",
}
LOGS_CONDITION = {
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 100, "matchType": "at_least_once", "op": "above", "channels": ["list-rules-v3-channel"]}],
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"aggregations": [{"expression": "count()"}],
"filter": {"expression": ""},
},
}
],
},
"selectedQueryName": "A",
}
PROMQL_CONDITION = {
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 1, "matchType": "at_least_once", "op": "below", "channels": ["list-rules-v3-channel"]}],
},
"compositeQuery": {
"queryType": "promql",
"panelType": "graph",
"queries": [{"type": "promql", "spec": {"name": "A", "query": '{"list_rules_v3_up"}'}}],
},
"selectedQueryName": "A",
}
SEED_RULES = [
{
"alert": "payment latency high",
"description": "p99 latency guard",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"condition": METRIC_CONDITION,
"labels": {"severity": "critical", "team": "payments", "k8s.cluster": "prod-1"},
"annotations": {"summary": "s", "description": "d"},
"evaluation": EVALUATION,
"notificationSettings": NOTIFICATION_SETTINGS,
"version": "v5",
"schemaVersion": "v2alpha1",
},
{
"alert": "payment gateway errors",
"description": "error rate watch",
"alertType": "LOGS_BASED_ALERT",
"ruleType": "threshold_rule",
"condition": LOGS_CONDITION,
"labels": {"severity": "warning", "team": "payments"},
"annotations": {"summary": "s", "description": "d"},
"evaluation": EVALUATION,
"notificationSettings": NOTIFICATION_SETTINGS,
"version": "v5",
"schemaVersion": "v2alpha1",
},
{
"alert": "checkout conversion drop",
"description": "funnel watcher",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"condition": METRIC_CONDITION,
"labels": {"severity": "important", "team": "checkout"},
"annotations": {"summary": "s", "description": "d"},
"disabled": True,
"evaluation": EVALUATION,
"notificationSettings": NOTIFICATION_SETTINGS,
"version": "v5",
"schemaVersion": "v2alpha1",
},
{
"alert": "infra cpu saturation",
"description": "node headroom",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"condition": METRIC_CONDITION,
"labels": {"team": "infra"},
"annotations": {"summary": "s", "description": "d"},
"evaluation": EVALUATION,
"notificationSettings": NOTIFICATION_SETTINGS,
"version": "v5",
"schemaVersion": "v2alpha1",
},
{
"alert": "prom uptime probe",
"description": "blackbox liveness",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "promql_rule",
"condition": PROMQL_CONDITION,
"labels": {},
"annotations": {"summary": "s", "description": "d"},
"evaluation": EVALUATION,
"notificationSettings": NOTIFICATION_SETTINGS,
"version": "v5",
"schemaVersion": "v2alpha1",
},
]
RESERVED_KEYWORDS = [
"alert_type",
"created_at",
"created_by",
"labels.<key>",
"name",
"rule_type",
"severity",
"updated_at",
"updated_by",
]
def test_envelope_and_slim_rows(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
seed_alert_rules: Callable[[dict, list[dict]], None],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
seed_alert_rules(SEED_CHANNEL, SEED_RULES)
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
data = response.json()["data"]
assert data["total"] == 5
assert len(data["rules"]) == 5
assert data["reservedKeywords"] == RESERVED_KEYWORDS
label_pairs = [(pair["key"], pair["value"]) for pair in data["labels"]]
assert label_pairs == sorted(label_pairs), "label pairs must be sorted by key then value"
for expected_pair in [
("k8s.cluster", "prod-1"),
("severity", "critical"),
("severity", "important"),
("severity", "warning"),
("team", "checkout"),
("team", "infra"),
("team", "payments"),
]:
assert expected_pair in label_pairs, f"missing label pair {expected_pair}"
by_name = {rule["alert"]: rule for rule in data["rules"]}
assert set(by_name) == {r["alert"] for r in SEED_RULES}
for rule in data["rules"]:
for forbidden_field in ("condition", "annotations", "notificationSettings", "evaluation", "source", "version", "schemaVersion"):
assert forbidden_field not in rule, f"slim row leaked {forbidden_field}"
for required_field in ("id", "state", "alert", "alertType", "ruleType", "createdAt", "updatedAt"):
assert required_field in rule, f"slim row missing {required_field}"
assert rule["createdBy"] == USER_ADMIN_EMAIL
assert rule["updatedBy"] == USER_ADMIN_EMAIL
assert by_name["checkout conversion drop"]["state"] == "disabled"
assert by_name["checkout conversion drop"]["disabled"] is True
assert by_name["payment latency high"]["state"] == "inactive"
assert by_name["payment latency high"]["description"] == "p99 latency guard"
assert by_name["payment latency high"]["labels"] == {"severity": "critical", "team": "payments", "k8s.cluster": "prod-1"}
assert by_name["payment gateway errors"]["alertType"] == "LOGS_BASED_ALERT"
assert by_name["prom uptime probe"]["ruleType"] == "promql_rule"
def test_query_filters(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
seed_alert_rules: Callable[[dict, list[dict]], None],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
seed_alert_rules(SEED_CHANNEL, SEED_RULES)
cases = [
("name = 'payment latency high'", {"payment latency high"}),
("name CONTAINS 'payment'", {"payment latency high", "payment gateway errors"}),
# free text goes through LOWER() on both dialects, so a case mismatch must still match
("PAYMENT", {"payment latency high", "payment gateway errors"}),
# free text also matches the description field
("blackbox", {"prom uptime probe"}),
(f"created_by = '{USER_ADMIN_EMAIL}'", {r["alert"] for r in SEED_RULES}),
("created_at >= '2020-01-01T00:00:00Z'", {r["alert"] for r in SEED_RULES}),
("created_at < '2020-01-01T00:00:00Z'", set()),
("alert_type = 'LOGS_BASED_ALERT'", {"payment gateway errors"}),
("rule_type = 'promql_rule'", {"prom uptime probe"}),
("rule_type IN ['threshold_rule']", {"payment latency high", "payment gateway errors", "checkout conversion drop", "infra cpu saturation"}),
("labels.team = 'payments'", {"payment latency high", "payment gateway errors"}),
("labels.k8s.cluster = 'prod-1'", {"payment latency high"}),
("labels.team EXISTS", {"payment latency high", "payment gateway errors", "checkout conversion drop", "infra cpu saturation"}),
("labels.team NOT EXISTS", {"prom uptime probe"}),
("NOT (labels.team EXISTS)", {"prom uptime probe"}),
(
"(labels.team = 'payments' OR labels.team = 'infra') AND name NOT CONTAINS 'gateway'",
{"payment latency high", "infra cpu saturation"},
),
]
for query, expected_names in cases:
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"query": query},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"query {query!r}: {response.text}"
data = response.json()["data"]
assert {rule["alert"] for rule in data["rules"]} == expected_names, f"query {query!r}"
assert data["total"] == len(expected_names), f"query {query!r}: total mismatch"
def test_label_missing_semantics(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
seed_alert_rules: Callable[[dict, list[dict]], None],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
seed_alert_rules(SEED_CHANNEL, SEED_RULES)
# A missing label uniformly evaluates as the empty string for value
# operators; presence is expressed with EXISTS / NOT EXISTS.
cases = [
("severity = ''", {"infra cpu saturation", "prom uptime probe"}),
("severity != ''", {"payment latency high", "payment gateway errors", "checkout conversion drop"}),
("severity != 'critical'", {"payment gateway errors", "checkout conversion drop", "infra cpu saturation", "prom uptime probe"}),
("severity EXISTS", {"payment latency high", "payment gateway errors", "checkout conversion drop"}),
("severity NOT EXISTS", {"infra cpu saturation", "prom uptime probe"}),
("severity = 'critical'", {"payment latency high"}),
("severity IN ['critical', 'warning']", {"payment latency high", "payment gateway errors"}),
("labels.team != 'payments'", {"checkout conversion drop", "infra cpu saturation", "prom uptime probe"}),
("labels.team NOT IN ['payments']", {"checkout conversion drop", "infra cpu saturation", "prom uptime probe"}),
("labels.team NOT CONTAINS 'pay'", {"checkout conversion drop", "infra cpu saturation", "prom uptime probe"}),
]
for query, expected_names in cases:
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"query": query},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"query {query!r}: {response.text}"
data = response.json()["data"]
assert {rule["alert"] for rule in data["rules"]} == expected_names, f"query {query!r}"
assert data["total"] == len(expected_names), f"query {query!r}: total mismatch"
def test_states_param(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
seed_alert_rules: Callable[[dict, list[dict]], None],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
seed_alert_rules(SEED_CHANNEL, SEED_RULES)
# No telemetry is seeded, so enabled rules sit at inactive and the one
# disabled rule reads disabled, deterministic without waiting on evals.
cases = [
({"states": ["disabled"]}, {"checkout conversion drop"}),
({"states": ["inactive"]}, {"payment latency high", "payment gateway errors", "infra cpu saturation", "prom uptime probe"}),
({"states": ["inactive", "disabled"]}, {r["alert"] for r in SEED_RULES}),
({"states": ["firing"]}, set()),
({"states": ["disabled"], "query": "labels.team = 'checkout'"}, {"checkout conversion drop"}),
({"states": ["disabled"], "query": "labels.team = 'payments'"}, set()),
]
for params, expected_names in cases:
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params=params,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"params {params!r}: {response.text}"
data = response.json()["data"]
assert {rule["alert"] for rule in data["rules"]} == expected_names, f"params {params!r}"
assert data["total"] == len(expected_names), f"params {params!r}: total mismatch"
def test_sorting(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
seed_alert_rules: Callable[[dict, list[dict]], None],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
seed_alert_rules(SEED_CHANNEL, SEED_RULES)
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"sort": "name", "order": "asc"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
assert [rule["alert"] for rule in response.json()["data"]["rules"]] == [
"checkout conversion drop",
"infra cpu saturation",
"payment gateway errors",
"payment latency high",
"prom uptime probe",
]
# state display priority: inactive (rank 1) outranks disabled (rank 0);
# the four inactive rules tie on state and must break on name asc
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"sort": "state", "order": "desc"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
assert [rule["alert"] for rule in response.json()["data"]["rules"]] == [
"infra cpu saturation",
"payment gateway errors",
"payment latency high",
"prom uptime probe",
"checkout conversion drop",
]
# asc flips the state buckets but the name tiebreak stays ascending
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"sort": "state", "order": "asc"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
assert [rule["alert"] for rule in response.json()["data"]["rules"]] == [
"checkout conversion drop",
"infra cpu saturation",
"payment gateway errors",
"payment latency high",
"prom uptime probe",
]
# severity: known ranks first (critical > warning), then custom values
# lexically, then rules without severity tie and break on name asc
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"sort": "severity", "order": "desc"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
assert [rule["alert"] for rule in response.json()["data"]["rules"]] == [
"payment latency high",
"payment gateway errors",
"checkout conversion drop",
"infra cpu saturation",
"prom uptime probe",
]
for order in ("asc", "desc"):
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"sort": "created_at", "order": order},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
created_ats = [rule["createdAt"] for rule in response.json()["data"]["rules"]]
assert created_ats == sorted(created_ats, reverse=order == "desc"), f"created_at {order} not monotonic"
def test_pagination(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
seed_alert_rules: Callable[[dict, list[dict]], None],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
seed_alert_rules(SEED_CHANNEL, SEED_RULES)
pages = []
for offset in (0, 2, 4):
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"sort": "name", "order": "asc", "limit": 2, "offset": offset},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
data = response.json()["data"]
assert data["total"] == 5, f"offset {offset}: total must stay the full filtered count"
pages.append([rule["alert"] for rule in data["rules"]])
assert [len(page) for page in pages] == [2, 2, 1]
flattened = [name for page in pages for name in page]
assert len(flattened) == len(set(flattened)), "pages must be disjoint"
assert set(flattened) == {r["alert"] for r in SEED_RULES}
# state sort is almost all ties (four inactive rules); the name/id tiebreak
# must keep the pages disjoint and in the same order on every request
tie_pages = []
for offset in (0, 2, 4):
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"sort": "state", "order": "desc", "limit": 2, "offset": offset},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
tie_pages.append([rule["alert"] for rule in response.json()["data"]["rules"]])
assert [name for page in tie_pages for name in page] == [
"infra cpu saturation",
"payment gateway errors",
"payment latency high",
"prom uptime probe",
"checkout conversion drop",
], "tied rows must not shuffle between page requests"
# a past-the-end offset returns an empty page but keeps the real total
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"limit": 2, "offset": 50},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
data = response.json()["data"]
assert data["rules"] == []
assert data["total"] == 5
# an over-max limit is clamped, not rejected
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"limit": 6000},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["total"] == 5
def test_error_contract(
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)
cases = [
({"query": "created_by ==== ((("}, "rule_list_filter_invalid", "invalid filter query:"),
({"query": "team = 'infra'"}, "rule_list_filter_invalid", 'unknown filter key "team"'),
({"query": "state = 'firing'"}, "rule_list_filter_invalid", 'unknown filter key "state"'),
({"query": "alert_type = 'bogus'"}, "rule_list_filter_invalid", "METRIC_BASED_ALERT"),
({"query": "name REGEXP 'x.*'"}, "rule_list_filter_invalid", "operator REGEXP is not allowed"),
({"query": "created_at >= 'yesterday'"}, "rule_list_filter_invalid", "invalid RFC3339 timestamp"),
({"query": "name LIKE 'prod\\\\'"}, "rule_list_filter_invalid", "must not end with an unescaped backslash"),
({"states": ["bogus"]}, "rule_list_invalid", 'invalid state "bogus"'),
({"sort": "bogus"}, "rule_list_invalid", "invalid sort"),
({"order": "bogus"}, "rule_list_invalid", "invalid order"),
({"limit": -1}, "rule_list_invalid", "invalid limit"),
({"offset": -1}, "rule_list_invalid", "invalid offset"),
]
for params, expected_code, expected_message_part in cases:
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params=params,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, f"params {params!r}: {response.text}"
error = response.json()["error"]
assert error["code"] == expected_code, f"params {params!r}"
assert expected_message_part in error["message"], f"params {params!r}: {error['message']}"
def test_v2_list_still_serves_bare_array(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
seed_alert_rules: Callable[[dict, list[dict]], None],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
seed_alert_rules(SEED_CHANNEL, SEED_RULES)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/rules"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
data = response.json()["data"]
assert isinstance(data, list), "deprecated v2 must keep returning a bare array"
assert {rule["alert"] for rule in data} == {r["alert"] for r in SEED_RULES}

View File

@@ -1,283 +0,0 @@
import uuid
from collections.abc import Callable
from http import HTTPStatus
import pytest
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import Operation, SigNoz
BASE_URL = "/api/v2/rule_views"
@pytest.mark.parametrize(
("body", "expected_message"),
[
({"data": {"version": "v1"}}, "name is required"),
({"name": " ", "data": {"version": "v1"}}, "name is required"),
(
{"name": " Storage ", "data": {"version": "v1"}},
"name must not have leading or trailing whitespace",
),
(
{"name": "x" * 65, "data": {"version": "v1"}},
"name must be at most 64 characters, got 65",
),
(
{"name": "wrong-version", "data": {"version": "v2"}},
'version must be "v1", got "v2"',
),
(
{"name": "missing-version", "data": {}},
'version must be "v1", got ""',
),
(
{"name": "bad-state", "data": {"version": "v1", "states": ["exploding"]}},
"invalid states",
),
(
{"name": "bad-sort", "data": {"version": "v1", "sort": "bogus"}},
"invalid sort",
),
(
{"name": "bad-order", "data": {"version": "v1", "order": "bogus"}},
"invalid order",
),
(
{"name": "long-query", "data": {"version": "v1", "query": "x" * 1025}},
"query cannot be longer than 1024 characters",
),
(
{"name": "rejects-unknown", "data": {"version": "v1"}, "unknownfield": "boom"},
"invalid saved view request body",
),
],
ids=[
"missing_name",
"blank_name",
"whitespace_name",
"name_too_long",
"wrong_schema_version",
"missing_version",
"invalid_state",
"invalid_sort",
"invalid_order",
"query_too_long",
"unknown_field",
],
)
def test_create_rejects_invalid_body(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
body: dict,
expected_message: str,
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=body,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "rule_view_invalid_input"
assert expected_message in response.json()["error"]["message"]
def test_update_rejects_malformed_id(
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)
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/not-a-uuid"),
json={"name": "x", "data": {"version": "v1"}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
def test_update_missing_view_returns_not_found(
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)
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
json={"name": "x", "data": {"version": "v1"}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.json()["error"]["code"] == "rule_view_not_found"
def test_delete_rejects_malformed_id(
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)
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/not-a-uuid"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
def test_delete_missing_view_returns_not_found(
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)
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.json()["error"]["code"] == "rule_view_not_found"
def test_rule_view_lifecycle(
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)
# The DB is reused across runs, so wipe every view first; this test then
# owns the whole view space and asserts on global counts.
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
for view in response.json()["data"]["views"]:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"name": "Critical Prod",
"data": {
"version": "v1",
"query": "name CONTAINS 'prod' AND severity = 'critical'",
"states": ["firing", "pending"],
"sort": "name",
"order": "asc",
},
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
created = response.json()["data"]
view_id = created["id"]
assert created["name"] == "Critical Prod"
assert created["data"]["version"] == "v1"
assert created["data"]["query"] == "name CONTAINS 'prod' AND severity = 'critical'"
assert created["data"]["states"] == ["firing", "pending"]
# Zero sort and order are normalized to the list defaults on save.
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={"name": "Disabled", "data": {"version": "v1", "states": ["disabled"]}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
disabled = response.json()["data"]
assert disabled["name"] == "Disabled"
assert disabled["data"]["states"] == ["disabled"]
assert disabled["data"]["sort"] == "updated_at"
assert disabled["data"]["order"] == "desc"
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
views = response.json()["data"]["views"]
assert len(views) == 2
assert {v["name"] for v in views} == {"Critical Prod", "Disabled"}
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
json={
"name": "Critical Staging",
"data": {
"version": "v1",
"query": "name CONTAINS 'staging'",
"states": ["firing"],
"sort": "created_at",
"order": "desc",
},
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
updated = response.json()["data"]
assert updated["id"] == view_id
assert updated["name"] == "Critical Staging"
assert updated["data"]["query"] == "name CONTAINS 'staging'"
assert updated["data"]["states"] == ["firing"]
assert updated["data"]["sort"] == "created_at"
assert updated["data"]["order"] == "desc"
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
listed = {v["name"]: v for v in response.json()["data"]["views"]}
assert set(listed) == {"Critical Staging", "Disabled"}
assert listed["Critical Staging"]["data"]["query"] == "name CONTAINS 'staging'"
assert (
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NO_CONTENT
)
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert {v["name"] for v in response.json()["data"]["views"]} == {"Disabled"}
assert (
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NOT_FOUND
)

View File

@@ -41,7 +41,7 @@ def test_create_groups_and_simulate_with_backfill(
},
json={
"name": "llm-backfill",
"condition": {"attributes": ["model"], "resource": []},
"condition": {"attributes": [{"value": "model", "enabled": True}], "resource": []},
"enabled": True,
},
)
@@ -69,6 +69,7 @@ def test_create_groups_and_simulate_with_backfill(
"context": "attribute",
"operation": "copy",
"priority": 1,
"enabled": True,
}
]
},
@@ -126,13 +127,13 @@ def test_create_groups_and_simulate_with_backfill(
# No "mappers" key: the server backfills them from the saved group.
{
"name": "llm-backfill",
"condition": {"attributes": ["model"], "resource": []},
"condition": {"attributes": [{"value": "model", "enabled": True}], "resource": []},
"enabled": True,
},
# Unsaved group; mappers provided inline.
{
"name": "db-inline",
"condition": {"attributes": ["db"], "resource": []},
"condition": {"attributes": [{"value": "db", "enabled": True}], "resource": []},
"enabled": True,
"mappers": [
{
@@ -145,6 +146,7 @@ def test_create_groups_and_simulate_with_backfill(
"context": "attribute",
"operation": "move",
"priority": 1,
"enabled": True,
}
]
},

View File

@@ -0,0 +1,152 @@
from collections.abc import Callable
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
GROUPS_PATH = "/api/v1/span_mapper_groups"
def test_default_groups_are_seeded_and_shipped_items_are_toggle_only(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""
Setup:
A fresh org. The reconciler seeds the shipped mapping groups at startup
and on org creation, so nothing has to be created here.
Tests:
1. The list contains llm, agent and tool as system groups with shipped
substrings
2. Shipped mappers and their sources are system-owned and enabled
3. A shipped name cannot be taken by a user group, and system groups and
mappers cannot be deleted
4. A shipped source can be switched off and a user override added; both
round-trip through PATCH and the simulator honours them
5. A shipped substring can be switched off and a user one added
"""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"authorization": f"Bearer {token}", "content-type": "application/json"}
list_groups = requests.get(signoz.self.host_configs["8080"].get(GROUPS_PATH), timeout=10, headers=headers)
assert list_groups.status_code == HTTPStatus.OK
groups = {g["name"]: g for g in list_groups.json()["data"]["items"]}
assert {"llm", "agent", "tool"} <= set(groups)
for name in ("llm", "agent", "tool"):
assert groups[name]["origin"] == "system"
assert groups[name]["version"] >= 1
assert groups[name]["createdBy"] == "signoz"
llm = groups["llm"]
assert llm["condition"]["attributes"] == [{"value": "model", "enabled": True, "origin": "system"}]
list_mappers = requests.get(signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}/span_mappers"), timeout=10, headers=headers)
assert list_mappers.status_code == HTTPStatus.OK
mappers = {m["name"]: m for m in list_mappers.json()["data"]["items"]}
model = mappers["gen_ai.request.model"]
assert model["origin"] == "system"
assert model["enabled"] is True
assert all(s["origin"] == "system" and s["enabled"] is True for s in model["config"]["sources"])
assert "llm.model_name" in [s["key"] for s in model["config"]["sources"]]
reserved = requests.post(
signoz.self.host_configs["8080"].get(GROUPS_PATH),
timeout=10,
headers=headers,
json={"name": "tool", "condition": {"attributes": [{"value": "tool", "enabled": True}], "resource": []}, "enabled": True},
)
assert reserved.status_code == HTTPStatus.BAD_REQUEST
assert reserved.json()["error"]["code"] == "span_attribute_mapping_group_name_reserved"
delete_group = requests.delete(signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}"), timeout=10, headers=headers)
assert delete_group.status_code == HTTPStatus.BAD_REQUEST
assert delete_group.json()["error"]["code"] == "span_attribute_mapping_group_not_deletable"
delete_mapper = requests.delete(signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}/span_mappers/{model['id']}"), timeout=10, headers=headers)
assert delete_mapper.status_code == HTTPStatus.BAD_REQUEST
assert delete_mapper.json()["error"]["code"] == "span_attribute_mapper_not_deletable"
# Switch the shipped llm.model_name source off and re-add it as a user move.
sources = [{**s, "enabled": s["key"] != "llm.model_name"} for s in model["config"]["sources"]] + [{"key": "llm.model_name", "context": "attribute", "operation": "move", "priority": 1, "enabled": True}]
patch_mapper = requests.patch(
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}/span_mappers/{model['id']}"),
timeout=10,
headers=headers,
json={"config": {"sources": sources}},
)
assert patch_mapper.status_code == HTTPStatus.NO_CONTENT
list_mappers = requests.get(signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}/span_mappers"), timeout=10, headers=headers)
updated = {m["name"]: m for m in list_mappers.json()["data"]["items"]}["gen_ai.request.model"]
by_origin = {(s["key"], s["origin"]): s for s in updated["config"]["sources"]}
assert by_origin[("llm.model_name", "system")]["enabled"] is False
assert by_origin[("llm.model_name", "user")]["operation"] == "move"
assert len(updated["config"]["sources"]) == len(model["config"]["sources"]) + 1
# The user override wins: the source is moved, not copied.
simulate = requests.post(
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/test"),
timeout=10,
headers=headers,
json={
"spans": [{"attributes": {"llm.model_name": "gpt-4o"}, "resource": {}}],
"groups": [{"name": "llm", "condition": llm["condition"], "enabled": True}],
},
)
assert simulate.status_code == HTTPStatus.OK
attrs = simulate.json()["data"]["spans"][0]["attributes"]
assert attrs["gen_ai.request.model"] == "gpt-4o"
assert "llm.model_name" not in attrs
# A shipped substring that does not exist is rejected; toggling one is not.
bad_condition = requests.patch(
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}"),
timeout=10,
headers=headers,
json={"condition": {"attributes": [{"value": "nope", "enabled": True, "origin": "system"}], "resource": []}},
)
assert bad_condition.status_code == HTTPStatus.BAD_REQUEST
patch_group = requests.patch(
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}"),
timeout=10,
headers=headers,
json={
"condition": {
"attributes": [
{"value": "model", "enabled": False, "origin": "system"},
{"value": "gen_ai.request.model", "enabled": True},
],
"resource": [],
}
},
)
assert patch_group.status_code == HTTPStatus.NO_CONTENT
list_groups = requests.get(signoz.self.host_configs["8080"].get(GROUPS_PATH), timeout=10, headers=headers)
llm_after = {g["name"]: g for g in list_groups.json()["data"]["items"]}["llm"]
assert llm_after["condition"]["attributes"] == [
{"value": "model", "enabled": False, "origin": "system"},
{"value": "gen_ai.request.model", "enabled": True, "origin": "user"},
]
assert llm_after["origin"] == "system"
assert llm_after["name"] == "llm"
# Leave the shipped group as seeded for the other suites.
restore_group = requests.patch(
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}"),
timeout=10,
headers=headers,
json={"condition": {"attributes": [{"value": "model", "enabled": True, "origin": "system"}], "resource": []}},
)
assert restore_group.status_code == HTTPStatus.NO_CONTENT
restore_mapper = requests.patch(
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}/span_mappers/{model['id']}"),
timeout=10,
headers=headers,
json={"config": {"sources": model["config"]["sources"]}},
)
assert restore_mapper.status_code == HTTPStatus.NO_CONTENT