mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-10 13:30:42 +01:00
Compare commits
9 Commits
feat/query
...
issue_5329
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a849eac876 | ||
|
|
5258f5e8e3 | ||
|
|
8292988751 | ||
|
|
6e96a5ce37 | ||
|
|
d5af6f6d6b | ||
|
|
e7c48e964f | ||
|
|
fe8c9d8cf4 | ||
|
|
2ad3057fa6 | ||
|
|
9fc73fc0b1 |
@@ -397,7 +397,6 @@ identn:
|
||||
# headers to use for tokenizer identN resolver
|
||||
headers:
|
||||
- Authorization
|
||||
- Sec-WebSocket-Protocol
|
||||
apikey:
|
||||
# toggle apikey identN
|
||||
enabled: true
|
||||
|
||||
@@ -9427,6 +9427,8 @@ components:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
origin:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
@@ -9439,6 +9441,7 @@ components:
|
||||
- fieldContext
|
||||
- config
|
||||
- enabled
|
||||
- origin
|
||||
type: object
|
||||
SpantypesSpanMapperConfig:
|
||||
properties:
|
||||
@@ -9467,48 +9470,75 @@ components:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
origin:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
version:
|
||||
type: integer
|
||||
required:
|
||||
- id
|
||||
- orgId
|
||||
- name
|
||||
- condition
|
||||
- enabled
|
||||
- origin
|
||||
- version
|
||||
type: object
|
||||
SpantypesSpanMapperGroupCondition:
|
||||
nullable: true
|
||||
properties:
|
||||
attributes:
|
||||
items:
|
||||
type: string
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperGroupConditionKey'
|
||||
nullable: true
|
||||
type: array
|
||||
resource:
|
||||
items:
|
||||
type: string
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperGroupConditionKey'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- attributes
|
||||
- resource
|
||||
type: object
|
||||
SpantypesSpanMapperGroupConditionKey:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
origin:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
|
||||
value:
|
||||
type: string
|
||||
required:
|
||||
- value
|
||||
- enabled
|
||||
type: object
|
||||
SpantypesSpanMapperOperation:
|
||||
enum:
|
||||
- move
|
||||
- copy
|
||||
type: string
|
||||
SpantypesSpanMapperOrigin:
|
||||
enum:
|
||||
- user
|
||||
- system
|
||||
type: string
|
||||
SpantypesSpanMapperSource:
|
||||
properties:
|
||||
context:
|
||||
$ref: '#/components/schemas/SpantypesFieldContext'
|
||||
enabled:
|
||||
type: boolean
|
||||
key:
|
||||
type: string
|
||||
operation:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOperation'
|
||||
origin:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
|
||||
priority:
|
||||
type: integer
|
||||
required:
|
||||
@@ -9516,6 +9546,7 @@ components:
|
||||
- context
|
||||
- operation
|
||||
- priority
|
||||
- enabled
|
||||
type: object
|
||||
SpantypesSpanMapperTestSpan:
|
||||
properties:
|
||||
|
||||
@@ -184,7 +184,6 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
apiHandler.RegisterIntegrationRoutes(r, am)
|
||||
apiHandler.RegisterQueryRangeV3Routes(r, am)
|
||||
apiHandler.RegisterQueryRangeV4Routes(r, am)
|
||||
apiHandler.RegisterWebSocketPaths(r, am)
|
||||
apiHandler.RegisterMessagingQueuesRoutes(r, am)
|
||||
apiHandler.RegisterThirdPartyApiRoutes(r, am)
|
||||
apiHandler.RegisterTraceFunnelsRoutes(r, am)
|
||||
@@ -197,7 +196,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control", "X-SIGNOZ-QUERY-ID", "Sec-WebSocket-Protocol"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control"},
|
||||
})
|
||||
|
||||
handler := c.Handler(r)
|
||||
|
||||
@@ -10518,15 +10518,31 @@ export interface SpantypesGettableFlamegraphTraceDTO {
|
||||
startTimestampMillis: number;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanMapperOriginDTO {
|
||||
user = 'user',
|
||||
system = 'system',
|
||||
}
|
||||
export interface SpantypesSpanMapperGroupConditionKeyDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
origin?: SpantypesSpanMapperOriginDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type SpantypesSpanMapperGroupConditionDTOAnyOf = {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
attributes: string[] | null;
|
||||
attributes: SpantypesSpanMapperGroupConditionKeyDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
resource: string[] | null;
|
||||
resource: SpantypesSpanMapperGroupConditionKeyDTO[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -10562,6 +10578,7 @@ export interface SpantypesSpanMapperGroupDTO {
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
origin: SpantypesSpanMapperOriginDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -10571,6 +10588,10 @@ export interface SpantypesSpanMapperGroupDTO {
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableSpanMapperGroupsDTO {
|
||||
@@ -10628,11 +10649,16 @@ export enum SpantypesSpanMapperOperationDTO {
|
||||
}
|
||||
export interface SpantypesSpanMapperSourceDTO {
|
||||
context: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
origin?: SpantypesSpanMapperOriginDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -10674,6 +10700,7 @@ export interface SpantypesSpanMapperDTO {
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
origin: SpantypesSpanMapperOriginDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/quickFilters/getCustomFilters';
|
||||
|
||||
const getCustomFilters = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
const { signal } = props;
|
||||
try {
|
||||
const response = await axios.get(`/orgs/me/filters/${signal}`);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default getCustomFilters;
|
||||
@@ -1,13 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { AxiosError } from 'axios';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { UpdateCustomFiltersProps } from 'types/api/quickFilters/updateCustomFilters';
|
||||
|
||||
const updateCustomFiltersAPI = async (
|
||||
props: UpdateCustomFiltersProps,
|
||||
): Promise<SuccessResponse<void> | AxiosError> =>
|
||||
axios.put(`/orgs/me/filters`, {
|
||||
...props.data,
|
||||
});
|
||||
|
||||
export default updateCustomFiltersAPI;
|
||||
@@ -34,6 +34,7 @@ export function mockFieldsValuesAPI(response: {
|
||||
relatedValues?: (string | null)[];
|
||||
stringValues?: (string | null)[];
|
||||
numberValues?: (number | null)[];
|
||||
boolValues?: (boolean | null)[];
|
||||
}): void {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>
|
||||
@@ -46,6 +47,7 @@ export function mockFieldsValuesAPI(response: {
|
||||
relatedValues: response.relatedValues ?? [],
|
||||
stringValues: response.stringValues ?? [],
|
||||
numberValues: response.numberValues ?? [],
|
||||
boolValues: response.boolValues ?? [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -22,7 +22,10 @@ interface UseFieldValuesReturn {
|
||||
isFetching: boolean;
|
||||
}
|
||||
|
||||
const DATA_SOURCE_TO_SIGNAL: Record<DataSource, TelemetrytypesSignalDTO> = {
|
||||
export const DATA_SOURCE_TO_SIGNAL: Record<
|
||||
DataSource,
|
||||
TelemetrytypesSignalDTO
|
||||
> = {
|
||||
[DataSource.METRICS]: TelemetrytypesSignalDTO.metrics,
|
||||
[DataSource.TRACES]: TelemetrytypesSignalDTO.traces,
|
||||
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
|
||||
@@ -89,8 +92,12 @@ export function useFieldValues({
|
||||
values.numberValues
|
||||
?.filter((value): value is number => value !== null && value !== undefined)
|
||||
.map((value) => value.toString()) || [];
|
||||
const boolValues =
|
||||
values.boolValues
|
||||
?.filter((value): value is boolean => value !== null && value !== undefined)
|
||||
.map((value) => value.toString()) || [];
|
||||
|
||||
return [...stringValues, ...numberValues];
|
||||
return [...stringValues, ...numberValues, ...boolValues];
|
||||
}, [data]);
|
||||
|
||||
return { relatedValues, allValues, isLoading, isFetching };
|
||||
|
||||
@@ -17,7 +17,7 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button } from 'antd';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { GripVertical } from '@signozhq/icons';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
function SortableFilter({
|
||||
filter,
|
||||
@@ -25,13 +25,13 @@ function SortableFilter({
|
||||
allowDrag,
|
||||
allowRemove,
|
||||
}: {
|
||||
filter: FilterType;
|
||||
onRemove: (filter: FilterType) => void;
|
||||
filter: TelemetryFieldKey;
|
||||
onRemove: (filter: TelemetryFieldKey) => void;
|
||||
allowDrag: boolean;
|
||||
allowRemove: boolean;
|
||||
}): JSX.Element {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: filter.key });
|
||||
useSortable({ id: filter.key as string });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -46,14 +46,14 @@ function SortableFilter({
|
||||
>
|
||||
<div {...attributes} {...listeners} className="drag-handle">
|
||||
{allowDrag && <GripVertical size={16} />}
|
||||
{filter.key}
|
||||
{filter.name}
|
||||
</div>
|
||||
{allowRemove && (
|
||||
<Button
|
||||
className="remove-filter-btn periscope-btn"
|
||||
size="small"
|
||||
onClick={(): void => {
|
||||
onRemove(filter as FilterType);
|
||||
onRemove(filter);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
@@ -69,8 +69,8 @@ function AddedFilters({
|
||||
setAddedFilters,
|
||||
}: {
|
||||
inputValue: string;
|
||||
addedFilters: FilterType[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
|
||||
addedFilters: TelemetryFieldKey[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
|
||||
}): JSX.Element {
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
@@ -90,12 +90,12 @@ function AddedFilters({
|
||||
const filteredAddedFilters = useMemo(
|
||||
() =>
|
||||
addedFilters.filter((filter) =>
|
||||
filter.key.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
filter.name.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
),
|
||||
[addedFilters, inputValue],
|
||||
);
|
||||
|
||||
const handleRemoveFilter = (filter: FilterType): void => {
|
||||
const handleRemoveFilter = (filter: TelemetryFieldKey): void => {
|
||||
setAddedFilters((prev) => prev.filter((f) => f.key !== filter.key));
|
||||
};
|
||||
|
||||
@@ -116,7 +116,7 @@ function AddedFilters({
|
||||
<div className="no-values-found">No values found</div>
|
||||
) : (
|
||||
<SortableContext
|
||||
items={addedFilters.map((f) => f.key)}
|
||||
items={addedFilters.map((f) => f.key as string)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
disabled={!allowDrag}
|
||||
>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { useGetFieldsKeys } from 'api/generated/services/fields';
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { DATA_SOURCE_TO_SIGNAL } from 'components/QuickFilters/FilterRenderers/Checkbox/v2/useFieldValues';
|
||||
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import {
|
||||
FieldContext,
|
||||
FieldDataType,
|
||||
TelemetryFieldKey,
|
||||
} from 'types/api/v5/queryRange';
|
||||
|
||||
function OtherFiltersSkeleton(): JSX.Element {
|
||||
return (
|
||||
@@ -37,106 +37,48 @@ function OtherFilters({
|
||||
}: {
|
||||
signal: SignalType | undefined;
|
||||
inputValue: string;
|
||||
addedFilters: FilterType[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
|
||||
addedFilters: TelemetryFieldKey[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
|
||||
}): JSX.Element {
|
||||
const isLogDataSource = useMemo(
|
||||
() => SIGNAL_DATA_SOURCE_MAP[signal as SignalType] === DataSource.LOGS,
|
||||
[signal],
|
||||
);
|
||||
const isMeterDataSource = useMemo(
|
||||
() => signal && signal === SignalType.METER_EXPLORER,
|
||||
[signal],
|
||||
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
|
||||
|
||||
const { data, isFetching } = useGetFieldsKeys(
|
||||
{
|
||||
searchText: inputValue,
|
||||
signal: signal
|
||||
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
|
||||
: undefined,
|
||||
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
|
||||
},
|
||||
{ query: { enabled: !!signal } },
|
||||
);
|
||||
|
||||
const { data: suggestionsData, isFetching: isFetchingSuggestions } =
|
||||
useGetAttributeSuggestions(
|
||||
{
|
||||
searchText: inputValue,
|
||||
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
|
||||
filters: {} as TagFilter,
|
||||
},
|
||||
{
|
||||
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
|
||||
enabled: !!signal && isLogDataSource,
|
||||
},
|
||||
);
|
||||
const otherFilters = useMemo<TelemetryFieldKey[]>(() => {
|
||||
const rawSuggestions = Object.values(data?.data?.keys ?? {}).flat();
|
||||
// Normalize: synthesize the composite `key` once so downstream reads (dedupe,
|
||||
// add, render) can trust it.
|
||||
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
|
||||
name: attr.name,
|
||||
signal: attr.signal as TelemetryFieldKey['signal'],
|
||||
fieldContext: attr.fieldContext as FieldContext,
|
||||
fieldDataType: attr.fieldDataType as FieldDataType,
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext, attr.fieldDataType),
|
||||
}));
|
||||
|
||||
const { data: aggregateKeysData, isFetching: isFetchingAggregateKeys } =
|
||||
useGetAggregateKeys(
|
||||
{
|
||||
searchText: inputValue,
|
||||
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: '',
|
||||
tagType: '',
|
||||
},
|
||||
{
|
||||
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
|
||||
enabled: !!signal && !isLogDataSource && !isMeterDataSource,
|
||||
},
|
||||
const addedKeys = new Set(
|
||||
addedFilters.map((filter) =>
|
||||
buildCompositeKey(filter.name, filter.fieldContext, filter.fieldDataType),
|
||||
),
|
||||
);
|
||||
return suggestions.filter((attr) => !addedKeys.has(attr.key as string));
|
||||
}, [data, addedFilters]);
|
||||
|
||||
const { data: fieldKeysData, isLoading: isLoadingFieldKeys } =
|
||||
useGetQueryKeySuggestions(
|
||||
{
|
||||
searchText: inputValue,
|
||||
signal: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
|
||||
signalSource: 'meter',
|
||||
},
|
||||
{
|
||||
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
|
||||
enabled: !!signal && isMeterDataSource,
|
||||
},
|
||||
);
|
||||
|
||||
const otherFilters = useMemo(() => {
|
||||
let filterAttributes;
|
||||
if (isLogDataSource) {
|
||||
filterAttributes = suggestionsData?.payload?.attributes || [];
|
||||
} else if (isMeterDataSource) {
|
||||
const fieldKeys: QueryKeyDataSuggestionsProps[] = Object.values(
|
||||
fieldKeysData?.data?.data?.keys || {},
|
||||
)?.flat();
|
||||
filterAttributes = fieldKeys.map(
|
||||
(attr) =>
|
||||
({
|
||||
key: attr.name,
|
||||
dataType: attr.fieldDataType,
|
||||
type: attr.fieldContext,
|
||||
signal: attr.signal,
|
||||
}) as BaseAutocompleteData,
|
||||
);
|
||||
} else {
|
||||
filterAttributes = aggregateKeysData?.payload?.attributeKeys || [];
|
||||
}
|
||||
return filterAttributes?.filter(
|
||||
(attr) => !addedFilters.some((filter) => filter.key === attr.key),
|
||||
);
|
||||
}, [
|
||||
suggestionsData,
|
||||
aggregateKeysData,
|
||||
addedFilters,
|
||||
isLogDataSource,
|
||||
fieldKeysData,
|
||||
isMeterDataSource,
|
||||
]);
|
||||
|
||||
const handleAddFilter = (filter: FilterType): void => {
|
||||
setAddedFilters((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: filter.key,
|
||||
dataType: filter.dataType,
|
||||
type: filter.type,
|
||||
},
|
||||
]);
|
||||
const handleAddFilter = (filter: TelemetryFieldKey): void => {
|
||||
setAddedFilters((prev) => [...prev, filter]);
|
||||
};
|
||||
|
||||
const renderFilters = (): React.ReactNode => {
|
||||
const isLoading =
|
||||
isFetchingSuggestions || isFetchingAggregateKeys || isLoadingFieldKeys;
|
||||
if (isLoading) {
|
||||
if (isFetching) {
|
||||
return <OtherFiltersSkeleton />;
|
||||
}
|
||||
if (!otherFilters?.length) {
|
||||
@@ -145,11 +87,11 @@ function OtherFilters({
|
||||
|
||||
return otherFilters.map((filter) => (
|
||||
<div key={filter.key} className="qf-filter-item other-filters-item">
|
||||
<div className="qf-filter-key">{filter.key}</div>
|
||||
<div className="qf-filter-key">{filter.name}</div>
|
||||
<Button
|
||||
className="add-filter-btn periscope-btn"
|
||||
size="small"
|
||||
onClick={(): void => handleAddFilter(filter as FilterType)}
|
||||
onClick={(): void => handleAddFilter(filter)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { SignalType } from '../types';
|
||||
import AddedFilters from './AddedFilters';
|
||||
@@ -19,7 +18,7 @@ function QuickFiltersSettings({
|
||||
}: {
|
||||
signal: SignalType | undefined;
|
||||
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
||||
customFilters: FilterType[];
|
||||
customFilters: TelemetryFieldKey[];
|
||||
refetchCustomFilters: () => void;
|
||||
}): JSX.Element {
|
||||
const {
|
||||
@@ -28,6 +27,7 @@ function QuickFiltersSettings({
|
||||
addedFilters,
|
||||
setAddedFilters,
|
||||
handleSaveChanges,
|
||||
hasUnsavedChanges,
|
||||
isUpdatingCustomFilters,
|
||||
inputValue,
|
||||
handleInputChange,
|
||||
@@ -39,18 +39,6 @@ function QuickFiltersSettings({
|
||||
signal,
|
||||
});
|
||||
|
||||
const hasUnsavedChanges = useMemo(
|
||||
() =>
|
||||
// check if both arrays have the same length and same order of elements
|
||||
!(
|
||||
addedFilters.length === customFilters.length &&
|
||||
addedFilters.every(
|
||||
(filter, index) => filter.key === customFilters[index].key,
|
||||
)
|
||||
),
|
||||
[addedFilters, customFilters],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="qf-header">
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useUpdateQuickFilters } from 'api/generated/services/quick-filter';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import updateCustomFiltersAPI from 'api/quickFilters/updateCustomFilters';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
interface UseQuickFilterSettingsProps {
|
||||
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
||||
customFilters: FilterType[];
|
||||
customFilters: TelemetryFieldKey[];
|
||||
refetchCustomFilters: () => void;
|
||||
signal?: SignalType;
|
||||
}
|
||||
|
||||
interface UseQuickFilterSettingsReturn {
|
||||
addedFilters: FilterType[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
|
||||
addedFilters: TelemetryFieldKey[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
|
||||
handleSettingsClose: () => void;
|
||||
handleDiscardChanges: () => void;
|
||||
handleSaveChanges: () => void;
|
||||
hasUnsavedChanges: boolean;
|
||||
isUpdatingCustomFilters: boolean;
|
||||
inputValue: string;
|
||||
setInputValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
@@ -37,27 +41,43 @@ const useQuickFilterSettings = ({
|
||||
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
const [debouncedInputValue, setDebouncedInputValue] = useState<string>('');
|
||||
const [addedFilters, setAddedFilters] = useState<FilterType[]>(customFilters);
|
||||
const normalizedCustomFilters = useMemo<TelemetryFieldKey[]>(
|
||||
() =>
|
||||
customFilters.map((filter) => ({
|
||||
...filter,
|
||||
key: buildCompositeKey(
|
||||
filter.name,
|
||||
filter.fieldContext,
|
||||
filter.fieldDataType,
|
||||
),
|
||||
})),
|
||||
[customFilters],
|
||||
);
|
||||
const [addedFilters, setAddedFilters] = useState<TelemetryFieldKey[]>(
|
||||
normalizedCustomFilters,
|
||||
);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { mutate: updateCustomFilters, isLoading: isUpdatingCustomFilters } =
|
||||
useMutation(updateCustomFiltersAPI, {
|
||||
onSuccess: () => {
|
||||
setIsSettingsOpen(false);
|
||||
refetchCustomFilters();
|
||||
logEvent('Quick Filters Settings: changes saved', {
|
||||
addedFilters,
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Quick filters updated successfully',
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
notifications.error({
|
||||
message: axios.isAxiosError(error) ? error.message : SOMETHING_WENT_WRONG,
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
useUpdateQuickFilters({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
setIsSettingsOpen(false);
|
||||
refetchCustomFilters();
|
||||
void logEvent('Quick Filters Settings: changes saved', {
|
||||
addedFilters,
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Quick filters updated successfully',
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.error({
|
||||
message: error.message || SOMETHING_WENT_WRONG,
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
const debouncedUpdate = useDebouncedFn((value) => {
|
||||
@@ -78,19 +98,32 @@ const useQuickFilterSettings = ({
|
||||
}, [setIsSettingsOpen]);
|
||||
|
||||
const handleDiscardChanges = useCallback((): void => {
|
||||
setAddedFilters(customFilters);
|
||||
}, [customFilters, setAddedFilters]);
|
||||
setAddedFilters(normalizedCustomFilters);
|
||||
}, [normalizedCustomFilters, setAddedFilters]);
|
||||
|
||||
const hasUnsavedChanges = useMemo(
|
||||
() =>
|
||||
!(
|
||||
addedFilters.length === normalizedCustomFilters.length &&
|
||||
addedFilters.every(
|
||||
(filter, index) => filter.key === normalizedCustomFilters[index].key,
|
||||
)
|
||||
),
|
||||
[addedFilters, normalizedCustomFilters],
|
||||
);
|
||||
|
||||
const handleSaveChanges = useCallback((): void => {
|
||||
if (signal) {
|
||||
updateCustomFilters({
|
||||
pathParams: { source: signal },
|
||||
data: {
|
||||
// Send only the stored TelemetryFieldKey fields; the composite `key`
|
||||
// is UI-only.
|
||||
filters: addedFilters.map((filter) => ({
|
||||
key: filter.key,
|
||||
datatype: filter.dataType,
|
||||
type: filter.type,
|
||||
name: filter.name,
|
||||
fieldContext: filter.fieldContext as TelemetrytypesFieldContextDTO,
|
||||
fieldDataType: filter.fieldDataType as TelemetrytypesFieldDataTypeDTO,
|
||||
})),
|
||||
signal,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -102,6 +135,7 @@ const useQuickFilterSettings = ({
|
||||
addedFilters,
|
||||
setAddedFilters,
|
||||
handleSaveChanges,
|
||||
hasUnsavedChanges,
|
||||
isUpdatingCustomFilters,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import getCustomFilters from 'api/quickFilters/getCustomFilters';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { useGetQuickFilters } from 'api/generated/services/quick-filter';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { IQuickFiltersConfig, SignalType } from '../types';
|
||||
import { getFilterConfig } from '../utils';
|
||||
@@ -13,7 +11,7 @@ interface UseFilterConfigProps {
|
||||
}
|
||||
interface UseFilterConfigReturn {
|
||||
filterConfig: IQuickFiltersConfig[];
|
||||
customFilters: FilterType[];
|
||||
customFilters: TelemetryFieldKey[];
|
||||
isCustomFiltersLoading: boolean;
|
||||
isDynamicFilters: boolean;
|
||||
refetchCustomFilters: () => void;
|
||||
@@ -25,17 +23,16 @@ const useFilterConfig = ({
|
||||
}: UseFilterConfigProps): UseFilterConfigReturn => {
|
||||
const {
|
||||
isFetching: isCustomFiltersLoading,
|
||||
data: customFilters = [],
|
||||
data,
|
||||
refetch,
|
||||
} = useQuery<FilterType[], Error>(
|
||||
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
|
||||
async () => {
|
||||
const res = await getCustomFilters({ signal: signal || '' });
|
||||
return 'payload' in res && res.payload?.filters ? res.payload.filters : [];
|
||||
},
|
||||
{
|
||||
enabled: !!signal,
|
||||
},
|
||||
} = useGetQuickFilters(
|
||||
{ source: signal ?? '' },
|
||||
{ query: { enabled: !!signal } },
|
||||
);
|
||||
|
||||
const customFilters = useMemo<TelemetryFieldKey[]>(
|
||||
() => (data?.data?.filters ?? []) as TelemetryFieldKey[],
|
||||
[data],
|
||||
);
|
||||
|
||||
const isDynamicFilters = useMemo(
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'mocks-server/__mockdata__/customQuickFilters';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
@@ -34,9 +34,9 @@ const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const SIGNAL = SignalType.LOGS;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/${SIGNAL}`;
|
||||
const saveQuickFiltersURL = `${BASE_URL}/api/v1/orgs/me/filters`;
|
||||
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v3/filter_suggestions`;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
|
||||
const saveQuickFiltersURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
|
||||
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v1/fields/keys`;
|
||||
const quickFiltersAttributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
|
||||
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
|
||||
|
||||
@@ -338,6 +338,63 @@ describe('Quick Filters with custom filters', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps same-name fields with different context as distinct entries', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
server.use(
|
||||
rest.get(quickFiltersSuggestionsURL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: {
|
||||
level: [
|
||||
{
|
||||
name: 'level',
|
||||
fieldContext: 'attribute',
|
||||
fieldDataType: 'string',
|
||||
signal: 'logs',
|
||||
},
|
||||
{
|
||||
name: 'level',
|
||||
fieldContext: 'span',
|
||||
fieldDataType: 'string',
|
||||
signal: 'logs',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
const settingsButton = icon.closest('button') ?? icon;
|
||||
await user.click(settingsButton);
|
||||
|
||||
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
|
||||
// Both `level` variants are shown despite sharing a name.
|
||||
await waitFor(() =>
|
||||
expect(within(otherSection).getAllByText('level')).toHaveLength(2),
|
||||
);
|
||||
|
||||
// Adding one variant removes only that one; the other stays.
|
||||
const firstLevel = within(otherSection).getAllByText('level')[0];
|
||||
const addButton = firstLevel.parentElement?.querySelector('button');
|
||||
await user.click(addButton as HTMLButtonElement);
|
||||
|
||||
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
|
||||
await waitFor(() => {
|
||||
expect(within(addedSection).getAllByText('level')).toHaveLength(1);
|
||||
expect(within(otherSection).getAllByText('level')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
@@ -456,12 +513,10 @@ describe('Quick Filters with custom filters', () => {
|
||||
});
|
||||
|
||||
const requestBody = putHandler.mock.calls[0][0];
|
||||
expect(requestBody.filters).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
expect.not.objectContaining({ key: FILTER_OS_DESCRIPTION }),
|
||||
]),
|
||||
expect(requestBody.filters).not.toContainEqual(
|
||||
expect.objectContaining({ name: FILTER_OS_DESCRIPTION }),
|
||||
);
|
||||
expect(requestBody.signal).toBe(SIGNAL);
|
||||
expect(requestBody.filters).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('should render duration slider for duration_nono filter', async () => {
|
||||
@@ -612,9 +667,9 @@ describe('Quick Filters refetch behavior', () => {
|
||||
filters: [
|
||||
...(quickFiltersListResponse.data.filters ?? []),
|
||||
{
|
||||
key: 'new.custom.filter',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'new.custom.filter',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
} as const,
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { fieldDataTypeToDataType } from 'utils/fieldDataType';
|
||||
|
||||
import { FiltersType, IQuickFiltersConfig, SignalType } from './types';
|
||||
|
||||
const FILTER_TITLE_MAP: Record<string, string> = {
|
||||
duration_nano: 'Duration',
|
||||
hasError: 'Has Error (Status)',
|
||||
has_error: 'Has Error (Status)',
|
||||
};
|
||||
|
||||
const FILTER_TYPE_MAP: Record<string, FiltersType> = {
|
||||
duration_nano: FiltersType.DURATION,
|
||||
};
|
||||
|
||||
// The map below exists only for the old v3 attribute-values fetch
|
||||
// (useCheckboxFilterValues), the sole reader of attributeKey.dataType/type.
|
||||
// Once the values fetch moves to fields/values, remove this and reduce
|
||||
// attributeKey to { id, key }.
|
||||
|
||||
const FIELD_CONTEXT_TO_ATTRIBUTE_TYPE: Record<string, string> = {
|
||||
[TelemetrytypesFieldContextDTO.attribute]: 'tag',
|
||||
[TelemetrytypesFieldContextDTO.resource]: 'resource',
|
||||
};
|
||||
|
||||
const mapFieldContext = (fieldContext?: string): string =>
|
||||
(fieldContext && FIELD_CONTEXT_TO_ATTRIBUTE_TYPE[fieldContext]) || '';
|
||||
|
||||
const getFilterName = (str: string): string => {
|
||||
if (FILTER_TITLE_MAP[str]) {
|
||||
return FILTER_TITLE_MAP[str];
|
||||
@@ -26,16 +42,16 @@ const getFilterName = (str: string): string => {
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const getFilterType = (att: FilterType): FiltersType => {
|
||||
if (FILTER_TYPE_MAP[att.key]) {
|
||||
return FILTER_TYPE_MAP[att.key];
|
||||
const getFilterType = (att: TelemetryFieldKey): FiltersType => {
|
||||
if (FILTER_TYPE_MAP[att.name]) {
|
||||
return FILTER_TYPE_MAP[att.name];
|
||||
}
|
||||
return FiltersType.CHECKBOX;
|
||||
};
|
||||
|
||||
export const getFilterConfig = (
|
||||
signal?: SignalType,
|
||||
customFilters?: FilterType[],
|
||||
customFilters?: TelemetryFieldKey[],
|
||||
config?: IQuickFiltersConfig[],
|
||||
): IQuickFiltersConfig[] => {
|
||||
if (!customFilters?.length || !signal) {
|
||||
@@ -46,13 +62,13 @@ export const getFilterConfig = (
|
||||
(att, index) =>
|
||||
({
|
||||
type: getFilterType(att),
|
||||
title: getFilterName(att.key),
|
||||
title: getFilterName(att.name),
|
||||
dataSource: SIGNAL_DATA_SOURCE_MAP[signal],
|
||||
attributeKey: {
|
||||
id: att.key,
|
||||
key: att.key,
|
||||
dataType: att.dataType,
|
||||
type: att.type,
|
||||
id: att.name,
|
||||
key: att.name,
|
||||
dataType: fieldDataTypeToDataType(att.fieldDataType),
|
||||
type: mapFieldContext(att.fieldContext),
|
||||
},
|
||||
defaultOpen: index < 2,
|
||||
}) as IQuickFiltersConfig,
|
||||
|
||||
@@ -184,6 +184,11 @@
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.6;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-restricted-banner,
|
||||
|
||||
@@ -16,7 +16,6 @@ import * as Sentry from '@sentry/react';
|
||||
import { Toaster } from '@signozhq/ui/sonner';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { Flex } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import getChangelogByVersion from 'api/changelog/getChangelogByVersion';
|
||||
@@ -820,14 +819,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
{' '}
|
||||
Please{' '}
|
||||
<AuthZTooltip checks={SubscriptionManagePermissions}>
|
||||
<Button
|
||||
variant="link"
|
||||
color="none"
|
||||
className="upgrade-link"
|
||||
onClick={handleFailedPayment}
|
||||
>
|
||||
<a className="upgrade-link" onClick={handleFailedPayment}>
|
||||
pay the bill
|
||||
</Button>
|
||||
</a>
|
||||
</AuthZTooltip>
|
||||
to continue using SigNoz features.
|
||||
<span className="refresh-payment-status">
|
||||
|
||||
@@ -60,6 +60,10 @@
|
||||
margin: var(--spacing-12) var(--spacing-4);
|
||||
}
|
||||
|
||||
.usageDenied {
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.billingDetails {
|
||||
margin: var(--spacing-4) 0;
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
@@ -37,6 +37,7 @@ import { isEmpty, pick } from 'lodash-es';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
|
||||
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
import {
|
||||
SubscriptionCreatePermission,
|
||||
SubscriptionManagePermissions,
|
||||
@@ -509,7 +510,15 @@ export default function BillingContainer(): JSX.Element {
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<AuthZGuardContent checks={[SubscriptionReadPermission]}>
|
||||
<AuthZGuardContent
|
||||
checks={[SubscriptionReadPermission]}
|
||||
fallback={({ deniedPermissions }): JSX.Element => (
|
||||
<PermissionDeniedCallout
|
||||
deniedPermissions={deniedPermissions}
|
||||
className={styles.usageDenied}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<>
|
||||
<div className={styles.billingGraphSection}>
|
||||
{!isLoading && !isFetchingBillingData ? (
|
||||
|
||||
@@ -333,6 +333,7 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
priority,
|
||||
enabled: true,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { ConditionKey } from 'container/LLMObservability/AttributeMapping/types';
|
||||
|
||||
import styles from './ConditionsTooltip.module.scss';
|
||||
|
||||
interface ConditionsTooltipProps {
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
attributes: ConditionKey[];
|
||||
resource: ConditionKey[];
|
||||
}
|
||||
|
||||
function ConditionsTooltip({
|
||||
@@ -33,8 +35,8 @@ function ConditionsTooltip({
|
||||
</Typography.Text>
|
||||
<div className={styles.keyList}>
|
||||
{attributes.map((key) => (
|
||||
<code key={key} className={styles.key}>
|
||||
{key}
|
||||
<code key={`${key.origin}-${key.value}`} className={styles.key}>
|
||||
{key.value}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
@@ -47,8 +49,8 @@ function ConditionsTooltip({
|
||||
</Typography.Text>
|
||||
<div className={styles.keyList}>
|
||||
{resource.map((key) => (
|
||||
<code key={key} className={styles.key}>
|
||||
{key}
|
||||
<code key={`${key.origin}-${key.value}`} className={styles.key}>
|
||||
{key.value}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
SpantypesSpanMapperDTO as Mapper,
|
||||
SpantypesSpanMapperGroupDTO as MapperGroup,
|
||||
SpantypesSpanMapperOperationDTO as MapperOperation,
|
||||
SpantypesSpanMapperOriginDTO as MapperOrigin,
|
||||
SpantypesSpanMapperTestSpanDTO as TestSpan,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
@@ -21,9 +22,15 @@ export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
|
||||
orgId: 'org-1',
|
||||
name: 'demo',
|
||||
enabled: true,
|
||||
origin: MapperOrigin.user,
|
||||
version: 0,
|
||||
condition: {
|
||||
attributes: ['ai.embeddings'],
|
||||
resource: ['cloud.account.id'],
|
||||
attributes: [
|
||||
{ value: 'ai.embeddings', enabled: true, origin: MapperOrigin.user },
|
||||
],
|
||||
resource: [
|
||||
{ value: 'cloud.account.id', enabled: true, origin: MapperOrigin.user },
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
@@ -35,6 +42,7 @@ export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
|
||||
groupId: 'group-1',
|
||||
name: 'gen_ai.request.model',
|
||||
enabled: true,
|
||||
origin: MapperOrigin.user,
|
||||
fieldContext: FieldContext.attribute,
|
||||
config: {
|
||||
sources: [
|
||||
@@ -43,12 +51,16 @@ export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
priority: 2,
|
||||
enabled: true,
|
||||
origin: MapperOrigin.user,
|
||||
},
|
||||
{
|
||||
key: 'llm.model',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.move,
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
origin: MapperOrigin.user,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -85,8 +97,12 @@ export const mockGroups: MapperGroup[] = [
|
||||
id: 'group-1',
|
||||
name: 'demo',
|
||||
condition: {
|
||||
attributes: ['ai.embeddings'],
|
||||
resource: ['cloud.account.id'],
|
||||
attributes: [
|
||||
{ value: 'ai.embeddings', enabled: true, origin: MapperOrigin.user },
|
||||
],
|
||||
resource: [
|
||||
{ value: 'cloud.account.id', enabled: true, origin: MapperOrigin.user },
|
||||
],
|
||||
},
|
||||
}),
|
||||
makeGroup({
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus, X } from '@signozhq/icons';
|
||||
|
||||
import { FieldContextValue } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import {
|
||||
ConditionKey,
|
||||
FieldContextValue,
|
||||
} from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { createConditionKey } from 'container/LLMObservability/AttributeMapping/utils';
|
||||
import KeySearchInput from '../../../KeySearchInput/KeySearchInput';
|
||||
import styles from './ConditionKeyList.module.scss';
|
||||
|
||||
interface ConditionKeyListProps {
|
||||
label: string;
|
||||
labelHint?: string;
|
||||
keys: string[];
|
||||
keys: ConditionKey[];
|
||||
placeholder: string;
|
||||
addLabel: string;
|
||||
testIdPrefix: string;
|
||||
fieldContext: FieldContextValue;
|
||||
onChange: (keys: string[]) => void;
|
||||
onChange: (keys: ConditionKey[]) => void;
|
||||
}
|
||||
|
||||
function ConditionKeyList({
|
||||
@@ -27,11 +31,11 @@ function ConditionKeyList({
|
||||
onChange,
|
||||
}: ConditionKeyListProps): JSX.Element {
|
||||
const updateKey = (index: number, value: string): void => {
|
||||
onChange(keys.map((key, i) => (i === index ? value : key)));
|
||||
onChange(keys.map((key, i) => (i === index ? { ...key, value } : key)));
|
||||
};
|
||||
|
||||
const addKey = (): void => {
|
||||
onChange([...keys, '']);
|
||||
onChange([...keys, createConditionKey()]);
|
||||
};
|
||||
|
||||
const removeKey = (index: number): void => {
|
||||
@@ -53,7 +57,7 @@ function ConditionKeyList({
|
||||
<KeySearchInput
|
||||
className={styles.keyInput}
|
||||
placeholder={placeholder}
|
||||
value={key}
|
||||
value={key.value}
|
||||
fieldContext={fieldContext}
|
||||
onChange={(next): void => updateKey(index, next)}
|
||||
testId={`${testIdPrefix}-${index}`}
|
||||
|
||||
@@ -42,7 +42,9 @@ function sourcesEqual(a: SourceConfig[], b: SourceConfig[]): boolean {
|
||||
(source, index) =>
|
||||
source.key === b[index].key &&
|
||||
source.context === b[index].context &&
|
||||
source.operation === b[index].operation,
|
||||
source.operation === b[index].operation &&
|
||||
source.enabled === b[index].enabled &&
|
||||
source.origin === b[index].origin,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
SpantypesSpanMapperDTO,
|
||||
SpantypesSpanMapperGroupDTO,
|
||||
SpantypesSpanMapperOperationDTO,
|
||||
SpantypesSpanMapperOriginDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type MapperGroup = SpantypesSpanMapperGroupDTO;
|
||||
@@ -11,6 +12,16 @@ export const FieldContext = SpantypesFieldContextDTO;
|
||||
export type FieldContextValue = SpantypesFieldContextDTO;
|
||||
export const MapperOperation = SpantypesSpanMapperOperationDTO;
|
||||
export type MapperOperationValue = SpantypesSpanMapperOperationDTO;
|
||||
export const MapperOrigin = SpantypesSpanMapperOriginDTO;
|
||||
export type MapperOriginValue = SpantypesSpanMapperOriginDTO;
|
||||
|
||||
// One condition substring. Shipped (system) keys are read-only apart from
|
||||
// `enabled`; user keys are fully editable.
|
||||
export interface ConditionKey {
|
||||
value: string;
|
||||
enabled: boolean;
|
||||
origin: MapperOriginValue;
|
||||
}
|
||||
|
||||
export type MapperDraftMode = 'add' | 'edit';
|
||||
|
||||
@@ -18,6 +29,8 @@ export interface SourceConfig {
|
||||
key: string;
|
||||
context: SpantypesFieldContextDTO;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
enabled: boolean;
|
||||
origin: MapperOriginValue;
|
||||
}
|
||||
|
||||
// Editable form state for a mapper. `sources` is ordered highest priority
|
||||
@@ -33,8 +46,8 @@ export interface MapperDraft {
|
||||
export interface GroupDraft {
|
||||
id: string | null;
|
||||
name: string;
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
attributes: ConditionKey[];
|
||||
resource: ConditionKey[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -51,8 +64,8 @@ export interface DraftGroup {
|
||||
localId: string;
|
||||
serverId: string | null;
|
||||
name: string;
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
attributes: ConditionKey[];
|
||||
resource: ConditionKey[];
|
||||
enabled: boolean;
|
||||
mappers: DraftMapper[];
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import {
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesSpanMapperGroupConditionKeyDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
ConditionKey,
|
||||
DraftGroup,
|
||||
DraftMapper,
|
||||
FieldContext,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
MapperDraft,
|
||||
MapperGroup,
|
||||
MapperOperation,
|
||||
MapperOrigin,
|
||||
SourceConfig,
|
||||
} from './types';
|
||||
|
||||
@@ -24,20 +27,36 @@ function genLocalId(prefix: 'group' | 'mapper'): string {
|
||||
return `local-${prefix}-${uuid()}`;
|
||||
}
|
||||
|
||||
// Trimmed, de-duplicated, non-empty keys preserving input order.
|
||||
function cleanKeys(keys: string[]): string[] {
|
||||
export function createConditionKey(value = ''): ConditionKey {
|
||||
return { value, enabled: true, origin: MapperOrigin.user };
|
||||
}
|
||||
|
||||
// Trimmed, de-duplicated, non-empty keys preserving input order. A shipped and
|
||||
// a user key may share a value, so the origin is part of the identity.
|
||||
function cleanKeys(keys: ConditionKey[]): ConditionKey[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
const result: ConditionKey[] = [];
|
||||
keys.forEach((raw) => {
|
||||
const key = raw.trim();
|
||||
if (key && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(key);
|
||||
const value = raw.value.trim();
|
||||
const dedupeKey = `${raw.origin}:${value}`;
|
||||
if (value && !seen.has(dedupeKey)) {
|
||||
seen.add(dedupeKey);
|
||||
result.push({ ...raw, value });
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function fromConditionKeys(
|
||||
keys: SpantypesSpanMapperGroupConditionKeyDTO[] | null | undefined,
|
||||
): ConditionKey[] {
|
||||
return (keys ?? []).map((key) => ({
|
||||
value: key.value,
|
||||
enabled: key.enabled,
|
||||
origin: key.origin ?? MapperOrigin.user,
|
||||
}));
|
||||
}
|
||||
|
||||
// Source configs for a mapper, highest priority first (first match wins at
|
||||
// evaluation time).
|
||||
function getMapperSources(mapper: Mapper): SourceConfig[] {
|
||||
@@ -48,6 +67,8 @@ function getMapperSources(mapper: Mapper): SourceConfig[] {
|
||||
key: source.key,
|
||||
context: source.context,
|
||||
operation: source.operation,
|
||||
enabled: source.enabled,
|
||||
origin: source.origin ?? MapperOrigin.user,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -56,6 +77,8 @@ export function createEmptySource(): SourceConfig {
|
||||
key: '',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
enabled: true,
|
||||
origin: MapperOrigin.user,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,7 +95,7 @@ function getCleanSources(draft: MapperDraft): SourceConfig[] {
|
||||
const result: SourceConfig[] = [];
|
||||
draft.sources.forEach((source) => {
|
||||
const key = source.key.trim();
|
||||
const dedupeKey = `${source.context}:${key}`;
|
||||
const dedupeKey = `${source.origin}:${source.context}:${key}`;
|
||||
if (key && !seen.has(dedupeKey)) {
|
||||
seen.add(dedupeKey);
|
||||
result.push({ ...source, key });
|
||||
@@ -95,6 +118,8 @@ function buildSources(
|
||||
context: source.context,
|
||||
operation: source.operation,
|
||||
priority: sources.length - index,
|
||||
enabled: source.enabled,
|
||||
origin: source.origin,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -123,7 +148,7 @@ export function buildUpdatableMapper(
|
||||
export const EMPTY_GROUP_DRAFT: GroupDraft = {
|
||||
id: null,
|
||||
name: '',
|
||||
attributes: [''],
|
||||
attributes: [createConditionKey()],
|
||||
resource: [],
|
||||
enabled: true,
|
||||
};
|
||||
@@ -170,8 +195,8 @@ export function buildDraftGroup(
|
||||
localId: group.id,
|
||||
serverId: group.id,
|
||||
name: group.name,
|
||||
attributes: group.condition?.attributes ?? [],
|
||||
resource: group.condition?.resource ?? [],
|
||||
attributes: fromConditionKeys(group.condition?.attributes),
|
||||
resource: fromConditionKeys(group.condition?.resource),
|
||||
enabled: group.enabled,
|
||||
mappers: mappers.map(buildDraftMapper),
|
||||
};
|
||||
@@ -182,7 +207,8 @@ export function groupDraftFromNode(group: DraftGroup): GroupDraft {
|
||||
return {
|
||||
id: group.localId,
|
||||
name: group.name,
|
||||
attributes: group.attributes.length > 0 ? group.attributes : [''],
|
||||
attributes:
|
||||
group.attributes.length > 0 ? group.attributes : [createConditionKey()],
|
||||
resource: group.resource,
|
||||
enabled: group.enabled,
|
||||
};
|
||||
|
||||
@@ -108,7 +108,6 @@ function LogsExplorerViewsContainer({
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [logs, setLogs] = useState<ILog[]>([]);
|
||||
const [requestData, setRequestData] = useState<Query | null>(null);
|
||||
const [queryId, setQueryId] = useState<string>(v4());
|
||||
const [listChartQuery, setListChartQuery] = useState<Query | null>(null);
|
||||
|
||||
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
|
||||
@@ -180,12 +179,7 @@ function LogsExplorerViewsContainer({
|
||||
},
|
||||
undefined,
|
||||
listQueryKeyRef,
|
||||
{
|
||||
...(!isEmpty(queryId) &&
|
||||
selectedPanelType !== PANEL_TYPES.LIST && {
|
||||
'X-SIGNOZ-QUERY-ID': queryId,
|
||||
}),
|
||||
},
|
||||
undefined,
|
||||
// custom selected time interval to prevent recalculating the start and end timestamps before fetching next pages
|
||||
'custom',
|
||||
);
|
||||
@@ -250,10 +244,6 @@ function LogsExplorerViewsContainer({
|
||||
setRequestData(newRequestData);
|
||||
}, [isLimit, logs, listQuery, pageSize, stagedQuery, getRequestData, page]);
|
||||
|
||||
useEffect(() => {
|
||||
setQueryId(v4());
|
||||
}, [data]);
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current && !isUndefined(data?.payload)) {
|
||||
|
||||
@@ -4,114 +4,85 @@ export const quickFiltersListResponse = {
|
||||
signal: 'logs',
|
||||
filters: [
|
||||
{
|
||||
key: 'os.description',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'os.description',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'service.name',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'duration_nano',
|
||||
dataType: 'float64',
|
||||
type: 'tag',
|
||||
name: 'duration_nano',
|
||||
fieldDataType: 'float64',
|
||||
fieldContext: 'attribute',
|
||||
},
|
||||
{
|
||||
key: 'quantity',
|
||||
dataType: 'float64',
|
||||
type: 'tag',
|
||||
name: 'quantity',
|
||||
fieldDataType: 'float64',
|
||||
fieldContext: 'attribute',
|
||||
},
|
||||
{
|
||||
key: 'body',
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
name: 'body',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
},
|
||||
{
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'deployment.environment',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.namespace',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'service.namespace',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.namespace.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'k8s.namespace.name',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.instance.id',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'service.instance.id',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.pod.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'k8s.pod.name',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'process.owner',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'process.owner',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const otherFilterName = (name: string): { [k: string]: unknown[] } => ({
|
||||
[name]: [
|
||||
{ name, fieldContext: 'resource', fieldDataType: 'string', signal: 'logs' },
|
||||
],
|
||||
});
|
||||
|
||||
export const otherFiltersResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
attributes: [
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.deployment.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.namespace',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.namespace.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.instance.id',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.pod.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.pod.uid',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'os.description',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
],
|
||||
complete: true,
|
||||
keys: {
|
||||
...otherFilterName('service.name'),
|
||||
...otherFilterName('k8s.deployment.name'),
|
||||
...otherFilterName('deployment.environment'),
|
||||
...otherFilterName('service.namespace'),
|
||||
...otherFilterName('k8s.namespace.name'),
|
||||
...otherFilterName('service.instance.id'),
|
||||
...otherFilterName('k8s.pod.name'),
|
||||
...otherFilterName('k8s.pod.uid'),
|
||||
...otherFilterName('os.description'),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -504,7 +504,7 @@ jest.mock('hooks/useHandleExplorerTabChange', () => ({
|
||||
let capturedPayload: QueryRangePayloadV5;
|
||||
|
||||
describe('TracesExplorer -', () => {
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/traces`;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/traces`;
|
||||
|
||||
const setupServer = (): void => {
|
||||
server.use(
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
export interface Filter {
|
||||
key: string;
|
||||
dataType: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
signal: string;
|
||||
}
|
||||
|
||||
export type PayloadProps = {
|
||||
filters: Filter[];
|
||||
signal: string;
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
|
||||
interface FilterType {
|
||||
key: string;
|
||||
datatype: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface UpdateCustomFiltersProps {
|
||||
data: {
|
||||
filters: FilterType[];
|
||||
signal: SignalType;
|
||||
};
|
||||
}
|
||||
2
go.mod
2
go.mod
@@ -31,7 +31,6 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/handlers v1.5.1
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
||||
github.com/huandu/go-sqlbuilder v1.39.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12
|
||||
@@ -134,6 +133,7 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
||||
github.com/hashicorp/go-metrics v0.5.4 // indirect
|
||||
github.com/huandu/go-clone v1.7.3 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
|
||||
@@ -45,7 +45,7 @@ func newConfig() factory.Config {
|
||||
return &Config{
|
||||
Tokenizer: TokenizerConfig{
|
||||
Enabled: true,
|
||||
Headers: []string{"Authorization", "Sec-WebSocket-Protocol"},
|
||||
Headers: []string{"Authorization"},
|
||||
},
|
||||
APIKeyConfig: APIKeyConfig{
|
||||
Enabled: true,
|
||||
|
||||
@@ -82,6 +82,7 @@ func (handler *handler) GetFieldsValues(rw http.ResponseWriter, req *http.Reques
|
||||
|
||||
values := &telemetrytypes.TelemetryFieldValues{
|
||||
StringValues: allValues.StringValues,
|
||||
BoolValues: allValues.BoolValues,
|
||||
NumberValues: allValues.NumberValues,
|
||||
RelatedValues: relatedValues,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -16,10 +17,11 @@ type setter struct {
|
||||
alertmanager alertmanager.Alertmanager
|
||||
quickfilter quickfilter.Module
|
||||
dashboard dashboard.Module
|
||||
spanMapper spanmapper.Module
|
||||
}
|
||||
|
||||
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module, dashboard dashboard.Module) organization.Setter {
|
||||
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter, dashboard: dashboard}
|
||||
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module, dashboard dashboard.Module, spanMapper spanmapper.Module) organization.Setter {
|
||||
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter, dashboard: dashboard, spanMapper: spanMapper}
|
||||
}
|
||||
|
||||
func (module *setter) Create(ctx context.Context, organization *types.Organization, createManagedRoles func(context.Context, valuer.UUID) error) error {
|
||||
@@ -43,6 +45,10 @@ func (module *setter) Create(ctx context.Context, organization *types.Organizati
|
||||
return err
|
||||
}
|
||||
|
||||
if err := module.spanMapper.ReconcileSystemGroups(ctx, organization.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
46
pkg/modules/spanmapper/implspanmapper/definitions.go
Normal file
46
pkg/modules/spanmapper/implspanmapper/definitions.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package implspanmapper
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"path"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
)
|
||||
|
||||
const definitionsRoot = "fs/definitions"
|
||||
|
||||
//go:embed fs/definitions/*.json
|
||||
var definitionFiles embed.FS
|
||||
|
||||
// NewSystemGroupRegistry parses every embedded definition. Definitions are
|
||||
// build-time assets validated by a test, so a failure here means the binary
|
||||
// shipped broken JSON.
|
||||
func NewSystemGroupRegistry() (spantypes.SpanMapperGroupRegistry, error) {
|
||||
entries, err := fs.ReadDir(definitionFiles, definitionsRoot)
|
||||
if err != nil {
|
||||
return spantypes.SpanMapperGroupRegistry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read span mapper group definitions")
|
||||
}
|
||||
|
||||
definitions := make([]spantypes.SpanMapperGroupDefinition, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
file := path.Join(definitionsRoot, entry.Name())
|
||||
raw, err := definitionFiles.ReadFile(file)
|
||||
if err != nil {
|
||||
return spantypes.SpanMapperGroupRegistry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read %s", file)
|
||||
}
|
||||
|
||||
definition, err := spantypes.NewSpanMapperGroupDefinition(raw)
|
||||
if err != nil {
|
||||
return spantypes.SpanMapperGroupRegistry{}, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't parse %s", file)
|
||||
}
|
||||
definitions = append(definitions, definition)
|
||||
}
|
||||
|
||||
return spantypes.NewSpanMapperGroupRegistry(definitions)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"version": 1,
|
||||
"definition": {
|
||||
"name": "agent",
|
||||
"condition": {
|
||||
"attributes": [
|
||||
{
|
||||
"value": "agent"
|
||||
}
|
||||
],
|
||||
"resource": []
|
||||
},
|
||||
"enabled": true,
|
||||
"mappers": [
|
||||
{
|
||||
"name": "gen_ai.agent.name",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "agent.name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "agent_name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.agent.id",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "agent.id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.agent.description",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "agent.description",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.output.messages",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "final_result",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
347
pkg/modules/spanmapper/implspanmapper/fs/definitions/llm.json
Normal file
347
pkg/modules/spanmapper/implspanmapper/fs/definitions/llm.json
Normal file
@@ -0,0 +1,347 @@
|
||||
{
|
||||
"version": 1,
|
||||
"definition": {
|
||||
"name": "llm",
|
||||
"condition": {
|
||||
"attributes": [
|
||||
{
|
||||
"value": "model"
|
||||
}
|
||||
],
|
||||
"resource": []
|
||||
},
|
||||
"enabled": true,
|
||||
"mappers": [
|
||||
{
|
||||
"name": "gen_ai.request.model",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "llm.model_name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 60
|
||||
},
|
||||
{
|
||||
"key": "llm.request.model",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 50
|
||||
},
|
||||
{
|
||||
"key": "ai.model.id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "langfuse.observation.model.name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "embedding.model_name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "model",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.response.model",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "llm.response.model",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.response.model",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.provider.name",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.system",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 50
|
||||
},
|
||||
{
|
||||
"key": "llm.vendor",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "llm.provider",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "llm.system",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.model.provider",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.operation.name",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "llm.request.type",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.usage.input_tokens",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.usage.prompt_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 50
|
||||
},
|
||||
{
|
||||
"key": "llm.usage.prompt_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "llm.token_count.prompt",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "ai.usage.inputTokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.usage.promptTokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.usage.output_tokens",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.usage.completion_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 50
|
||||
},
|
||||
{
|
||||
"key": "llm.usage.completion_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "llm.token_count.completion",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "ai.usage.outputTokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.usage.completionTokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.usage.cache_read.input_tokens",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.usage.cache_read_input_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "llm.token_count.prompt_details.cache_read",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.usage.cachedInputTokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.usage.cache_creation.input_tokens",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.usage.cache_write.input_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "gen_ai.usage.cache_creation_input_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "llm.token_count.prompt_details.cache_write",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.input.messages",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.prompt",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "ai.prompt.messages",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "input.value",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.output.messages",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.completion",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "ai.response.text",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "output.value",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.conversation.id",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "session.id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "langfuse.session.id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.response.finish_reason",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "ai.response.finishReason",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
135
pkg/modules/spanmapper/implspanmapper/fs/definitions/tool.json
Normal file
135
pkg/modules/spanmapper/implspanmapper/fs/definitions/tool.json
Normal file
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"version": 1,
|
||||
"definition": {
|
||||
"name": "tool",
|
||||
"condition": {
|
||||
"attributes": [
|
||||
{
|
||||
"value": "tool"
|
||||
}
|
||||
],
|
||||
"resource": []
|
||||
},
|
||||
"enabled": true,
|
||||
"mappers": [
|
||||
{
|
||||
"name": "gen_ai.tool.name",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "tool.name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.toolCall.name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.tool.call.id",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "tool.id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.toolCall.id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.tool.description",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "tool.description",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.tool.call.arguments",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "ai.toolCall.args",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "traceloop.entity.input",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "gcp.vertex.agent.tool_call_args",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "input.value",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.tool.call.result",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "ai.toolCall.result",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "traceloop.entity.output",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "gcp.vertex.agent.tool_response",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "output.value",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,10 @@ func (h *handler) CreateGroup(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
group := spantypes.NewSpanMapperGroup(orgID, claims.Email, req)
|
||||
|
||||
@@ -191,6 +195,10 @@ func (h *handler) CreateMapper(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
mapper := spantypes.NewSpanMapper(groupID, claims.Email, req)
|
||||
|
||||
if err := h.module.CreateMapper(ctx, orgID, groupID, mapper); err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
@@ -14,13 +15,24 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// maxTestSpans bounds the input size: every test request boots a full
|
||||
// in-memory collector pipeline and is reachable with viewer access.
|
||||
const maxTestSpans = 100
|
||||
|
||||
type module struct {
|
||||
store spantypes.SpanMapperStore
|
||||
flagger flagger.Flagger
|
||||
store spantypes.SpanMapperStore
|
||||
flagger flagger.Flagger
|
||||
registry spantypes.SpanMapperGroupRegistry
|
||||
settings factory.ScopedProviderSettings
|
||||
}
|
||||
|
||||
func NewModule(store spantypes.SpanMapperStore, flagger flagger.Flagger) spanmapper.Module {
|
||||
return &module{store: store, flagger: flagger}
|
||||
func NewModule(store spantypes.SpanMapperStore, flagger flagger.Flagger, registry spantypes.SpanMapperGroupRegistry, providerSettings factory.ProviderSettings) spanmapper.Module {
|
||||
return &module{
|
||||
store: store,
|
||||
flagger: flagger,
|
||||
registry: registry,
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"),
|
||||
}
|
||||
}
|
||||
|
||||
func (module *module) ListGroups(ctx context.Context, orgID valuer.UUID, q *spantypes.ListSpanMapperGroupsQuery) ([]*spantypes.SpanMapperGroup, error) {
|
||||
@@ -32,6 +44,9 @@ func (module *module) GetGroup(ctx context.Context, orgID, id valuer.UUID) (*spa
|
||||
}
|
||||
|
||||
func (module *module) CreateGroup(ctx context.Context, orgID valuer.UUID, group *spantypes.SpanMapperGroup) error {
|
||||
if module.registry.IsReserved(group.Name) {
|
||||
return errors.Newf(errors.TypeInvalidInput, spantypes.ErrCodeMappingGroupNameReserved, "group name %q is reserved for a default group", group.Name)
|
||||
}
|
||||
return module.store.CreateGroup(ctx, group)
|
||||
}
|
||||
|
||||
@@ -40,10 +55,14 @@ func (module *module) UpdateGroup(ctx context.Context, orgID, id valuer.UUID, na
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
group.Update(name, condition, enabled, updatedBy)
|
||||
if name != nil && *name != group.Name && module.registry.IsReserved(*name) {
|
||||
return errors.Newf(errors.TypeInvalidInput, spantypes.ErrCodeMappingGroupNameReserved, "group name %q is reserved for a default group", *name)
|
||||
}
|
||||
if err := group.Update(name, condition, enabled, updatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = module.store.UpdateGroup(ctx, group)
|
||||
if err != nil {
|
||||
if err := module.store.UpdateGroup(ctx, group); err != nil {
|
||||
return err
|
||||
}
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
@@ -51,10 +70,16 @@ func (module *module) UpdateGroup(ctx context.Context, orgID, id valuer.UUID, na
|
||||
}
|
||||
|
||||
func (module *module) DeleteGroup(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
err := module.store.DeleteGroup(ctx, orgID, id)
|
||||
group, err := module.store.GetGroup(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := group.ErrIfNotDeletable(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := module.store.DeleteGroup(ctx, orgID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
@@ -81,14 +106,13 @@ func (module *module) CreateMapper(ctx context.Context, orgID, groupID valuer.UU
|
||||
}
|
||||
|
||||
func (module *module) UpdateMapper(ctx context.Context, orgID, groupID, id valuer.UUID, fieldContext spantypes.FieldContext, config *spantypes.SpanMapperConfig, enabled *bool, updatedBy string) error {
|
||||
if _, err := module.store.GetGroup(ctx, orgID, groupID); err != nil {
|
||||
return err
|
||||
}
|
||||
mapper, err := module.store.GetMapper(ctx, orgID, groupID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mapper.Update(fieldContext, config, enabled, updatedBy)
|
||||
if err := mapper.Update(fieldContext, config, enabled, updatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
err = module.store.UpdateMapper(ctx, mapper)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -98,7 +122,14 @@ func (module *module) UpdateMapper(ctx context.Context, orgID, groupID, id value
|
||||
}
|
||||
|
||||
func (module *module) DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error {
|
||||
err := module.store.DeleteMapper(ctx, orgID, groupID, id)
|
||||
mapper, err := module.store.GetMapper(ctx, orgID, groupID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mapper.ErrIfNotDeletable(); err != nil {
|
||||
return err
|
||||
}
|
||||
err = module.store.DeleteMapper(ctx, orgID, groupID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -106,10 +137,6 @@ func (module *module) DeleteMapper(ctx context.Context, orgID, groupID, id value
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxTestSpans bounds the input size: every test request boots a full
|
||||
// in-memory collector pipeline and is reachable with viewer access.
|
||||
const maxTestSpans = 100
|
||||
|
||||
func (module *module) TestMappers(ctx context.Context, orgID valuer.UUID, spans []spantypes.SpanMapperTestSpan, groups []*spantypes.SpanMapperGroupWithMappers) ([]spantypes.SpanMapperTestSpan, []string, error) {
|
||||
if len(spans) == 0 {
|
||||
return nil, nil, errors.New(errors.TypeInvalidInput, spantypes.ErrCodeMappingInvalidInput, "'spans' must contain at least one span")
|
||||
@@ -130,37 +157,6 @@ func (module *module) TestMappers(ctx context.Context, orgID valuer.UUID, spans
|
||||
return out, collectorLogs, nil
|
||||
}
|
||||
|
||||
// backfillMappers loads saved mappers for any enabled group whose Mappers is
|
||||
// nil. Disabled groups are skipped: the simulation filters them out anyway,
|
||||
// so there is no point loading their mappers or failing on their names.
|
||||
func (module *module) backfillMappers(ctx context.Context, orgID valuer.UUID, groups []*spantypes.SpanMapperGroupWithMappers) ([]*spantypes.SpanMapperGroupWithMappers, error) {
|
||||
savedGroups, err := module.store.ListGroups(ctx, orgID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
savedByName := make(map[string]*spantypes.SpanMapperGroup, len(savedGroups))
|
||||
for _, g := range savedGroups {
|
||||
savedByName[g.Name] = g
|
||||
}
|
||||
|
||||
// For each group in the request, if Mappers is nil, load the saved mappers for that group name.
|
||||
for _, g := range groups {
|
||||
if g.Mappers != nil || !g.Group.Enabled {
|
||||
continue
|
||||
}
|
||||
saved, ok := savedByName[g.Group.Name]
|
||||
if !ok {
|
||||
return nil, errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "no saved group named %q to load mappers from; send 'mappers' for new or edited groups", g.Group.Name)
|
||||
}
|
||||
loaded, err := module.store.ListMappers(ctx, orgID, saved.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Mappers = loaded
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func (module *module) AgentFeatureType() agentConf.AgentFeatureType {
|
||||
return spantypes.SpanAttrMappingFeatureType
|
||||
}
|
||||
@@ -196,6 +192,37 @@ func (module *module) RecommendAgentConfig(orgID valuer.UUID, currentConfYaml []
|
||||
return updatedConf, string(serialized), nil
|
||||
}
|
||||
|
||||
// backfillMappers loads saved mappers for any enabled group whose Mappers is
|
||||
// nil. Disabled groups are skipped: the simulation filters them out anyway,
|
||||
// so there is no point loading their mappers or failing on their names.
|
||||
func (module *module) backfillMappers(ctx context.Context, orgID valuer.UUID, groups []*spantypes.SpanMapperGroupWithMappers) ([]*spantypes.SpanMapperGroupWithMappers, error) {
|
||||
savedGroups, err := module.store.ListGroups(ctx, orgID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
savedByName := make(map[string]*spantypes.SpanMapperGroup, len(savedGroups))
|
||||
for _, g := range savedGroups {
|
||||
savedByName[g.Name] = g
|
||||
}
|
||||
|
||||
// For each group in the request, if Mappers is nil, load the saved mappers for that group name.
|
||||
for _, g := range groups {
|
||||
if g.Mappers != nil || !g.Group.Enabled {
|
||||
continue
|
||||
}
|
||||
saved, ok := savedByName[g.Group.Name]
|
||||
if !ok {
|
||||
return nil, errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "no saved group named %q to load mappers from; send 'mappers' for new or edited groups", g.Group.Name)
|
||||
}
|
||||
loaded, err := module.store.ListMappers(ctx, orgID, saved.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Mappers = loaded
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// listEnabledGroupsWithMappers returns groups with their mappers.
|
||||
func (module *module) listEnabledGroupsWithMappers(ctx context.Context, orgID valuer.UUID) ([]*spantypes.SpanMapperGroupWithMappers, error) {
|
||||
enabled := true
|
||||
|
||||
228
pkg/modules/spanmapper/implspanmapper/module_test.go
Normal file
228
pkg/modules/spanmapper/implspanmapper/module_test.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package implspanmapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testUser = "user@signoz.io"
|
||||
|
||||
func newTestSQLStore(t *testing.T) sqlstore.SQLStore {
|
||||
t.Helper()
|
||||
|
||||
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
|
||||
Provider: "sqlite",
|
||||
Connection: sqlstore.ConnectionConfig{MaxOpenConns: 10},
|
||||
Sqlite: sqlstore.SqliteConfig{
|
||||
Path: filepath.Join(t.TempDir(), "test.db"),
|
||||
Mode: "wal",
|
||||
BusyTimeout: 5 * time.Second,
|
||||
TransactionMode: "deferred",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, model := range []any{
|
||||
(*spantypes.StorableSpanMapperGroup)(nil),
|
||||
(*spantypes.StorableSpanMapper)(nil),
|
||||
} {
|
||||
_, err := store.BunDB().NewCreateTable().Model(model).IfNotExists().Exec(context.Background())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_span_mapper_group_org_name ON span_mapper_group (org_id, name)`)
|
||||
require.NoError(t, err)
|
||||
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_span_mapper_group_name ON span_mapper (group_id, name)`)
|
||||
require.NoError(t, err)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func newTestModule(t *testing.T, sqlStore sqlstore.SQLStore, definitions ...spantypes.SpanMapperGroupDefinition) *module {
|
||||
t.Helper()
|
||||
|
||||
registry, err := spantypes.NewSpanMapperGroupRegistry(definitions)
|
||||
require.NoError(t, err)
|
||||
|
||||
return NewModule(NewStore(sqlStore), nil, registry, factorytest.NewSettings()).(*module)
|
||||
}
|
||||
|
||||
func newTestDefinition(t *testing.T, version int, body string) spantypes.SpanMapperGroupDefinition {
|
||||
t.Helper()
|
||||
|
||||
definition, err := spantypes.NewSpanMapperGroupDefinition([]byte(`{"version": ` + strconv.Itoa(version) + `, "definition": ` + body + `}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
return definition
|
||||
}
|
||||
|
||||
// llmV1 ships two mappers; llmV2 renames a source, adds a mapper and drops one.
|
||||
const llmV1 = `{
|
||||
"name": "llm",
|
||||
"condition": {"attributes": [{"value": "model"}], "resource": []},
|
||||
"enabled": true,
|
||||
"mappers": [
|
||||
{"name": "gen_ai.request.model", "fieldContext": "attribute", "config": {"sources": [
|
||||
{"key": "llm.model_name", "context": "attribute", "operation": "copy", "priority": 20},
|
||||
{"key": "ai.model.id", "context": "attribute", "operation": "copy", "priority": 10}
|
||||
]}},
|
||||
{"name": "gen_ai.input.messages", "fieldContext": "attribute", "config": {"sources": [
|
||||
{"key": "gen_ai.prompt", "context": "attribute", "operation": "copy", "priority": 10}
|
||||
]}}
|
||||
]
|
||||
}`
|
||||
|
||||
const llmV2 = `{
|
||||
"name": "llm",
|
||||
"condition": {"attributes": [{"value": "model"}, {"value": "llm."}], "resource": []},
|
||||
"enabled": true,
|
||||
"mappers": [
|
||||
{"name": "gen_ai.request.model", "fieldContext": "attribute", "config": {"sources": [
|
||||
{"key": "llm.model_name", "context": "attribute", "operation": "copy", "priority": 20},
|
||||
{"key": "langfuse.observation.model.name", "context": "attribute", "operation": "copy", "priority": 10}
|
||||
]}},
|
||||
{"name": "gen_ai.provider.name", "fieldContext": "attribute", "config": {"sources": [
|
||||
{"key": "llm.vendor", "context": "attribute", "operation": "copy", "priority": 10}
|
||||
]}}
|
||||
]
|
||||
}`
|
||||
|
||||
func findMapper(t *testing.T, mappers []*spantypes.SpanMapper, name string) *spantypes.SpanMapper {
|
||||
t.Helper()
|
||||
for _, m := range mappers {
|
||||
if m.Name == name {
|
||||
return m
|
||||
}
|
||||
}
|
||||
require.Failf(t, "mapper not found", "no mapper named %q", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func findSource(t *testing.T, sources []spantypes.SpanMapperSource, key string, origin spantypes.SpanMapperOrigin) spantypes.SpanMapperSource {
|
||||
t.Helper()
|
||||
for _, s := range sources {
|
||||
if s.Key == key && s.Origin == origin {
|
||||
return s
|
||||
}
|
||||
}
|
||||
require.Failf(t, "source not found", "no %s source with key %q", origin.StringValue(), key)
|
||||
return spantypes.SpanMapperSource{}
|
||||
}
|
||||
|
||||
func TestReconcileUpgradeKeepsTogglesAndUserItems(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
orgID := valuer.GenerateUUID()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
v1 := newTestModule(t, sqlStore, newTestDefinition(t, 1, llmV1))
|
||||
require.NoError(t, v1.ReconcileSystemGroups(ctx, orgID))
|
||||
|
||||
group, err := v1.store.GetGroupByName(ctx, orgID, "llm")
|
||||
require.NoError(t, err)
|
||||
mappers, err := v1.ListMappers(ctx, orgID, group.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Switch the shipped substring off and add a user one.
|
||||
off := false
|
||||
require.NoError(t, v1.UpdateGroup(ctx, orgID, group.ID, nil, &spantypes.SpanMapperGroupCondition{
|
||||
Attributes: []spantypes.SpanMapperGroupConditionKey{
|
||||
{Value: "model", Enabled: false, Origin: spantypes.SpanMapperOriginSystem},
|
||||
{Value: "gen_ai.request.model", Enabled: true, Origin: spantypes.SpanMapperOriginUser},
|
||||
},
|
||||
Resource: []spantypes.SpanMapperGroupConditionKey{},
|
||||
}, &off, testUser))
|
||||
|
||||
// Switch a shipped source off, add a user override, and switch the mapper off.
|
||||
model := findMapper(t, mappers, "gen_ai.request.model")
|
||||
require.NoError(t, v1.UpdateMapper(ctx, orgID, group.ID, model.ID, spantypes.FieldContext{}, &spantypes.SpanMapperConfig{Sources: []spantypes.SpanMapperSource{
|
||||
{Key: "llm.model_name", Context: spantypes.FieldContextSpanAttribute, Operation: spantypes.SpanMapperOperationCopy, Priority: 20, Enabled: false, Origin: spantypes.SpanMapperOriginSystem},
|
||||
{Key: "llm.model_name", Context: spantypes.FieldContextSpanAttribute, Operation: spantypes.SpanMapperOperationMove, Priority: 1, Enabled: true, Origin: spantypes.SpanMapperOriginUser},
|
||||
}}, &off, testUser))
|
||||
|
||||
// Add a user source to the mapper v2 stops shipping, so it must survive.
|
||||
messages := findMapper(t, mappers, "gen_ai.input.messages")
|
||||
require.NoError(t, v1.UpdateMapper(ctx, orgID, group.ID, messages.ID, spantypes.FieldContext{}, &spantypes.SpanMapperConfig{Sources: []spantypes.SpanMapperSource{
|
||||
{Key: "input.value", Context: spantypes.FieldContextSpanAttribute, Operation: spantypes.SpanMapperOperationCopy, Priority: 1, Enabled: true},
|
||||
}}, nil, testUser))
|
||||
|
||||
// A user mapper in the shipped group.
|
||||
require.NoError(t, v1.CreateMapper(ctx, orgID, group.ID, spantypes.NewSpanMapper(group.ID, testUser, &spantypes.PostableSpanMapper{
|
||||
Name: "gen_ai.custom", FieldContext: spantypes.FieldContextSpanAttribute, Enabled: true,
|
||||
Config: spantypes.SpanMapperConfig{Sources: []spantypes.SpanMapperSource{{Key: "custom", Context: spantypes.FieldContextSpanAttribute, Operation: spantypes.SpanMapperOperationCopy, Priority: 1, Enabled: true}}},
|
||||
})))
|
||||
|
||||
v2 := newTestModule(t, sqlStore, newTestDefinition(t, 2, llmV2))
|
||||
require.NoError(t, v2.ReconcileSystemGroups(ctx, orgID))
|
||||
|
||||
upgraded, err := v2.GetGroup(ctx, orgID, group.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, upgraded.Version)
|
||||
assert.False(t, upgraded.Enabled)
|
||||
assert.Equal(t, spantypes.ProvisionerIdentity, upgraded.UpdatedBy)
|
||||
assert.Equal(t, []spantypes.SpanMapperGroupConditionKey{
|
||||
{Value: "model", Enabled: false, Origin: spantypes.SpanMapperOriginSystem},
|
||||
{Value: "llm.", Enabled: true, Origin: spantypes.SpanMapperOriginSystem},
|
||||
{Value: "gen_ai.request.model", Enabled: true, Origin: spantypes.SpanMapperOriginUser},
|
||||
}, upgraded.Condition.Attributes)
|
||||
|
||||
mappers, err = v2.ListMappers(ctx, orgID, group.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, mappers, 4)
|
||||
|
||||
model = findMapper(t, mappers, "gen_ai.request.model")
|
||||
assert.False(t, model.Enabled)
|
||||
assert.Equal(t, spantypes.SpanMapperOriginSystem, model.Origin)
|
||||
assert.False(t, findSource(t, model.Config.Sources, "llm.model_name", spantypes.SpanMapperOriginSystem).Enabled)
|
||||
assert.True(t, findSource(t, model.Config.Sources, "langfuse.observation.model.name", spantypes.SpanMapperOriginSystem).Enabled)
|
||||
assert.Equal(t, spantypes.SpanMapperOperationMove, findSource(t, model.Config.Sources, "llm.model_name", spantypes.SpanMapperOriginUser).Operation)
|
||||
assert.Len(t, model.Config.Sources, 3)
|
||||
|
||||
messages = findMapper(t, mappers, "gen_ai.input.messages")
|
||||
assert.Equal(t, spantypes.SpanMapperOriginUser, messages.Origin)
|
||||
require.Len(t, messages.Config.Sources, 1)
|
||||
assert.Equal(t, "input.value", messages.Config.Sources[0].Key)
|
||||
|
||||
assert.Equal(t, spantypes.SpanMapperOriginSystem, findMapper(t, mappers, "gen_ai.provider.name").Origin)
|
||||
assert.Equal(t, spantypes.SpanMapperOriginUser, findMapper(t, mappers, "gen_ai.custom").Origin)
|
||||
|
||||
// Shipping v1 again drops provider.name outright (no user sources) and
|
||||
// re-adopts the surviving user mapper input.messages as a shipped one.
|
||||
v3 := newTestModule(t, sqlStore, newTestDefinition(t, 3, llmV1))
|
||||
require.NoError(t, v3.ReconcileSystemGroups(ctx, orgID))
|
||||
mappers, err = v3.ListMappers(ctx, orgID, group.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, mappers, 3)
|
||||
for _, m := range mappers {
|
||||
assert.NotEqual(t, "gen_ai.provider.name", m.Name)
|
||||
}
|
||||
messages = findMapper(t, mappers, "gen_ai.input.messages")
|
||||
assert.Equal(t, spantypes.SpanMapperOriginSystem, messages.Origin)
|
||||
assert.Len(t, messages.Config.Sources, 2)
|
||||
}
|
||||
|
||||
func TestReconcileDoesNotDowngrade(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
orgID := valuer.GenerateUUID()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
|
||||
require.NoError(t, newTestModule(t, sqlStore, newTestDefinition(t, 2, llmV2)).ReconcileSystemGroups(ctx, orgID))
|
||||
older := newTestModule(t, sqlStore, newTestDefinition(t, 1, llmV1))
|
||||
require.NoError(t, older.ReconcileSystemGroups(ctx, orgID))
|
||||
|
||||
group, err := older.store.GetGroupByName(ctx, orgID, "llm")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, group.Version)
|
||||
mappers, err := older.ListMappers(ctx, orgID, group.ID)
|
||||
require.NoError(t, err)
|
||||
findMapper(t, mappers, "gen_ai.provider.name")
|
||||
}
|
||||
187
pkg/modules/spanmapper/implspanmapper/reconcile.go
Normal file
187
pkg/modules/spanmapper/implspanmapper/reconcile.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package implspanmapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func (module *module) ReconcileSystemGroups(ctx context.Context, orgID valuer.UUID) error {
|
||||
for _, definition := range module.registry.List() {
|
||||
if err := module.reconcileSystemGroup(ctx, orgID, definition); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcileSystemGroup brings one org's copy of a definition to the shipped
|
||||
// version in a single transaction. A concurrent provisioner (another replica,
|
||||
// or the org-creation hook racing the startup sweep) loses on the group's
|
||||
// unique (org_id, name) index and is treated as a no-op.
|
||||
func (module *module) reconcileSystemGroup(ctx context.Context, orgID valuer.UUID, definition spantypes.SpanMapperGroupDefinition) error {
|
||||
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
group, err := module.store.GetGroupByName(ctx, orgID, definition.Name())
|
||||
if err != nil && errors.Ast(err, errors.TypeNotFound) {
|
||||
group = newSystemGroup(orgID, definition)
|
||||
err = module.store.CreateGroup(ctx, group)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if group.Origin != spantypes.SpanMapperOriginSystem {
|
||||
module.settings.Logger().WarnContext(ctx, "skipping default span mapper group: a user group holds its name", slog.String("name", definition.Name()), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
if group.Version >= definition.Version {
|
||||
return nil
|
||||
}
|
||||
return module.applyDefinition(ctx, orgID, group, definition)
|
||||
})
|
||||
if err != nil && errors.Ast(err, errors.TypeAlreadyExists) {
|
||||
module.settings.Logger().DebugContext(ctx, "default span mapper group provisioned concurrently", slog.String("name", definition.Name()), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// applyDefinition replaces every shipped item with the definition, carrying each
|
||||
// enabled flag over by identity, and leaves user items untouched. A mapper that
|
||||
// is no longer shipped is deleted unless the user added sources to it, in which
|
||||
// case it survives as a user mapper.
|
||||
func (module *module) applyDefinition(ctx context.Context, orgID valuer.UUID, group *spantypes.SpanMapperGroup, definition spantypes.SpanMapperGroupDefinition) error {
|
||||
mappers, err := module.store.ListMappers(ctx, orgID, group.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byName := make(map[string]*spantypes.SpanMapper, len(mappers))
|
||||
for _, m := range mappers {
|
||||
byName[m.Name] = m
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for i := range definition.Definition.Mappers {
|
||||
pm := &definition.Definition.Mappers[i]
|
||||
mapper, exists := byName[pm.Name]
|
||||
delete(byName, pm.Name)
|
||||
if !exists {
|
||||
if err := module.store.CreateMapper(ctx, newSystemMapper(group.ID, pm)); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
mapper.Config.Sources = mergeShippedSources(mapper.Config.Sources, pm.Config.Sources)
|
||||
mapper.FieldContext = pm.FieldContext
|
||||
mapper.Origin = spantypes.SpanMapperOriginSystem
|
||||
mapper.UpdatedAt = now
|
||||
mapper.UpdatedBy = spantypes.ProvisionerIdentity
|
||||
if err := module.store.UpdateMapper(ctx, mapper); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever is left in byName is not shipped any more.
|
||||
for _, mapper := range byName {
|
||||
if mapper.Origin != spantypes.SpanMapperOriginSystem {
|
||||
continue
|
||||
}
|
||||
mapper.Config.Sources = mergeShippedSources(mapper.Config.Sources, nil)
|
||||
if len(mapper.Config.Sources) == 0 {
|
||||
if err := module.store.DeleteMapper(ctx, orgID, group.ID, mapper.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
mapper.Origin = spantypes.SpanMapperOriginUser
|
||||
mapper.UpdatedAt = now
|
||||
mapper.UpdatedBy = spantypes.ProvisionerIdentity
|
||||
if err := module.store.UpdateMapper(ctx, mapper); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
shipped := definition.Definition.Condition
|
||||
group.Condition = spantypes.SpanMapperGroupCondition{
|
||||
Attributes: mergeShippedConditionKeys(group.Condition.Attributes, shipped.Attributes),
|
||||
Resource: mergeShippedConditionKeys(group.Condition.Resource, shipped.Resource),
|
||||
}
|
||||
group.Version = definition.Version
|
||||
group.UpdatedAt = now
|
||||
group.UpdatedBy = spantypes.ProvisionerIdentity
|
||||
if err := module.store.UpdateGroup(ctx, group); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
module.settings.Logger().InfoContext(ctx, "applied default span mapper group", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
|
||||
// newSystemGroup is the empty shell applyDefinition fills: version 0 so the
|
||||
// definition is applied right after the row exists.
|
||||
func newSystemGroup(orgID valuer.UUID, definition spantypes.SpanMapperGroupDefinition) *spantypes.SpanMapperGroup {
|
||||
group := spantypes.NewSpanMapperGroup(orgID, spantypes.ProvisionerIdentity, &definition.Definition.PostableSpanMapperGroup)
|
||||
group.Condition = definition.Definition.Condition
|
||||
group.Enabled = true
|
||||
group.Origin = spantypes.SpanMapperOriginSystem
|
||||
return group
|
||||
}
|
||||
|
||||
func newSystemMapper(groupID valuer.UUID, pm *spantypes.PostableSpanMapper) *spantypes.SpanMapper {
|
||||
mapper := spantypes.NewSpanMapper(groupID, spantypes.ProvisionerIdentity, pm)
|
||||
mapper.Config = pm.Config
|
||||
mapper.Enabled = true
|
||||
mapper.Origin = spantypes.SpanMapperOriginSystem
|
||||
return mapper
|
||||
}
|
||||
|
||||
// mergeShippedConditionKeys returns the shipped keys, each keeping the enabled
|
||||
// flag of the stored system key with the same value, followed by the stored
|
||||
// user keys.
|
||||
func mergeShippedConditionKeys(stored, shipped []spantypes.SpanMapperGroupConditionKey) []spantypes.SpanMapperGroupConditionKey {
|
||||
out := make([]spantypes.SpanMapperGroupConditionKey, 0, len(stored)+len(shipped))
|
||||
for _, k := range shipped {
|
||||
idx := slices.IndexFunc(stored, func(s spantypes.SpanMapperGroupConditionKey) bool {
|
||||
return s.Origin == spantypes.SpanMapperOriginSystem && s.Value == k.Value
|
||||
})
|
||||
if idx != -1 {
|
||||
k.Enabled = stored[idx].Enabled
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
for _, k := range stored {
|
||||
if k.Origin != spantypes.SpanMapperOriginSystem {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeShippedSources returns the shipped sources, each keeping the enabled
|
||||
// flag of the stored system source with the same key and context, followed by
|
||||
// the stored user sources.
|
||||
func mergeShippedSources(stored, shipped []spantypes.SpanMapperSource) []spantypes.SpanMapperSource {
|
||||
out := make([]spantypes.SpanMapperSource, 0, len(stored)+len(shipped))
|
||||
for _, s := range shipped {
|
||||
idx := slices.IndexFunc(stored, func(o spantypes.SpanMapperSource) bool {
|
||||
return o.Origin == spantypes.SpanMapperOriginSystem && o.Key == s.Key && o.Context == s.Context
|
||||
})
|
||||
if idx != -1 {
|
||||
s.Enabled = stored[idx].Enabled
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
for _, s := range stored {
|
||||
if s.Origin != spantypes.SpanMapperOriginSystem {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
81
pkg/modules/spanmapper/implspanmapper/service.go
Normal file
81
pkg/modules/spanmapper/implspanmapper/service.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package implspanmapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
)
|
||||
|
||||
const reconcileRetryInterval = 30 * time.Second
|
||||
|
||||
type service struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
module spanmapper.Module
|
||||
orgGetter organization.Getter
|
||||
stopC chan struct{}
|
||||
healthyC chan struct{}
|
||||
}
|
||||
|
||||
// NewService reconciles every org's default mapping groups once at startup.
|
||||
// Orgs created later are reconciled by the organization setter instead.
|
||||
func NewService(providerSettings factory.ProviderSettings, module spanmapper.Module, orgGetter organization.Getter) factory.Service {
|
||||
return &service{
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"),
|
||||
module: module,
|
||||
orgGetter: orgGetter,
|
||||
stopC: make(chan struct{}),
|
||||
healthyC: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (service *service) Start(ctx context.Context) error {
|
||||
ticker := time.NewTicker(reconcileRetryInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
err := service.reconcile(ctx)
|
||||
if err == nil {
|
||||
close(service.healthyC)
|
||||
<-service.stopC
|
||||
return nil
|
||||
}
|
||||
|
||||
service.settings.Logger().WarnContext(ctx, "default span mapper group reconciliation failed, retrying", errors.Attr(err))
|
||||
|
||||
select {
|
||||
case <-service.stopC:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (service *service) Healthy() <-chan struct{} {
|
||||
return service.healthyC
|
||||
}
|
||||
|
||||
func (service *service) Stop(_ context.Context) error {
|
||||
close(service.stopC)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *service) reconcile(ctx context.Context) error {
|
||||
orgs, err := service.orgGetter.ListByOwnedKeyRange(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
if err := service.module.ReconcileSystemGroups(ctx, org.ID); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't reconcile default span mapper groups for org %s", org.ID.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
service.settings.Logger().InfoContext(ctx, "default span mapper group reconciliation completed", slog.Int("orgs", len(orgs)))
|
||||
return nil
|
||||
}
|
||||
@@ -17,6 +17,10 @@ func NewStore(sqlstore sqlstore.SQLStore) spantypes.SpanMapperStore {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (s *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
|
||||
return s.sqlstore.RunInTxCtx(ctx, nil, cb)
|
||||
}
|
||||
|
||||
func (s *store) CreateGroup(ctx context.Context, group *spantypes.SpanMapperGroup) error {
|
||||
storable := group.ToStorable()
|
||||
_, err := s.sqlstore.
|
||||
@@ -34,7 +38,7 @@ func (s *store) GetGroup(ctx context.Context, orgID, id valuer.UUID) (*spantypes
|
||||
storable := new(spantypes.StorableSpanMapperGroup)
|
||||
|
||||
err := s.sqlstore.
|
||||
BunDB().
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("org_id = ?", orgID).
|
||||
@@ -46,11 +50,27 @@ func (s *store) GetGroup(ctx context.Context, orgID, id valuer.UUID) (*spantypes
|
||||
return storable.ToSpanMapperGroup(), nil
|
||||
}
|
||||
|
||||
func (s *store) GetGroupByName(ctx context.Context, orgID valuer.UUID, name string) (*spantypes.SpanMapperGroup, error) {
|
||||
storable := new(spantypes.StorableSpanMapperGroup)
|
||||
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("name = ?", name).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, s.sqlstore.WrapNotFoundErrf(err, spantypes.ErrCodeMappingGroupNotFound, "span mapper group %q not found", name)
|
||||
}
|
||||
return storable.ToSpanMapperGroup(), nil
|
||||
}
|
||||
|
||||
func (s *store) ListGroups(ctx context.Context, orgID valuer.UUID, q *spantypes.ListSpanMapperGroupsQuery) ([]*spantypes.SpanMapperGroup, error) {
|
||||
storables := make([]*spantypes.StorableSpanMapperGroup, 0)
|
||||
|
||||
sel := s.sqlstore.
|
||||
BunDB().
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&storables).
|
||||
Where("org_id = ?", orgID)
|
||||
@@ -91,38 +111,35 @@ func (s *store) UpdateGroup(ctx context.Context, group *spantypes.SpanMapperGrou
|
||||
}
|
||||
|
||||
func (s *store) DeleteGroup(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
tx, err := s.sqlstore.BunDBCtx(ctx).BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
return s.RunInTx(ctx, func(ctx context.Context) error {
|
||||
db := s.sqlstore.BunDBCtx(ctx)
|
||||
|
||||
// Cascade: remove mappers belonging to this group first.
|
||||
if _, err := tx.NewDelete().
|
||||
Model((*spantypes.StorableSpanMapper)(nil)).
|
||||
Where("group_id = ?", id).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
// Cascade: remove mappers belonging to this group first.
|
||||
if _, err := db.NewDelete().
|
||||
Model((*spantypes.StorableSpanMapper)(nil)).
|
||||
Where("group_id = ?", id).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := tx.NewDelete().
|
||||
Model((*spantypes.StorableSpanMapperGroup)(nil)).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := db.NewDelete().
|
||||
Model((*spantypes.StorableSpanMapperGroup)(nil)).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "span mapper group %s not found", id)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "span mapper group %s not found", id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *store) CreateMapper(ctx context.Context, mapper *spantypes.SpanMapper) error {
|
||||
@@ -146,7 +163,7 @@ func (s *store) GetMapper(ctx context.Context, orgID, groupID, id valuer.UUID) (
|
||||
|
||||
storable := new(spantypes.StorableSpanMapper)
|
||||
err := s.sqlstore.
|
||||
BunDB().
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("group_id = ?", groupID).
|
||||
@@ -166,7 +183,7 @@ func (s *store) ListMappers(ctx context.Context, orgID, groupID valuer.UUID) ([]
|
||||
|
||||
storables := make([]*spantypes.StorableSpanMapper, 0)
|
||||
if err := s.sqlstore.
|
||||
BunDB().
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(&storables).
|
||||
Where("group_id = ?", groupID).
|
||||
|
||||
@@ -28,6 +28,10 @@ type Module interface {
|
||||
UpdateMapper(ctx context.Context, orgID, groupID, id valuer.UUID, fieldContext spantypes.FieldContext, config *spantypes.SpanMapperConfig, enabled *bool, updatedBy string) error
|
||||
DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error
|
||||
TestMappers(ctx context.Context, orgID valuer.UUID, spans []spantypes.SpanMapperTestSpan, groups []*spantypes.SpanMapperGroupWithMappers) ([]spantypes.SpanMapperTestSpan, []string, error)
|
||||
|
||||
// ReconcileSystemGroups provisions or upgrades the shipped mapping groups
|
||||
// for one org. It runs at startup for every org and again on org creation.
|
||||
ReconcileSystemGroups(ctx context.Context, orgID valuer.UUID) error
|
||||
}
|
||||
|
||||
// Handler defines the HTTP handler interface for mapping group and mapper endpoints.
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
package queryprogress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/exp/maps"
|
||||
)
|
||||
|
||||
// tracks progress and manages subscriptions for all queries
|
||||
type inMemoryQueryProgressTracker struct {
|
||||
logger *slog.Logger
|
||||
queries map[string]*queryTracker
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
func (tracker *inMemoryQueryProgressTracker) ReportQueryStarted(
|
||||
queryId string,
|
||||
) (postQueryCleanup func(), apiErr *model.ApiError) {
|
||||
tracker.lock.Lock()
|
||||
defer tracker.lock.Unlock()
|
||||
|
||||
_, exists := tracker.queries[queryId]
|
||||
if exists {
|
||||
return nil, model.BadRequest(fmt.Errorf(
|
||||
"query %s already started", queryId,
|
||||
))
|
||||
}
|
||||
|
||||
tracker.queries[queryId] = newQueryTracker(tracker.logger, queryId)
|
||||
|
||||
return func() {
|
||||
tracker.onQueryFinished(queryId)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (tracker *inMemoryQueryProgressTracker) ReportQueryProgress(
|
||||
queryId string, chProgress *clickhouse.Progress,
|
||||
) *model.ApiError {
|
||||
queryTracker, err := tracker.getQueryTracker(queryId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
queryTracker.handleProgressUpdate(chProgress)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tracker *inMemoryQueryProgressTracker) SubscribeToQueryProgress(
|
||||
queryId string,
|
||||
) (<-chan model.QueryProgress, func(), *model.ApiError) {
|
||||
queryTracker, err := tracker.getQueryTracker(queryId)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return queryTracker.subscribe()
|
||||
}
|
||||
|
||||
func (tracker *inMemoryQueryProgressTracker) onQueryFinished(
|
||||
queryId string,
|
||||
) {
|
||||
tracker.lock.Lock()
|
||||
queryTracker := tracker.queries[queryId]
|
||||
if queryTracker != nil {
|
||||
delete(tracker.queries, queryId)
|
||||
}
|
||||
tracker.lock.Unlock()
|
||||
|
||||
if queryTracker != nil {
|
||||
queryTracker.onFinished()
|
||||
}
|
||||
}
|
||||
|
||||
func (tracker *inMemoryQueryProgressTracker) getQueryTracker(
|
||||
queryId string,
|
||||
) (*queryTracker, *model.ApiError) {
|
||||
tracker.lock.RLock()
|
||||
defer tracker.lock.RUnlock()
|
||||
|
||||
queryTracker := tracker.queries[queryId]
|
||||
if queryTracker == nil {
|
||||
return nil, model.NotFoundError(fmt.Errorf(
|
||||
"query %s doesn't exist", queryId,
|
||||
))
|
||||
}
|
||||
|
||||
return queryTracker, nil
|
||||
}
|
||||
|
||||
// Tracks progress and manages subscriptions for a single query
|
||||
type queryTracker struct {
|
||||
logger *slog.Logger
|
||||
queryId string
|
||||
isFinished bool
|
||||
|
||||
progress *model.QueryProgress
|
||||
subscriptions map[string]*queryProgressSubscription
|
||||
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
func newQueryTracker(logger *slog.Logger, queryId string) *queryTracker {
|
||||
return &queryTracker{
|
||||
logger: logger,
|
||||
queryId: queryId,
|
||||
subscriptions: map[string]*queryProgressSubscription{},
|
||||
}
|
||||
}
|
||||
|
||||
func (qt *queryTracker) handleProgressUpdate(p *clickhouse.Progress) {
|
||||
qt.lock.Lock()
|
||||
defer qt.lock.Unlock()
|
||||
|
||||
if qt.isFinished {
|
||||
qt.logger.Warn("received clickhouse progress update for finished query", "queryId", qt.queryId, "progress", p)
|
||||
return
|
||||
}
|
||||
|
||||
if qt.progress == nil {
|
||||
// This is the first update
|
||||
qt.progress = &model.QueryProgress{}
|
||||
}
|
||||
updateQueryProgress(qt.progress, p)
|
||||
|
||||
// broadcast latest state to all subscribers.
|
||||
for _, sub := range maps.Values(qt.subscriptions) {
|
||||
sub.send(*qt.progress)
|
||||
}
|
||||
}
|
||||
|
||||
func (qt *queryTracker) subscribe() (
|
||||
<-chan model.QueryProgress, func(), *model.ApiError,
|
||||
) {
|
||||
qt.lock.Lock()
|
||||
defer qt.lock.Unlock()
|
||||
|
||||
if qt.isFinished {
|
||||
return nil, nil, model.NotFoundError(fmt.Errorf(
|
||||
"query %s already finished", qt.queryId,
|
||||
))
|
||||
}
|
||||
|
||||
subscriberId := uuid.NewString()
|
||||
subscription := newQueryProgressSubscription(qt.logger)
|
||||
qt.subscriptions[subscriberId] = subscription
|
||||
|
||||
if qt.progress != nil {
|
||||
subscription.send(*qt.progress)
|
||||
}
|
||||
|
||||
return subscription.ch, func() {
|
||||
qt.unsubscribe(subscriberId)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (qt *queryTracker) unsubscribe(subscriberId string) {
|
||||
qt.lock.Lock()
|
||||
defer qt.lock.Unlock()
|
||||
|
||||
if qt.isFinished {
|
||||
qt.logger.Debug("received unsubscribe request after query finished", "subscriber", subscriberId, "queryId", qt.queryId)
|
||||
return
|
||||
}
|
||||
|
||||
subscription := qt.subscriptions[subscriberId]
|
||||
if subscription != nil {
|
||||
subscription.close()
|
||||
delete(qt.subscriptions, subscriberId)
|
||||
}
|
||||
}
|
||||
|
||||
func (qt *queryTracker) onFinished() {
|
||||
qt.lock.Lock()
|
||||
defer qt.lock.Unlock()
|
||||
|
||||
if qt.isFinished {
|
||||
qt.logger.Warn("receiver query finish report after query finished", "queryId", qt.queryId)
|
||||
return
|
||||
}
|
||||
|
||||
for subId, sub := range qt.subscriptions {
|
||||
sub.close()
|
||||
delete(qt.subscriptions, subId)
|
||||
}
|
||||
|
||||
qt.isFinished = true
|
||||
}
|
||||
|
||||
type queryProgressSubscription struct {
|
||||
logger *slog.Logger
|
||||
ch chan model.QueryProgress
|
||||
isClosed bool
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
func newQueryProgressSubscription(logger *slog.Logger) *queryProgressSubscription {
|
||||
ch := make(chan model.QueryProgress, 1000)
|
||||
return &queryProgressSubscription{
|
||||
logger: logger,
|
||||
ch: ch,
|
||||
}
|
||||
}
|
||||
|
||||
// Must not block or panic in any scenario
|
||||
func (ch *queryProgressSubscription) send(progress model.QueryProgress) {
|
||||
ch.lock.Lock()
|
||||
defer ch.lock.Unlock()
|
||||
|
||||
if ch.isClosed {
|
||||
ch.logger.Error("can't send query progress: channel already closed.", "progress", progress)
|
||||
return
|
||||
}
|
||||
|
||||
// subscription channels are expected to have big enough buffers to ensure
|
||||
// blocking while sending doesn't happen in the happy path
|
||||
select {
|
||||
case ch.ch <- progress:
|
||||
ch.logger.Debug("published query progress", "progress", progress)
|
||||
default:
|
||||
ch.logger.Error("couldn't publish query progress. dropping update.", "progress", progress)
|
||||
}
|
||||
}
|
||||
|
||||
func (ch *queryProgressSubscription) close() {
|
||||
ch.lock.Lock()
|
||||
defer ch.lock.Unlock()
|
||||
|
||||
if !ch.isClosed {
|
||||
close(ch.ch)
|
||||
ch.isClosed = true
|
||||
}
|
||||
}
|
||||
|
||||
func updateQueryProgress(qp *model.QueryProgress, chProgress *clickhouse.Progress) {
|
||||
qp.ReadRows += chProgress.Rows
|
||||
qp.ReadBytes += chProgress.Bytes
|
||||
qp.ElapsedMs += uint64(chProgress.Elapsed.Milliseconds())
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package queryprogress
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
)
|
||||
|
||||
type QueryProgressTracker interface {
|
||||
// Tells the tracker that query with id `queryId` has started.
|
||||
// Progress can only be reported for and tracked for a query that is in progress.
|
||||
// Returns a cleanup function that must be called after the query finishes.
|
||||
ReportQueryStarted(queryId string) (postQueryCleanup func(), apiErr *model.ApiError)
|
||||
|
||||
// Report progress stats received from clickhouse for `queryId`
|
||||
ReportQueryProgress(queryId string, chProgress *clickhouse.Progress) *model.ApiError
|
||||
|
||||
// Subscribe to progress updates for `queryId`
|
||||
// The returned channel will produce `QueryProgress` instances representing
|
||||
// the latest state of query progress stats. Also returns a function that
|
||||
// can be called to unsubscribe before the query finishes, if needed.
|
||||
SubscribeToQueryProgress(queryId string) (ch <-chan model.QueryProgress, unsubscribe func(), apiErr *model.ApiError)
|
||||
}
|
||||
|
||||
func NewQueryProgressTracker(logger *slog.Logger) QueryProgressTracker {
|
||||
// InMemory tracker is useful only for single replica query service setups.
|
||||
// Multi replica setups must use a centralized store for tracking and subscribing to query progress
|
||||
return &inMemoryQueryProgressTracker{
|
||||
logger: logger,
|
||||
queries: map[string]*queryTracker{},
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package queryprogress
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestQueryProgressTracking(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
tracker := NewQueryProgressTracker(slog.Default())
|
||||
|
||||
testQueryId := "test-query"
|
||||
|
||||
testProgress := &clickhouse.Progress{}
|
||||
err := tracker.ReportQueryProgress(testQueryId, testProgress)
|
||||
require.NotNil(err, "shouldn't be able to report query progress before query has been started")
|
||||
require.Equal(err.Type(), model.ErrorNotFound)
|
||||
|
||||
ch, unsubscribe, err := tracker.SubscribeToQueryProgress(testQueryId)
|
||||
require.NotNil(err, "shouldn't be able to subscribe for progress updates before query has been started")
|
||||
require.Equal(err.Type(), model.ErrorNotFound)
|
||||
require.Nil(ch)
|
||||
require.Nil(unsubscribe)
|
||||
|
||||
reportQueryFinished, err := tracker.ReportQueryStarted(testQueryId)
|
||||
require.Nil(err, "should be able to report start of a query to be tracked")
|
||||
|
||||
testProgress1 := &clickhouse.Progress{
|
||||
Rows: 10,
|
||||
Bytes: 20,
|
||||
TotalRows: 100,
|
||||
Elapsed: 20 * time.Millisecond,
|
||||
}
|
||||
err = tracker.ReportQueryProgress(testQueryId, testProgress1)
|
||||
require.Nil(err, "should be able to report progress after query has started")
|
||||
|
||||
ch, unsubscribe, err = tracker.SubscribeToQueryProgress(testQueryId)
|
||||
require.Nil(err, "should be able to subscribe to query progress updates after query started")
|
||||
require.NotNil(ch)
|
||||
require.NotNil(unsubscribe)
|
||||
|
||||
expectedProgress := model.QueryProgress{}
|
||||
updateQueryProgress(&expectedProgress, testProgress1)
|
||||
require.Equal(expectedProgress.ReadRows, testProgress1.Rows)
|
||||
select {
|
||||
case qp := <-ch:
|
||||
require.Equal(qp, expectedProgress)
|
||||
default:
|
||||
require.Fail("should receive latest query progress state immediately after subscription")
|
||||
}
|
||||
select {
|
||||
case _ = <-ch:
|
||||
require.Fail("should have had only one pending update at this point")
|
||||
default:
|
||||
}
|
||||
|
||||
testProgress2 := &clickhouse.Progress{
|
||||
Rows: 20,
|
||||
Bytes: 40,
|
||||
TotalRows: 100,
|
||||
Elapsed: 40 * time.Millisecond,
|
||||
}
|
||||
err = tracker.ReportQueryProgress(testQueryId, testProgress2)
|
||||
require.Nil(err, "should be able to report progress multiple times while query is in progress")
|
||||
|
||||
updateQueryProgress(&expectedProgress, testProgress2)
|
||||
select {
|
||||
case qp := <-ch:
|
||||
require.Equal(qp, expectedProgress)
|
||||
default:
|
||||
require.Fail("should receive updates whenever new progress updates get reported to tracker")
|
||||
}
|
||||
select {
|
||||
case _ = <-ch:
|
||||
require.Fail("should have had only one pending update at this point")
|
||||
default:
|
||||
}
|
||||
|
||||
reportQueryFinished()
|
||||
select {
|
||||
case _, isSubscriptionChannelOpen := <-ch:
|
||||
require.False(isSubscriptionChannelOpen, "subscription channels should get closed after query finishes")
|
||||
default:
|
||||
require.Fail("subscription channels should get closed after query finishes")
|
||||
}
|
||||
|
||||
err = tracker.ReportQueryProgress(testQueryId, testProgress)
|
||||
require.NotNil(err, "shouldn't be able to report query progress after query has finished")
|
||||
require.Equal(err.Type(), model.ErrorNotFound)
|
||||
|
||||
ch, unsubscribe, err = tracker.SubscribeToQueryProgress(testQueryId)
|
||||
require.NotNil(err, "shouldn't be able to subscribe for progress updates after query has finished")
|
||||
require.Equal(err.Type(), model.ErrorNotFound)
|
||||
require.Nil(ch)
|
||||
require.Nil(unsubscribe)
|
||||
}
|
||||
@@ -44,7 +44,6 @@ import (
|
||||
|
||||
"log/slog"
|
||||
|
||||
queryprogress "github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader/query_progress"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/resource"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/services"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/traces/smart"
|
||||
@@ -145,7 +144,6 @@ type ClickHouseReader struct {
|
||||
logsResourceKeys string
|
||||
logsTagAttributeTableV2 string
|
||||
logger *slog.Logger
|
||||
queryProgressTracker queryprogress.QueryProgressTracker
|
||||
|
||||
logsTableV2 string
|
||||
logsLocalTableV2 string
|
||||
@@ -214,7 +212,6 @@ func NewReader(
|
||||
logsTagAttributeTableV2: options.primary.LogsTagAttributeTableV2,
|
||||
liveTailRefreshSeconds: options.primary.LiveTailRefreshSeconds,
|
||||
cluster: cluster,
|
||||
queryProgressTracker: queryprogress.NewQueryProgressTracker(logger),
|
||||
logsTableV2: options.primary.LogsTableV2,
|
||||
logsLocalTableV2: options.primary.LogsLocalTableV2,
|
||||
logsResourceTableV2: options.primary.LogsResourceTableV2,
|
||||
@@ -4024,27 +4021,6 @@ func (r *ClickHouseReader) GetTimeSeriesResultV3(ctx context.Context, query stri
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetTimeSeriesResultV3",
|
||||
})
|
||||
// Hook up query progress reporting if requested.
|
||||
queryId := ctx.Value("queryId")
|
||||
if queryId != nil {
|
||||
qid, ok := queryId.(string)
|
||||
if !ok {
|
||||
r.logger.Error("GetTimeSeriesResultV3: queryId in ctx not a string as expected", "queryId", queryId)
|
||||
|
||||
} else {
|
||||
ctx = clickhouse.Context(ctx, clickhouse.WithProgress(
|
||||
func(p *clickhouse.Progress) {
|
||||
go func() {
|
||||
err := r.queryProgressTracker.ReportQueryProgress(qid, p)
|
||||
if err != nil {
|
||||
r.logger.Error("Couldn't report query progress", "queryId", qid, errorsV2.Attr(err))
|
||||
}
|
||||
}()
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := r.db.Query(ctx, query)
|
||||
|
||||
if err != nil {
|
||||
@@ -5005,18 +4981,6 @@ func (r *ClickHouseReader) GetMinAndMaxTimestampForTraceID(ctx context.Context,
|
||||
return minTime.UnixNano(), maxTime.UnixNano(), nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) ReportQueryStartForProgressTracking(
|
||||
queryId string,
|
||||
) (func(), *model.ApiError) {
|
||||
return r.queryProgressTracker.ReportQueryStarted(queryId)
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) SubscribeToQueryProgress(
|
||||
queryId string,
|
||||
) (<-chan model.QueryProgress, func(), *model.ApiError) {
|
||||
return r.queryProgressTracker.SubscribeToQueryProgress(queryId)
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) UpdateMetricsMetadata(ctx context.Context, orgID valuer.UUID, req *model.UpdateMetricsMetadata) *model.ApiError {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
|
||||
@@ -36,7 +36,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/websocket"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
@@ -115,9 +114,6 @@ type APIHandler struct {
|
||||
// is registers.
|
||||
SetupCompleted bool
|
||||
|
||||
// Websocket connection upgrader
|
||||
Upgrader *websocket.Upgrader
|
||||
|
||||
QueryParserAPI *queryparser.API
|
||||
|
||||
Signoz *signoz.SigNoz
|
||||
@@ -207,12 +203,6 @@ func NewAPIHandler(opts APIHandlerOpts, config signoz.Config) (*APIHandler, erro
|
||||
aH.SetupCompleted = true
|
||||
}
|
||||
|
||||
aH.Upgrader = &websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
return aH, nil
|
||||
}
|
||||
|
||||
@@ -354,18 +344,10 @@ func (aH *APIHandler) RegisterQueryRangeV3Routes(router *mux.Router, am *middlew
|
||||
|
||||
subRouter.HandleFunc("/filter_suggestions", am.ViewAccess(aH.getQueryBuilderSuggestions)).Methods(http.MethodGet)
|
||||
|
||||
// TODO(Raj): Remove this handler after /ws based path has been completely rolled out.
|
||||
subRouter.HandleFunc("/query_progress", am.ViewAccess(aH.GetQueryProgressUpdates)).Methods(http.MethodGet)
|
||||
|
||||
// live logs
|
||||
subRouter.HandleFunc("/logs/livetail", am.ViewAccess(aH.Signoz.Handlers.QuerierHandler.QueryRawStream)).Methods(http.MethodGet)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) RegisterWebSocketPaths(router *mux.Router, am *middleware.AuthZ) {
|
||||
subRouter := router.PathPrefix("/ws").Subrouter()
|
||||
subRouter.HandleFunc("/query_progress", am.ViewAccess(aH.GetQueryProgressUpdates)).Methods(http.MethodGet)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) RegisterQueryRangeV4Routes(router *mux.Router, am *middleware.AuthZ) {
|
||||
subRouter := router.PathPrefix("/api/v4").Subrouter()
|
||||
subRouter.HandleFunc("/query_range", am.ViewAccess(aH.QueryRangeV4)).Methods(http.MethodPost)
|
||||
@@ -3545,27 +3527,6 @@ func (aH *APIHandler) queryRangeV3(ctx context.Context, queryRangeParams *v3.Que
|
||||
}
|
||||
}
|
||||
|
||||
// Hook up query progress tracking if requested
|
||||
queryIdHeader := r.Header.Get("X-SIGNOZ-QUERY-ID")
|
||||
if len(queryIdHeader) > 0 {
|
||||
onQueryFinished, apiErr := aH.reader.ReportQueryStartForProgressTracking(queryIdHeader)
|
||||
|
||||
if apiErr != nil {
|
||||
aH.logger.ErrorContext(ctx, "failed to report query start for progress tracking",
|
||||
"query_id", queryIdHeader, errors.Attr(apiErr),
|
||||
)
|
||||
|
||||
} else {
|
||||
// Adding queryId to the context signals clickhouse queries to report progress
|
||||
//lint:ignore SA1029 ignore for now
|
||||
ctx = context.WithValue(ctx, "queryId", queryIdHeader)
|
||||
|
||||
defer func() {
|
||||
go onQueryFinished()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "app",
|
||||
instrumentationtypes.CodeFunctionName: "QueryRange",
|
||||
@@ -3747,73 +3708,6 @@ func (aH *APIHandler) QueryRangeV3(w http.ResponseWriter, r *http.Request) {
|
||||
aH.queryRangeV3(r.Context(), queryRangeParams, w, r)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) GetQueryProgressUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
// Upgrade connection to websocket, sending back the requested protocol
|
||||
// value for sec-websocket-protocol
|
||||
//
|
||||
// Since js websocket API doesn't allow setting headers, this header is often
|
||||
// used for passing auth tokens. As per websocket spec the connection will only
|
||||
// succeed if the requested `Sec-Websocket-Protocol` is sent back as a header
|
||||
// in the upgrade response (signifying that the protocol is supported by the server).
|
||||
upgradeResponseHeaders := http.Header{}
|
||||
requestedProtocol := r.Header.Get("Sec-WebSocket-Protocol")
|
||||
if len(requestedProtocol) > 0 {
|
||||
upgradeResponseHeaders.Add("Sec-WebSocket-Protocol", requestedProtocol)
|
||||
}
|
||||
|
||||
c, err := aH.Upgrader.Upgrade(w, r, upgradeResponseHeaders)
|
||||
if err != nil {
|
||||
RespondError(w, model.InternalError(fmt.Errorf(
|
||||
"couldn't upgrade connection: %w", err,
|
||||
)), nil)
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// Websocket upgrade complete. Subscribe to query progress and send updates to client
|
||||
//
|
||||
// Note: we handle any subscription problems (queryId query param missing or query already complete etc)
|
||||
// after the websocket connection upgrade by closing the channel.
|
||||
// The other option would be to handle the errors before websocket upgrade by sending an
|
||||
// error response instead of the upgrade response, but that leads to a generic websocket
|
||||
// connection failure on the client.
|
||||
|
||||
queryId := r.URL.Query().Get("q")
|
||||
|
||||
progressCh, unsubscribe, apiErr := aH.reader.SubscribeToQueryProgress(queryId)
|
||||
if apiErr != nil {
|
||||
// Shouldn't happen unless query progress requested after query finished
|
||||
aH.logger.WarnContext(r.Context(), "failed to subscribe to query progress",
|
||||
"query_id", queryId, errors.Attr(apiErr),
|
||||
)
|
||||
return
|
||||
}
|
||||
defer func() { go unsubscribe() }()
|
||||
|
||||
for queryProgress := range progressCh {
|
||||
msg, err := json.Marshal(queryProgress)
|
||||
if err != nil {
|
||||
aH.logger.ErrorContext(r.Context(), "failed to serialize progress message",
|
||||
"query_id", queryId, "progress", queryProgress, errors.Attr(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
err = c.WriteMessage(websocket.TextMessage, msg)
|
||||
if err != nil {
|
||||
aH.logger.ErrorContext(r.Context(), "failed to write progress message to websocket",
|
||||
"query_id", queryId, "msg", string(msg), errors.Attr(err),
|
||||
)
|
||||
break
|
||||
|
||||
} else {
|
||||
aH.logger.DebugContext(r.Context(), "wrote progress message to websocket",
|
||||
"query_id", queryId, "msg", string(msg),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (aH *APIHandler) getMetricMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -164,7 +164,6 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
|
||||
api.RegisterLogsRoutes(r, am)
|
||||
api.RegisterIntegrationRoutes(r, am)
|
||||
api.RegisterQueryRangeV3Routes(r, am)
|
||||
api.RegisterWebSocketPaths(r, am)
|
||||
api.RegisterQueryRangeV4Routes(r, am)
|
||||
api.RegisterMessagingQueuesRoutes(r, am)
|
||||
api.RegisterThirdPartyApiRoutes(r, am)
|
||||
@@ -178,7 +177,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control", "X-SIGNOZ-QUERY-ID", "Sec-WebSocket-Protocol"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control"},
|
||||
})
|
||||
|
||||
handler := c.Handler(r)
|
||||
|
||||
@@ -91,10 +91,6 @@ type Reader interface {
|
||||
|
||||
GetMinAndMaxTimestampForTraceID(ctx context.Context, traceID []string) (int64, int64, error)
|
||||
|
||||
// Query Progress tracking helpers.
|
||||
ReportQueryStartForProgressTracking(queryId string) (reportQueryFinished func(), apiErr *model.ApiError)
|
||||
SubscribeToQueryProgress(queryId string) (<-chan model.QueryProgress, func(), *model.ApiError)
|
||||
|
||||
//trace
|
||||
GetTraceFields(ctx context.Context) (*model.GetFieldsResponse, *model.ApiError)
|
||||
UpdateTraceField(ctx context.Context, field *model.UpdateField) *model.ApiError
|
||||
|
||||
@@ -7,14 +7,6 @@ import (
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
)
|
||||
|
||||
type QueryProgress struct {
|
||||
ReadRows uint64 `json:"read_rows"`
|
||||
|
||||
ReadBytes uint64 `json:"read_bytes"`
|
||||
|
||||
ElapsedMs uint64 `json:"elapsed_ms"`
|
||||
}
|
||||
|
||||
func GetLogFieldsV3(ctx context.Context, queryRangeParams *v3.QueryRangeParamsV3, fields *GetFieldsResponse) map[string]v3.AttributeKey {
|
||||
data := map[string]v3.AttributeKey{}
|
||||
for _, query := range queryRangeParams.CompositeQuery.BuilderQueries {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
@@ -61,7 +62,10 @@ func TestNewHandlers(t *testing.T) {
|
||||
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
|
||||
|
||||
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil)
|
||||
spanMapperRegistry, err := implspanmapper.NewSystemGroupRegistry()
|
||||
require.NoError(t, err)
|
||||
spanMapperModule := implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), flagger, spanMapperRegistry, providerSettings)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil, spanMapperModule)
|
||||
|
||||
querierHandler := querier.NewHandler(providerSettings, nil, nil)
|
||||
registryHandler := factory.NewHandler(nil)
|
||||
|
||||
@@ -45,7 +45,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session/implsession"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag"
|
||||
@@ -124,9 +123,10 @@ func NewModules(
|
||||
fl flagger.Flagger,
|
||||
tagModule tag.Module,
|
||||
metricReductionRule metricreductionrule.Module,
|
||||
spanMapper spanmapper.Module,
|
||||
) Modules {
|
||||
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
|
||||
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter, dashboard)
|
||||
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter, dashboard, spanMapper)
|
||||
// Cleanup callbacks from other modules, invoked when a user is deleted.
|
||||
onDeleteUser := []user.OnDeleteUser{
|
||||
dashboard.DeletePreferencesForUser,
|
||||
@@ -162,7 +162,7 @@ func NewModules(
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
|
||||
SpanMapper: spanMapper,
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
|
||||
Tag: tagModule,
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
@@ -68,7 +69,11 @@ func TestNewModules(t *testing.T) {
|
||||
|
||||
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
|
||||
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule())
|
||||
spanMapperRegistry, err := implspanmapper.NewSystemGroupRegistry()
|
||||
require.NoError(t, err)
|
||||
spanMapperModule := implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), flagger, spanMapperRegistry, providerSettings)
|
||||
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule(), spanMapperModule)
|
||||
|
||||
reflectVal := reflect.ValueOf(modules)
|
||||
for i := 0; i < reflectVal.NumField(); i++ {
|
||||
|
||||
@@ -253,6 +253,8 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddQuickFilterTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddIngestionTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddSubscriptionTuplesFactory(sqlstore),
|
||||
sqlmigration.NewNormalizeQuickFilterFieldsFactory(sqlstore),
|
||||
sqlmigration.NewAddSpanMapperOriginFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
@@ -549,7 +550,15 @@ func New(
|
||||
metricReductionRuleModule := metricReductionRuleModuleCallback(sqlstore, telemetrystore, dashboard, queryParser, licensing, flagger, telemetryMetadataStore, providerSettings, config.MetricsExplorer.TelemetryStore.Threads)
|
||||
|
||||
// Initialize all modules
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule)
|
||||
// The default mapping group registry is parsed here so a malformed embedded
|
||||
// definition fails startup instead of a request.
|
||||
spanMapperRegistry, err := implspanmapper.NewSystemGroupRegistry()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spanMapperModule := implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), flagger, spanMapperRegistry, providerSettings)
|
||||
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule, spanMapperModule)
|
||||
|
||||
// Initialize ruler from the variant-specific provider factories
|
||||
rulerInstance, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Ruler, rulerProviderFactories(cache, alertmanager, sqlstore, telemetrystore, telemetryMetadataStore, prometheus, orgGetter, modules.RuleStateHistory, querier, queryParser), "signoz")
|
||||
@@ -619,6 +628,7 @@ func New(
|
||||
factory.NewNamedService(factory.MustNewName("meterreporter"), meterReporter, factory.MustNewName("licensing")),
|
||||
factory.NewNamedService(factory.MustNewName("ruler"), rulerInstance),
|
||||
factory.NewNamedService(factory.MustNewName("systemdashboard"), impldashboard.NewService(providerSettings, dashboard, orgGetter)),
|
||||
factory.NewNamedService(factory.MustNewName("spanmappergroup"), implspanmapper.NewService(providerSettings, spanMapperModule, orgGetter)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
161
pkg/sqlmigration/126_normalize_quick_filter_fields.go
Normal file
161
pkg/sqlmigration/126_normalize_quick_filter_fields.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
)
|
||||
|
||||
type quickFilterSourceRow struct {
|
||||
bun.BaseModel `bun:"table:quick_filter"`
|
||||
|
||||
ID string `bun:"id,pk"`
|
||||
Source string `bun:"source"`
|
||||
Filter string `bun:"filter"`
|
||||
}
|
||||
|
||||
type quickFilterStaticField struct {
|
||||
name string
|
||||
fieldContext string
|
||||
fieldDataType string
|
||||
}
|
||||
|
||||
// quickFilterSpanFields are the span-level fields the fields API serves with
|
||||
// the span context, keyed by every name a stored filter may carry for them.
|
||||
var quickFilterSpanFields = func() map[string]quickFilterStaticField {
|
||||
fields := map[string]quickFilterStaticField{}
|
||||
for name, dataType := range map[string]string{
|
||||
"trace_id": "string", "span_id": "string", "trace_state": "string", "parent_span_id": "string",
|
||||
"flags": "number", "name": "string", "kind": "number", "kind_string": "string",
|
||||
"duration_nano": "number", "status_code": "number", "status_message": "string", "status_code_string": "string",
|
||||
"response_status_code": "string", "external_http_url": "string", "http_url": "string",
|
||||
"external_http_method": "string", "http_method": "string", "http_host": "string",
|
||||
"db_name": "string", "db_operation": "string", "has_error": "bool", "is_remote": "string",
|
||||
} {
|
||||
fields[name] = quickFilterStaticField{name: name, fieldContext: "span", fieldDataType: dataType}
|
||||
}
|
||||
for deprecated, current := range map[string]string{
|
||||
"responseStatusCode": "response_status_code", "externalHttpUrl": "external_http_url", "httpUrl": "http_url",
|
||||
"externalHttpMethod": "external_http_method", "httpMethod": "http_method", "httpHost": "http_host",
|
||||
"dbName": "db_name", "dbOperation": "db_operation", "hasError": "has_error", "isRemote": "is_remote",
|
||||
} {
|
||||
fields[deprecated] = fields[current]
|
||||
}
|
||||
return fields
|
||||
}()
|
||||
|
||||
// quickFilterLogFields are the log-level fields the fields API serves with
|
||||
// the log context.
|
||||
var quickFilterLogFields = map[string]quickFilterStaticField{
|
||||
"body": {name: "body", fieldContext: "log", fieldDataType: "string"},
|
||||
"severity_text": {name: "severity_text", fieldContext: "log", fieldDataType: "string"},
|
||||
"severity_number": {name: "severity_number", fieldContext: "log", fieldDataType: "number"},
|
||||
"trace_id": {name: "trace_id", fieldContext: "log", fieldDataType: "string"},
|
||||
"span_id": {name: "span_id", fieldContext: "log", fieldDataType: "string"},
|
||||
"trace_flags": {name: "trace_flags", fieldContext: "log", fieldDataType: "number"},
|
||||
}
|
||||
|
||||
type normalizeQuickFilterFields struct {
|
||||
settings factory.ProviderSettings
|
||||
}
|
||||
|
||||
func NewNormalizeQuickFilterFieldsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("normalize_quick_filter_fields"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &normalizeQuickFilterFields{settings: ps}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *normalizeQuickFilterFields) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *normalizeQuickFilterFields) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var rows []*quickFilterSourceRow
|
||||
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var migrated, skipped int
|
||||
for _, row := range rows {
|
||||
normalized, changed, ok := normalizeQuickFilterEntries(row.Source, row.Filter)
|
||||
if !ok {
|
||||
migration.settings.Logger.WarnContext(ctx, "quick filter could not be parsed, leaving it untouched", slog.String("quick_filter_id", row.ID), slog.String("raw_filter", row.Filter))
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
|
||||
migrated++
|
||||
if _, err := tx.NewUpdate().Model((*quickFilterSourceRow)(nil)).Set("filter = ?", normalized).Where("id = ?", row.ID).Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
migration.settings.Logger.InfoContext(ctx, "normalized quick filter static fields", slog.Int("total", len(rows)), slog.Int("migrated", migrated), slog.Int("skipped", skipped))
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *normalizeQuickFilterFields) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeQuickFilterEntries rewrites the static fields of a stored filter
|
||||
// list to the name, context and data type the fields API serves them with:
|
||||
// span fields for the trace-based sources, log fields for logs, whatever
|
||||
// context the legacy seeds gave them. Other keys are left as they are;
|
||||
// ok=false means unparseable.
|
||||
func normalizeQuickFilterEntries(source string, filter string) (normalized string, changed bool, ok bool) {
|
||||
var staticFields map[string]quickFilterStaticField
|
||||
switch source {
|
||||
case "traces", "api_monitoring", "exceptions", "ai_observability":
|
||||
staticFields = quickFilterSpanFields
|
||||
case "logs":
|
||||
staticFields = quickFilterLogFields
|
||||
default:
|
||||
return "", false, true
|
||||
}
|
||||
|
||||
var entries []telemetryFieldKeyOutput
|
||||
if err := json.Unmarshal([]byte(filter), &entries); err != nil {
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
for i, entry := range entries {
|
||||
field, static := staticFields[entry.Name]
|
||||
if !static {
|
||||
continue
|
||||
}
|
||||
if entry.Name == field.name && entry.FieldContext == field.fieldContext && entry.FieldDataType == field.fieldDataType {
|
||||
continue
|
||||
}
|
||||
entries[i].Name = field.name
|
||||
entries[i].FieldContext = field.fieldContext
|
||||
entries[i].FieldDataType = field.fieldDataType
|
||||
changed = true
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return "", false, true
|
||||
}
|
||||
|
||||
normalizedJSON, err := marshalUnescaped(entries)
|
||||
if err != nil {
|
||||
return "", false, false
|
||||
}
|
||||
return string(normalizedJSON), true, true
|
||||
}
|
||||
93
pkg/sqlmigration/127_add_span_mapper_origin.go
Normal file
93
pkg/sqlmigration/127_add_span_mapper_origin.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addSpanMapperOrigin struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
sqlschema sqlschema.SQLSchema
|
||||
}
|
||||
|
||||
func NewAddSpanMapperOriginFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName("add_span_mapper_origin"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addSpanMapperOrigin{sqlstore: sqlstore, sqlschema: sqlschema}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (migration *addSpanMapperOrigin) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
// Up adds the ownership columns that let SigNoz ship default mapping groups
|
||||
// alongside user ones.
|
||||
func (migration *addSpanMapperOrigin) Up(ctx context.Context, db *bun.DB) error {
|
||||
// span_mapper references span_mapper_group and both have foreign keys, so
|
||||
// enforcement must be off for the SQLite recreate-table fallback.
|
||||
if err := migration.sqlschema.ToggleFKEnforcement(ctx, db, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
groupTable, groupUniqueConstraints, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("span_mapper_group"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sqls := migration.sqlschema.Operator().AddColumn(groupTable, groupUniqueConstraints, &sqlschema.Column{
|
||||
Name: sqlschema.ColumnName("origin"),
|
||||
DataType: sqlschema.DataTypeText,
|
||||
Nullable: false,
|
||||
Default: "'user'",
|
||||
}, "user")
|
||||
sqls = append(sqls, migration.sqlschema.Operator().AddColumn(groupTable, groupUniqueConstraints, &sqlschema.Column{
|
||||
Name: sqlschema.ColumnName("version"),
|
||||
DataType: sqlschema.DataTypeBigInt,
|
||||
Nullable: false,
|
||||
Default: "0",
|
||||
}, 0)...)
|
||||
|
||||
mapperTable, mapperUniqueConstraints, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("span_mapper"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sqls = append(sqls, migration.sqlschema.Operator().AddColumn(mapperTable, mapperUniqueConstraints, &sqlschema.Column{
|
||||
Name: sqlschema.ColumnName("origin"),
|
||||
DataType: sqlschema.DataTypeText,
|
||||
Nullable: false,
|
||||
Default: "'user'",
|
||||
}, "user")...)
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return migration.sqlschema.ToggleFKEnforcement(ctx, db, true)
|
||||
}
|
||||
|
||||
func (migration *addSpanMapperOrigin) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
61
pkg/telemetrymetadata/bool_values.go
Normal file
61
pkg/telemetrymetadata/bool_values.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package telemetrymetadata
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// boolFieldValues is the suggestion set for a bool field, optionally narrowed
|
||||
// by the search text.
|
||||
func boolFieldValues(searchText string) *telemetrytypes.TelemetryFieldValues {
|
||||
values := &telemetrytypes.TelemetryFieldValues{}
|
||||
needle := strings.ToLower(searchText)
|
||||
for _, v := range []bool{true, false} {
|
||||
if needle == "" || strings.Contains(strconv.FormatBool(v), needle) {
|
||||
values.BoolValues = append(values.BoolValues, v)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// spanSearchScopeFieldValues is the suggestion set for a search-scope selector
|
||||
// (isRoot, isEntryPoint), which only filters with true. ok is false for any
|
||||
// other name.
|
||||
func spanSearchScopeFieldValues(name, searchText string) (*telemetrytypes.TelemetryFieldValues, bool) {
|
||||
for scopeName := range tracestelemetryschema.SpanSearchScopeFields {
|
||||
if !strings.EqualFold(scopeName, name) {
|
||||
continue
|
||||
}
|
||||
values := &telemetrytypes.TelemetryFieldValues{}
|
||||
if needle := strings.ToLower(searchText); needle == "" || strings.Contains("true", needle) {
|
||||
values.BoolValues = []bool{true}
|
||||
}
|
||||
return values, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// isKnownBoolField is true when the caller asked for the bool data type, or
|
||||
// when the name is one of the signal's static bool fields and the requested
|
||||
// context does not rule that static field out.
|
||||
func isKnownBoolField(selector *telemetrytypes.FieldValueSelector, staticFields ...map[string]telemetrytypes.TelemetryFieldKey) bool {
|
||||
if selector.FieldDataType == telemetrytypes.FieldDataTypeBool {
|
||||
return true
|
||||
}
|
||||
if selector.FieldDataType != telemetrytypes.FieldDataTypeUnspecified {
|
||||
return false
|
||||
}
|
||||
for _, fields := range staticFields {
|
||||
field, ok := fields[selector.Name]
|
||||
if !ok || field.FieldDataType != telemetrytypes.FieldDataTypeBool {
|
||||
continue
|
||||
}
|
||||
if selector.FieldContext == telemetrytypes.FieldContextUnspecified || selector.FieldContext == field.FieldContext {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -187,8 +187,6 @@ func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelector
|
||||
).From(t.tracesDBName + "." + t.spanAttributesKeysTblName)
|
||||
var limit int
|
||||
|
||||
searchTexts := []string{}
|
||||
|
||||
conds := []string{}
|
||||
for _, fieldKeySelector := range fieldKeySelectors {
|
||||
|
||||
@@ -208,14 +206,12 @@ func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelector
|
||||
fieldKeyConds = append(fieldKeyConds, sb.ILike("tagKey", "%"+escapeForLike(fieldKeySelector.Name)+"%"))
|
||||
}
|
||||
|
||||
searchTexts = append(searchTexts, fieldKeySelector.Name)
|
||||
// now look at the field context
|
||||
// we don't write most of intrinsic fields to keys table
|
||||
// for this reason we don't want to apply tagType if the field context
|
||||
// is not attribute or resource attribute
|
||||
if fieldKeySelector.FieldContext != telemetrytypes.FieldContextUnspecified &&
|
||||
(fieldKeySelector.FieldContext == telemetrytypes.FieldContextAttribute ||
|
||||
fieldKeySelector.FieldContext == telemetrytypes.FieldContextResource) {
|
||||
// is not attribute, resource attribute or scope
|
||||
switch fieldKeySelector.FieldContext {
|
||||
case telemetrytypes.FieldContextAttribute, telemetrytypes.FieldContextResource, telemetrytypes.FieldContextScope:
|
||||
fieldKeyConds = append(fieldKeyConds, sb.E("tagType", fieldKeySelector.FieldContext.TagType()))
|
||||
}
|
||||
|
||||
@@ -288,41 +284,20 @@ func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelector
|
||||
// hit the limit? (only counting DB results)
|
||||
complete := rowCount <= limit
|
||||
|
||||
staticKeys := []string{"isRoot", "isEntryPoint"}
|
||||
staticKeys = append(staticKeys, maps.Keys(tracestelemetryschema.IntrinsicFields)...)
|
||||
staticKeys = append(staticKeys, maps.Keys(tracestelemetryschema.CalculatedFields)...)
|
||||
// Add the matching static fields: the span scope selectors, the intrinsic
|
||||
// columns and the calculated columns. These don't count towards the limit
|
||||
staticFields := maps.Values(tracestelemetryschema.SpanSearchScopeFields)
|
||||
staticFields = append(staticFields, maps.Values(tracestelemetryschema.IntrinsicFields)...)
|
||||
staticFields = append(staticFields, maps.Values(tracestelemetryschema.CalculatedFields)...)
|
||||
|
||||
// Add matching intrinsic and matching calculated fields
|
||||
// These don't count towards the limit
|
||||
for _, key := range staticKeys {
|
||||
found := false
|
||||
for _, v := range searchTexts {
|
||||
if v == "" || strings.Contains(key, v) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
for _, field := range staticFields {
|
||||
if !staticFieldMatchesAny(field, fieldKeySelectors) {
|
||||
continue
|
||||
}
|
||||
|
||||
if found {
|
||||
if field, exists := tracestelemetryschema.IntrinsicFields[key]; exists {
|
||||
if _, added := mapOfKeys[field.Name+";"+field.FieldContext.StringValue()+";"+field.FieldDataType.StringValue()]; !added {
|
||||
keys = append(keys, &field)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if field, exists := tracestelemetryschema.CalculatedFields[key]; exists {
|
||||
if _, added := mapOfKeys[field.Name+";"+field.FieldContext.StringValue()+";"+field.FieldDataType.StringValue()]; !added {
|
||||
keys = append(keys, &field)
|
||||
}
|
||||
continue
|
||||
}
|
||||
keys = append(keys, &telemetrytypes.TelemetryFieldKey{
|
||||
Name: key,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
})
|
||||
if _, added := mapOfKeys[field.Name+";"+field.FieldContext.StringValue()+";"+field.FieldDataType.StringValue()]; added {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, &field)
|
||||
}
|
||||
|
||||
if err = t.updateColumnEvolutionMetadataForKeys(ctx, keys); err != nil {
|
||||
@@ -542,12 +517,6 @@ func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, orgID valuer.UUID,
|
||||
allArgs = append(allArgs, args...)
|
||||
}
|
||||
|
||||
if len(queries) == 0 {
|
||||
// No matching contexts, return empty result
|
||||
return []*telemetrytypes.TelemetryFieldKey{}, true, nil
|
||||
}
|
||||
|
||||
// Combine queries with UNION ALL
|
||||
var limit int
|
||||
for _, fieldKeySelector := range fieldKeySelectors {
|
||||
limit += fieldKeySelector.Limit
|
||||
@@ -556,7 +525,15 @@ func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, orgID valuer.UUID,
|
||||
limit = 1000
|
||||
}
|
||||
|
||||
mainQuery := fmt.Sprintf(`
|
||||
keys := []*telemetrytypes.TelemetryFieldKey{}
|
||||
parentTypes := make(map[string][]telemetrytypes.FieldDataType)
|
||||
rowCount := 0
|
||||
|
||||
// the log and scope contexts have no keys table; they are served by the
|
||||
// static fields appended below
|
||||
if len(queries) > 0 {
|
||||
// Combine queries with UNION ALL
|
||||
mainQuery := fmt.Sprintf(`
|
||||
SELECT tag_key, tag_type, tag_data_type, max(priority) as priority
|
||||
FROM (
|
||||
%s
|
||||
@@ -566,103 +543,75 @@ func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, orgID valuer.UUID,
|
||||
LIMIT %d
|
||||
`, strings.Join(queries, " UNION ALL "), limit+1)
|
||||
|
||||
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, mainQuery, allArgs...)
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetLogsKeys.Error())
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := []*telemetrytypes.TelemetryFieldKey{}
|
||||
parentTypes := make(map[string][]telemetrytypes.FieldDataType)
|
||||
rowCount := 0
|
||||
searchTexts := []string{}
|
||||
|
||||
// Collect search texts for static field matching
|
||||
for _, fieldKeySelector := range fieldKeySelectors {
|
||||
searchTexts = append(searchTexts, fieldKeySelector.Name)
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
rowCount++
|
||||
// reached the limit, we know there are more results
|
||||
if rowCount > limit {
|
||||
break
|
||||
}
|
||||
|
||||
var name string
|
||||
var fieldContext telemetrytypes.FieldContext
|
||||
var fieldDataType telemetrytypes.FieldDataType
|
||||
var priority uint8
|
||||
err = rows.Scan(&name, &fieldContext, &fieldDataType, &priority)
|
||||
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, mainQuery, allArgs...)
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetLogsKeys.Error())
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// ArrayJSON/ArrayDynamic body rows for parent paths are needed by the JSON access plan
|
||||
// builder (enrichJSONKeys). Always record them in parentTypes. Only skip adding to keys
|
||||
// if the user did not also directly request this name — a field like "education" can be
|
||||
// both a parent of "education[].name" and an explicitly queried field in its own right.
|
||||
switch fieldDataType {
|
||||
case telemetrytypes.FieldDataTypeArrayJSON, telemetrytypes.FieldDataTypeArrayDynamic:
|
||||
if fieldContext == telemetrytypes.FieldContextBody && parentPaths[name] {
|
||||
parentTypes[name] = append(parentTypes[name], fieldDataType)
|
||||
if !mapOfRequestedSelectors[name] {
|
||||
continue // skip; don't register the key.
|
||||
for rows.Next() {
|
||||
rowCount++
|
||||
// reached the limit, we know there are more results
|
||||
if rowCount > limit {
|
||||
break
|
||||
}
|
||||
|
||||
var name string
|
||||
var fieldContext telemetrytypes.FieldContext
|
||||
var fieldDataType telemetrytypes.FieldDataType
|
||||
var priority uint8
|
||||
err = rows.Scan(&name, &fieldContext, &fieldDataType, &priority)
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetLogsKeys.Error())
|
||||
}
|
||||
|
||||
// ArrayJSON/ArrayDynamic body rows for parent paths are needed by the JSON access plan
|
||||
// builder (enrichJSONKeys). Always record them in parentTypes. Only skip adding to keys
|
||||
// if the user did not also directly request this name — a field like "education" can be
|
||||
// both a parent of "education[].name" and an explicitly queried field in its own right.
|
||||
switch fieldDataType {
|
||||
case telemetrytypes.FieldDataTypeArrayJSON, telemetrytypes.FieldDataTypeArrayDynamic:
|
||||
if fieldContext == telemetrytypes.FieldContextBody && parentPaths[name] {
|
||||
parentTypes[name] = append(parentTypes[name], fieldDataType)
|
||||
if !mapOfRequestedSelectors[name] {
|
||||
continue // skip; don't register the key.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
key, ok := mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()]
|
||||
key, ok := mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()]
|
||||
|
||||
// if there is no materialised column, create a key with the field context and data type
|
||||
if !ok {
|
||||
key = &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: fieldContext,
|
||||
FieldDataType: fieldDataType,
|
||||
// if there is no materialised column, create a key with the field context and data type
|
||||
if !ok {
|
||||
key = &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: fieldContext,
|
||||
FieldDataType: fieldDataType,
|
||||
}
|
||||
}
|
||||
|
||||
keys = append(keys, key)
|
||||
mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()] = key
|
||||
}
|
||||
|
||||
keys = append(keys, key)
|
||||
mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()] = key
|
||||
}
|
||||
|
||||
if rows.Err() != nil {
|
||||
return nil, false, errors.Wrap(rows.Err(), errors.TypeInternal, errors.CodeInternal, ErrFailedToGetLogsKeys.Error())
|
||||
if rows.Err() != nil {
|
||||
return nil, false, errors.Wrap(rows.Err(), errors.TypeInternal, errors.CodeInternal, ErrFailedToGetLogsKeys.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// hit the limit? (only counting DB results)
|
||||
complete := rowCount <= limit
|
||||
|
||||
staticKeys := []string{}
|
||||
staticKeys = append(staticKeys, maps.Keys(logstelemetryschema.IntrinsicFields)...)
|
||||
|
||||
// Add matching intrinsic and matching calculated fields
|
||||
// These don't count towards the limit
|
||||
for _, key := range staticKeys {
|
||||
found := false
|
||||
for _, v := range searchTexts {
|
||||
if v == "" || strings.Contains(key, v) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
// Add the matching intrinsic columns. These don't count towards the limit
|
||||
for _, field := range maps.Values(logstelemetryschema.IntrinsicFields) {
|
||||
if !staticFieldMatchesAny(field, fieldKeySelectors) {
|
||||
continue
|
||||
}
|
||||
|
||||
if found {
|
||||
if field, exists := logstelemetryschema.IntrinsicFields[key]; exists {
|
||||
if _, added := mapOfKeys[field.Name+";"+field.FieldContext.StringValue()+";"+field.FieldDataType.StringValue()]; !added {
|
||||
keys = append(keys, &field)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
keys = append(keys, &telemetrytypes.TelemetryFieldKey{
|
||||
Name: key,
|
||||
FieldContext: telemetrytypes.FieldContextLog,
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
})
|
||||
if _, added := mapOfKeys[field.Name+";"+field.FieldContext.StringValue()+";"+field.FieldDataType.StringValue()]; added {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, &field)
|
||||
}
|
||||
|
||||
// enrich body keys with promoted paths, indexes, and JSON access plans
|
||||
@@ -806,10 +755,6 @@ func (t *telemetryMetaStore) getAuditKeys(ctx context.Context, fieldKeySelectors
|
||||
allArgs = append(allArgs, args...)
|
||||
}
|
||||
|
||||
if len(queries) == 0 {
|
||||
return []*telemetrytypes.TelemetryFieldKey{}, true, nil
|
||||
}
|
||||
|
||||
var limit int
|
||||
for _, fieldKeySelector := range fieldKeySelectors {
|
||||
limit += fieldKeySelector.Limit
|
||||
@@ -818,7 +763,13 @@ func (t *telemetryMetaStore) getAuditKeys(ctx context.Context, fieldKeySelectors
|
||||
limit = 1000
|
||||
}
|
||||
|
||||
mainQuery := fmt.Sprintf(`
|
||||
keys := []*telemetrytypes.TelemetryFieldKey{}
|
||||
rowCount := 0
|
||||
|
||||
// the log and scope contexts have no keys table; they are served by the
|
||||
// static fields appended below
|
||||
if len(queries) > 0 {
|
||||
mainQuery := fmt.Sprintf(`
|
||||
SELECT tag_key, tag_type, tag_data_type, max(priority) as priority
|
||||
FROM (
|
||||
%s
|
||||
@@ -828,73 +779,57 @@ func (t *telemetryMetaStore) getAuditKeys(ctx context.Context, fieldKeySelectors
|
||||
LIMIT %d
|
||||
`, strings.Join(queries, " UNION ALL "), limit+1)
|
||||
|
||||
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, mainQuery, allArgs...)
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetAuditKeys.Error())
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := []*telemetrytypes.TelemetryFieldKey{}
|
||||
rowCount := 0
|
||||
searchTexts := []string{}
|
||||
|
||||
for _, fieldKeySelector := range fieldKeySelectors {
|
||||
searchTexts = append(searchTexts, fieldKeySelector.Name)
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
rowCount++
|
||||
if rowCount > limit {
|
||||
break
|
||||
}
|
||||
|
||||
var name string
|
||||
var fieldContext telemetrytypes.FieldContext
|
||||
var fieldDataType telemetrytypes.FieldDataType
|
||||
var priority uint8
|
||||
err = rows.Scan(&name, &fieldContext, &fieldDataType, &priority)
|
||||
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, mainQuery, allArgs...)
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetAuditKeys.Error())
|
||||
}
|
||||
key, ok := mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()]
|
||||
defer rows.Close()
|
||||
|
||||
if !ok {
|
||||
key = &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: fieldContext,
|
||||
FieldDataType: fieldDataType,
|
||||
for rows.Next() {
|
||||
rowCount++
|
||||
if rowCount > limit {
|
||||
break
|
||||
}
|
||||
|
||||
var name string
|
||||
var fieldContext telemetrytypes.FieldContext
|
||||
var fieldDataType telemetrytypes.FieldDataType
|
||||
var priority uint8
|
||||
err = rows.Scan(&name, &fieldContext, &fieldDataType, &priority)
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetAuditKeys.Error())
|
||||
}
|
||||
key, ok := mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()]
|
||||
|
||||
if !ok {
|
||||
key = &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: fieldContext,
|
||||
FieldDataType: fieldDataType,
|
||||
}
|
||||
}
|
||||
|
||||
keys = append(keys, key)
|
||||
mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()] = key
|
||||
}
|
||||
|
||||
keys = append(keys, key)
|
||||
mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()] = key
|
||||
}
|
||||
|
||||
if rows.Err() != nil {
|
||||
return nil, false, errors.Wrap(rows.Err(), errors.TypeInternal, errors.CodeInternal, ErrFailedToGetAuditKeys.Error())
|
||||
if rows.Err() != nil {
|
||||
return nil, false, errors.Wrap(rows.Err(), errors.TypeInternal, errors.CodeInternal, ErrFailedToGetAuditKeys.Error())
|
||||
}
|
||||
}
|
||||
|
||||
complete := rowCount <= limit
|
||||
|
||||
// Add intrinsic audit fields (same as logs intrinsics: body, severity_text, etc.)
|
||||
staticKeys := maps.Keys(audittelemetryschema.IntrinsicFields)
|
||||
for _, key := range staticKeys {
|
||||
found := false
|
||||
for _, v := range searchTexts {
|
||||
if v == "" || strings.Contains(key, v) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
// Add the matching intrinsic audit fields (same as logs intrinsics: body, severity_text, etc.)
|
||||
for _, field := range maps.Values(audittelemetryschema.IntrinsicFields) {
|
||||
if !staticFieldMatchesAny(field, fieldKeySelectors) {
|
||||
continue
|
||||
}
|
||||
|
||||
if found {
|
||||
if field, exists := audittelemetryschema.IntrinsicFields[key]; exists {
|
||||
if _, added := mapOfKeys[field.Name+";"+field.FieldContext.StringValue()+";"+field.FieldDataType.StringValue()]; !added {
|
||||
keys = append(keys, &field)
|
||||
}
|
||||
}
|
||||
if _, added := mapOfKeys[field.Name+";"+field.FieldContext.StringValue()+";"+field.FieldDataType.StringValue()]; added {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, &field)
|
||||
}
|
||||
|
||||
return keys, complete, nil
|
||||
@@ -1091,9 +1026,12 @@ func (t *telemetryMetaStore) getMeterSourceMetricKeys(ctx context.Context, field
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetMeterKeys.Error())
|
||||
}
|
||||
// meter labels are stored as strings in the labels JSON and have no
|
||||
// attribute context, so only the data type is known
|
||||
keys = append(keys, &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1506,88 +1444,13 @@ func (t *telemetryMetaStore) getSpanFieldValues(ctx context.Context, fieldValueS
|
||||
instrumentationtypes.CodeNamespace: "metadata",
|
||||
instrumentationtypes.CodeFunctionName: "getSpanFieldValues",
|
||||
})
|
||||
// build the query to get the keys from the spans that match the field selection criteria
|
||||
limit := fieldValueSelector.Limit
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
|
||||
if values, ok := spanSearchScopeFieldValues(fieldValueSelector.Name, fieldValueSelector.Value); ok {
|
||||
return values, true, nil
|
||||
}
|
||||
|
||||
sb := sqlbuilder.Select("DISTINCT string_value, number_value").From(t.tracesDBName + "." + t.tracesFieldsTblName)
|
||||
|
||||
if fieldValueSelector.Name != "" {
|
||||
sb.Where(sb.E("tag_key", fieldValueSelector.Name))
|
||||
}
|
||||
|
||||
// now look at the field context
|
||||
if fieldValueSelector.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||
sb.Where(sb.E("tag_type", fieldValueSelector.FieldContext.TagType()))
|
||||
}
|
||||
|
||||
// now look at the field data type
|
||||
if fieldValueSelector.FieldDataType != telemetrytypes.FieldDataTypeUnspecified {
|
||||
sb.Where(sb.E("tag_data_type", fieldValueSelector.FieldDataType.TagDataType()))
|
||||
}
|
||||
|
||||
if fieldValueSelector.Value != "" {
|
||||
switch fieldValueSelector.FieldDataType {
|
||||
case telemetrytypes.FieldDataTypeString:
|
||||
sb.Where(sb.ILike("string_value", "%"+escapeForLike(fieldValueSelector.Value)+"%"))
|
||||
case telemetrytypes.FieldDataTypeNumber:
|
||||
sb.Where(sb.IsNotNull("number_value"))
|
||||
sb.Where(sb.ILike("toString(number_value)", "%"+escapeForLike(fieldValueSelector.Value)+"%"))
|
||||
case telemetrytypes.FieldDataTypeUnspecified:
|
||||
// or b/w string and number
|
||||
sb.Where(sb.Or(
|
||||
sb.ILike("string_value", "%"+escapeForLike(fieldValueSelector.Value)+"%"),
|
||||
sb.ILike("toString(number_value)", "%"+escapeForLike(fieldValueSelector.Value)+"%"),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// query one extra to check if we hit the limit
|
||||
sb.Limit(limit + 1)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetLogsKeys.Error())
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
values := &telemetrytypes.TelemetryFieldValues{}
|
||||
seen := make(map[string]bool)
|
||||
rowCount := 0
|
||||
totalCount := 0 // Track total unique values
|
||||
|
||||
for rows.Next() {
|
||||
rowCount++
|
||||
|
||||
var stringValue string
|
||||
var numberValue float64
|
||||
if err := rows.Scan(&stringValue, &numberValue); err != nil {
|
||||
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetLogsKeys.Error())
|
||||
}
|
||||
|
||||
// Only add values if we haven't hit the limit yet
|
||||
if totalCount < limit {
|
||||
if _, ok := seen[stringValue]; !ok && stringValue != "" {
|
||||
values.StringValues = append(values.StringValues, stringValue)
|
||||
seen[stringValue] = true
|
||||
totalCount++
|
||||
}
|
||||
if _, ok := seen[fmt.Sprintf("%f", numberValue)]; !ok && numberValue != 0 && totalCount < limit {
|
||||
values.NumberValues = append(values.NumberValues, numberValue)
|
||||
seen[fmt.Sprintf("%f", numberValue)] = true
|
||||
totalCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hit the limit?
|
||||
complete := rowCount <= limit
|
||||
|
||||
return values, complete, nil
|
||||
knownBool := isKnownBoolField(fieldValueSelector, tracestelemetryschema.IntrinsicFields, tracestelemetryschema.CalculatedFields)
|
||||
// unix_milli is the hour of the span start
|
||||
return t.getTagTableValues(ctx, t.tracesDBName+"."+t.tracesFieldsTblName, fieldValueSelector, knownBool)
|
||||
}
|
||||
|
||||
func (t *telemetryMetaStore) getLogFieldValues(ctx context.Context, fieldValueSelector *telemetrytypes.FieldValueSelector) (*telemetrytypes.TelemetryFieldValues, bool, error) {
|
||||
@@ -1596,17 +1459,77 @@ func (t *telemetryMetaStore) getLogFieldValues(ctx context.Context, fieldValueSe
|
||||
instrumentationtypes.CodeNamespace: "metadata",
|
||||
instrumentationtypes.CodeFunctionName: "getLogFieldValues",
|
||||
})
|
||||
// build the query to get the keys from the spans that match the field selection criteria
|
||||
|
||||
knownBool := isKnownBoolField(fieldValueSelector, logstelemetryschema.IntrinsicFields)
|
||||
// unix_milli is the hour the log was ingested, not the log's own timestamp
|
||||
return t.getTagTableValues(ctx, t.logsDBName+"."+t.logsFieldsTblName, fieldValueSelector, knownBool)
|
||||
}
|
||||
|
||||
// tagTableSinceDay restricts rows to the tag table's day partitions from the
|
||||
// start's day on. Partitions are toDate(unix_milli / 1000) in the server's
|
||||
// timezone, and a value's surviving row within a day carries whichever hour
|
||||
// was inserted last, so the day is the finest safe unit.
|
||||
func tagTableSinceDay(sb *sqlbuilder.SelectBuilder, startUnixMilli int64) {
|
||||
if startUnixMilli != 0 {
|
||||
sb.Where(fmt.Sprintf("toDate(unix_milli / 1000) >= toDate(%d)", startUnixMilli/1000))
|
||||
}
|
||||
}
|
||||
|
||||
// tagTableHasBoolRows reports whether the tag table holds a bool row for the
|
||||
// key. Bool rows carry no value, so one row is enough to know the key takes
|
||||
// the values true and false.
|
||||
func (t *telemetryMetaStore) tagTableHasBoolRows(ctx context.Context, table string, selector *telemetrytypes.FieldValueSelector) (bool, error) {
|
||||
sb := sqlbuilder.Select("1").From(table)
|
||||
sb.Where(sb.E("tag_key", selector.Name))
|
||||
sb.Where(sb.E("tag_data_type", telemetrytypes.FieldDataTypeBool.TagDataType()))
|
||||
if selector.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||
sb.Where(sb.E("tag_type", selector.FieldContext.TagType()))
|
||||
}
|
||||
tagTableSinceDay(sb, selector.StartUnixMilli)
|
||||
sb.Limit(1)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetLogsKeys.Error())
|
||||
}
|
||||
defer rows.Close()
|
||||
return rows.Next(), rows.Err()
|
||||
}
|
||||
|
||||
// getTagTableValues returns the string and number values of the key from a
|
||||
// tag table, and true and false when the key is a known bool field or the
|
||||
// table holds bool rows for it. Bool rows do not count towards the limit.
|
||||
func (t *telemetryMetaStore) getTagTableValues(ctx context.Context, table string, fieldValueSelector *telemetrytypes.FieldValueSelector, knownBool bool) (*telemetrytypes.TelemetryFieldValues, bool, error) {
|
||||
limit := fieldValueSelector.Limit
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
sb := sqlbuilder.Select("DISTINCT string_value, number_value").From(t.logsDBName + "." + t.logsFieldsTblName)
|
||||
values := &telemetrytypes.TelemetryFieldValues{}
|
||||
if knownBool {
|
||||
values.BoolValues = boolFieldValues(fieldValueSelector.Value).BoolValues
|
||||
if fieldValueSelector.FieldDataType == telemetrytypes.FieldDataTypeBool {
|
||||
return values, true, nil
|
||||
}
|
||||
} else if fieldValueSelector.FieldDataType == telemetrytypes.FieldDataTypeUnspecified {
|
||||
hasBoolRows, err := t.tagTableHasBoolRows(ctx, table, fieldValueSelector)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if hasBoolRows {
|
||||
values.BoolValues = boolFieldValues(fieldValueSelector.Value).BoolValues
|
||||
}
|
||||
}
|
||||
|
||||
sb := sqlbuilder.Select("DISTINCT string_value, number_value").From(table)
|
||||
|
||||
if fieldValueSelector.Name != "" {
|
||||
sb.Where(sb.E("tag_key", fieldValueSelector.Name))
|
||||
}
|
||||
sb.Where(sb.NE("tag_data_type", telemetrytypes.FieldDataTypeBool.TagDataType()))
|
||||
|
||||
tagTableSinceDay(sb, fieldValueSelector.StartUnixMilli)
|
||||
|
||||
if fieldValueSelector.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||
sb.Where(sb.E("tag_type", fieldValueSelector.FieldContext.TagType()))
|
||||
@@ -1643,7 +1566,6 @@ func (t *telemetryMetaStore) getLogFieldValues(ctx context.Context, fieldValueSe
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
values := &telemetrytypes.TelemetryFieldValues{}
|
||||
seen := make(map[string]bool)
|
||||
rowCount := 0
|
||||
totalCount := 0 // Track total unique values
|
||||
@@ -2097,6 +2019,18 @@ func populateAllUnspecifiedValues(allUnspecifiedValues *telemetrytypes.Telemetry
|
||||
}
|
||||
}
|
||||
|
||||
for _, value := range values.BoolValues {
|
||||
if totalCount >= limit {
|
||||
complete = false
|
||||
break
|
||||
}
|
||||
if _, ok := mapOfValues[value]; !ok {
|
||||
mapOfValues[value] = true
|
||||
allUnspecifiedValues.BoolValues = append(allUnspecifiedValues.BoolValues, value)
|
||||
totalCount++
|
||||
}
|
||||
}
|
||||
|
||||
for _, value := range values.RelatedValues {
|
||||
if totalCount >= limit {
|
||||
complete = false
|
||||
@@ -2467,6 +2401,10 @@ func (k *telemetryMetaStore) fetchEvolutionEntryFromClickHouse(ctx context.Conte
|
||||
|
||||
// updateColumnEvolutionMetadataForKeys updates the evolution field for keys.
|
||||
func (k *telemetryMetaStore) updateColumnEvolutionMetadataForKeys(ctx context.Context, keysToUpdate []*telemetrytypes.TelemetryFieldKey) error {
|
||||
// an empty selector list would run the evolution query without a filter
|
||||
if len(keysToUpdate) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var metadataKeySelectors []*telemetrytypes.EvolutionSelector
|
||||
for _, keySelector := range keysToUpdate {
|
||||
|
||||
53
pkg/telemetrymetadata/static_fields.go
Normal file
53
pkg/telemetrymetadata/static_fields.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package telemetrymetadata
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
func staticFieldMatchesAny(field telemetrytypes.TelemetryFieldKey, selectors []*telemetrytypes.FieldKeySelector) bool {
|
||||
for _, selector := range selectors {
|
||||
if staticFieldMatches(field, selector) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// staticFieldMatches mirrors the keys-table lookup for a static field: the
|
||||
// requested context and data type, when given, must agree with the field's,
|
||||
// and the name matches case-insensitively, as a substring for fuzzy selectors
|
||||
// and as the whole name for exact ones.
|
||||
func staticFieldMatches(field telemetrytypes.TelemetryFieldKey, selector *telemetrytypes.FieldKeySelector) bool {
|
||||
if selector.FieldContext != telemetrytypes.FieldContextUnspecified && selector.FieldContext != field.FieldContext {
|
||||
return false
|
||||
}
|
||||
if selector.FieldDataType != telemetrytypes.FieldDataTypeUnspecified && !sameDataTypeFamily(selector.FieldDataType, field.FieldDataType) {
|
||||
return false
|
||||
}
|
||||
if selector.Name == "" {
|
||||
return true
|
||||
}
|
||||
if selector.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeExact {
|
||||
return strings.EqualFold(field.Name, selector.Name)
|
||||
}
|
||||
return strings.Contains(strings.ToLower(field.Name), strings.ToLower(selector.Name))
|
||||
}
|
||||
|
||||
// sameDataTypeFamily treats the numeric types as one family: static fields
|
||||
// declare "number" while callers may ask for int64 or float64.
|
||||
func sameDataTypeFamily(requested, actual telemetrytypes.FieldDataType) bool {
|
||||
if requested == actual {
|
||||
return true
|
||||
}
|
||||
return isNumericDataType(requested) && isNumericDataType(actual)
|
||||
}
|
||||
|
||||
func isNumericDataType(dataType telemetrytypes.FieldDataType) bool {
|
||||
switch dataType {
|
||||
case telemetrytypes.FieldDataTypeNumber, telemetrytypes.FieldDataTypeInt64, telemetrytypes.FieldDataTypeFloat64:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -392,6 +392,24 @@ var (
|
||||
SpanSearchScopeRoot = "isroot"
|
||||
SpanSearchScopeEntryPoint = "isentrypoint"
|
||||
|
||||
// SpanSearchScopeFields are the search-scope selectors (isRoot, isEntryPoint),
|
||||
// not columns and unrelated to the instrumentation scope: they only filter
|
||||
// with the value true.
|
||||
SpanSearchScopeFields = map[string]telemetrytypes.TelemetryFieldKey{
|
||||
"isRoot": {
|
||||
Name: "isRoot",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeBool,
|
||||
},
|
||||
"isEntryPoint": {
|
||||
Name: "isEntryPoint",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeBool,
|
||||
},
|
||||
}
|
||||
|
||||
// IntrinsicSpanFields lists the intrinsic span columns, in the order they
|
||||
// should appear when a raw query expands its SelectFields.
|
||||
IntrinsicSpanFields = []telemetrytypes.TelemetryFieldKey{
|
||||
|
||||
@@ -173,18 +173,18 @@ func NewSourceFilterFromStorableQuickFilter(storableQuickFilter *StorableQuickFi
|
||||
// NewDefaultQuickFilter generates default filters for all supported sources.
|
||||
func NewDefaultQuickFilter(orgID valuer.UUID) ([]*StorableQuickFilter, error) {
|
||||
tracesFilters := []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "duration_nano", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeNumber},
|
||||
{Name: "duration_nano", FieldContext: telemetrytypes.FieldContextSpan, FieldDataType: telemetrytypes.FieldDataTypeNumber},
|
||||
{Name: "deployment.environment", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "hasError", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeBool},
|
||||
{Name: "has_error", FieldContext: telemetrytypes.FieldContextSpan, FieldDataType: telemetrytypes.FieldDataTypeBool},
|
||||
{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "name", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "name", FieldContext: telemetrytypes.FieldContextSpan, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "rpc.method", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "response_status_code", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "http_host", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "response_status_code", FieldContext: telemetrytypes.FieldContextSpan, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "http_host", FieldContext: telemetrytypes.FieldContextSpan, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "http.method", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "http.route", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "http_url", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "trace_id", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "http_url", FieldContext: telemetrytypes.FieldContextSpan, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "trace_id", FieldContext: telemetrytypes.FieldContextSpan, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
}
|
||||
|
||||
logsFilters := []telemetrytypes.TelemetryFieldKey{
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
var (
|
||||
ErrCodeMapperNotFound = errors.MustNewCode("span_attribute_mapper_not_found")
|
||||
ErrCodeMapperAlreadyExists = errors.MustNewCode("span_attribute_mapper_already_exists")
|
||||
ErrCodeMapperNotDeletable = errors.MustNewCode("span_attribute_mapper_not_deletable")
|
||||
ErrCodeMappingInvalidInput = errors.MustNewCode("span_attribute_mapping_invalid_input")
|
||||
)
|
||||
|
||||
@@ -34,12 +37,25 @@ var (
|
||||
SpanMapperOperationCopy = SpanMapperOperation{valuer.NewString("copy")}
|
||||
)
|
||||
|
||||
// SpanMapperOrigin tells shipped (system) items apart from user-created ones.
|
||||
// System items are read-only apart from their enabled toggle.
|
||||
type SpanMapperOrigin struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
SpanMapperOriginUser = SpanMapperOrigin{valuer.NewString("user")}
|
||||
SpanMapperOriginSystem = SpanMapperOrigin{valuer.NewString("system")}
|
||||
)
|
||||
|
||||
// MapperSource describes one candidate source for a target attribute.
|
||||
type SpanMapperSource struct {
|
||||
Key string `json:"key" required:"true"`
|
||||
Context FieldContext `json:"context" required:"true"`
|
||||
Operation SpanMapperOperation `json:"operation" required:"true"`
|
||||
Priority int `json:"priority" required:"true"`
|
||||
Enabled bool `json:"enabled" required:"true"`
|
||||
Origin SpanMapperOrigin `json:"origin"`
|
||||
}
|
||||
|
||||
// MapperConfig holds the mapping logic for a single target attribute.
|
||||
@@ -59,6 +75,7 @@ type SpanMapper struct {
|
||||
FieldContext FieldContext `json:"fieldContext" required:"true"`
|
||||
Config SpanMapperConfig `json:"config" required:"true"`
|
||||
Enabled bool `json:"enabled" required:"true"`
|
||||
Origin SpanMapperOrigin `json:"origin" required:"true"`
|
||||
}
|
||||
|
||||
type PostableSpanMapper struct {
|
||||
@@ -90,6 +107,63 @@ func (SpanMapperOperation) Enum() []any {
|
||||
return []any{SpanMapperOperationMove, SpanMapperOperationCopy}
|
||||
}
|
||||
|
||||
func (SpanMapperOrigin) Enum() []any {
|
||||
return []any{SpanMapperOriginUser, SpanMapperOriginSystem}
|
||||
}
|
||||
|
||||
func (p *PostableSpanMapper) Validate() error {
|
||||
if strings.TrimSpace(p.Name) == "" {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "mapper name must not be blank")
|
||||
}
|
||||
if err := p.FieldContext.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.Config.Validate()
|
||||
}
|
||||
|
||||
func (f FieldContext) Validate() error {
|
||||
if f != FieldContextSpanAttribute && f != FieldContextResource {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "field context must be one of %q or %q, got %q", FieldContextSpanAttribute, FieldContextResource, f.StringValue())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks every source and rejects duplicate priorities within an
|
||||
// origin. Shipped and user sources are never compared with each other: a user
|
||||
// re-adding a shipped key with another operation is the supported override.
|
||||
func (c *SpanMapperConfig) Validate() error {
|
||||
if len(c.Sources) == 0 {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "config.sources must contain at least one source")
|
||||
}
|
||||
seen := map[SpanMapperOrigin]map[int]struct{}{}
|
||||
for _, s := range c.Sources {
|
||||
if strings.TrimSpace(s.Key) == "" {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "source key must not be blank")
|
||||
}
|
||||
if err := s.Context.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Operation != SpanMapperOperationCopy && s.Operation != SpanMapperOperationMove {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "source operation must be one of %q or %q, got %q", SpanMapperOperationCopy, SpanMapperOperationMove, s.Operation.StringValue())
|
||||
}
|
||||
if !s.Origin.IsZero() && s.Origin != SpanMapperOriginUser && s.Origin != SpanMapperOriginSystem {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "source origin must be one of %q or %q, got %q", SpanMapperOriginUser, SpanMapperOriginSystem, s.Origin.StringValue())
|
||||
}
|
||||
origin := s.Origin
|
||||
if origin.IsZero() {
|
||||
origin = SpanMapperOriginUser
|
||||
}
|
||||
if seen[origin] == nil {
|
||||
seen[origin] = map[int]struct{}{}
|
||||
}
|
||||
if _, dup := seen[origin][s.Priority]; dup {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "source priority %d is used more than once", s.Priority)
|
||||
}
|
||||
seen[origin][s.Priority] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewSpanMapper(groupID valuer.UUID, createdBy string, p *PostableSpanMapper) *SpanMapper {
|
||||
now := time.Now()
|
||||
return &SpanMapper{
|
||||
@@ -97,8 +171,9 @@ func NewSpanMapper(groupID valuer.UUID, createdBy string, p *PostableSpanMapper)
|
||||
GroupID: groupID,
|
||||
Name: p.Name,
|
||||
FieldContext: p.FieldContext,
|
||||
Config: p.Config,
|
||||
Config: SpanMapperConfig{Sources: withOrigin(p.Config.Sources, SpanMapperOriginUser)},
|
||||
Enabled: p.Enabled,
|
||||
Origin: SpanMapperOriginUser,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -110,16 +185,42 @@ func NewSpanMapper(groupID valuer.UUID, createdBy string, p *PostableSpanMapper)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SpanMapper) Update(fieldContext FieldContext, config *SpanMapperConfig, enabled *bool, updatedBy string) {
|
||||
m.FieldContext = fieldContext
|
||||
// Update applies a user edit; a zero fieldContext means it was omitted. On a
|
||||
// system mapper the field context is fixed and the stored system sources are
|
||||
// kept; see nextSources.
|
||||
func (m *SpanMapper) Update(fieldContext FieldContext, config *SpanMapperConfig, enabled *bool, updatedBy string) error {
|
||||
if !fieldContext.IsZero() {
|
||||
if m.Origin == SpanMapperOriginSystem && fieldContext != m.FieldContext {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "field context of system mapper %q cannot be changed", m.Name)
|
||||
}
|
||||
if err := fieldContext.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
m.FieldContext = fieldContext
|
||||
}
|
||||
if config != nil {
|
||||
m.Config = *config
|
||||
sources, err := m.nextSources(config.Sources)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Config = SpanMapperConfig{Sources: sources}
|
||||
if err := m.Config.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if enabled != nil {
|
||||
m.Enabled = *enabled
|
||||
}
|
||||
m.UpdatedAt = time.Now()
|
||||
m.UpdatedBy = updatedBy
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *SpanMapper) ErrIfNotDeletable() error {
|
||||
if m.Origin == SpanMapperOriginSystem {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeMapperNotDeletable, "system mapper %q cannot be deleted, disable it instead", m.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *SpanMapper) ToStorable() *StorableSpanMapper {
|
||||
@@ -132,6 +233,7 @@ func (m *SpanMapper) ToStorable() *StorableSpanMapper {
|
||||
FieldContext: m.FieldContext,
|
||||
Config: m.Config,
|
||||
Enabled: m.Enabled,
|
||||
Origin: m.Origin,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +247,7 @@ func (s *StorableSpanMapper) ToSpanMapper() *SpanMapper {
|
||||
FieldContext: s.FieldContext,
|
||||
Config: s.Config,
|
||||
Enabled: s.Enabled,
|
||||
Origin: s.Origin,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,3 +262,39 @@ func NewSpanMappersFromStorable(ss []*StorableSpanMapper) []*SpanMapper {
|
||||
func NewGettableSpanMappers(m []*SpanMapper) *GettableSpanMappers {
|
||||
return &GettableSpanMappers{Items: m}
|
||||
}
|
||||
|
||||
// nextSources builds the source list from an edit: user sources are taken from
|
||||
// the edit as sent, system sources stay as stored and the edit can only flip
|
||||
// their enabled flag.
|
||||
func (m *SpanMapper) nextSources(edit []SpanMapperSource) ([]SpanMapperSource, error) {
|
||||
var systemSources, userSources []SpanMapperSource
|
||||
for _, s := range m.Config.Sources {
|
||||
if s.Origin == SpanMapperOriginSystem {
|
||||
systemSources = append(systemSources, s)
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range edit {
|
||||
if s.Origin != SpanMapperOriginSystem {
|
||||
s.Origin = SpanMapperOriginUser
|
||||
userSources = append(userSources, s)
|
||||
continue
|
||||
}
|
||||
idx := slices.IndexFunc(systemSources, func(o SpanMapperSource) bool { return o.Key == s.Key && o.Context == s.Context })
|
||||
if idx == -1 {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "system source %q does not exist on this mapper; only its enabled flag can change", s.Key)
|
||||
}
|
||||
systemSources[idx].Enabled = s.Enabled
|
||||
}
|
||||
|
||||
return append(systemSources, userSources...), nil
|
||||
}
|
||||
|
||||
func withOrigin(sources []SpanMapperSource, origin SpanMapperOrigin) []SpanMapperSource {
|
||||
out := make([]SpanMapperSource, len(sources))
|
||||
for i, s := range sources {
|
||||
s.Origin = origin
|
||||
out[i] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -11,17 +13,28 @@ import (
|
||||
var (
|
||||
ErrCodeMappingGroupNotFound = errors.MustNewCode("span_attribute_mapping_group_not_found")
|
||||
ErrCodeMappingGroupAlreadyExists = errors.MustNewCode("span_attribute_mapping_group_already_exists")
|
||||
ErrCodeMappingGroupNameReserved = errors.MustNewCode("span_attribute_mapping_group_name_reserved")
|
||||
ErrCodeMappingGroupNotDeletable = errors.MustNewCode("span_attribute_mapping_group_not_deletable")
|
||||
)
|
||||
|
||||
// SpanMapperGroupConditionKey is one substring a span's attribute or resource
|
||||
// keys are matched against.
|
||||
type SpanMapperGroupConditionKey struct {
|
||||
Value string `json:"value" required:"true"`
|
||||
Enabled bool `json:"enabled" required:"true"`
|
||||
Origin SpanMapperOrigin `json:"origin"`
|
||||
}
|
||||
|
||||
// SpanMapperGroupCondition gates whether a group's rules run for a given span.
|
||||
// A group runs when any attribute or resource key on the span CONTAINS one of
|
||||
// the listed substrings (plain substring match — no glob syntax).
|
||||
type SpanMapperGroupCondition struct {
|
||||
Attributes []string `json:"attributes" required:"true" nullable:"true"`
|
||||
Resource []string `json:"resource" required:"true" nullable:"true"`
|
||||
Attributes []SpanMapperGroupConditionKey `json:"attributes" required:"true" nullable:"true"`
|
||||
Resource []SpanMapperGroupConditionKey `json:"resource" required:"true" nullable:"true"`
|
||||
}
|
||||
|
||||
// SpanMapperGroup is the domain model for a span attribute mapping group.
|
||||
// Version is the shipped definition version for system groups and 0 otherwise.
|
||||
type SpanMapperGroup struct {
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
@@ -31,6 +44,8 @@ type SpanMapperGroup struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
Condition SpanMapperGroupCondition `json:"condition" required:"true"`
|
||||
Enabled bool `json:"enabled" required:"true"`
|
||||
Origin SpanMapperOrigin `json:"origin" required:"true"`
|
||||
Version int `json:"version" required:"true"`
|
||||
}
|
||||
|
||||
// GettableSpanMapperGroup is the HTTP response representation of a mapping group.
|
||||
@@ -58,14 +73,42 @@ type GettableSpanMapperGroups struct {
|
||||
Items []*GettableSpanMapperGroup `json:"items" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
// Validate requires at least one substring overall and rejects blank ones.
|
||||
// All-off is allowed: a group with every substring disabled simply never runs.
|
||||
func (c *SpanMapperGroupCondition) Validate() error {
|
||||
if len(c.Attributes)+len(c.Resource) == 0 {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "condition must list at least one attribute or resource substring")
|
||||
}
|
||||
for _, k := range slices.Concat(c.Attributes, c.Resource) {
|
||||
if strings.TrimSpace(k.Value) == "" {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "condition substrings must not be blank")
|
||||
}
|
||||
if !k.Origin.IsZero() && k.Origin != SpanMapperOriginUser && k.Origin != SpanMapperOriginSystem {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "condition origin must be one of %q or %q, got %q", SpanMapperOriginUser, SpanMapperOriginSystem, k.Origin.StringValue())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PostableSpanMapperGroup) Validate() error {
|
||||
if strings.TrimSpace(p.Name) == "" {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "group name must not be blank")
|
||||
}
|
||||
return p.Condition.Validate()
|
||||
}
|
||||
|
||||
func NewSpanMapperGroup(orgID valuer.UUID, createdBy string, p *PostableSpanMapperGroup) *SpanMapperGroup {
|
||||
now := time.Now()
|
||||
return &SpanMapperGroup{
|
||||
ID: valuer.GenerateUUID(),
|
||||
OrgID: orgID,
|
||||
Name: p.Name,
|
||||
Condition: p.Condition,
|
||||
Enabled: p.Enabled,
|
||||
ID: valuer.GenerateUUID(),
|
||||
OrgID: orgID,
|
||||
Name: p.Name,
|
||||
Condition: SpanMapperGroupCondition{
|
||||
Attributes: conditionKeysWithOrigin(p.Condition.Attributes, SpanMapperOriginUser),
|
||||
Resource: conditionKeysWithOrigin(p.Condition.Resource, SpanMapperOriginUser),
|
||||
},
|
||||
Enabled: p.Enabled,
|
||||
Origin: SpanMapperOriginUser,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -77,18 +120,45 @@ func NewSpanMapperGroup(orgID valuer.UUID, createdBy string, p *PostableSpanMapp
|
||||
}
|
||||
}
|
||||
|
||||
func (g *SpanMapperGroup) Update(name *string, condition *SpanMapperGroupCondition, enabled *bool, updatedBy string) {
|
||||
// Update applies a user edit. A system group keeps its name and its system
|
||||
// substrings; see nextConditionKeys.
|
||||
func (g *SpanMapperGroup) Update(name *string, condition *SpanMapperGroupCondition, enabled *bool, updatedBy string) error {
|
||||
if name != nil {
|
||||
if g.Origin == SpanMapperOriginSystem && *name != g.Name {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "system group %q cannot be renamed", g.Name)
|
||||
}
|
||||
if strings.TrimSpace(*name) == "" {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "group name must not be blank")
|
||||
}
|
||||
g.Name = *name
|
||||
}
|
||||
if condition != nil {
|
||||
g.Condition = *condition
|
||||
attrs, err := nextConditionKeys(g.Condition.Attributes, condition.Attributes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := nextConditionKeys(g.Condition.Resource, condition.Resource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.Condition = SpanMapperGroupCondition{Attributes: attrs, Resource: res}
|
||||
if err := g.Condition.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if enabled != nil {
|
||||
g.Enabled = *enabled
|
||||
}
|
||||
g.UpdatedAt = time.Now()
|
||||
g.UpdatedBy = updatedBy
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *SpanMapperGroup) ErrIfNotDeletable() error {
|
||||
if g.Origin == SpanMapperOriginSystem {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeMappingGroupNotDeletable, "system group %q cannot be deleted, disable it instead", g.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *SpanMapperGroup) ToStorable() *StorableSpanMapperGroup {
|
||||
@@ -100,6 +170,8 @@ func (g *SpanMapperGroup) ToStorable() *StorableSpanMapperGroup {
|
||||
Name: g.Name,
|
||||
Condition: g.Condition,
|
||||
Enabled: g.Enabled,
|
||||
Origin: g.Origin,
|
||||
Version: g.Version,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +184,8 @@ func (s *StorableSpanMapperGroup) ToSpanMapperGroup() *SpanMapperGroup {
|
||||
Name: s.Name,
|
||||
Condition: s.Condition,
|
||||
Enabled: s.Enabled,
|
||||
Origin: s.Origin,
|
||||
Version: s.Version,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,3 +200,39 @@ func NewSpanMapperGroupsFromStorable(ss []*StorableSpanMapperGroup) []*SpanMappe
|
||||
func NewGettableSpanMapperGroups(g []*SpanMapperGroup) *GettableSpanMapperGroups {
|
||||
return &GettableSpanMapperGroups{Items: g}
|
||||
}
|
||||
|
||||
// nextConditionKeys builds a substring list from an edit: user substrings are
|
||||
// taken from the edit as sent, system substrings stay as stored and the edit
|
||||
// can only flip their enabled flag.
|
||||
func nextConditionKeys(stored, edit []SpanMapperGroupConditionKey) ([]SpanMapperGroupConditionKey, error) {
|
||||
var systemKeys, userKeys []SpanMapperGroupConditionKey
|
||||
for _, k := range stored {
|
||||
if k.Origin == SpanMapperOriginSystem {
|
||||
systemKeys = append(systemKeys, k)
|
||||
}
|
||||
}
|
||||
|
||||
for _, k := range edit {
|
||||
if k.Origin != SpanMapperOriginSystem {
|
||||
k.Origin = SpanMapperOriginUser
|
||||
userKeys = append(userKeys, k)
|
||||
continue
|
||||
}
|
||||
idx := slices.IndexFunc(systemKeys, func(s SpanMapperGroupConditionKey) bool { return s.Value == k.Value })
|
||||
if idx == -1 {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeMappingInvalidInput, "system substring %q does not exist on this group; only its enabled flag can change", k.Value)
|
||||
}
|
||||
systemKeys[idx].Enabled = k.Enabled
|
||||
}
|
||||
|
||||
return append(systemKeys, userKeys...), nil
|
||||
}
|
||||
|
||||
func conditionKeysWithOrigin(keys []SpanMapperGroupConditionKey, origin SpanMapperOrigin) []SpanMapperGroupConditionKey {
|
||||
out := make([]SpanMapperGroupConditionKey, len(keys))
|
||||
for i, k := range keys {
|
||||
k.Origin = origin
|
||||
out[i] = k
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
121
pkg/types/spantypes/spanmapperdefinition.go
Normal file
121
pkg/types/spantypes/spanmapperdefinition.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
var ErrCodeMappingDefinitionInvalid = errors.MustNewCode("span_attribute_mapping_definition_invalid")
|
||||
|
||||
// ProvisionerIdentity is stamped into created_by/updated_by by the reconciler.
|
||||
const ProvisionerIdentity = "signoz"
|
||||
|
||||
// SpanMapperGroupDefinition is one shipped mapping group. Version is bumped on
|
||||
// every content change and drives upgrades; the group name is the stable key
|
||||
// and never changes. Once parsed, every substring and source carries the
|
||||
// system origin and is enabled.
|
||||
type SpanMapperGroupDefinition struct {
|
||||
Version int `json:"version"`
|
||||
Definition PostableSpanMapperTestGroup `json:"definition"`
|
||||
}
|
||||
|
||||
func (d SpanMapperGroupDefinition) Name() string {
|
||||
return d.Definition.Name
|
||||
}
|
||||
|
||||
func NewSpanMapperGroupDefinition(raw []byte) (SpanMapperGroupDefinition, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
var d SpanMapperGroupDefinition
|
||||
if err := decoder.Decode(&d); err != nil {
|
||||
return SpanMapperGroupDefinition{}, errors.WrapInvalidInputf(err, ErrCodeMappingDefinitionInvalid, "%s", err.Error())
|
||||
}
|
||||
if err := d.validate(); err != nil {
|
||||
return SpanMapperGroupDefinition{}, err
|
||||
}
|
||||
|
||||
for _, keys := range [][]SpanMapperGroupConditionKey{d.Definition.Condition.Attributes, d.Definition.Condition.Resource} {
|
||||
for i := range keys {
|
||||
keys[i].Enabled = true
|
||||
keys[i].Origin = SpanMapperOriginSystem
|
||||
}
|
||||
}
|
||||
for i := range d.Definition.Mappers {
|
||||
sources := d.Definition.Mappers[i].Config.Sources
|
||||
for j := range sources {
|
||||
sources[j].Enabled = true
|
||||
sources[j].Origin = SpanMapperOriginSystem
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// SpanMapperGroupRegistry holds every definition embedded in the binary, keyed by name.
|
||||
type SpanMapperGroupRegistry struct {
|
||||
definitions map[string]SpanMapperGroupDefinition
|
||||
}
|
||||
|
||||
func NewSpanMapperGroupRegistry(definitions []SpanMapperGroupDefinition) (SpanMapperGroupRegistry, error) {
|
||||
byName := make(map[string]SpanMapperGroupDefinition, len(definitions))
|
||||
for _, d := range definitions {
|
||||
if _, dup := byName[d.Name()]; dup {
|
||||
return SpanMapperGroupRegistry{}, errors.NewInvalidInputf(ErrCodeMappingDefinitionInvalid, "duplicate span mapper group name %q", d.Name())
|
||||
}
|
||||
byName[d.Name()] = d
|
||||
}
|
||||
return SpanMapperGroupRegistry{definitions: byName}, nil
|
||||
}
|
||||
|
||||
func (r SpanMapperGroupRegistry) IsReserved(name string) bool {
|
||||
_, ok := r.definitions[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// List returns the definitions sorted by name so provisioning order is stable.
|
||||
func (r SpanMapperGroupRegistry) List() []SpanMapperGroupDefinition {
|
||||
out := make([]SpanMapperGroupDefinition, 0, len(r.definitions))
|
||||
for _, d := range r.definitions {
|
||||
out = append(out, d)
|
||||
}
|
||||
slices.SortFunc(out, func(a, b SpanMapperGroupDefinition) int { return strings.Compare(a.Name(), b.Name()) })
|
||||
return out
|
||||
}
|
||||
|
||||
func (d SpanMapperGroupDefinition) validate() error {
|
||||
if d.Version < 1 {
|
||||
return errors.NewInvalidInputf(ErrCodeMappingDefinitionInvalid, "version must be at least 1, got %d", d.Version)
|
||||
}
|
||||
if err := d.Definition.Validate(); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeMappingDefinitionInvalid, "%s", d.Name())
|
||||
}
|
||||
if len(d.Definition.Mappers) == 0 {
|
||||
return errors.NewInvalidInputf(ErrCodeMappingDefinitionInvalid, "%s: at least one mapper is required", d.Name())
|
||||
}
|
||||
names := make(map[string]struct{}, len(d.Definition.Mappers))
|
||||
for i := range d.Definition.Mappers {
|
||||
m := &d.Definition.Mappers[i]
|
||||
if err := m.Validate(); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeMappingDefinitionInvalid, "%s: mapper %q", d.Name(), m.Name)
|
||||
}
|
||||
if _, dup := names[m.Name]; dup {
|
||||
return errors.NewInvalidInputf(ErrCodeMappingDefinitionInvalid, "%s: duplicate mapper %q", d.Name(), m.Name)
|
||||
}
|
||||
names[m.Name] = struct{}{}
|
||||
for _, s := range m.Config.Sources {
|
||||
if !s.Origin.IsZero() || s.Enabled {
|
||||
return errors.NewInvalidInputf(ErrCodeMappingDefinitionInvalid, "%s: mapper %q: sources must not set origin or enabled", d.Name(), m.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range slices.Concat(d.Definition.Condition.Attributes, d.Definition.Condition.Resource) {
|
||||
if !k.Origin.IsZero() || k.Enabled {
|
||||
return errors.NewInvalidInputf(ErrCodeMappingDefinitionInvalid, "%s: condition substrings must not set origin or enabled", d.Name())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -86,17 +86,31 @@ func buildProcessorConfig(groups []*SpanMapperGroupWithMappers) *spanMapperProce
|
||||
out := make([]spanMapperProcessorGroup, 0, len(groups))
|
||||
|
||||
for _, gm := range groups {
|
||||
existsAny := spanMapperProcessorExistsAny{
|
||||
Attributes: enabledConditionValues(gm.Group.Condition.Attributes),
|
||||
Resource: enabledConditionValues(gm.Group.Condition.Resource),
|
||||
}
|
||||
// The collector rejects an empty exists_any and empty sources; with
|
||||
// per-item toggles, all-off is valid stored state and means "never runs".
|
||||
if len(existsAny.Attributes)+len(existsAny.Resource) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
rules := make([]spanMapperProcessorAttribute, 0, len(gm.Mappers))
|
||||
for _, m := range gm.Mappers {
|
||||
rules = append(rules, buildAttributeRule(m))
|
||||
rule := buildAttributeRule(m)
|
||||
if len(rule.Sources) == 0 {
|
||||
continue
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, spanMapperProcessorGroup{
|
||||
ID: gm.Group.Name,
|
||||
ExistsAny: spanMapperProcessorExistsAny{
|
||||
Attributes: gm.Group.Condition.Attributes,
|
||||
Resource: gm.Group.Condition.Resource,
|
||||
},
|
||||
ID: gm.Group.Name,
|
||||
ExistsAny: existsAny,
|
||||
Attributes: rules,
|
||||
})
|
||||
}
|
||||
@@ -104,14 +118,31 @@ func buildProcessorConfig(groups []*SpanMapperGroupWithMappers) *spanMapperProce
|
||||
return &spanMapperProcessorConfig{Groups: out}
|
||||
}
|
||||
|
||||
func enabledConditionValues(keys []SpanMapperGroupConditionKey) []string {
|
||||
out := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if k.Enabled {
|
||||
out = append(out, k.Value)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildAttributeRule maps a single SpanMapper to a collector attribute rule.
|
||||
// Sources are sorted by Priority DESC (highest-priority first); read-from-
|
||||
// resource sources are encoded via the "resource." prefix on the key. Each
|
||||
// source carries its own action — "copy" is omitted to keep the emitted YAML
|
||||
// compact, and only "move" is set explicitly.
|
||||
// Disabled sources are skipped and the rest are sorted by Priority DESC
|
||||
// (highest-priority first); read-from-resource sources are encoded via the
|
||||
// "resource." prefix on the key. Each source carries its own action — "copy"
|
||||
// is omitted to keep the emitted YAML compact, and only "move" is set explicitly.
|
||||
func buildAttributeRule(m *SpanMapper) spanMapperProcessorAttribute {
|
||||
sources := make([]SpanMapperSource, len(m.Config.Sources))
|
||||
copy(sources, m.Config.Sources)
|
||||
sources := make([]SpanMapperSource, 0, len(m.Config.Sources))
|
||||
for _, s := range m.Config.Sources {
|
||||
if s.Enabled {
|
||||
sources = append(sources, s)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(sources, func(i, j int) bool { return sources[i].Priority > sources[j].Priority })
|
||||
|
||||
out := make([]spanMapperProcessorSource, 0, len(sources))
|
||||
|
||||
@@ -145,6 +145,22 @@ func TestBuildAttributeRule(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "disabled_sources_skipped",
|
||||
mapper: newMapper("gen_ai.input.messages", FieldContextSpanAttribute,
|
||||
systemSrc("gen_ai.prompt", SpanMapperOperationCopy, 30, false),
|
||||
systemSrc("input.value", SpanMapperOperationCopy, 20, true),
|
||||
attrSrc("gen_ai.prompt", SpanMapperOperationMove, 40),
|
||||
),
|
||||
want: spanMapperProcessorAttribute{
|
||||
Target: "gen_ai.input.messages",
|
||||
Context: FieldContextSpanAttribute.StringValue(),
|
||||
Sources: []spanMapperProcessorSource{
|
||||
{Key: "gen_ai.prompt", Action: SpanMapperOperationMove.StringValue()},
|
||||
{Key: "input.value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
@@ -155,6 +171,33 @@ func TestBuildAttributeRule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProcessorConfigDropsAllOffItems(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
offGroup := newGroup("all-off", nil, nil)
|
||||
offGroup.Condition.Attributes = []SpanMapperGroupConditionKey{{Value: "model", Enabled: false, Origin: SpanMapperOriginSystem}}
|
||||
|
||||
mixed := newGroup("llm", nil, nil)
|
||||
mixed.Condition.Attributes = []SpanMapperGroupConditionKey{
|
||||
{Value: "model", Enabled: false, Origin: SpanMapperOriginSystem},
|
||||
{Value: "gen_ai.request.model", Enabled: true, Origin: SpanMapperOriginUser},
|
||||
}
|
||||
|
||||
got := buildProcessorConfig([]*SpanMapperGroupWithMappers{
|
||||
{Group: offGroup, Mappers: []*SpanMapper{newMapper("gen_ai.request.model", FieldContextSpanAttribute, attrSrc("llm.model", SpanMapperOperationCopy, 1))}},
|
||||
{Group: mixed, Mappers: []*SpanMapper{
|
||||
newMapper("gen_ai.request.model", FieldContextSpanAttribute, systemSrc("llm.model", SpanMapperOperationCopy, 10, false)),
|
||||
newMapper("gen_ai.provider.name", FieldContextSpanAttribute, systemSrc("llm.vendor", SpanMapperOperationCopy, 10, true)),
|
||||
}},
|
||||
})
|
||||
|
||||
require.Len(t, got.Groups, 1)
|
||||
assert.Equal(t, "llm", got.Groups[0].ID)
|
||||
assert.Equal(t, []string{"gen_ai.request.model"}, got.Groups[0].ExistsAny.Attributes)
|
||||
require.Len(t, got.Groups[0].Attributes, 1)
|
||||
assert.Equal(t, "gen_ai.provider.name", got.Groups[0].Attributes[0].Target)
|
||||
}
|
||||
|
||||
func loadFixture(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
@@ -174,12 +217,23 @@ func assertYAMLEqual(t *testing.T, want, got []byte) {
|
||||
|
||||
func newGroup(name string, attrs, res []string) *SpanMapperGroup {
|
||||
return &SpanMapperGroup{
|
||||
Name: name,
|
||||
Condition: SpanMapperGroupCondition{Attributes: attrs, Resource: res},
|
||||
Enabled: true,
|
||||
Name: name,
|
||||
Condition: SpanMapperGroupCondition{
|
||||
Attributes: userConditionKeys(attrs),
|
||||
Resource: userConditionKeys(res),
|
||||
},
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func userConditionKeys(values []string) []SpanMapperGroupConditionKey {
|
||||
out := make([]SpanMapperGroupConditionKey, len(values))
|
||||
for i, v := range values {
|
||||
out[i] = SpanMapperGroupConditionKey{Value: v, Enabled: true, Origin: SpanMapperOriginUser}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newMapper(name string, target FieldContext, sources ...SpanMapperSource) *SpanMapper {
|
||||
return &SpanMapper{
|
||||
Name: name,
|
||||
@@ -190,9 +244,13 @@ func newMapper(name string, target FieldContext, sources ...SpanMapperSource) *S
|
||||
}
|
||||
|
||||
func attrSrc(key string, op SpanMapperOperation, priority int) SpanMapperSource {
|
||||
return SpanMapperSource{Key: key, Context: FieldContextSpanAttribute, Operation: op, Priority: priority}
|
||||
return SpanMapperSource{Key: key, Context: FieldContextSpanAttribute, Operation: op, Priority: priority, Enabled: true, Origin: SpanMapperOriginUser}
|
||||
}
|
||||
|
||||
func resSrc(key string, op SpanMapperOperation, priority int) SpanMapperSource {
|
||||
return SpanMapperSource{Key: key, Context: FieldContextResource, Operation: op, Priority: priority}
|
||||
return SpanMapperSource{Key: key, Context: FieldContextResource, Operation: op, Priority: priority, Enabled: true, Origin: SpanMapperOriginUser}
|
||||
}
|
||||
|
||||
func systemSrc(key string, op SpanMapperOperation, priority int, enabled bool) SpanMapperSource {
|
||||
return SpanMapperSource{Key: key, Context: FieldContextSpanAttribute, Operation: op, Priority: priority, Enabled: enabled, Origin: SpanMapperOriginSystem}
|
||||
}
|
||||
|
||||
@@ -15,14 +15,14 @@ func TestSimulateSpanMappersProcessing_EndToEnd(t *testing.T) {
|
||||
groups := []*SpanMapperGroupWithMappers{{
|
||||
Group: &SpanMapperGroup{
|
||||
Name: "llm",
|
||||
Condition: SpanMapperGroupCondition{Attributes: []string{"model"}},
|
||||
Condition: SpanMapperGroupCondition{Attributes: userConditionKeys([]string{"model"})},
|
||||
Enabled: true,
|
||||
},
|
||||
Mappers: []*SpanMapper{{
|
||||
Name: "gen_ai.request.model",
|
||||
FieldContext: FieldContextSpanAttribute,
|
||||
Config: SpanMapperConfig{Sources: []SpanMapperSource{
|
||||
{Key: "llm.model", Context: FieldContextSpanAttribute, Operation: SpanMapperOperationCopy, Priority: 1},
|
||||
{Key: "llm.model", Context: FieldContextSpanAttribute, Operation: SpanMapperOperationCopy, Priority: 1, Enabled: true, Origin: SpanMapperOriginUser},
|
||||
}},
|
||||
Enabled: true,
|
||||
}},
|
||||
|
||||
@@ -20,7 +20,9 @@ type StorableSpanMapperGroup struct {
|
||||
OrgID valuer.UUID `bun:"org_id,type:text,notnull"`
|
||||
Name string `bun:"name,type:text,notnull"`
|
||||
Condition SpanMapperGroupCondition `bun:"condition,type:jsonb,notnull"`
|
||||
Enabled bool `bun:"enabled,notnull,default:true"`
|
||||
Enabled bool `bun:"enabled,notnull"`
|
||||
Origin SpanMapperOrigin `bun:"origin,type:text,notnull"`
|
||||
Version int `bun:"version,notnull"`
|
||||
}
|
||||
|
||||
type StorableSpanMapper struct {
|
||||
@@ -34,7 +36,8 @@ type StorableSpanMapper struct {
|
||||
Name string `bun:"name,type:text,notnull"`
|
||||
FieldContext FieldContext `bun:"field_context,type:text,notnull"`
|
||||
Config SpanMapperConfig `bun:"config,type:jsonb,notnull"`
|
||||
Enabled bool `bun:"enabled,notnull,default:true"`
|
||||
Enabled bool `bun:"enabled,notnull"`
|
||||
Origin SpanMapperOrigin `bun:"origin,type:text,notnull"`
|
||||
}
|
||||
|
||||
func (c SpanMapperGroupCondition) Value() (driver.Value, error) {
|
||||
|
||||
@@ -9,9 +9,14 @@ import (
|
||||
)
|
||||
|
||||
type SpanMapperStore interface {
|
||||
// RunInTx runs cb in one transaction; every store call made with the
|
||||
// callback's ctx joins it.
|
||||
RunInTx(ctx context.Context, cb func(ctx context.Context) error) error
|
||||
|
||||
// Group operations
|
||||
ListGroups(ctx context.Context, orgID valuer.UUID, q *ListSpanMapperGroupsQuery) ([]*SpanMapperGroup, error)
|
||||
GetGroup(ctx context.Context, orgID, id valuer.UUID) (*SpanMapperGroup, error)
|
||||
GetGroupByName(ctx context.Context, orgID valuer.UUID, name string) (*SpanMapperGroup, error)
|
||||
CreateGroup(ctx context.Context, group *SpanMapperGroup) error
|
||||
UpdateGroup(ctx context.Context, group *SpanMapperGroup) error
|
||||
DeleteGroup(ctx context.Context, orgID, id valuer.UUID) error
|
||||
|
||||
@@ -70,9 +70,9 @@ require (
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/goleak v1.3.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
@@ -86,8 +86,8 @@ require (
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/api v0.272.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
google.golang.org/grpc v1.82.1 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/grpc v1.83.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -355,16 +355,16 @@ go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.67.0/go.mod h1:gO9smoZe9KnZcJCqcB0lMmQ4Z5VEifYmjMTpnwtTSuQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
@@ -405,12 +405,12 @@ gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E
|
||||
google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA=
|
||||
google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA=
|
||||
google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
|
||||
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -10,7 +10,6 @@ from fixtures import types
|
||||
from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
create_active_user,
|
||||
)
|
||||
|
||||
TIMEOUT = 10
|
||||
|
||||
235
tests/integration/tests/queriercommon/07_fields_keys_values.py
Normal file
235
tests/integration/tests/queriercommon/07_fields_keys_values.py
Normal file
@@ -0,0 +1,235 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.traces import Traces
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"signal,field_context,present,absent",
|
||||
[
|
||||
pytest.param("logs", "log", {"severity_text": "log", "body": "log", "trace_id": "log"}, ["code.file", "scope_name"], id="log_context_lists_log_intrinsics"),
|
||||
pytest.param("logs", "scope", {"scope_name": "scope", "scope_version": "scope"}, ["severity_text", "body", "code.file"], id="scope_context_lists_scope_intrinsics_for_logs"),
|
||||
pytest.param("logs", "attribute", {"code.file": "attribute"}, ["body", "scope_name"], id="attribute_context_excludes_log_intrinsics"),
|
||||
pytest.param("traces", "span", {"name": "span", "has_error": "span", "isRoot": "span", "http.method": "attribute"}, ["scope.name"], id="span_context_lists_span_intrinsics_and_attributes"),
|
||||
pytest.param("traces", "scope", {"scope.name": "scope", "scope.version": "scope"}, ["name", "has_error", "isRoot", "http.method", "host.name"], id="scope_context_lists_scope_intrinsics_for_traces"),
|
||||
pytest.param("traces", "resource", {"host.name": "resource"}, ["name", "has_error", "isRoot", "http.method"], id="resource_context_excludes_span_intrinsics"),
|
||||
pytest.param("traces", "attribute", {"http.method": "attribute"}, ["name", "has_error", "isRoot", "host.name"], id="attribute_context_excludes_span_intrinsics"),
|
||||
],
|
||||
)
|
||||
def test_fields_keys_by_context(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
signal: str,
|
||||
field_context: str,
|
||||
present: dict[str, str],
|
||||
absent: list[str],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Insert a log with a code.file attribute and a span with an http.method attribute and a host.name resource.
|
||||
|
||||
Tests:
|
||||
1. Keys for a context list that context's intrinsic columns and the stored keys the context maps to,
|
||||
each with its context; intrinsics of other contexts are not listed. The span context also keeps
|
||||
listing attributes because `span.<attribute>` resolves attributes in queries.
|
||||
"""
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_logs([Logs(timestamp=now, attributes={"code.file": "/opt/integration.go"}, body="a log line")])
|
||||
insert_traces([Traces(timestamp=now, resources={"host.name": "linux-001"}, attributes={"http.method": "GET"})])
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/fields/keys"),
|
||||
timeout=2,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
params={"signal": signal, "fieldContext": field_context},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
keys = response.json()["data"]["keys"]
|
||||
listed = {name: [key["fieldContext"] for key in keys.get(name, [])] for name in present}
|
||||
assert listed == {name: [context] for name, context in present.items()}, f"keys for the {field_context} context"
|
||||
assert [name for name in absent if name in keys] == [], f"keys that do not belong to the {field_context} context"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"signal,field_context,field_data_type,present,absent",
|
||||
[
|
||||
pytest.param("traces", "span", "float64", ["duration_nano", "status_code"], ["name", "has_error"], id="float64_matches_number_span_intrinsics"),
|
||||
pytest.param("traces", "span", "int64", ["duration_nano", "status_code"], ["name", "has_error"], id="int64_matches_number_span_intrinsics"),
|
||||
pytest.param("traces", "span", "bool", ["has_error", "isRoot", "isEntryPoint"], ["name", "duration_nano"], id="bool_matches_bool_span_intrinsics"),
|
||||
pytest.param("traces", "span", "string", ["name", "http_method"], ["duration_nano", "has_error"], id="string_matches_string_span_intrinsics"),
|
||||
pytest.param("logs", "log", "number", ["severity_number", "trace_flags"], ["severity_text", "body"], id="number_matches_number_log_intrinsics"),
|
||||
],
|
||||
)
|
||||
def test_fields_keys_by_data_type(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
signal: str,
|
||||
field_context: str,
|
||||
field_data_type: str,
|
||||
present: list[str],
|
||||
absent: list[str],
|
||||
) -> None:
|
||||
"""
|
||||
Tests:
|
||||
1. A data type filter keeps the intrinsic columns of that type; number, int64 and float64 are one family.
|
||||
"""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/fields/keys"),
|
||||
timeout=2,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
params={"signal": signal, "fieldContext": field_context, "fieldDataType": field_data_type},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
keys = response.json()["data"]["keys"]
|
||||
assert [name for name in present if name not in keys] == [], f"intrinsics of type {field_data_type}"
|
||||
assert [name for name in absent if name in keys] == [], f"intrinsics not of type {field_data_type}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"signal,search_text,present",
|
||||
[
|
||||
pytest.param("logs", "SEVERITY", ["severity_text", "severity_number"], id="upper_case_search_logs"),
|
||||
pytest.param("traces", "HTTP_", ["http_method", "http_host", "http_url"], id="upper_case_search_traces"),
|
||||
pytest.param("traces", "Duration", ["duration_nano"], id="mixed_case_search_traces"),
|
||||
pytest.param("traces", "span.HAS_ERR", ["has_error"], id="context_prefix_with_upper_case_search"),
|
||||
],
|
||||
)
|
||||
def test_fields_keys_search_matches_intrinsics_case_insensitively(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
signal: str,
|
||||
search_text: str,
|
||||
present: list[str],
|
||||
) -> None:
|
||||
"""
|
||||
Tests:
|
||||
1. The search text matches intrinsic columns case-insensitively, as it does for stored keys,
|
||||
with or without a context prefix.
|
||||
"""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/fields/keys"),
|
||||
timeout=2,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
params={"signal": signal, "searchText": search_text},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
keys = response.json()["data"]["keys"]
|
||||
assert [name for name in present if name not in keys] == [], f"intrinsics matching {search_text!r}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"signal,params,expected",
|
||||
[
|
||||
pytest.param("traces", {"name": "has_error"}, [True, False], id="calculated_bool_span_field"),
|
||||
pytest.param("traces", {"name": "has_error", "fieldContext": "span"}, [True, False], id="calculated_bool_span_field_with_context"),
|
||||
pytest.param("traces", {"name": "has_error", "searchText": "tr"}, [True], id="search_text_narrows_bool_values"),
|
||||
pytest.param("traces", {"name": "isRoot"}, [True], id="span_scope_field_is_true_only"),
|
||||
pytest.param("logs", {"name": "retry"}, [True, False], id="bool_attribute_from_tag_rows"),
|
||||
pytest.param("logs", {"name": "retry", "fieldContext": "attribute"}, [True, False], id="bool_attribute_with_context"),
|
||||
pytest.param("logs", {"name": "retry", "searchText": "tr"}, [True], id="search_text_narrows_stored_bool_values"),
|
||||
pytest.param("logs", {"name": "never_seen", "fieldDataType": "bool"}, [True, False], id="declared_bool_type_needs_no_rows"),
|
||||
],
|
||||
)
|
||||
def test_fields_values_bool_fields(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
signal: str,
|
||||
params: dict[str, str],
|
||||
expected: list[bool],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Insert a log with a bool attribute.
|
||||
|
||||
Tests:
|
||||
1. Values for a bool field are true and false (narrowed by the search text): for the calculated span
|
||||
field, for a stored bool attribute whose tag rows carry no value, and for a key the caller
|
||||
declares bool.
|
||||
2. A span scope selector (isRoot) only takes true.
|
||||
"""
|
||||
insert_logs([Logs(timestamp=datetime.now(tz=UTC), attributes={"retry": True}, body="retrying")])
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
|
||||
timeout=2,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
params={"signal": signal, **params},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["data"]["values"]["boolValues"] == expected
|
||||
assert response.json()["data"]["complete"] is True
|
||||
|
||||
|
||||
def test_fields_values_start_excludes_span_values_not_seen_since_the_day(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Insert a span three days old and a span now, with different service names.
|
||||
|
||||
Tests:
|
||||
1. Values with startUnixMilli an hour ago contain only the service seen today: the start is
|
||||
floored to the day, the tag table's deduplication unit.
|
||||
2. Values without a start contain both services.
|
||||
|
||||
Logs are not covered: the logs collector stamps tag rows with the ingestion hour, not the
|
||||
log's timestamp, and the fixture writes the log's timestamp.
|
||||
"""
|
||||
signal = "traces"
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_traces(
|
||||
[
|
||||
Traces(timestamp=now - timedelta(days=3), resources={"service.name": "archived-service"}),
|
||||
Traces(timestamp=now, resources={"service.name": "live-service"}),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
|
||||
timeout=2,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
params={
|
||||
"signal": signal,
|
||||
"name": "service.name",
|
||||
"startUnixMilli": int((now - timedelta(hours=1)).timestamp() * 1000),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["data"]["values"]["stringValues"] == ["live-service"], "values last seen before the start must be dropped"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
|
||||
timeout=2,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
params={"signal": signal, "name": "service.name"},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert set(response.json()["data"]["values"]["stringValues"]) == {"archived-service", "live-service"}
|
||||
@@ -71,7 +71,7 @@ def test_v1_get_serves_legacy_shape(
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
filters = response.json()["data"]["filters"]
|
||||
assert filters[0]["key"] == "duration_nano"
|
||||
assert filters[0]["type"] == "tag"
|
||||
assert filters[0]["type"] == "", "span fields have no v3 attribute type"
|
||||
assert filters[0]["dataType"] == "float64"
|
||||
assert all("name" not in legacy_filter for legacy_filter in filters)
|
||||
|
||||
@@ -274,3 +274,36 @@ def test_update_quick_filters_rejects_invalid_input(
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
|
||||
def test_default_traces_filters_are_served_as_the_fields_api_serves_them(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/quick_filters/traces"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
filters = {field_key["name"]: field_key for field_key in response.json()["data"]["filters"]}
|
||||
|
||||
assert "hasError" not in filters
|
||||
assert (filters["has_error"]["fieldContext"], filters["has_error"]["fieldDataType"]) == ("span", "bool")
|
||||
assert (filters["name"]["fieldContext"], filters["name"]["fieldDataType"]) == ("span", "string")
|
||||
assert (filters["duration_nano"]["fieldContext"], filters["duration_nano"]["fieldDataType"]) == ("span", "number")
|
||||
assert (filters["http.route"]["fieldContext"], filters["http.route"]["fieldDataType"]) == ("attribute", "string")
|
||||
|
||||
for name in ("has_error", "name"):
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/fields/keys"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
params={"signal": "traces", "searchText": name, "fieldContext": filters[name]["fieldContext"]},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
served = response.json()["data"]["keys"][name]
|
||||
assert (filters[name]["fieldContext"], filters[name]["fieldDataType"]) in [(key["fieldContext"], key["fieldDataType"]) for key in served]
|
||||
|
||||
@@ -41,7 +41,7 @@ def test_create_groups_and_simulate_with_backfill(
|
||||
},
|
||||
json={
|
||||
"name": "llm-backfill",
|
||||
"condition": {"attributes": ["model"], "resource": []},
|
||||
"condition": {"attributes": [{"value": "model", "enabled": True}], "resource": []},
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
@@ -69,6 +69,7 @@ def test_create_groups_and_simulate_with_backfill(
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 1,
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -126,13 +127,13 @@ def test_create_groups_and_simulate_with_backfill(
|
||||
# No "mappers" key: the server backfills them from the saved group.
|
||||
{
|
||||
"name": "llm-backfill",
|
||||
"condition": {"attributes": ["model"], "resource": []},
|
||||
"condition": {"attributes": [{"value": "model", "enabled": True}], "resource": []},
|
||||
"enabled": True,
|
||||
},
|
||||
# Unsaved group; mappers provided inline.
|
||||
{
|
||||
"name": "db-inline",
|
||||
"condition": {"attributes": ["db"], "resource": []},
|
||||
"condition": {"attributes": [{"value": "db", "enabled": True}], "resource": []},
|
||||
"enabled": True,
|
||||
"mappers": [
|
||||
{
|
||||
@@ -145,6 +146,7 @@ def test_create_groups_and_simulate_with_backfill(
|
||||
"context": "attribute",
|
||||
"operation": "move",
|
||||
"priority": 1,
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
152
tests/integration/tests/spanmapper/02_default_groups.py
Normal file
152
tests/integration/tests/spanmapper/02_default_groups.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
|
||||
GROUPS_PATH = "/api/v1/span_mapper_groups"
|
||||
|
||||
|
||||
def test_default_groups_are_seeded_and_shipped_items_are_toggle_only(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
A fresh org. The reconciler seeds the shipped mapping groups at startup
|
||||
and on org creation, so nothing has to be created here.
|
||||
|
||||
Tests:
|
||||
1. The list contains llm, agent and tool as system groups with shipped
|
||||
substrings
|
||||
2. Shipped mappers and their sources are system-owned and enabled
|
||||
3. A shipped name cannot be taken by a user group, and system groups and
|
||||
mappers cannot be deleted
|
||||
4. A shipped source can be switched off and a user override added; both
|
||||
round-trip through PATCH and the simulator honours them
|
||||
5. A shipped substring can be switched off and a user one added
|
||||
"""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"authorization": f"Bearer {token}", "content-type": "application/json"}
|
||||
|
||||
list_groups = requests.get(signoz.self.host_configs["8080"].get(GROUPS_PATH), timeout=10, headers=headers)
|
||||
assert list_groups.status_code == HTTPStatus.OK
|
||||
groups = {g["name"]: g for g in list_groups.json()["data"]["items"]}
|
||||
assert {"llm", "agent", "tool"} <= set(groups)
|
||||
for name in ("llm", "agent", "tool"):
|
||||
assert groups[name]["origin"] == "system"
|
||||
assert groups[name]["version"] >= 1
|
||||
assert groups[name]["createdBy"] == "signoz"
|
||||
llm = groups["llm"]
|
||||
assert llm["condition"]["attributes"] == [{"value": "model", "enabled": True, "origin": "system"}]
|
||||
|
||||
list_mappers = requests.get(signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}/span_mappers"), timeout=10, headers=headers)
|
||||
assert list_mappers.status_code == HTTPStatus.OK
|
||||
mappers = {m["name"]: m for m in list_mappers.json()["data"]["items"]}
|
||||
model = mappers["gen_ai.request.model"]
|
||||
assert model["origin"] == "system"
|
||||
assert model["enabled"] is True
|
||||
assert all(s["origin"] == "system" and s["enabled"] is True for s in model["config"]["sources"])
|
||||
assert "llm.model_name" in [s["key"] for s in model["config"]["sources"]]
|
||||
|
||||
reserved = requests.post(
|
||||
signoz.self.host_configs["8080"].get(GROUPS_PATH),
|
||||
timeout=10,
|
||||
headers=headers,
|
||||
json={"name": "tool", "condition": {"attributes": [{"value": "tool", "enabled": True}], "resource": []}, "enabled": True},
|
||||
)
|
||||
assert reserved.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert reserved.json()["error"]["code"] == "span_attribute_mapping_group_name_reserved"
|
||||
|
||||
delete_group = requests.delete(signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}"), timeout=10, headers=headers)
|
||||
assert delete_group.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert delete_group.json()["error"]["code"] == "span_attribute_mapping_group_not_deletable"
|
||||
|
||||
delete_mapper = requests.delete(signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}/span_mappers/{model['id']}"), timeout=10, headers=headers)
|
||||
assert delete_mapper.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert delete_mapper.json()["error"]["code"] == "span_attribute_mapper_not_deletable"
|
||||
|
||||
# Switch the shipped llm.model_name source off and re-add it as a user move.
|
||||
sources = [{**s, "enabled": s["key"] != "llm.model_name"} for s in model["config"]["sources"]] + [{"key": "llm.model_name", "context": "attribute", "operation": "move", "priority": 1, "enabled": True}]
|
||||
patch_mapper = requests.patch(
|
||||
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}/span_mappers/{model['id']}"),
|
||||
timeout=10,
|
||||
headers=headers,
|
||||
json={"config": {"sources": sources}},
|
||||
)
|
||||
assert patch_mapper.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
list_mappers = requests.get(signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}/span_mappers"), timeout=10, headers=headers)
|
||||
updated = {m["name"]: m for m in list_mappers.json()["data"]["items"]}["gen_ai.request.model"]
|
||||
by_origin = {(s["key"], s["origin"]): s for s in updated["config"]["sources"]}
|
||||
assert by_origin[("llm.model_name", "system")]["enabled"] is False
|
||||
assert by_origin[("llm.model_name", "user")]["operation"] == "move"
|
||||
assert len(updated["config"]["sources"]) == len(model["config"]["sources"]) + 1
|
||||
|
||||
# The user override wins: the source is moved, not copied.
|
||||
simulate = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/test"),
|
||||
timeout=10,
|
||||
headers=headers,
|
||||
json={
|
||||
"spans": [{"attributes": {"llm.model_name": "gpt-4o"}, "resource": {}}],
|
||||
"groups": [{"name": "llm", "condition": llm["condition"], "enabled": True}],
|
||||
},
|
||||
)
|
||||
assert simulate.status_code == HTTPStatus.OK
|
||||
attrs = simulate.json()["data"]["spans"][0]["attributes"]
|
||||
assert attrs["gen_ai.request.model"] == "gpt-4o"
|
||||
assert "llm.model_name" not in attrs
|
||||
|
||||
# A shipped substring that does not exist is rejected; toggling one is not.
|
||||
bad_condition = requests.patch(
|
||||
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}"),
|
||||
timeout=10,
|
||||
headers=headers,
|
||||
json={"condition": {"attributes": [{"value": "nope", "enabled": True, "origin": "system"}], "resource": []}},
|
||||
)
|
||||
assert bad_condition.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
patch_group = requests.patch(
|
||||
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}"),
|
||||
timeout=10,
|
||||
headers=headers,
|
||||
json={
|
||||
"condition": {
|
||||
"attributes": [
|
||||
{"value": "model", "enabled": False, "origin": "system"},
|
||||
{"value": "gen_ai.request.model", "enabled": True},
|
||||
],
|
||||
"resource": [],
|
||||
}
|
||||
},
|
||||
)
|
||||
assert patch_group.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
list_groups = requests.get(signoz.self.host_configs["8080"].get(GROUPS_PATH), timeout=10, headers=headers)
|
||||
llm_after = {g["name"]: g for g in list_groups.json()["data"]["items"]}["llm"]
|
||||
assert llm_after["condition"]["attributes"] == [
|
||||
{"value": "model", "enabled": False, "origin": "system"},
|
||||
{"value": "gen_ai.request.model", "enabled": True, "origin": "user"},
|
||||
]
|
||||
assert llm_after["origin"] == "system"
|
||||
assert llm_after["name"] == "llm"
|
||||
|
||||
# Leave the shipped group as seeded for the other suites.
|
||||
restore_group = requests.patch(
|
||||
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}"),
|
||||
timeout=10,
|
||||
headers=headers,
|
||||
json={"condition": {"attributes": [{"value": "model", "enabled": True, "origin": "system"}], "resource": []}},
|
||||
)
|
||||
assert restore_group.status_code == HTTPStatus.NO_CONTENT
|
||||
restore_mapper = requests.patch(
|
||||
signoz.self.host_configs["8080"].get(f"{GROUPS_PATH}/{llm['id']}/span_mappers/{model['id']}"),
|
||||
timeout=10,
|
||||
headers=headers,
|
||||
json={"config": {"sources": model["config"]["sources"]}},
|
||||
)
|
||||
assert restore_mapper.status_code == HTTPStatus.NO_CONTENT
|
||||
Reference in New Issue
Block a user