Compare commits

..

17 Commits

Author SHA1 Message Date
Vikrant Gupta
654eebd897 Merge branch 'main' into platform-pod/issues/2682 2026-07-15 14:34:29 +05:30
vikrantgupta25
e23f4a3b8c revert(authz): restore transaction group diff in role update 2026-07-15 14:31:52 +05:30
vikrantgupta25
199092276a feat(authz): use escaped plaintext telemetry selectors with mechanical ladder 2026-07-15 14:19:36 +05:30
Aditya Singh
0c28b8b251 feat: scalar/table serializers for client-side export (3) (#12070)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* refactor(data-export): extract the withUnit header helper

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

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

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

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

exportScalarData takes the queryRange response object (formatForWeb webTables payload) + the builder query and serializes via createTableColumnsFromQuery — the exact preparer QueryTable renders from — so exports inherit the on-screen merge, naming and column order 1:1.
2026-07-15 05:37:15 +00:00
Aditya Singh
10a0d262d9 feat: add download option to trace view (#12116) 2026-07-15 05:36:59 +00:00
Vinicius Lourenço
334155a226 fix(infra-monitoring-v2): ensure workloads has filters by cluster+namespace (#12076)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(tanstack): add support for itemKey be object

* feat(infra-monitoring): add base structure for multiple selected items

* refactor(infra-monitoring): add cluster for namespaces, cluster/namespace for volumes + update other entities

* refactor(infra-monitoring): add cluster + namespace for statefulsets

* refactor(infra-monitoring): add cluster + namespace for jobs

* refactor(infra-monitoring): add cluster + namespace for deployments

* refactor(infra-monitoring): add cluster + namespace for daemonsets

* test(infra-monitoring): add new tests for changes of k8s base list

* fix(pr): address comments
2026-07-14 18:24:42 +00:00
Pandey
42de840534 fix(rules): scope alert rule store operations by org (#12117)
* fix(rules): scope alert rule store operations by org

The rule store predicates filtered on the rule id only, so on multi-org
deployments (Cloud/EE/multi-org self-hosted, where orgs share the
instance via the noop sharder) an authenticated user could read, edit or
delete another org's alert rules by supplying the target rule UUID.
ViewAccess/EditAccess only check the caller's own-org role, never the
resource's org, so nothing enforced tenant isolation on the rule itself.

Enforce org scoping at the store layer, which is the durable fix:

- GetStoredRule and DeleteRule now take the caller's orgID and add
  `org_id = ?` to their predicates.
- EditRule adds `org_id = ?` (from the model, which is now always
  sourced from an org-scoped read).
- The manager passes claims.OrgID on every by-id path; GetRule now
  derives the org from claims (it previously fetched by id alone).

Cross-org ids now resolve to NotFound instead of leaking or mutating
another org's rule. Single-org OSS instances are unaffected.

CWE-639 (authorization bypass through user-controlled key).

* refactor(rules): derive claims org id with valuer.MustNewUUID

Claims are pre-validated by the auth middleware, so the NewUUID error
branch is dead code. Follow the handler convention (docs/contributing/
go/handler.md) and use the Must constructor on the by-id rule paths.
2026-07-14 18:00:09 +00:00
vikrantgupta25
4f291e0664 feat(authz): move telemetry grant key to plaintext selector segment 2026-07-14 19:09:09 +05:30
vikrantgupta25
410eb36fde feat(authz): encode telemetry grants as query-type qualified atom selectors 2026-07-14 17:50:19 +05:30
vikrantgupta25
422c9588c4 feat(authz): gate v5 query_range on service.name telemetry selectors 2026-07-13 18:31:19 +05:30
vikrantgupta25
5c6e8b16f9 Merge remote-tracking branch 'origin/main' into platform-pod/issues/2682
# Conflicts:
#	pkg/signoz/provider.go
2026-07-13 17:53:10 +05:30
vikrantgupta25
168658b4c3 Merge remote-tracking branch 'origin/platform-pod/issues/2606' into platform-pod/issues/2682 2026-07-13 17:50:09 +05:30
vikrantgupta25
9be93a308a refactor(telemetry): restructure normalizer file and quote bare values 2026-07-13 13:32:56 +05:30
vikrantgupta25
042e1fb4f0 feat(telemetry): add where clause visitor 2026-07-13 10:12:04 +05:30
vikrantgupta25
1798abfc6b chore(docs): regenerate openapi spec with telemetry read scopes 2026-07-07 14:59:00 +05:30
vikrantgupta25
f155f71051 feat(authz): widen telemetry selector segments to 128 bits
64-bit truncation permits chosen-collision attacks at ~2^32 work; 128 bits
pushes this to 2^64. No hashed selector is persisted yet, so the change is
free.
2026-07-07 14:57:08 +05:30
vikrantgupta25
4559a78752 feat(authz): enable FGA for telemetry resources on v5 query_range
Authorize /api/v5/query_range and /preview at the telemetry-resource level,
derived from the request body:

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

View File

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

View File

@@ -8048,19 +8048,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:
@@ -8157,39 +8144,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:
@@ -8351,17 +8305,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:
@@ -14091,69 +14034,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
@@ -24584,9 +24464,17 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- VIEWER
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Query range
tags:
- querier
@@ -24653,9 +24541,17 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- VIEWER
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Query range preview
tags:
- querier

View File

@@ -9258,48 +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',
@@ -9641,33 +9599,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;
@@ -11018,14 +10949,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

@@ -3,6 +3,7 @@ import { Button, Popover, Tooltip } from 'antd';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useExportRawData } from 'hooks/useExportData/useServerExport';
import { Download, LoaderCircle } from '@signozhq/icons';
import { DataSource } from 'types/common/queryBuilder';
@@ -18,11 +19,13 @@ import './DownloadOptionsMenu.styles.scss';
interface DownloadOptionsMenuProps {
dataSource: DataSource;
selectedColumns?: TelemetryFieldKey[];
panelType?: PANEL_TYPES;
}
export default function DownloadOptionsMenu({
dataSource,
selectedColumns,
panelType,
}: DownloadOptionsMenuProps): JSX.Element {
const [exportFormat, setExportFormat] = useState<string>(DownloadFormats.CSV);
const [rowLimit, setRowLimit] = useState<number>(DownloadRowCounts.TEN_K);
@@ -33,6 +36,7 @@ export default function DownloadOptionsMenu({
const { isDownloading, handleExportRawData } = useExportRawData({
dataSource,
panelType,
});
const handleExport = useCallback(async (): Promise<void> => {

View File

@@ -11,17 +11,17 @@ import { FlatItem, TableRowContext } from './types';
import tableStyles from './TanStackTable.module.scss';
type VirtuosoTableRowProps<TData> = ComponentProps<
type VirtuosoTableRowProps<TData, TItemKey = string> = ComponentProps<
NonNullable<
TableComponents<FlatItem<TData>, TableRowContext<TData>>['TableRow']
TableComponents<FlatItem<TData>, TableRowContext<TData, TItemKey>>['TableRow']
>
>;
function TanStackCustomTableRow<TData>({
function TanStackCustomTableRow<TData, TItemKey = string>({
item,
context,
...props
}: VirtuosoTableRowProps<TData>): JSX.Element {
}: VirtuosoTableRowProps<TData, TItemKey>): JSX.Element {
const rowId = item.row.id;
const rowData = item.row.original;
@@ -84,9 +84,9 @@ function TanStackCustomTableRow<TData>({
// This looks overkill but ensures the table is stable and doesn't re-render on every change
// If you add any new prop to context, remember to update this function
// eslint-disable-next-line sonarjs/cognitive-complexity
function areTableRowPropsEqual<TData>(
prev: Readonly<VirtuosoTableRowProps<TData>>,
next: Readonly<VirtuosoTableRowProps<TData>>,
function areTableRowPropsEqual<TData, TItemKey = string>(
prev: Readonly<VirtuosoTableRowProps<TData, TItemKey>>,
next: Readonly<VirtuosoTableRowProps<TData, TItemKey>>,
): boolean {
if (prev.item.row.id !== next.item.row.id) {
return false;
@@ -141,7 +141,9 @@ function areTableRowPropsEqual<TData>(
return true;
}
export default memo(
TanStackCustomTableRow,
areTableRowPropsEqual,
) as typeof TanStackCustomTableRow;
export default memo(TanStackCustomTableRow, areTableRowPropsEqual as any) as <
TData,
TItemKey = string,
>(
props: VirtuosoTableRowProps<TData, TItemKey>,
) => JSX.Element;

View File

@@ -8,23 +8,23 @@ import { TableRowContext } from './types';
import tableStyles from './TanStackTable.module.scss';
type TanStackRowCellsProps<TData> = {
type TanStackRowCellsProps<TData, TItemKey = string> = {
row: TanStackRowModel<TData>;
context: TableRowContext<TData> | undefined;
context: TableRowContext<TData, TItemKey> | undefined;
itemKind: 'row' | 'expansion';
hasSingleColumn: boolean;
columnOrderKey: string;
columnVisibilityKey: string;
};
function TanStackRowCellsInner<TData>({
function TanStackRowCellsInner<TData, TItemKey = string>({
row,
context,
itemKind,
hasSingleColumn,
columnOrderKey: _columnOrderKey,
columnVisibilityKey: _columnVisibilityKey,
}: TanStackRowCellsProps<TData>): JSX.Element {
}: TanStackRowCellsProps<TData, TItemKey>): JSX.Element {
const hasHovered = useIsRowHovered(row.id);
const rowData = row.original;
const visibleCells = row.getVisibleCells();
@@ -40,8 +40,10 @@ function TanStackRowCellsInner<TData>({
const handleClick = useCallback(
(event: MouseEvent<HTMLTableCellElement>) => {
// Fall back to an empty key so row clicks still fire for consumers
// that don't provide getRowKey (e.g. Logs Explorer / Live Logs).
const keyData = getRowKeyData?.(rowIndex);
const itemKey = keyData?.itemKey ?? '';
const itemKey = keyData?.itemKey ?? ('' as TItemKey);
// Handle ctrl+click or cmd+click (open in new tab)
if ((event.ctrlKey || event.metaKey) && onRowClickNewTab) {
@@ -131,6 +133,8 @@ function areRowCellsPropsEqual<TData>(
const TanStackRowCells = memo(
TanStackRowCellsInner,
areRowCellsPropsEqual as any,
) as <T>(props: TanStackRowCellsProps<T>) => JSX.Element;
) as <T, TItemKey = string>(
props: TanStackRowCellsProps<T, TItemKey>,
) => JSX.Element;
export default TanStackRowCells;

View File

@@ -67,7 +67,7 @@ const INCREASE_VIEWPORT_BY = { top: 500, bottom: 500 };
const noopColumnVisibility = (): void => {};
// eslint-disable-next-line sonarjs/cognitive-complexity
function TanStackTableInner<TData>(
function TanStackTableInner<TData, TItemKey = string>(
{
data,
columns,
@@ -107,7 +107,7 @@ function TanStackTableInner<TData>(
suffixPaginationContent,
enableAlternatingRowColors,
disableVirtualScroll,
}: TanStackTableProps<TData>,
}: TanStackTableProps<TData, TItemKey>,
forwardedRef: React.ForwardedRef<TanStackTableHandle>,
): JSX.Element {
if (disableVirtualScroll && onEndReached) {
@@ -193,7 +193,7 @@ function TanStackTableInner<TData>(
skeletonRowCount,
});
const { rowKeyData, getRowKeyData } = useRowKeyData({
const { rowKeyData, getRowKeyData } = useRowKeyData<TData, TItemKey>({
data: effectiveData,
isLoading,
getRowKey,
@@ -229,7 +229,7 @@ function TanStackTableInner<TData>(
const tanstackColumns = useMemo<ColumnDef<TData>[]>(
() =>
effectiveColumns.map((colDef) =>
buildTanstackColumnDef(colDef, isRowActive, getRowKeyData),
buildTanstackColumnDef<TData, TItemKey>(colDef, isRowActive, getRowKeyData),
),
[effectiveColumns, isRowActive, getRowKeyData],
);
@@ -356,7 +356,7 @@ function TanStackTableInner<TData>(
[effectiveVisibility, columnIds],
);
const virtuosoContext = useMemo<TableRowContext<TData>>(
const virtuosoContext = useMemo<TableRowContext<TData, TItemKey>>(
() => ({
getRowStyle,
getRowClassName,
@@ -520,13 +520,15 @@ function TanStackTableInner<TData>(
);
type VirtuosoTableComponentProps = ComponentProps<
NonNullable<TableComponents<FlatItem<TData>, TableRowContext<TData>>['Table']>
NonNullable<
TableComponents<FlatItem<TData>, TableRowContext<TData, TItemKey>>['Table']
>
>;
// Use refs in virtuosoComponents to keep the component reference stable during resize
// This prevents Virtuoso from re-rendering all rows when columns are resized
const virtuosoComponents = useMemo(
() => ({
(): TableComponents<FlatItem<TData>, TableRowContext<TData, TItemKey>> => ({
Table: ({ style, children }: VirtuosoTableComponentProps): JSX.Element => (
<table className={tableStyles.tanStackTable} style={style}>
<VirtuosoTableColGroup
@@ -582,7 +584,7 @@ function TanStackTableInner<TData>(
</table>
</div>
) : (
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData>>
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData, TItemKey>>
className={virtuosoClassName}
ref={virtuosoRef}
{...restTableScrollerProps}
@@ -660,8 +662,11 @@ function TanStackTableInner<TData>(
);
}
const TanStackTableForward = forwardRef(TanStackTableInner) as <TData>(
props: TanStackTableProps<TData> & {
const TanStackTableForward = forwardRef(TanStackTableInner) as <
TData,
TItemKey = string,
>(
props: TanStackTableProps<TData, TItemKey> & {
ref?: React.Ref<TanStackTableHandle>;
},
) => JSX.Element;

View File

@@ -57,6 +57,39 @@ describe('TanStackRowCells', () => {
});
it('calls onRowClick when a cell is clicked', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
const ctx: TableRowContext<Row> = {
colCount: 1,
onRowClick,
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
});
it('fires onRowClick with empty itemKey when getRowKeyData is not provided', async () => {
// Mirrors Logs Explorer / Live Logs, which set onRowClick but no getRowKey.
const user = userEvent.setup();
const onRowClick = jest.fn();
const ctx: TableRowContext<Row> = {
@@ -84,7 +117,6 @@ describe('TanStackRowCells', () => {
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
// onRowClick receives (rowData, itemKey) - itemKey is empty when getRowKeyData not provided
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '');
});
@@ -97,6 +129,7 @@ describe('TanStackRowCells', () => {
onRowClick,
onRowDeactivate,
isRowActive: () => true,
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
@@ -194,6 +227,7 @@ describe('TanStackRowCells', () => {
colCount: 1,
onRowClick,
onRowClickNewTab,
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
@@ -216,7 +250,7 @@ describe('TanStackRowCells', () => {
</table>,
);
fireEvent.click(screen.getAllByRole('cell')[0], { ctrlKey: true });
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, '');
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
expect(onRowClick).not.toHaveBeenCalled();
});
@@ -227,6 +261,7 @@ describe('TanStackRowCells', () => {
colCount: 1,
onRowClick,
onRowClickNewTab,
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
@@ -249,7 +284,7 @@ describe('TanStackRowCells', () => {
</table>,
);
fireEvent.click(screen.getAllByRole('cell')[0], { metaKey: true });
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, '');
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
expect(onRowClick).not.toHaveBeenCalled();
});
@@ -260,6 +295,7 @@ describe('TanStackRowCells', () => {
colCount: 1,
onRowClick,
onRowClickNewTab,
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',

View File

@@ -614,6 +614,34 @@ describe('TanStackTableView Integration', () => {
);
});
it('calls onRowClick with object itemKey when getItemKey returns object', async () => {
type SelectionParams = { id: string; name: string };
const user = userEvent.setup();
const onRowClick = jest.fn<void, [unknown, SelectionParams]>();
renderTanStackTable<
(typeof import('./testUtils').defaultData)[0],
SelectionParams
>({
props: {
onRowClick,
getRowKey: (row) => row.id,
getItemKey: (row) => ({ id: row.id, name: row.name }),
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
await user.click(screen.getByText('Item 1'));
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: '1', name: 'Item 1' }),
{ id: '1', name: 'Item 1' },
);
});
it('applies active class when isRowActive returns true', async () => {
renderTanStackTable({
props: {

View File

@@ -42,14 +42,14 @@ export const defaultData: TestRow[] = [
{ id: '3', name: 'Item 3', value: 300 },
];
export type RenderTanStackTableOptions<T> = {
props?: Partial<TanStackTableProps<T>>;
export type RenderTanStackTableOptions<T, TItemKey = string> = {
props?: Partial<TanStackTableProps<T, TItemKey>>;
queryParams?: Record<string, string>;
onUrlUpdate?: OnUrlUpdateFunction;
};
export function renderTanStackTable<T = TestRow>(
options: RenderTanStackTableOptions<T> = {},
export function renderTanStackTable<T = TestRow, TItemKey = string>(
options: RenderTanStackTableOptions<T, TItemKey> = {},
): RenderResult {
const { props = {}, queryParams, onUrlUpdate } = options;
@@ -57,7 +57,7 @@ export function renderTanStackTable<T = TestRow>(
data: defaultData as unknown as T[],
columns: defaultColumns as unknown as TableColumnDef<T>[],
...props,
} as TanStackTableProps<T>;
} as TanStackTableProps<T, TItemKey>;
return render(
<NuqsTestingAdapter searchParams={queryParams} onUrlUpdate={onUrlUpdate}>
@@ -65,7 +65,7 @@ export function renderTanStackTable<T = TestRow>(
value={{ viewportHeight: 500, itemHeight: 50 }}
>
<TooltipProvider>
<TanStackTable<T> {...mergedProps} />
<TanStackTable<T, TItemKey> {...mergedProps} />
</TooltipProvider>
</VirtuosoMockContext.Provider>
</NuqsTestingAdapter>,

View File

@@ -123,6 +123,22 @@ export * from './useTableParams';
* />
* ```
*
* @example Object itemKey — use generic `TItemKey` when selection needs compound keys.
* ```tsx
* type SelectionParams = { id: string; cluster: string; namespace: string };
*
* <TanStackTable<Row, SelectionParams>
* data={data}
* columns={columns}
* getRowKey={(row) => row.uid}
* getItemKey={(row) => ({ id: row.name, cluster: row.cluster, namespace: row.namespace })}
* onRowClick={(row, itemKey) => {
* // itemKey is typed as SelectionParams
* setSelection(itemKey);
* }}
* />
* ```
*
* @example Expandable rows. `renderExpandedRow` receives `(row, rowKey, groupMeta?)`.
* ```tsx
* <TanStackTable

View File

@@ -24,17 +24,15 @@ export type TableCellContext<TData, TValue> = {
isExpanded: boolean;
canExpand: boolean;
toggleExpanded: () => void;
/** Business/selection key for the row */
itemKey: string;
/** Group metadata when row is part of a grouped view */
groupMeta?: Record<string, string>;
};
export type RowKeyData = {
export type RowKeyData<TItemKey = string> = {
/** Final unique key (with duplicate suffix if needed) */
finalKey: string;
/** Business/selection key */
itemKey: string;
itemKey: TItemKey;
/** Group metadata */
groupMeta?: Record<string, string>;
};
@@ -82,14 +80,14 @@ export type FlatItem<TData> =
| { kind: 'row'; row: TanStackRowType<TData> }
| { kind: 'expansion'; row: TanStackRowType<TData> };
export type TableRowContext<TData> = {
export type TableRowContext<TData, TItemKey = string> = {
getRowStyle?: (row: TData) => CSSProperties;
getRowClassName?: (row: TData) => string;
isRowActive?: (row: TData) => boolean;
renderRowActions?: (row: TData) => ReactNode;
onRowClick?: (row: TData, itemKey: string) => void;
onRowClick?: (row: TData, itemKey: TItemKey) => void;
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: string) => void;
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
onRowDeactivate?: () => void;
renderExpandedRow?: (
row: TData,
@@ -97,7 +95,7 @@ export type TableRowContext<TData> = {
groupMeta?: Record<string, string>,
) => ReactNode;
/** Get key data for a row by index */
getRowKeyData?: (index: number) => RowKeyData | undefined;
getRowKeyData?: (index: number) => RowKeyData<TItemKey> | undefined;
colCount: number;
isDarkMode?: boolean;
/** When set, primitive cell output (string/number/boolean) is wrapped with typography + line-clamp (see `plainTextCellLineClamp` on the table). */
@@ -147,7 +145,7 @@ export type TanstackTableQueryParamsConfig = {
expanded?: string;
};
export type TanStackTableProps<TData> = {
export type TanStackTableProps<TData, TItemKey = string> = {
data: TData[];
columns: TableColumnDef<TData>[];
/** Storage key for column state persistence (visibility, sizing, ordering). When set, enables unified column management. */
@@ -172,7 +170,7 @@ export type TanStackTableProps<TData> = {
* When set, enables automatic duplicate key detection and group-aware key composition. */
getRowKey?: (row: TData) => string;
/** Function to get the business/selection key. Defaults to getRowKey result. */
getItemKey?: (row: TData) => string;
getItemKey?: (row: TData) => TItemKey;
/** When set, enables group-aware key generation (prefixes rowKey with group values). */
groupBy?: Array<{ key: string }>;
/** Extract group metadata from a row. Required when groupBy is set. */
@@ -181,9 +179,9 @@ export type TanStackTableProps<TData> = {
getRowClassName?: (row: TData) => string;
isRowActive?: (row: TData) => boolean;
renderRowActions?: (row: TData) => ReactNode;
onRowClick?: (row: TData, itemKey: string) => void;
onRowClick?: (row: TData, itemKey: TItemKey) => void;
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: string) => void;
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
onRowDeactivate?: () => void;
activeRowIndex?: number;
renderExpandedRow?: (

View File

@@ -1,51 +1,51 @@
import { useCallback, useMemo } from 'react';
export interface RowKeyDataItem {
export interface RowKeyDataItem<TItemKey = string> {
/** Final unique key for the row (with dedup suffix if needed) */
finalKey: string;
/** Item key for tracking (may differ from finalKey) */
itemKey: string;
itemKey: TItemKey;
/** Group metadata when grouped */
groupMeta: Record<string, string> | undefined;
}
export interface UseRowKeyDataOptions<TData> {
export interface UseRowKeyDataOptions<TData, TItemKey = string> {
data: TData[];
isLoading: boolean;
getRowKey?: (item: TData) => string;
getItemKey?: (item: TData) => string;
getItemKey?: (item: TData) => TItemKey;
groupBy?: Array<{ key: string }>;
getGroupKey?: (item: TData) => Record<string, string>;
}
export interface UseRowKeyDataResult {
export interface UseRowKeyDataResult<TItemKey = string> {
/** Array of key data for each row, undefined if getRowKey not provided or loading */
rowKeyData: RowKeyDataItem[] | undefined;
getRowKeyData: (index: number) => RowKeyDataItem | undefined;
rowKeyData: RowKeyDataItem<TItemKey>[] | undefined;
getRowKeyData: (index: number) => RowKeyDataItem<TItemKey> | undefined;
}
/**
* Computes unique row keys with duplicate handling and group prefixes.
*/
export function useRowKeyData<TData>({
export function useRowKeyData<TData, TItemKey = string>({
data,
isLoading,
getRowKey,
getItemKey,
groupBy,
getGroupKey,
}: UseRowKeyDataOptions<TData>): UseRowKeyDataResult {
}: UseRowKeyDataOptions<TData, TItemKey>): UseRowKeyDataResult<TItemKey> {
// eslint-disable-next-line sonarjs/cognitive-complexity
const rowKeyData = useMemo((): RowKeyDataItem[] | undefined => {
const rowKeyData = useMemo((): RowKeyDataItem<TItemKey>[] | undefined => {
if (!getRowKey || isLoading) {
return undefined;
}
const keyCount = new Map<string, number>();
return data.map((item, index): RowKeyDataItem => {
return data.map((item, index): RowKeyDataItem<TItemKey> => {
const itemIdentifier = getRowKey(item);
const itemKey = getItemKey?.(item) ?? itemIdentifier;
const itemKey = getItemKey?.(item) ?? (itemIdentifier as TItemKey);
const groupMeta = groupBy?.length ? getGroupKey?.(item) : undefined;
// Build rowKey with group prefix when grouped

View File

@@ -86,10 +86,10 @@ const buildAccessorFn = <TData>(
};
};
export function buildTanstackColumnDef<TData>(
export function buildTanstackColumnDef<TData, TItemKey = string>(
colDef: TableColumnDef<TData>,
isRowActive?: (row: TData) => boolean,
getRowKeyData?: (index: number) => RowKeyData | undefined,
getRowKeyData?: (index: number) => RowKeyData<TItemKey> | undefined,
): ColumnDef<TData> {
const isFixed = colDef.width?.fixed != null;
const headerFn =
@@ -140,7 +140,6 @@ export function buildTanstackColumnDef<TData>(
toggleExpanded: (): void => {
row.toggleExpanded();
},
itemKey: keyData?.itemKey ?? '',
groupMeta: keyData?.groupMeta,
});
},

View File

@@ -10,6 +10,7 @@ import {
import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
import {
getHostQueryPayload,
hostWidgetInfo,
@@ -107,8 +108,10 @@ export function getHostMetricsQueryPayload(
export { hostWidgetInfo };
export const hostGetSelectedItemExpression = (hostName: string): string =>
`host.name = ${formatValueForExpression(hostName)}`;
export const hostGetSelectedItemExpression = (
params: SelectedItemParams,
): string =>
`host.name = ${formatValueForExpression(params.selectedItem ?? '')}`;
export function hostInitialLogTracesExpression(
host: InframonitoringtypesHostRecordDTO,

View File

@@ -52,9 +52,10 @@ import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
import {
SelectedItemParams,
useInfraMonitoringEventsFilters,
useInfraMonitoringLogFilters,
useInfraMonitoringSelectedItem,
useInfraMonitoringSelectedItemParams,
useInfraMonitoringTracesFilters,
useInfraMonitoringView,
} from '../hooks';
@@ -81,7 +82,7 @@ export interface K8sBaseDetailsProps<T> {
category: InfraMonitoringEntity;
eventCategory: string;
// Data fetching configuration
getSelectedItemExpression: (selectedItem: string) => string;
getSelectedItemExpression: (params: SelectedItemParams) => string;
fetchEntityData: (
filters: K8sDetailsFilters,
signal?: AbortSignal,
@@ -152,7 +153,9 @@ export default function K8sBaseDetails<T>({
const isDarkMode = useIsDarkMode();
const [selectedItem, setSelectedItem] = useInfraMonitoringSelectedItem();
const [selectedItemParams, setSelectedItemParams] =
useInfraMonitoringSelectedItemParams();
const selectedItem = selectedItemParams.selectedItem;
const entityQueryKey = useMemo(
() =>
@@ -160,8 +163,17 @@ export default function K8sBaseDetails<T>({
selectedTime,
`${queryKeyPrefix}EntityDetails`,
selectedItem,
selectedItemParams.clusterName,
selectedItemParams.namespaceName,
),
[queryKeyPrefix, selectedItem, selectedTime, getAutoRefreshQueryKey],
[
queryKeyPrefix,
selectedItem,
selectedItemParams.clusterName,
selectedItemParams.namespaceName,
selectedTime,
getAutoRefreshQueryKey,
],
);
const {
@@ -178,7 +190,7 @@ export default function K8sBaseDetails<T>({
const { minTime, maxTime } = getMinMaxTime();
const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER);
const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER);
const expression = getSelectedItemExpression(selectedItem);
const expression = getSelectedItemExpression(selectedItemParams);
return fetchEntityData({ filter: { expression }, start, end }, signal);
},
@@ -203,8 +215,8 @@ export default function K8sBaseDetails<T>({
}, [entity, getInitialEventsExpression]);
const handleClose = useCallback((): void => {
setSelectedItem(null);
}, [setSelectedItem]);
setSelectedItemParams(null);
}, [setSelectedItemParams]);
const entityName = entity ? getEntityName(entity) : '';

View File

@@ -10,7 +10,6 @@ import TanStackTable, {
} from 'components/TanStackTableView';
import { InfraMonitoringEvents } from 'constants/events';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { parseAsString, useQueryState } from 'nuqs';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { Querybuildertypesv5QueryWarnDataDTO } from 'api/generated/services/sigNoz.schemas';
@@ -21,8 +20,10 @@ import {
InfraMonitoringEntity,
} from '../constants';
import {
SelectedItemParams,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringSelectedItemParams,
useInfraMonitoringStatusFilter,
} from '../hooks';
import { useInfraMonitoringLineClamp } from '../components';
@@ -49,7 +50,10 @@ export type K8sBaseListEmptyStateContext = {
/** Base type constraint for K8s entity data */
export type K8sEntityData = { meta?: Record<string, string> | null };
export type K8sBaseListProps<T extends K8sEntityData> = {
export type K8sBaseListProps<
T extends K8sEntityData,
TItemKey extends string | SelectedItemParams = string,
> = {
controlListPrefix?: React.ReactNode;
leftFilters?: React.ReactNode;
entity: InfraMonitoringEntity;
@@ -69,8 +73,8 @@ export type K8sBaseListProps<T extends K8sEntityData> = {
}>;
/** Function to get the unique key for a row. */
getRowKey?: (record: T) => string;
/** Function to get the item key used for selection. Defaults to getRowKey if not provided. */
getItemKey?: (record: T) => string;
/** Function to get the item key used for selection. Can return string or SelectedItemParams. */
getItemKey?: (record: T) => TItemKey;
eventCategory: InfraMonitoringEvents;
renderEmptyState?: (
context: K8sBaseListEmptyStateContext,
@@ -78,7 +82,10 @@ export type K8sBaseListProps<T extends K8sEntityData> = {
extraQueryKeyParts?: string[];
};
export function K8sBaseList<T extends K8sEntityData>({
export function K8sBaseList<
T extends K8sEntityData,
TItemKey extends string | SelectedItemParams = string,
>({
controlListPrefix,
leftFilters,
entity,
@@ -89,17 +96,16 @@ export function K8sBaseList<T extends K8sEntityData>({
eventCategory,
renderEmptyState,
extraQueryKeyParts = [],
}: K8sBaseListProps<T>): JSX.Element {
}: K8sBaseListProps<T, TItemKey>): JSX.Element {
const { currentQuery } = useQueryBuilder();
const expression = currentQuery.builder.queryData[0]?.filter?.expression || '';
const lineClamp = useInfraMonitoringLineClamp();
const [groupBy] = useInfraMonitoringGroupBy();
const [orderBy] = useInfraMonitoringOrderBy();
const [statusFilter] = useInfraMonitoringStatusFilter();
const [selectedItem, setSelectedItem] = useQueryState(
'selectedItem',
parseAsString,
);
const [selectedItemParams, setSelectedItemParams] =
useInfraMonitoringSelectedItemParams();
const selectedItem = selectedItemParams.selectedItem;
const columnStorageKey = `k8s-${entity}-columns`;
const hiddenColumnIds = useHiddenColumnIds(columnStorageKey);
@@ -226,9 +232,17 @@ export function K8sBaseList<T extends K8sEntityData>({
}, [eventCategory, totalCount]);
const handleRowClick = useCallback(
(_record: T, itemKey: string): void => {
(_record: T, itemKey: TItemKey): void => {
if (groupBy.length === 0) {
void setSelectedItem(itemKey);
if (typeof itemKey === 'object' && itemKey !== null) {
setSelectedItemParams(itemKey);
} else {
setSelectedItemParams({
selectedItem: itemKey,
clusterName: null,
namespaceName: null,
});
}
}
void logEvent(InfraMonitoringEvents.ItemClicked, {
@@ -237,18 +251,43 @@ export function K8sBaseList<T extends K8sEntityData>({
category: eventCategory,
});
},
[eventCategory, groupBy.length, setSelectedItem],
[eventCategory, groupBy.length, setSelectedItemParams],
);
const handleRowClickNewTab = useCallback(
(_record: T, itemKey: string): void => {
(_record: T, itemKey: TItemKey): void => {
if (groupBy.length > 0) {
return;
}
// Build URL with selectedItem param
// Build URL with selectedItem params
const url = new URL(window.location.href);
url.searchParams.set('selectedItem', itemKey);
if (typeof itemKey === 'object' && itemKey !== null) {
const params = itemKey;
if (params.selectedItem) {
url.searchParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM,
params.selectedItem,
);
}
if (params.clusterName) {
url.searchParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME,
params.clusterName,
);
}
if (params.namespaceName) {
url.searchParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME,
params.namespaceName,
);
}
} else {
url.searchParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM,
itemKey,
);
}
openInNewTab(url.pathname + url.search);
void logEvent(InfraMonitoringEvents.ItemClicked, {
@@ -274,7 +313,7 @@ export function K8sBaseList<T extends K8sEntityData>({
rowKey: string,
groupMeta?: Record<string, string>,
): JSX.Element => (
<K8sExpandedRow<T>
<K8sExpandedRow<T, TItemKey>
rowKey={rowKey}
groupMeta={groupMeta}
entity={entity}
@@ -347,7 +386,7 @@ export function K8sBaseList<T extends K8sEntityData>({
{showEmptyState ? (
<div className={styles.emptyStateContainer}>{emptyTableMessage}</div>
) : (
<TanStackTable<T>
<TanStackTable<T, TItemKey>
data={pageData}
columns={tableColumns}
columnStorageKey={columnStorageKey}

View File

@@ -22,10 +22,11 @@ import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { InfraMonitoringEntity } from '../constants';
import {
SelectedItemParams,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringSelectedItem,
useInfraMonitoringSelectedItemParams,
} from '../hooks';
import { K8sBaseFilters } from './types';
@@ -34,7 +35,7 @@ import { buildExpressionFromGroupMeta } from './utils';
const EXPANDED_ROW_LIMIT = 10;
export type K8sExpandedRowProps<T> = {
export type K8sExpandedRowProps<T, TItemKey = string> = {
/** Pre-computed row key from parent table (includes group prefix + duplicate handling) */
rowKey: string;
/** Group metadata for building filters */
@@ -59,10 +60,10 @@ export type K8sExpandedRowProps<T> = {
/** Function to get the unique key for a row. */
getRowKey?: (record: T) => string;
/** Function to get the item key used for selection. Defaults to getRowKey if not provided. */
getItemKey?: (record: T) => string;
getItemKey?: (record: T) => TItemKey;
};
export function K8sExpandedRow<T>({
export function K8sExpandedRow<T, TItemKey = string>({
rowKey,
groupMeta,
entity,
@@ -71,13 +72,13 @@ export function K8sExpandedRow<T>({
extraQueryKeyParts = [],
getRowKey,
getItemKey,
}: K8sExpandedRowProps<T>): JSX.Element {
}: K8sExpandedRowProps<T, TItemKey>): JSX.Element {
const [, setGroupBy] = useInfraMonitoringGroupBy();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const { currentQuery } = useQueryBuilder();
const parentExpression =
currentQuery.builder.queryData[0]?.filter?.expression || '';
const [, setSelectedItem] = useInfraMonitoringSelectedItem();
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
const [, setMainOrderBy] = useInfraMonitoringOrderBy();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
@@ -175,10 +176,18 @@ export function K8sExpandedRow<T>({
const expandedData = data?.data ?? [];
const handleRowClick = useCallback(
(_row: T, itemKey: string): void => {
void setSelectedItem(itemKey);
(_row: T, itemKey: TItemKey): void => {
if (typeof itemKey === 'object' && itemKey !== null) {
setSelectedItemParams(itemKey as unknown as SelectedItemParams);
} else {
setSelectedItemParams({
selectedItem: itemKey as string,
clusterName: null,
namespaceName: null,
});
}
},
[setSelectedItem],
[setSelectedItemParams],
);
const handleViewAllClick = (): void => {
@@ -239,7 +248,7 @@ export function K8sExpandedRow<T>({
<div data-testid="expanded-table">
<TanStackTableStateProvider>
<TanStackTable<T>
<TanStackTable<T, TItemKey>
data={expandedData}
columns={tableColumns}
columnStorageKey={storageKey}

View File

@@ -22,6 +22,7 @@ import { openInNewTab } from 'utils/navigation';
import { TableColumnDef } from 'components/TanStackTableView';
import { InfraMonitoringEntity } from '../../constants';
import { SelectedItemParams } from '../../hooks';
window.ResizeObserver =
window.ResizeObserver ||
@@ -165,11 +166,14 @@ function createTestColumnsWithGroup(): TableColumnDef<TestItemWithGroup>[] {
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
function renderComponent<T extends K8sEntityData>({
function renderComponent<
T extends K8sEntityData,
TItemKey extends string | SelectedItemParams = string,
>({
queryParams,
onUrlUpdate,
...props
}: K8sBaseListProps<T> & {
}: K8sBaseListProps<T, TItemKey> & {
queryParams?: Record<string, string>;
onUrlUpdate?: OnUrlUpdateFunction;
}) {
@@ -196,7 +200,7 @@ function renderComponent<T extends K8sEntityData>({
value={{ viewportHeight: 800, itemHeight: 50 }}
>
<TooltipProvider>
<K8sBaseList {...props} />
<K8sBaseList<T, TItemKey> {...props} />
</TooltipProvider>
</VirtuosoMockContext.Provider>
</NuqsTestingAdapter>
@@ -941,4 +945,113 @@ describe('K8sBaseList', () => {
});
});
});
describe('with object itemKey (selectedItem + cluster + namespace params)', () => {
const itemId = 'obj-item';
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
const fetchListDataMock = jest.fn<
ReturnType<
NonNullable<K8sBaseListProps<TestItemWithTitle>['fetchListData']>
>,
Parameters<NonNullable<K8sBaseListProps<TestItemWithTitle>['fetchListData']>>
>();
const getLatestParam = (key: string): string | undefined =>
onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get(key))
.filter(Boolean)
.pop() as string | undefined;
beforeEach(() => {
onUrlUpdateMock.mockClear();
fetchListDataMock.mockClear();
openInNewTabMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [{ id: `PodId:${itemId}`, title: `PodTitle:${itemId}` }],
total: 1,
error: null,
});
});
it('should set selectedItem, cluster and namespace params on row click', async () => {
const user = userEvent.setup();
renderComponent<TestItemWithTitle, SelectedItemParams>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithTitle(),
getRowKey: (row): string => row.id,
getItemKey: (row): SelectedItemParams => ({
selectedItem: row.id,
clusterName: 'prod-cluster',
namespaceName: 'default-ns',
}),
});
const firstRowEl = await screen.findByText(`PodId:${itemId}`);
await user.click(firstRowEl);
await waitFor(() => {
expect(getLatestParam('selectedItem')).toBe(`PodId:${itemId}`);
expect(getLatestParam('selectedItemClusterName')).toBe('prod-cluster');
expect(getLatestParam('selectedItemNamespaceName')).toBe('default-ns');
});
});
it('should include cluster and namespace params in new tab URL on ctrl+click', async () => {
renderComponent<TestItemWithTitle, SelectedItemParams>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithTitle(),
getRowKey: (row): string => row.id,
getItemKey: (row): SelectedItemParams => ({
selectedItem: row.id,
clusterName: 'prod-cluster',
namespaceName: 'default-ns',
}),
});
const firstRow = await screen.findByText(`PodId:${itemId}`);
fireEvent.click(firstRow, { ctrlKey: true });
await waitFor(() => {
expect(openInNewTabMock).toHaveBeenCalledTimes(1);
});
const url = openInNewTabMock.mock.calls[0][0] as string;
expect(url).toContain(`selectedItem=PodId%3A${itemId}`);
expect(url).toContain('selectedItemClusterName=prod-cluster');
expect(url).toContain('selectedItemNamespaceName=default-ns');
});
it('should omit null cluster/namespace params in new tab URL', async () => {
renderComponent<TestItemWithTitle, SelectedItemParams>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithTitle(),
getRowKey: (row): string => row.id,
getItemKey: (row): SelectedItemParams => ({
selectedItem: row.id,
clusterName: null,
namespaceName: null,
}),
});
const firstRow = await screen.findByText(`PodId:${itemId}`);
fireEvent.click(firstRow, { ctrlKey: true });
await waitFor(() => {
expect(openInNewTabMock).toHaveBeenCalledTimes(1);
});
const url = openInNewTabMock.mock.calls[0][0] as string;
expect(url).toContain(`selectedItem=PodId%3A${itemId}`);
expect(url).not.toContain('selectedItemClusterName');
expect(url).not.toContain('selectedItemNamespaceName');
});
});
});

View File

@@ -0,0 +1,132 @@
import {
buildEventsExpression,
buildExpressionFromSelectedItemParams,
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
describe('buildExpressionFromSelectedItemParams', () => {
it('should build expression from params with all values', () => {
const result = buildExpressionFromSelectedItemParams(
{
selectedItem: 'nginx',
clusterName: 'prod',
namespaceName: 'default',
},
'k8s.deployment.name',
);
expect(result).toBe(
"k8s.deployment.name = 'nginx' AND k8s.cluster.name = 'prod' AND k8s.namespace.name = 'default'",
);
});
it('should skip null values', () => {
const result = buildExpressionFromSelectedItemParams(
{
selectedItem: 'nginx',
clusterName: null,
namespaceName: 'default',
},
'k8s.deployment.name',
);
expect(result).toBe(
"k8s.deployment.name = 'nginx' AND k8s.namespace.name = 'default'",
);
});
it('should handle only selectedItem', () => {
const result = buildExpressionFromSelectedItemParams(
{
selectedItem: 'pod-123',
clusterName: null,
namespaceName: null,
},
'k8s.pod.uid',
);
expect(result).toBe("k8s.pod.uid = 'pod-123'");
});
});
describe('buildEventsExpression', () => {
it('should build expression with kind, name, cluster and attribute-context namespace', () => {
const result = buildEventsExpression({
objectKind: 'Deployment',
objectName: 'nginx',
clusterName: 'prod',
namespaceName: 'default',
});
expect(result).toBe(
"k8s.object.kind = 'Deployment' AND k8s.object.name = 'nginx' AND k8s.cluster.name = 'prod' AND attribute.k8s.namespace.name = 'default'",
);
});
it('should always include kind and name, even when name is empty', () => {
const result = buildEventsExpression({
objectKind: 'Node',
objectName: '',
});
expect(result).toBe("k8s.object.kind = 'Node' AND k8s.object.name = ''");
});
it('should skip cluster and namespace clauses when values are missing', () => {
const result = buildEventsExpression({
objectKind: 'Pod',
objectName: 'pod-1',
clusterName: null,
namespaceName: undefined,
});
expect(result).toBe("k8s.object.kind = 'Pod' AND k8s.object.name = 'pod-1'");
});
it('should escape values with quotes', () => {
const result = buildEventsExpression({
objectKind: 'Pod',
objectName: "po'd",
namespaceName: 'ns-1',
});
expect(result).toBe(
`k8s.object.kind = 'Pod' AND k8s.object.name = 'po\\'d' AND attribute.k8s.namespace.name = 'ns-1'`,
);
});
});
describe('buildLogsTracesExpression', () => {
it('should build expression with main attribute, cluster and namespace', () => {
const result = buildLogsTracesExpression({
mainAttributeKey: 'k8s.deployment.name',
mainAttributeValue: 'nginx',
clusterName: 'prod',
namespaceName: 'default',
});
expect(result).toBe(
"k8s.deployment.name = 'nginx' AND k8s.cluster.name = 'prod' AND k8s.namespace.name = 'default'",
);
});
it('should skip clauses with empty values', () => {
const result = buildLogsTracesExpression({
mainAttributeKey: 'k8s.node.name',
mainAttributeValue: 'node-1',
clusterName: '',
namespaceName: null,
});
expect(result).toBe("k8s.node.name = 'node-1'");
});
it('should return empty string when all values are missing', () => {
const result = buildLogsTracesExpression({
mainAttributeKey: 'k8s.pod.name',
mainAttributeValue: '',
});
expect(result).toBe('');
});
});

View File

@@ -2,7 +2,12 @@ import { Badge } from '@signozhq/ui/badge';
import styles from './utils.module.scss';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import {
convertFiltersToExpression,
formatValueForExpression,
} from 'components/QueryBuilderV2/utils';
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
const dotToUnder: Record<string, string> = {
'os.type': 'os_type',
@@ -89,3 +94,91 @@ export function buildExpressionFromGroupMeta(
}
return parent || metaExpression;
}
export interface EventsExpressionParams {
objectKind: string;
objectName: string;
clusterName?: string | null;
namespaceName?: string | null;
}
export function buildEventsExpression(params: EventsExpressionParams): string {
const clauses: string[] = [
`${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = ${formatValueForExpression(params.objectKind)}`,
`${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${formatValueForExpression(params.objectName)}`,
];
if (params.clusterName) {
clauses.push(
`${INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME} = ${formatValueForExpression(params.clusterName)}`,
);
}
// the other attributes are resource., and fallbacks correctly without prefix
// this one needs attribute. prefix otherwise it fails the query
if (params.namespaceName) {
clauses.push(
`attribute.${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${formatValueForExpression(params.namespaceName)}`,
);
}
return clauses.join(' AND ');
}
export interface LogsTracesExpressionParams {
mainAttributeKey: string;
mainAttributeValue?: string | null;
clusterName?: string | null;
namespaceName?: string | null;
}
export function buildLogsTracesExpression(
params: LogsTracesExpressionParams,
): string {
const clauses: string[] = [];
if (params.mainAttributeValue) {
clauses.push(
`${params.mainAttributeKey} = ${formatValueForExpression(params.mainAttributeValue)}`,
);
}
if (params.clusterName) {
clauses.push(
`${INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME} = ${formatValueForExpression(params.clusterName)}`,
);
}
if (params.namespaceName) {
clauses.push(
`${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${formatValueForExpression(params.namespaceName)}`,
);
}
return clauses.join(' AND ');
}
export function buildExpressionFromSelectedItemParams(
params: SelectedItemParams,
mainAttributeKey: string,
): string {
const clauses: string[] = [];
if (params.selectedItem) {
clauses.push(
`${mainAttributeKey} = ${formatValueForExpression(params.selectedItem)}`,
);
}
if (params.clusterName) {
clauses.push(
`${INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME} = ${formatValueForExpression(params.clusterName)}`,
);
}
if (params.namespaceName) {
clauses.push(
`${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${formatValueForExpression(params.namespaceName)}`,
);
}
return clauses.join(' AND ');
}

View File

@@ -9,27 +9,35 @@ import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
buildEventsExpression,
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
export const k8sClusterGetSelectedItemExpression = (
selectedItemId: string,
): string => `k8s.cluster.name = ${formatValueForExpression(selectedItemId)}`;
params: SelectedItemParams,
): string =>
`k8s.cluster.name = ${formatValueForExpression(params.selectedItem ?? '')}`;
export const k8sClusterDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesClusterRecordDTO>[] =
[{ label: 'Cluster Name', getValue: (p): string => p.clusterName || '' }];
export const k8sClusterInitialEventsExpression = (
item: InframonitoringtypesClusterRecordDTO,
): string => {
const objectName = formatValueForExpression(item.clusterName || '');
return `${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = 'Cluster' AND ${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${objectName}`;
};
): string =>
buildEventsExpression({
objectKind: 'Cluster',
objectName: item.clusterName || '',
});
export const k8sClusterInitialLogTracesExpression = (
item: InframonitoringtypesClusterRecordDTO,
): string => {
const clusterName = formatValueForExpression(item.clusterName || '');
return `${INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME} = ${clusterName}`;
};
): string =>
buildLogsTracesExpression({
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
mainAttributeValue: item.clusterName,
});
export const k8sClusterGetEntityName = (
item: InframonitoringtypesClusterRecordDTO,

View File

@@ -10,6 +10,7 @@ import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
daemonSetWidgetInfo,
getDaemonSetMetricsQueryPayload,
@@ -111,7 +112,7 @@ function K8sDaemonSetsList({
);
return (
<>
<K8sBaseList<InframonitoringtypesDaemonSetRecordDTO>
<K8sBaseList<InframonitoringtypesDaemonSetRecordDTO, SelectedItemParams>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.DAEMONSETS}
tableColumns={k8sDaemonSetsColumnsConfig}

View File

@@ -7,13 +7,21 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
buildEventsExpression,
buildExpressionFromSelectedItemParams,
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
export const k8sDaemonSetGetSelectedItemExpression = (
selectedItemId: string,
params: SelectedItemParams,
): string =>
`${INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME} = ${formatValueForExpression(selectedItemId)}`;
buildExpressionFromSelectedItemParams(
params,
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
);
export const k8sDaemonSetDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesDaemonSetRecordDTO>[] =
[
@@ -37,12 +45,23 @@ export const k8sDaemonSetDetailsMetadataConfig: K8sDetailsMetadataConfig<Inframo
export const k8sDaemonSetInitialEventsExpression = (
item: InframonitoringtypesDaemonSetRecordDTO,
): string =>
`${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = 'DaemonSet' AND ${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${formatValueForExpression(item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ?? '')}`;
buildEventsExpression({
objectKind: 'DaemonSet',
objectName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ?? '',
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const k8sDaemonSetInitialLogTracesExpression = (
item: InframonitoringtypesDaemonSetRecordDTO,
): string =>
`${INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME} = ${formatValueForExpression(item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ?? '')} AND ${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${formatValueForExpression(item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '')}`;
buildLogsTracesExpression({
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
mainAttributeValue:
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME],
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const k8sDaemonSetGetEntityName = (
item: InframonitoringtypesDaemonSetRecordDTO,
@@ -115,6 +134,40 @@ export const getDaemonSetMetricsQueryPayload = (
? 'k8s.namespace.name'
: 'k8s_namespace_name';
const k8sClusterNameKey = dotMetricsEnabled
? 'k8s.cluster.name'
: 'k8s_cluster_name';
const clusterName =
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? '';
const namespaceName =
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '';
const filters = [
{
id: 'f1',
key: {
dataType: DataTypes.String,
id: 'k8s_cluster_name--string--tag--false',
key: k8sClusterNameKey,
type: 'tag',
},
op: '=',
value: clusterName,
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: namespaceName,
},
];
return [
{
selectedTime: 'GLOBAL_TIME',
@@ -148,19 +201,7 @@ export const getDaemonSetMetricsQueryPayload = (
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
'',
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -202,19 +243,7 @@ export const getDaemonSetMetricsQueryPayload = (
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
'',
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -256,19 +285,7 @@ export const getDaemonSetMetricsQueryPayload = (
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
'',
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -344,19 +361,7 @@ export const getDaemonSetMetricsQueryPayload = (
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
'',
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -398,19 +403,7 @@ export const getDaemonSetMetricsQueryPayload = (
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
'',
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -452,19 +445,7 @@ export const getDaemonSetMetricsQueryPayload = (
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
'',
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -540,19 +521,7 @@ export const getDaemonSetMetricsQueryPayload = (
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
'',
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -641,19 +610,7 @@ export const getDaemonSetMetricsQueryPayload = (
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ??
'',
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},

View File

@@ -6,6 +6,7 @@ import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodPhaseStatusItems } from '../commonUtils';
import {
CellValueTooltip,
@@ -31,8 +32,15 @@ export function getK8sDaemonSetRowKey(
export function getK8sDaemonSetItemKey(
daemonSet: InframonitoringtypesDaemonSetRecordDTO,
): string {
return daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] || '';
): SelectedItemParams {
return {
selectedItem:
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] ?? null,
clusterName:
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? null,
namespaceName:
daemonSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? null,
};
}
export type DaemonSetTableColumnConfig =

View File

@@ -11,6 +11,7 @@ import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
deploymentWidgetInfo,
getDeploymentMetricsQueryPayload,
@@ -117,7 +118,7 @@ function K8sDeploymentsList({
return (
<>
<K8sBaseList<InframonitoringtypesDeploymentRecordDTO>
<K8sBaseList<InframonitoringtypesDeploymentRecordDTO, SelectedItemParams>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.DEPLOYMENTS}
tableColumns={k8sDeploymentsColumnsConfig}

View File

@@ -7,13 +7,21 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
buildEventsExpression,
buildExpressionFromSelectedItemParams,
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
export const k8sDeploymentGetSelectedItemExpression = (
selectedItemId: string,
params: SelectedItemParams,
): string =>
`${INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME} = ${formatValueForExpression(selectedItemId)}`;
buildExpressionFromSelectedItemParams(
params,
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
);
export const k8sDeploymentDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesDeploymentRecordDTO>[] =
[
@@ -36,24 +44,24 @@ export const k8sDeploymentDetailsMetadataConfig: K8sDetailsMetadataConfig<Infram
export const k8sDeploymentInitialEventsExpression = (
item: InframonitoringtypesDeploymentRecordDTO,
): string => {
const objectName = formatValueForExpression(
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ?? '',
);
return `${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = 'Deployment' AND ${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${objectName}`;
};
): string =>
buildEventsExpression({
objectKind: 'Deployment',
objectName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ?? '',
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const k8sDeploymentInitialLogTracesExpression = (
item: InframonitoringtypesDeploymentRecordDTO,
): string => {
const deploymentName = formatValueForExpression(
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ?? '',
);
const namespaceName = formatValueForExpression(
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '',
);
return `${INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME} = ${deploymentName} AND ${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${namespaceName}`;
};
): string =>
buildLogsTracesExpression({
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
mainAttributeValue:
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME],
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const k8sDeploymentGetEntityName = (
item: InframonitoringtypesDeploymentRecordDTO,
@@ -121,6 +129,43 @@ export const getDeploymentMetricsQueryPayload = (
: 'k8s_deployment_name';
const k8sPodNameKey = dotMetricsEnabled ? 'k8s.pod.name' : 'k8s_pod_name';
const k8sClusterNameKey = dotMetricsEnabled
? 'k8s.cluster.name'
: 'k8s_cluster_name';
const k8sNamespaceNameKey = dotMetricsEnabled
? 'k8s.namespace.name'
: 'k8s_namespace_name';
const clusterName =
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? '';
const namespaceName =
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '';
const filters = [
{
id: 'f1',
key: {
dataType: DataTypes.String,
id: 'k8s_cluster_name--string--tag--false',
key: k8sClusterNameKey,
type: 'tag',
},
op: '=',
value: clusterName,
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: namespaceName,
},
];
return [
{
@@ -155,6 +200,7 @@ export const getDeploymentMetricsQueryPayload = (
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -196,6 +242,7 @@ export const getDeploymentMetricsQueryPayload = (
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -237,6 +284,7 @@ export const getDeploymentMetricsQueryPayload = (
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -312,6 +360,7 @@ export const getDeploymentMetricsQueryPayload = (
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -353,6 +402,7 @@ export const getDeploymentMetricsQueryPayload = (
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -394,6 +444,7 @@ export const getDeploymentMetricsQueryPayload = (
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -469,6 +520,7 @@ export const getDeploymentMetricsQueryPayload = (
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -557,6 +609,7 @@ export const getDeploymentMetricsQueryPayload = (
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ??
'',
},
...filters,
],
op: 'AND',
},

View File

@@ -6,6 +6,7 @@ import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodPhaseStatusItems } from '../commonUtils';
import {
CellValueTooltip,
@@ -31,8 +32,15 @@ export function getK8sDeploymentRowKey(
export function getK8sDeploymentItemKey(
deployment: InframonitoringtypesDeploymentRecordDTO,
): string {
return deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] || '';
): SelectedItemParams {
return {
selectedItem:
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] ?? null,
clusterName:
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? null,
namespaceName:
deployment.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? null,
};
}
export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDeploymentRecordDTO>[] =

View File

@@ -44,7 +44,7 @@ import {
useInfraMonitoringCategory,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringSelectedItem,
useInfraMonitoringSelectedItemParams,
} from './hooks';
import K8sJobsList from './Jobs/K8sJobsList';
import K8sNamespacesList from './Namespaces/K8sNamespacesList';
@@ -63,7 +63,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
const [, setGroupBy] = useInfraMonitoringGroupBy();
const [, setOrderBy] = useInfraMonitoringOrderBy();
const [, setSelectedItem] = useInfraMonitoringSelectedItem();
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
const compositeQuery = useGetCompositeQueryParam();
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
@@ -179,7 +179,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
void setSelectedCategory(key as string);
void setOrderBy(null);
void setGroupBy(null);
void setSelectedItem(null);
setSelectedItemParams(null);
redirectWithQueryBuilderData({
...currentQuery,
builder: {

View File

@@ -11,6 +11,7 @@ import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
getJobMetricsQueryPayload,
jobWidgetInfo,
@@ -117,7 +118,7 @@ function K8sJobsList({
return (
<>
<K8sBaseList<InframonitoringtypesJobRecordDTO>
<K8sBaseList<InframonitoringtypesJobRecordDTO, SelectedItemParams>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.JOBS}
tableColumns={k8sJobsColumnsConfig}

View File

@@ -7,13 +7,21 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
buildEventsExpression,
buildExpressionFromSelectedItemParams,
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
export const k8sJobGetSelectedItemExpression = (
selectedItemId: string,
params: SelectedItemParams,
): string =>
`${INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME} = ${formatValueForExpression(selectedItemId)}`;
buildExpressionFromSelectedItemParams(
params,
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
);
export const k8sJobDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesJobRecordDTO>[] =
[
@@ -36,24 +44,23 @@ export const k8sJobDetailsMetadataConfig: K8sDetailsMetadataConfig<Inframonitori
export const k8sJobInitialEventsExpression = (
item: InframonitoringtypesJobRecordDTO,
): string => {
const name = formatValueForExpression(
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME] ?? '',
);
return `${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = 'Job' AND ${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${name}`;
};
): string =>
buildEventsExpression({
objectKind: 'Job',
objectName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME] ?? '',
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const k8sJobInitialLogTracesExpression = (
item: InframonitoringtypesJobRecordDTO,
): string => {
const jobName = formatValueForExpression(
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME] ?? '',
);
const namespaceName = formatValueForExpression(
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '',
);
return `${INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME} = ${jobName} AND ${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${namespaceName}`;
};
): string =>
buildLogsTracesExpression({
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
mainAttributeValue: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME],
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const k8sJobGetEntityName = (
item: InframonitoringtypesJobRecordDTO,
@@ -97,10 +104,54 @@ export const getJobMetricsQueryPayload = (
? 'k8s.pod.network.errors'
: 'k8s_pod_network_errors';
const k8sJobNameKey = dotMetricsEnabled ? 'k8s.job.name' : 'k8s_job_name';
const k8sClusterNameKey = dotMetricsEnabled
? 'k8s.cluster.name'
: 'k8s_cluster_name';
const k8sNamespaceNameKey = dotMetricsEnabled
? 'k8s.namespace.name'
: 'k8s_namespace_name';
const clusterName =
job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? '';
const namespaceName =
job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '';
const filters = [
{
id: 'f1',
key: {
dataType: DataTypes.String,
id: 'k8s_job_name--string--tag--false',
key: k8sJobNameKey,
type: 'tag',
},
op: '=',
value: job.jobName,
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'k8s_cluster_name--string--tag--false',
key: k8sClusterNameKey,
type: 'tag',
},
op: '=',
value: clusterName,
},
{
id: 'f3',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: namespaceName,
},
];
return [
{
selectedTime: 'GLOBAL_TIME',
@@ -120,31 +171,7 @@ export const getJobMetricsQueryPayload = (
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '6b59b690',
key: {
dataType: DataTypes.String,
id: 'k8s_job_name--string--tag--false',
key: k8sJobNameKey,
type: 'tag',
},
op: '=',
value: job.jobName,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '',
},
],
items: [...filters],
op: 'AND',
},
functions: [],
@@ -205,31 +232,7 @@ export const getJobMetricsQueryPayload = (
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '8c217f4d',
key: {
dataType: DataTypes.String,
id: 'k8s_job_name--string--tag--false',
key: k8sJobNameKey,
type: 'tag',
},
op: '=',
value: job.jobName,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '',
},
],
items: [...filters],
op: 'AND',
},
functions: [],
@@ -290,31 +293,7 @@ export const getJobMetricsQueryPayload = (
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '2bbf9d0c',
key: {
dataType: DataTypes.String,
id: 'k8s_job_name--string--tag--false',
key: k8sJobNameKey,
type: 'tag',
},
op: '=',
value: job.jobName,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '',
},
],
items: [...filters],
op: 'AND',
},
functions: [],
@@ -388,31 +367,7 @@ export const getJobMetricsQueryPayload = (
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '448e6cf7',
key: {
dataType: DataTypes.String,
id: 'k8s_job_name--string--tag--false',
key: k8sJobNameKey,
type: 'tag',
},
op: '=',
value: job.jobName,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '',
},
],
items: [...filters],
op: 'AND',
},
functions: [],

View File

@@ -6,6 +6,7 @@ import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodPhaseStatusItems } from '../commonUtils';
import {
CellValueTooltip,
@@ -27,8 +28,13 @@ export function getK8sJobRowKey(job: InframonitoringtypesJobRecordDTO): string {
export function getK8sJobItemKey(
job: InframonitoringtypesJobRecordDTO,
): string {
return job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME] || '';
): SelectedItemParams {
return {
selectedItem: job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME] ?? null,
clusterName: job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? null,
namespaceName:
job.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? null,
};
}
export type JobTableColumnConfig =

View File

@@ -11,6 +11,7 @@ import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
getNamespaceMetricsQueryPayload,
k8sNamespaceDetailsMetadataConfig,
@@ -117,7 +118,7 @@ function K8sNamespacesList({
return (
<>
<K8sBaseList<InframonitoringtypesNamespaceRecordDTO>
<K8sBaseList<InframonitoringtypesNamespaceRecordDTO, SelectedItemParams>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.NAMESPACES}
tableColumns={k8sNamespacesColumnsConfig}

View File

@@ -7,13 +7,21 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
buildEventsExpression,
buildExpressionFromSelectedItemParams,
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
export const k8sNamespaceGetSelectedItemExpression = (
selectedItemId: string,
params: SelectedItemParams,
): string =>
`${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${formatValueForExpression(selectedItemId)}`;
buildExpressionFromSelectedItemParams(
params,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
);
export const k8sNamespaceDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesNamespaceRecordDTO>[] =
[
@@ -27,17 +35,21 @@ export const k8sNamespaceDetailsMetadataConfig: K8sDetailsMetadataConfig<Inframo
export const k8sNamespaceInitialEventsExpression = (
item: InframonitoringtypesNamespaceRecordDTO,
): string => {
const name = formatValueForExpression(item.namespaceName || '');
return `${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = 'Namespace' AND ${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${name}`;
};
): string =>
buildEventsExpression({
objectKind: 'Namespace',
objectName: item.namespaceName || '',
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
});
export const k8sNamespaceInitialLogTracesExpression = (
item: InframonitoringtypesNamespaceRecordDTO,
): string => {
const name = formatValueForExpression(item.namespaceName || '');
return `${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${name}`;
};
): string =>
buildLogsTracesExpression({
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
mainAttributeValue: item.namespaceName,
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
});
export const k8sNamespaceGetEntityName = (
item: InframonitoringtypesNamespaceRecordDTO,
@@ -179,6 +191,24 @@ export const getNamespaceMetricsQueryPayload = (
'k8s.deployment.name',
'k8s_deployment_name',
);
const k8sClusterNameKey = getKey('k8s.cluster.name', 'k8s_cluster_name');
const clusterName =
namespace.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? '';
const filters = [
{
id: 'f1',
key: {
dataType: DataTypes.String,
id: 'k8s_cluster_name--string--tag--false',
key: k8sClusterNameKey,
type: 'tag',
},
op: '=',
value: clusterName,
},
];
return [
{
@@ -211,6 +241,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -250,6 +281,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -289,6 +321,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -328,6 +361,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -401,6 +435,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -440,6 +475,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -479,6 +515,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -518,6 +555,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -557,6 +595,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -596,6 +635,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -669,6 +709,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -749,6 +790,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -829,6 +871,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -915,6 +958,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1001,6 +1045,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1047,6 +1092,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1093,6 +1139,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1173,6 +1220,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1225,6 +1273,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1311,6 +1360,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1357,6 +1407,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1403,6 +1454,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1449,6 +1501,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1529,6 +1582,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},
@@ -1575,6 +1629,7 @@ export const getNamespaceMetricsQueryPayload = (
op: '=',
value: namespace.namespaceName,
},
...filters,
],
op: 'AND',
},

View File

@@ -16,6 +16,7 @@ import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
} from '../constants';
import { SelectedItemParams } from '../hooks';
export function getK8sNamespaceRowKey(
namespace: InframonitoringtypesNamespaceRecordDTO,
@@ -29,8 +30,15 @@ export function getK8sNamespaceRowKey(
export function getK8sNamespaceItemKey(
namespace: InframonitoringtypesNamespaceRecordDTO,
): string {
return namespace.namespaceName;
): SelectedItemParams {
return {
selectedItem:
namespace.namespaceName ??
namespace.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
null,
clusterName:
namespace.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? null,
};
}
export type NamespaceTableColumnConfig =

View File

@@ -7,13 +7,19 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import {
buildEventsExpression,
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
export const k8sNodeGetSelectedItemExpression = (
selectedItemId: string,
): string => `k8s.node.name = ${formatValueForExpression(selectedItemId)}`;
params: SelectedItemParams,
): string =>
`k8s.node.name = ${formatValueForExpression(params.selectedItem ?? '')}`;
export const k8sNodeDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesNodeRecordDTO>[] =
[
@@ -28,12 +34,20 @@ export const k8sNodeDetailsMetadataConfig: K8sDetailsMetadataConfig<Inframonitor
export const k8sNodeInitialEventsExpression = (
item: InframonitoringtypesNodeRecordDTO,
): string =>
`${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = 'Node' AND ${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${formatValueForExpression(item.nodeName || '')}`;
buildEventsExpression({
objectKind: 'Node',
objectName: item.nodeName || '',
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
});
export const k8sNodeInitialLogTracesExpression = (
item: InframonitoringtypesNodeRecordDTO,
): string =>
`${INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME} = ${formatValueForExpression(item.nodeName || '')} AND ${INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME} = ${formatValueForExpression(item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] || '')}`;
buildLogsTracesExpression({
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
mainAttributeValue: item.nodeName,
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
});
export const k8sNodeGetEntityName = (
item: InframonitoringtypesNodeRecordDTO,

View File

@@ -8,11 +8,17 @@ import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import {
buildEventsExpression,
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
export const k8sPodGetSelectedItemExpression = (
selectedItemId: string,
): string => `k8s.pod.uid = ${formatValueForExpression(selectedItemId)}`;
params: SelectedItemParams,
): string =>
`k8s.pod.uid = ${formatValueForExpression(params.selectedItem ?? '')}`;
export const k8sPodDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesPodRecordDTO>[] =
[
@@ -35,21 +41,23 @@ export const k8sPodDetailsMetadataConfig: K8sDetailsMetadataConfig<Inframonitori
export const k8sPodInitialEventsExpression = (
pod: InframonitoringtypesPodRecordDTO,
): string => {
const podName = formatValueForExpression(
pod.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
);
return `${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = 'Pod' AND ${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${podName}`;
};
): string =>
buildEventsExpression({
objectKind: 'Pod',
objectName: pod.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
clusterName: pod.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName: pod.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const k8sPodInitialLogTracesExpression = (
pod: InframonitoringtypesPodRecordDTO,
): string => {
const podName = formatValueForExpression(
pod.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
);
return `${INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME} = ${podName}`;
};
): string =>
buildLogsTracesExpression({
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
mainAttributeValue: pod.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME],
clusterName: pod.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName: pod.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const k8sPodGetEntityName = (
pod: InframonitoringtypesPodRecordDTO,

View File

@@ -11,6 +11,7 @@ import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
getStatefulSetMetricsQueryPayload,
k8sStatefulSetDetailsMetadataConfig,
@@ -117,7 +118,7 @@ function K8sStatefulSetsList({
return (
<>
<K8sBaseList<InframonitoringtypesStatefulSetRecordDTO>
<K8sBaseList<InframonitoringtypesStatefulSetRecordDTO, SelectedItemParams>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.STATEFULSETS}
tableColumns={k8sStatefulSetsColumnsConfig}

View File

@@ -7,13 +7,21 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
buildEventsExpression,
buildExpressionFromSelectedItemParams,
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
export const k8sStatefulSetGetSelectedItemExpression = (
selectedItemId: string,
params: SelectedItemParams,
): string =>
`${INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME} = ${formatValueForExpression(selectedItemId)}`;
buildExpressionFromSelectedItemParams(
params,
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
);
export const k8sStatefulSetDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesStatefulSetRecordDTO>[] =
[
@@ -22,6 +30,11 @@ export const k8sStatefulSetDetailsMetadataConfig: K8sDetailsMetadataConfig<Infra
getValue: (p): string =>
p.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME] ?? '',
},
{
label: 'Cluster Name',
getValue: (p): string =>
p.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? '',
},
{
label: 'Namespace Name',
getValue: (p): string =>
@@ -31,24 +44,25 @@ export const k8sStatefulSetDetailsMetadataConfig: K8sDetailsMetadataConfig<Infra
export const k8sStatefulSetInitialEventsExpression = (
item: InframonitoringtypesStatefulSetRecordDTO,
): string => {
const objectName = formatValueForExpression(
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME] ?? '',
);
return `${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = 'StatefulSet' AND ${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${objectName}`;
};
): string =>
buildEventsExpression({
objectKind: 'StatefulSet',
objectName:
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME] ?? '',
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const k8sStatefulSetInitialLogTracesExpression = (
item: InframonitoringtypesStatefulSetRecordDTO,
): string => {
const statefulSetName = formatValueForExpression(
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME] ?? '',
);
const namespaceName = formatValueForExpression(
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '',
);
return `${INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME} = ${statefulSetName} AND ${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${namespaceName}`;
};
): string =>
buildLogsTracesExpression({
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
mainAttributeValue:
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME],
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const k8sStatefulSetGetEntityName = (
item: InframonitoringtypesStatefulSetRecordDTO,
@@ -93,7 +107,42 @@ export const getStatefulSetMetricsQueryPayload = (
const k8sNamespaceNameKey = dotMetricsEnabled
? INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME
: 'k8s_namespace_name';
const k8sPodNameKey = dotMetricsEnabled ? 'k8s.pod.name' : 'k8s_pod_name';
const k8sPodNameKey = dotMetricsEnabled
? INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME
: 'k8s_pod_name';
const k8sClusterNameKey = dotMetricsEnabled
? INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME
: 'k8s_cluster_name';
const clusterName =
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? '';
const namespaceName =
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? '';
const filters = [
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: namespaceName,
},
{
id: 'f3',
key: {
dataType: DataTypes.String,
id: 'k8s_cluster_name--string--tag--false',
key: k8sClusterNameKey,
type: 'tag',
},
op: '=',
value: clusterName,
},
];
const k8sPodCpuUtilKey = dotMetricsEnabled
? 'k8s.pod.cpu.usage'
@@ -168,19 +217,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -223,19 +260,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -278,19 +303,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -353,19 +366,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -408,19 +409,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -483,19 +472,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -525,7 +502,7 @@ export const getStatefulSetMetricsQueryPayload = (
filters: {
items: [
{
id: 'f3',
id: 'f1',
key: {
dataType: DataTypes.String,
id: 'pod_name',
@@ -538,19 +515,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -593,19 +558,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -668,19 +621,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -723,19 +664,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -798,19 +727,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},
@@ -886,19 +803,7 @@ export const getStatefulSetMetricsQueryPayload = (
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME
] ?? '',
},
{
id: 'f2',
key: {
dataType: DataTypes.String,
id: 'ns_name',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ??
'',
},
...filters,
],
op: 'AND',
},

View File

@@ -6,6 +6,7 @@ import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodPhaseStatusItems } from '../commonUtils';
import {
CellValueTooltip,
@@ -31,10 +32,15 @@ export function getK8sStatefulSetRowKey(
export function getK8sStatefulSetItemKey(
statefulSet: InframonitoringtypesStatefulSetRecordDTO,
): string {
return (
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME] || ''
);
): SelectedItemParams {
return {
selectedItem:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME] ?? null,
clusterName:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? null,
namespaceName:
statefulSet.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? null,
};
}
export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesStatefulSetRecordDTO>[] =

View File

@@ -11,6 +11,7 @@ import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
getVolumeMetricsQueryPayload,
k8sVolumeDetailsMetadataConfig,
@@ -117,7 +118,7 @@ function K8sVolumesList({
return (
<>
<K8sBaseList<InframonitoringtypesVolumeRecordDTO>
<K8sBaseList<InframonitoringtypesVolumeRecordDTO, SelectedItemParams>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.VOLUMES}
tableColumns={k8sVolumesColumnsConfig}

View File

@@ -6,15 +6,22 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
buildEventsExpression,
buildExpressionFromSelectedItemParams,
buildLogsTracesExpression,
} from 'container/InfraMonitoringK8sV2/Base/utils';
export const k8sVolumeGetSelectedItemExpression = (
selectedItemId: string,
params: SelectedItemParams,
): string =>
`${INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME} = ${formatValueForExpression(selectedItemId)}`;
buildExpressionFromSelectedItemParams(
params,
INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
);
export const k8sVolumeDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesVolumeRecordDTO>[] =
[
@@ -36,22 +43,23 @@ export const k8sVolumeDetailsMetadataConfig: K8sDetailsMetadataConfig<Inframonit
export const k8sVolumeInitialEventsExpression = (
item: InframonitoringtypesVolumeRecordDTO,
): string => {
const objectName = formatValueForExpression(
item.persistentVolumeClaimName || '',
);
return `${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_KIND} = 'PersistentVolumeClaim' AND ${INFRA_MONITORING_ATTR_KEYS.K8S_OBJECT_NAME} = ${objectName}`;
};
): string =>
buildEventsExpression({
objectKind: 'PersistentVolumeClaim',
objectName: item.persistentVolumeClaimName || '',
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const k8sVolumeInitialLogTracesExpression = (
item: InframonitoringtypesVolumeRecordDTO,
): string => {
const pvcName = formatValueForExpression(item.persistentVolumeClaimName || '');
const namespaceName = formatValueForExpression(
item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] || '',
);
return `${INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME} = ${pvcName} AND ${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${namespaceName}`;
};
): string =>
buildLogsTracesExpression({
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
mainAttributeValue: item.persistentVolumeClaimName,
clusterName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName: item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const k8sVolumeGetEntityName = (
item: InframonitoringtypesVolumeRecordDTO,

View File

@@ -1,6 +1,5 @@
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { InframonitoringtypesVolumeRecordDTO } from 'api/generated/services/sigNoz.schemas';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
@@ -12,6 +11,7 @@ import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
} from '../constants';
import { SelectedItemParams } from '../hooks';
import { HardDrive } from '@signozhq/icons';
export function getK8sVolumeRowKey(
@@ -26,8 +26,17 @@ export function getK8sVolumeRowKey(
export function getK8sVolumeItemKey(
volume: InframonitoringtypesVolumeRecordDTO,
): string {
return volume.persistentVolumeClaimName;
): SelectedItemParams {
return {
selectedItem:
volume.persistentVolumeClaimName ??
volume.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME] ??
null,
clusterName:
volume.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] ?? null,
namespaceName:
volume.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] ?? null,
};
}
export type VolumeTableColumnConfig =

View File

@@ -897,6 +897,8 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
PAGE_SIZE: 'pageSize',
EXPANDED: 'expanded',
SELECTED_ITEM: 'selectedItem',
SELECTED_ITEM_CLUSTER_NAME: 'selectedItemClusterName',
SELECTED_ITEM_NAMESPACE_NAME: 'selectedItemNamespaceName',
};
/** Metric namespace prefixes for /fields/keys and /fields/values APIs */

View File

@@ -5,8 +5,10 @@ import {
parseAsJson,
parseAsString,
useQueryState,
useQueryStates,
UseQueryStateReturn,
} from 'nuqs';
import { useCallback, useMemo } from 'react';
import {
IBuilderQuery,
TagFilter,
@@ -128,16 +130,70 @@ export const useInfraMonitoringCategory = (): UseQueryStateReturn<
parseAsString.withDefault(K8sCategories.PODS).withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringSelectedItem = (): UseQueryStateReturn<
string,
string | undefined
> => {
return useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM,
parseAsString,
);
export interface SelectedItemParams {
selectedItem: string | null;
clusterName?: string | null;
namespaceName?: string | null;
}
const selectedItemParamsParsers = {
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: parseAsString,
};
export type UseSelectedItemParamsReturn = [
SelectedItemParams,
(params: SelectedItemParams | null) => void,
];
export const useInfraMonitoringSelectedItemParams =
(): UseSelectedItemParamsReturn => {
const [rawParams, setRawParams] = useQueryStates(
selectedItemParamsParsers,
defaultNuqsOptions,
);
const params: SelectedItemParams = useMemo(
() => ({
selectedItem:
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM] ?? null,
clusterName:
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME] ??
null,
namespaceName:
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME] ??
null,
}),
[rawParams],
);
const setParams = useCallback(
(newParams: Partial<SelectedItemParams> | null): void => {
if (newParams === null) {
void setRawParams({
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: null,
});
return;
}
void setRawParams({
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]:
newParams.selectedItem ?? null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]:
newParams.clusterName ?? null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]:
newParams.namespaceName ?? null,
});
},
[setRawParams],
);
return [params, setParams];
};
export const useInfraMonitoringStatusFilter = (): UseQueryStateReturn<
string,
string

View File

@@ -11,6 +11,7 @@ import {
import { useSelector } from 'react-redux';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { ResizeTable } from 'components/ResizeTable';
import { ENTITY_VERSION_V5 } from 'constants/app';
@@ -152,6 +153,11 @@ function TracesView({
</Typography>
<div className="trace-explorer-controls">
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TRACE}
/>
<TraceExplorerControls
isLoading={isLoading}
totalCount={responseData?.length || 0}

View File

@@ -19,6 +19,7 @@ interface ExportOptions {
interface UseExportRawDataProps {
dataSource: DataSource;
panelType?: PANEL_TYPES;
}
interface UseExportRawDataReturn {
@@ -28,6 +29,7 @@ interface UseExportRawDataReturn {
export function useExportRawData({
dataSource,
panelType = PANEL_TYPES.LIST,
}: UseExportRawDataProps): UseExportRawDataReturn {
const [isDownloading, setIsDownloading] = useState<boolean>(false);
@@ -83,7 +85,7 @@ export function useExportRawData({
const { queryPayload } = prepareQueryRangePayloadV5({
query: exportQuery,
graphType: PANEL_TYPES.LIST,
graphType: panelType,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval,
});
@@ -96,7 +98,7 @@ export function useExportRawData({
setIsDownloading(false);
}
},
[stagedQuery, globalSelectedInterval, dataSource],
[stagedQuery, globalSelectedInterval, dataSource, panelType],
);
return { isDownloading, handleExportRawData };

View File

@@ -0,0 +1,94 @@
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { exportScalarData } from '../exportScalarData';
const query = {
queryType: 'builder',
builder: {
queryData: [
{
queryName: 'A',
dataSource: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [
{ key: 'service.name', dataType: 'string', type: 'tag', id: 'svc' },
],
legend: '',
},
],
queryFormulas: [],
},
} as unknown as Query;
function makeResponse(
tables: {
queryName: string;
columns: { name: string; id?: string; isValueColumn: boolean }[];
rows: Record<string, string | number>[];
}[],
): SuccessResponse<MetricRangePayloadProps> {
return {
statusCode: 200,
error: null,
message: '',
payload: {
data: {
resultType: 'scalar',
result: tables.map((table) => ({
queryName: table.queryName,
legend: '',
series: null,
list: null,
table: {
columns: table.columns.map((col) => ({
...col,
queryName: table.queryName,
})),
rows: table.rows.map((row) => ({ data: row })),
},
})),
},
},
} as unknown as SuccessResponse<MetricRangePayloadProps>;
}
describe('exportScalarData', () => {
it('serializes the table exactly as QueryTable prepares it', () => {
const data = makeResponse([
{
queryName: 'A',
columns: [
{ name: 'service.name', id: 'service.name', isValueColumn: false },
{ name: 'count()', id: 'A', isValueColumn: true },
],
rows: [
{ 'service.name': 'frontend', A: 120 },
{ 'service.name': 'cart', A: 80 },
],
},
]);
const table = exportScalarData({ data, query });
// group + aggregation columns, raw values, on-screen order — inherited
// 1:1 from createTableColumnsFromQuery (the renderer's own preparer)
expect(table).toStrictEqual({
headers: ['service.name', 'count()'],
rows: [
['frontend', 120],
['cart', 80],
],
});
});
it('returns an empty table for an empty response', () => {
const table = exportScalarData({
data: makeResponse([]),
query,
});
expect(table.rows).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,58 @@
import { exportTableData } from '../exportTableData';
const columns = [
{ name: 'service.name', key: 'service.name' },
{ name: 'count()', key: 'A', isValueColumn: true },
{ name: 'avg(duration)', key: 'B', isValueColumn: true },
];
describe('exportTableData', () => {
it('serializes raw values in display column order', () => {
const table = exportTableData({
columns,
dataSource: [
{ 'service.name': 'frontend', A: 120, B: 45.5 },
{ 'service.name': 'cart', A: 80, B: 12 },
],
});
expect(table).toStrictEqual({
headers: ['service.name', 'count()', 'avg(duration)'],
rows: [
['frontend', 120, 45.5],
['cart', 80, 12],
],
});
});
it('appends column units to value columns only, skipping display-only ids', () => {
const table = exportTableData({
columns,
dataSource: [{ 'service.name': 'frontend', A: 120, B: 45.5 }],
columnUnits: { A: 'short', B: 'ms', 'service.name': 'ms' },
});
// group column never gets a unit; 'short' is display-only and skipped
expect(table.headers).toStrictEqual([
'service.name',
'count()',
'avg(duration) (ms)',
]);
});
it('marks missing cells as blank gaps', () => {
const table = exportTableData({
columns,
dataSource: [{ 'service.name': 'frontend', A: 120 }],
});
expect(table.rows).toStrictEqual([['frontend', 120, '']]);
});
it('returns a headers-only table for empty data', () => {
expect(exportTableData({ columns, dataSource: [] })).toStrictEqual({
headers: ['service.name', 'count()', 'avg(duration)'],
rows: [],
});
});
});

View File

@@ -0,0 +1,48 @@
import { createTableColumnsFromQuery } from 'lib/query/createTableColumnsFromQuery';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { exportTableData } from './exportTableData';
import { SerializedTable } from './types';
interface ExportScalarDataArgs {
// The queryRange response object the table mount already holds (the
// formatForWeb payload carrying webTables).
data?: SuccessResponse<MetricRangePayloadProps>;
query: Query;
}
/**
* Serializes a scalar/table queryRange response into a table — via
* createTableColumnsFromQuery, the exact preparer QueryTable renders from, so
* the export inherits the on-screen merge, naming and column order 1:1.
*/
export function exportScalarData({
data,
query,
}: ExportScalarDataArgs): SerializedTable {
const queryTableData = (data?.payload?.data?.newResult?.data?.result ||
data?.payload?.data?.result ||
[]) as QueryDataV3[];
const { columns, dataSource } = createTableColumnsFromQuery({
query,
queryTableData,
});
return exportTableData({
// antd widens title/dataIndex; createTableColumnsFromQuery always sets strings
columns: columns.map((column) => {
const rawIndex = 'dataIndex' in column ? column.dataIndex : undefined;
const key =
typeof rawIndex === 'string' || typeof rawIndex === 'number'
? String(rawIndex)
: '';
const name = typeof column.title === 'string' ? column.title : key;
return { name, key: key || name };
}),
dataSource: dataSource as unknown as Record<string, unknown>[],
});
}

View File

@@ -0,0 +1,46 @@
import { SerializedTable } from './types';
import { withUnit } from './withUnit';
/** Generic table-model column — any prepared table (QueryTable, dashboard
* tables, plain antd tables) adapts to this in a line or two. */
export interface ExportTableColumn {
/** Display name, used as the export header (column order = array order). */
name: string;
/** Key into each dataSource record. */
key: string;
isValueColumn?: boolean;
}
interface ExportTableDataArgs {
columns: ExportTableColumn[];
dataSource: Record<string, unknown>[];
/** Per-column display unit, keyed by column key (dashboards; absent in explorer). */
columnUnits?: Record<string, string>;
}
/**
* Serializes a prepared table model into a format-agnostic table — raw values
* in display column order (lossless; no cell formatting applied).
*/
export function exportTableData({
columns,
dataSource,
columnUnits,
}: ExportTableDataArgs): SerializedTable {
const headers = columns.map((column) =>
column.isValueColumn
? withUnit(column.name, columnUnits?.[column.key])
: column.name,
);
const rows = dataSource.map((record) =>
columns.map((column) => {
const value = record[column.key];
return value === undefined || value === null
? ''
: (value as string | number);
}),
);
return { headers, rows };
}

View File

@@ -5,6 +5,7 @@ import { TimeSeries, TimeSeriesData } from 'types/api/v5/queryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import { SerializedTable } from './types';
import { withUnit } from './withUnit';
interface ExportTimeseriesDataArgs {
data: TimeSeriesData[];
@@ -98,18 +99,6 @@ function flatten(
return flat;
}
// Display-format ids, not physical units — meaningful on a chart axis
// (compact-number formatting) but misleading in an export header.
const DISPLAY_ONLY_UNITS = new Set(['short', 'none']);
// Appends the y-axis unit to the value header: `value` → `value (ms)`.
function withUnit(header: string, yAxisUnit?: string): string {
if (!yAxisUnit || DISPLAY_ONLY_UNITS.has(yAxisUnit)) {
return header;
}
return `${header} (${yAxisUnit})`;
}
function toIso(timestamp: number): string {
return new Date(timestamp).toISOString();
}

View File

@@ -0,0 +1,11 @@
// Display-format ids, not physical units — meaningful on a chart axis
// (compact-number formatting) but misleading in an export header.
const DISPLAY_ONLY_UNITS = new Set(['short', 'none']);
/** Appends a unit to a header: `value` → `value (ms)`. Skips display-only ids. */
export function withUnit(header: string, unit?: string): string {
if (!unit || DISPLAY_ONLY_UNITS.has(unit)) {
return header;
}
return `${header} (${unit})`;
}

6
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
@@ -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

25
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=

View File

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

View File

@@ -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{

View File

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

View File

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

View File

@@ -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

@@ -357,10 +357,7 @@ func (m *Manager) EditRule(ctx context.Context, ruleStr string, id valuer.UUID)
if err != nil {
return err
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
return err
}
orgID := valuer.MustNewUUID(claims.OrgID)
parsedRule := ruletypes.PostableRule{}
err = json.Unmarshal([]byte(ruleStr), &parsedRule)
if err != nil {
@@ -372,7 +369,7 @@ func (m *Manager) EditRule(ctx context.Context, ruleStr string, id valuer.UUID)
if err := m.validateChannels(ctx, claims.OrgID, &parsedRule); err != nil {
return err
}
existingRule, err := m.ruleStore.GetStoredRule(ctx, id)
existingRule, err := m.ruleStore.GetStoredRule(ctx, orgID, id)
if err != nil {
return err
}
@@ -485,17 +482,14 @@ func (m *Manager) DeleteRule(ctx context.Context, idStr string) error {
return err
}
_, err = m.ruleStore.GetStoredRule(ctx, id)
orgID := valuer.MustNewUUID(claims.OrgID)
_, err = m.ruleStore.GetStoredRule(ctx, orgID, id)
if err != nil {
return err
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
return err
}
return m.ruleStore.DeleteRule(ctx, id, func(ctx context.Context) error {
return m.ruleStore.DeleteRule(ctx, orgID, id, func(ctx context.Context) error {
cfg, err := m.alertmanager.GetConfig(ctx, claims.OrgID)
if err != nil {
return err
@@ -886,7 +880,14 @@ func (m *Manager) ListRuleStates(ctx context.Context) (*ruletypes.GettableRules,
}
func (m *Manager) GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error) {
s, err := m.ruleStore.GetStoredRule(ctx, id)
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, err
}
orgID := valuer.MustNewUUID(claims.OrgID)
s, err := m.ruleStore.GetStoredRule(ctx, orgID, id)
if err != nil {
return nil, err
}
@@ -951,15 +952,12 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
return nil, err
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
return nil, err
}
orgID := valuer.MustNewUUID(claims.OrgID)
taskName := prepareTaskName(id.StringValue())
// retrieve rule from DB
storedJSON, err := m.ruleStore.GetStoredRule(ctx, id)
storedJSON, err := m.ruleStore.GetStoredRule(ctx, orgID, id)
if err != nil {
m.logger.ErrorContext(ctx, "failed to get stored rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,13 +50,13 @@ func (m *MockSQLRuleStore) EditRule(ctx context.Context, rule *ruletypes.Storabl
}
// DeleteRule implements ruletypes.RuleStore - delegates to underlying ruleStore to trigger SQL.
func (m *MockSQLRuleStore) DeleteRule(ctx context.Context, id valuer.UUID, fn func(context.Context) error) error {
return m.ruleStore.DeleteRule(ctx, id, fn)
func (m *MockSQLRuleStore) DeleteRule(ctx context.Context, orgID valuer.UUID, id valuer.UUID, fn func(context.Context) error) error {
return m.ruleStore.DeleteRule(ctx, orgID, id, fn)
}
// GetStoredRule implements ruletypes.RuleStore - delegates to underlying ruleStore to trigger SQL.
func (m *MockSQLRuleStore) GetStoredRule(ctx context.Context, id valuer.UUID) (*ruletypes.StorableRule, error) {
return m.ruleStore.GetStoredRule(ctx, id)
func (m *MockSQLRuleStore) GetStoredRule(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*ruletypes.StorableRule, error) {
return m.ruleStore.GetStoredRule(ctx, orgID, id)
}
// GetStoredRules implements ruletypes.RuleStore - delegates to underlying ruleStore to trigger SQL.
@@ -82,14 +82,14 @@ func (m *MockSQLRuleStore) ExpectCreateRule(rule *ruletypes.StorableRule) {
// ExpectEditRule sets up SQL expectations for EditRule operation.
func (m *MockSQLRuleStore) ExpectEditRule(rule *ruletypes.StorableRule) {
expectedPattern := `UPDATE "rule".+` + rule.UpdatedBy + `.+` + rule.OrgID + `.+WHERE \(id = '` + rule.ID.StringValue() + `'\)`
expectedPattern := `UPDATE "rule".+` + rule.UpdatedBy + `.+` + rule.OrgID + `.+WHERE \(org_id = '` + rule.OrgID + `'\) AND \(id = '` + rule.ID.StringValue() + `'\)`
m.mock.ExpectExec(expectedPattern).
WillReturnResult(sqlmock.NewResult(1, 1))
}
// ExpectDeleteRule sets up SQL expectations for DeleteRule operation.
func (m *MockSQLRuleStore) ExpectDeleteRule(ruleID valuer.UUID) {
expectedPattern := `DELETE FROM "rule".+WHERE \(id = '` + ruleID.StringValue() + `'\)`
expectedPattern := `DELETE FROM "rule".+WHERE \(org_id = '.+'\) AND \(id = '` + ruleID.StringValue() + `'\)`
m.mock.ExpectExec(expectedPattern).
WillReturnResult(sqlmock.NewResult(1, 1))
}
@@ -98,7 +98,7 @@ func (m *MockSQLRuleStore) ExpectDeleteRule(ruleID valuer.UUID) {
func (m *MockSQLRuleStore) ExpectGetStoredRule(ruleID valuer.UUID, rule *ruletypes.StorableRule) {
rows := sqlmock.NewRows([]string{"id", "created_at", "updated_at", "created_by", "updated_by", "deleted", "data", "org_id"}).
AddRow(rule.ID, rule.CreatedAt, rule.UpdatedAt, rule.CreatedBy, rule.UpdatedBy, rule.Deleted, rule.Data, rule.OrgID)
expectedPattern := `SELECT (.+) FROM "rule".+WHERE \(id = '` + ruleID.StringValue() + `'\)`
expectedPattern := `SELECT (.+) FROM "rule".+WHERE \(org_id = '.+'\) AND \(id = '` + ruleID.StringValue() + `'\)`
m.mock.ExpectQuery(expectedPattern).
WillReturnRows(rows)
}

View File

@@ -57,6 +57,7 @@ func (r *rule) EditRule(ctx context.Context, storedRule *ruletypes.StorableRule,
BunDBCtx(ctx).
NewUpdate().
Model(storedRule).
Where("org_id = ?", storedRule.OrgID).
Where("id = ?", storedRule.ID.StringValue()).
Exec(ctx)
if err != nil {
@@ -67,12 +68,13 @@ func (r *rule) EditRule(ctx context.Context, storedRule *ruletypes.StorableRule,
})
}
func (r *rule) DeleteRule(ctx context.Context, id valuer.UUID, cb func(context.Context) error) error {
func (r *rule) DeleteRule(ctx context.Context, orgID valuer.UUID, id valuer.UUID, cb func(context.Context) error) error {
if err := r.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
_, err := r.sqlstore.
BunDBCtx(ctx).
NewDelete().
Model(new(ruletypes.StorableRule)).
Where("org_id = ?", orgID.StringValue()).
Where("id = ?", id.StringValue()).
Exec(ctx)
if err != nil {
@@ -102,12 +104,13 @@ func (r *rule) GetStoredRules(ctx context.Context, orgID string) ([]*ruletypes.S
return rules, nil
}
func (r *rule) GetStoredRule(ctx context.Context, id valuer.UUID) (*ruletypes.StorableRule, error) {
func (r *rule) GetStoredRule(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*ruletypes.StorableRule, error) {
rule := new(ruletypes.StorableRule)
err := r.sqlstore.
BunDB().
NewSelect().
Model(rule).
Where("org_id = ?", orgID.StringValue()).
Where("id = ?", id.StringValue()).
Scan(ctx)
if err != nil {

View File

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

View File

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

View File

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

View File

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

View File

@@ -56,8 +56,8 @@ type RuleAlert struct {
type RuleStore interface {
CreateRule(context.Context, *StorableRule, func(context.Context, valuer.UUID) error) (valuer.UUID, error)
EditRule(context.Context, *StorableRule, func(context.Context) error) error
DeleteRule(context.Context, valuer.UUID, func(context.Context) error) error
DeleteRule(context.Context, valuer.UUID, valuer.UUID, func(context.Context) error) error
GetStoredRules(context.Context, string) ([]*StorableRule, error)
GetStoredRule(context.Context, valuer.UUID) (*StorableRule, error)
GetStoredRule(context.Context, valuer.UUID, valuer.UUID) (*StorableRule, error)
GetStoredRulesByMetricName(context.Context, string, string) ([]RuleAlert, error)
}

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

@@ -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"]]