Compare commits

...

6 Commits

Author SHA1 Message Date
Aditya Singh
6c6632f940 test(saved-views): authz stories for each missing permission (#13172)
Some checks failed
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / staging (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
#### Description
- The saved views stories now have an `Authz` folder, with one story per
missing permission. The stories follow the dashboard and billing authz
stories.
- `NoList`, `NoCreate`, `NoRead`, `NoUpdate` and `NoDelete` each deny
one permission.
- `NoCreateSaveMenu`, `NoUpdateSaveMenu` and `NoReadOpenView` show the
same denials in the `✓` menu and on an open view.
  - `ReadOnly` denies create, update and delete together.
- `CheckLoading` and `CheckFailed` cover a pending check and a failed
check.
- Each story checks what is disabled and what still works.
- The main stories move to `Components/Saved Views/Overview`, and
`NoAccess` is now `NoList`.
- The header `✓` and `✕` buttons get a gap again. After the ui upgrade
they are link buttons without padding, so they touched.

#### Issues closed by this PR

#### Screenshot

Before

<img width="608" height="120" alt="image"
src="https://github.com/user-attachments/assets/e9fcf890-3355-4cd5-82e4-a4e0221aaacd"
/>


After

<img width="734" height="212" alt="image"
src="https://github.com/user-attachments/assets/7ce0d2d3-7155-4365-bbab-aad0fef6fc29"
/>


Part of https://github.com/SigNoz/events-pod/issues/71
2026-10-16 19:26:53 +00:00
Aditya Singh
39b752c428 feat(logs): move the list row filter chips off v3 attribute keys (#13148)
#### Description

- The "Add to query" chip on a logs list row no longer fetches v3
`attribute_keys` before it adds the filter. The hook builds the filter
item with the same util the log details drawer uses, then redirects. The
stored key has an empty data type and context. The filter expression is
built from key and value, so the request is unchanged.
- `LogDetail` dropped two props it never read, `onAddToQuery` and
`onClickActionItem`, from its type and from six call sites.
- Removed the code that only existed for those props: the `EntityLogs`
callback, `generateFilterQuery`, `getOldLogsOperatorFromNew` and
`ActionItemProps`. Removed `onGroupByAttribute` from `useActiveLog`,
which nothing called.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/160


#### Screenshot



https://github.com/user-attachments/assets/7cce500a-6dbf-45cb-937f-7366d515b0b9



https://github.com/user-attachments/assets/f5cbd1bd-0863-4633-ad6e-2fd15f7ae7c5



https://github.com/user-attachments/assets/2fb7dbeb-c474-4730-9a9d-363257a7fff7



#### Additional Information

- Only one user path runs the changed hook: the field chips on a log row
in the logs explorer list and in live logs.
- The merged where clause shows adjacent clauses without `AND`. The
grammar allows an implicit AND and the query runs. This is pre-existing
in `convertFiltersToExpressionWithExistingQuery` and is not changed
here.
- Part of https://github.com/SigNoz/events-pod/issues/156.
2026-10-16 18:32:25 +00:00
Aditya Singh
dccd75e1de feat(saved-views): gate saved views on their permissions (#13027)
#### Description
- Saved views now follow the `saved-view` permissions:
  - `list` gates the views panel.
  - `create` gates `+`, "Save as new view" and "Create view".
- `read` gates opening a view. List rows for views the user cannot read
are disabled, with the reason on hover.
  - `update` and `delete` gate the row menu and the `✓` menu, per view.
- Opening a view checks `read` first. Without it, the view is not
fetched and the header shows "No view access".
- Denied buttons and menu items stay visible. They are disabled and show
the reason on hover. They use `AuthZButton` and `AuthZDropdown`.
- The row menu has "Copy ID", so a user can copy a view's id to use in a
role.
- The authz generator now includes `saved-view`, and roles settings gets
a Saved Views panel.
- Stories for the header and the views panel are under `Components/Saved
Views`.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/76


#### Screenshot / Screenrecording



https://github.com/user-attachments/assets/c6505fb1-5a57-4c61-ad7e-09531d500002

<img width="490" height="982" alt="Screenshot 2026-10-15 at 12 13 00 PM"
src="https://github.com/user-attachments/assets/b8ab7760-c0c9-4ee7-8201-c8dae8cdfd57"
/>


<img width="349" height="567" alt="Screenshot 2026-10-15 at 12 13 32 PM"
src="https://github.com/user-attachments/assets/ee7e0ebc-e929-4ff1-a624-e605fe09cb95"
/>


<img width="693" height="955" alt="Screenshot 2026-10-15 at 12 14 10 PM"
src="https://github.com/user-attachments/assets/023c022a-5105-471a-acce-835251f9ea3f"
/>


<img width="1458" height="2016" alt="image"
src="https://github.com/user-attachments/assets/c33d5e65-dae7-454e-a5e0-2b7796e204cc"
/>


#### Additional Information
- The change is behind `SAVED_VIEW_ENABLED`.
- On staging, the per view update and delete checks do not show in the
AuthZ DevTools. List and create work. The backend team must check this
before the PR moves out of draft.
- The save as new prompt and the unsaved changes nudge are not in this
PR.
2026-10-16 17:32:31 +00:00
Nikhil Mantri
4f3e7fa50e feat(ruler): add clone alert rule API (#13093)
#### Description

- Adds `POST /api/v2/rules/{id}/clone`, a server-side clone for alert
rules. The caller passes only the source rule's id and gets back the
newly created rule.
- Until now, cloning was done entirely by the frontend, which re-posted
the whole rule it had in memory to the create endpoint. The new endpoint
copies the rule from what is actually persisted, so clones are
consistent regardless of the client.
- The clone keeps every modeled field of the source, including its
enabled or disabled state, and is owned by the user who cloned it.
Cloning fails with a 400 that names the source rule when it no longer
passes create validation, for example when its channel has been deleted.
- The name follows the convention introduced by the dashboard clone API:
`My Alert - Copy`, then `My Alert - Copy (2)`, `My Alert - Copy (3)`
when a copy is cloned again. The same suffix logic was adopted, minus
the length cap, since alert names have no limit today.
- The endpoint reuses the existing create path, so validation,
notification routing and evaluator start-up behave exactly as a manual
create.
- Includes unit tests for the naming and copy logic and an integration
test covering clone, clone of a clone, unknown id and malformed id.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#228

#### Additional Information

- Backend only. The frontend still uses the client-side approach and can
switch to this endpoint in a follow-up.
- Names are not de-duplicated against existing rules, matching the
dashboard clone behaviour. Rule names are not unique today, so a
collision is harmless.
2026-10-16 17:23:32 +00:00
Nityananda Gohain
531504c523 fix(tracedetail): keep litellm tool calls and AI SDK JSON answers, mark redacted media (#13166)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- AI SDK: a JSON answer such as `{"answer": 42}` was dropped, and a JSON
list was read as nameless tool calls. The output is now tool calls only
when every item has `toolName`;
  anything else is kept as text.
- litellm: parallel calls (`gen_ai.completion.N` numbers tool calls, not
choices), a call next to text, and the history's `tool_calls` /
`tool_call_id` kept beside `parts` now become tool call and tool
response parts.
- Redaction placeholders (OpenInference's `__REDACTED__`, OpenRouter's
`[OR_REDACTED]`) no longer become `uri` parts: media without a URL
scheme is a generic part, with a warning.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of
https://github.com/SigNoz/nerve-pod/issues/266#issuecomment-6198620761

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
- Checked against real spans from litellm 1.105.1, OpenInference 0.1.49
and AI SDK 5.0.277 run against a local stub server. Replaying the
static-telemetry-generator captures changes only the four affected
spans.
- OpenInference redacts `input.value` too, so a redacted image cannot be
recovered from the raw request.
2026-10-16 14:23:02 +00:00
Nikhil Soni
b770c249a0 feat(querier): read traces json numbers as toFloat64OrNull and index that (#13101)
#### Description

- The querier reads a traces JSON number as
`toFloat64OrNull(p::String)`: every numeric type and a numeric string
read as Float64. Replaces the `dynamicType` gate around
`accurateCastOrNull`.
- The `toFloat64` collision cast leaves a read already starting
`toFloat64OrNull(` bare.

Why avoiding the `toFloat64` wrapper on json number queries?
The wrapper exists for the map (`Bad get: has UInt64, requested
Float64`) and hid the read from its skip index.



#### Additional Information

- A number-typed key now matches a value stored as a numeric string
(`"404"`), and an integer above 2^53 reads rounded instead of NULL.

#### Performance Impact
Almost none

```
case: num = all values already numbers
case: mixed = values are numbers and stringified mixed
rows: 20M
┌──────────────────────────────────────────────────────┬───────┬─────────────┬─────────┐
│                         Read                         │  num  │    mixed    │   ≈     │
│                                                      │       │             │ ns/row  │
├──────────────────────────────────────────────────────┼───────┼─────────────┼─────────┤
│ new toFloat64OrNull(p::String)                       │ 0.56s │ 0.56s       │ 28      │
├──────────────────────────────────────────────────────┼───────┼─────────────┼─────────┤
│ old dynamicType gate (the QB wrapped it in           │ 0.57s │ 0.77s       │ 29 / 38 │
│ toFloat64)                                           │       │             │         │
└──────────────────────────────────────────────────────┴───────┴─────────────┴─────────┘
```
2026-10-16 14:04:08 +00:00
101 changed files with 2924 additions and 476 deletions

View File

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

View File

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

View File

@@ -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(),

View File

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

View File

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

View File

@@ -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();

View File

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

View File

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

View File

@@ -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']>;

View File

@@ -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}
/>,

View File

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

View File

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

View File

@@ -259,8 +259,6 @@ function LiveLogsList({
selectedTab={selectedTab}
log={activeLog}
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
logs={formattedLogs}
onNavigateLog={handleSetActiveLog}

View File

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

View File

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

View File

@@ -381,8 +381,6 @@ function LogsExplorerList({
selectedTab={selectedTab}
log={activeLog}
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
logs={logs}
onNavigateLog={handleSetActiveLog}

View File

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

View File

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

View File

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

View File

@@ -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.',

View File

@@ -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>
);
}

View File

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

View File

@@ -78,7 +78,7 @@
display: flex;
align-items: center;
flex-shrink: 0;
gap: var(--spacing-1);
gap: var(--spacing-4);
}
.collapseIcon {

View File

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

View File

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

View File

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

View File

@@ -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), []);

View File

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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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();
});

View File

@@ -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),
);
});
});

View File

@@ -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'));

View File

@@ -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'));

View File

@@ -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'));

View File

@@ -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();

View File

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

View File

@@ -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');
}

View File

@@ -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,
},
];

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View 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}`);

View File

@@ -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);
},
};

View 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()}`;
};

View File

@@ -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);
},
};

View File

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

View 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');
});
});

View File

@@ -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());

View File

@@ -25,5 +25,4 @@ export type UseActiveLog = {
operator: string,
dataType?: DataTypes,
) => void;
onGroupByAttribute: (fieldKey: string, dataType?: DataTypes) => Promise<void>;
};

View File

@@ -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,
};
};

View File

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

View File

@@ -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}`);

View File

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

View File

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

View File

@@ -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,
],
);

View File

@@ -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"},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),

View File

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

View File

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

View File

@@ -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(),

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),
})
}

View File

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

View File

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

View File

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

View File

@@ -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'))",
},
}

View File

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

View File

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

View File

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

View File

@@ -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: `[]`,
},
})
}

View File

@@ -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: `[]`,
},
})
}

View File

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

View File

@@ -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: `[]`,
},
})
}

View File

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

View File

@@ -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"}]`,
},
})
}

View File

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

View File

@@ -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"}]`,
},
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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())
}

View File

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

View File

@@ -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,)]

View File

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

View 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