Compare commits

..

1 Commits

Author SHA1 Message Date
srikanthccv
6218e27ec2 chore(ruletypes): update rule schema with required fields and remove v1 fields 2026-07-14 14:46:16 +05:30
42 changed files with 236 additions and 1462 deletions

View File

@@ -57,7 +57,6 @@ jobs:
- role
- rootuser
- serviceaccount
- spanmapper
- querier_json_body
- querier_skip_resource_fingerprint
- ttl

View File

@@ -7415,6 +7415,7 @@ components:
queries:
items:
$ref: '#/components/schemas/Querybuildertypesv5QueryEnvelope'
minItems: 1
nullable: true
type: array
queryType:
@@ -7601,35 +7602,28 @@ components:
type: string
disabled:
type: boolean
evalWindow:
type: string
evaluation:
$ref: '#/components/schemas/RuletypesEvaluationEnvelope'
frequency:
type: string
labels:
additionalProperties:
type: string
type: object
notificationSettings:
$ref: '#/components/schemas/RuletypesNotificationSettings'
preferredChannels:
items:
type: string
type: array
ruleType:
$ref: '#/components/schemas/RuletypesRuleType'
schemaVersion:
type: string
source:
type: string
version:
enum:
- v2alpha1
type: string
required:
- alert
- alertType
- ruleType
- condition
- schemaVersion
- evaluation
- notificationSettings
type: object
RuletypesQueryType:
enum:
@@ -7679,12 +7673,8 @@ components:
type: string
disabled:
type: boolean
evalWindow:
type: string
evaluation:
$ref: '#/components/schemas/RuletypesEvaluationEnvelope'
frequency:
type: string
id:
type: string
labels:
@@ -7693,15 +7683,11 @@ components:
type: object
notificationSettings:
$ref: '#/components/schemas/RuletypesNotificationSettings'
preferredChannels:
items:
type: string
type: array
ruleType:
$ref: '#/components/schemas/RuletypesRuleType'
schemaVersion:
type: string
source:
enum:
- v2alpha1
type: string
state:
$ref: '#/components/schemas/RuletypesAlertState'
@@ -7710,8 +7696,6 @@ components:
type: string
updatedBy:
type: string
version:
type: string
required:
- id
- state
@@ -7719,6 +7703,9 @@ components:
- alertType
- ruleType
- condition
- schemaVersion
- evaluation
- notificationSettings
type: object
RuletypesRuleCondition:
properties:
@@ -7731,10 +7718,6 @@ components:
type: string
compositeQuery:
$ref: '#/components/schemas/RuletypesAlertCompositeQuery'
matchType:
$ref: '#/components/schemas/RuletypesMatchType'
op:
$ref: '#/components/schemas/RuletypesCompareOperator'
requireMinPoints:
type: boolean
requiredNumPoints:
@@ -7743,16 +7726,12 @@ components:
$ref: '#/components/schemas/RuletypesSeasonality'
selectedQueryName:
type: string
target:
format: double
nullable: true
type: number
targetUnit:
type: string
thresholds:
$ref: '#/components/schemas/RuletypesRuleThresholdData'
required:
- compositeQuery
- thresholds
- selectedQueryName
type: object
RuletypesRuleThresholdData:
discriminator:
@@ -8048,28 +8027,6 @@ components:
required:
- items
type: object
SpantypesGettableSpanMapperTest:
properties:
collectorLogs:
items:
type: string
nullable: true
type: array
spans:
items:
$ref: '#/components/schemas/SpantypesSpanMapperTestSpan'
nullable: true
type: array
type: object
SpantypesGettableSpanMappers:
properties:
items:
items:
$ref: '#/components/schemas/SpantypesSpanMapper'
type: array
required:
- items
type: object
SpantypesGettableTraceAggregations:
properties:
aggregations:
@@ -8157,39 +8114,6 @@ components:
- name
- condition
type: object
SpantypesPostableSpanMapperTest:
properties:
groups:
items:
$ref: '#/components/schemas/SpantypesPostableSpanMapperTestGroup'
nullable: true
type: array
spans:
items:
$ref: '#/components/schemas/SpantypesSpanMapperTestSpan'
nullable: true
type: array
required:
- spans
- groups
type: object
SpantypesPostableSpanMapperTestGroup:
properties:
condition:
$ref: '#/components/schemas/SpantypesSpanMapperGroupCondition'
enabled:
type: boolean
mappers:
items:
$ref: '#/components/schemas/SpantypesPostableSpanMapper'
nullable: true
type: array
name:
type: string
required:
- name
- condition
type: object
SpantypesPostableTraceAggregations:
properties:
aggregations:
@@ -8255,7 +8179,7 @@ components:
type: boolean
fieldContext:
$ref: '#/components/schemas/SpantypesFieldContext'
groupId:
group_id:
type: string
id:
type: string
@@ -8268,7 +8192,7 @@ components:
type: string
required:
- id
- groupId
- group_id
- name
- fieldContext
- config
@@ -8351,17 +8275,6 @@ components:
- operation
- priority
type: object
SpantypesSpanMapperTestSpan:
properties:
attributes:
additionalProperties: {}
nullable: true
type: object
resource:
additionalProperties: {}
nullable: true
type: object
type: object
SpantypesUpdatableSpanMapper:
properties:
config:
@@ -13858,7 +13771,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableSpanMappers'
$ref: '#/components/schemas/SpantypesGettableSpanMapperGroups'
status:
type: string
required:
@@ -14091,69 +14004,6 @@ paths:
summary: Update a span mapper
tags:
- spanmapper
/api/v1/span_mapper_groups/test:
post:
deprecated: false
description: Tests how span mappers would transform sample spans
operationId: TestSpanMappers
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SpantypesPostableSpanMapperTest'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableSpanMapperTest'
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: Test span mappers against sample spans
tags:
- spanmapper
/api/v1/stats:
get:
deprecated: false

View File

@@ -9258,118 +9258,6 @@ export interface SpantypesGettableSpanMapperGroupsDTO {
items: SpantypesSpanMapperGroupDTO[];
}
export type SpantypesSpanMapperTestSpanDTOAttributesAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMapperTestSpanDTOAttributes =
SpantypesSpanMapperTestSpanDTOAttributesAnyOf | null;
export type SpantypesSpanMapperTestSpanDTOResourceAnyOf = {
[key: string]: unknown;
};
/**
* @nullable
*/
export type SpantypesSpanMapperTestSpanDTOResource =
SpantypesSpanMapperTestSpanDTOResourceAnyOf | null;
export interface SpantypesSpanMapperTestSpanDTO {
/**
* @type object,null
*/
attributes?: SpantypesSpanMapperTestSpanDTOAttributes;
/**
* @type object,null
*/
resource?: SpantypesSpanMapperTestSpanDTOResource;
}
export interface SpantypesGettableSpanMapperTestDTO {
/**
* @type array,null
*/
collectorLogs?: string[] | null;
/**
* @type array,null
*/
spans?: SpantypesSpanMapperTestSpanDTO[] | null;
}
export enum SpantypesSpanMapperOperationDTO {
move = 'move',
copy = 'copy',
}
export interface SpantypesSpanMapperSourceDTO {
context: SpantypesFieldContextDTO;
/**
* @type string
*/
key: string;
operation: SpantypesSpanMapperOperationDTO;
/**
* @type integer
*/
priority: number;
}
export interface SpantypesSpanMapperConfigDTO {
/**
* @type array,null
*/
sources: SpantypesSpanMapperSourceDTO[] | null;
}
export interface SpantypesSpanMapperDTO {
config: SpantypesSpanMapperConfigDTO;
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type boolean
*/
enabled: boolean;
fieldContext: SpantypesFieldContextDTO;
/**
* @type string
*/
groupId: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface SpantypesGettableSpanMappersDTO {
/**
* @type array
*/
items: SpantypesSpanMapperDTO[];
}
export enum SpantypesSpanAggregationTypeDTO {
span_count = 'span_count',
execution_time_percentage = 'execution_time_percentage',
@@ -9616,6 +9504,30 @@ export interface SpantypesPostableFlamegraphDTO {
selectedSpanId?: string;
}
export enum SpantypesSpanMapperOperationDTO {
move = 'move',
copy = 'copy',
}
export interface SpantypesSpanMapperSourceDTO {
context: SpantypesFieldContextDTO;
/**
* @type string
*/
key: string;
operation: SpantypesSpanMapperOperationDTO;
/**
* @type integer
*/
priority: number;
}
export interface SpantypesSpanMapperConfigDTO {
/**
* @type array,null
*/
sources: SpantypesSpanMapperSourceDTO[] | null;
}
export interface SpantypesPostableSpanMapperDTO {
config: SpantypesSpanMapperConfigDTO;
/**
@@ -9641,33 +9553,6 @@ export interface SpantypesPostableSpanMapperGroupDTO {
name: string;
}
export interface SpantypesPostableSpanMapperTestGroupDTO {
condition: SpantypesSpanMapperGroupConditionDTO | null;
/**
* @type boolean
*/
enabled?: boolean;
/**
* @type array,null
*/
mappers?: SpantypesPostableSpanMapperDTO[] | null;
/**
* @type string
*/
name: string;
}
export interface SpantypesPostableSpanMapperTestDTO {
/**
* @type array,null
*/
groups: SpantypesPostableSpanMapperTestGroupDTO[] | null;
/**
* @type array,null
*/
spans: SpantypesSpanMapperTestSpanDTO[] | null;
}
export interface SpantypesSpanAggregationDTO {
aggregation: SpantypesSpanAggregationTypeDTO;
field: TelemetrytypesTelemetryFieldKeyDTO;
@@ -9691,6 +9576,45 @@ export interface SpantypesPostableWaterfallDTO {
uncollapsedSpans?: string[] | null;
}
export interface SpantypesSpanMapperDTO {
config: SpantypesSpanMapperConfigDTO;
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type boolean
*/
enabled: boolean;
fieldContext: SpantypesFieldContextDTO;
/**
* @type string
*/
group_id: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface SpantypesUpdatableSpanMapperDTO {
config?: SpantypesSpanMapperConfigDTO;
/**
@@ -10992,7 +10916,7 @@ export type ListSpanMappersPathParameters = {
groupId: string;
};
export type ListSpanMappers200 = {
data: SpantypesGettableSpanMappersDTO;
data: SpantypesGettableSpanMapperGroupsDTO;
/**
* @type string
*/
@@ -11018,14 +10942,6 @@ export type UpdateSpanMapperPathParameters = {
groupId: string;
mapperId: string;
};
export type TestSpanMappers200 = {
data: SpantypesGettableSpanMapperTestDTO;
/**
* @type string
*/
status: string;
};
export type GetStats200Data = { [key: string]: unknown };
export type GetStats200 = {

View File

@@ -30,10 +30,8 @@ import type {
RenderErrorResponseDTO,
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesPostableSpanMapperTestDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
TestSpanMappers200,
UpdateSpanMapperGroupPathParameters,
UpdateSpanMapperPathParameters,
} from '../sigNoz.schemas';
@@ -782,86 +780,3 @@ export const useUpdateSpanMapper = <
> => {
return useMutation(getUpdateSpanMapperMutationOptions(options));
};
/**
* Tests how span mappers would transform sample spans
* @summary Test span mappers against sample spans
*/
export const testSpanMappers = (
spantypesPostableSpanMapperTestDTO?: BodyType<SpantypesPostableSpanMapperTestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<TestSpanMappers200>({
url: `/api/v1/span_mapper_groups/test`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: spantypesPostableSpanMapperTestDTO,
signal,
});
};
export const getTestSpanMappersMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
> => {
const mutationKey = ['testSpanMappers'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof testSpanMappers>>,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> }
> = (props) => {
const { data } = props ?? {};
return testSpanMappers(data);
};
return { mutationFn, ...mutationOptions };
};
export type TestSpanMappersMutationResult = NonNullable<
Awaited<ReturnType<typeof testSpanMappers>>
>;
export type TestSpanMappersMutationBody =
| BodyType<SpantypesPostableSpanMapperTestDTO>
| undefined;
export type TestSpanMappersMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Test span mappers against sample spans
*/
export const useTestSpanMappers = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof testSpanMappers>>,
TError,
{ data?: BodyType<SpantypesPostableSpanMapperTestDTO> },
TContext
> => {
return useMutation(getTestSpanMappersMutationOptions(options));
};

View File

@@ -139,31 +139,21 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
</ColumnHeader>
),
accessorFn: (row): number => row.currentNodes,
width: { min: 210 },
width: { min: 180 },
enableSort: false,
enableResize: true,
cell: ({ row }): React.ReactNode => (
<GroupedStatusCounts
items={[
{
value: row.readyNodes,
label: 'Ready',
color: Color.BG_FOREST_500,
},
{
value: row.currentNodes,
label: 'Current',
color: Color.BG_ROBIN_500,
color: Color.BG_FOREST_500,
},
{
value: row.desiredNodes,
label: 'Desired',
color: Color.BG_SAKURA_400,
},
{
value: row.misscheduledNodes,
label: 'Misscheduled',
color: Color.BG_AMBER_500,
color: Color.BG_ROBIN_500,
},
]}
/>
@@ -322,30 +312,6 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
);
},
},
{
id: 'ready_nodes',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#ready">
Ready Nodes
</ColumnHeader>
),
accessorFn: (row): number => row.readyNodes,
width: { min: 140 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const readyNodes = value as number;
return (
<ValidateColumnValueWrapper
value={readyNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="ready node"
>
<TanStackTable.Text>{readyNodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'current_nodes',
header: (): React.ReactNode => (
@@ -394,28 +360,4 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
);
},
},
{
id: 'misscheduled_nodes',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#misscheduled">
Misscheduled Nodes
</ColumnHeader>
),
accessorFn: (row): number => row.misscheduledNodes,
width: { min: 140 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const misscheduledNodes = value as number;
return (
<ValidateColumnValueWrapper
value={misscheduledNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="misscheduled node"
>
<TanStackTable.Text>{misscheduledNodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];

View File

@@ -30,7 +30,7 @@ export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
return {
id: 'mapper-1',
groupId: 'group-1',
group_id: 'group-1',
name: 'gen_ai.request.model',
enabled: true,
fieldContext: FieldContext.attribute,
@@ -89,5 +89,5 @@ export const mockGroups: MapperGroup[] = [
];
export const mockMappers: Mapper[] = [
makeMapper({ id: 'mapper-1', groupId: 'group-1' }),
makeMapper({ id: 'mapper-1', group_id: 'group-1' }),
];

View File

@@ -4,8 +4,6 @@ export function useIntersectionObserver<T extends HTMLElement>(
ref: RefObject<T>,
options?: IntersectionObserverInit,
isObserverOnce?: boolean,
/** Defer observation by this many ms to let a transient mount layout settle. */
startDelayMs = 0,
): boolean {
const [isIntersecting, setIntersecting] = useState(false);
@@ -25,28 +23,16 @@ export function useIntersectionObserver<T extends HTMLElement>(
}
}, options);
const startObserving = (): void => {
if (currentReference) {
observer.observe(currentReference);
}
};
let timer: ReturnType<typeof setTimeout> | undefined;
if (startDelayMs > 0) {
timer = setTimeout(startObserving, startDelayMs);
} else {
startObserving();
if (currentReference) {
observer.observe(currentReference);
}
return (): void => {
if (timer) {
clearTimeout(timer);
}
if (currentReference) {
observer.unobserve(currentReference);
}
};
}, [ref, options, isObserverOnce, startDelayMs]);
}, [ref, options, isObserverOnce]);
return isIntersecting;
}

View File

@@ -92,7 +92,6 @@ function Panel({
panelId={panelId}
data={data}
isFetching={isFetching}
isVisible={isVisible}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}

View File

@@ -23,8 +23,6 @@ interface PanelBodyProps {
panelId: string;
data: PanelQueryData;
isFetching: boolean;
/** Panel not yet scrolled into view — its fetch is deferred, so show the loader rather than NoData. */
isVisible?: boolean;
/** Showing a prior page's data while the next loads; forwarded so list renderers can show skeletons. */
isPreviousData?: boolean;
error: Error | null;
@@ -56,7 +54,6 @@ function PanelBody({
panelId,
data,
isFetching,
isVisible,
isPreviousData,
error,
refetch,
@@ -108,9 +105,9 @@ function PanelBody({
);
}
// Full-panel loader on first fetch or while the fetch is deferred (off-screen); a refetch
// over existing data keeps the renderer mounted, empty data loads via NoData.
if ((isFetching || isVisible === false) && !hasData) {
// Full-panel loader only on first fetch; a refetch over existing data keeps the renderer
// mounted (e.g. list page change). A refetch over empty data loads via NoData.
if (isFetching && !hasData) {
return <PanelLoader />;
}

View File

@@ -79,21 +79,6 @@ describe('PanelBody', () => {
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
});
it('shows the loader while the fetch is deferred (panel not yet scrolled into view)', () => {
render(
<PanelBody
{...baseProps}
panel={runnablePanel()}
data={{} as PanelQueryData}
isFetching={false}
isVisible={false}
/>,
);
expect(screen.getByTestId('panel-loading')).toBeInTheDocument();
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
});
it('keeps the renderer mounted during a refetch over existing data (e.g. list page change)', () => {
render(
<PanelBody

View File

@@ -113,8 +113,8 @@ function ViewPanelModalHeader({
/>
<Button
size="icon"
variant="outlined"
color="secondary"
variant="solid"
color="primary"
onClick={onRefresh}
disabled={isFetching}
aria-label="Refresh"

View File

@@ -22,7 +22,6 @@ function SectionGrid({
sections,
}: SectionGridProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const rglLayout = useMemo<Layout[]>(
() =>
items.map((item) => ({

View File

@@ -10,10 +10,6 @@ const VIEWPORT_OBSERVER_OPTIONS: IntersectionObserverInit = {
rootMargin: '200px',
};
// Start observing after RGL's mount unfold settles, so a panel that only
// transiently overlaps the viewport during layout doesn't fire a throwaway fetch.
const OBSERVER_START_DELAY_MS = 350;
interface SectionGridItemProps {
panel: DashboardtypesPanelDTO;
panelId: string;
@@ -21,9 +17,9 @@ interface SectionGridItemProps {
}
/**
* Lazy-loads a single panel: tracks its live viewport intersection and passes it to
* the presentational Panel as `isVisible`, so a board of many panels only fetches
* (and refetches on time change / auto-refresh) what's on screen.
* Lazy-loads a single panel: watches its own viewport intersection (latched) and
* passes it to the presentational Panel as `isVisible`, so a board of many panels
* only fetches what's on screen.
*/
function SectionGridItem({
panel,
@@ -34,10 +30,7 @@ function SectionGridItem({
const isVisible = useIntersectionObserver(
containerRef,
VIEWPORT_OBSERVER_OPTIONS,
// Not once: track the live viewport so a time change / auto-refresh only
// refetches on-screen panels (off-screen ones stay query-disabled).
false,
OBSERVER_START_DELAY_MS,
true,
);
useScrollIntoView(panelId, containerRef);

View File

@@ -23,7 +23,7 @@ describe('useScrollIntoView', () => {
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'center',
block: 'start',
});
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
});
@@ -38,7 +38,7 @@ describe('useScrollIntoView', () => {
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'center',
block: 'start',
});
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
});

View File

@@ -9,7 +9,7 @@ import { useScrollIntoViewStore } from '../../../store/useScrollIntoViewStore';
export function useScrollIntoView(
id: string,
ref: RefObject<HTMLElement>,
block: ScrollLogicalPosition = 'center',
block: ScrollLogicalPosition = 'start',
): void {
const scrollTargetId = useScrollIntoViewStore((s) => s.scrollTargetId);
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);

View File

@@ -57,8 +57,5 @@ export function useGetQueryRangeV5({
retry: retryUnlessClientError,
keepPreviousData,
cacheTime,
// A resolved window is immutable per key, so a panel scrolled back into view
// serves cache instead of refetching; a key change or manual refetch still runs.
staleTime: Infinity,
});
}

View File

@@ -107,9 +107,6 @@ export function ContextMenu({
}
}}
trigger="click"
// Anchor to body (like the backdrop), not the host container: a modal's
// transformed dialog would break `position: fixed` and trap the menu below it.
getPopupContainer={(): HTMLElement => document.body}
overlayStyle={{
position: 'fixed',
left: position.left,

View File

@@ -77,11 +77,6 @@
color: var(--muted-foreground);
}
// Body-portaled overlay: stay clickable when a modal sets `body { pointer-events: none }`.
.context-menu {
pointer-events: auto;
}
.context-menu .ant-popover-inner {
padding: 0;
border-radius: 6px;

8
go.mod
View File

@@ -9,7 +9,7 @@ require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/SigNoz/clickhouse-go-mock v0.14.0
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd
github.com/SigNoz/signoz-otel-collector v0.144.6
github.com/SigNoz/signoz-otel-collector v0.144.3
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/bytedance/sonic v1.14.1
@@ -83,7 +83,7 @@ require (
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.52.0
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
golang.org/x/net v0.55.0
golang.org/x/net v0.54.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.20.0
golang.org/x/text v0.37.0
@@ -150,6 +150,10 @@ require (
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/collector/client v1.54.0 // indirect
go.opentelemetry.io/collector/config/configoptional v1.50.0 // indirect
go.opentelemetry.io/collector/config/configretry v1.50.0 // indirect
go.opentelemetry.io/collector/exporter/exporterhelper v0.144.0 // indirect
go.opentelemetry.io/collector/internal/componentalias v0.148.0 // indirect
go.opentelemetry.io/collector/pdata/xpdata v0.148.0 // indirect
go.uber.org/goleak v1.3.0 // indirect

29
go.sum
View File

@@ -110,13 +110,11 @@ github.com/SigNoz/expr v1.17.7-beta h1:FyZkleM5dTQ0O6muQfwGpoH5A2ohmN/XTasRCO72g
github.com/SigNoz/expr v1.17.7-beta/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd h1:Bk43AsDYe0fhkbj57eGXx8H3ZJ4zhmQXBnrW523ktj8=
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd/go.mod h1:nxRcH/OEdM8QxzH37xkGzomr1O0JpYBRS6pwjsWW6Pc=
github.com/SigNoz/signoz-otel-collector v0.144.6 h1:AgblCIDlLKGtssS4ywvyEByH9fbJ7hFqZvPvUVJKwp8=
github.com/SigNoz/signoz-otel-collector v0.144.6/go.mod h1:YRQw6ETaq7fm8ts4Itgb6wC0bVhc0+nC6PFI0MaiH9k=
github.com/SigNoz/signoz-otel-collector v0.144.3 h1:/7PPIqIpPsaWtrgnfHam2XVYP41ZlgEKLHzQO8oVxcA=
github.com/SigNoz/signoz-otel-collector v0.144.3/go.mod h1:9pLVpcIQvUT88ZiNnZN/MI+XLruvwC+Xm2UzPmGjNfA=
github.com/Yiling-J/theine-go v0.6.2 h1:1GeoXeQ0O0AUkiwj2S9Jc0Mzx+hpqzmqsJ4kIC4M9AY=
github.com/Yiling-J/theine-go v0.6.2/go.mod h1:08QpMa5JZ2pKN+UJCRrCasWYO1IKCdl54Xa836rpmDU=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U=
github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
@@ -126,10 +124,6 @@ github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vS
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/antchfx/xmlquery v1.5.0 h1:uAi+mO40ZWfyU6mlUBxRVvL6uBNZ6LMU4M3+mQIBV4c=
github.com/antchfx/xmlquery v1.5.0/go.mod h1:lJfWRXzYMK1ss32zm1GQV3gMIW/HFey3xDZmkP1SuNc=
github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
@@ -292,8 +286,6 @@ github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84=
github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
github.com/elastic/go-grok v0.3.1 h1:WEhUxe2KrwycMnlvMimJXvzRa7DoByJB4PVUIE1ZD/U=
github.com/elastic/go-grok v0.3.1/go.mod h1:n38ls8ZgOboZRgKcjMY8eFeZFMmcL9n2lP0iHhIDk64=
github.com/elastic/lunes v0.2.0 h1:WI3bsdOTuaYXVe2DS1KbqA7u7FOHN4o8qJw80ZyZoQs=
github.com/elastic/lunes v0.2.0/go.mod h1:u3W/BdONWTrh0JjNZ21C907dDc+cUZttZrGa625nf2k=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
@@ -469,7 +461,6 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
@@ -683,8 +674,6 @@ github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc=
github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE=
github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI=
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -877,18 +866,12 @@ github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal
github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal v0.144.0/go.mod h1:O2rZKRXk1WeYhzfJBVXES/g7+PlIds/TzPZW/4NfTNA=
github.com/open-telemetry/opentelemetry-collector-contrib/internal/exp/metrics v0.148.0 h1:CiTjQE/Hh5xK2t56ogrDK4nl0+tJPNmASCs4zEYZ/xU=
github.com/open-telemetry/opentelemetry-collector-contrib/internal/exp/metrics v0.148.0/go.mod h1:WUFkzTiOpt7EYyL67gv1GOf3RD8qKWGtin3lY9LYzW4=
github.com/open-telemetry/opentelemetry-collector-contrib/internal/filter v0.144.0 h1:Ywu5mU4K5TMJigiXdyZloCRs/cq3/2OnoK3WjxNHWJo=
github.com/open-telemetry/opentelemetry-collector-contrib/internal/filter v0.144.0/go.mod h1:iebqlu6UvpiV1hO37r1sXA9fXaCaA8sQXilG0///xss=
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl v0.144.0 h1:TMRTvQSAeeLtkKwSrqcbectxDRPiqB6yYM3IvjC75es=
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl v0.144.0/go.mod h1:1HU0qJ4hFrphDebuBs3I4DPQ6zyBFGinQ5/bXEUM7pw=
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatatest v0.148.0 h1:i12duJOl5VCb9mbb8FfZCaP2CjeXbNsbg82JjSe7sy8=
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatatest v0.148.0/go.mod h1:jyw+QvkmCrF/oYy31O2ndb5KZZK4l+iR89msnV3LN/k=
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatautil v0.148.0 h1:1TLg6YrS3Au6F7xw3ws2Njbwj13IMqPplvGFi+18fWs=
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatautil v0.148.0/go.mod h1:P8hZEDIQk4REgUWyLhSVRHwTxK6KkifKfg36BmmQ/DI=
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza v0.144.0 h1:EIAygME70IOdEwaSr6bA3Wcdp7hXEqRsGsVfrI5v8OA=
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza v0.144.0/go.mod h1:3Y6ctEEwRg19B0jqsrQH6Hiquqte+zC0ZxpXLLSa5sA=
github.com/open-telemetry/opentelemetry-collector-contrib/processor/attributesprocessor v0.144.0 h1:gRk73SsIJv3q/HI0kxMRN5TiIiJj+MRxrz0GEYx3jZw=
github.com/open-telemetry/opentelemetry-collector-contrib/processor/attributesprocessor v0.144.0/go.mod h1:7b8ZcPdN6JSm6+LUq3rienoqHHiX60kyoOHDPCMM+L8=
github.com/open-telemetry/opentelemetry-collector-contrib/processor/deltatocumulativeprocessor v0.148.0 h1:xgD/kNGp/wWY+bwY599Pc01OamYN17phRiTP934bM5Y=
github.com/open-telemetry/opentelemetry-collector-contrib/processor/deltatocumulativeprocessor v0.148.0/go.mod h1:ZK7wvaefla9lB3bAW0rNKt7IzRPcTRQoOFqr4sZy/XM=
github.com/open-telemetry/opentelemetry-collector-contrib/processor/logstransformprocessor v0.144.0 h1:HbmpzTixpQG/xGhQuQoiJTXQPrixe+yivAsF6tl2o4g=
@@ -1136,10 +1119,6 @@ github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GH
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg=
github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
github.com/ua-parser/uap-go v0.0.0-20240611065828-3a4781585db6 h1:SIKIoA4e/5Y9ZOl0DCe3eVMLPOQzJxgZpfdHHeauNTM=
github.com/ua-parser/uap-go v0.0.0-20240611065828-3a4781585db6/go.mod h1:BUbeWZiieNxAuuADTBNb3/aeje6on3DhU3rpWsQSB1E=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/uptrace/bun v1.2.9 h1:OOt2DlIcRUMSZPr6iXDFg/LaQd59kOxbAjpIVHddKRs=
@@ -1509,8 +1488,8 @@ golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View File

@@ -51,26 +51,6 @@ func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/span_mapper_groups/test", handler.New(
provider.authzMiddleware.ViewAccess(provider.spanMapperHandler.TestMappers),
handler.OpenAPIDef{
ID: "TestSpanMappers",
Tags: []string{"spanmapper"},
Summary: "Test span mappers against sample spans",
Description: "Tests how span mappers would transform sample spans",
Request: new(spantypes.PostableSpanMapperTest),
RequestContentType: "application/json",
Response: new(spantypes.GettableSpanMapperTest),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/span_mapper_groups/{groupId}", handler.New(
provider.authzMiddleware.AdminAccess(provider.spanMapperHandler.UpdateGroup),
handler.OpenAPIDef{
@@ -118,7 +98,7 @@ func (provider *provider) addSpanMapperRoutes(router *mux.Router) error {
Description: "Returns all mappers belonging to a mapping group.",
Request: nil,
RequestContentType: "",
Response: new(spantypes.GettableSpanMappers),
Response: new(spantypes.GettableSpanMapperGroups),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},

View File

@@ -20,21 +20,21 @@ func (c Compiled) IsEmpty() bool {
// 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 {
if len(query) == 0 {
return &Compiled{}, nil
}
sql, args, errs := newVisitor(formatter).compile(query)
if len(errs) > 0 {
queryVisitor := newVisitor(formatter)
sql, args, syntaxErrs := queryVisitor.compile(query)
if len(syntaxErrs) > 0 {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(errs, "; "))
"invalid filter query: %s", strings.Join(syntaxErrs, "; "))
}
if len(queryVisitor.errors) > 0 {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(queryVisitor.errors, "; "))
}
return &Compiled{

View File

@@ -460,83 +460,6 @@ func TestCompile_ComplexExamples(t *testing.T) {
})
}
func TestCompile_FreeText(t *testing.T) {
// freeTextSQL is the predicate every free-text query compiles to; only the
// bound pattern differs.
freeTextSQL := `
(
lower(COALESCE(json_extract("dashboard"."data", '$.spec.display.name'), '')) LIKE LOWER(?) ESCAPE '\'
OR lower(COALESCE(json_extract("dashboard"."data", '$.spec.display.description'), '')) LIKE LOWER(?) ESCAPE '\'
OR EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = ? AND tr.resource_id = dashboard.id
AND (lower(COALESCE(t.key, '')) LIKE LOWER(?) ESCAPE '\' OR lower(COALESCE(t.value, '')) LIKE LOWER(?) ESCAPE '\')
))`
freeTextArgs := func(pattern string) []any {
return []any{pattern, pattern, kindArg, pattern, pattern}
}
runCompileCases(t, []compileCase{
{
subtestName: "single bare word",
dslQueryToCompile: `payment`,
expectedSQL: freeTextSQL,
expectedArgs: freeTextArgs("%payment%"),
},
{
// consecutive words are implicit-AND per the grammar, so each is its
// own term; `"prod payment"` (below) is the way to match the phrase
subtestName: "words are separate terms AND'd together",
dslQueryToCompile: `prod payment`,
expectedSQL: "(" + freeTextSQL + " AND " + freeTextSQL + ")",
expectedArgs: append(freeTextArgs("%prod%"), freeTextArgs("%payment%")...),
},
{
subtestName: "a quoted token matches the whole phrase",
dslQueryToCompile: `"prod payment"`,
expectedSQL: freeTextSQL,
expectedArgs: freeTextArgs("%prod payment%"),
},
{
subtestName: "quoting is the escape hatch for a DSL-like literal",
dslQueryToCompile: `"team = prod"`,
expectedSQL: freeTextSQL,
expectedArgs: freeTextArgs("%team = prod%"),
},
{
subtestName: "LIKE wildcards in the term are escaped to match literally",
dslQueryToCompile: `"50%"`,
expectedSQL: freeTextSQL,
expectedArgs: freeTextArgs(`%50\%%`),
},
{
subtestName: "surrounding whitespace is trimmed",
dslQueryToCompile: ` payment `,
expectedSQL: freeTextSQL,
expectedArgs: freeTextArgs("%payment%"),
},
{
subtestName: "free-text term composes with a comparison via AND",
dslQueryToCompile: `prod AND name CONTAINS 'signoz'`,
expectedSQL: "(" + freeTextSQL + ` AND json_extract("dashboard"."data", '$.spec.display.name') LIKE ? ESCAPE '\')`,
expectedArgs: append(freeTextArgs("%prod%"), "%signoz%"),
},
{
subtestName: "free-text words compose with a comparison via OR",
dslQueryToCompile: `prod payment OR name = 'x'`,
expectedSQL: "((" + freeTextSQL + " AND " + freeTextSQL + `) OR json_extract("dashboard"."data", '$.spec.display.name') = ?)`,
expectedArgs: append(append(freeTextArgs("%prod%"), freeTextArgs("%payment%")...), "x"),
},
{
subtestName: "NOT negates a free-text term",
dslQueryToCompile: `NOT payment`,
expectedSQL: "NOT (" + freeTextSQL + ")",
expectedArgs: freeTextArgs("%payment%"),
},
})
}
func TestCompile_Rejections(t *testing.T) {
runCompileCases(t, []compileCase{
{

View File

@@ -32,19 +32,13 @@ func newVisitor(formatter sqlstore.SQLFormatter) *visitor {
}
}
// 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.
// compile turns the parse tree into `?`-placeholder WHERE SQL + arguments for bun.
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
}
@@ -125,10 +119,10 @@ func (v *visitor) VisitPrimary(ctx *grammar.PrimaryContext) any {
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()))
// Bare keys, values, full text, and function calls are not part of the
// dashboard list DSL.
v.addError("unsupported expression %q — every term must be of the form `key OP value`", ctx.GetText())
return ""
}
// VisitComparison dispatches a single `key OP value` term. A key that matches
@@ -407,50 +401,6 @@ func buildSubqueryForTagKeyAndValue(subqueryBuilder *sqlbuilder.SelectBuilder, t
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) {

View File

@@ -273,35 +273,6 @@ func (h *handler) DeleteMapper(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusNoContent, nil)
}
// TestMappers handles POST /api/v1/span_mapper_groups/test.
func (h *handler) TestMappers(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
req := new(spantypes.PostableSpanMapperTest)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
groups := spantypes.NewSpanMapperGroupsWithMappersFromPostable(orgID, req.Groups)
out, collectorLogs, err := h.module.TestMappers(ctx, orgID, req.Spans, groups)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, &spantypes.GettableSpanMapperTest{Spans: out, CollectorLogs: collectorLogs})
}
// groupIDFromPath extracts and validates the {id} or {groupId} path variable.
func groupIDFromPath(r *http.Request) (valuer.UUID, error) {
vars := mux.Vars(r)

View File

@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
@@ -106,61 +105,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")
}
if len(spans) > maxTestSpans {
return nil, nil, errors.Newf(errors.TypeInvalidInput, spantypes.ErrCodeMappingInvalidInput, "'spans' must contain at most %d spans", maxTestSpans)
}
resolved, err := module.backfillMappers(ctx, orgID, groups)
if err != nil {
return nil, nil, err
}
out, collectorLogs, err := spantypes.SimulateSpanMappersProcessing(ctx, resolved, spans)
if err != nil {
return nil, nil, err
}
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
}

View File

@@ -27,7 +27,6 @@ type Module interface {
CreateMapper(ctx context.Context, orgID, groupID valuer.UUID, mapper *spantypes.SpanMapper) error
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)
}
// Handler defines the HTTP handler interface for mapping group and mapper endpoints.
@@ -43,5 +42,4 @@ type Handler interface {
CreateMapper(rw http.ResponseWriter, r *http.Request)
UpdateMapper(rw http.ResponseWriter, r *http.Request)
DeleteMapper(rw http.ResponseWriter, r *http.Request)
TestMappers(rw http.ResponseWriter, r *http.Request)
}

View File

@@ -98,7 +98,7 @@ func (s *store) CreateOrGet(ctx context.Context, tags []*tagtypes.Tag) ([]*tagty
BunDBCtx(ctx).
NewInsert().
Model(&tags).
On("CONFLICT (org_id, kind, (LOWER(key)), (LOWER(value))) DO UPDATE").
// On("CONFLICT (org_id, kind, (LOWER(key)), (LOWER(value))) DO UPDATE").
Set("key = tag.key").
Returning("*").
Scan(ctx)

View File

@@ -88,60 +88,62 @@ func TestStore_Create_PopulatesIDsOnFreshInsert(t *testing.T) {
assert.Equal(t, preIDB, stored["team\x00blr"].ID)
}
func TestStore_Create_ConflictReturnsExistingRowID(t *testing.T) {
ctx := context.Background()
sqlstore := newTestStore(t)
s := NewStore(sqlstore)
// todo (@namanverma): uncomment once unique index is there.
//
// func TestStore_Create_ConflictReturnsExistingRowID(t *testing.T) {
// ctx := context.Background()
// sqlstore := newTestStore(t)
// s := NewStore(sqlstore)
orgID := valuer.GenerateUUID()
// orgID := valuer.GenerateUUID()
// Simulate a concurrent insert: someone else has already inserted "tag:Database".
winner := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
_, err := s.CreateOrGet(ctx, []*tagtypes.Tag{winner})
require.NoError(t, err)
winnerID := winner.ID
// // Simulate a concurrent insert: someone else has already inserted "tag:Database".
// winner := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
// _, err := s.CreateOrGet(ctx, []*tagtypes.Tag{winner})
// require.NoError(t, err)
// winnerID := winner.ID
// Now our request runs with a different pre-generated ID for the same
// (key, value) — case differs but the functional unique index collapses
// them. RETURNING should overwrite our stale ID with winner's ID.
loser := tagtypes.NewTag(orgID, dashboardKind, "TAG", "DATABASE")
loserPreID := loser.ID
require.NotEqual(t, winnerID, loserPreID, "pre-generated IDs must differ for this test to be meaningful")
// // Now our request runs with a different pre-generated ID for the same
// // (key, value) — case differs but the functional unique index collapses
// // them. RETURNING should overwrite our stale ID with winner's ID.
// loser := tagtypes.NewTag(orgID, dashboardKind, "TAG", "DATABASE")
// loserPreID := loser.ID
// require.NotEqual(t, winnerID, loserPreID, "pre-generated IDs must differ for this test to be meaningful")
got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{loser})
require.NoError(t, err)
require.Len(t, got, 1)
// got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{loser})
// require.NoError(t, err)
// require.Len(t, got, 1)
assert.Equal(t, winnerID, got[0].ID, "returned slice should carry the existing row's ID, not our stale one")
assert.Equal(t, winnerID, loser.ID, "input slice element is mutated in place")
// assert.Equal(t, winnerID, got[0].ID, "returned slice should carry the existing row's ID, not our stale one")
// assert.Equal(t, winnerID, loser.ID, "input slice element is mutated in place")
// And the DB still has exactly one row for that (lower(key), lower(value)) — winner's, with winner's casing.
stored := tagsByLowerKeyValue(t, sqlstore.BunDB())
require.Len(t, stored, 1)
assert.Equal(t, winnerID, stored["tag\x00database"].ID)
assert.Equal(t, "tag", stored["tag\x00database"].Key, "winner's casing preserved in key")
assert.Equal(t, "Database", stored["tag\x00database"].Value, "winner's casing preserved in value")
}
// // And the DB still has exactly one row for that (lower(key), lower(value)) — winner's, with winner's casing.
// stored := tagsByLowerKeyValue(t, sqlstore.BunDB())
// require.Len(t, stored, 1)
// assert.Equal(t, winnerID, stored["tag\x00database"].ID)
// assert.Equal(t, "tag", stored["tag\x00database"].Key, "winner's casing preserved in key")
// assert.Equal(t, "Database", stored["tag\x00database"].Value, "winner's casing preserved in value")
// }
func TestStore_Create_MixedFreshAndConflict(t *testing.T) {
ctx := context.Background()
sqlstore := newTestStore(t)
s := NewStore(sqlstore)
// func TestStore_Create_MixedFreshAndConflict(t *testing.T) {
// ctx := context.Background()
// sqlstore := newTestStore(t)
// s := NewStore(sqlstore)
orgID := valuer.GenerateUUID()
pre := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
_, err := s.CreateOrGet(ctx, []*tagtypes.Tag{pre})
require.NoError(t, err)
preExistingID := pre.ID
// orgID := valuer.GenerateUUID()
// pre := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
// _, err := s.CreateOrGet(ctx, []*tagtypes.Tag{pre})
// require.NoError(t, err)
// preExistingID := pre.ID
conflict := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
fresh := tagtypes.NewTag(orgID, dashboardKind, "team", "BLR")
freshPreID := fresh.ID
// conflict := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
// fresh := tagtypes.NewTag(orgID, dashboardKind, "team", "BLR")
// freshPreID := fresh.ID
got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{conflict, fresh})
require.NoError(t, err)
require.Len(t, got, 2)
// got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{conflict, fresh})
// require.NoError(t, err)
// require.Len(t, got, 2)
assert.Equal(t, preExistingID, got[0].ID, "conflicting row's ID overwritten with the existing row's")
assert.Equal(t, freshPreID, got[1].ID, "fresh row's pre-generated ID is preserved")
}
// assert.Equal(t, preExistingID, got[0].ID, "conflicting row's ID overwritten with the existing row's")
// assert.Equal(t, freshPreID, got[1].ID, "fresh row's pre-generated ID is preserved")
// }

View File

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

View File

@@ -1,59 +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 addTagUniqueIndex struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddTagUniqueIndexFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_tag_unique_index"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addTagUniqueIndex{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *addTagUniqueIndex) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addTagUniqueIndex) 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().CreateIndex(
&sqlschema.UniqueIndexWithExpressions{
TableName: "tag",
Expressions: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
},
)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addTagUniqueIndex) Down(_ context.Context, _ *bun.DB) error {
return nil
}

View File

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

View File

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

View File

@@ -54,7 +54,7 @@ type SpanMapper struct {
types.UserAuditable
ID valuer.UUID `json:"id" required:"true"`
GroupID valuer.UUID `json:"groupId" required:"true"`
GroupID valuer.UUID `json:"group_id" required:"true"`
Name string `json:"name" required:"true"`
FieldContext FieldContext `json:"fieldContext" required:"true"`
Config SpanMapperConfig `json:"config" required:"true"`
@@ -63,7 +63,7 @@ type SpanMapper struct {
type PostableSpanMapper struct {
Name string `json:"name" required:"true"`
FieldContext FieldContext `json:"fieldContext" required:"true"`
FieldContext FieldContext `json:"fieldContext" required:"true"`
Config SpanMapperConfig `json:"config" required:"true"`
Enabled bool `json:"enabled"`
}

View File

@@ -1,183 +0,0 @@
package spantypes
import (
"context"
"maps"
"sort"
"time"
"github.com/SigNoz/signoz-otel-collector/pkg/collectorsimulator"
"github.com/SigNoz/signoz-otel-collector/processor/signozspanmapperprocessor"
"github.com/SigNoz/signoz/pkg/errors"
"go.opentelemetry.io/collector/otelcol"
"go.opentelemetry.io/collector/pdata/ptrace"
"gopkg.in/yaml.v3"
)
var (
ErrCodeProcessorFactoryMapFailed = errors.MustNewCode("processor_factory_map_failed")
ErrCodeSpanMapperSimulationFailed = errors.MustNewCode("span_mapper_simulation_failed")
)
const spanInputOrderAttr = "__signoz_input_idx__"
func SimulateSpanMappersProcessing(ctx context.Context, groups []*SpanMapperGroupWithMappers, spans []SpanMapperTestSpan) ([]SpanMapperTestSpan, []string, error) {
enabled := filterEnabledGroupsWithMappers(groups)
if len(enabled) < 1 {
return spans, nil, nil
}
// Tag each span with its request position so the output can be returned in
// input order; work on copies so the caller's attribute maps stay untouched.
inputSpans := make([]SpanMapperTestSpan, len(spans))
for i, s := range spans {
attrs := make(map[string]any, len(s.Attributes)+1)
maps.Copy(attrs, s.Attributes)
attrs[spanInputOrderAttr] = int64(i)
inputSpans[i] = SpanMapperTestSpan{Attributes: attrs, Resource: s.Resource}
}
simulatorInput := SpansToPTraces(inputSpans)
processorFactories, err := otelcol.MakeFactoryMap(signozspanmapperprocessor.NewFactory())
if err != nil {
return nil, nil, errors.WrapInternalf(err, ErrCodeProcessorFactoryMapFailed, "could not construct processor factory map")
}
configGenerator := func(baseConf []byte) ([]byte, error) {
withProcessor, err := GenerateCollectorConfigWithSpanMapperProcessor(baseConf, enabled)
if err != nil {
return nil, err
}
return wireSpanMapperIntoTracesPipeline(withProcessor)
}
// signozspanmapperprocessor does no batching; spans flow through immediately.
timeout := 200 * time.Millisecond
outputTraces, collectorErrs, simErr := collectorsimulator.SimulateTracesProcessing(
ctx,
processorFactories,
configGenerator,
simulatorInput,
timeout,
)
if simErr != nil {
if errors.Is(simErr, collectorsimulator.ErrInvalidConfig) {
return nil, nil, errors.WrapInvalidInputf(simErr, errors.CodeInvalidInput, "invalid config")
}
return nil, nil, errors.WrapInternalf(simErr, ErrCodeSpanMapperSimulationFailed, "could not simulate span mapper processing")
}
outputSpans := PTracesToSpans(outputTraces)
// The simulator returns whatever has been exported when its timeout
// elapses, without error; surface that instead of silently dropping spans.
if len(outputSpans) != len(spans) {
return nil, nil, errors.Newf(errors.TypeInternal, ErrCodeSpanMapperSimulationFailed, "simulation returned %d spans for %d input spans", len(outputSpans), len(spans))
}
sort.Slice(outputSpans, func(i, j int) bool {
iIdx, _ := outputSpans[i].Attributes[spanInputOrderAttr].(int64)
jIdx, _ := outputSpans[j].Attributes[spanInputOrderAttr].(int64)
return iIdx < jIdx
})
for _, s := range outputSpans {
delete(s.Attributes, spanInputOrderAttr)
}
return outputSpans, collectorErrs, nil
}
func SpansToPTraces(spans []SpanMapperTestSpan) []ptrace.Traces {
result := make([]ptrace.Traces, 0, len(spans))
for _, s := range spans {
td := ptrace.NewTraces()
rs := td.ResourceSpans().AppendEmpty()
if s.Resource != nil {
_ = rs.Resource().Attributes().FromRaw(s.Resource)
}
sl := rs.ScopeSpans().AppendEmpty()
span := sl.Spans().AppendEmpty()
if s.Attributes != nil {
_ = span.Attributes().FromRaw(s.Attributes)
}
result = append(result, td)
}
return result
}
func PTracesToSpans(traces []ptrace.Traces) []SpanMapperTestSpan {
result := []SpanMapperTestSpan{}
for _, td := range traces {
rss := td.ResourceSpans()
for i := 0; i < rss.Len(); i++ {
rs := rss.At(i)
resourceAttrs := rs.Resource().Attributes().AsRaw()
ilss := rs.ScopeSpans()
for j := 0; j < ilss.Len(); j++ {
spans := ilss.At(j).Spans()
for k := 0; k < spans.Len(); k++ {
result = append(result, SpanMapperTestSpan{
Attributes: spans.At(k).Attributes().AsRaw(),
Resource: resourceAttrs,
})
}
}
}
}
return result
}
// wireSpanMapperIntoTracesPipeline appends "signozspanmapper" to
// service.pipelines.traces.processors so the processor defined by
// GenerateCollectorConfigWithSpanMapperProcessor actually runs against the
// traces flowing through the simulator. Idempotent: skips appending if the
// processor name is already present. Errors if the config has no traces
// pipeline, since the simulation would otherwise silently pass spans through.
func wireSpanMapperIntoTracesPipeline(confYaml []byte) ([]byte, error) {
var conf map[string]any
if err := yaml.Unmarshal(confYaml, &conf); err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeInvalidCollectorConfig, "failed to unmarshal collector config for pipeline wiring")
}
// Failed assertions yield nil, and indexing a nil map is safe,
// so any missing/mistyped level surfaces as traces == nil below.
service, _ := conf["service"].(map[string]any)
pipelines, _ := service["pipelines"].(map[string]any)
traces, _ := pipelines["traces"].(map[string]any)
if traces == nil {
return nil, errors.Newf(errors.TypeInternal, ErrCodeBuildMappingProcessorConfig, "collector config has no service.pipelines.traces pipeline to wire %q into", ProcessorName)
}
procs, _ := traces["processors"].([]any)
for _, p := range procs {
if name, ok := p.(string); ok && name == ProcessorName {
return confYaml, nil
}
}
traces["processors"] = append(procs, ProcessorName)
out, err := yaml.Marshal(conf)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, ErrCodeBuildMappingProcessorConfig, "failed to marshal collector config after pipeline wiring")
}
return out, nil
}
func filterEnabledGroupsWithMappers(groups []*SpanMapperGroupWithMappers) []*SpanMapperGroupWithMappers {
out := make([]*SpanMapperGroupWithMappers, 0, len(groups))
for _, gm := range groups {
if gm == nil || gm.Group == nil || !gm.Group.Enabled {
continue
}
enabled := make([]*SpanMapper, 0, len(gm.Mappers))
for _, m := range gm.Mappers {
if m != nil && m.Enabled {
enabled = append(enabled, m)
}
}
if len(enabled) > 0 {
out = append(out, &SpanMapperGroupWithMappers{Group: gm.Group, Mappers: enabled})
}
}
return out
}

View File

@@ -1,43 +0,0 @@
package spantypes
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestSimulateSpanMappersProcessing_EndToEnd is an integration test: it spins
// up an actual in-memory otel-collector pipeline with signozspanmapperprocessor
// and verifies the produced span has the expected target attribute.
func TestSimulateSpanMappersProcessing_EndToEnd(t *testing.T) {
groups := []*SpanMapperGroupWithMappers{{
Group: &SpanMapperGroup{
Name: "llm",
Condition: SpanMapperGroupCondition{Attributes: []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},
}},
Enabled: true,
}},
}}
spans := []SpanMapperTestSpan{{Attributes: map[string]any{"llm.model": "gpt-4"}}}
out, _, err := SimulateSpanMappersProcessing(context.Background(), groups, spans)
require.NoError(t, err)
require.Len(t, out, 1)
assert.Equal(t, "gpt-4", out[0].Attributes["gen_ai.request.model"], "target attribute must be populated by the spanmapper processor")
// Source attribute is preserved (copy, not move).
assert.Equal(t, "gpt-4", out[0].Attributes["llm.model"])
// Order-tracking attribute is stripped from the output.
_, hasOrderAttr := out[0].Attributes[spanInputOrderAttr]
assert.False(t, hasOrderAttr, "internal ordering attribute must be removed from the response")
}

View File

@@ -1,54 +0,0 @@
package spantypes
import (
"github.com/SigNoz/signoz/pkg/valuer"
)
type SpanMapperTestSpan struct {
Attributes map[string]any `json:"attributes"`
Resource map[string]any `json:"resource"`
}
// Mappers is optional because the module can backfill from the store by Group.Name.
type PostableSpanMapperTestGroup struct {
PostableSpanMapperGroup
Mappers []PostableSpanMapper `json:"mappers"`
}
type PostableSpanMapperTest struct {
Spans []SpanMapperTestSpan `json:"spans" required:"true"`
Groups []PostableSpanMapperTestGroup `json:"groups" required:"true"`
}
type GettableSpanMapperTest struct {
Spans []SpanMapperTestSpan `json:"spans"`
CollectorLogs []string `json:"collectorLogs"`
}
func NewSpanMapperGroupsWithMappersFromPostable(orgID valuer.UUID, in []PostableSpanMapperTestGroup) []*SpanMapperGroupWithMappers {
out := make([]*SpanMapperGroupWithMappers, 0, len(in))
for _, pg := range in {
var mappers []*SpanMapper
if pg.Mappers != nil {
mappers = make([]*SpanMapper, 0, len(pg.Mappers))
for _, pm := range pg.Mappers {
mappers = append(mappers, &SpanMapper{
Name: pm.Name,
FieldContext: pm.FieldContext,
Config: pm.Config,
Enabled: pm.Enabled,
})
}
}
out = append(out, &SpanMapperGroupWithMappers{
Group: &SpanMapperGroup{
OrgID: orgID,
Name: pg.Name,
Condition: pg.Condition,
Enabled: pg.Enabled,
},
Mappers: mappers,
})
}
return out
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"strings"
"github.com/SigNoz/signoz-otel-collector/exporter/jsontypeexporter"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -21,11 +22,8 @@ const (
// BodyJSONStringSearchPrefix is the prefix used for body JSON search queries.
// e.g., "body.status" where "body." is the prefix.
BodyJSONStringSearchPrefix = "body."
// ArraySep must match the array separator written by the collector's JSON
// type exporter; the shared constant (jsontypeexporter.ArraySeparator) was
// removed from signoz-otel-collector, so the value is duplicated here.
ArraySep = "[]."
ArraySepSuffix = "[]"
ArraySep = jsontypeexporter.ArraySeparator
ArraySepSuffix = "[]"
// TODO(Piyush): Remove once we've migrated to the new array syntax.
ArrayAnyIndex = "[*]."
ArrayAnyIndexSuffix = "[*]"

View File

@@ -6,6 +6,7 @@ import (
"slices"
"strings"
"github.com/SigNoz/signoz-otel-collector/exporter/jsontypeexporter"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -75,7 +76,7 @@ func (n *JSONAccessNode) Alias() string {
parentAlias := strings.TrimLeft(n.Parent.Alias(), "`")
parentAlias = strings.TrimRight(parentAlias, "`")
sep := ArraySep
sep := jsontypeexporter.ArraySeparator
if n.Parent.isRoot {
sep = "."
}

View File

@@ -723,74 +723,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
assert response.status_code == HTTPStatus.OK, response.text
assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == expected, query
# ── stage 5: free-text search (bare-word terms) ──────────────────────────
# A bare word is a case-insensitive substring search over the name and every
# tag key/value. Consecutive words are separate terms AND'd together (implicit
# AND); a quoted token matches the whole phrase; and a term composes with
# comparisons via AND/OR.
free_text_cases = [
# name substring, matched case-insensitively
("overview", {"Alpha Overview", "Beta Overview", "Zeta Overview"}),
# matches a name substring on some rows and a tag value on the same rows
("storage", {"Gamma Storage", "Delta Storage"}),
# tag value only (no name contains "pulse")
("pulse", {"Alpha Overview", "Beta Overview", "Zeta Overview"}),
# tag value only
("critical", {"Delta Storage", "Epsilon Metrics"}),
# tag key only
("tier", {"Delta Storage", "Epsilon Metrics"}),
# tag key present on every dashboard
(
"env",
{
"Alpha Overview",
"Beta Overview",
"Gamma Storage",
"Delta Storage",
"Epsilon Metrics",
"Zeta Overview",
},
),
# two words AND'd: only Delta matches both "delta" and "storage"
("delta storage", {"Delta Storage"}),
# two words AND'd with no dashboard matching both
("overview storage", set()),
# a quoted token matches the whole phrase
('"Alpha Overview"', {"Alpha Overview"}),
# a free-text term AND'd with a comparison (the reviewer's case)
("pulse AND env = 'prod'", {"Alpha Overview"}),
# a free-text term OR'd with a comparison
(
"storage OR env = 'staging'",
{"Gamma Storage", "Delta Storage", "Epsilon Metrics", "Zeta Overview"},
),
# no match anywhere
("nonexistent", set()),
# NOT over a term nothing matches returns everything — including these
# description-less dashboards, which a negated search must not exclude.
(
"NOT payment",
{
"Alpha Overview",
"Beta Overview",
"Gamma Storage",
"Delta Storage",
"Epsilon Metrics",
"Zeta Overview",
},
),
]
for query, expected in free_text_cases:
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"query": query, "limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == expected, query
# ── stage 6: name sort honours order ─────────────────────────────────────
# ── stage 5: name sort honours order ─────────────────────────────────────
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"sort": "name", "order": "asc", "limit": 200},
@@ -820,7 +753,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
"Alpha Overview",
]
# ── stage 7: pinning floats a dashboard to the top of any ordering ───────
# ── stage 6: pinning floats a dashboard to the top of any ordering ───────
assert (
requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids['lc-gamma']}/pins"),
@@ -858,7 +791,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
]
assert all("pinned" not in d for d in response.json()["data"]["dashboards"])
# ── stage 8: unpinning restores the natural ordering ─────────────────────
# ── stage 7: unpinning restores the natural ordering ─────────────────────
assert (
requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/me/dashboards/{ids['lc-gamma']}/pins"),
@@ -882,7 +815,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
"Zeta Overview",
]
# ── stage 9: update mutates the spec but keeps the immutable name ────────
# ── stage 8: update mutates the spec but keeps the immutable name ────────
update_body = {
"schemaVersion": "v6",
"name": "lc-alpha",
@@ -907,18 +840,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
)
assert response.json()["data"]["spec"]["display"]["description"] == "now with a description"
# free-text search also matches the description (only Alpha has one now);
# quoted so the phrase matches as one substring rather than per-word
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me/dashboards"),
params={"query": '"now with a description"', "limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert {d["spec"]["display"]["name"] for d in response.json()["data"]["dashboards"]} == {"Alpha Overview"}
# ── stage 10: a locked dashboard rejects updates until unlocked ──────────
# ── stage 9: a locked dashboard rejects updates until unlocked ───────────
assert (
requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-beta']}/lock"),
@@ -958,7 +880,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
== HTTPStatus.OK
)
# ── stage 11: delete removes the dashboard from get and list ─────────────
# ── stage 10: delete removes the dashboard from get and list ─────────────
assert (
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-gamma']}"),
@@ -990,7 +912,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
"Zeta Overview",
}
# ── stage 12: clone suffixes the display name and mints a new, retrievable one ─
# ── stage 11: clone suffixes the display name and mints a new, retrievable one ─
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{ids['lc-alpha']}/clone"),
headers={"Authorization": f"Bearer {token}"},

View File

@@ -1,174 +0,0 @@
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 _auth_headers(token: str) -> dict[str, str]:
return {
"authorization": f"Bearer {token}",
"content-type": "application/json",
}
def test_create_groups_and_simulate_with_backfill(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""
Setup:
Create a mapping group and a mapper through the CRUD API, then hit the
simulate endpoint with two groups: the saved one referenced by name only
(server backfills its mappers from the store) and a second, unsaved group
sent with inline mappers.
Tests:
1. Create a group (POST /span_mapper_groups) and read back its id
2. Create a mapper in that group (POST .../{groupId}/span_mappers)
3. List groups and mappers and verify what we created is persisted
4. Simulate spans against both groups: a matching span gets the saved
group's mappers (backfilled, copy) and the inline group's mappers
(move) applied, while a span matching no condition passes through
5. Delete the group and verify it (and its mappers) are gone
"""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
create_group = requests.post(
signoz.self.host_configs["8080"].get(GROUPS_PATH),
timeout=10,
headers=_auth_headers(token),
json={
"name": "llm-backfill",
"condition": {"attributes": ["model"], "resource": []},
"enabled": True,
},
)
assert create_group.status_code == HTTPStatus.CREATED
group = create_group.json()["data"]
groupId = group["id"]
assert group["name"] == "llm-backfill"
assert group["enabled"] is True
create_mapper = requests.post(
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{groupId}/span_mappers"),
timeout=10,
headers=_auth_headers(token),
json={
"name": "gen_ai.request.model",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "llm.model",
"context": "attribute",
"operation": "copy",
"priority": 1,
}
]
},
"enabled": True,
},
)
assert create_mapper.status_code == HTTPStatus.CREATED
mapper = create_mapper.json()["data"]
assert mapper["name"] == "gen_ai.request.model"
assert mapper["groupId"] == groupId
list_groups = requests.get(
signoz.self.host_configs["8080"].get(GROUPS_PATH),
timeout=10,
headers=_auth_headers(token),
)
assert list_groups.status_code == HTTPStatus.OK
assert groupId in [g["id"] for g in list_groups.json()["data"]["items"]]
list_mappers = requests.get(
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{groupId}/span_mappers"),
timeout=10,
headers=_auth_headers(token),
)
assert list_mappers.status_code == HTTPStatus.OK
assert "gen_ai.request.model" in [m["name"] for m in list_mappers.json()["data"]["items"]]
simulate = requests.post(
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/test"),
timeout=10,
headers=_auth_headers(token),
json={
"spans": [
{
"attributes": {"llm.model": "gpt-4", "db.system": "postgres"},
"resource": {},
},
# Matches no group condition, so it must pass through untouched.
{
"attributes": {"http.method": "GET"},
"resource": {},
},
],
"groups": [
# No "mappers" key: the server backfills them from the saved group.
{
"name": "llm-backfill",
"condition": {"attributes": ["model"], "resource": []},
"enabled": True,
},
# Unsaved group; mappers provided inline.
{
"name": "db-inline",
"condition": {"attributes": ["db"], "resource": []},
"enabled": True,
"mappers": [
{
"name": "db.name",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "db.system",
"context": "attribute",
"operation": "move",
"priority": 1,
}
]
},
"enabled": True,
}
],
},
],
},
)
assert simulate.status_code == HTTPStatus.OK
out_spans = simulate.json()["data"]["spans"]
assert len(out_spans) == 2
attrs = out_spans[0]["attributes"]
assert attrs["gen_ai.request.model"] == "gpt-4" # backfilled mapper applied
assert attrs["llm.model"] == "gpt-4" # copy preserves source
assert attrs["db.name"] == "postgres" # inline mapper applied
assert "db.system" not in attrs # move removes source
assert out_spans[1]["attributes"] == {"http.method": "GET"} # unchanged
delete_group = requests.delete(
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{groupId}"),
timeout=10,
headers=_auth_headers(token),
)
assert delete_group.status_code == HTTPStatus.NO_CONTENT
list_after_delete = requests.get(
signoz.self.host_configs["8080"].get(GROUPS_PATH),
timeout=10,
headers=_auth_headers(token),
)
assert list_after_delete.status_code == HTTPStatus.OK
assert groupId not in [g["id"] for g in list_after_delete.json()["data"]["items"]]