mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-19 00:20:40 +01:00
Compare commits
6 Commits
feat/trace
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c6632f940 | ||
|
|
39b752c428 | ||
|
|
dccd75e1de | ||
|
|
4f3e7fa50e | ||
|
|
531504c523 | ||
|
|
b770c249a0 |
@@ -104,6 +104,7 @@ func runGenerateAuthz(_ context.Context) error {
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMeterMetrics).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceQuickFilter).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSavedView).String(): true,
|
||||
}
|
||||
|
||||
allowedTypes := map[string]bool{}
|
||||
|
||||
@@ -25008,6 +25008,75 @@ paths:
|
||||
tags:
|
||||
- rules
|
||||
x-signoz-stability: alpha
|
||||
/api/v2/rules/{id}/clone:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a new alert rule from the stored definition
|
||||
of an existing one. The clone keeps every modeled field of the source, including
|
||||
its enabled/disabled state, and gets the source's name suffixed with " - Copy"
|
||||
(or a bumped " - Copy (n)" counter when the source is itself a copy). The
|
||||
clone is owned by the caller.
|
||||
operationId: CloneRuleByID
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesRule'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Clone alert rule
|
||||
tags:
|
||||
- rules
|
||||
x-signoz-stability: alpha
|
||||
/api/v2/rules/{id}/history/filter_keys:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -423,7 +423,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
func (r *AnomalyRule) String() string {
|
||||
|
||||
ar := ruletypes.PostableRule{
|
||||
AlertName: r.Name(),
|
||||
AlertName: valuer.NewDisplayName(r.Name()),
|
||||
RuleCondition: r.Condition(),
|
||||
EvalWindow: r.EvalWindow(),
|
||||
Labels: r.Labels().Map(),
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
|
||||
target := 500.0
|
||||
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Test anomaly no data",
|
||||
AlertName: valuer.NewDisplayName("Test anomaly no data"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeAnomaly,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -149,7 +149,7 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
|
||||
target := 500.0
|
||||
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Test anomaly no data with AbsentFor",
|
||||
AlertName: valuer.NewDisplayName("Test anomaly no data with AbsentFor"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeAnomaly,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) {
|
||||
@@ -118,7 +119,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
|
||||
}
|
||||
|
||||
parsedRule := opts.Rule
|
||||
var alertname = parsedRule.AlertName
|
||||
var alertname = parsedRule.AlertName.StringValue()
|
||||
if alertname == "" {
|
||||
// alertname is not mandatory for testing, so picking
|
||||
// a random string here
|
||||
@@ -126,7 +127,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
|
||||
}
|
||||
|
||||
// append name to indicate this is test alert
|
||||
parsedRule.AlertName = fmt.Sprintf("%s%s", alertname, ruletypes.TestAlertPostFix)
|
||||
parsedRule.AlertName = valuer.NewDisplayName(fmt.Sprintf("%s%s", alertname, ruletypes.TestAlertPostFix))
|
||||
|
||||
var rule baserules.Rule
|
||||
var err error
|
||||
|
||||
@@ -9,12 +9,14 @@ describe('ErrorResponseHandler', () => {
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
expect(ErrorResponseHandler(new CanceledError() as AxiosError)).toEqual({
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
error: 'Something went wrong',
|
||||
message: null,
|
||||
});
|
||||
expect(ErrorResponseHandler(new CanceledError() as AxiosError)).toStrictEqual(
|
||||
{
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
error: 'Something went wrong',
|
||||
message: null,
|
||||
},
|
||||
);
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
|
||||
consoleError.mockRestore();
|
||||
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
CloneRuleByID201,
|
||||
CloneRuleByIDPathParameters,
|
||||
CreateRule201,
|
||||
CreateRuleView201,
|
||||
DeleteRuleByIDPathParameters,
|
||||
@@ -963,6 +965,85 @@ export const useUpdateRuleByID = <
|
||||
> => {
|
||||
return useMutation(getUpdateRuleByIDMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with " - Copy" (or a bumped " - Copy (n)" counter when the source is itself a copy). The clone is owned by the caller.
|
||||
* @summary Clone alert rule
|
||||
*/
|
||||
export const cloneRuleByID = (
|
||||
{ id }: CloneRuleByIDPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CloneRuleByID201>({
|
||||
url: `/api/v2/rules/${id}/clone`,
|
||||
method: 'POST',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCloneRuleByIDMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: CloneRuleByIDPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: CloneRuleByIDPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['cloneRuleByID'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>,
|
||||
{ pathParams: CloneRuleByIDPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return cloneRuleByID(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CloneRuleByIDMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>
|
||||
>;
|
||||
|
||||
export type CloneRuleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Clone alert rule
|
||||
*/
|
||||
export const useCloneRuleByID = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: CloneRuleByIDPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: CloneRuleByIDPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCloneRuleByIDMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns distinct label keys from rule history entries for the selected range.
|
||||
* @summary Get rule history filter keys
|
||||
|
||||
@@ -14612,6 +14612,17 @@ export type PatchRuleByID200 = {
|
||||
export type UpdateRuleByIDPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type CloneRuleByIDPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type CloneRuleByID201 = {
|
||||
data: RuletypesRuleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetRuleHistoryFilterKeysPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { DrawerProps } from 'antd';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
|
||||
import { ActionItemProps } from 'container/LogDetailedView/LogDetailedView.types';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
@@ -20,9 +18,7 @@ export type LogDetailProps = {
|
||||
handleOpenInExplorer?: MouseEventHandler;
|
||||
getContainer?: DrawerProps['getContainer'];
|
||||
onApplyLogFilter?: (expression: string) => void;
|
||||
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &
|
||||
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
|
||||
Pick<DrawerProps, 'onClose'>;
|
||||
} & Pick<DrawerProps, 'onClose'>;
|
||||
|
||||
export type LogDetailInnerProps = LogDetailProps & {
|
||||
log: NonNullable<LogDetailProps['log']>;
|
||||
|
||||
@@ -46,8 +46,6 @@ function renderDrawer(props: Partial<LogDetailProps> = {}): void {
|
||||
<LogDetail
|
||||
log={mockLog}
|
||||
selectedTab={VIEW_TYPES.OVERVIEW}
|
||||
onAddToQuery={jest.fn()}
|
||||
onClickActionItem={jest.fn()}
|
||||
onClose={jest.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../types';
|
||||
import QuickFiltersLayout from '../QuickFiltersLayout';
|
||||
@@ -197,6 +197,11 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-open')).not.toHaveAttribute(
|
||||
'aria-busy',
|
||||
),
|
||||
);
|
||||
await user.click(screen.getByTestId('saved-views-open'));
|
||||
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
@@ -227,6 +232,11 @@ describe('QuickFiltersLayout', () => {
|
||||
'data-source',
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-open')).not.toHaveAttribute(
|
||||
'aria-busy',
|
||||
),
|
||||
);
|
||||
await userEvent.setup().click(screen.getByTestId('saved-views-open'));
|
||||
expect(screen.getByTestId('saved-views-panel')).toHaveAttribute(
|
||||
'data-source',
|
||||
|
||||
@@ -25,10 +25,8 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
|
||||
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
||||
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
||||
import { generateFilterQuery } from 'lib/logs/generateFilterQuery';
|
||||
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -82,29 +80,6 @@ function EntityLogsContent({
|
||||
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
|
||||
useLogDetailHandlers();
|
||||
|
||||
// TODO: Move away from using onAddToQuery after old drawer cleanup
|
||||
const onAddToQuery = useCallback(
|
||||
(fieldKey: string, fieldValue: string, operator: string): void => {
|
||||
handleCloseLogDetail();
|
||||
|
||||
const partExpression = generateFilterQuery({
|
||||
fieldKey,
|
||||
fieldValue,
|
||||
type: getOldLogsOperatorFromNew(operator),
|
||||
});
|
||||
|
||||
const currentUser = userExpression;
|
||||
const newUser = currentUser.trim()
|
||||
? `${currentUser} AND ${partExpression}`
|
||||
: partExpression;
|
||||
|
||||
querySearchOnRun(newUser);
|
||||
|
||||
logInfraDrawerFilterCustomizedEvent(category, 'logs', newUser, 'logs');
|
||||
},
|
||||
[userExpression, querySearchOnRun, handleCloseLogDetail, category],
|
||||
);
|
||||
|
||||
const onApplyLogFilter = useCallback(
|
||||
(expression: string): void => {
|
||||
handleCloseLogDetail();
|
||||
@@ -342,8 +317,6 @@ function EntityLogsContent({
|
||||
logs={logs}
|
||||
onNavigateLog={handleSetActiveLog}
|
||||
selectedTab={selectedTab}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
onApplyLogFilter={onApplyLogFilter}
|
||||
onScrollToLog={handleScrollToLog}
|
||||
handleOpenInExplorer={(e) => handleOpenInExplorer(e, activeLog)}
|
||||
|
||||
@@ -259,8 +259,6 @@ function LiveLogsList({
|
||||
selectedTab={selectedTab}
|
||||
log={activeLog}
|
||||
onClose={handleCloseLogDetail}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
logs={formattedLogs}
|
||||
onNavigateLog={handleSetActiveLog}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
export type AnyObject = { [key: string]: any };
|
||||
|
||||
@@ -19,15 +18,3 @@ export interface DataType {
|
||||
field: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ActionItemProps {
|
||||
fieldKey: string;
|
||||
fieldValue: string;
|
||||
onClickActionItem: (
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
operator: string,
|
||||
dataType?: DataTypes,
|
||||
fieldType?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ const normalizeDataType = (
|
||||
// key is fabricated locally (empty source list), so no click-time getAggregateKeys fetch.
|
||||
export const getFilterQueryData = (
|
||||
item: IBuilderQuery,
|
||||
target: LogFilterTarget,
|
||||
target: Pick<LogFilterTarget, 'fieldKey' | 'dataType' | 'metricsType'>,
|
||||
value: unknown,
|
||||
operator: string,
|
||||
): IBuilderQuery => {
|
||||
|
||||
@@ -381,8 +381,6 @@ function LogsExplorerList({
|
||||
selectedTab={selectedTab}
|
||||
log={activeLog}
|
||||
onClose={handleCloseLogDetail}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
logs={logs}
|
||||
onNavigateLog={handleSetActiveLog}
|
||||
|
||||
@@ -91,13 +91,8 @@ function LogsPanelComponent({
|
||||
() => logs.map((log) => FlatLogData(log) as RowData),
|
||||
[logs],
|
||||
);
|
||||
const {
|
||||
activeLog,
|
||||
onAddToQuery,
|
||||
selectedTab,
|
||||
handleSetActiveLog,
|
||||
handleCloseLogDetail,
|
||||
} = useLogDetailHandlers();
|
||||
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
|
||||
useLogDetailHandlers();
|
||||
|
||||
const handleRow = useCallback(
|
||||
(record: RowData): HTMLAttributes<RowData> => ({
|
||||
@@ -179,8 +174,6 @@ function LogsPanelComponent({
|
||||
selectedTab={selectedTab}
|
||||
log={activeLog}
|
||||
onClose={handleCloseLogDetail}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
isListViewPanel
|
||||
listViewPanelSelectedFields={widget?.selectedLogFields}
|
||||
logs={logs}
|
||||
|
||||
@@ -10,8 +10,7 @@ import { ILog } from 'types/api/logs/log';
|
||||
import './styles.scss';
|
||||
|
||||
function LogsList({ logs }: LogsListProps): JSX.Element {
|
||||
const { activeLog, onSetActiveLog, onClearActiveLog, onAddToQuery } =
|
||||
useActiveLog();
|
||||
const { activeLog, onSetActiveLog, onClearActiveLog } = useActiveLog();
|
||||
|
||||
const makeLogDetailsHandler = (log: ILog) => (): void => onSetActiveLog(log);
|
||||
|
||||
@@ -45,8 +44,6 @@ function LogsList({ logs }: LogsListProps): JSX.Element {
|
||||
selectedTab={VIEW_TYPES.OVERVIEW}
|
||||
log={activeLog}
|
||||
onClose={onClearActiveLog}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -329,7 +329,7 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
|
||||
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
|
||||
const result = transformTransactionGroupsToResourcePermissions([]);
|
||||
|
||||
expect(result).toHaveLength(11);
|
||||
expect(result).toHaveLength(12);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'dashboard',
|
||||
@@ -339,6 +339,7 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
|
||||
'metrics',
|
||||
'quick-filter',
|
||||
'role',
|
||||
'saved-view',
|
||||
'serviceaccount',
|
||||
'subscription',
|
||||
'traces',
|
||||
@@ -422,7 +423,7 @@ describe('createEmptyRolePermissions', () => {
|
||||
it('creates permissions for all resources in RESOURCE_ORDER', () => {
|
||||
const result = createEmptyRolePermissions();
|
||||
|
||||
expect(result).toHaveLength(11);
|
||||
expect(result).toHaveLength(12);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'dashboard',
|
||||
@@ -432,6 +433,7 @@ describe('createEmptyRolePermissions', () => {
|
||||
'metrics',
|
||||
'quick-filter',
|
||||
'role',
|
||||
'saved-view',
|
||||
'serviceaccount',
|
||||
'subscription',
|
||||
'traces',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Logs,
|
||||
Receipt,
|
||||
Shield,
|
||||
TowerControl,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
|
||||
@@ -95,6 +96,14 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
|
||||
'Type quick filter ID, separate multiple with comma or space',
|
||||
docsAnchor: 'quick-filter',
|
||||
},
|
||||
'saved-view': {
|
||||
label: 'Saved Views',
|
||||
description: 'Saved views in the logs, traces, metrics and meter explorers.',
|
||||
icon: TowerControl,
|
||||
selectorPlaceholder:
|
||||
'Type saved view ID, separate multiple with comma or space',
|
||||
docsAnchor: 'saved-view',
|
||||
},
|
||||
logs: {
|
||||
label: 'Logs',
|
||||
description: 'Log data collected across the workspace.',
|
||||
|
||||
@@ -1,41 +1,50 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Check } from '@signozhq/icons';
|
||||
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
|
||||
import AuthZDropdown from 'lib/authz/components/AuthZDropdown/AuthZDropdown';
|
||||
import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/types';
|
||||
import {
|
||||
buildSavedViewUpdatePermission,
|
||||
SavedViewCreatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import { SaveChangesMenuProps } from './types';
|
||||
|
||||
function SaveChangesMenu({
|
||||
viewId,
|
||||
disabled,
|
||||
onSaveAsNew,
|
||||
onUpdate,
|
||||
}: SaveChangesMenuProps): JSX.Element {
|
||||
const items = useMemo<DropdownItemType[]>(
|
||||
const items = useMemo<AuthZDropdownItemType[]>(
|
||||
() => [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'save-as-new',
|
||||
label: 'Save as new view',
|
||||
checks: [SavedViewCreatePermission],
|
||||
onClick: onSaveAsNew,
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
value: 'update',
|
||||
label: 'Update selected view',
|
||||
checks: [buildSavedViewUpdatePermission(viewId)],
|
||||
onClick: onUpdate,
|
||||
},
|
||||
],
|
||||
[onSaveAsNew, onUpdate],
|
||||
[viewId, onSaveAsNew, onUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
<AuthZDropdown
|
||||
items={items}
|
||||
nativeButton
|
||||
align="end"
|
||||
side="bottom"
|
||||
disabled={disabled}
|
||||
disabledTooltip="Saving the view"
|
||||
testId="saved-views-save-changes"
|
||||
>
|
||||
<SavedViewsIconButton
|
||||
title="Save changes"
|
||||
@@ -43,7 +52,7 @@ function SaveChangesMenu({
|
||||
color="warning"
|
||||
testId="saved-views-save-changes"
|
||||
/>
|
||||
</Dropdown>
|
||||
</AuthZDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { SavedViewCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
|
||||
import SaveViewModal from './SaveViewModal';
|
||||
import { SavedViewsEmptyStateProps } from './types';
|
||||
@@ -31,7 +32,8 @@ function SavedViewsEmptyState({
|
||||
<Typography.Text className={styles.stateText}>
|
||||
Save the current view to open it later.
|
||||
</Typography.Text>
|
||||
<Button
|
||||
<AuthZButton
|
||||
checks={[SavedViewCreatePermission]}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
@@ -40,7 +42,7 @@ function SavedViewsEmptyState({
|
||||
testId="saved-views-list-create"
|
||||
>
|
||||
Create view
|
||||
</Button>
|
||||
</AuthZButton>
|
||||
{isModalOpen && (
|
||||
<SaveViewModal
|
||||
mode="create"
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: var(--spacing-1);
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.collapseIcon {
|
||||
|
||||
@@ -5,9 +5,17 @@ import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
buildSavedViewReadPermission,
|
||||
SavedViewCreatePermission,
|
||||
SavedViewListPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
|
||||
import {
|
||||
MY_VIEW_NAME,
|
||||
SAVED_VIEW_FORBIDDEN_NAME,
|
||||
SAVED_VIEW_LOAD_FAILED_NAME,
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
} from './constants';
|
||||
@@ -31,8 +39,16 @@ function SavedViewsHeader({
|
||||
onOpenViews?: () => void;
|
||||
onCollapse?: () => void;
|
||||
}): JSX.Element {
|
||||
const { view, isLoading, isError, hasUnsavedChanges } =
|
||||
useActiveSavedView(source);
|
||||
const {
|
||||
view,
|
||||
isLoading,
|
||||
isError,
|
||||
isReadDenied,
|
||||
errorMessage,
|
||||
hasUnsavedChanges,
|
||||
} = useActiveSavedView(source);
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const readPermission = buildSavedViewReadPermission(viewKey);
|
||||
const { revertView, clearView, createView, updateView, isSaving } =
|
||||
useSavedViewActions(source);
|
||||
|
||||
@@ -49,11 +65,27 @@ function SavedViewsHeader({
|
||||
const openSaveAsNew = useCallback((): void => setModalMode('saveAsNew'), []);
|
||||
const closeModal = useCallback((): void => setModalMode(null), []);
|
||||
|
||||
const name = isError
|
||||
? SAVED_VIEW_LOAD_FAILED_NAME
|
||||
: (view?.spec.displayName ?? MY_VIEW_NAME);
|
||||
let name = view?.spec.displayName ?? MY_VIEW_NAME;
|
||||
if (isReadDenied) {
|
||||
name = SAVED_VIEW_FORBIDDEN_NAME;
|
||||
} else if (isError) {
|
||||
name = SAVED_VIEW_LOAD_FAILED_NAME;
|
||||
}
|
||||
const isDirty = !isError && !!view && hasUnsavedChanges;
|
||||
|
||||
const nameChip = (
|
||||
<span
|
||||
className={cx(styles.name, {
|
||||
[styles.isDirty]: isDirty,
|
||||
[styles.isError]: isError,
|
||||
})}
|
||||
data-testid="saved-views-name"
|
||||
>
|
||||
{isDirty && <span className={styles.dot} />}
|
||||
<span className={styles.nameText}>{name}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.header, {
|
||||
@@ -68,6 +100,7 @@ function SavedViewsHeader({
|
||||
<SavedViewsIconButton
|
||||
title="All views"
|
||||
icon={<List size={14} />}
|
||||
checks={[SavedViewListPermission]}
|
||||
onClick={onOpenViews}
|
||||
testId="saved-views-open"
|
||||
/>
|
||||
@@ -79,19 +112,10 @@ function SavedViewsHeader({
|
||||
className={styles.skeleton}
|
||||
testId="saved-views-loading"
|
||||
/>
|
||||
) : isReadDenied ? (
|
||||
<AuthZTooltip checks={[readPermission]}>{nameChip}</AuthZTooltip>
|
||||
) : (
|
||||
<Tooltip title={name}>
|
||||
<span
|
||||
className={cx(styles.name, {
|
||||
[styles.isDirty]: isDirty,
|
||||
[styles.isError]: isError,
|
||||
})}
|
||||
data-testid="saved-views-name"
|
||||
>
|
||||
{isDirty && <span className={styles.dot} />}
|
||||
<span className={styles.nameText}>{name}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={errorMessage ?? name}>{nameChip}</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -102,6 +126,7 @@ function SavedViewsHeader({
|
||||
icon={<Plus size={14} />}
|
||||
disabled={isLoading}
|
||||
disabledTooltip="Loading views"
|
||||
checks={[SavedViewCreatePermission]}
|
||||
onClick={(): void => setModalMode('create')}
|
||||
testId="saved-views-create"
|
||||
/>
|
||||
@@ -117,6 +142,7 @@ function SavedViewsHeader({
|
||||
{view && isDirty && (
|
||||
<>
|
||||
<SaveChangesMenu
|
||||
viewId={view.id}
|
||||
disabled={isSaving}
|
||||
onSaveAsNew={openSaveAsNew}
|
||||
onUpdate={(): void => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Button, type VariantColorType } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
|
||||
import { SavedViewsIconButtonProps } from './types';
|
||||
|
||||
@@ -25,11 +26,33 @@ const SavedViewsIconButton = forwardRef<
|
||||
color = 'secondary',
|
||||
disabled,
|
||||
disabledTooltip,
|
||||
checks,
|
||||
onClick,
|
||||
...triggerProps
|
||||
},
|
||||
ref,
|
||||
): JSX.Element {
|
||||
if (checks?.length) {
|
||||
return (
|
||||
<Tooltip title={title}>
|
||||
<AuthZButton
|
||||
checks={checks}
|
||||
{...VARIANT_COLORS[color]}
|
||||
size="sm"
|
||||
icon
|
||||
aria-label={title}
|
||||
disabled={disabled}
|
||||
// AuthZButton skips its checks while a block reason is set.
|
||||
disabledTooltip={disabled ? disabledTooltip : undefined}
|
||||
onClick={onClick}
|
||||
testId={testId}
|
||||
>
|
||||
{icon}
|
||||
</AuthZButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={title}>
|
||||
<Button
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
gap: var(--spacing-4);
|
||||
height: var(--spacing-16);
|
||||
padding-right: var(--spacing-4);
|
||||
border-radius: var(--radius-2);
|
||||
@@ -71,7 +71,7 @@
|
||||
|
||||
.row:hover .rowMenu,
|
||||
.row:focus-within .rowMenu,
|
||||
.rowMenu:has([data-state='open']) {
|
||||
.rowMenu:has([data-popup-open]) {
|
||||
width: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -40,8 +40,14 @@ function SavedViewsList({
|
||||
const saveNewView = useSaveNewView(createView);
|
||||
const { createdByMe, createdByOthers, isLoading, isError, refetch } =
|
||||
useSavedViewsList(source, search);
|
||||
const { copyViewLink, renameView, deleteView, isRenaming, isDeleting } =
|
||||
useSavedViewRowActions(source);
|
||||
const {
|
||||
copyViewLink,
|
||||
copyViewId,
|
||||
renameView,
|
||||
deleteView,
|
||||
isRenaming,
|
||||
isDeleting,
|
||||
} = useSavedViewRowActions(source);
|
||||
const [pendingAction, setPendingAction] =
|
||||
useState<PendingSavedViewAction | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
@@ -79,10 +85,14 @@ function SavedViewsList({
|
||||
copyViewLink(view);
|
||||
return;
|
||||
}
|
||||
if (key === 'copyId') {
|
||||
copyViewId(view);
|
||||
return;
|
||||
}
|
||||
closeHoverCard();
|
||||
setPendingAction({ key, view });
|
||||
},
|
||||
[copyViewLink, closeHoverCard],
|
||||
[copyViewLink, copyViewId, closeHoverCard],
|
||||
);
|
||||
|
||||
const closePendingAction = useCallback((): void => setPendingAction(null), []);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import cx from 'classnames';
|
||||
import { Undo2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
import SavedViewsRowButton from './SavedViewsRowButton';
|
||||
import SavedViewsRowMenu from './SavedViewsRowMenu';
|
||||
@@ -17,17 +19,26 @@ function SavedViewsListRow({
|
||||
hover,
|
||||
}: SavedViewsListRowProps): JSX.Element {
|
||||
const name = view.spec.displayName;
|
||||
const readPermission = buildSavedViewReadPermission(view.id);
|
||||
// Same cached check as the button; gates the hover card.
|
||||
const { isLoading, deniedPermissions } = useAuthZ([readPermission]);
|
||||
const canRead = !isLoading && deniedPermissions.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.row, { [styles.isActive]: isActive })}
|
||||
data-testid="saved-views-row"
|
||||
data-view-id={view.id}
|
||||
onMouseEnter={(event): void => hover.onRowEnter(view, event.currentTarget)}
|
||||
onMouseEnter={(event): void => {
|
||||
if (canRead) {
|
||||
hover.onRowEnter(view, event.currentTarget);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={hover.onRowLeave}
|
||||
>
|
||||
<div className={styles.rowName}>
|
||||
<Button
|
||||
<AuthZButton
|
||||
checks={[readPermission]}
|
||||
size="md"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
@@ -41,7 +52,7 @@ function SavedViewsListRow({
|
||||
testId="saved-views-row-name"
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
</AuthZButton>
|
||||
</div>
|
||||
{isActive && (
|
||||
<SavedViewsRowButton
|
||||
@@ -54,6 +65,7 @@ function SavedViewsListRow({
|
||||
)}
|
||||
<span className={styles.rowMenu}>
|
||||
<SavedViewsRowMenu
|
||||
viewId={view.id}
|
||||
onAction={(key): void => onAction(view, key)}
|
||||
onOpen={hover.onMenuOpen}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,9 @@ import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
|
||||
import { SavedViewListPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import SavedViewsList from './SavedViewsList';
|
||||
|
||||
@@ -39,16 +42,20 @@ function SavedViewsPanel({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event): void => setSearch(event.target.value)}
|
||||
placeholder="Search..."
|
||||
aria-label="Search views"
|
||||
testId="saved-views-search"
|
||||
/>
|
||||
</div>
|
||||
<SavedViewsList source={source} search={search} />
|
||||
<AuthZGuardContent checks={[SavedViewListPermission]}>
|
||||
<>
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event): void => setSearch(event.target.value)}
|
||||
placeholder="Search..."
|
||||
aria-label="Search views"
|
||||
testId="saved-views-search"
|
||||
/>
|
||||
</div>
|
||||
<SavedViewsList source={source} search={search} />
|
||||
</>
|
||||
</AuthZGuardContent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
|
||||
import AuthZDropdown from 'lib/authz/components/AuthZDropdown/AuthZDropdown';
|
||||
import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/types';
|
||||
|
||||
import { SAVED_VIEW_ROW_ACTIONS } from './constants';
|
||||
import SavedViewsRowButton from './SavedViewsRowButton';
|
||||
import { SavedViewsRowMenuProps } from './types';
|
||||
|
||||
function SavedViewsRowMenu({
|
||||
viewId,
|
||||
onAction,
|
||||
onOpen,
|
||||
}: SavedViewsRowMenuProps): JSX.Element {
|
||||
const items = useMemo<DropdownItemType[]>(
|
||||
const items = useMemo<AuthZDropdownItemType[]>(
|
||||
() =>
|
||||
SAVED_VIEW_ROW_ACTIONS.flatMap(
|
||||
({
|
||||
@@ -19,7 +21,8 @@ function SavedViewsRowMenu({
|
||||
icon: Icon,
|
||||
danger,
|
||||
hasDividerBefore,
|
||||
}): DropdownItemType[] => [
|
||||
buildPermission,
|
||||
}): AuthZDropdownItemType[] => [
|
||||
...(hasDividerBefore
|
||||
? [{ type: 'separator' as const, value: `separator-${key}` }]
|
||||
: []),
|
||||
@@ -29,15 +32,16 @@ function SavedViewsRowMenu({
|
||||
label,
|
||||
prefix: <Icon size={14} />,
|
||||
danger,
|
||||
checks: buildPermission ? [buildPermission(viewId)] : undefined,
|
||||
onClick: (): void => onAction(key),
|
||||
},
|
||||
],
|
||||
),
|
||||
[onAction],
|
||||
[viewId, onAction],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
<AuthZDropdown
|
||||
items={items}
|
||||
nativeButton
|
||||
side="right"
|
||||
@@ -47,6 +51,7 @@ function SavedViewsRowMenu({
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
testId="saved-views-row-menu"
|
||||
>
|
||||
<SavedViewsRowButton
|
||||
icon={<Ellipsis size={14} />}
|
||||
@@ -54,7 +59,7 @@ function SavedViewsRowMenu({
|
||||
tooltip="More options"
|
||||
testId="saved-views-row-menu"
|
||||
/>
|
||||
</Dropdown>
|
||||
</AuthZDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { act, fireEvent, screen, within } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -12,6 +18,7 @@ import {
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
queryWith,
|
||||
@@ -110,6 +117,11 @@ async function renderPanel(
|
||||
dataSource,
|
||||
);
|
||||
await screen.findByTestId('saved-views-list');
|
||||
await waitFor(() =>
|
||||
screen
|
||||
.getAllByTestId('saved-views-row-name')
|
||||
.forEach((name) => expectUsable(name)),
|
||||
);
|
||||
jest.useFakeTimers();
|
||||
}
|
||||
|
||||
@@ -140,6 +152,10 @@ describe('SavedViewHoverCard', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// useAuthZ batches checks on a timer; left pending, it blocks later checks.
|
||||
act(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
buildSavedViewDeletePermission,
|
||||
buildSavedViewReadPermission,
|
||||
buildSavedViewUpdatePermission,
|
||||
SavedViewCreatePermission,
|
||||
SavedViewListPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
setupAuthzDeny,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { API, mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
explorerUrl,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const slow = makeView({ id: 'view-2', displayName: 'Slow spans' });
|
||||
|
||||
function renderPanel(url = explorerUrl(PATH, {})): void {
|
||||
renderWithExplorerProviders(
|
||||
<SavedViewsPanel
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
function renderHeader(
|
||||
url: string,
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
/>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const rowNamed = (name: string): HTMLElement =>
|
||||
screen
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((row) => row.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
async function openRowMenu(name: string): Promise<void> {
|
||||
await screen.findAllByTestId('saved-views-row');
|
||||
fireEvent.click(within(rowNamed(name)).getByTestId('saved-views-row-menu'));
|
||||
}
|
||||
|
||||
// Resolves once the row's check is back, so its state is final.
|
||||
async function settledRow(testId: string): Promise<HTMLElement> {
|
||||
await waitFor(
|
||||
() => expect(screen.getByTestId(testId)).not.toHaveAttribute('data-loading'),
|
||||
MENU_CHECK_TIMEOUT,
|
||||
);
|
||||
return screen.getByTestId(testId);
|
||||
}
|
||||
|
||||
describe('SavedViews - AuthZ', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('shows the denied callout instead of the list without list', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(SavedViewListPermission));
|
||||
renderPanel();
|
||||
|
||||
await expect(
|
||||
screen.findByText(/not authorized/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-list')).toBeNull();
|
||||
expect(screen.queryByTestId('saved-views-search')).toBeNull();
|
||||
});
|
||||
|
||||
it('disables only the views it cannot read', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
renderPanel();
|
||||
|
||||
await screen.findAllByTestId('saved-views-row');
|
||||
const nameOf = (view: string): HTMLElement =>
|
||||
within(rowNamed(view)).getByTestId('saved-views-row-name');
|
||||
await waitFor(() =>
|
||||
expect(nameOf('Errors')).toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-busy'),
|
||||
);
|
||||
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('keeps the row menu on a view it cannot read', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
renderPanel();
|
||||
|
||||
await openRowMenu('Errors');
|
||||
|
||||
await expect(
|
||||
settledRow('saved-views-row-menu-item-delete'),
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
await expect(
|
||||
settledRow('saved-views-row-menu-item-editDetails'),
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
await expect(
|
||||
settledRow('saved-views-row-menu-item-copyLink'),
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
|
||||
it('disables creating without create', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
server.use(setupAuthzDeny(SavedViewCreatePermission));
|
||||
renderPanel();
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-list-create')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-create')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps create disabled while the check is pending', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.delay('infinite'))),
|
||||
);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
const create = screen.getByTestId('saved-views-create');
|
||||
expect(create).toHaveAttribute('aria-busy', 'true');
|
||||
await userEvent.click(create);
|
||||
expect(screen.queryByTestId('save-view-name')).toBeNull();
|
||||
});
|
||||
|
||||
it('disables edit details without update and delete without delete', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(
|
||||
setupAuthzDeny(
|
||||
buildSavedViewUpdatePermission(errors.id),
|
||||
buildSavedViewDeletePermission(errors.id),
|
||||
),
|
||||
);
|
||||
renderPanel();
|
||||
|
||||
await openRowMenu('Errors');
|
||||
|
||||
await expect(
|
||||
settledRow('saved-views-row-menu-item-editDetails'),
|
||||
).resolves.toHaveAttribute('data-disabled');
|
||||
await expect(
|
||||
settledRow('saved-views-row-menu-item-delete'),
|
||||
).resolves.toHaveAttribute('data-disabled');
|
||||
await expect(
|
||||
settledRow('saved-views-row-menu-item-copyLink'),
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
|
||||
it('disables updating the open view without update, keeping save as new', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewUpdatePermission(errors.id)));
|
||||
const { history } = renderHeader(viewUrl(PATH, errors));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('Errors'),
|
||||
);
|
||||
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
);
|
||||
await userEvent.click(await screen.findByTestId('saved-views-save-changes'));
|
||||
|
||||
await expect(
|
||||
settledRow('saved-views-save-changes-item-update'),
|
||||
).resolves.toHaveAttribute('data-disabled');
|
||||
await expect(
|
||||
settledRow('saved-views-save-changes-item-save-as-new'),
|
||||
).resolves.not.toHaveAttribute('data-disabled');
|
||||
});
|
||||
|
||||
it('says no access when the check denies read, even if the server returns the view', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
'No view access',
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveAttribute(
|
||||
'data-denied-permissions',
|
||||
),
|
||||
);
|
||||
await userEvent.setup().hover(screen.getByTestId('saved-views-name'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryAllByText(/is not authorized to perform read:saved-view:view-1/)
|
||||
.length,
|
||||
).toBeGreaterThan(0),
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a 403 the check allowed as a failed load, showing the server message', async () => {
|
||||
const message = 'saved-view:read is missing, ask an admin for it';
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(
|
||||
rest.get(`${API}/:id`, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(403),
|
||||
ctx.json({ status: 'error', error: { code: 'forbidden', message } }),
|
||||
),
|
||||
),
|
||||
);
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
'View failed to load',
|
||||
),
|
||||
);
|
||||
await userEvent.setup().hover(screen.getByTestId('saved-views-name'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryAllByText(message).length).toBeGreaterThan(0),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,9 @@ import { usePreferenceContext } from 'providers/preferences/context/PreferenceCo
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectRowUsable,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
renderWithExplorerProviders,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
@@ -115,9 +117,11 @@ describe('SavedViewsHeader columns', () => {
|
||||
await userEvent.click(screen.getByTestId('add-column'));
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
|
||||
await userEvent.click(
|
||||
await screen.findByRole('menuitem', { name: 'Update selected view' }),
|
||||
const update = await screen.findByTestId(
|
||||
'saved-views-save-changes-item-update',
|
||||
);
|
||||
await waitFor(() => expectRowUsable(update), MENU_CHECK_TIMEOUT);
|
||||
await userEvent.click(update);
|
||||
await waitFor(() => expect(isDirty()).toBe(false));
|
||||
|
||||
await userEvent.click(screen.getByTestId('add-column'));
|
||||
|
||||
@@ -9,8 +9,11 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import {
|
||||
expectRowUsable,
|
||||
expectUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
@@ -67,9 +70,21 @@ async function openDirtyView(
|
||||
return rendered;
|
||||
}
|
||||
|
||||
const SAVE_MENU_ITEM_TEST_IDS: Record<string, string> = {
|
||||
'Save as new view': 'saved-views-save-changes-item-save-as-new',
|
||||
'Update selected view': 'saved-views-save-changes-item-update',
|
||||
};
|
||||
|
||||
async function chooseFromSaveMenu(item: string): Promise<void> {
|
||||
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
|
||||
await userEvent.click(await screen.findByRole('menuitem', { name: item }));
|
||||
const menuItem = await screen.findByTestId(SAVE_MENU_ITEM_TEST_IDS[item]);
|
||||
await waitFor(() => expectRowUsable(menuItem), MENU_CHECK_TIMEOUT);
|
||||
await userEvent.click(menuItem);
|
||||
}
|
||||
|
||||
async function clickCreate(): Promise<void> {
|
||||
await waitFor(() => expectUsable(screen.getByTestId('saved-views-create')));
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
}
|
||||
|
||||
async function submitName(name: string): Promise<void> {
|
||||
@@ -87,7 +102,7 @@ describe('SavedViewsHeader saving', () => {
|
||||
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
await clickCreate();
|
||||
|
||||
expect(within(modal()).getByText('Create new view')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('save-view-submit')).toHaveAttribute(
|
||||
@@ -130,7 +145,7 @@ describe('SavedViewsHeader saving', () => {
|
||||
const requests = mockSavedViewsApi([], { createStatus: 500 });
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
await clickCreate();
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
@@ -145,7 +160,7 @@ describe('SavedViewsHeader saving', () => {
|
||||
const requests = mockSavedViewsApi([]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
await clickCreate();
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), 'Draft');
|
||||
await userEvent.click(screen.getByTestId('save-view-cancel'));
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import ROUTES from 'constants/routes';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
renderWithExplorerProviders,
|
||||
@@ -84,9 +85,9 @@ describe('SavedViewsPanel list', () => {
|
||||
const { history } = renderPanel();
|
||||
|
||||
await screen.findByTestId('saved-views-list');
|
||||
await userEvent.click(
|
||||
within(row('Slow spans')).getByTestId('saved-views-row-name'),
|
||||
);
|
||||
const name = within(row('Slow spans')).getByTestId('saved-views-row-name');
|
||||
await waitFor(() => expectUsable(name));
|
||||
await userEvent.click(name);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
@@ -161,7 +162,9 @@ describe('SavedViewsPanel list', () => {
|
||||
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
const { history } = renderPanel();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('saved-views-list-create'));
|
||||
const create = await screen.findByTestId('saved-views-list-create');
|
||||
await waitFor(() => expectUsable(create));
|
||||
await userEvent.click(create);
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), 'First view');
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { mockSavedViewsApi } from './savedViewsApiMock';
|
||||
import {
|
||||
expectRowUsable,
|
||||
explorerUrl,
|
||||
makeView,
|
||||
MENU_CHECK_TIMEOUT,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
@@ -58,13 +60,22 @@ const row = (name: string): HTMLElement =>
|
||||
.getAllByTestId('saved-views-row')
|
||||
.find((element) => element.textContent?.includes(name)) as HTMLElement;
|
||||
|
||||
const ROW_MENU_ITEM_TEST_IDS: Record<string, string> = {
|
||||
'Copy link': 'saved-views-row-menu-item-copyLink',
|
||||
'Copy ID': 'saved-views-row-menu-item-copyId',
|
||||
'Edit details': 'saved-views-row-menu-item-editDetails',
|
||||
Delete: 'saved-views-row-menu-item-delete',
|
||||
};
|
||||
|
||||
async function chooseFromRowMenu(
|
||||
viewName: string,
|
||||
item: string,
|
||||
): Promise<void> {
|
||||
await screen.findByTestId('saved-views-list');
|
||||
fireEvent.click(within(row(viewName)).getByTestId('saved-views-row-menu'));
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: item }));
|
||||
const menuItem = await screen.findByTestId(ROW_MENU_ITEM_TEST_IDS[item]);
|
||||
await waitFor(() => expectRowUsable(menuItem), MENU_CHECK_TIMEOUT);
|
||||
fireEvent.click(menuItem);
|
||||
}
|
||||
|
||||
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
|
||||
@@ -76,21 +87,38 @@ describe('SavedViewsPanel row menu', () => {
|
||||
jest.mocked(toast.success).mockClear();
|
||||
});
|
||||
|
||||
it('offers copy link, edit details and delete', async () => {
|
||||
it('offers copy link, copy id, edit details and delete', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderPanelWithHeader();
|
||||
await screen.findByTestId('saved-views-list');
|
||||
|
||||
fireEvent.click(within(row('Errors')).getByTestId('saved-views-row-menu'));
|
||||
|
||||
const items = await screen.findAllByRole('menuitem');
|
||||
expect(items.map((item) => item.textContent)).toStrictEqual([
|
||||
await screen.findByTestId(ROW_MENU_ITEM_TEST_IDS['Copy link']);
|
||||
const items = Object.values(ROW_MENU_ITEM_TEST_IDS).map(
|
||||
(testId) => screen.getByTestId(testId).textContent,
|
||||
);
|
||||
expect(items).toStrictEqual([
|
||||
'Copy link',
|
||||
'Copy ID',
|
||||
'Edit details',
|
||||
'Delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('copies the view id', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
renderPanelWithHeader();
|
||||
|
||||
await chooseFromRowMenu('Slow spans', 'Copy ID');
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(slow.id);
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
'View ID copied',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('copies a link that opens the view with its query and tab', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
renderPanelWithHeader();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
|
||||
@@ -13,6 +14,7 @@ export interface Requests {
|
||||
}
|
||||
|
||||
// Mutates `views` on create, update and delete, so a refetch sees the change.
|
||||
// Grants every permission; a later server.use overrides it.
|
||||
export function mockSavedViewsApi(
|
||||
views: SavedviewtypesSavedViewDTO[],
|
||||
{
|
||||
@@ -30,6 +32,7 @@ export function mockSavedViewsApi(
|
||||
const requests: Requests = { created: [], updated: [], deleted: [] };
|
||||
let failedLists = 0;
|
||||
server.use(
|
||||
setupAuthzAdmin(),
|
||||
rest.get(API, (req, res, ctx) => {
|
||||
if (failedLists < listFailures) {
|
||||
failedLists += 1;
|
||||
|
||||
@@ -251,3 +251,20 @@ export function renderWithExplorerProviders(
|
||||
export function urlParam(history: MemoryHistory, key: string): string | null {
|
||||
return new URLSearchParams(history.location.search).get(key);
|
||||
}
|
||||
|
||||
// The ui Button marks a pending check with aria-busy and a block with
|
||||
// aria-disabled, never the disabled attribute.
|
||||
export function expectUsable(element: HTMLElement): void {
|
||||
expect(element).not.toHaveAttribute('aria-busy');
|
||||
expect(element).not.toHaveAttribute('aria-disabled', 'true');
|
||||
}
|
||||
|
||||
// A menu checks its rows on first open; on a loaded CI runner that outlasts
|
||||
// waitFor's 1s default.
|
||||
export const MENU_CHECK_TIMEOUT = { timeout: 5000 };
|
||||
|
||||
// A menu row waiting on its check is data-loading, not yet data-disabled.
|
||||
export function expectRowUsable(row: HTMLElement): void {
|
||||
expect(row).not.toHaveAttribute('data-loading');
|
||||
expect(row).not.toHaveAttribute('data-disabled');
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Link, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import { Copy, Link, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import {
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
buildSavedViewDeletePermission,
|
||||
buildSavedViewUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SavedViewRowActionConfig, SaveViewModalMode } from './types';
|
||||
@@ -30,6 +34,8 @@ export const MY_VIEW_NAME = 'My view';
|
||||
|
||||
export const SAVED_VIEW_LOAD_FAILED_NAME = 'View failed to load';
|
||||
|
||||
export const SAVED_VIEW_FORBIDDEN_NAME = 'No view access';
|
||||
|
||||
export const SAVED_VIEWS_LIST_LOADING_ROWS = 4;
|
||||
|
||||
export const SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS = 1000;
|
||||
@@ -47,13 +53,20 @@ export const SAVE_VIEW_MODAL_TITLE: Record<SaveViewModalMode, string> = {
|
||||
|
||||
export const SAVED_VIEW_ROW_ACTIONS: SavedViewRowActionConfig[] = [
|
||||
{ key: 'copyLink', label: 'Copy link', icon: Link },
|
||||
{ key: 'editDetails', label: 'Edit details', icon: PenLine },
|
||||
{ key: 'copyId', label: 'Copy ID', icon: Copy },
|
||||
{
|
||||
key: 'editDetails',
|
||||
label: 'Edit details',
|
||||
icon: PenLine,
|
||||
buildPermission: buildSavedViewUpdatePermission,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
icon: Trash2,
|
||||
danger: true,
|
||||
hasDividerBefore: true,
|
||||
buildPermission: buildSavedViewDeletePermission,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { SAVED_VIEW_OPTIONS_DATA_SOURCE } from '../constants';
|
||||
import { UseActiveSavedViewResult } from '../types';
|
||||
@@ -23,11 +26,19 @@ export function useActiveSavedView(
|
||||
dataSource: optionsDataSource ?? DataSource.LOGS,
|
||||
});
|
||||
|
||||
const { data, isLoading, isError } = useGetSavedView(
|
||||
{ id: viewKey },
|
||||
{ query: { enabled: !!viewKey } },
|
||||
const { isLoading: isCheckingRead, deniedPermissions } = useAuthZ(
|
||||
[buildSavedViewReadPermission(viewKey)],
|
||||
{ enabled: !!viewKey },
|
||||
);
|
||||
const view = viewKey ? data?.data : undefined;
|
||||
const isReadDenied = !!viewKey && deniedPermissions.length > 0;
|
||||
|
||||
const canFetch = !!viewKey && !isCheckingRead && !isReadDenied;
|
||||
|
||||
const { data, isLoading, isError, error } = useGetSavedView(
|
||||
{ id: viewKey },
|
||||
{ query: { enabled: canFetch } },
|
||||
);
|
||||
const view = canFetch ? data?.data : undefined;
|
||||
|
||||
// The query builder stages the url query a render later. Until the ids
|
||||
// match, keep the last answer for this view; another view starts clean.
|
||||
@@ -68,10 +79,17 @@ export function useActiveSavedView(
|
||||
lastResult.current = { viewId: view?.id, hasUnsavedChanges };
|
||||
}, [view?.id, hasUnsavedChanges]);
|
||||
|
||||
const errorMessage =
|
||||
viewKey && isError && error
|
||||
? toAPIError(error, '').getErrorMessage() || undefined
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
view,
|
||||
isLoading: !!viewKey && isLoading,
|
||||
isError: !!viewKey && isError,
|
||||
isLoading: !!viewKey && (isCheckingRead || isLoading),
|
||||
isError: !!viewKey && (isError || isReadDenied),
|
||||
isReadDenied,
|
||||
errorMessage,
|
||||
hasUnsavedChanges,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,6 +59,14 @@ export function useSavedViewRowActions(
|
||||
[pathname, copyToClipboard],
|
||||
);
|
||||
|
||||
const copyViewId = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO): void => {
|
||||
copyToClipboard(view.id);
|
||||
toast.success('View ID copied', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
},
|
||||
[copyToClipboard],
|
||||
);
|
||||
|
||||
const renameView = useCallback(
|
||||
async (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
@@ -101,5 +109,12 @@ export function useSavedViewRowActions(
|
||||
[deleteSavedView, source, queryClient],
|
||||
);
|
||||
|
||||
return { copyViewLink, renameView, deleteView, isRenaming, isDeleting };
|
||||
return {
|
||||
copyViewLink,
|
||||
copyViewId,
|
||||
renameView,
|
||||
deleteView,
|
||||
isRenaming,
|
||||
isDeleting,
|
||||
};
|
||||
}
|
||||
|
||||
329
frontend/src/container/SavedViews/stories/SavedViews.stories.mocks.tsx
generated
Normal file
329
frontend/src/container/SavedViews/stories/SavedViews.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { rest, type RequestHandler } from 'msw';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import remove from 'api/browser/localstorage/remove';
|
||||
import {
|
||||
type AuthtypesTransactionDTO,
|
||||
type SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
type SavedviewtypesUpdatableSavedViewDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { SIDEBAR_TOOLTIP_DELAY_MS } from 'components/QuickFilters/QuickFiltersLayout/constants';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { accessFor } from '@/storybook/access/access';
|
||||
import {
|
||||
choiceControl,
|
||||
countControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { MockControlValues, StoryMocks } from '@/storybook/controls/types';
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
import { authzMocks, type AuthzArgs } from '@/storybook/globals/authzMocks';
|
||||
import { respondWith } from '@/storybook/runtime/responseState';
|
||||
|
||||
import {
|
||||
createdSavedView,
|
||||
OPEN_VIEW_ERRORS,
|
||||
OPEN_VIEWS,
|
||||
type OpenView,
|
||||
SAVED_VIEW_SOURCES,
|
||||
SAVED_VIEWS_MAX,
|
||||
savedViewById,
|
||||
savedViewResponse,
|
||||
savedViewRoute,
|
||||
savedViewsListResponse,
|
||||
type SavedViewSource,
|
||||
unreadableViewId,
|
||||
} from './__story_mockdata__/savedViews';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import type { SavedViewRowActionKey } from '../types';
|
||||
|
||||
const API = 'http://localhost/api/v2/saved_views';
|
||||
|
||||
const LIST = 'Saved Views · list';
|
||||
const VIEW = 'Saved Views · open view';
|
||||
|
||||
const controls = {
|
||||
source: choiceControl<SavedViewSource>('Source', {
|
||||
group: VIEW,
|
||||
description: 'Which explorer the views belong to.',
|
||||
options: SAVED_VIEW_SOURCES,
|
||||
value: SavedviewtypesSourceDTO.traces,
|
||||
}),
|
||||
openView: choiceControl<OpenView>('Open view', {
|
||||
group: VIEW,
|
||||
description:
|
||||
'The `viewKey` the explorer url carries. `unsaved-changes` stages a query that differs from the view, which swaps in the ✓ save menu; `forbidden`, `failed` and `not-found` are the 403, 500 and 404 the view fetch answers with; each shows View failed to load.',
|
||||
options: OPEN_VIEWS,
|
||||
value: 'none',
|
||||
}),
|
||||
unreadableView: toggleControl('Unreadable view', {
|
||||
group: VIEW,
|
||||
description:
|
||||
'Denies `read` on the first view created by others alone, on top of the Access controls: its row greys out, and with Open view on `created-by-others` the header shows No view access.',
|
||||
value: false,
|
||||
}),
|
||||
createdByMe: countControl('Created by me', {
|
||||
group: LIST,
|
||||
value: 3,
|
||||
max: SAVED_VIEWS_MAX,
|
||||
}),
|
||||
createdByOthers: countControl('Created by others', {
|
||||
group: LIST,
|
||||
description: 'Both at 0 is the empty state, with its Create view button.',
|
||||
value: 4,
|
||||
max: SAVED_VIEWS_MAX,
|
||||
}),
|
||||
};
|
||||
|
||||
type SavedViewsValues = MockControlValues<typeof controls>;
|
||||
|
||||
type WrittenView = Pick<SavedviewtypesSavedViewDTO, 'spec' | 'source'>;
|
||||
|
||||
const viewDenied = (
|
||||
transaction: AuthtypesTransactionDTO,
|
||||
deniedId: string,
|
||||
): boolean =>
|
||||
transaction.relation === 'read' &&
|
||||
transaction.object.resource.kind === 'saved-view' &&
|
||||
transaction.object.selector === deniedId;
|
||||
|
||||
const definition = defineStoryMocks({
|
||||
controls,
|
||||
handlers: (
|
||||
values: SavedViewsValues & AuthzArgs,
|
||||
response,
|
||||
): RequestHandler[] => {
|
||||
const { source, openView, createdByMe, createdByOthers } = values;
|
||||
|
||||
// What the panel writes stays in this handler set until a control changes.
|
||||
const created: SavedviewtypesSavedViewDTO[] = [];
|
||||
const updated = new Map<string, WrittenView>();
|
||||
const deleted = new Set<string>();
|
||||
|
||||
const current = (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): SavedviewtypesSavedViewDTO => ({ ...view, ...updated.get(view.id) });
|
||||
|
||||
const findView = (id: string): SavedviewtypesSavedViewDTO | undefined => {
|
||||
const view = created.find((item) => item.id === id) ?? savedViewById(id);
|
||||
|
||||
return view && !deleted.has(id) ? current(view) : undefined;
|
||||
};
|
||||
|
||||
const openError = OPEN_VIEW_ERRORS[openView];
|
||||
|
||||
return [
|
||||
rest.post(API, async (req, res, ctx) => {
|
||||
const body = (await req.json()) as SavedviewtypesUpdatableSavedViewDTO;
|
||||
const id = `storybook-${source}-created-${created.length + 1}`;
|
||||
|
||||
created.push(createdSavedView(source, id, body.spec));
|
||||
|
||||
return res(ctx.status(201), ctx.json({ status: 'success', data: { id } }));
|
||||
}),
|
||||
|
||||
rest.get(
|
||||
API,
|
||||
response.json(() => {
|
||||
const list = savedViewsListResponse(source, createdByMe, createdByOthers);
|
||||
|
||||
return {
|
||||
...list,
|
||||
data: [...(list.data ?? []), ...created]
|
||||
.filter((view) => !deleted.has(view.id))
|
||||
.map(current),
|
||||
};
|
||||
}),
|
||||
),
|
||||
|
||||
rest.get(`${API}/:id`, (req, res, ctx) => {
|
||||
if (openError) {
|
||||
return res(ctx.status(openError.status), ctx.json(openError.body));
|
||||
}
|
||||
|
||||
const view = findView(String(req.params.id));
|
||||
|
||||
if (!view) {
|
||||
return res(ctx.status(404), ctx.json(OPEN_VIEW_ERRORS['not-found']?.body));
|
||||
}
|
||||
|
||||
return response.json(() => savedViewResponse(view))(req, res, ctx);
|
||||
}),
|
||||
|
||||
rest.put(`${API}/:id`, async (req, res, ctx) => {
|
||||
const body = (await req.json()) as SavedviewtypesUpdatableSavedViewDTO;
|
||||
|
||||
updated.set(String(req.params.id), {
|
||||
spec: body.spec,
|
||||
source: body.source,
|
||||
});
|
||||
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
|
||||
rest.delete(`${API}/:id`, (req, res, ctx) => {
|
||||
deleted.add(String(req.params.id));
|
||||
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
|
||||
...(values.unreadableView
|
||||
? [
|
||||
rest.post(
|
||||
AUTHZ_CHECK_URL,
|
||||
respondWith(values.authzState, async (req) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
const granted = accessFor(
|
||||
values.access,
|
||||
values.permissions,
|
||||
values.revoked,
|
||||
);
|
||||
const deniedId = unreadableViewId(source);
|
||||
|
||||
return authzMockResponse(
|
||||
payload,
|
||||
payload.map(
|
||||
(transaction) =>
|
||||
!viewDenied(transaction, deniedId) && granted.allows(transaction),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
config: ({ source, openView }) => ({
|
||||
route: savedViewRoute(source, openView),
|
||||
}),
|
||||
effect: () => {
|
||||
remove(LOCALSTORAGE.LAST_USED_SAVED_VIEWS);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The per-view read denial answers `authz/check` in place of the global access
|
||||
* mocks, so it reads their controls as well as its own to answer every other
|
||||
* check the way they would.
|
||||
*/
|
||||
export const savedViewsMocks: StoryMocks<typeof controls> = {
|
||||
...definition,
|
||||
read: (args) => ({ ...authzMocks.read(args), ...definition.read(args) }),
|
||||
};
|
||||
|
||||
/** The sidebar slot of an explorer without quick filters. */
|
||||
export const sidebarCanvas = withCanvas({
|
||||
width: 280,
|
||||
height: 640,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRight: '1px solid var(--l1-border)',
|
||||
});
|
||||
|
||||
/**
|
||||
* The header above the pinned list. `onOpenViews` adds All views, which the
|
||||
* header only shows in an explorer whose quick filters hide the list.
|
||||
*/
|
||||
export function SavedViewsSidebar({
|
||||
source,
|
||||
onOpenViews,
|
||||
}: {
|
||||
source: SavedViewSource;
|
||||
onOpenViews?: () => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<TooltipProvider delay={SIDEBAR_TOOLTIP_DELAY_MS}>
|
||||
<SavedViewsHeader source={source} onOpenViews={onOpenViews} />
|
||||
<SavedViewsPanel source={source} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** The panel fetches the list and checks every row's permissions before it settles. */
|
||||
export const UNTIL_LOADED = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* Re-queried on every attempt: a button whose check settles is a new node, so a
|
||||
* reference taken while it was pending never changes.
|
||||
*/
|
||||
type FindElement = () => HTMLElement;
|
||||
|
||||
export const byTestId =
|
||||
(testId: string): FindElement =>
|
||||
(): HTMLElement =>
|
||||
screen.getByTestId(testId);
|
||||
|
||||
/** A check still in flight: `aria-busy` on a button, `data-loading` on a menu row. */
|
||||
const isPending = (element: HTMLElement): boolean =>
|
||||
element.getAttribute('aria-busy') === 'true' ||
|
||||
element.hasAttribute('data-loading');
|
||||
|
||||
const isDisabled = (element: HTMLElement): boolean =>
|
||||
element.hasAttribute('disabled') || element.hasAttribute('data-disabled');
|
||||
|
||||
export const expectDisabled = (
|
||||
find: FindElement,
|
||||
disabled = true,
|
||||
): Promise<void> =>
|
||||
waitFor(async () => {
|
||||
const element = find();
|
||||
|
||||
await expect(isPending(element)).toBe(false);
|
||||
await expect(isDisabled(element)).toBe(disabled);
|
||||
}, UNTIL_LOADED);
|
||||
|
||||
export const expectPending = (find: FindElement): Promise<void> =>
|
||||
waitFor(() => expect(isPending(find())).toBe(true), UNTIL_LOADED);
|
||||
|
||||
export const findRows = (canvasElement: HTMLElement): Promise<HTMLElement[]> =>
|
||||
within(canvasElement).findAllByTestId(
|
||||
'saved-views-row',
|
||||
undefined,
|
||||
UNTIL_LOADED,
|
||||
);
|
||||
|
||||
export const rowName =
|
||||
(row: HTMLElement): FindElement =>
|
||||
(): HTMLElement =>
|
||||
within(row).getByTestId('saved-views-row-name');
|
||||
|
||||
export const openRowMenu = async (row: HTMLElement): Promise<void> => {
|
||||
await userEvent.click(within(row).getByTestId('saved-views-row-menu'));
|
||||
await screen.findByRole('menu', undefined, UNTIL_LOADED);
|
||||
};
|
||||
|
||||
export const rowMenuItem = (key: SavedViewRowActionKey): FindElement =>
|
||||
byTestId(`saved-views-row-menu-item-${key}`);
|
||||
|
||||
export const openSaveChangesMenu = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
const trigger = await waitFor(async () => {
|
||||
const button = within(canvasElement).getByTestId('saved-views-save-changes');
|
||||
|
||||
await expect(button).not.toHaveAttribute('aria-busy');
|
||||
|
||||
return button;
|
||||
}, UNTIL_LOADED);
|
||||
|
||||
await userEvent.click(trigger);
|
||||
await screen.findByRole('menu', undefined, UNTIL_LOADED);
|
||||
};
|
||||
|
||||
export const saveChangesItem = (key: 'save-as-new' | 'update'): FindElement =>
|
||||
byTestId(`saved-views-save-changes-item-${key}`);
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import {
|
||||
findRows,
|
||||
openRowMenu,
|
||||
openSaveChangesMenu,
|
||||
SavedViewsSidebar,
|
||||
savedViewsMocks,
|
||||
sidebarCanvas,
|
||||
} from './SavedViews.stories.mocks';
|
||||
|
||||
type SavedViewsArgs = PageStoryArgs<typeof savedViewsMocks>;
|
||||
|
||||
const componentStory = storyMocks(savedViewsMocks);
|
||||
|
||||
/**
|
||||
* The saved views sidebar of an explorer without quick filters: the header with
|
||||
* the open view and its actions, above the list of views created by the user
|
||||
* and by others. Every action is gated on the `saved-view` authz permissions
|
||||
* (list, create, and read, update and delete per view).
|
||||
*
|
||||
* Route: the explorer of the Source control, with the Open view control's
|
||||
* `viewKey`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Components/Saved Views/Overview',
|
||||
tags: ['authz', 'play'],
|
||||
component: SavedViewsPanel,
|
||||
decorators: [sidebarCanvas],
|
||||
render: ({ source }): JSX.Element => <SavedViewsSidebar source={source} />,
|
||||
...componentStory,
|
||||
parameters: { ...componentStory.parameters },
|
||||
} satisfies Meta<SavedViewsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<SavedViewsArgs>;
|
||||
|
||||
/** Traces views created by the user and by others, with no view open. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A workspace with no views yet: the empty state offers Create view. */
|
||||
export const Empty: Story = {
|
||||
args: { createdByMe: 0, createdByOthers: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* A view open with a query that differs from what it saved: the name carries
|
||||
* the unsaved dot, and ✓ and discard replace Clear view.
|
||||
*/
|
||||
export const UnsavedChanges: Story = {
|
||||
args: { openView: 'unsaved-changes' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The first row's menu. Edit details and Delete follow `update` and `delete` on
|
||||
* that view, so revoking either disables its item.
|
||||
*/
|
||||
export const RowMenu: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await openRowMenu(row);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The ✓ menu on a view with unsaved changes. Save as new view follows `create`
|
||||
* and Update selected view follows `update` on the open view.
|
||||
*/
|
||||
export const SaveChangesMenu: Story = {
|
||||
args: { openView: 'unsaved-changes' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openSaveChangesMenu(canvasElement);
|
||||
},
|
||||
};
|
||||
309
frontend/src/container/SavedViews/stories/__story_mockdata__/savedViews.ts
generated
Normal file
309
frontend/src/container/SavedViews/stories/__story_mockdata__/savedViews.ts
generated
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import type {
|
||||
GetSavedView200,
|
||||
ListSavedViews200,
|
||||
RenderErrorResponseDTO,
|
||||
SavedviewtypesSavedViewDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryMeterWithType,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
|
||||
import { toSavedViewSpec } from 'container/SavedViews/utils/toSavedViewSpec';
|
||||
import { createAppContextMock } from 'tests/fixtures/appContextMock';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
export const SAVED_VIEW_SOURCES = [
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
SavedviewtypesSourceDTO.logs,
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
SavedviewtypesSourceDTO.meter,
|
||||
] as const;
|
||||
|
||||
export type SavedViewSource = (typeof SAVED_VIEW_SOURCES)[number];
|
||||
|
||||
export const OPEN_VIEWS = [
|
||||
'none',
|
||||
'created-by-me',
|
||||
'created-by-others',
|
||||
'unsaved-changes',
|
||||
'forbidden',
|
||||
'failed',
|
||||
'not-found',
|
||||
] as const;
|
||||
|
||||
export type OpenView = (typeof OPEN_VIEWS)[number];
|
||||
|
||||
export const SAVED_VIEWS_MAX = 8;
|
||||
|
||||
/** The user the story app context signs in as, so "Created by me" follows it. */
|
||||
const STORY_USER_EMAIL = createAppContextMock(USER_ROLES.ADMIN).user.email;
|
||||
|
||||
const OTHER_AUTHORS = ['priya@signoz.io', 'marco@signoz.io', 'lena@signoz.io'];
|
||||
|
||||
const MY_VIEW_NAMES = [
|
||||
'Checkout errors',
|
||||
'Slow payment calls',
|
||||
'Cart service, production',
|
||||
'Auth retries',
|
||||
'Inventory sync',
|
||||
'Search p99 regressions',
|
||||
'Webhook failures',
|
||||
'Cold starts after deploy',
|
||||
];
|
||||
|
||||
const OTHER_VIEW_NAMES = [
|
||||
'On-call triage',
|
||||
'Frontend 5xx',
|
||||
'Shipping latency by region',
|
||||
'Kafka consumer lag',
|
||||
'Staging smoke tests',
|
||||
'Recommendation timeouts',
|
||||
'Ad service sampling',
|
||||
'Email queue backlog',
|
||||
];
|
||||
|
||||
const EXPRESSIONS = [
|
||||
"service.name = 'checkout' AND has_error = true",
|
||||
"service.name = 'payments'",
|
||||
"service.name = 'cart' AND deployment.environment = 'production'",
|
||||
"service.name = 'auth'",
|
||||
"service.name IN ('inventory', 'catalog')",
|
||||
"service.name = 'search'",
|
||||
"service.name = 'webhooks' AND has_error = true",
|
||||
"deployment.environment = 'production'",
|
||||
];
|
||||
|
||||
const BASE_QUERY: Record<SavedViewSource, Query> = {
|
||||
traces: initialQueriesMap.traces,
|
||||
logs: initialQueriesMap.logs,
|
||||
metrics: initialQueriesMap.metrics,
|
||||
meter: initialQueryMeterWithType,
|
||||
};
|
||||
|
||||
const EXPLORER_ROUTE: Record<SavedViewSource, string> = {
|
||||
traces: ROUTES.TRACES_EXPLORER,
|
||||
logs: ROUTES.LOGS_EXPLORER,
|
||||
metrics: ROUTES.METRICS_EXPLORER_EXPLORER,
|
||||
meter: ROUTES.METER_EXPLORER,
|
||||
};
|
||||
|
||||
const queryWith = (source: SavedViewSource, expression: string): Query => {
|
||||
const base = BASE_QUERY[source];
|
||||
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
{
|
||||
...base.builder.queryData[0],
|
||||
filter: { expression },
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query;
|
||||
};
|
||||
|
||||
type Author = 'mine' | 'others';
|
||||
|
||||
const viewId = (
|
||||
source: SavedViewSource,
|
||||
author: Author,
|
||||
index: number,
|
||||
): string => `storybook-${source}-${author}-${index + 1}`;
|
||||
|
||||
/** The view other people made that the per-view read denial applies to. */
|
||||
export const unreadableViewId = (source: SavedViewSource): string =>
|
||||
viewId(source, 'others', 0);
|
||||
|
||||
const savedView = (
|
||||
source: SavedViewSource,
|
||||
author: Author,
|
||||
index: number,
|
||||
): SavedviewtypesSavedViewDTO => {
|
||||
const displayName = (author === 'mine' ? MY_VIEW_NAMES : OTHER_VIEW_NAMES)[
|
||||
index
|
||||
];
|
||||
const createdBy =
|
||||
author === 'mine'
|
||||
? STORY_USER_EMAIL
|
||||
: OTHER_AUTHORS[index % OTHER_AUTHORS.length];
|
||||
const id = viewId(source, author, index);
|
||||
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
source,
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
|
||||
createdAt: '2026-09-02T08:30:00Z',
|
||||
createdBy,
|
||||
updatedAt: `2026-10-${String(14 - (index % 10)).padStart(2, '0')}T11:42:00Z`,
|
||||
updatedBy: createdBy,
|
||||
spec: toSavedViewSpec({
|
||||
query: queryWith(
|
||||
source,
|
||||
EXPRESSIONS[(index + (author === 'mine' ? 0 : 3)) % EXPRESSIONS.length],
|
||||
),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const savedViews = (
|
||||
source: SavedViewSource,
|
||||
author: Author,
|
||||
count: number,
|
||||
): SavedviewtypesSavedViewDTO[] =>
|
||||
Array.from({ length: Math.min(count, SAVED_VIEWS_MAX) }, (_, index) =>
|
||||
savedView(source, author, index),
|
||||
);
|
||||
|
||||
export const savedViewsListResponse = (
|
||||
source: SavedViewSource,
|
||||
createdByMe: number,
|
||||
createdByOthers: number,
|
||||
): ListSavedViews200 => ({
|
||||
status: 'success',
|
||||
data: [
|
||||
...savedViews(source, 'mine', createdByMe),
|
||||
...savedViews(source, 'others', createdByOthers),
|
||||
],
|
||||
});
|
||||
|
||||
/** Every view the workspace has, whatever the list control trims it to. */
|
||||
export const savedViewById = (
|
||||
id: string,
|
||||
): SavedviewtypesSavedViewDTO | undefined =>
|
||||
SAVED_VIEW_SOURCES.flatMap((source) => [
|
||||
...savedViews(source, 'mine', SAVED_VIEWS_MAX),
|
||||
...savedViews(source, 'others', SAVED_VIEWS_MAX),
|
||||
]).find((view) => view.id === id);
|
||||
|
||||
/** A view the panel itself created, owned by the story user. */
|
||||
export const createdSavedView = (
|
||||
source: SavedViewSource,
|
||||
id: string,
|
||||
spec: SavedviewtypesSavedViewDTO['spec'],
|
||||
): SavedviewtypesSavedViewDTO => ({
|
||||
...savedView(source, 'mine', 0),
|
||||
id,
|
||||
name: id,
|
||||
createdAt: '2026-10-16T09:00:00Z',
|
||||
updatedAt: '2026-10-16T09:00:00Z',
|
||||
spec,
|
||||
});
|
||||
|
||||
export const savedViewResponse = (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): GetSavedView200 => ({ status: 'success', data: view });
|
||||
|
||||
export const OPEN_VIEW_ERRORS: Partial<
|
||||
Record<OpenView, { status: number; body: RenderErrorResponseDTO }>
|
||||
> = {
|
||||
forbidden: {
|
||||
status: 403,
|
||||
body: {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'forbidden',
|
||||
message: 'You do not have access to this saved view',
|
||||
errors: [],
|
||||
suggestions: [],
|
||||
type: 'forbidden',
|
||||
},
|
||||
},
|
||||
},
|
||||
failed: {
|
||||
status: 500,
|
||||
body: {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: 'Failed to load the saved view',
|
||||
errors: [],
|
||||
suggestions: [],
|
||||
type: 'internal',
|
||||
},
|
||||
},
|
||||
},
|
||||
'not-found': {
|
||||
status: 404,
|
||||
body: {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'not_found',
|
||||
message: 'Saved view not found',
|
||||
errors: [],
|
||||
suggestions: [],
|
||||
type: 'not_found',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const openViewId = (source: SavedViewSource, open: OpenView): string | null => {
|
||||
if (open === 'none') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (open === 'created-by-others' || open === 'forbidden') {
|
||||
return viewId(source, 'others', 0);
|
||||
}
|
||||
|
||||
if (open === 'not-found') {
|
||||
return `storybook-${source}-deleted`;
|
||||
}
|
||||
|
||||
return viewId(source, 'mine', 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* The explorer url with a view open, its params written the way
|
||||
* `redirectWithQueryBuilderData` writes them. `panelTypes` is left out: the
|
||||
* query builder seeds its panel type from the raw, JSON-quoted param, which no
|
||||
* saved panel type equals, and without it the header compares against `list`.
|
||||
*/
|
||||
export const savedViewRoute = (
|
||||
source: SavedViewSource,
|
||||
open: OpenView,
|
||||
): string => {
|
||||
const params = new URLSearchParams({ relativeTime: '15m' });
|
||||
const id = openViewId(source, open);
|
||||
|
||||
if (id) {
|
||||
const view = savedViewById(id);
|
||||
const query = view
|
||||
? getSavedViewQuery(view)
|
||||
: queryWith(source, EXPRESSIONS[0]);
|
||||
const staged =
|
||||
open === 'unsaved-changes'
|
||||
? queryWith(source, `${EXPRESSIONS[0]} AND duration_nano > 500000000`)
|
||||
: query;
|
||||
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(
|
||||
JSON.stringify({ ...staged, id: `storybook-staged-${open}` }),
|
||||
),
|
||||
);
|
||||
params.set(QueryParams.viewKey, JSON.stringify(id));
|
||||
}
|
||||
|
||||
return `${EXPLORER_ROUTE[source]}?${params.toString()}`;
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { unreadableViewId } from '../__story_mockdata__/savedViews';
|
||||
import {
|
||||
byTestId,
|
||||
expectDisabled,
|
||||
expectPending,
|
||||
findRows,
|
||||
openRowMenu,
|
||||
openSaveChangesMenu,
|
||||
rowMenuItem,
|
||||
rowName,
|
||||
saveChangesItem,
|
||||
SavedViewsSidebar,
|
||||
savedViewsMocks,
|
||||
sidebarCanvas,
|
||||
UNTIL_LOADED,
|
||||
} from '../SavedViews.stories.mocks';
|
||||
|
||||
import SavedViewsPanel from '../../SavedViewsPanel';
|
||||
|
||||
type SavedViewsArgs = PageStoryArgs<typeof savedViewsMocks>;
|
||||
|
||||
const componentStory = storyMocks(savedViewsMocks);
|
||||
|
||||
/**
|
||||
* The saved views sidebar with one permission missing at a time. Every story
|
||||
* here is an admin denied exactly what its name says. `list` and `create` are
|
||||
* workspace-wide; `read`, `update` and `delete` are checked per view, so a row
|
||||
* menu only checks its view once it is opened.
|
||||
*
|
||||
* Route: the explorer of the Source control, with the Open view control's
|
||||
* `viewKey`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Components/Saved Views/Authz',
|
||||
tags: ['authz', 'play'],
|
||||
component: SavedViewsPanel,
|
||||
decorators: [sidebarCanvas],
|
||||
render: ({ source }): JSX.Element => <SavedViewsSidebar source={source} />,
|
||||
...componentStory,
|
||||
parameters: { ...componentStory.parameters },
|
||||
} satisfies Meta<SavedViewsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<SavedViewsArgs>;
|
||||
|
||||
const LIST = 'list:saved-view';
|
||||
const CREATE = 'create:saved-view';
|
||||
const UPDATE = 'update:saved-view';
|
||||
const DELETE = 'delete:saved-view';
|
||||
|
||||
/**
|
||||
* The panel shows the permission callout in place of the list, and All views,
|
||||
* in an explorer whose quick filters hide the list, is disabled.
|
||||
*/
|
||||
export const NoList: Story = {
|
||||
args: { revoked: [LIST] },
|
||||
render: ({ source }): JSX.Element => (
|
||||
<SavedViewsSidebar source={source} onOpenViews={(): void => {}} />
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expectDisabled(byTestId('saved-views-open'));
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(canvas.getByTestId('saved-views-panel')).toHaveTextContent(
|
||||
/is not authorized to perform list/,
|
||||
),
|
||||
UNTIL_LOADED,
|
||||
);
|
||||
await expect(canvas.queryByTestId('saved-views-search')).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
/** An empty workspace with both ways to create a view disabled: + and Create view. */
|
||||
export const NoCreate: Story = {
|
||||
args: { revoked: [CREATE], createdByMe: 0, createdByOthers: 0 },
|
||||
play: async () => {
|
||||
await expectDisabled(byTestId('saved-views-create'));
|
||||
await expectDisabled(byTestId('saved-views-list-create'));
|
||||
},
|
||||
};
|
||||
|
||||
/** The ✓ menu on unsaved changes: Save as new view disabled, Update selected view open. */
|
||||
export const NoCreateSaveMenu: Story = {
|
||||
args: { revoked: [CREATE], openView: 'unsaved-changes' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSaveChangesMenu(canvasElement);
|
||||
await expectDisabled(saveChangesItem('save-as-new'));
|
||||
await expectDisabled(saveChangesItem('update'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `read` denied on one view made by someone else: its name is disabled and its
|
||||
* hover card never opens, while every other view still opens.
|
||||
*/
|
||||
export const NoRead: Story = {
|
||||
args: { unreadableView: true },
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const rows = await findRows(canvasElement);
|
||||
const deniedId = unreadableViewId(args.source);
|
||||
|
||||
await Promise.all(
|
||||
rows.map((row) =>
|
||||
expectDisabled(rowName(row), row.getAttribute('data-view-id') === deniedId),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The unreadable view open from its link: the header names it No view access. */
|
||||
export const NoReadOpenView: Story = {
|
||||
args: { unreadableView: true, openView: 'created-by-others' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(
|
||||
await within(canvasElement).findByText(
|
||||
'No view access',
|
||||
undefined,
|
||||
UNTIL_LOADED,
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** A row's menu: Edit details disabled; Copy link, Copy ID and Delete open. */
|
||||
export const NoUpdate: Story = {
|
||||
args: { revoked: [UPDATE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await openRowMenu(row);
|
||||
await expectDisabled(rowMenuItem('editDetails'));
|
||||
await expectDisabled(rowMenuItem('copyLink'), false);
|
||||
await expectDisabled(rowMenuItem('copyId'), false);
|
||||
await expectDisabled(rowMenuItem('delete'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/** The ✓ menu on unsaved changes: Update selected view disabled, Save as new view open. */
|
||||
export const NoUpdateSaveMenu: Story = {
|
||||
args: { revoked: [UPDATE], openView: 'unsaved-changes' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSaveChangesMenu(canvasElement);
|
||||
await expectDisabled(saveChangesItem('update'));
|
||||
await expectDisabled(saveChangesItem('save-as-new'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/** A row's menu: Delete disabled, Edit details still open. */
|
||||
export const NoDelete: Story = {
|
||||
args: { revoked: [DELETE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await openRowMenu(row);
|
||||
await expectDisabled(rowMenuItem('delete'));
|
||||
await expectDisabled(rowMenuItem('editDetails'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Everything but listing and reading: + is disabled, and a row's menu has only
|
||||
* Copy link and Copy ID left.
|
||||
*/
|
||||
export const ReadOnly: Story = {
|
||||
args: { revoked: [CREATE, UPDATE, DELETE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expectDisabled(byTestId('saved-views-create'));
|
||||
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await expectDisabled(rowName(row), false);
|
||||
await openRowMenu(row);
|
||||
await expectDisabled(rowMenuItem('editDetails'));
|
||||
await expectDisabled(rowMenuItem('delete'));
|
||||
await expectDisabled(rowMenuItem('copyLink'), false);
|
||||
await expectDisabled(rowMenuItem('copyId'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The permission check never answering: + spins, and the list stays blank
|
||||
* behind its `list` check.
|
||||
*/
|
||||
export const CheckLoading: Story = {
|
||||
args: { authzState: 'loading' },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expectPending(byTestId('saved-views-create'));
|
||||
await expect(canvas.queryByTestId('saved-views-search')).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The permission check failing rather than denying: the sidebar fails open,
|
||||
* so the list renders and every action stays enabled for the API to refuse.
|
||||
*/
|
||||
export const CheckFailed: Story = {
|
||||
args: { authzState: 'error' },
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expectDisabled(byTestId('saved-views-create'), false);
|
||||
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await expectDisabled(rowName(row), false);
|
||||
await openRowMenu(row);
|
||||
await expectDisabled(rowMenuItem('editDetails'), false);
|
||||
await expectDisabled(rowMenuItem('delete'), false);
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactElement, ReactNode } from 'react';
|
||||
import type { Link } from '@signozhq/icons';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
@@ -41,6 +42,8 @@ export interface UseActiveSavedViewResult {
|
||||
view: SavedviewtypesSavedViewDTO | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isReadDenied: boolean;
|
||||
errorMessage: string | undefined;
|
||||
hasUnsavedChanges: boolean;
|
||||
}
|
||||
|
||||
@@ -70,6 +73,7 @@ export interface SaveViewModalProps {
|
||||
}
|
||||
|
||||
export interface SaveChangesMenuProps {
|
||||
viewId: string;
|
||||
disabled: boolean;
|
||||
onSaveAsNew: () => void;
|
||||
onUpdate: () => void;
|
||||
@@ -82,6 +86,7 @@ export interface SavedViewsIconButtonProps {
|
||||
color?: 'secondary' | 'warning';
|
||||
disabled?: boolean;
|
||||
disabledTooltip?: ReactNode;
|
||||
checks?: BrandedPermission[];
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
@@ -116,7 +121,11 @@ export interface SavedViewsListRowProps {
|
||||
hover: SavedViewsListRowHoverProps;
|
||||
}
|
||||
|
||||
export type SavedViewRowActionKey = 'copyLink' | 'editDetails' | 'delete';
|
||||
export type SavedViewRowActionKey =
|
||||
| 'copyLink'
|
||||
| 'copyId'
|
||||
| 'editDetails'
|
||||
| 'delete';
|
||||
|
||||
export interface SavedViewRowActionConfig {
|
||||
key: SavedViewRowActionKey;
|
||||
@@ -124,10 +133,11 @@ export interface SavedViewRowActionConfig {
|
||||
icon: typeof Link;
|
||||
danger?: boolean;
|
||||
hasDividerBefore?: boolean;
|
||||
buildPermission?: (id: string) => BrandedPermission;
|
||||
}
|
||||
|
||||
export interface PendingSavedViewAction {
|
||||
key: Exclude<SavedViewRowActionKey, 'copyLink'>;
|
||||
key: Exclude<SavedViewRowActionKey, 'copyLink' | 'copyId'>;
|
||||
view: SavedviewtypesSavedViewDTO;
|
||||
}
|
||||
|
||||
@@ -140,6 +150,7 @@ export interface SavedViewsRowButtonProps {
|
||||
}
|
||||
|
||||
export interface SavedViewsRowMenuProps {
|
||||
viewId: string;
|
||||
onAction: (key: SavedViewRowActionKey) => void;
|
||||
onOpen: () => void;
|
||||
}
|
||||
@@ -152,6 +163,7 @@ export interface DeleteSavedViewDialogProps {
|
||||
|
||||
export interface UseSavedViewRowActionsResult {
|
||||
copyViewLink: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
copyViewId: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
renameView: (
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
displayName: string,
|
||||
|
||||
61
frontend/src/hooks/logs/__tests__/useActiveLog.test.ts
Normal file
61
frontend/src/hooks/logs/__tests__/useActiveLog.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { useActiveLog } from '../useActiveLog';
|
||||
|
||||
const mockRedirect = jest.fn();
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => ({
|
||||
currentQuery: initialQueriesMap.logs,
|
||||
redirectWithQueryBuilderData: mockRedirect,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): URLSearchParams => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
describe('useActiveLog', () => {
|
||||
beforeEach(() => {
|
||||
mockRedirect.mockClear();
|
||||
});
|
||||
|
||||
it('adds the chip field as a filter with its data type and redirects', () => {
|
||||
const { result } = renderHook(() => useActiveLog());
|
||||
|
||||
result.current.onAddToQuery(
|
||||
'service.name',
|
||||
'frontend',
|
||||
'=',
|
||||
DataTypes.String,
|
||||
);
|
||||
|
||||
expect(mockRedirect).toHaveBeenCalledTimes(1);
|
||||
const query = mockRedirect.mock.calls[0][0] as Query;
|
||||
const item = query.builder.queryData[0].filters?.items.at(-1);
|
||||
expect(item?.key).toMatchObject({
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
});
|
||||
expect(item?.op).toBe('=');
|
||||
expect(item?.value).toBe('frontend');
|
||||
});
|
||||
|
||||
it('keeps the existing filter items ahead of the new one', () => {
|
||||
const { result } = renderHook(() => useActiveLog());
|
||||
|
||||
result.current.onAddToQuery('k8s.pod.name', 'pod-1', '=');
|
||||
|
||||
const query = mockRedirect.mock.calls[0][0] as Query;
|
||||
const items = query.builder.queryData[0].filters?.items ?? [];
|
||||
expect(items).toHaveLength(
|
||||
(initialQueriesMap.logs.builder.queryData[0].filters?.items.length ?? 0) + 1,
|
||||
);
|
||||
expect(items.at(-1)?.key?.key).toBe('k8s.pod.name');
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,6 @@ jest.mock('hooks/useIsTextSelected');
|
||||
const mockOnSetActiveLog = jest.fn();
|
||||
const mockOnClearActiveLog = jest.fn();
|
||||
const mockOnAddToQuery = jest.fn();
|
||||
const mockOnGroupByAttribute = jest.fn();
|
||||
const mockIsTextSelected = jest.fn();
|
||||
|
||||
const mockLog: ILog = {
|
||||
@@ -44,7 +43,6 @@ beforeEach(() => {
|
||||
onSetActiveLog: mockOnSetActiveLog,
|
||||
onClearActiveLog: mockOnClearActiveLog,
|
||||
onAddToQuery: mockOnAddToQuery,
|
||||
onGroupByAttribute: mockOnGroupByAttribute,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,7 +78,6 @@ it('should toggle off when clicking the same active log', () => {
|
||||
onSetActiveLog: mockOnSetActiveLog,
|
||||
onClearActiveLog: mockOnClearActiveLog,
|
||||
onAddToQuery: mockOnAddToQuery,
|
||||
onGroupByAttribute: mockOnGroupByAttribute,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useLogDetailHandlers());
|
||||
|
||||
@@ -25,5 +25,4 @@ export type UseActiveLog = {
|
||||
operator: string,
|
||||
dataType?: DataTypes,
|
||||
) => void;
|
||||
onGroupByAttribute: (fieldKey: string, dataType?: DataTypes) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -1,40 +1,16 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
|
||||
import { getFilterQueryData } from 'container/LogDetailedView/logAttributeActions.utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { UseActiveLog } from './types';
|
||||
|
||||
export function getOldLogsOperatorFromNew(operator: string): string {
|
||||
switch (operator) {
|
||||
case OPERATORS['=']:
|
||||
return OPERATORS.IN;
|
||||
case OPERATORS['!=']:
|
||||
return OPERATORS.NIN;
|
||||
default:
|
||||
return operator;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export const useActiveLog = (): UseActiveLog => {
|
||||
const queryClient = useQueryClient();
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const [activeLog, setActiveLog] = useState<ILog | null>(null);
|
||||
|
||||
@@ -58,121 +34,32 @@ export const useActiveLog = (): UseActiveLog => {
|
||||
|
||||
const onClearActiveLog = useCallback((): void => setActiveLog(null), []);
|
||||
|
||||
const onAddToQueryExplorer = useCallback(
|
||||
async (
|
||||
const onAddToQuery = useCallback(
|
||||
(
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
operator: string,
|
||||
dataType?: DataTypes,
|
||||
fieldType?: MetricsType | undefined,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const keysAutocompleteResponse = await queryClient.fetchQuery(
|
||||
[QueryBuilderKeys.GET_AGGREGATE_KEYS, fieldKey],
|
||||
async () =>
|
||||
getAggregateKeys({
|
||||
searchText: fieldKey,
|
||||
aggregateOperator:
|
||||
currentQuery.builder.queryData[0].aggregateOperator || '',
|
||||
dataSource: currentQuery.builder.queryData[0].dataSource,
|
||||
aggregateAttribute:
|
||||
currentQuery.builder.queryData[0].aggregateAttribute?.key || '',
|
||||
}),
|
||||
);
|
||||
): void => {
|
||||
const nextQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item) =>
|
||||
getFilterQueryData(item, { fieldKey, dataType }, fieldValue, operator),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const keysAutocomplete: BaseAutocompleteData[] =
|
||||
keysAutocompleteResponse.payload?.attributeKeys || [];
|
||||
|
||||
const existAutocompleteKey = chooseAutocompleteFromCustomValue(
|
||||
keysAutocomplete,
|
||||
fieldKey,
|
||||
dataType,
|
||||
fieldType,
|
||||
);
|
||||
|
||||
const currentOperator = getOperatorValue(operator);
|
||||
|
||||
const nextQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
filters: {
|
||||
...item.filters,
|
||||
items: [
|
||||
...(item.filters?.items || []),
|
||||
{
|
||||
id: uuid(),
|
||||
key: existAutocompleteKey,
|
||||
op: currentOperator,
|
||||
value: fieldValue,
|
||||
},
|
||||
],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
redirectWithQueryBuilderData(nextQuery);
|
||||
} catch {
|
||||
notifications.error({ message: SOMETHING_WENT_WRONG });
|
||||
}
|
||||
redirectWithQueryBuilderData(nextQuery);
|
||||
},
|
||||
[currentQuery, notifications, queryClient, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
const onGroupByAttribute = useCallback(
|
||||
async (fieldKey: string, dataType?: DataTypes): Promise<void> => {
|
||||
try {
|
||||
const keysAutocompleteResponse = await queryClient.fetchQuery(
|
||||
[QueryBuilderKeys.GET_AGGREGATE_KEYS, fieldKey],
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
async () =>
|
||||
getAggregateKeys({
|
||||
searchText: fieldKey,
|
||||
aggregateOperator:
|
||||
currentQuery.builder.queryData[0].aggregateOperator || '',
|
||||
dataSource: currentQuery.builder.queryData[0].dataSource,
|
||||
aggregateAttribute:
|
||||
currentQuery.builder.queryData[0].aggregateAttribute?.key || '',
|
||||
}),
|
||||
);
|
||||
|
||||
const keysAutocomplete: BaseAutocompleteData[] =
|
||||
keysAutocompleteResponse.payload?.attributeKeys || [];
|
||||
|
||||
const existAutocompleteKey = chooseAutocompleteFromCustomValue(
|
||||
keysAutocomplete,
|
||||
fieldKey,
|
||||
dataType,
|
||||
);
|
||||
|
||||
const nextQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
groupBy: [...item.groupBy, existAutocompleteKey],
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
redirectWithQueryBuilderData(nextQuery);
|
||||
} catch {
|
||||
notifications.error({ message: SOMETHING_WENT_WRONG });
|
||||
}
|
||||
},
|
||||
[currentQuery, notifications, queryClient, redirectWithQueryBuilderData],
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
return {
|
||||
activeLog,
|
||||
onSetActiveLog,
|
||||
onClearActiveLog,
|
||||
onAddToQuery: onAddToQueryExplorer,
|
||||
onGroupByAttribute,
|
||||
onAddToQuery,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -23,6 +23,11 @@ export default {
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['list', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
kind: 'saved-view',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
kind: 'subscription',
|
||||
type: 'metaresource',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { BrandedPermission } from '../types';
|
||||
import { buildPermission } from '../utils';
|
||||
|
||||
export const SavedViewListPermission = buildPermission('list', 'saved-view:*');
|
||||
export const SavedViewCreatePermission = buildPermission(
|
||||
'create',
|
||||
'saved-view:*',
|
||||
);
|
||||
|
||||
export const buildSavedViewReadPermission = (id: string): BrandedPermission =>
|
||||
buildPermission('read', `saved-view:${id}`);
|
||||
export const buildSavedViewUpdatePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('update', `saved-view:${id}`);
|
||||
export const buildSavedViewDeletePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('delete', `saved-view:${id}`);
|
||||
@@ -1,24 +0,0 @@
|
||||
import { QueryOperatorsMultiVal } from 'lib/logql/tokens';
|
||||
|
||||
type Keys = keyof typeof QueryOperatorsMultiVal;
|
||||
type Values = (typeof QueryOperatorsMultiVal)[Keys];
|
||||
|
||||
interface GenerateFilterQueryParams {
|
||||
fieldKey: string;
|
||||
fieldValue: string;
|
||||
type: Values;
|
||||
}
|
||||
export const generateFilterQuery = ({
|
||||
fieldKey,
|
||||
fieldValue,
|
||||
type,
|
||||
}: GenerateFilterQueryParams): string => {
|
||||
let generatedQueryString = `${fieldKey} ${type} `;
|
||||
if (typeof fieldValue === 'number') {
|
||||
generatedQueryString += `(${fieldValue})`;
|
||||
} else {
|
||||
generatedQueryString += `('${fieldValue}')`;
|
||||
}
|
||||
|
||||
return generatedQueryString;
|
||||
};
|
||||
@@ -217,8 +217,6 @@ function ListPanelRenderer({
|
||||
log={logDetail.activeLog}
|
||||
selectedTab={logDetail.selectedTab}
|
||||
onClose={logDetail.onClose}
|
||||
onAddToQuery={logDetail.onAddToQuery}
|
||||
onClickActionItem={logDetail.onAddToQuery}
|
||||
onNavigateLog={logDetail.onNavigateLog}
|
||||
logs={logDetail.logs}
|
||||
isListViewPanel
|
||||
|
||||
@@ -18,7 +18,6 @@ export interface ListLogDetail {
|
||||
selectedTab: VIEWS;
|
||||
logs: ILog[];
|
||||
onClose: () => void;
|
||||
onAddToQuery: ReturnType<typeof useLogDetailHandlers>['onAddToQuery'];
|
||||
onNavigateLog: (log: ILog) => void;
|
||||
}
|
||||
|
||||
@@ -49,13 +48,8 @@ export function useListRowInteraction({
|
||||
const isLogs = signal === TelemetrytypesSignalDTO.logs;
|
||||
const isTraces = signal === TelemetrytypesSignalDTO.traces;
|
||||
|
||||
const {
|
||||
activeLog,
|
||||
onAddToQuery,
|
||||
selectedTab,
|
||||
handleSetActiveLog,
|
||||
handleCloseLogDetail,
|
||||
} = useLogDetailHandlers();
|
||||
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
|
||||
useLogDetailHandlers();
|
||||
|
||||
const logs = useMemo(
|
||||
() => (isLogs ? rows.map(rawRowToILog) : []),
|
||||
@@ -90,7 +84,6 @@ export function useListRowInteraction({
|
||||
selectedTab: selectedTab ?? VIEW_TYPES.OVERVIEW,
|
||||
logs,
|
||||
onClose: handleCloseLogDetail,
|
||||
onAddToQuery,
|
||||
onNavigateLog: handleSetActiveLog,
|
||||
}
|
||||
: null,
|
||||
@@ -100,7 +93,6 @@ export function useListRowInteraction({
|
||||
selectedTab,
|
||||
logs,
|
||||
handleCloseLogDetail,
|
||||
onAddToQuery,
|
||||
handleSetActiveLog,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -71,6 +71,20 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/clone", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CloneRuleByID), handler.OpenAPIDef{
|
||||
ID: "CloneRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Clone alert rule",
|
||||
Description: "This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with \" - Copy\" (or a bumped \" - Copy (n)\" counter when the source is itself a copy). The clone is owned by the caller.",
|
||||
Response: new(ruletypes.Rule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.UpdateRuleByID), handler.OpenAPIDef{
|
||||
ID: "UpdateRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
|
||||
@@ -253,7 +253,7 @@ func (m *module) promotePaths(ctx context.Context, target promotetypes.Target, p
|
||||
return err
|
||||
}
|
||||
indexes = append(indexes, schemamigrator.Index{
|
||||
Name: schemamigrator.JSONSubColumnIndexName(parentColumn, it.Path, index.JSONDataType.StringValue(), typeIndex),
|
||||
Name: schemamigrator.JSONSubColumnIndexName(parentColumn, it.Path, strings.ToLower(index.JSONDataType.StringValue()), typeIndex),
|
||||
Expression: target.IndexExpression(parentColumn, it.Path, index.JSONDataType.StringValue()),
|
||||
Type: index.Type,
|
||||
Granularity: index.Granularity,
|
||||
|
||||
@@ -4,15 +4,20 @@ import (
|
||||
"context"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sqlmock "github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
|
||||
"github.com/SigNoz/signoz/pkg/types/promotetypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||
)
|
||||
@@ -150,7 +155,7 @@ func TestPromotePathsCreatesIndexes(t *testing.T) {
|
||||
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1, JSONDataType: telemetrytypes.String},
|
||||
},
|
||||
},
|
||||
wantDDLColumn: "`attributes_promoted.http.method_String_ngrambf_v1` attributes_promoted.`http.method`::String",
|
||||
wantDDLColumn: "`attributes_promoted.http.method_string_ngrambf_v1` attributes_promoted.`http.method`::String",
|
||||
},
|
||||
{
|
||||
name: "TracesUnpromotedPath_IndexesBaseColumn",
|
||||
@@ -162,7 +167,7 @@ func TestPromotePathsCreatesIndexes(t *testing.T) {
|
||||
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1, JSONDataType: telemetrytypes.String},
|
||||
},
|
||||
},
|
||||
wantDDLColumn: "`attributes.http.method_String_ngrambf_v1` attributes.`http.method`::String",
|
||||
wantDDLColumn: "`attributes.http.method_string_ngrambf_v1` attributes.`http.method`::String",
|
||||
},
|
||||
{
|
||||
name: "BloomFilterIndex_FullTypeInDDL",
|
||||
@@ -174,7 +179,7 @@ func TestPromotePathsCreatesIndexes(t *testing.T) {
|
||||
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "bloom_filter(0.01)", Granularity: 1, JSONDataType: telemetrytypes.String},
|
||||
},
|
||||
},
|
||||
wantDDLColumn: "`body_v2.user.name_String_bloom_filter` lower(assumeNotNull(dynamicElement(body_v2.user.name, 'String'))) TYPE bloom_filter(0.01)",
|
||||
wantDDLColumn: "`body_v2.user.name_string_bloom_filter` lower(assumeNotNull(dynamicElement(body_v2.user.name, 'String'))) TYPE bloom_filter(0.01)",
|
||||
},
|
||||
{
|
||||
name: "SetIndex_FullTypeInDDL",
|
||||
@@ -186,7 +191,7 @@ func TestPromotePathsCreatesIndexes(t *testing.T) {
|
||||
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "set(100)", Granularity: 1, JSONDataType: telemetrytypes.String},
|
||||
},
|
||||
},
|
||||
wantDDLColumn: "`body_v2.user.name_String_set` lower(assumeNotNull(dynamicElement(body_v2.user.name, 'String'))) TYPE set(100)",
|
||||
wantDDLColumn: "`body_v2.user.name_string_set` lower(assumeNotNull(dynamicElement(body_v2.user.name, 'String'))) TYPE set(100)",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -419,8 +424,8 @@ func TestIndexMaterializedPaths(t *testing.T) {
|
||||
{Name: "retry.count", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeNumber, Indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{{IndexType: "minmax", Granularity: 1}}},
|
||||
{Name: "k8s.pod.name", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString, Indexes: bloomIndex},
|
||||
}
|
||||
userIDDDL := "`attributes.user.id_String_bloom_filter` attributes.`user.id`::String TYPE bloom_filter GRANULARITY 4"
|
||||
retryCountDDL := "`attributes.retry.count_Float64_minmax` attributes.`retry.count`::Float64 TYPE minmax GRANULARITY 1"
|
||||
userIDDDL := "`attributes.user.id_string_bloom_filter` attributes.`user.id`::String TYPE bloom_filter GRANULARITY 4"
|
||||
retryCountDDL := "`attributes.retry.count_float64_minmax` toFloat64OrNull(attributes.`retry.count`::String) TYPE minmax GRANULARITY 1"
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -510,3 +515,34 @@ func TestIndexMaterializedPaths(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An index is used only when a filter's left-hand side is its exact expression.
|
||||
func TestTracesNumberIndexExpr_MatchesQuerierFilters(t *testing.T) {
|
||||
release := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
queryInfo := qbtypes.QueryInfo{StartNs: uint64(release.AddDate(0, 6, 0).UnixNano()), EndNs: uint64(release.AddDate(0, 7, 0).UnixNano()), TraceAttrsJSONOn: true}
|
||||
key := telemetrytypes.TelemetryFieldKey{Name: "http.status_code", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeNumber, Evolutions: tracestelemetryschema.MockAttributeEvolutionData(release)}
|
||||
indexExpr := promotetypes.NewTracesAttributesTarget().IndexExpression("attributes", "http.status_code", telemetrytypes.Float64.StringValue())
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
operator qbtypes.FilterOperator
|
||||
value any
|
||||
want string
|
||||
}{
|
||||
{name: "Equal", operator: qbtypes.FilterOperatorEqual, value: float64(200), want: indexExpr + " = ?"},
|
||||
{name: "In", operator: qbtypes.FilterOperatorIn, value: []any{float64(200), float64(404)}, want: indexExpr + " = ? OR " + indexExpr + " = ?"},
|
||||
{name: "GreaterThan", operator: qbtypes.FilterOperatorGreaterThan, value: float64(200), want: indexExpr + " > ?"},
|
||||
{name: "Between", operator: qbtypes.FilterOperatorBetween, value: []any{float64(200), float64(299)}, want: indexExpr + " BETWEEN ? AND ?"},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
conditions, _, err := querybuilder.Conditions(context.Background(), queryInfo, tracestelemetryschema.NewStorage(), &key, testCase.operator, testCase.value, map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {&key}}, false, sb)
|
||||
require.NoError(t, err)
|
||||
sb.Where(conditions...)
|
||||
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
assert.Contains(t, sql, testCase.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -159,7 +159,7 @@ func NewBaseRule(
|
||||
id: id,
|
||||
orgID: orgID,
|
||||
externalURL: externalURL,
|
||||
name: p.AlertName,
|
||||
name: p.AlertName.StringValue(),
|
||||
typ: p.AlertType,
|
||||
ruleCondition: p.RuleCondition,
|
||||
evalWindow: p.EvalWindow,
|
||||
|
||||
@@ -126,7 +126,7 @@ func mergeFirstSeenMaps(maps ...map[telemetrytypes.MetricMetadataLookupKey]int64
|
||||
// createPostableRule creates a PostableRule with the given CompositeQuery
|
||||
func createPostableRule(compositeQuery *ruletypes.AlertCompositeQuery) ruletypes.PostableRule {
|
||||
return ruletypes.PostableRule{
|
||||
AlertName: "Test Rule",
|
||||
AlertName: valuer.NewDisplayName("Test Rule"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{
|
||||
|
||||
@@ -546,6 +546,24 @@ func (m *Manager) deleteTask(taskName string) {
|
||||
// CreateRule stores rule def into db and also
|
||||
// starts an executor for the rule, unless the rule is disabled
|
||||
func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error) {
|
||||
parsedRule := ruletypes.PostableRule{}
|
||||
if err := json.Unmarshal([]byte(ruleStr), &parsedRule); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m.createRule(ctx, parsedRule, ruleStr)
|
||||
}
|
||||
|
||||
func (m *Manager) CreatePostableRule(ctx context.Context, rule *ruletypes.PostableRule) (*ruletypes.GettableRule, error) {
|
||||
data, err := json.Marshal(rule)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m.createRule(ctx, *rule, string(data))
|
||||
}
|
||||
|
||||
func (m *Manager) createRule(ctx context.Context, parsedRule ruletypes.PostableRule, data string) (*ruletypes.GettableRule, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -555,11 +573,6 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsedRule := ruletypes.PostableRule{}
|
||||
err = json.Unmarshal([]byte(ruleStr), &parsedRule)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := parsedRule.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -579,7 +592,7 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
|
||||
CreatedBy: claims.Email,
|
||||
UpdatedBy: claims.Email,
|
||||
},
|
||||
Data: ruleStr,
|
||||
Data: data,
|
||||
OrgID: claims.OrgID,
|
||||
}
|
||||
|
||||
@@ -1106,7 +1119,7 @@ func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleS
|
||||
parsedRule.NotificationSettings.GroupBy = append(parsedRule.NotificationSettings.GroupBy, ruletypes.LabelThresholdName)
|
||||
}
|
||||
config := parsedRule.NotificationSettings.GetAlertManagerNotificationConfig()
|
||||
err = m.alertmanager.SetNotificationConfig(ctx, orgID, parsedRule.AlertName, &config)
|
||||
err = m.alertmanager.SetNotificationConfig(ctx, orgID, parsedRule.AlertName.StringValue(), &config)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ type PromRuleTestCase struct {
|
||||
// ThresholdRuleAtLeastOnceValueAbove creates a PostableRule for threshold rule test notifications
|
||||
func ThresholdRuleAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes.PostableRule {
|
||||
return ruletypes.PostableRule{
|
||||
AlertName: "test-alert",
|
||||
AlertName: valuer.NewDisplayName("test-alert"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -95,7 +95,7 @@ func ThresholdRuleAtLeastOnceValueAbove(target float64, recovery *float64) rulet
|
||||
// BuildPromAtLeastOnceValueAbove creates a PostableRule for PromQL rule test notifications
|
||||
func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes.PostableRule {
|
||||
return ruletypes.PostableRule{
|
||||
AlertName: "test-prom-alert",
|
||||
AlertName: valuer.NewDisplayName("test-prom-alert"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeProm,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
|
||||
@@ -359,7 +359,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
|
||||
func (r *PromRule) String() string {
|
||||
ar := ruletypes.PostableRule{
|
||||
AlertName: r.name,
|
||||
AlertName: valuer.NewDisplayName(r.name),
|
||||
RuleCondition: r.ruleCondition,
|
||||
EvalWindow: r.evalWindow,
|
||||
Labels: r.labels.Map(),
|
||||
|
||||
@@ -35,7 +35,7 @@ func getVectorValues(vectors []ruletypes.Sample) []float64 {
|
||||
|
||||
func TestPromRuleEval(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Test Rule",
|
||||
AlertName: valuer.NewDisplayName("Test Rule"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeProm,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -746,7 +746,7 @@ func TestPromRuleUnitCombinations(t *testing.T) {
|
||||
evalTime := baseTime.Add(5 * time.Minute)
|
||||
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Units test",
|
||||
AlertName: valuer.NewDisplayName("Units test"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeProm,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -914,7 +914,7 @@ func TestPromRuleUnitCombinations(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: postableRule.AlertName,
|
||||
Name: postableRule.AlertName.StringValue(),
|
||||
TargetValue: &c.target,
|
||||
TargetUnit: c.targetUnit,
|
||||
MatchType: c.matchType,
|
||||
@@ -962,7 +962,7 @@ func TestPromRuleNoData(t *testing.T) {
|
||||
evalTime := baseTime.Add(5 * time.Minute)
|
||||
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "No data test",
|
||||
AlertName: valuer.NewDisplayName("No data test"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeProm,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -1019,7 +1019,7 @@ func TestPromRuleNoData(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: postableRule.AlertName,
|
||||
Name: postableRule.AlertName.StringValue(),
|
||||
TargetValue: &target,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsEq,
|
||||
@@ -1062,7 +1062,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
|
||||
evalTime := baseTime.Add(5 * time.Minute)
|
||||
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Multiple threshold test",
|
||||
AlertName: valuer.NewDisplayName("Multiple threshold test"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeProm,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -1261,7 +1261,7 @@ func TestPromRule_NoData(t *testing.T) {
|
||||
evalTime := time.Now()
|
||||
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Test no data",
|
||||
AlertName: valuer.NewDisplayName("Test no data"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeProm,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -1357,7 +1357,7 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
|
||||
target := 500.0
|
||||
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Test no data with AbsentFor",
|
||||
AlertName: valuer.NewDisplayName("Test no data with AbsentFor"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeProm,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -1483,7 +1483,7 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
|
||||
lookBackDelta := time.Minute
|
||||
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Unit test",
|
||||
AlertName: valuer.NewDisplayName("Unit test"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeProm,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -1572,7 +1572,7 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: postableRule.AlertName,
|
||||
Name: postableRule.AlertName.StringValue(),
|
||||
TargetValue: rc.Target,
|
||||
MatchType: rc.MatchType,
|
||||
CompareOperator: rc.CompareOperator,
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// TestNotification prepares a dummy rule for given rule parameters and
|
||||
@@ -23,7 +24,7 @@ func defaultTestNotification(opts PrepareTestRuleOptions) (int, error) {
|
||||
}
|
||||
|
||||
parsedRule := opts.Rule
|
||||
var alertname = parsedRule.AlertName
|
||||
var alertname = parsedRule.AlertName.StringValue()
|
||||
if alertname == "" {
|
||||
// alertname is not mandatory for testing, so picking
|
||||
// a random string here
|
||||
@@ -31,7 +32,7 @@ func defaultTestNotification(opts PrepareTestRuleOptions) (int, error) {
|
||||
}
|
||||
|
||||
// append name to indicate this is test alert
|
||||
parsedRule.AlertName = fmt.Sprintf("%s%s", alertname, ruletypes.TestAlertPostFix)
|
||||
parsedRule.AlertName = valuer.NewDisplayName(fmt.Sprintf("%s%s", alertname, ruletypes.TestAlertPostFix))
|
||||
|
||||
var rule Rule
|
||||
var err error
|
||||
|
||||
@@ -468,7 +468,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
|
||||
func (r *ThresholdRule) String() string {
|
||||
ar := ruletypes.PostableRule{
|
||||
AlertName: r.name,
|
||||
AlertName: valuer.NewDisplayName(r.name),
|
||||
RuleCondition: r.ruleCondition,
|
||||
EvalWindow: r.evalWindow,
|
||||
Labels: r.labels.Map(),
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
|
||||
func TestThresholdRuleEvalWithoutRecoveryTarget(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Eval test without recovery target",
|
||||
AlertName: valuer.NewDisplayName("Eval test without recovery target"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -145,7 +145,7 @@ func TestNormalizeLabelName(t *testing.T) {
|
||||
|
||||
func TestPrepareParamsForLogs(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Tricky Condition Tests",
|
||||
AlertName: valuer.NewDisplayName("Tricky Condition Tests"),
|
||||
AlertType: ruletypes.AlertTypeLogs,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -210,7 +210,7 @@ func TestPrepareParamsForLogs(t *testing.T) {
|
||||
|
||||
func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Tricky Condition Tests",
|
||||
AlertName: valuer.NewDisplayName("Tricky Condition Tests"),
|
||||
AlertType: ruletypes.AlertTypeLogs,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -275,7 +275,7 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
|
||||
|
||||
func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Tricky Condition Tests",
|
||||
AlertName: valuer.NewDisplayName("Tricky Condition Tests"),
|
||||
AlertType: ruletypes.AlertTypeTraces,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -340,7 +340,7 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
|
||||
|
||||
func TestPrepareParamsForTraces(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Links to traces test",
|
||||
AlertName: valuer.NewDisplayName("Links to traces test"),
|
||||
AlertType: ruletypes.AlertTypeTraces,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -406,7 +406,7 @@ func TestPrepareParamsForTraces(t *testing.T) {
|
||||
|
||||
func TestThresholdRuleLabelNormalization(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Tricky Condition Tests",
|
||||
AlertName: valuer.NewDisplayName("Tricky Condition Tests"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -511,7 +511,7 @@ func TestThresholdRuleLabelNormalization(t *testing.T) {
|
||||
|
||||
func TestThresholdRuleUnitCombinations(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Units test",
|
||||
AlertName: valuer.NewDisplayName("Units test"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -662,7 +662,7 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: postableRule.AlertName,
|
||||
Name: postableRule.AlertName.StringValue(),
|
||||
TargetValue: &c.target,
|
||||
TargetUnit: c.targetUnit,
|
||||
MatchType: c.matchType,
|
||||
@@ -704,7 +704,7 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
|
||||
|
||||
func TestThresholdRuleNoData(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "No data test",
|
||||
AlertName: valuer.NewDisplayName("No data test"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -771,7 +771,7 @@ func TestThresholdRuleNoData(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: postableRule.AlertName,
|
||||
Name: postableRule.AlertName.StringValue(),
|
||||
TargetValue: &target,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsEq,
|
||||
@@ -808,7 +808,7 @@ func TestThresholdRuleNoData(t *testing.T) {
|
||||
|
||||
func TestThresholdRuleTracesLink(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Traces link test",
|
||||
AlertName: valuer.NewDisplayName("Traces link test"),
|
||||
AlertType: ruletypes.AlertTypeTraces,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -876,7 +876,7 @@ func TestThresholdRuleTracesLink(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: postableRule.AlertName,
|
||||
Name: postableRule.AlertName.StringValue(),
|
||||
TargetValue: &c.target,
|
||||
TargetUnit: c.targetUnit,
|
||||
MatchType: c.matchType,
|
||||
@@ -918,7 +918,7 @@ func TestThresholdRuleTracesLink(t *testing.T) {
|
||||
|
||||
func TestThresholdRuleAITracesLink(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "AI traces link test",
|
||||
AlertName: valuer.NewDisplayName("AI traces link test"),
|
||||
AlertType: ruletypes.AlertTypeAITraces,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -984,7 +984,7 @@ func TestThresholdRuleAITracesLink(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: postableRule.AlertName,
|
||||
Name: postableRule.AlertName.StringValue(),
|
||||
TargetValue: &c.target,
|
||||
TargetUnit: c.targetUnit,
|
||||
MatchType: c.matchType,
|
||||
@@ -1016,7 +1016,7 @@ func TestThresholdRuleAITracesLink(t *testing.T) {
|
||||
|
||||
func TestThresholdRuleLogsLink(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Logs link test",
|
||||
AlertName: valuer.NewDisplayName("Logs link test"),
|
||||
AlertType: ruletypes.AlertTypeLogs,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -1092,7 +1092,7 @@ func TestThresholdRuleLogsLink(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: postableRule.AlertName,
|
||||
Name: postableRule.AlertName.StringValue(),
|
||||
TargetValue: &c.target,
|
||||
TargetUnit: c.targetUnit,
|
||||
MatchType: c.matchType,
|
||||
@@ -1134,7 +1134,7 @@ func TestThresholdRuleLogsLink(t *testing.T) {
|
||||
|
||||
func TestMultipleThresholdRule(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Multiple threshold test",
|
||||
AlertName: valuer.NewDisplayName("Multiple threshold test"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -1315,7 +1315,7 @@ func TestMultipleThresholdRule(t *testing.T) {
|
||||
|
||||
func TestThresholdRuleEval_BasicCases(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Eval Recovery Threshold Test",
|
||||
AlertName: valuer.NewDisplayName("Eval Recovery Threshold Test"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -1349,7 +1349,7 @@ func TestThresholdRuleEval_BasicCases(t *testing.T) {
|
||||
|
||||
func TestThresholdRuleEval_MatchPlusCompareOps(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Eval Match Plus Compare Ops Threshold Test",
|
||||
AlertName: valuer.NewDisplayName("Eval Match Plus Compare Ops Threshold Test"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -1389,7 +1389,7 @@ func TestThresholdRuleEval_SendUnmatchedBypassesRecovery(t *testing.T) {
|
||||
recovery := 4.0
|
||||
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Send unmatched bypass recovery",
|
||||
AlertName: valuer.NewDisplayName("Send unmatched bypass recovery"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -1476,7 +1476,7 @@ func TestThresholdRuleEval_SendUnmatchedVariants(t *testing.T) {
|
||||
target := 10.0
|
||||
recovery := 5.0
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Send unmatched variants",
|
||||
AlertName: valuer.NewDisplayName("Send unmatched variants"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -1588,7 +1588,7 @@ func TestThresholdRuleEval_RecoveryNotMetSendUnmatchedFalse(t *testing.T) {
|
||||
|
||||
now := time.Now()
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Recovery not met sendUnmatched false",
|
||||
AlertName: valuer.NewDisplayName("Recovery not met sendUnmatched false"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -1888,7 +1888,7 @@ func runMultiThresholdEvalTests(t *testing.T, postableRule ruletypes.PostableRul
|
||||
// where each threshold can be in a different state (firing, recovering, resolved)
|
||||
func TestThresholdRuleEval_MultiThreshold(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Multi-Threshold Recovery Test",
|
||||
AlertName: valuer.NewDisplayName("Multi-Threshold Recovery Test"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -1922,7 +1922,7 @@ func TestThresholdRuleEval_MultiThreshold(t *testing.T) {
|
||||
|
||||
func TestThresholdEval_RequireMinPoints(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Unit test",
|
||||
AlertName: valuer.NewDisplayName("Unit test"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
@@ -2057,7 +2057,7 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: postableRule.AlertName,
|
||||
Name: postableRule.AlertName.StringValue(),
|
||||
TargetValue: &c.target,
|
||||
MatchType: rc.MatchType,
|
||||
CompareOperator: rc.CompareOperator,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
"github.com/SigNoz/signoz/pkg/clickhousesql"
|
||||
@@ -135,6 +136,8 @@ func DataTypeCollisionHandledFieldName(key *telemetrytypes.TelemetryFieldKey, va
|
||||
// This following is one way to workaround it
|
||||
|
||||
// if the key is a number, the value is a string, we will let clickHouse handle the conversion
|
||||
// and clickhouse float64 has a 53-bit mantissa, so values above 2^53 rounds to the nearest representable double
|
||||
// breaking the equality filters for large integers
|
||||
case float32, float64:
|
||||
tblFieldName = castFloatHack(tblFieldName)
|
||||
case string:
|
||||
@@ -207,14 +210,22 @@ func ColumnIsTemporal(col *schema.Column) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func castFloat(col string) string { return fmt.Sprintf("toFloat64OrNull(%s)", col) }
|
||||
func castFloatHack(col string) string { return fmt.Sprintf("toFloat64(%s)", col) }
|
||||
func castString(col string) string { return fmt.Sprintf("toString(%s)", col) }
|
||||
func castBool(col string) string { return fmt.Sprintf("accurateCastOrNull(%s, 'Bool')", col) }
|
||||
func castFloat(col string) string { return fmt.Sprintf("toFloat64OrNull(%s)", col) }
|
||||
func castString(col string) string { return fmt.Sprintf("toString(%s)", col) }
|
||||
func castBool(col string) string { return fmt.Sprintf("accurateCastOrNull(%s, 'Bool')", col) }
|
||||
func accurateCastFloat(col string) string {
|
||||
return fmt.Sprintf("accurateCastOrNull(%s, 'Float64')", col)
|
||||
}
|
||||
|
||||
// castFloatHack leaves a read already cast to Float64 bare: the wrapper would hide it from
|
||||
// its skip index.
|
||||
func castFloatHack(col string) string {
|
||||
if strings.HasPrefix(col, "toFloat64OrNull(") {
|
||||
return col
|
||||
}
|
||||
return fmt.Sprintf("toFloat64(%s)", col)
|
||||
}
|
||||
|
||||
func allFloats(in []any) bool {
|
||||
for _, x := range in {
|
||||
if _, ok := x.(float64); !ok {
|
||||
|
||||
@@ -7,6 +7,7 @@ type Handler interface {
|
||||
ListRulesV3(http.ResponseWriter, *http.Request)
|
||||
GetRuleByID(http.ResponseWriter, *http.Request)
|
||||
CreateRule(http.ResponseWriter, *http.Request)
|
||||
CloneRuleByID(http.ResponseWriter, *http.Request)
|
||||
UpdateRuleByID(http.ResponseWriter, *http.Request)
|
||||
DeleteRuleByID(http.ResponseWriter, *http.Request)
|
||||
PatchRuleByID(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -29,6 +29,9 @@ type Ruler interface {
|
||||
// model to store structured data.
|
||||
CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error)
|
||||
|
||||
// CloneRule creates a new rule from the stored definition of id, with a " - Copy" suffixed name.
|
||||
CloneRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error)
|
||||
|
||||
// EditRule replaces the rule identified by id with the given JSON string.
|
||||
// TODO: same as CreateRule — accept PostableRule instead of raw string.
|
||||
EditRule(ctx context.Context, ruleStr string, id valuer.UUID) error
|
||||
|
||||
@@ -235,7 +235,7 @@ func (r *rule) GetStoredRulesByMetricName(ctx context.Context, orgID string, met
|
||||
if found && !seen[storedRule.ID.StringValue()] {
|
||||
seen[storedRule.ID.StringValue()] = true
|
||||
alerts = append(alerts, ruletypes.RuleAlert{
|
||||
AlertName: ruleData.AlertName,
|
||||
AlertName: ruleData.AlertName.StringValue(),
|
||||
AlertID: storedRule.ID.StringValue(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -105,6 +105,25 @@ func (handler *handler) CreateRule(rw http.ResponseWriter, req *http.Request) {
|
||||
render.Success(rw, http.StatusCreated, ruletypes.NewRule(rule))
|
||||
}
|
||||
|
||||
func (handler *handler) CloneRuleByID(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
return
|
||||
}
|
||||
|
||||
rule, err := handler.ruler.CloneRule(ctx, id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, ruletypes.NewRule(rule))
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateRuleByID(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -128,6 +129,30 @@ func (provider *provider) CreateRule(ctx context.Context, ruleStr string) (*rule
|
||||
return provider.manager.CreateRule(ctx, ruleStr)
|
||||
}
|
||||
|
||||
func (provider *provider) CloneRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storedRule, err := provider.ruleStore.GetStoredRule(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
postableRule, err := storedRule.ToPostableRuleForCloning()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return provider.manager.CreatePostableRule(ctx, postableRule)
|
||||
}
|
||||
|
||||
func (provider *provider) EditRule(ctx context.Context, ruleStr string, id valuer.UUID) error {
|
||||
return provider.manager.EditRule(ctx, ruleStr, id)
|
||||
}
|
||||
|
||||
@@ -42,12 +42,18 @@ var logsBodyIndexLookup = promotetypes.NewLogsBodyTarget().JSONIndexLookup()
|
||||
// a backtick-quoted path may hold any character, parentheses included.
|
||||
var simpleJSONSubColumnIndexExprRe = regexp.MustCompile("^CAST\\((?P<expr>(?:[^()`]|`[^`]*`)+), '(?P<type>.+)'\\)$")
|
||||
|
||||
// unfoldJSONSubColumnIndexExpr accepts both the folded (lower/assumeNotNull)
|
||||
// and the bare type-cast expression forms.
|
||||
// ClickHouse stores a traces number index expression with its ::String unfolded to CAST.
|
||||
var jsonNumberSubColumnIndexExprRe = regexp.MustCompile("^toFloat64OrNull\\(CAST\\((?P<expr>(?:[^()`]|`[^`]*`)+), 'String'\\)\\)$")
|
||||
|
||||
// unfoldJSONSubColumnIndexExpr accepts the folded (lower/assumeNotNull), the
|
||||
// traces number and the bare type-cast expression forms.
|
||||
func unfoldJSONSubColumnIndexExpr(expr string) (string, string, error) {
|
||||
if columnExpr, columnType, err := schemamigrator.UnfoldJSONSubColumnIndexExpr(expr); err == nil {
|
||||
return columnExpr, columnType, nil
|
||||
}
|
||||
if matches := jsonNumberSubColumnIndexExprRe.FindStringSubmatch(expr); matches != nil {
|
||||
return matches[1], telemetrytypes.Float64.StringValue(), nil
|
||||
}
|
||||
matches := simpleJSONSubColumnIndexExprRe.FindStringSubmatch(expr)
|
||||
if matches == nil {
|
||||
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid expression: %s", expr)
|
||||
|
||||
@@ -92,6 +92,23 @@ func TestUnfoldJSONSubColumnIndexExpr(t *testing.T) {
|
||||
wantColumn: "attributes_promoted.status",
|
||||
wantType: "Int64",
|
||||
},
|
||||
{
|
||||
name: "TracesNumber",
|
||||
expr: "toFloat64OrNull(CAST(attributes.`http.status_code`, 'String'))",
|
||||
wantColumn: "attributes.`http.status_code`",
|
||||
wantType: "Float64",
|
||||
},
|
||||
{
|
||||
name: "TracesNumber_BacktickedParentheses",
|
||||
expr: "toFloat64OrNull(CAST(attributes.`latency(ms)`, 'String'))",
|
||||
wantColumn: "attributes.`latency(ms)`",
|
||||
wantType: "Float64",
|
||||
},
|
||||
{
|
||||
name: "TracesNumber_NotAStringCast",
|
||||
expr: "toFloat64OrNull(CAST(attributes.a, 'Int64'))",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "UnrecognizedForm",
|
||||
expr: "toString(attributes.status)",
|
||||
@@ -137,9 +154,25 @@ func TestUnfoldJSONSubColumnIndexExprTracesRoundTrip(t *testing.T) {
|
||||
name: "PlainKey_UnquotedInStorage",
|
||||
column: "attributes_promoted",
|
||||
path: "status",
|
||||
jsonDataType: "Int64",
|
||||
wantExpr: "attributes_promoted.`status`::Int64",
|
||||
storedExpr: "CAST(attributes_promoted.status, 'Int64')",
|
||||
jsonDataType: "String",
|
||||
wantExpr: "attributes_promoted.`status`::String",
|
||||
storedExpr: "CAST(attributes_promoted.status, 'String')",
|
||||
},
|
||||
{
|
||||
name: "DottedNumber_BackticksPreservedInStorage",
|
||||
column: "attributes",
|
||||
path: "http.status_code",
|
||||
jsonDataType: "Float64",
|
||||
wantExpr: "toFloat64OrNull(attributes.`http.status_code`::String)",
|
||||
storedExpr: "toFloat64OrNull(CAST(attributes.`http.status_code`, 'String'))",
|
||||
},
|
||||
{
|
||||
name: "PlainNumber_UnquotedInStorage",
|
||||
column: "attributes_promoted",
|
||||
path: "latency",
|
||||
jsonDataType: "Float64",
|
||||
wantExpr: "toFloat64OrNull(attributes_promoted.`latency`::String)",
|
||||
storedExpr: "toFloat64OrNull(CAST(attributes_promoted.latency, 'String'))",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -273,7 +273,7 @@ func (m *storage) resolveColumnExprs(
|
||||
}
|
||||
case telemetrytypes.FieldContextAttribute:
|
||||
path := fmt.Sprintf("%s.%s", columnName, clickhousesql.Identifier(key.Name))
|
||||
expr, existExpr := attributeJSONValueExpr(path, key.FieldDataType)
|
||||
expr, existExpr := AttributeJSONValueExpr(path, key.FieldDataType)
|
||||
exprs = append(exprs, expr)
|
||||
existExprs = append(existExprs, existExpr)
|
||||
default:
|
||||
@@ -330,20 +330,22 @@ func attributeColumnEvolutionRegistered(key *telemetrytypes.TelemetryFieldKey, c
|
||||
return false
|
||||
}
|
||||
|
||||
// attributeJSONValueExpr renders the value expression for a span attribute read from the JSON
|
||||
// AttributeJSONValueExpr renders the value expression for a span attribute read from the JSON
|
||||
// column along with its per-type existence guard.
|
||||
// Numeric and bool gate a crash-safe accurateCastOrNull by dynamicType: the cast alone coerces
|
||||
// across domains (bool true reads 1, '200' reads 200, 200.5 reads true), so the read is
|
||||
// restricted to values stored as that type — the per-type separation the typed maps gave
|
||||
// structurally. Being NULL-capable, the gated read itself is the existence guard (present AS
|
||||
// THIS TYPE). Other reads are total (::String folds absent to ” on the raw path), so the
|
||||
// guard is presence on the raw path.
|
||||
func attributeJSONValueExpr(path string, dataType telemetrytypes.FieldDataType) (string, string) {
|
||||
// Numeric reads through the string form so that a value stored as a number or as a numeric
|
||||
// string reads alike, like the attributes_number map did. Bool gates a crash-safe
|
||||
// accurateCastOrNull by dynamicType: the cast alone coerces across domains (200.5 reads
|
||||
// true), so the read is restricted to values stored as that type — the per-type separation the
|
||||
// typed maps gave structurally. Being NULL-capable, the numeric and bool reads are their own
|
||||
// existence guard (present AS THIS TYPE). Other reads are total (::String folds absent to ” on
|
||||
// the raw path), so the guard is presence on the raw path. Skip indexes are built over the value
|
||||
// expression.
|
||||
func AttributeJSONValueExpr(path string, dataType telemetrytypes.FieldDataType) (string, string) {
|
||||
switch dataType {
|
||||
case telemetrytypes.FieldDataTypeInt64,
|
||||
telemetrytypes.FieldDataTypeFloat64,
|
||||
telemetrytypes.FieldDataTypeNumber:
|
||||
expr := fmt.Sprintf("if(dynamicType(%s) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(%s, 'Float64'), NULL)", path, path) // all numeric types to float64 like attributes_number map.
|
||||
expr := fmt.Sprintf("toFloat64OrNull(%s::String)", path)
|
||||
return expr, expr + " IS NOT NULL"
|
||||
case telemetrytypes.FieldDataTypeBool:
|
||||
expr := fmt.Sprintf("if(dynamicType(%s) = 'Bool', accurateCastOrNull(%s, 'Bool'), NULL)", path, path)
|
||||
|
||||
@@ -57,9 +57,9 @@ func TestFieldForAttributeJSONEvolution(t *testing.T) {
|
||||
{"string after -> json", telemetrytypes.FieldDataTypeString, attrWindowAfter, "attributes.`user.id`::String"},
|
||||
{"string straddle -> dual", telemetrytypes.FieldDataTypeString, attrWindowStraddle, "multiIf(attributes.`user.id` IS NOT NULL, attributes.`user.id`::String, mapContains(attributes_string, 'user.id'), attributes_string['user.id'], NULL)"},
|
||||
{"number before -> map", telemetrytypes.FieldDataTypeNumber, attrWindowBefore, "attributes_number['user.id']"},
|
||||
{"number after -> json", telemetrytypes.FieldDataTypeNumber, attrWindowAfter, "if(dynamicType(attributes.`user.id`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`user.id`, 'Float64'), NULL)"},
|
||||
{"number straddle -> dual", telemetrytypes.FieldDataTypeNumber, attrWindowStraddle, "multiIf(if(dynamicType(attributes.`user.id`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`user.id`, 'Float64'), NULL) IS NOT NULL, if(dynamicType(attributes.`user.id`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`user.id`, 'Float64'), NULL), mapContains(attributes_number, 'user.id'), attributes_number['user.id'], NULL)"},
|
||||
{"int64 after -> json", telemetrytypes.FieldDataTypeInt64, attrWindowAfter, "if(dynamicType(attributes.`user.id`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`user.id`, 'Float64'), NULL)"},
|
||||
{"number after -> json", telemetrytypes.FieldDataTypeNumber, attrWindowAfter, "toFloat64OrNull(attributes.`user.id`::String)"},
|
||||
{"number straddle -> dual", telemetrytypes.FieldDataTypeNumber, attrWindowStraddle, "multiIf(toFloat64OrNull(attributes.`user.id`::String) IS NOT NULL, toFloat64OrNull(attributes.`user.id`::String), mapContains(attributes_number, 'user.id'), attributes_number['user.id'], NULL)"},
|
||||
{"int64 after -> json", telemetrytypes.FieldDataTypeInt64, attrWindowAfter, "toFloat64OrNull(attributes.`user.id`::String)"},
|
||||
{"bool before -> map", telemetrytypes.FieldDataTypeBool, attrWindowBefore, "attributes_bool['user.id']"},
|
||||
{"bool after -> json", telemetrytypes.FieldDataTypeBool, attrWindowAfter, "if(dynamicType(attributes.`user.id`) = 'Bool', accurateCastOrNull(attributes.`user.id`, 'Bool'), NULL)"},
|
||||
{"bool straddle -> dual", telemetrytypes.FieldDataTypeBool, attrWindowStraddle, "multiIf(if(dynamicType(attributes.`user.id`) = 'Bool', accurateCastOrNull(attributes.`user.id`, 'Bool'), NULL) IS NOT NULL, if(dynamicType(attributes.`user.id`) = 'Bool', accurateCastOrNull(attributes.`user.id`, 'Bool'), NULL), mapContains(attributes_bool, 'user.id'), attributes_bool['user.id'], NULL)"},
|
||||
@@ -180,7 +180,7 @@ func TestConditionForAttributeJSON(t *testing.T) {
|
||||
name: "greater than number",
|
||||
key: attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo),
|
||||
operator: qbtypes.FilterOperatorGreaterThan, value: float64(200),
|
||||
expected: "toFloat64(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL)) > ?",
|
||||
expected: "toFloat64OrNull(attributes.`http.status_code`::String) > ?",
|
||||
},
|
||||
{
|
||||
name: "ilike string",
|
||||
@@ -216,7 +216,31 @@ func TestConditionForAttributeJSON(t *testing.T) {
|
||||
name: "between number",
|
||||
key: attrKey("latency", telemetrytypes.FieldDataTypeNumber, evo),
|
||||
operator: qbtypes.FilterOperatorBetween, value: []any{float64(1), float64(9)},
|
||||
expected: "toFloat64(if(dynamicType(attributes.`latency`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`latency`, 'Float64'), NULL)) BETWEEN ? AND ?",
|
||||
expected: "toFloat64OrNull(attributes.`latency`::String) BETWEEN ? AND ?",
|
||||
},
|
||||
{
|
||||
name: "In_Number",
|
||||
key: attrKey("http.status_code", telemetrytypes.FieldDataTypeNumber, evo),
|
||||
operator: qbtypes.FilterOperatorIn, value: []any{float64(200), float64(404)},
|
||||
expected: "(toFloat64OrNull(attributes.`http.status_code`::String) = ? OR toFloat64OrNull(attributes.`http.status_code`::String) = ?)",
|
||||
},
|
||||
{
|
||||
name: "GreaterThan_NumericString",
|
||||
key: attrKey("http.status_code", telemetrytypes.FieldDataTypeNumber, evo),
|
||||
operator: qbtypes.FilterOperatorGreaterThan, value: "200",
|
||||
expected: "toFloat64OrNull(attributes.`http.status_code`::String) > ?",
|
||||
},
|
||||
{
|
||||
name: "Equal_NonNumericString_CastsToString",
|
||||
key: attrKey("http.status_code", telemetrytypes.FieldDataTypeNumber, evo),
|
||||
operator: qbtypes.FilterOperatorEqual, value: "ok",
|
||||
expected: "toString(toFloat64OrNull(attributes.`http.status_code`::String)) = ?",
|
||||
},
|
||||
{
|
||||
name: "Equal_Bool_CastsToString",
|
||||
key: attrKey("http.status_code", telemetrytypes.FieldDataTypeNumber, evo),
|
||||
operator: qbtypes.FilterOperatorEqual, value: true,
|
||||
expected: "toString(toFloat64OrNull(attributes.`http.status_code`::String)) = ?",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -271,7 +295,7 @@ func TestColumnExpressionForAttributeJSON(t *testing.T) {
|
||||
key := attrKey("latency", telemetrytypes.FieldDataTypeNumber, evo)
|
||||
got, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &key, telemetrytypes.FieldDataTypeFloat64, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "multiIf(if(dynamicType(attributes.`latency`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`latency`, 'Float64'), NULL) IS NOT NULL, toFloat64(if(dynamicType(attributes.`latency`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`latency`, 'Float64'), NULL)), mapContains(attributes_number, 'attribute.latency'), toFloat64(attributes_number['attribute.latency']), NULL)", got)
|
||||
assert.Equal(t, "multiIf(toFloat64OrNull(attributes.`latency`::String) IS NOT NULL, toFloat64OrNull(attributes.`latency`::String), mapContains(attributes_number, 'attribute.latency'), toFloat64(attributes_number['attribute.latency']), NULL)", got)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -314,7 +338,7 @@ func TestConditionForAttributeJSONTypeCollision(t *testing.T) {
|
||||
sb.Where(sb.Or(conds...))
|
||||
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
assert.Contains(t, sql, "toFloat64OrNull(attributes.`http.status_code`::String) = ?")
|
||||
assert.Contains(t, sql, "toFloat64(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL)) = ?")
|
||||
assert.Contains(t, sql, "toFloat64OrNull(attributes.`http.status_code`::String) = ?")
|
||||
assert.Contains(t, sql, "attributes.`http.status_code` IS NOT NULL")
|
||||
assert.NotEmpty(t, warnings, "a colliding name must surface the ambiguity warning")
|
||||
}
|
||||
@@ -339,7 +363,7 @@ func TestColumnExpressionForAttributeJSONTypeCollision(t *testing.T) {
|
||||
got, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &ref, telemetrytypes.FieldDataTypeString, fieldKeys)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t,
|
||||
"multiIf(attributes.`http.status_code` IS NOT NULL, attributes.`http.status_code`::String, if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL) IS NOT NULL, toString(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL)), NULL)",
|
||||
"multiIf(attributes.`http.status_code` IS NOT NULL, attributes.`http.status_code`::String, toFloat64OrNull(attributes.`http.status_code`::String) IS NOT NULL, toString(toFloat64OrNull(attributes.`http.status_code`::String)), NULL)",
|
||||
got)
|
||||
}
|
||||
|
||||
@@ -363,7 +387,7 @@ func TestColumnExpressionForAttributeJSONTypeCollisionNumericAgg(t *testing.T) {
|
||||
got, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &ref, telemetrytypes.FieldDataTypeFloat64, fieldKeys)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t,
|
||||
"multiIf(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL) IS NOT NULL, toFloat64(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL)), attributes.`http.status_code` IS NOT NULL, toFloat64OrNull(attributes.`http.status_code`::String), NULL)",
|
||||
"multiIf(toFloat64OrNull(attributes.`http.status_code`::String) IS NOT NULL, toFloat64OrNull(attributes.`http.status_code`::String), attributes.`http.status_code` IS NOT NULL, toFloat64OrNull(attributes.`http.status_code`::String), NULL)",
|
||||
got)
|
||||
}
|
||||
|
||||
@@ -409,7 +433,7 @@ func TestColumnForUnspecifiedAttributeNoBranchFlip(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestConditionForAttributeJSONNegativeOperatorParity pins Map parity for numeric/bool attributes.
|
||||
// The value reads an absent key as NULL (accurateCastOrNull, or the straddle multiIf else); a
|
||||
// The value reads an absent key as NULL (toFloat64OrNull, or the straddle multiIf else); a
|
||||
// positive operator excludes such a row via the exists guard, but a negative operator has no guard,
|
||||
// so the condition builder folds the NULL to the Map's type zero (ifNull) for negatives only.
|
||||
// String needs no fold — ::String already reads absent as ”. The fold rides the attributes
|
||||
@@ -432,7 +456,7 @@ func TestConditionForAttributeJSONNegativeOperatorParity(t *testing.T) {
|
||||
t.Run("not equal number after -> NULL folded to 0", func(t *testing.T) {
|
||||
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
|
||||
sql := build(t, key, attrWindowAfter, qbtypes.FilterOperatorNotEqual, float64(200))
|
||||
assert.Contains(t, sql, "toFloat64(ifNull(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL), 0)) <> ?")
|
||||
assert.Contains(t, sql, "toFloat64(ifNull(toFloat64OrNull(attributes.`http.status_code`::String), 0)) <> ?")
|
||||
})
|
||||
|
||||
t.Run("not equal bool after -> NULL folded to false", func(t *testing.T) {
|
||||
@@ -447,27 +471,27 @@ func TestConditionForAttributeJSONNegativeOperatorParity(t *testing.T) {
|
||||
t.Run("not equal number after, string value -> string cast over folded read, no type mismatch", func(t *testing.T) {
|
||||
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
|
||||
sql := build(t, key, attrWindowAfter, qbtypes.FilterOperatorNotEqual, "teapot")
|
||||
assert.Contains(t, sql, "toString(ifNull(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL), 0)) <> ?")
|
||||
assert.Contains(t, sql, "toString(ifNull(toFloat64OrNull(attributes.`http.status_code`::String), 0)) <> ?")
|
||||
assert.NotContains(t, sql, "ifNull(toString(")
|
||||
})
|
||||
|
||||
t.Run("equal number after -> not folded, exists guard excludes absent", func(t *testing.T) {
|
||||
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
|
||||
sql := build(t, key, attrWindowAfter, qbtypes.FilterOperatorEqual, float64(0))
|
||||
assert.Contains(t, sql, "toFloat64(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL)) = ?")
|
||||
assert.Contains(t, sql, "toFloat64OrNull(attributes.`http.status_code`::String) = ?")
|
||||
assert.NotContains(t, sql, "ifNull")
|
||||
})
|
||||
|
||||
t.Run("not in number after -> each operand folded to 0", func(t *testing.T) {
|
||||
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
|
||||
sql := build(t, key, attrWindowAfter, qbtypes.FilterOperatorNotIn, []any{float64(200), float64(404)})
|
||||
assert.Contains(t, sql, "(toFloat64(ifNull(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL), 0)) <> ? AND toFloat64(ifNull(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL), 0)) <> ?)")
|
||||
assert.Contains(t, sql, "(toFloat64(ifNull(toFloat64OrNull(attributes.`http.status_code`::String), 0)) <> ? AND toFloat64(ifNull(toFloat64OrNull(attributes.`http.status_code`::String), 0)) <> ?)")
|
||||
})
|
||||
|
||||
t.Run("not equal number straddle -> whole multiIf folded", func(t *testing.T) {
|
||||
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
|
||||
sql := build(t, key, attrWindowStraddle, qbtypes.FilterOperatorNotEqual, float64(200))
|
||||
assert.Contains(t, sql, "toFloat64(ifNull(multiIf(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL) IS NOT NULL, if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL), mapContains(attributes_number, 'http.status_code'), attributes_number['http.status_code'], NULL), 0)) <> ?")
|
||||
assert.Contains(t, sql, "toFloat64(ifNull(multiIf(toFloat64OrNull(attributes.`http.status_code`::String) IS NOT NULL, toFloat64OrNull(attributes.`http.status_code`::String), mapContains(attributes_number, 'http.status_code'), attributes_number['http.status_code'], NULL), 0)) <> ?")
|
||||
})
|
||||
|
||||
t.Run("not equal number before -> harmless fold over the map read", func(t *testing.T) {
|
||||
@@ -509,7 +533,7 @@ func TestConditionForAttributeJSONStraddleAbsentKeyExclusion(t *testing.T) {
|
||||
return sql
|
||||
}
|
||||
|
||||
guard := "multiIf(if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL) IS NOT NULL, if(dynamicType(attributes.`http.status_code`) IN ('Int64', 'UInt64', 'Float64'), accurateCastOrNull(attributes.`http.status_code`, 'Float64'), NULL), mapContains(attributes_number, 'http.status_code'), attributes_number['http.status_code'], NULL) IS NOT NULL"
|
||||
guard := "multiIf(toFloat64OrNull(attributes.`http.status_code`::String) IS NOT NULL, toFloat64OrNull(attributes.`http.status_code`::String), mapContains(attributes_number, 'http.status_code'), attributes_number['http.status_code'], NULL) IS NOT NULL"
|
||||
|
||||
t.Run("equal zero takes no guard: the absent read is NULL", func(t *testing.T) {
|
||||
key := attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo)
|
||||
|
||||
@@ -24,7 +24,10 @@ const (
|
||||
FormatterGeneric = "generic"
|
||||
)
|
||||
|
||||
var dataURL = regexp.MustCompile(`^data:([^;,]+);base64,(.+)$`)
|
||||
var (
|
||||
dataURL = regexp.MustCompile(`^data:([^;,]+);base64,(.+)$`)
|
||||
urlScheme = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.-]*:`)
|
||||
)
|
||||
|
||||
// Provider spellings of the finish reasons; the enum values themselves pass through. tool_calls and
|
||||
// function_call are OpenAI's, completed, failed, cancelled, incomplete and max_output_tokens the Responses
|
||||
@@ -77,6 +80,7 @@ func FormatMessages(attributes map[string]any) FormattedMessages {
|
||||
}
|
||||
f.prependSystemInstructions()
|
||||
f.fillFinishReasons()
|
||||
f.genericMedia()
|
||||
return f.out
|
||||
}
|
||||
|
||||
@@ -268,12 +272,6 @@ func indexed(doc map[string]any, key string) []map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
// indexedAt reads one entry of a flattened list by its index.
|
||||
func indexedAt(doc map[string]any, key string, i int) map[string]any {
|
||||
m, _ := attribute(doc, key+"."+strconv.Itoa(i)).(map[string]any)
|
||||
return m
|
||||
}
|
||||
|
||||
func firstObject(list []any) (map[string]any, bool) {
|
||||
if len(list) == 0 {
|
||||
return nil, false
|
||||
@@ -390,6 +388,24 @@ func textPart(content string) TextPart {
|
||||
return TextPart{Type: PartTypeText, Content: content}
|
||||
}
|
||||
|
||||
func hasToolCall(parts Parts) bool {
|
||||
for _, p := range parts {
|
||||
if _, ok := p.Value.(ToolCallRequestPart); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func onlyText(parts Parts) bool {
|
||||
for _, p := range parts {
|
||||
if _, ok := p.Value.(TextPart); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// textOf joins the text parts, for comparing a system message with gen_ai.system_instructions.
|
||||
func textOf(parts Parts) string {
|
||||
var texts []string
|
||||
@@ -513,14 +529,46 @@ func modalityOf(mimeType string) Modality {
|
||||
return ModalityDocument
|
||||
}
|
||||
|
||||
// mediaPart places a data URL in a BlobPart, any other URL in a UriPart.
|
||||
// mediaPart places a data URL in a BlobPart and any other URL in a UriPart. A value without a URL scheme,
|
||||
// such as a redaction placeholder, is kept as media without a URL.
|
||||
func mediaPart(url string, modality Modality) any {
|
||||
if m := dataURL.FindStringSubmatch(url); m != nil {
|
||||
return BlobPart{Type: PartTypeBlob, Modality: modality, MimeType: &m[1], Content: m[2]}
|
||||
}
|
||||
if !urlScheme.MatchString(url) {
|
||||
return mediaWithoutURL{"type": FormatterGeneric, "modality": modality.StringValue(), "content": url}
|
||||
}
|
||||
return UriPart{Type: PartTypeURI, Modality: modality, URI: url}
|
||||
}
|
||||
|
||||
// mediaWithoutURL is a generic part that FormatMessages warns about once the converters ran.
|
||||
type mediaWithoutURL GenericPart
|
||||
|
||||
// genericMedia turns media without a URL into generic parts, with one warning.
|
||||
func (f *formatting) genericMedia() {
|
||||
found := false
|
||||
for i := range f.out.Input {
|
||||
found = genericMediaParts(f.out.Input[i].Parts) || found
|
||||
}
|
||||
for i := range f.out.Output {
|
||||
found = genericMediaParts(f.out.Output[i].Parts) || found
|
||||
}
|
||||
if found {
|
||||
f.warn("media without a URL kept as generic")
|
||||
}
|
||||
}
|
||||
|
||||
func genericMediaParts(parts Parts) bool {
|
||||
found := false
|
||||
for i, p := range parts {
|
||||
if media, ok := p.Value.(mediaWithoutURL); ok {
|
||||
parts[i] = part(GenericPart(media))
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// stringOf renders nil as "" and non-strings as compact JSON.
|
||||
func stringOf(value any) string {
|
||||
switch v := value.(type) {
|
||||
|
||||
@@ -214,5 +214,16 @@ func TestFormat_OpenAIChat(t *testing.T) {
|
||||
{"role": "assistant", "parts": [{"type": "text", "content": "hi"}], "finish_reason": "stop"}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
// openrouter-gateway capture: OpenRouter replaces the image with a placeholder, which is no URL
|
||||
name: "RedactedImageURL_GenericPart_Warns",
|
||||
input: `{"messages":[{"role":"user","content":[{"type":"text","text":"What animal is in this image? One sentence."},{"type":"image_url","image_url":{"url":"[OR_REDACTED]"}}]}]}`,
|
||||
warnings: []string{"media without a URL kept as generic"},
|
||||
wantInput: `[{"role": "user", "parts": [
|
||||
{"type": "text", "content": "What animal is in this image? One sentence."},
|
||||
{"type": "generic", "modality": "image", "content": "[OR_REDACTED]"}
|
||||
]}]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -320,5 +320,18 @@ func TestFormat_OpenInference(t *testing.T) {
|
||||
wantInput: `[{"role": "tool", "name": "multiply", "parts": [{"type": "tool_call_response", "id": "call_62136355", "response": "2001"}]}]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
// OpenInference replaces a base64 image over 32,000 characters with __REDACTED__:
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/openinference-instrumentation/src/openinference/instrumentation/config.py#L431
|
||||
name: "RedactedImage_GenericPart_Warns",
|
||||
attributes: map[string]any{
|
||||
"llm.input_messages.0.message.role": "user",
|
||||
"llm.input_messages.0.message.contents.0.message_content.type": "image",
|
||||
"llm.input_messages.0.message.contents.0.message_content.image.image.url": "__REDACTED__",
|
||||
},
|
||||
warnings: []string{"media without a URL kept as generic"},
|
||||
wantInput: `[{"role": "user", "parts": [{"type": "generic", "modality": "image", "content": "__REDACTED__"}]}]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ import (
|
||||
// writes them:
|
||||
// https://github.com/pydantic/pydantic-ai/blob/9ac853bb84ffed7ba0edccb026c5b4f3228c1de7/pydantic_ai_slim/pydantic_ai/messages.py#L3130
|
||||
// https://github.com/pydantic/pydantic-ai/blob/9ac853bb84ffed7ba0edccb026c5b4f3228c1de7/pydantic_ai_slim/pydantic_ai/messages.py#L1724
|
||||
// The choice finish_reason as the provider spells it, as litellm copies it:
|
||||
// The choice finish_reason as the provider spells it, and the OpenAI tool_calls and tool_call_id beside parts,
|
||||
// as litellm copies them:
|
||||
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2822
|
||||
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2803
|
||||
// Not yet: "thinking" for "reasoning".
|
||||
|
||||
// semconv decodes messages that already follow the schema through the genai types. A part of a
|
||||
@@ -41,10 +43,31 @@ func (f *formatting) semconv(list []any) OutputMessages {
|
||||
msgs[i].Parts[j] = contentPart(map[string]any(block))
|
||||
}
|
||||
}
|
||||
if m, ok := list[i].(map[string]any); ok {
|
||||
semconvToolFields(&msgs[i], m)
|
||||
}
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// semconvToolFields turns the tool_call_id of a tool message into its response part, and tool_calls into
|
||||
// tool call parts when the message has none.
|
||||
func semconvToolFields(msg *OutputMessage, m map[string]any) {
|
||||
if id := stringOf(m["tool_call_id"]); msg.Role == RoleTool && id != "" && onlyText(msg.Parts) {
|
||||
msg.Parts = Parts{part(ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: &id, Response: toolResponse(textOf(msg.Parts))})}
|
||||
return
|
||||
}
|
||||
if hasToolCall(msg.Parts) {
|
||||
return
|
||||
}
|
||||
calls, _ := m["tool_calls"].([]any)
|
||||
for _, call := range calls {
|
||||
if c, ok := call.(map[string]any); ok {
|
||||
msg.Parts = append(msg.Parts, toolCallPart(c))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// semconvRepairs copies the parts whose keys SDKs misspell into the schema's spelling. The input
|
||||
// is left untouched, it is also the span's raw attribute.
|
||||
func semconvRepairs(list []any) []any {
|
||||
|
||||
@@ -186,5 +186,18 @@ func TestFormat_Semconv_Repairs(t *testing.T) {
|
||||
wantInput: `[]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [], "finish_reason": "tool_call"}]`,
|
||||
},
|
||||
{
|
||||
// litellm capture, litellm_request span: litellm keeps the OpenAI tool_calls and tool_call_id beside
|
||||
// parts on each message:
|
||||
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2803
|
||||
name: "LiteLLMToolFieldsBesideParts_ToolCallAndResponseParts",
|
||||
input: `[{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Bengaluru? Use the tool."}]}, {"role": "assistant", "parts": [], "tool_calls": [{"function": {"arguments": "{\"city\":\"Bengaluru\"}", "name": "get_current_weather"}, "id": "call_GuU9siyYfjnx3DfHKzFbRrvD", "type": "function"}]}, {"role": "tool", "parts": [{"type": "text", "content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"}], "tool_call_id": "call_GuU9siyYfjnx3DfHKzFbRrvD"}]`,
|
||||
wantInput: `[
|
||||
{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Bengaluru? Use the tool."}]},
|
||||
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_GuU9siyYfjnx3DfHKzFbRrvD", "arguments": {"city": "Bengaluru"}}]},
|
||||
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_GuU9siyYfjnx3DfHKzFbRrvD", "response": {"city": "Bengaluru", "temp_c": 28, "summary": "Partly cloudy"}}]}
|
||||
]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,15 +25,20 @@ func (f *formatting) traceloop() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// completionToolCalls fills an output message left without parts from the gen_ai.completion.N keys
|
||||
// litellm writes beside its semconv value, whose parts never carry the tool call:
|
||||
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2365
|
||||
// completionToolCalls adds the gen_ai.completion.N.function_call keys litellm writes beside its semconv value,
|
||||
// whose parts never carry the tool calls, to the first output message without a tool call. N numbers the
|
||||
// tool calls, not the choices:
|
||||
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2770
|
||||
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2369
|
||||
func (f *formatting) completionToolCalls() {
|
||||
for i := range f.out.Output {
|
||||
if len(f.out.Output[i].Parts) == 0 {
|
||||
f.out.Output[i].Parts = append(f.out.Output[i].Parts, traceloopToolCalls(indexedAt(f.attributes, traceloopCompletions, i))...)
|
||||
if hasToolCall(f.out.Output[i].Parts) {
|
||||
continue
|
||||
}
|
||||
for _, completion := range indexed(f.attributes, traceloopCompletions) {
|
||||
f.out.Output[i].Parts = append(f.out.Output[i].Parts, traceloopToolCalls(completion)...)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,19 +88,33 @@ func TestFormat_Traceloop(t *testing.T) {
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
// litellm capture, litellm_request span: the semconv output has no part for the tool call, the
|
||||
// gen_ai.completion.0.function_call keys beside it still carry it
|
||||
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2365
|
||||
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2770
|
||||
name: "SemconvEmptyParts_ToolCallFromCompletionKeys",
|
||||
// litellm capture, litellm_request span: the semconv output has no tool call parts; the
|
||||
// gen_ai.completion.N.function_call keys, one N per tool call, carry them
|
||||
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2369
|
||||
name: "SemconvEmptyParts_ParallelToolCallsFromCompletionKeys",
|
||||
attributes: map[string]any{
|
||||
GenAIInputMessages: `[{"role": "user", "parts": [{"type": "text", "content": "What is the weather in Bengaluru in celsius?"}]}]`,
|
||||
GenAIInputMessages: `[{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Paris and in London? Use the tool for each."}]}]`,
|
||||
GenAIOutputMessages: `[{"role": "assistant", "parts": [], "finish_reason": "tool_calls"}]`,
|
||||
"gen_ai.completion.0.function_call.name": "get_current_weather",
|
||||
"gen_ai.completion.0.function_call.arguments": `{"city":"Bengaluru"}`,
|
||||
"gen_ai.completion.0.function_call.arguments": `{"city": "Paris"}`,
|
||||
"gen_ai.completion.1.function_call.name": "get_current_weather",
|
||||
"gen_ai.completion.1.function_call.arguments": `{"city": "London"}`,
|
||||
},
|
||||
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "What is the weather in Bengaluru in celsius?"}]}]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]`,
|
||||
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Paris and in London? Use the tool for each."}]}]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [
|
||||
{"type": "tool_call", "name": "get_current_weather", "arguments": {"city": "Paris"}},
|
||||
{"type": "tool_call", "name": "get_current_weather", "arguments": {"city": "London"}}
|
||||
], "finish_reason": "tool_call"}]`,
|
||||
},
|
||||
{
|
||||
name: "SemconvTextPart_ToolCallFromCompletionKeysAdded",
|
||||
attributes: map[string]any{
|
||||
GenAIOutputMessages: `[{"role": "assistant", "parts": [{"type": "text", "content": "Let me check."}], "finish_reason": "tool_calls"}]`,
|
||||
"gen_ai.completion.0.function_call.name": "get_weather",
|
||||
"gen_ai.completion.0.function_call.arguments": `{"city": "Paris"}`,
|
||||
},
|
||||
wantInput: `[]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Let me check."}, {"type": "tool_call", "name": "get_weather", "arguments": {"city": "Paris"}}], "finish_reason": "tool_call"}]`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,11 +44,12 @@ func (f *formatting) vercel() bool {
|
||||
}
|
||||
|
||||
text, calls := attribute(f.attributes, vercelResponseText), decode(attribute(f.attributes, vercelResponseCalls))
|
||||
switch output := decode(attribute(f.attributes, GenAIOutputMessages)).(type) {
|
||||
case string:
|
||||
text = output
|
||||
case []any:
|
||||
calls = output
|
||||
if output := attribute(f.attributes, GenAIOutputMessages); output != nil {
|
||||
if list, ok := vercelToolCalls(decode(output)); ok {
|
||||
calls = list
|
||||
} else {
|
||||
text = output
|
||||
}
|
||||
}
|
||||
msg := OutputMessage{Role: RoleAssistant, Parts: Parts{}, FinishReason: finishReason(stringOf(attribute(f.attributes, vercelResponseFinished)))}
|
||||
if s := stringOf(text); s != "" {
|
||||
@@ -147,6 +148,23 @@ func vercelPart(item any) Part {
|
||||
return part(GenericPart(p))
|
||||
}
|
||||
|
||||
// vercelToolCalls returns the value as the recorded tool calls, objects with toolName, so an answer that is
|
||||
// itself JSON stays text:
|
||||
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/ai/src/generate-text/generate-text.ts#L780
|
||||
func vercelToolCalls(value any) ([]any, bool) {
|
||||
list, ok := value.([]any)
|
||||
if !ok || len(list) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
for _, item := range list {
|
||||
call, ok := item.(map[string]any)
|
||||
if !ok || stringOf(call["toolName"]) == "" {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return list, true
|
||||
}
|
||||
|
||||
// vercelToolCall reads {toolCallId, toolName, input} or the v1 {toolCallId, toolName, args}; the
|
||||
// arguments may be a JSON string:
|
||||
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-prompt.ts#L126
|
||||
|
||||
@@ -155,5 +155,21 @@ func TestFormat_Vercel(t *testing.T) {
|
||||
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "this should fail"}]}]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
// ai.response.text is the model's text, moved onto the output by the mapper; an answer that is JSON
|
||||
// stays text, as only the recorded tool calls are objects with toolName:
|
||||
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/ai/src/generate-text/generate-text.ts#L385
|
||||
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/ai/src/generate-text/generate-text.ts#L780
|
||||
name: "JSONObjectAnswer_KeptAsText",
|
||||
attributes: map[string]any{"ai.operationId": "ai.generateText.doGenerate", "ai.response.finishReason": "stop", GenAIOutputMessages: `{"answer": 42}`},
|
||||
wantInput: `[]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "{\"answer\": 42}"}], "finish_reason": "stop"}]`,
|
||||
},
|
||||
{
|
||||
name: "JSONListAnswer_KeptAsText",
|
||||
attributes: map[string]any{"ai.operationId": "ai.generateText.doGenerate", "ai.response.finishReason": "stop", GenAIOutputMessages: `[{"name":"Alice"},{"name":"Bob"}]`},
|
||||
wantInput: `[]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "[{\"name\":\"Alice\"},{\"name\":\"Bob\"}]"}], "finish_reason": "stop"}]`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func NewMaterializedPromotePath(target Target, key *telemetrytypes.TelemetryFiel
|
||||
Promote: false,
|
||||
Indexes: indexes,
|
||||
}
|
||||
if err := path.validateAndSetIndexDefaults(); err != nil {
|
||||
if err := path.validateAndSetIndexDefaults(target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return path, nil
|
||||
|
||||
@@ -27,14 +27,24 @@ func (t Target) BaseColumnPrefix() string { return t.BaseColumn + "." }
|
||||
func (t Target) PromotedColumnPrefix() string { return t.PromotedColumn() + "." }
|
||||
|
||||
// IndexExpression folds logs strings to lower case over assumeNotNull for
|
||||
// case-insensitive LIKE searches; traces indexes are a bare type cast.
|
||||
// case-insensitive LIKE searches; traces indexes are the querier's read, which
|
||||
// an index matches only as that exact text.
|
||||
func (t Target) IndexExpression(column, path, jsonDataType string) string {
|
||||
switch t.Entry.Signal {
|
||||
case telemetrytypes.SignalLogs:
|
||||
if t.Entry.Signal == telemetrytypes.SignalLogs {
|
||||
return schemamigrator.JSONSubColumnIndexExpr(column, path, jsonDataType)
|
||||
default:
|
||||
return simpleJSONSubColumnIndexExpr(column, path, jsonDataType)
|
||||
}
|
||||
subColumn := fmt.Sprintf("%s.%s", column, clickhousesql.Identifier(path))
|
||||
expr, _ := tracestelemetryschema.AttributeJSONValueExpr(subColumn, telemetrytypes.MappingJSONDataTypeToFieldDataType[jsonDataType])
|
||||
return expr
|
||||
}
|
||||
|
||||
// IndexDataType folds traces Int64 into Float64, the one type the querier
|
||||
// reads every traces number as.
|
||||
func (t Target) IndexDataType(jsonDataType telemetrytypes.JSONDataType) telemetrytypes.JSONDataType {
|
||||
if t.Entry.Signal != telemetrytypes.SignalLogs && jsonDataType == telemetrytypes.Int64 {
|
||||
return telemetrytypes.Float64
|
||||
}
|
||||
return jsonDataType
|
||||
}
|
||||
|
||||
func (t Target) JSONIndexLookup() telemetrytypes.JSONIndexLookup {
|
||||
@@ -121,14 +131,6 @@ func TargetFor(signal telemetrytypes.Signal, context telemetrytypes.FieldContext
|
||||
return Target{}, false
|
||||
}
|
||||
|
||||
// simpleJSONSubColumnIndexExpr renders column.`path`::Type with the path
|
||||
// quoted as one identifier, the form trace queries read the sub-column with;
|
||||
// the cast unwraps the Nullable the sub-column access returns, which bloom
|
||||
// filter indexes reject.
|
||||
func simpleJSONSubColumnIndexExpr(column, path, jsonDataType string) string {
|
||||
return fmt.Sprintf("%s.%s::%s", column, clickhousesql.Identifier(path), jsonDataType)
|
||||
}
|
||||
|
||||
// reservedPathPrefix returns the target prefix path carries, if any: the base
|
||||
// or promoted column prefix, or the logs body search alias, which is stripped
|
||||
// from queries before metadata lookup and can never match.
|
||||
|
||||
@@ -3,6 +3,7 @@ package promotetypes
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -47,6 +48,22 @@ func TestIndexExpression(t *testing.T) {
|
||||
jsonDataType: "String",
|
||||
want: "attributes.`user-name`::String",
|
||||
},
|
||||
{
|
||||
name: "TracesFloat64_QuerierNumberRead",
|
||||
target: NewTracesAttributesTarget(),
|
||||
column: "attributes",
|
||||
path: "http.status_code",
|
||||
jsonDataType: "Float64",
|
||||
want: "toFloat64OrNull(attributes.`http.status_code`::String)",
|
||||
},
|
||||
{
|
||||
name: "TracesInt64_QuerierNumberRead",
|
||||
target: NewTracesAttributesTarget(),
|
||||
column: "attributes_promoted",
|
||||
path: "retry.count",
|
||||
jsonDataType: "Int64",
|
||||
want: "toFloat64OrNull(attributes_promoted.`retry.count`::String)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
@@ -55,3 +72,22 @@ func TestIndexExpression(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexDataType(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
target Target
|
||||
jsonDataType telemetrytypes.JSONDataType
|
||||
want telemetrytypes.JSONDataType
|
||||
}{
|
||||
{name: "TracesInt64_Float64", target: NewTracesAttributesTarget(), jsonDataType: telemetrytypes.Int64, want: telemetrytypes.Float64},
|
||||
{name: "TracesString_Unchanged", target: NewTracesAttributesTarget(), jsonDataType: telemetrytypes.String, want: telemetrytypes.String},
|
||||
{name: "LogsInt64_Unchanged", target: NewLogsBodyTarget(), jsonDataType: telemetrytypes.Int64, want: telemetrytypes.Int64},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.want, testCase.target.IndexDataType(testCase.jsonDataType))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ func (i *PromotePath) ValidateAndSetDefaults(target Target) error {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cardinal paths can not be promoted or indexed")
|
||||
}
|
||||
|
||||
return i.validateAndSetIndexDefaults()
|
||||
return i.validateAndSetIndexDefaults(target)
|
||||
}
|
||||
|
||||
func (index WrappedIndex) SkipIndexType() (schemamigrator.IndexType, error) {
|
||||
@@ -201,7 +201,7 @@ func validateIndexType(indexType string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *PromotePath) validateAndSetIndexDefaults() error {
|
||||
func (i *PromotePath) validateAndSetIndexDefaults(target Target) error {
|
||||
for idx, index := range i.Indexes {
|
||||
if index.Type == "" {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "index type is required")
|
||||
@@ -221,7 +221,7 @@ func (i *PromotePath) validateAndSetIndexDefaults() error {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "index is not supported for column type: %s", index.FieldDataType)
|
||||
}
|
||||
|
||||
i.Indexes[idx].JSONDataType = jsonDataType
|
||||
i.Indexes[idx].JSONDataType = target.IndexDataType(jsonDataType)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -60,7 +60,7 @@ const (
|
||||
|
||||
// PostableRule is used to create alerting rule from HTTP api.
|
||||
type PostableRule struct {
|
||||
AlertName string `json:"alert" required:"true"`
|
||||
AlertName valuer.DisplayName `json:"alert" required:"true"`
|
||||
AlertType AlertType `json:"alertType" required:"true"`
|
||||
Description string `json:"description,omitempty"`
|
||||
RuleType RuleType `json:"ruleType" required:"true"`
|
||||
@@ -403,7 +403,7 @@ func (r *PostableRule) validate() error {
|
||||
func (r *PostableRule) Validate() error {
|
||||
var errs []error
|
||||
|
||||
if r.AlertName == "" {
|
||||
if r.AlertName.IsZero() {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "alert: field is required"))
|
||||
}
|
||||
|
||||
@@ -585,7 +585,7 @@ func (r *PostableRule) validateV2Alpha1() []error {
|
||||
}
|
||||
|
||||
func testTemplateParsing(rl *PostableRule) (errs []error) {
|
||||
if rl.AlertName == "" {
|
||||
if rl.AlertName.IsZero() {
|
||||
// Not an alerting rule.
|
||||
return errs
|
||||
}
|
||||
@@ -597,7 +597,7 @@ func testTemplateParsing(rl *PostableRule) (errs []error) {
|
||||
tmpl := NewTemplateExpander(
|
||||
context.TODO(),
|
||||
defs+text,
|
||||
"__alert_"+rl.AlertName,
|
||||
"__alert_"+rl.AlertName.StringValue(),
|
||||
tmplData,
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestParseIntoRule(t *testing.T) {
|
||||
kind: RuleDataKindJson,
|
||||
expectError: false,
|
||||
validate: func(t *testing.T, rule *PostableRule) {
|
||||
if rule.AlertName != "TestAlert" {
|
||||
if rule.AlertName.StringValue() != "TestAlert" {
|
||||
t.Errorf("Expected alert name 'TestAlert', got '%s'", rule.AlertName)
|
||||
}
|
||||
if rule.RuleType != RuleTypeThreshold {
|
||||
|
||||
@@ -48,6 +48,15 @@ func NewStatsFromRules(rules []*StorableRule) map[string]any {
|
||||
return stats
|
||||
}
|
||||
|
||||
func (rule *StorableRule) ToPostableRuleForCloning() (*PostableRule, error) {
|
||||
postable := &PostableRule{}
|
||||
if err := json.Unmarshal([]byte(rule.Data), postable); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
postable.AlertName = postable.AlertName.GenerateCloneName()
|
||||
return postable, nil
|
||||
}
|
||||
|
||||
// RuleAlert represents an alert associated with a rule, used when filtering by metric name.
|
||||
type RuleAlert struct {
|
||||
AlertName string
|
||||
|
||||
110
pkg/valuer/display_name.go
Normal file
110
pkg/valuer/display_name.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package valuer
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
var _ Valuer = (*DisplayName)(nil)
|
||||
|
||||
var cloneCopySuffixRegex = regexp.MustCompile(`^(.*) - Copy(?: \((\d+)\))?$`)
|
||||
|
||||
// DisplayName is a free-text, user-facing name; unlike String it is stored exactly as given.
|
||||
type DisplayName struct {
|
||||
val string
|
||||
}
|
||||
|
||||
func NewDisplayName(val string) DisplayName {
|
||||
return DisplayName{val: val}
|
||||
}
|
||||
|
||||
func (name DisplayName) IsZero() bool {
|
||||
return name.val == ""
|
||||
}
|
||||
|
||||
func (name DisplayName) StringValue() string {
|
||||
return name.val
|
||||
}
|
||||
|
||||
func (name DisplayName) String() string {
|
||||
return name.val
|
||||
}
|
||||
|
||||
// GenerateCloneName appends " - Copy" or bumps an existing " - Copy (n)" counter.
|
||||
func (name DisplayName) GenerateCloneName() DisplayName {
|
||||
base, count := name.val, 0
|
||||
if m := cloneCopySuffixRegex.FindStringSubmatch(name.val); m != nil {
|
||||
base = m[1]
|
||||
count = 1 // bare " - Copy"
|
||||
if m[2] != "" {
|
||||
count, _ = strconv.Atoi(m[2])
|
||||
}
|
||||
}
|
||||
|
||||
if count++; count > 1 {
|
||||
return NewDisplayName(fmt.Sprintf("%s - Copy (%d)", base, count))
|
||||
}
|
||||
return NewDisplayName(fmt.Sprintf("%s - Copy", base))
|
||||
}
|
||||
|
||||
func (name DisplayName) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(name.val)
|
||||
}
|
||||
|
||||
// UnmarshalJSON treats null as a no-op, matching how a plain string field decodes.
|
||||
func (name *DisplayName) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*name = NewDisplayName(str)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (name DisplayName) Value() (driver.Value, error) {
|
||||
return name.val, nil
|
||||
}
|
||||
|
||||
func (name *DisplayName) Scan(val any) error {
|
||||
if name == nil {
|
||||
return errors.Newf(errors.TypeInternal, ErrCodeUnknownValuerScan, "display name: (nil \"%s\")", reflect.TypeOf(name).String())
|
||||
}
|
||||
|
||||
if val == nil {
|
||||
*name = NewDisplayName("")
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := val.(string)
|
||||
if !ok {
|
||||
return errors.Newf(errors.TypeInternal, ErrCodeUnknownValuerScan, "display name: (non-string \"%s\")", reflect.TypeOf(val).String())
|
||||
}
|
||||
|
||||
*name = NewDisplayName(str)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (name *DisplayName) UnmarshalText(text []byte) error {
|
||||
*name = NewDisplayName(string(text))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (name DisplayName) MarshalText() ([]byte, error) {
|
||||
return []byte(name.val), nil
|
||||
}
|
||||
|
||||
func (name *DisplayName) UnmarshalParam(param string) error {
|
||||
*name = NewDisplayName(param)
|
||||
return nil
|
||||
}
|
||||
112
pkg/valuer/display_name_test.go
Normal file
112
pkg/valuer/display_name_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package valuer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDisplayName_GenerateCloneName(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{name: "PlainName_CopySuffixAppended", input: "High CPU", expected: "High CPU - Copy"},
|
||||
{name: "CopySuffix_BumpedToTwo", input: "High CPU - Copy", expected: "High CPU - Copy (2)"},
|
||||
{name: "NumberedSuffix_Incremented", input: "High CPU - Copy (2)", expected: "High CPU - Copy (3)"},
|
||||
{name: "MultiDigitSuffix_Incremented", input: "svc - Copy (41)", expected: "svc - Copy (42)"},
|
||||
{name: "ContainsCopyWord_NotTreatedAsSuffix", input: "Copy of things", expected: "Copy of things - Copy"},
|
||||
{name: "RepeatedCopy_OnlyTrailingMarkerParsed", input: "Prod - Copy - Copy", expected: "Prod - Copy - Copy (2)"},
|
||||
{name: "EmptyName_SuffixOnly", input: "", expected: " - Copy"},
|
||||
{
|
||||
name: "LongName_NotTruncated",
|
||||
input: "Muting And Manual Resolving Log Based Alert 7 October With A Very Long Descriptive Name That Goes On And On Past One Hundred Twenty Eight Characters",
|
||||
expected: "Muting And Manual Resolving Log Based Alert 7 October With A Very Long Descriptive Name That Goes On And On Past One Hundred Twenty Eight Characters - Copy",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.expected, NewDisplayName(testCase.input).GenerateCloneName().StringValue())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayName_UnmarshalJSON(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
existing string
|
||||
input string
|
||||
expected string
|
||||
expectErr bool
|
||||
}{
|
||||
{name: "MixedCaseAndWhitespace_Preserved", input: `" High CPU Mixed "`, expected: " High CPU Mixed "},
|
||||
{name: "Null_KeepsExistingValue", existing: "kept", input: `null`, expected: "kept"},
|
||||
{name: "NonString_Error", input: `42`, expectErr: true},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
name := NewDisplayName(testCase.existing)
|
||||
|
||||
err := json.Unmarshal([]byte(testCase.input), &name)
|
||||
if testCase.expectErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.expected, name.StringValue())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayName_MarshalJSON(t *testing.T) {
|
||||
out, err := json.Marshal(struct {
|
||||
Name DisplayName `json:"name"`
|
||||
}{Name: NewDisplayName(" High CPU ")})
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"name": " High CPU "}`, string(out))
|
||||
}
|
||||
|
||||
func TestDisplayName_Scan(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input any
|
||||
expected string
|
||||
expectErr bool
|
||||
}{
|
||||
{name: "String_Scanned", input: "High CPU", expected: "High CPU"},
|
||||
{name: "Nil_ScannedAsZero", input: nil, expected: ""},
|
||||
{name: "NonString_Error", input: 42, expectErr: true},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
name := NewDisplayName("previous")
|
||||
|
||||
err := name.Scan(testCase.input)
|
||||
if testCase.expectErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.expected, name.StringValue())
|
||||
|
||||
value, err := name.Value()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.expected, value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayName_TextRoundTrip(t *testing.T) {
|
||||
text, err := NewDisplayName(" High CPU ").MarshalText()
|
||||
require.NoError(t, err)
|
||||
|
||||
var name DisplayName
|
||||
require.NoError(t, name.UnmarshalText(text))
|
||||
assert.Equal(t, " High CPU ", name.StringValue())
|
||||
}
|
||||
15
tests/fixtures/materialized.py
vendored
15
tests/fixtures/materialized.py
vendored
@@ -9,19 +9,26 @@ MATERIALIZED_TABLES = {
|
||||
"traces": ("signoz_traces", "signoz_index_v3", "distributed_signoz_index_v3"),
|
||||
}
|
||||
|
||||
# data type -> (column type, attribute map)
|
||||
MATERIALIZED_COLUMN_TYPES = {
|
||||
"string": ("LowCardinality(String)", "attributes_string"),
|
||||
"number": ("Float64", "attributes_number"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(name="materialize_attribute", scope="function")
|
||||
def materialize_attribute(signoz: types.SigNoz) -> Generator[Callable[[str, str, bool], str]]:
|
||||
def materialize_attribute(signoz: types.SigNoz) -> Generator[Callable[..., str]]:
|
||||
"""Teardown drops the columns and every index whose expression mentions a materialized key."""
|
||||
cluster = signoz.telemetrystore.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"]
|
||||
created: list[tuple[str, str, str]] = []
|
||||
|
||||
def materialize(signal: str, key: str, indexed: bool) -> str:
|
||||
def materialize(signal: str, key: str, indexed: bool, data_type: str = "string") -> str:
|
||||
database, local_table, distributed_table = MATERIALIZED_TABLES[signal]
|
||||
column = f"attribute_string_{key.replace('.', '$$')}"
|
||||
column_type, attribute_map = MATERIALIZED_COLUMN_TYPES[data_type]
|
||||
column = f"attribute_{data_type}_{key.replace('.', '$$')}"
|
||||
created.append((signal, key, column))
|
||||
for table in (local_table, distributed_table):
|
||||
signoz.telemetrystore.conn.query(f"ALTER TABLE {database}.{table} ON CLUSTER '{cluster}' ADD COLUMN IF NOT EXISTS `{column}` LowCardinality(String) DEFAULT attributes_string['{key}']")
|
||||
signoz.telemetrystore.conn.query(f"ALTER TABLE {database}.{table} ON CLUSTER '{cluster}' ADD COLUMN IF NOT EXISTS `{column}` {column_type} DEFAULT {attribute_map}['{key}']")
|
||||
if indexed:
|
||||
signoz.telemetrystore.conn.query(f"ALTER TABLE {database}.{local_table} ON CLUSTER '{cluster}' ADD INDEX IF NOT EXISTS `{column}_idx` `{column}` TYPE bloom_filter(0.01) GRANULARITY 64")
|
||||
return column
|
||||
|
||||
@@ -5,7 +5,9 @@ import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.traces import Traces
|
||||
|
||||
PROMOTED_PATHS = "/api/v1/promoted_paths"
|
||||
MATERIALIZED_PATH = "/api/v1/promoted_paths/materialized"
|
||||
PROBE_INDEX_EXPRS_QUERY = "SELECT expr FROM system.data_skipping_indices WHERE database = 'signoz_traces' AND table = 'signoz_index_v3' AND expr LIKE 'CAST(%materialized.%probe%'"
|
||||
|
||||
@@ -14,7 +16,7 @@ def test_index_materialized_paths(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
materialize_attribute: Callable[[str, str, bool], str],
|
||||
materialize_attribute: Callable[..., str],
|
||||
) -> None:
|
||||
materialize_attribute("traces", "materialized.probe", True)
|
||||
materialize_attribute("traces", "materialized.unindexed_probe", False)
|
||||
@@ -60,3 +62,40 @@ def test_index_materialized_paths(
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
assert "materialized.probe" not in {path["path"] for path in resp.json()["data"]["indexed"]}
|
||||
assert "materialized.probe" in {path["path"] for path in resp.json()["data"]["skipped"]}
|
||||
|
||||
|
||||
def test_index_materialized_number_path(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
materialize_attribute: Callable[..., str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
materialize_attribute("traces", "materialized.number_probe", True, "number")
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(MATERIALIZED_PATH),
|
||||
json={"signal": "traces"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
indexed = {path["path"]: path["indexes"] for path in resp.json()["data"]["indexed"]}
|
||||
assert indexed["materialized.number_probe"] == [{"fieldDataType": "number", "type": "bloom_filter(0.01)", "granularity": 64}]
|
||||
exprs = signoz.telemetrystore.conn.query("SELECT expr FROM system.data_skipping_indices WHERE database = 'signoz_traces' AND table = 'signoz_index_v3' AND expr LIKE '%materialized.number_probe%'").result_rows
|
||||
assert [row[0] for row in exprs] == ["toFloat64OrNull(CAST(attributes.`materialized.number_probe`, 'String'))"]
|
||||
|
||||
resp = requests.get(
|
||||
signoz.self.host_configs["8080"].get(PROMOTED_PATHS),
|
||||
params={"signal": "traces", "context": "attribute"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
listed = {path["path"]: path.get("indexes") for path in resp.json()["data"]}
|
||||
assert listed["materialized.number_probe"] == [{"fieldDataType": "float64", "type": "bloom_filter(0.01)", "granularity": 64}]
|
||||
|
||||
# a non-numeric value must not fail the insert on the numeric index
|
||||
insert_traces([Traces(attributes={"materialized.number_probe": 200}), Traces(attributes={"materialized.number_probe": "not-a-number"})])
|
||||
assert signoz.telemetrystore.conn.query("SELECT count() FROM signoz_traces.distributed_signoz_index_v3 WHERE attributes.`materialized.number_probe` IS NOT NULL").result_rows == [(2,)]
|
||||
|
||||
@@ -335,6 +335,63 @@ def test_traces_attributes_json_collision_and_map_parity(
|
||||
assert set(series.keys()) == expected, label
|
||||
|
||||
|
||||
def test_traces_attributes_json_number_reads(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
seed_attribute_evolution: Callable[[str, datetime], None],
|
||||
) -> None:
|
||||
"""A JSON number reads like the attributes_number map: a numeric string as its number, an
|
||||
integer above 2^53 rounded, and a non-numeric string as no number."""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
evolution_time = datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=30)
|
||||
seed_attribute_evolution("traces", evolution_time)
|
||||
|
||||
hit = evolution_time + timedelta(minutes=5)
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=hit,
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name=route,
|
||||
attributes={"http.route": route, "app.code": code},
|
||||
attribute_write_mode="dual_write",
|
||||
)
|
||||
for route, code in [("/int", 404), ("/numeric_string", "404"), ("/big", 2**53 + 1), ("/text", "abc")]
|
||||
]
|
||||
)
|
||||
|
||||
start_ms = int((hit - timedelta(minutes=1)).timestamp() * 1000)
|
||||
end_ms = int((hit + timedelta(minutes=1)).timestamp() * 1000)
|
||||
|
||||
for label, filter_expression, expected in [
|
||||
("numeric_string_eq", "app.code = 404", {"/int", "/numeric_string"}),
|
||||
("big_int_rounded", f"app.code = {2**53}", {"/big"}),
|
||||
("text_not_a_number", "app.code > 0", {"/int", "/numeric_string", "/big"}),
|
||||
]:
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
request_type=RequestType.TIME_SERIES,
|
||||
queries=[
|
||||
build_traces_scalar_query(
|
||||
aggregations=[build_aggregation("count()")],
|
||||
group_by=[build_group_by_field("http.route", field_context="attribute")],
|
||||
filter_expression=filter_expression,
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, label
|
||||
aggregations = (response.json()["data"]["data"]["results"][0].get("aggregations")) or []
|
||||
series = index_series_by_label(aggregations[0]["series"], "http.route") if aggregations else {}
|
||||
assert set(series.keys()) == expected, label
|
||||
|
||||
|
||||
def test_traces_attributes_json_list_view(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
|
||||
80
tests/integration/tests/queriertraces/14_skip_indexes.py
Normal file
80
tests/integration/tests/queriertraces/14_skip_indexes.py
Normal file
@@ -0,0 +1,80 @@
|
||||
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.querier import RequestType, build_aggregation, build_traces_scalar_query, get_preview_statements, make_preview_query_request
|
||||
from fixtures.traces import ATTRIBUTE_JSON_ROLLOUT_TIME, Traces
|
||||
|
||||
PROMOTED_PATHS = "/api/v1/promoted_paths"
|
||||
|
||||
|
||||
def test_traces_filters_use_skip_indexes(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
seed_attribute_evolution: Callable[[str, datetime], None],
|
||||
request: pytest.FixtureRequest,
|
||||
) -> None:
|
||||
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(PROMOTED_PATHS),
|
||||
json=[
|
||||
{"signal": "traces", "context": "attribute", "path": "index.number_probe", "indexes": [{"fieldDataType": "number", "type": "bloom_filter(0.01)", "granularity": 64}]},
|
||||
{"signal": "traces", "context": "attribute", "path": "index.string_probe", "indexes": [{"fieldDataType": "string", "type": "bloom_filter(0.01)", "granularity": 64}]},
|
||||
],
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.CREATED, resp.text
|
||||
cluster = signoz.telemetrystore.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"]
|
||||
for index_name in ("attributes.index.number_probe_float64_bloom_filter", "attributes.index.string_probe_string_bloom_filter"):
|
||||
request.addfinalizer(lambda index_name=index_name: signoz.telemetrystore.conn.query(f"ALTER TABLE signoz_traces.signoz_index_v3 ON CLUSTER '{cluster}' DROP INDEX IF EXISTS `{index_name}`"))
|
||||
|
||||
resp = requests.post(
|
||||
signoz.self.host_configs["8080"].get(PROMOTED_PATHS),
|
||||
json=[{"signal": "traces", "context": "attribute", "path": "index.bool_probe", "indexes": [{"fieldDataType": "bool", "type": "bloom_filter(0.01)", "granularity": 64}]}],
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.BAD_REQUEST, resp.text
|
||||
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_traces([Traces(timestamp=now - timedelta(seconds=10), attributes={"index.number_probe": 200, "index.string_probe": "probe"})])
|
||||
|
||||
for filter_expression, index_name in [
|
||||
("index.number_probe = 200", "attributes.index.number_probe_float64_bloom_filter"),
|
||||
("index.string_probe = 'probe'", "attributes.index.string_probe_string_bloom_filter"),
|
||||
("name = 'probe'", "idx_name"),
|
||||
("duration_nano > 100", "idx_duration"),
|
||||
("kind = 2", "idx_kind"),
|
||||
("kind_string = 'Server'", "idx_kind_string"),
|
||||
("status_code_string = 'Ok'", "idx_status_code_string"),
|
||||
("response_status_code = '200'", "idx_response_statusCode"),
|
||||
("http_method = 'GET'", "idx_http_method"),
|
||||
("http_url = 'probe'", "idx_http_url"),
|
||||
("http_host = 'probe'", "idx_http_host"),
|
||||
("trace_id = '0af7651916cd43dd8448eb211c80319c'", "idx_trace_id"),
|
||||
("span_id = 'b7ad6b7169203331'", "idx_span_id"),
|
||||
]:
|
||||
statements = get_preview_statements(
|
||||
make_preview_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
request_type=RequestType.SCALAR,
|
||||
# count() over primary-key filters is answered by the exact count projection, which reads no index
|
||||
queries=[build_traces_scalar_query(aggregations=[build_aggregation("sum(duration_nano)")], filter_expression=filter_expression)],
|
||||
),
|
||||
"A",
|
||||
)
|
||||
used = {step["name"] for statement in statements for read in (statement["granules"] or {}).get("reads", []) for step in read["steps"]}
|
||||
assert index_name in used, f"{filter_expression}: {used}"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user