diff --git a/.github/workflows/staging-deployment.yaml b/.github/workflows/staging-deployment.yaml index 455ecbce8c..a2ff80354f 100644 --- a/.github/workflows/staging-deployment.yaml +++ b/.github/workflows/staging-deployment.yaml @@ -30,6 +30,7 @@ jobs: GCP_PROJECT: ${{ secrets.GCP_PROJECT }} GCP_ZONE: ${{ secrets.GCP_ZONE }} GCP_INSTANCE: ${{ secrets.GCP_INSTANCE }} + CLOUDSDK_CORE_DISABLE_PROMPTS: 1 run: | read -r -d '' COMMAND <>(); - const { trackPageView, trackEvent } = useAnalytics(); + const { trackPageView } = useAnalytics(); const { hostname, pathname } = window.location; @@ -199,7 +200,7 @@ function App(): JSX.Element { LOCALSTORAGE.THEME_ANALYTICS_V1, ); if (!isThemeAnalyticsSent) { - trackEvent('Theme Analytics', { + logEvent('Theme Analytics', { theme: isDarkMode ? THEME_MODE.DARK : THEME_MODE.LIGHT, user: pick(user, ['email', 'userId', 'name']), org, diff --git a/frontend/src/api/common/logEvent.ts b/frontend/src/api/common/logEvent.ts index 212d382d77..a1bf3dba7c 100644 --- a/frontend/src/api/common/logEvent.ts +++ b/frontend/src/api/common/logEvent.ts @@ -1,4 +1,4 @@ -import axios from 'api'; +import { ApiBaseInstance as axios } from 'api'; import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; @@ -21,6 +21,7 @@ const logEvent = async ( payload: response.data.data, }; } catch (error) { + console.error(error); return ErrorResponseHandler(error as AxiosError); } }; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 1ec4cda601..7f5e2d476c 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -96,6 +96,10 @@ const interceptorRejected = async ( } }; +const interceptorRejectedBase = async ( + value: AxiosResponse, +): Promise> => Promise.reject(value); + const instance = axios.create({ baseURL: `${ENVIRONMENT.baseURL}${apiV1}`, }); @@ -140,6 +144,18 @@ ApiV4Instance.interceptors.response.use( ApiV4Instance.interceptors.request.use(interceptorsRequestResponse); // +// axios Base +export const ApiBaseInstance = axios.create({ + baseURL: `${ENVIRONMENT.baseURL}${apiV1}`, +}); + +ApiBaseInstance.interceptors.response.use( + interceptorsResponse, + interceptorRejectedBase, +); +ApiBaseInstance.interceptors.request.use(interceptorsRequestResponse); +// + // gateway Api V1 export const GatewayApiV1Instance = axios.create({ baseURL: `${ENVIRONMENT.baseURL}${gatewayApiV1}`, diff --git a/frontend/src/components/Tags/Tags.tsx b/frontend/src/components/Tags/Tags.tsx index ac38e0e58c..7257594e7e 100644 --- a/frontend/src/components/Tags/Tags.tsx +++ b/frontend/src/components/Tags/Tags.tsx @@ -5,7 +5,6 @@ import { Button } from 'antd'; import { Tag } from 'antd/lib'; import Input from 'components/Input'; import { Check, X } from 'lucide-react'; -import { TweenOneGroup } from 'rc-tween-one'; import React, { Dispatch, SetStateAction, useState } from 'react'; function Tags({ tags, setTags }: AddTagsProps): JSX.Element { @@ -46,41 +45,19 @@ function Tags({ tags, setTags }: AddTagsProps): JSX.Element { func(value); }; - const forMap = (tag: string): React.ReactElement => ( - - { - e.preventDefault(); - handleClose(tag); - }} - > - {tag} - - - ); - - const tagChild = tags.map(forMap); - - const renderTagsAnimated = (): React.ReactElement => ( - { - if (e.type === 'appear' || e.type === 'enter') { - (e.target as any).style = 'display: inline-block'; - } - }} - > - {tagChild} - - ); - return (
- {renderTagsAnimated()} + {tags.map((tag) => ( + handleClose(tag)} + > + {tag} + + ))} + {inputVisible && (
- +
)} diff --git a/frontend/src/components/facingIssueBtn/FacingIssueBtn.tsx b/frontend/src/components/facingIssueBtn/FacingIssueBtn.tsx index 2a4b07aa22..093c2b8f02 100644 --- a/frontend/src/components/facingIssueBtn/FacingIssueBtn.tsx +++ b/frontend/src/components/facingIssueBtn/FacingIssueBtn.tsx @@ -16,6 +16,7 @@ export interface FacingIssueBtnProps { buttonText?: string; className?: string; onHoverText?: string; + intercomMessageDisabled?: boolean; } function FacingIssueBtn({ @@ -25,11 +26,12 @@ function FacingIssueBtn({ buttonText = '', className = '', onHoverText = '', + intercomMessageDisabled = false, }: FacingIssueBtnProps): JSX.Element | null { const handleFacingIssuesClick = (): void => { logEvent(eventName, attributes); - if (window.Intercom) { + if (window.Intercom && !intercomMessageDisabled) { window.Intercom('showNewMessage', defaultTo(message, '')); } }; @@ -62,6 +64,7 @@ FacingIssueBtn.defaultProps = { buttonText: '', className: '', onHoverText: '', + intercomMessageDisabled: false, }; export default FacingIssueBtn; diff --git a/frontend/src/container/AllError/index.tsx b/frontend/src/container/AllError/index.tsx index d571c3dba7..0dd46c0a64 100644 --- a/frontend/src/container/AllError/index.tsx +++ b/frontend/src/container/AllError/index.tsx @@ -423,9 +423,9 @@ function AllErrors(): JSX.Element { )?.tagValue; logEvent('Exception: List page visited', { - numberOfExceptions: errorCountResponse.data?.payload, + numberOfExceptions: errorCountResponse?.data?.payload, selectedEnvironments, - resourceAttributeUsed: !!queries.length, + resourceAttributeUsed: !!queries?.length, }); logEventCalledRef.current = true; } diff --git a/frontend/src/container/BillingContainer/BillingContainer.tsx b/frontend/src/container/BillingContainer/BillingContainer.tsx index 248819723c..e366f068b2 100644 --- a/frontend/src/container/BillingContainer/BillingContainer.tsx +++ b/frontend/src/container/BillingContainer/BillingContainer.tsx @@ -19,10 +19,10 @@ import { ColumnsType } from 'antd/es/table'; import updateCreditCardApi from 'api/billing/checkout'; import getUsage, { UsageResponsePayloadProps } from 'api/billing/getUsage'; import manageCreditCardApi from 'api/billing/manage'; +import logEvent from 'api/common/logEvent'; import Spinner from 'components/Spinner'; import { SOMETHING_WENT_WRONG } from 'constants/api'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; -import useAnalytics from 'hooks/analytics/useAnalytics'; import useAxiosError from 'hooks/useAxiosError'; import useLicense from 'hooks/useLicense'; import { useNotifications } from 'hooks/useNotifications'; @@ -137,8 +137,6 @@ export default function BillingContainer(): JSX.Element { Partial >({}); - const { trackEvent } = useAnalytics(); - const { isFetching, data: licensesData, error: licenseError } = useLicense(); const { user, org } = useSelector((state) => state.app); @@ -316,7 +314,7 @@ export default function BillingContainer(): JSX.Element { const handleBilling = useCallback(async () => { if (isFreeTrial && !licensesData?.payload?.trialConvertedToSubscription) { - trackEvent('Billing : Upgrade Plan', { + logEvent('Billing : Upgrade Plan', { user: pick(user, ['email', 'userId', 'name']), org, }); @@ -327,7 +325,7 @@ export default function BillingContainer(): JSX.Element { cancelURL: window.location.href, }); } else { - trackEvent('Billing : Manage Billing', { + logEvent('Billing : Manage Billing', { user: pick(user, ['email', 'userId', 'name']), org, }); diff --git a/frontend/src/container/CreateAlertChannels/index.tsx b/frontend/src/container/CreateAlertChannels/index.tsx index 85d609c24c..7345fa4ef9 100644 --- a/frontend/src/container/CreateAlertChannels/index.tsx +++ b/frontend/src/container/CreateAlertChannels/index.tsx @@ -449,8 +449,8 @@ function CreateAlertChannels({ const result = await functionToCall(); logEvent('Alert Channel: Save channel', { type: value, - sendResolvedAlert: selectedConfig.send_resolved, - name: selectedConfig.name, + sendResolvedAlert: selectedConfig?.send_resolved, + name: selectedConfig?.name, new: 'true', status: result?.status, statusMessage: result?.statusMessage, @@ -530,8 +530,8 @@ function CreateAlertChannels({ logEvent('Alert Channel: Test notification', { type: channelType, - sendResolvedAlert: selectedConfig.send_resolved, - name: selectedConfig.name, + sendResolvedAlert: selectedConfig?.send_resolved, + name: selectedConfig?.name, new: 'true', status: response && response.statusCode === 200 ? 'Test success' : 'Test failed', diff --git a/frontend/src/container/EditAlertChannels/index.tsx b/frontend/src/container/EditAlertChannels/index.tsx index b4fe30d557..0fc46beb33 100644 --- a/frontend/src/container/EditAlertChannels/index.tsx +++ b/frontend/src/container/EditAlertChannels/index.tsx @@ -370,8 +370,8 @@ function EditAlertChannels({ } logEvent('Alert Channel: Save channel', { type: value, - sendResolvedAlert: selectedConfig.send_resolved, - name: selectedConfig.name, + sendResolvedAlert: selectedConfig?.send_resolved, + name: selectedConfig?.name, new: 'false', status: result?.status, statusMessage: result?.statusMessage, @@ -441,8 +441,8 @@ function EditAlertChannels({ } logEvent('Alert Channel: Test notification', { type: channelType, - sendResolvedAlert: selectedConfig.send_resolved, - name: selectedConfig.name, + sendResolvedAlert: selectedConfig?.send_resolved, + name: selectedConfig?.name, new: 'false', status: response && response.statusCode === 200 ? 'Test success' : 'Test failed', diff --git a/frontend/src/container/ErrorDetails/index.tsx b/frontend/src/container/ErrorDetails/index.tsx index 2c87279e3d..c6b0d5fa22 100644 --- a/frontend/src/container/ErrorDetails/index.tsx +++ b/frontend/src/container/ErrorDetails/index.tsx @@ -114,10 +114,10 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element { const onClickTraceHandler = (): void => { logEvent('Exception: Navigate to trace detail page', { - groupId: errorDetail.groupID, + groupId: errorDetail?.groupID, spanId: errorDetail.spanID, traceId: errorDetail.traceID, - exceptionId: errorDetail.errorId, + exceptionId: errorDetail?.errorId, }); history.push(`/trace/${errorDetail.traceID}?spanId=${errorDetail.spanID}`); }; @@ -126,10 +126,10 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element { useEffect(() => { if (!logEventCalledRef.current && !isUndefined(data)) { logEvent('Exception: Detail page visited', { - groupId: errorDetail.groupID, + groupId: errorDetail?.groupID, spanId: errorDetail.spanID, traceId: errorDetail.traceID, - exceptionId: errorDetail.errorId, + exceptionId: errorDetail?.errorId, }); logEventCalledRef.current = true; } diff --git a/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx b/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx index e925a60a8a..138694058e 100644 --- a/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx +++ b/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx @@ -256,12 +256,12 @@ function ExplorerOptions({ if (sourcepage === DataSource.TRACES) { logEvent('Traces Explorer: Select view', { panelType, - viewName: option.value, + viewName: option?.value, }); } else if (sourcepage === DataSource.LOGS) { logEvent('Logs Explorer: Select view', { panelType, - viewName: option.value, + viewName: option?.value, }); } if (ref.current) { diff --git a/frontend/src/container/FormAlertRules/BasicInfo.tsx b/frontend/src/container/FormAlertRules/BasicInfo.tsx index 40edb7977e..d047ed617b 100644 --- a/frontend/src/container/FormAlertRules/BasicInfo.tsx +++ b/frontend/src/container/FormAlertRules/BasicInfo.tsx @@ -88,7 +88,7 @@ function BasicInfo({ if (!channels.loading && isNewRule) { logEvent('Alert: New alert creation page visited', { dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes], - numberOfChannels: channels.payload?.length, + numberOfChannels: channels?.payload?.length, }); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/src/container/GridCardLayout/GridCard/index.tsx b/frontend/src/container/GridCardLayout/GridCard/index.tsx index 0f30a189cf..8e34e32879 100644 --- a/frontend/src/container/GridCardLayout/GridCard/index.tsx +++ b/frontend/src/container/GridCardLayout/GridCard/index.tsx @@ -125,10 +125,9 @@ function GridCardGraph({ offset: 0, limit: updatedQuery.builder.queryData[0].limit || 0, }, + // we do not need select columns in case of logs selectColumns: - initialDataSource === DataSource.LOGS - ? widget.selectedLogFields - : widget.selectedTracesFields, + initialDataSource === DataSource.TRACES && widget.selectedTracesFields, }, fillGaps: widget.fillSpans, }; diff --git a/frontend/src/container/GridCardLayout/GridCardLayout.styles.scss b/frontend/src/container/GridCardLayout/GridCardLayout.styles.scss index 1b2bd2a7ba..762fcdbca8 100644 --- a/frontend/src/container/GridCardLayout/GridCardLayout.styles.scss +++ b/frontend/src/container/GridCardLayout/GridCardLayout.styles.scss @@ -50,7 +50,7 @@ .footer { display: flex; flex-direction: column; - position: absolute; + position: fixed; bottom: 0; width: -webkit-fill-available; diff --git a/frontend/src/container/IngestionSettings/IngestionSettings.styles.scss b/frontend/src/container/IngestionSettings/IngestionSettings.styles.scss index 2c77750cd4..c98c6884fd 100644 --- a/frontend/src/container/IngestionSettings/IngestionSettings.styles.scss +++ b/frontend/src/container/IngestionSettings/IngestionSettings.styles.scss @@ -940,3 +940,50 @@ border-color: var(--bg-vanilla-300) !important; } } + +.mt-8 { + margin-top: 8px; +} + +.mt-12 { + margin-top: 12px; +} + +.mt-24 { + margin-top: 24px; +} + +.mb-24 { + margin-bottom: 24px; +} + +.ingestion-setup-details-links { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 24px; + padding: 12px; + border-radius: 4px; + background: rgba(113, 144, 249, 0.1); + color: var(--bg-robin-300, #95acfb); + + .learn-more { + display: inline-flex; + justify-content: center; + align-items: center; + text-decoration: underline; + + color: var(--bg-robin-300, #95acfb); + } +} + +.lightMode { + .ingestion-setup-details-links { + background: rgba(113, 144, 249, 0.1); + color: var(--bg-robin-500); + + .learn-more { + color: var(--bg-robin-500); + } + } +} diff --git a/frontend/src/container/IngestionSettings/MultiIngestionSettings.tsx b/frontend/src/container/IngestionSettings/MultiIngestionSettings.tsx index 7d704f0432..0355d069fb 100644 --- a/frontend/src/container/IngestionSettings/MultiIngestionSettings.tsx +++ b/frontend/src/container/IngestionSettings/MultiIngestionSettings.tsx @@ -34,11 +34,14 @@ import dayjs, { Dayjs } from 'dayjs'; import { useGetAllIngestionsKeys } from 'hooks/IngestionKeys/useGetAllIngestionKeys'; import useDebouncedFn from 'hooks/useDebouncedFunction'; import { useNotifications } from 'hooks/useNotifications'; +import { isNil } from 'lodash-es'; import { + ArrowUpRight, CalendarClock, Check, Copy, Infinity, + Info, Minus, PenLine, Plus, @@ -603,243 +606,250 @@ function MultiIngestionSettings(): JSX.Element {
- {SIGNALS.map((signal) => ( -
-
-
{signal}
-
- {hasLimits(signal) ? ( - <> + {SIGNALS.map((signal) => { + const hasValidDayLimit = !isNil(limits[signal]?.config?.day?.size); + const hasValidSecondLimit = !isNil( + limits[signal]?.config?.second?.size, + ); + + return ( +
+
+
{signal}
+
+ {hasLimits(signal) ? ( + <> + + )} +
+
- enableEditLimitMode(APIKey, { - id: signal, - signal, - config: {}, - }); +
+ {activeAPIKey?.id === APIKey.id && + activeSignal?.signal === signal && + isEditAddLimitOpen ? ( +
- Limits - +
+
+
+
Daily limit
+
+ Add a limit for data ingested daily{' '} +
+
+ +
+ + + + + + + + } + /> + +
+
+ +
+
+
Per Second limit
+
+ {' '} + Add a limit for data ingested every second{' '} +
+
+ +
+ + + + + + + + } + /> + +
+
+
+ + {activeAPIKey?.id === APIKey.id && + activeSignal.signal === signal && + !isLoadingLimitForKey && + hasCreateLimitForIngestionKeyError && + createLimitForIngestionKeyError && + createLimitForIngestionKeyError?.error && ( +
+ {createLimitForIngestionKeyError?.error} +
+ )} + + {activeAPIKey?.id === APIKey.id && + activeSignal.signal === signal && + !isLoadingLimitForKey && + hasUpdateLimitForIngestionKeyError && + updateLimitForIngestionKeyError && ( +
+ {updateLimitForIngestionKeyError?.error} +
+ )} + + {activeAPIKey?.id === APIKey.id && + activeSignal.signal === signal && + isEditAddLimitOpen && ( +
+ + +
+ )} +
+ ) : ( +
+
+
+ Daily {' '} +
+ +
+ {hasValidDayLimit ? ( + <> + {getYAxisFormattedValue( + (limits[signal]?.metric?.day?.size || 0).toString(), + 'bytes', + )}{' '} + /{' '} + {getYAxisFormattedValue( + (limits[signal]?.config?.day?.size || 0).toString(), + 'bytes', + )} + + ) : ( + <> + NO LIMIT + + )} +
+
+ +
+
+ Seconds +
+ +
+ {hasValidSecondLimit ? ( + <> + {getYAxisFormattedValue( + (limits[signal]?.metric?.second?.size || 0).toString(), + 'bytes', + )}{' '} + /{' '} + {getYAxisFormattedValue( + (limits[signal]?.config?.second?.size || 0).toString(), + 'bytes', + )} + + ) : ( + <> + NO LIMIT + + )} +
+
+
)}
- -
- {activeAPIKey?.id === APIKey.id && - activeSignal?.signal === signal && - isEditAddLimitOpen ? ( -
-
-
-
-
Daily limit
-
- Add a limit for data ingested daily{' '} -
-
- -
- - - - - - - - } - /> - -
-
- -
-
-
Per Second limit
-
- {' '} - Add a limit for data ingested every second{' '} -
-
- -
- - - - - - - - } - /> - -
-
-
- - {activeAPIKey?.id === APIKey.id && - activeSignal.signal === signal && - !isLoadingLimitForKey && - hasCreateLimitForIngestionKeyError && - createLimitForIngestionKeyError && - createLimitForIngestionKeyError?.error && ( -
- {createLimitForIngestionKeyError?.error} -
- )} - - {activeAPIKey?.id === APIKey.id && - activeSignal.signal === signal && - !isLoadingLimitForKey && - hasUpdateLimitForIngestionKeyError && - updateLimitForIngestionKeyError && ( -
- {updateLimitForIngestionKeyError?.error} -
- )} - - {activeAPIKey?.id === APIKey.id && - activeSignal.signal === signal && - isEditAddLimitOpen && ( -
- - -
- )} -
- ) : ( -
-
-
- Daily {' '} -
- -
- {limits[signal]?.config?.day?.size ? ( - <> - {getYAxisFormattedValue( - (limits[signal]?.metric?.day?.size || 0).toString(), - 'bytes', - )}{' '} - /{' '} - {getYAxisFormattedValue( - (limits[signal]?.config?.day?.size || 0).toString(), - 'bytes', - )} - - ) : ( - <> - NO LIMIT - - )} -
-
- -
-
- Seconds -
- -
- {limits[signal]?.config?.second?.size ? ( - <> - {getYAxisFormattedValue( - (limits[signal]?.metric?.second?.size || 0).toString(), - 'bytes', - )}{' '} - /{' '} - {getYAxisFormattedValue( - (limits[signal]?.config?.second?.size || 0).toString(), - 'bytes', - )} - - ) : ( - <> - NO LIMIT - - )} -
-
-
- )} -
-
- ))} + ); + })}
@@ -875,10 +885,35 @@ function MultiIngestionSettings(): JSX.Element { return (
+
+ + + + Find your ingestion URL and learn more about sending data to SigNoz{' '} + + here + + +
+
Ingestion Keys - Create and manage ingestion keys for the SigNoz Cloud + Create and manage ingestion keys for the SigNoz Cloud{' '} + + {' '} + Learn more +
diff --git a/frontend/src/container/IngestionSettings/__tests__/MultiIngestionSettings.test.tsx b/frontend/src/container/IngestionSettings/__tests__/MultiIngestionSettings.test.tsx new file mode 100644 index 0000000000..36979a6e3f --- /dev/null +++ b/frontend/src/container/IngestionSettings/__tests__/MultiIngestionSettings.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from 'tests/test-utils'; + +import MultiIngestionSettings from '../MultiIngestionSettings'; + +describe('MultiIngestionSettings Page', () => { + beforeEach(() => { + render(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders MultiIngestionSettings page without crashing', () => { + expect( + screen.getByText( + 'Find your ingestion URL and learn more about sending data to SigNoz', + ), + ).toBeInTheDocument(); + + expect(screen.getByText('Ingestion Keys')).toBeInTheDocument(); + + expect( + screen.getByText('Create and manage ingestion keys for the SigNoz Cloud'), + ).toBeInTheDocument(); + + const overviewLink = screen.getByRole('link', { name: /here/i }); + expect(overviewLink).toHaveAttribute( + 'href', + 'https://signoz.io/docs/ingestion/signoz-cloud/overview/', + ); + expect(overviewLink).toHaveAttribute('target', '_blank'); + expect(overviewLink).toHaveClass('learn-more'); + expect(overviewLink).toHaveAttribute('rel', 'noreferrer'); + + const aboutKeyslink = screen.getByRole('link', { name: /Learn more/i }); + expect(aboutKeyslink).toHaveAttribute( + 'href', + 'https://signoz.io/docs/ingestion/signoz-cloud/keys/', + ); + expect(aboutKeyslink).toHaveAttribute('target', '_blank'); + expect(aboutKeyslink).toHaveClass('learn-more'); + expect(aboutKeyslink).toHaveAttribute('rel', 'noreferrer'); + }); +}); diff --git a/frontend/src/container/ListAlertRules/utils.ts b/frontend/src/container/ListAlertRules/utils.ts index 32da7eaad5..1556b28274 100644 --- a/frontend/src/container/ListAlertRules/utils.ts +++ b/frontend/src/container/ListAlertRules/utils.ts @@ -49,9 +49,9 @@ export const alertActionLogEvent = ( break; } logEvent('Alert: Action', { - ruleId: record.id, + ruleId: record?.id, dataSource: ALERTS_DATA_SOURCE_MAP[record.alertType as AlertTypes], - name: record.alert, + name: record?.alert, action: actionValue, }); }; diff --git a/frontend/src/container/ListOfDashboard/DashboardsList.tsx b/frontend/src/container/ListOfDashboard/DashboardsList.tsx index f3dc600f9b..17b6fe0863 100644 --- a/frontend/src/container/ListOfDashboard/DashboardsList.tsx +++ b/frontend/src/container/ListOfDashboard/DashboardsList.tsx @@ -73,7 +73,6 @@ import { Dashboard } from 'types/api/dashboard/getAll'; import AppReducer from 'types/reducer/app'; import { isCloudUser } from 'utils/app'; -import useUrlQuery from '../../hooks/useUrlQuery'; import DashboardTemplatesModal from './DashboardTemplates/DashboardTemplatesModal'; import ImportJSON from './ImportJSON'; import { DeleteButton } from './TableComponents/DeleteButton'; @@ -86,7 +85,7 @@ import { // eslint-disable-next-line sonarjs/cognitive-complexity function DashboardsList(): JSX.Element { const { - data: dashboardListResponse = [], + data: dashboardListResponse, isLoading: isDashboardListLoading, error: dashboardFetchError, refetch: refetchDashboardList, @@ -99,12 +98,14 @@ function DashboardsList(): JSX.Element { setListSortOrder: setSortOrder, } = useDashboard(); + const [searchString, setSearchString] = useState( + sortOrder.search || '', + ); const [action, createNewDashboard] = useComponentPermission( ['action', 'create_new_dashboards'], role, ); - const [searchValue, setSearchValue] = useState(''); const [ showNewDashboardTemplatesModal, setShowNewDashboardTemplatesModal, @@ -123,10 +124,6 @@ function DashboardsList(): JSX.Element { false, ); - const params = useUrlQuery(); - const searchParams = params.get('search'); - const [searchString, setSearchString] = useState(searchParams || ''); - const getLocalStorageDynamicColumns = (): DashboardDynamicColumns => { const dashboardDynamicColumnsString = localStorage.getItem('dashboard'); let dashboardDynamicColumns: DashboardDynamicColumns = { @@ -188,14 +185,6 @@ function DashboardsList(): JSX.Element { setDashboards(sortedDashboards); }; - useEffect(() => { - params.set('columnKey', sortOrder.columnKey as string); - params.set('order', sortOrder.order as string); - params.set('page', sortOrder.pagination || '1'); - history.replace({ search: params.toString() }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sortOrder]); - const sortHandle = (key: string): void => { if (!dashboards) return; if (key === 'createdAt') { @@ -204,6 +193,7 @@ function DashboardsList(): JSX.Element { columnKey: 'createdAt', order: 'descend', pagination: sortOrder.pagination || '1', + search: sortOrder.search || '', }); } else if (key === 'updatedAt') { sortDashboardsByUpdatedAt(dashboards); @@ -211,21 +201,19 @@ function DashboardsList(): JSX.Element { columnKey: 'updatedAt', order: 'descend', pagination: sortOrder.pagination || '1', + search: sortOrder.search || '', }); } }; function handlePageSizeUpdate(page: number): void { - setSortOrder((order) => ({ - ...order, - pagination: String(page), - })); + setSortOrder({ ...sortOrder, pagination: String(page) }); } useEffect(() => { const filteredDashboards = filterDashboard( searchString, - dashboardListResponse, + dashboardListResponse || [], ); if (sortOrder.columnKey === 'updatedAt') { sortDashboardsByUpdatedAt(filteredDashboards || []); @@ -236,6 +224,7 @@ function DashboardsList(): JSX.Element { columnKey: 'updatedAt', order: 'descend', pagination: sortOrder.pagination || '1', + search: sortOrder.search || '', }); sortDashboardsByUpdatedAt(filteredDashboards || []); } @@ -245,6 +234,7 @@ function DashboardsList(): JSX.Element { setSortOrder, sortOrder.columnKey, sortOrder.pagination, + sortOrder.search, ]); const [newDashboardState, setNewDashboardState] = useState({ @@ -316,12 +306,15 @@ function DashboardsList(): JSX.Element { const handleSearch = (event: ChangeEvent): void => { setIsFilteringDashboards(true); - setSearchValue(event.target.value); const searchText = (event as React.BaseSyntheticEvent)?.target?.value || ''; - const filteredDashboards = filterDashboard(searchText, dashboardListResponse); + const filteredDashboards = filterDashboard( + searchText, + dashboardListResponse || [], + ); setDashboards(filteredDashboards); setIsFilteringDashboards(false); setSearchString(searchText); + setSortOrder({ ...sortOrder, search: searchText }); }; const [state, setCopy] = useCopyToClipboard(); @@ -412,7 +405,7 @@ function DashboardsList(): JSX.Element { { title: 'Dashboards', key: 'dashboard', - render: (dashboard: Data): JSX.Element => { + render: (dashboard: Data, _, index): JSX.Element => { const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit', @@ -461,7 +454,9 @@ function DashboardsList(): JSX.Element { style={{ height: '14px', width: '14px' }} alt="dashboard-image" /> - {dashboard.name} + + {dashboard.name} +
@@ -658,8 +653,9 @@ function DashboardsList(): JSX.Element { }} eventName="Dashboard: Facing Issues in dashboard" message={dashboardListMessage} - buttonText="Facing issues with dashboards?" + buttonText="Need help with dashboards?" onHoverText="Click here to get help with dashboards" + intercomMessageDisabled />
@@ -701,7 +697,7 @@ function DashboardsList(): JSX.Element {
- ) : dashboards?.length === 0 && !searchValue ? ( + ) : dashboards?.length === 0 && !searchString ? (
{ window.open( 'https://signoz.io/docs/userguide/manage-dashboards?utm_source=product&utm_medium=dashboard-list-empty-state', @@ -758,7 +755,7 @@ function DashboardsList(): JSX.Element { } - value={searchValue} + value={searchString} onChange={handleSearch} /> {createNewDashboard && ( @@ -786,7 +783,7 @@ function DashboardsList(): JSX.Element {
img - No dashboards found for {searchValue}. Create a new dashboard? + No dashboards found for {searchString}. Create a new dashboard?
) : ( @@ -808,6 +805,7 @@ function DashboardsList(): JSX.Element { type="text" className={cx('sort-btns')} onClick={(): void => sortHandle('createdAt')} + data-testid="sort-by-last-created" > Last created {sortOrder.columnKey === 'createdAt' && } @@ -816,6 +814,7 @@ function DashboardsList(): JSX.Element { type="text" className={cx('sort-btns')} onClick={(): void => sortHandle('updatedAt')} + data-testid="sort-by-last-updated" > Last updated {sortOrder.columnKey === 'updatedAt' && } @@ -826,7 +825,7 @@ function DashboardsList(): JSX.Element { placement="bottomRight" arrow={false} > - + ({ + ...jest.requireActual('react-router-dom'), + useLocation: jest.fn(), + useRouteMatch: jest.fn().mockReturnValue({ + params: { + dashboardId: 4, + }, + }), +})); + +jest.mock( + 'container/TopNav/DateTimeSelectionV2/index.tsx', + () => + function MockDateTimeSelection(): JSX.Element { + return
MockDateTimeSelection
; + }, +); + +describe('Dashboard landing page actions header tests', () => { + it('unlock dashboard should be disabled for integrations created dashboards', async () => { + const mockLocation = { + pathname: `${process.env.FRONTEND_API_ENDPOINT}/dashboard/4`, + search: '', + }; + (useLocation as jest.Mock).mockReturnValue(mockLocation); + const { getByTestId } = render( + + + => Promise.resolve(), + exit: (): Promise => Promise.resolve(), + node: { current: null }, + }} + /> + + , + ); + + await waitFor(() => + expect(getByTestId('dashboard-title')).toHaveTextContent('thor'), + ); + + const dashboardSettingsTrigger = getByTestId('options'); + + await fireEvent.click(dashboardSettingsTrigger); + + const lockUnlockButton = screen.getByTestId('lock-unlock-dashboard'); + + await waitFor(() => expect(lockUnlockButton).toBeDisabled()); + }); + it('unlock dashboard should not be disabled for non integration created dashboards', async () => { + const mockLocation = { + pathname: `${process.env.FRONTEND_API_ENDPOINT}/dashboard/4`, + search: '', + }; + (useLocation as jest.Mock).mockReturnValue(mockLocation); + server.use( + rest.get('http://localhost/api/v1/dashboards/4', (_, res, ctx) => + res(ctx.status(200), ctx.json(getNonIntegrationDashboardById)), + ), + ); + const { getByTestId } = render( + + + => Promise.resolve(), + exit: (): Promise => Promise.resolve(), + node: { current: null }, + }} + /> + + , + ); + + await waitFor(() => + expect(getByTestId('dashboard-title')).toHaveTextContent('thor'), + ); + + const dashboardSettingsTrigger = getByTestId('options'); + + await fireEvent.click(dashboardSettingsTrigger); + + const lockUnlockButton = screen.getByTestId('lock-unlock-dashboard'); + + await waitFor(() => expect(lockUnlockButton).not.toBeDisabled()); + }); +}); diff --git a/frontend/src/container/NewDashboard/DashboardDescription/index.tsx b/frontend/src/container/NewDashboard/DashboardDescription/index.tsx index a7e4a39b35..1a60e74de9 100644 --- a/frontend/src/container/NewDashboard/DashboardDescription/index.tsx +++ b/frontend/src/container/NewDashboard/DashboardDescription/index.tsx @@ -1,7 +1,16 @@ import './Description.styles.scss'; import { PlusOutlined } from '@ant-design/icons'; -import { Button, Card, Input, Modal, Popover, Tag, Typography } from 'antd'; +import { + Button, + Card, + Input, + Modal, + Popover, + Tag, + Tooltip, + Typography, +} from 'antd'; import logEvent from 'api/common/logEvent'; import FacingIssueBtn from 'components/facingIssueBtn/FacingIssueBtn'; import { dashboardHelpMessage } from 'components/facingIssueBtn/util'; @@ -266,6 +275,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element { urlQuery.set('columnKey', listSortOrder.columnKey as string); urlQuery.set('order', listSortOrder.order as string); urlQuery.set('page', listSortOrder.pagination as string); + urlQuery.set('search', listSortOrder.search as string); urlQuery.delete(QueryParams.relativeTime); const generatedUrl = `${ROUTES.ALL_DASHBOARD}?${urlQuery.toString()}`; @@ -299,17 +309,6 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element { {title} -
@@ -318,10 +317,24 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element { alt="dashboard-img" style={{ width: '16px', height: '16px' }} /> - {title} + + {title} + {isDashboardLocked && }
+
{(isAuthor || role === USER_ROLES.ADMIN) && ( - + + )} {!isDashboardLocked && editDashboard && ( diff --git a/frontend/src/container/NewWidget/LeftContainer/ExplorerColumnsRenderer.tsx b/frontend/src/container/NewWidget/LeftContainer/ExplorerColumnsRenderer.tsx index a9a8d9ceb2..0ead9a3765 100644 --- a/frontend/src/container/NewWidget/LeftContainer/ExplorerColumnsRenderer.tsx +++ b/frontend/src/container/NewWidget/LeftContainer/ExplorerColumnsRenderer.tsx @@ -309,6 +309,7 @@ function ExplorerColumnsRenderer({ >
{isSaveDisabled && ( diff --git a/frontend/src/container/OnboardingContainer/OnboardingContainer.tsx b/frontend/src/container/OnboardingContainer/OnboardingContainer.tsx index d1f89b0762..3129d76c8e 100644 --- a/frontend/src/container/OnboardingContainer/OnboardingContainer.tsx +++ b/frontend/src/container/OnboardingContainer/OnboardingContainer.tsx @@ -11,7 +11,6 @@ import ROUTES from 'constants/routes'; import FullScreenHeader from 'container/FullScreenHeader/FullScreenHeader'; import InviteUserModal from 'container/OrganizationSettings/InviteUserModal/InviteUserModal'; import { InviteMemberFormValues } from 'container/OrganizationSettings/PendingInvitesContainer'; -import useAnalytics from 'hooks/analytics/useAnalytics'; import history from 'lib/history'; import { UserPlus } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; @@ -104,7 +103,6 @@ export default function Onboarding(): JSX.Element { const [selectedModuleSteps, setSelectedModuleSteps] = useState(APM_STEPS); const [activeStep, setActiveStep] = useState(1); const [current, setCurrent] = useState(0); - const { trackEvent } = useAnalytics(); const { location } = history; const { t } = useTranslation(['onboarding']); @@ -120,7 +118,7 @@ export default function Onboarding(): JSX.Element { } = useOnboardingContext(); useEffectOnce(() => { - trackEvent('Onboarding V2 Started'); + logEvent('Onboarding V2 Started', {}); }); const { status, data: ingestionData } = useQuery({ @@ -231,7 +229,7 @@ export default function Onboarding(): JSX.Element { const nextStep = activeStep + 1; // on next - trackEvent('Onboarding V2: Get Started', { + logEvent('Onboarding V2: Get Started', { selectedModule: selectedModule.id, nextStepId: nextStep, }); diff --git a/frontend/src/container/OnboardingContainer/Steps/ConnectionStatus/ConnectionStatus.tsx b/frontend/src/container/OnboardingContainer/Steps/ConnectionStatus/ConnectionStatus.tsx index 785e73d610..4cbdb39414 100644 --- a/frontend/src/container/OnboardingContainer/Steps/ConnectionStatus/ConnectionStatus.tsx +++ b/frontend/src/container/OnboardingContainer/Steps/ConnectionStatus/ConnectionStatus.tsx @@ -5,9 +5,9 @@ import { CloseCircleTwoTone, LoadingOutlined, } from '@ant-design/icons'; +import logEvent from 'api/common/logEvent'; import Header from 'container/OnboardingContainer/common/Header/Header'; import { useOnboardingContext } from 'container/OnboardingContainer/context/OnboardingContext'; -import useAnalytics from 'hooks/analytics/useAnalytics'; import { useQueryService } from 'hooks/useQueryService'; import useResourceAttribute from 'hooks/useResourceAttribute'; import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils'; @@ -41,8 +41,6 @@ export default function ConnectionStatus(): JSX.Element { [queries], ); - const { trackEvent } = useAnalytics(); - const [retryCount, setRetryCount] = useState(20); // Retry for 3 mins 20s const [loading, setLoading] = useState(true); const [isReceivingData, setIsReceivingData] = useState(false); @@ -155,7 +153,7 @@ export default function ConnectionStatus(): JSX.Element { if (data || isError) { setRetryCount(retryCount - 1); if (retryCount < 0) { - trackEvent('Onboarding V2: Connection Status', { + logEvent('Onboarding V2: Connection Status', { dataSource: selectedDataSource?.id, framework: selectedFramework, environment: selectedEnvironment, @@ -174,7 +172,7 @@ export default function ConnectionStatus(): JSX.Element { setLoading(false); setIsReceivingData(true); - trackEvent('Onboarding V2: Connection Status', { + logEvent('Onboarding V2: Connection Status', { dataSource: selectedDataSource?.id, framework: selectedFramework, environment: selectedEnvironment, diff --git a/frontend/src/container/OnboardingContainer/Steps/LogsConnectionStatus/LogsConnectionStatus.tsx b/frontend/src/container/OnboardingContainer/Steps/LogsConnectionStatus/LogsConnectionStatus.tsx index 9695721ef1..f418c4bb41 100644 --- a/frontend/src/container/OnboardingContainer/Steps/LogsConnectionStatus/LogsConnectionStatus.tsx +++ b/frontend/src/container/OnboardingContainer/Steps/LogsConnectionStatus/LogsConnectionStatus.tsx @@ -5,11 +5,11 @@ import { CloseCircleTwoTone, LoadingOutlined, } from '@ant-design/icons'; +import logEvent from 'api/common/logEvent'; import { DEFAULT_ENTITY_VERSION } from 'constants/app'; import { PANEL_TYPES } from 'constants/queryBuilder'; import Header from 'container/OnboardingContainer/common/Header/Header'; import { useOnboardingContext } from 'container/OnboardingContainer/context/OnboardingContext'; -import useAnalytics from 'hooks/analytics/useAnalytics'; import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange'; import { useEffect, useState } from 'react'; import { SuccessResponse } from 'types/api'; @@ -32,7 +32,6 @@ export default function LogsConnectionStatus(): JSX.Element { activeStep, selectedEnvironment, } = useOnboardingContext(); - const { trackEvent } = useAnalytics(); const [isReceivingData, setIsReceivingData] = useState(false); const [pollingInterval, setPollingInterval] = useState(15000); // initial Polling interval of 15 secs , Set to false after 5 mins const [retryCount, setRetryCount] = useState(20); // Retry for 5 mins @@ -105,7 +104,7 @@ export default function LogsConnectionStatus(): JSX.Element { setRetryCount(retryCount - 1); if (retryCount < 0) { - trackEvent('Onboarding V2: Connection Status', { + logEvent('Onboarding V2: Connection Status', { dataSource: selectedDataSource?.id, environment: selectedEnvironment, module: activeStep?.module?.id, @@ -141,7 +140,7 @@ export default function LogsConnectionStatus(): JSX.Element { setRetryCount(-1); setPollingInterval(false); - trackEvent('Onboarding V2: Connection Status', { + logEvent('Onboarding V2: Connection Status', { dataSource: selectedDataSource?.id, environment: selectedEnvironment, module: activeStep?.module?.id, diff --git a/frontend/src/container/OnboardingContainer/common/ModuleStepsContainer/ModuleStepsContainer.tsx b/frontend/src/container/OnboardingContainer/common/ModuleStepsContainer/ModuleStepsContainer.tsx index 28890e4d5a..ae74930d57 100644 --- a/frontend/src/container/OnboardingContainer/common/ModuleStepsContainer/ModuleStepsContainer.tsx +++ b/frontend/src/container/OnboardingContainer/common/ModuleStepsContainer/ModuleStepsContainer.tsx @@ -15,7 +15,6 @@ import ROUTES from 'constants/routes'; import { stepsMap } from 'container/OnboardingContainer/constants/stepsConfig'; import { DataSourceType } from 'container/OnboardingContainer/Steps/DataSource/DataSource'; import { hasFrameworks } from 'container/OnboardingContainer/utils/dataSourceUtils'; -import useAnalytics from 'hooks/analytics/useAnalytics'; import history from 'lib/history'; import { isEmpty, isNull } from 'lodash-es'; import { HelpCircle, UserPlus } from 'lucide-react'; @@ -79,7 +78,6 @@ export default function ModuleStepsContainer({ } = useOnboardingContext(); const [current, setCurrent] = useState(0); - const { trackEvent } = useAnalytics(); const [metaData, setMetaData] = useState(defaultMetaData); const lastStepIndex = selectedModuleSteps.length - 1; @@ -143,7 +141,7 @@ export default function ModuleStepsContainer({ }; const redirectToModules = (): void => { - trackEvent('Onboarding V2 Complete', { + logEvent('Onboarding V2 Complete', { module: selectedModule.id, dataSource: selectedDataSource?.id, framework: selectedFramework, @@ -186,14 +184,14 @@ export default function ModuleStepsContainer({ // on next step click track events switch (selectedModuleSteps[current].id) { case stepsMap.dataSource: - trackEvent('Onboarding V2: Data Source Selected', { + logEvent('Onboarding V2: Data Source Selected', { dataSource: selectedDataSource?.id, framework: selectedFramework, module: activeStep?.module?.id, }); break; case stepsMap.environmentDetails: - trackEvent('Onboarding V2: Environment Selected', { + logEvent('Onboarding V2: Environment Selected', { dataSource: selectedDataSource?.id, framework: selectedFramework, environment: selectedEnvironment, @@ -201,7 +199,7 @@ export default function ModuleStepsContainer({ }); break; case stepsMap.selectMethod: - trackEvent('Onboarding V2: Method Selected', { + logEvent('Onboarding V2: Method Selected', { dataSource: selectedDataSource?.id, framework: selectedFramework, environment: selectedEnvironment, @@ -211,7 +209,7 @@ export default function ModuleStepsContainer({ break; case stepsMap.setupOtelCollector: - trackEvent('Onboarding V2: Setup Otel Collector', { + logEvent('Onboarding V2: Setup Otel Collector', { dataSource: selectedDataSource?.id, framework: selectedFramework, environment: selectedEnvironment, @@ -220,7 +218,7 @@ export default function ModuleStepsContainer({ }); break; case stepsMap.instrumentApplication: - trackEvent('Onboarding V2: Instrument Application', { + logEvent('Onboarding V2: Instrument Application', { dataSource: selectedDataSource?.id, framework: selectedFramework, environment: selectedEnvironment, @@ -229,13 +227,13 @@ export default function ModuleStepsContainer({ }); break; case stepsMap.cloneRepository: - trackEvent('Onboarding V2: Clone Repository', { + logEvent('Onboarding V2: Clone Repository', { dataSource: selectedDataSource?.id, module: activeStep?.module?.id, }); break; case stepsMap.runApplication: - trackEvent('Onboarding V2: Run Application', { + logEvent('Onboarding V2: Run Application', { dataSource: selectedDataSource?.id, framework: selectedFramework, environment: selectedEnvironment, @@ -244,95 +242,95 @@ export default function ModuleStepsContainer({ }); break; case stepsMap.addHttpDrain: - trackEvent('Onboarding V2: Add HTTP Drain', { + logEvent('Onboarding V2: Add HTTP Drain', { dataSource: selectedDataSource?.id, module: activeStep?.module?.id, }); break; case stepsMap.startContainer: - trackEvent('Onboarding V2: Start Container', { + logEvent('Onboarding V2: Start Container', { dataSource: selectedDataSource?.id, module: activeStep?.module?.id, }); break; case stepsMap.setupLogDrains: - trackEvent('Onboarding V2: Setup Log Drains', { + logEvent('Onboarding V2: Setup Log Drains', { dataSource: selectedDataSource?.id, module: activeStep?.module?.id, }); break; case stepsMap.configureReceiver: - trackEvent('Onboarding V2: Configure Receiver', { + logEvent('Onboarding V2: Configure Receiver', { dataSource: selectedDataSource?.id, environment: selectedEnvironment, module: activeStep?.module?.id, }); break; case stepsMap.configureAws: - trackEvent('Onboarding V2: Configure AWS', { + logEvent('Onboarding V2: Configure AWS', { dataSource: selectedDataSource?.id, environment: selectedEnvironment, module: activeStep?.module?.id, }); break; case stepsMap.sendLogsCloudwatch: - trackEvent('Onboarding V2: Send Logs Cloudwatch', { + logEvent('Onboarding V2: Send Logs Cloudwatch', { dataSource: selectedDataSource?.id, environment: selectedEnvironment, module: activeStep?.module?.id, }); break; case stepsMap.setupDaemonService: - trackEvent('Onboarding V2: Setup ECS Daemon Service', { + logEvent('Onboarding V2: Setup ECS Daemon Service', { dataSource: selectedDataSource?.id, environment: selectedEnvironment, module: activeStep?.module?.id, }); break; case stepsMap.createOtelConfig: - trackEvent('Onboarding V2: Create ECS OTel Config', { + logEvent('Onboarding V2: Create ECS OTel Config', { dataSource: selectedDataSource?.id, environment: selectedEnvironment, module: activeStep?.module?.id, }); break; case stepsMap.createDaemonService: - trackEvent('Onboarding V2: Create ECS Daemon Service', { + logEvent('Onboarding V2: Create ECS Daemon Service', { dataSource: selectedDataSource?.id, environment: selectedEnvironment, module: activeStep?.module?.id, }); break; case stepsMap.ecsSendData: - trackEvent('Onboarding V2: ECS send traces data', { + logEvent('Onboarding V2: ECS send traces data', { dataSource: selectedDataSource?.id, environment: selectedEnvironment, module: activeStep?.module?.id, }); break; case stepsMap.createSidecarCollectorContainer: - trackEvent('Onboarding V2: ECS create Sidecar Container', { + logEvent('Onboarding V2: ECS create Sidecar Container', { dataSource: selectedDataSource?.id, environment: selectedEnvironment, module: activeStep?.module?.id, }); break; case stepsMap.deployTaskDefinition: - trackEvent('Onboarding V2: ECS deploy task definition', { + logEvent('Onboarding V2: ECS deploy task definition', { dataSource: selectedDataSource?.id, environment: selectedEnvironment, module: activeStep?.module?.id, }); break; case stepsMap.ecsSendLogsData: - trackEvent('Onboarding V2: ECS Fargate send logs data', { + logEvent('Onboarding V2: ECS Fargate send logs data', { dataSource: selectedDataSource?.id, environment: selectedEnvironment, module: activeStep?.module?.id, }); break; case stepsMap.monitorDashboard: - trackEvent('Onboarding V2: EKS monitor dashboard', { + logEvent('Onboarding V2: EKS monitor dashboard', { dataSource: selectedDataSource?.id, environment: selectedEnvironment, module: activeStep?.module?.id, diff --git a/frontend/src/container/PanelWrapper/__tests__/TablePanelWrapper.test.tsx b/frontend/src/container/PanelWrapper/__tests__/TablePanelWrapper.test.tsx new file mode 100644 index 0000000000..0c2389dead --- /dev/null +++ b/frontend/src/container/PanelWrapper/__tests__/TablePanelWrapper.test.tsx @@ -0,0 +1,31 @@ +import { render } from 'tests/test-utils'; +import { Widgets } from 'types/api/dashboard/getAll'; + +import TablePanelWrapper from '../TablePanelWrapper'; +import { + tablePanelQueryResponse, + tablePanelWidgetQuery, +} from './tablePanelWrapperHelper'; + +describe('Table panel wrappper tests', () => { + it('table should render fine with the query response and column units', () => { + const { container, getByText } = render( + {}} + />, + ); + // checking the overall rendering of the table + expect(container).toMatchSnapshot(); + + // the first row of the table should have the latency value with units + expect(getByText('4.35 s')).toBeInTheDocument(); + + // the rows should have optimised value for human readability + expect(getByText('31.3 ms')).toBeInTheDocument(); + + // the applied legend should appear as the column header + expect(getByText('latency-per-service')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/container/PanelWrapper/__tests__/ValuePanelWrapper.test.tsx b/frontend/src/container/PanelWrapper/__tests__/ValuePanelWrapper.test.tsx new file mode 100644 index 0000000000..519083cd0b --- /dev/null +++ b/frontend/src/container/PanelWrapper/__tests__/ValuePanelWrapper.test.tsx @@ -0,0 +1,38 @@ +import { render } from 'tests/test-utils'; +import { Widgets } from 'types/api/dashboard/getAll'; + +import ValuePanelWrapper from '../ValuePanelWrapper'; +import { + thresholds, + valuePanelQueryResponse, + valuePanelWidget, +} from './valuePanelWrapperHelper'; + +describe('Value panel wrappper tests', () => { + it('should render value panel correctly with yaxis unit', () => { + const { getByText } = render( + {}} + />, + ); + + // selected y axis unit as miliseconds (ms) + expect(getByText('295 ms')).toBeInTheDocument(); + }); + + it('should render tooltip when there are conflicting thresholds', () => { + const { getByTestId, container } = render( + {}} + />, + ); + + expect(getByTestId('conflicting-thresholds')).toBeInTheDocument(); + // added snapshot test here for checking the thresholds color being applied properly + expect(container).toMatchSnapshot(); + }); +}); diff --git a/frontend/src/container/PanelWrapper/__tests__/__snapshots__/TablePanelWrapper.test.tsx.snap b/frontend/src/container/PanelWrapper/__tests__/__snapshots__/TablePanelWrapper.test.tsx.snap new file mode 100644 index 0000000000..d37ccf5841 --- /dev/null +++ b/frontend/src/container/PanelWrapper/__tests__/__snapshots__/TablePanelWrapper.test.tsx.snap @@ -0,0 +1,389 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Table panel wrappper tests table should render fine with the query response and column units 1`] = ` +.c1 { + position: absolute; + right: -0.313rem; + bottom: 0; + z-index: 1; + width: 0.625rem; + height: 100%; + cursor: col-resize; +} + +.c0 { + height: 95%; + overflow: hidden; +} + +.c0 .ant-table-wrapper { + height: 100%; +} + +.c0 .ant-spin-nested-loading { + height: 100%; +} + +.c0 .ant-spin-container { + height: 100%; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; +} + +.c0 .ant-table { + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + overflow: auto; +} + +.c0 .ant-table > .ant-table-container > .ant-table-content > table { + min-width: 99% !important; +} + +
+ +
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + service_name + + + + +
+ +
+
+ + latency-per-service + + + + +
+ +
+
+ demo-app +
+
+
+ 4.35 s +
+
+
+ customer +
+
+
+ 431 ms +
+
+
+ mysql +
+
+
+ 431 ms +
+
+
+ frontend +
+
+
+ 287 ms +
+
+
+ driver +
+
+
+ 230 ms +
+
+
+ route +
+
+
+ 66.4 ms +
+
+
+ redis +
+
+
+ 31.3 ms +
+
+
+
+
+
+
+
+
+
+
+`; diff --git a/frontend/src/container/PanelWrapper/__tests__/__snapshots__/ValuePanelWrapper.test.tsx.snap b/frontend/src/container/PanelWrapper/__tests__/__snapshots__/ValuePanelWrapper.test.tsx.snap new file mode 100644 index 0000000000..435a7cb08d --- /dev/null +++ b/frontend/src/container/PanelWrapper/__tests__/__snapshots__/ValuePanelWrapper.test.tsx.snap @@ -0,0 +1,76 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Value panel wrappper tests should render tooltip when there are conflicting thresholds 1`] = ` +.c1 { + height: 100%; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; +} + +.c0 { + text-align: center; + padding-top: 1rem; +} + +
+ +
+
+
+
+
+ + 295 ms + +
+ + + +
+
+
+
+`; diff --git a/frontend/src/container/PanelWrapper/__tests__/tablePanelWrapperHelper.ts b/frontend/src/container/PanelWrapper/__tests__/tablePanelWrapperHelper.ts new file mode 100644 index 0000000000..c6be13c200 --- /dev/null +++ b/frontend/src/container/PanelWrapper/__tests__/tablePanelWrapperHelper.ts @@ -0,0 +1,286 @@ +export const tablePanelWidgetQuery = { + id: '727533b0-7718-4f99-a1db-a1875649325c', + title: '', + description: '', + isStacked: false, + nullZeroValues: 'zero', + opacity: '1', + panelTypes: 'table', + query: { + clickhouse_sql: [ + { + name: 'A', + legend: '', + disabled: false, + query: '', + }, + ], + promql: [ + { + name: 'A', + query: '', + legend: '', + disabled: false, + }, + ], + builder: { + queryData: [ + { + dataSource: 'metrics', + queryName: 'A', + aggregateOperator: 'count', + aggregateAttribute: { + key: 'signoz_latency', + dataType: 'float64', + type: 'ExponentialHistogram', + isColumn: true, + isJSON: false, + id: 'signoz_latency--float64--ExponentialHistogram--true', + }, + timeAggregation: '', + spaceAggregation: 'p90', + functions: [], + filters: { + items: [], + op: 'AND', + }, + expression: 'A', + disabled: false, + stepInterval: 60, + having: [], + limit: null, + orderBy: [], + groupBy: [ + { + key: 'service_name', + dataType: 'string', + type: 'tag', + isColumn: false, + isJSON: false, + id: 'service_name--string--tag--false', + }, + ], + legend: 'latency-per-service', + reduceTo: 'avg', + }, + ], + queryFormulas: [], + }, + id: '7feafec2-a450-4b5a-8897-260c1a9fe1e4', + queryType: 'builder', + }, + timePreferance: 'GLOBAL_TIME', + softMax: null, + softMin: null, + selectedLogFields: [ + { + dataType: 'string', + type: '', + name: 'body', + }, + { + dataType: 'string', + type: '', + name: 'timestamp', + }, + ], + selectedTracesFields: [ + { + key: 'serviceName', + dataType: 'string', + type: 'tag', + isColumn: true, + isJSON: false, + id: 'serviceName--string--tag--true', + }, + { + key: 'name', + dataType: 'string', + type: 'tag', + isColumn: true, + isJSON: false, + id: 'name--string--tag--true', + }, + { + key: 'durationNano', + dataType: 'float64', + type: 'tag', + isColumn: true, + isJSON: false, + id: 'durationNano--float64--tag--true', + }, + { + key: 'httpMethod', + dataType: 'string', + type: 'tag', + isColumn: true, + isJSON: false, + id: 'httpMethod--string--tag--true', + }, + { + key: 'responseStatusCode', + dataType: 'string', + type: 'tag', + isColumn: true, + isJSON: false, + id: 'responseStatusCode--string--tag--true', + }, + ], + yAxisUnit: 'none', + thresholds: [], + fillSpans: false, + columnUnits: { + A: 'ms', + }, + bucketCount: 30, + stackedBarChart: false, + bucketWidth: 0, + mergeAllActiveQueries: false, +}; + +export const tablePanelQueryResponse = { + status: 'success', + isLoading: false, + isSuccess: true, + isError: false, + isIdle: false, + data: { + statusCode: 200, + error: null, + message: 'success', + payload: { + status: 'success', + data: { + resultType: '', + result: [ + { + table: { + columns: [ + { + name: 'service_name', + queryName: '', + isValueColumn: false, + }, + { + name: 'A', + queryName: 'A', + isValueColumn: true, + }, + ], + rows: [ + { + data: { + A: 4353.81, + service_name: 'demo-app', + }, + }, + { + data: { + A: 431.25, + service_name: 'customer', + }, + }, + { + data: { + A: 431.25, + service_name: 'mysql', + }, + }, + { + data: { + A: 287.11, + service_name: 'frontend', + }, + }, + { + data: { + A: 230.02, + service_name: 'driver', + }, + }, + { + data: { + A: 66.37, + service_name: 'route', + }, + }, + { + data: { + A: 31.3, + service_name: 'redis', + }, + }, + ], + }, + }, + ], + }, + }, + params: { + start: 1721207225000, + end: 1721207525000, + step: 60, + variables: {}, + formatForWeb: true, + compositeQuery: { + queryType: 'builder', + panelType: 'table', + fillGaps: false, + builderQueries: { + A: { + dataSource: 'metrics', + queryName: 'A', + aggregateOperator: 'count', + aggregateAttribute: { + key: 'signoz_latency', + dataType: 'float64', + type: 'ExponentialHistogram', + isColumn: true, + isJSON: false, + id: 'signoz_latency--float64--ExponentialHistogram--true', + }, + timeAggregation: '', + spaceAggregation: 'p90', + functions: [], + filters: { + items: [], + op: 'AND', + }, + expression: 'A', + disabled: false, + stepInterval: 60, + having: [], + limit: null, + orderBy: [], + groupBy: [ + { + key: 'service_name', + dataType: 'string', + type: 'tag', + isColumn: false, + isJSON: false, + id: 'service_name--string--tag--false', + }, + ], + legend: '', + reduceTo: 'avg', + }, + }, + }, + }, + }, + dataUpdatedAt: 1721207526018, + error: null, + errorUpdatedAt: 0, + failureCount: 0, + errorUpdateCount: 0, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isRefetching: false, + isLoadingError: false, + isPlaceholderData: false, + isPreviousData: false, + isRefetchError: false, + isStale: true, +}; diff --git a/frontend/src/container/PanelWrapper/__tests__/valuePanelWrapperHelper.ts b/frontend/src/container/PanelWrapper/__tests__/valuePanelWrapperHelper.ts new file mode 100644 index 0000000000..1376feac06 --- /dev/null +++ b/frontend/src/container/PanelWrapper/__tests__/valuePanelWrapperHelper.ts @@ -0,0 +1,267 @@ +export const valuePanelWidget = { + id: 'b8b93086-ef01-47bf-9044-1e7abd583be4', + title: 'signoz latency in ms', + description: '', + isStacked: false, + nullZeroValues: 'zero', + opacity: '1', + panelTypes: 'value', + query: { + clickhouse_sql: [ + { + name: 'A', + legend: '', + disabled: false, + query: '', + }, + ], + promql: [ + { + name: 'A', + query: '', + legend: '', + disabled: false, + }, + ], + builder: { + queryData: [ + { + dataSource: 'metrics', + queryName: 'A', + aggregateOperator: 'count', + aggregateAttribute: { + key: 'signoz_latency', + dataType: 'float64', + type: 'ExponentialHistogram', + isColumn: true, + isJSON: false, + id: 'signoz_latency--float64--ExponentialHistogram--true', + }, + timeAggregation: '', + spaceAggregation: 'p90', + functions: [], + filters: { + items: [], + op: 'AND', + }, + expression: 'A', + disabled: false, + stepInterval: 60, + having: [], + limit: null, + orderBy: [], + groupBy: [], + legend: '', + reduceTo: 'avg', + }, + ], + queryFormulas: [], + }, + id: '3bec289c-49c3-4d7e-98bb-84d47c79909c', + queryType: 'builder', + }, + timePreferance: 'GLOBAL_TIME', + softMax: null, + softMin: null, + selectedLogFields: [ + { + dataType: 'string', + type: '', + name: 'body', + }, + { + dataType: 'string', + type: '', + name: 'timestamp', + }, + ], + selectedTracesFields: [ + { + key: 'serviceName', + dataType: 'string', + type: 'tag', + isColumn: true, + isJSON: false, + id: 'serviceName--string--tag--true', + }, + { + key: 'name', + dataType: 'string', + type: 'tag', + isColumn: true, + isJSON: false, + id: 'name--string--tag--true', + }, + { + key: 'durationNano', + dataType: 'float64', + type: 'tag', + isColumn: true, + isJSON: false, + id: 'durationNano--float64--tag--true', + }, + { + key: 'httpMethod', + dataType: 'string', + type: 'tag', + isColumn: true, + isJSON: false, + id: 'httpMethod--string--tag--true', + }, + { + key: 'responseStatusCode', + dataType: 'string', + type: 'tag', + isColumn: true, + isJSON: false, + id: 'responseStatusCode--string--tag--true', + }, + ], + yAxisUnit: 'ms', + thresholds: [], + fillSpans: false, + columnUnits: {}, + bucketCount: 30, + stackedBarChart: false, + bucketWidth: 0, + mergeAllActiveQueries: false, +}; + +export const thresholds = [ + { + index: '8eb16a3a-b4f1-47c8-943a-4b1786884583', + isEditEnabled: false, + thresholdColor: 'Blue', + thresholdFormat: 'Text', + thresholdOperator: '>', + thresholdUnit: 'none', + thresholdValue: 100, + keyIndex: 1, + selectedGraph: 'value', + thresholdTableOptions: '', + thresholdLabel: '', + }, + { + index: 'eb9c1186-ad7d-42dd-8e7f-3913a321d7cf', + isEditEnabled: false, + thresholdColor: 'Red', + thresholdFormat: 'Text', + thresholdOperator: '>', + thresholdUnit: 'none', + thresholdValue: 0, + keyIndex: 0, + selectedGraph: 'value', + thresholdTableOptions: '', + thresholdLabel: '', + }, +]; + +export const valuePanelQueryResponse = { + status: 'success', + isLoading: false, + isSuccess: true, + isError: false, + isIdle: false, + data: { + statusCode: 200, + error: null, + message: 'success', + payload: { + data: { + result: [ + { + metric: { + A: 'A', + }, + values: [[0, '295.4299833508185']], + queryName: 'A', + legend: 'A', + }, + ], + resultType: '', + newResult: { + status: 'success', + data: { + resultType: '', + result: [ + { + queryName: 'A', + series: [ + { + labels: { + A: 'A', + }, + labelsArray: null, + values: [ + { + timestamp: 0, + value: '295.4299833508185', + }, + ], + }, + ], + }, + ], + }, + }, + }, + }, + params: { + start: 1721203451000, + end: 1721203751000, + step: 60, + variables: {}, + formatForWeb: false, + compositeQuery: { + queryType: 'builder', + panelType: 'value', + fillGaps: false, + builderQueries: { + A: { + dataSource: 'metrics', + queryName: 'A', + aggregateOperator: 'count', + aggregateAttribute: { + key: 'signoz_latency', + dataType: 'float64', + type: 'ExponentialHistogram', + isColumn: true, + isJSON: false, + id: 'signoz_latency--float64--ExponentialHistogram--true', + }, + timeAggregation: '', + spaceAggregation: 'p90', + functions: [], + filters: { + items: [], + op: 'AND', + }, + expression: 'A', + disabled: false, + stepInterval: 60, + having: [], + limit: null, + orderBy: [], + groupBy: [], + legend: '', + reduceTo: 'avg', + }, + }, + }, + }, + }, + dataUpdatedAt: 1721203751775, + error: null, + errorUpdatedAt: 0, + failureCount: 0, + errorUpdateCount: 0, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isRefetching: false, + isLoadingError: false, + isPlaceholderData: false, + isPreviousData: false, + isRefetchError: false, + isStale: true, +}; diff --git a/frontend/src/container/PipelinePage/Layouts/Pipeline/CreatePipelineButton.tsx b/frontend/src/container/PipelinePage/Layouts/Pipeline/CreatePipelineButton.tsx index 23eaf335fe..e703e46a7e 100644 --- a/frontend/src/container/PipelinePage/Layouts/Pipeline/CreatePipelineButton.tsx +++ b/frontend/src/container/PipelinePage/Layouts/Pipeline/CreatePipelineButton.tsx @@ -1,6 +1,6 @@ import { EditFilled, PlusOutlined } from '@ant-design/icons'; +import logEvent from 'api/common/logEvent'; import TextToolTip from 'components/TextToolTip'; -import useAnalytics from 'hooks/analytics/useAnalytics'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { ActionMode, ActionType, Pipeline } from 'types/api/pipeline/def'; @@ -15,7 +15,6 @@ function CreatePipelineButton({ pipelineData, }: CreatePipelineButtonProps): JSX.Element { const { t } = useTranslation(['pipeline']); - const { trackEvent } = useAnalytics(); const isAddNewPipelineVisible = useMemo( () => checkDataLength(pipelineData?.pipelines), @@ -26,7 +25,7 @@ function CreatePipelineButton({ const onEnterEditMode = (): void => { setActionMode(ActionMode.Editing); - trackEvent('Logs: Pipelines: Entered Edit Mode', { + logEvent('Logs: Pipelines: Entered Edit Mode', { source: 'signoz-ui', }); }; @@ -34,7 +33,7 @@ function CreatePipelineButton({ setActionMode(ActionMode.Editing); setActionType(ActionType.AddPipeline); - trackEvent('Logs: Pipelines: Clicked Add New Pipeline', { + logEvent('Logs: Pipelines: Clicked Add New Pipeline', { source: 'signoz-ui', }); }; diff --git a/frontend/src/container/PipelinePage/PipelineListsView/PipelineExpandView.tsx b/frontend/src/container/PipelinePage/PipelineListsView/PipelineExpandView.tsx index 71a2ed9014..17761ad99f 100644 --- a/frontend/src/container/PipelinePage/PipelineListsView/PipelineExpandView.tsx +++ b/frontend/src/container/PipelinePage/PipelineListsView/PipelineExpandView.tsx @@ -1,6 +1,6 @@ import { PlusCircleOutlined } from '@ant-design/icons'; import { TableLocale } from 'antd/es/table/interface'; -import useAnalytics from 'hooks/analytics/useAnalytics'; +import logEvent from 'api/common/logEvent'; import { useIsDarkMode } from 'hooks/useDarkMode'; import React, { useCallback, useMemo } from 'react'; import { DndProvider } from 'react-dnd'; @@ -39,7 +39,6 @@ function PipelineExpandView({ }: PipelineExpandViewProps): JSX.Element { const { t } = useTranslation(['pipeline']); const isDarkMode = useIsDarkMode(); - const { trackEvent } = useAnalytics(); const isEditingActionMode = isActionMode === ActionMode.Editing; const deleteProcessorHandler = useCallback( @@ -192,7 +191,7 @@ function PipelineExpandView({ const addNewProcessorHandler = useCallback((): void => { setActionType(ActionType.AddProcessor); - trackEvent('Logs: Pipelines: Clicked Add New Processor', { + logEvent('Logs: Pipelines: Clicked Add New Processor', { source: 'signoz-ui', }); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/src/container/PipelinePage/PipelineListsView/PipelineListsView.tsx b/frontend/src/container/PipelinePage/PipelineListsView/PipelineListsView.tsx index 4e8def2e0c..aa39c9e2aa 100644 --- a/frontend/src/container/PipelinePage/PipelineListsView/PipelineListsView.tsx +++ b/frontend/src/container/PipelinePage/PipelineListsView/PipelineListsView.tsx @@ -5,7 +5,6 @@ import { Card, Modal, Table, Typography } from 'antd'; import { ExpandableConfig } from 'antd/es/table/interface'; import logEvent from 'api/common/logEvent'; import savePipeline from 'api/pipeline/post'; -import useAnalytics from 'hooks/analytics/useAnalytics'; import { useNotifications } from 'hooks/useNotifications'; import { isUndefined } from 'lodash-es'; import cloneDeep from 'lodash-es/cloneDeep'; @@ -100,7 +99,6 @@ function PipelineListsView({ const [modal, contextHolder] = Modal.useModal(); const { notifications } = useNotifications(); const [pipelineSearchValue, setPipelineSearchValue] = useState(''); - const { trackEvent } = useAnalytics(); const [prevPipelineData, setPrevPipelineData] = useState>( cloneDeep(pipelineData?.pipelines || []), ); @@ -376,7 +374,7 @@ function PipelineListsView({ const addNewPipelineHandler = useCallback((): void => { setActionType(ActionType.AddPipeline); - trackEvent('Logs: Pipelines: Clicked Add New Pipeline', { + logEvent('Logs: Pipelines: Clicked Add New Pipeline', { source: 'signoz-ui', }); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -415,7 +413,7 @@ function PipelineListsView({ setCurrPipelineData(pipelinesInDB); setPrevPipelineData(pipelinesInDB); - trackEvent('Logs: Pipelines: Saved Pipelines', { + logEvent('Logs: Pipelines: Saved Pipelines', { count: pipelinesInDB.length, enabled: pipelinesInDB.filter((p) => p.enabled).length, source: 'signoz-ui', diff --git a/frontend/src/container/PipelinePage/PipelineListsView/TableComponents/PipelineActions/components/PreviewAction.tsx b/frontend/src/container/PipelinePage/PipelineListsView/TableComponents/PipelineActions/components/PreviewAction.tsx index d4796b3609..8aaa4a092b 100644 --- a/frontend/src/container/PipelinePage/PipelineListsView/TableComponents/PipelineActions/components/PreviewAction.tsx +++ b/frontend/src/container/PipelinePage/PipelineListsView/TableComponents/PipelineActions/components/PreviewAction.tsx @@ -1,15 +1,13 @@ import { EyeFilled } from '@ant-design/icons'; import { Divider, Modal } from 'antd'; +import logEvent from 'api/common/logEvent'; import PipelineProcessingPreview from 'container/PipelinePage/PipelineListsView/Preview/PipelineProcessingPreview'; -import useAnalytics from 'hooks/analytics/useAnalytics'; import { useState } from 'react'; import { PipelineData } from 'types/api/pipeline/def'; import { iconStyle } from '../../../config'; function PreviewAction({ pipeline }: PreviewActionProps): JSX.Element | null { - const { trackEvent } = useAnalytics(); - const [previewKey, setPreviewKey] = useState(null); const isModalOpen = Boolean(previewKey); @@ -23,7 +21,7 @@ function PreviewAction({ pipeline }: PreviewActionProps): JSX.Element | null { const onOpenPreview = (): void => { openModal(); - trackEvent('Logs: Pipelines: Clicked Preview Pipeline', { + logEvent('Logs: Pipelines: Clicked Preview Pipeline', { source: 'signoz-ui', }); }; diff --git a/frontend/src/container/PipelinePage/tests/CreatePipelineButton.test.tsx b/frontend/src/container/PipelinePage/tests/CreatePipelineButton.test.tsx index 49d0a132ea..33ec54e3a0 100644 --- a/frontend/src/container/PipelinePage/tests/CreatePipelineButton.test.tsx +++ b/frontend/src/container/PipelinePage/tests/CreatePipelineButton.test.tsx @@ -1,5 +1,6 @@ import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import logEvent from 'api/common/logEvent'; import { I18nextProvider } from 'react-i18next'; import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; @@ -9,14 +10,7 @@ import store from 'store'; import CreatePipelineButton from '../Layouts/Pipeline/CreatePipelineButton'; import { pipelineApiResponseMockData } from '../mocks/pipeline'; -const trackEventVar = jest.fn(); -jest.mock('hooks/analytics/useAnalytics', () => ({ - __esModule: true, - default: jest.fn().mockImplementation(() => ({ - trackEvent: trackEventVar, - trackPageView: jest.fn(), - })), -})); +jest.mock('api/common/logEvent'); describe('PipelinePage container test', () => { it('should render CreatePipelineButton section', async () => { @@ -58,7 +52,7 @@ describe('PipelinePage container test', () => { expect(editButton).toBeInTheDocument(); await userEvent.click(editButton); - expect(trackEventVar).toBeCalledWith('Logs: Pipelines: Entered Edit Mode', { + expect(logEvent).toBeCalledWith('Logs: Pipelines: Entered Edit Mode', { source: 'signoz-ui', }); }); @@ -83,11 +77,8 @@ describe('PipelinePage container test', () => { expect(editButton).toBeInTheDocument(); await userEvent.click(editButton); - expect(trackEventVar).toBeCalledWith( - 'Logs: Pipelines: Clicked Add New Pipeline', - { - source: 'signoz-ui', - }, - ); + expect(logEvent).toBeCalledWith('Logs: Pipelines: Clicked Add New Pipeline', { + source: 'signoz-ui', + }); }); }); diff --git a/frontend/src/container/ServiceApplication/ServiceTraces/index.tsx b/frontend/src/container/ServiceApplication/ServiceTraces/index.tsx index 8d6238c68a..59c7a294bb 100644 --- a/frontend/src/container/ServiceApplication/ServiceTraces/index.tsx +++ b/frontend/src/container/ServiceApplication/ServiceTraces/index.tsx @@ -59,7 +59,7 @@ function ServiceTraces(): JSX.Element { logEvent('APM: List page visited', { numberOfServices: data?.length, selectedEnvironments, - resourceAttributeUsed: !!queries.length, + resourceAttributeUsed: !!queries?.length, rps, }); logEventCalledRef.current = true; diff --git a/frontend/src/container/SideNav/SideNav.tsx b/frontend/src/container/SideNav/SideNav.tsx index b5eb240af8..d4ad27908c 100644 --- a/frontend/src/container/SideNav/SideNav.tsx +++ b/frontend/src/container/SideNav/SideNav.tsx @@ -324,8 +324,8 @@ function SideNav({ onClickHandler(item?.key as string, event); } logEvent('Sidebar: Menu clicked', { - menuRoute: item.key, - menuLabel: item.label, + menuRoute: item?.key, + menuLabel: item?.label, }); }; @@ -455,8 +455,8 @@ function SideNav({ onClick={(event: MouseEvent): void => { handleUserManagentMenuItemClick(item?.key as string, event); logEvent('Sidebar: Menu clicked', { - menuRoute: item.key, - menuLabel: item.label, + menuRoute: item?.key, + menuLabel: item?.label, }); }} /> @@ -475,8 +475,8 @@ function SideNav({ history.push(`${inviteMemberMenuItem.key}`); } logEvent('Sidebar: Menu clicked', { - menuRoute: inviteMemberMenuItem.key, - menuLabel: inviteMemberMenuItem.label, + menuRoute: inviteMemberMenuItem?.key, + menuLabel: inviteMemberMenuItem?.label, }); }} /> @@ -493,7 +493,7 @@ function SideNav({ event, ); logEvent('Sidebar: Menu clicked', { - menuRoute: userSettingsMenuItem.key, + menuRoute: userSettingsMenuItem?.key, menuLabel: 'User', }); }} diff --git a/frontend/src/container/TimeSeriesView/styles.ts b/frontend/src/container/TimeSeriesView/styles.ts index d73f11c38a..41a730161d 100644 --- a/frontend/src/container/TimeSeriesView/styles.ts +++ b/frontend/src/container/TimeSeriesView/styles.ts @@ -1,5 +1,4 @@ -import { Typography } from 'antd'; -import Card from 'antd/es/card/Card'; +import { Card, Typography } from 'antd'; import styled from 'styled-components'; export const Container = styled(Card)` diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx b/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx index 427b57198c..2b553f8017 100644 --- a/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx +++ b/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx @@ -23,7 +23,7 @@ import NewExplorerCTA from 'container/NewExplorerCTA'; import dayjs, { Dayjs } from 'dayjs'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import useUrlQuery from 'hooks/useUrlQuery'; -import GetMinMax from 'lib/getMinMax'; +import GetMinMax, { isValidTimeFormat } from 'lib/getMinMax'; import getTimeString from 'lib/getTimeString'; import history from 'lib/history'; import { isObject } from 'lodash-es'; @@ -73,6 +73,7 @@ function DateTimeSelection({ const urlQuery = useUrlQuery(); const searchStartTime = urlQuery.get('startTime'); const searchEndTime = urlQuery.get('endTime'); + const relativeTimeFromUrl = urlQuery.get(QueryParams.relativeTime); const queryClient = useQueryClient(); const [enableAbsoluteTime, setEnableAbsoluteTime] = useState(false); const [isValidteRelativeTime, setIsValidteRelativeTime] = useState(false); @@ -404,9 +405,18 @@ function DateTimeSelection({ time: Time, currentRoute: string, ): Time | CustomTimeType => { + // if the relativeTime param is present in the url give top most preference to the same + // if the relativeTime param is not valid then move to next preference + if (relativeTimeFromUrl != null && isValidTimeFormat(relativeTimeFromUrl)) { + return relativeTimeFromUrl as Time; + } + + // if the startTime and endTime params are present in the url give next preference to the them. if (searchEndTime !== null && searchStartTime !== null) { return 'custom'; } + + // if nothing is present in the url for time range then rely on the local storage values if ( (localstorageEndTime === null || localstorageStartTime === null) && time === 'custom' @@ -414,6 +424,7 @@ function DateTimeSelection({ return getDefaultOption(currentRoute); } + // if not present in the local storage as well then rely on the defaults set for the page if (OLD_RELATIVE_TIME_VALUES.indexOf(time) > -1) { return convertOldTimeToNewValidCustomTimeFormat(time); } @@ -448,7 +459,11 @@ function DateTimeSelection({ setRefreshButtonHidden(updatedTime === 'custom'); - updateTimeInterval(updatedTime, [preStartTime, preEndTime]); + if (updatedTime !== 'custom') { + updateTimeInterval(updatedTime); + } else { + updateTimeInterval(updatedTime, [preStartTime, preEndTime]); + } if (updatedTime !== 'custom') { urlQuery.delete('startTime'); diff --git a/frontend/src/container/TopNav/index.tsx b/frontend/src/container/TopNav/index.tsx index 5277908240..8219395568 100644 --- a/frontend/src/container/TopNav/index.tsx +++ b/frontend/src/container/TopNav/index.tsx @@ -31,7 +31,14 @@ function TopNav(): JSX.Element | null { [location.pathname], ); - if (isSignUpPage || isDisabled || isRouteToSkip) { + const isNewAlertsLandingPage = useMemo( + () => + matchPath(location.pathname, { path: ROUTES.ALERTS_NEW, exact: true }) && + !location.search, + [location.pathname, location.search], + ); + + if (isSignUpPage || isDisabled || isRouteToSkip || isNewAlertsLandingPage) { return null; } diff --git a/frontend/src/container/TraceDetail/index.tsx b/frontend/src/container/TraceDetail/index.tsx index 568ed3c4f4..38f6db7c08 100644 --- a/frontend/src/container/TraceDetail/index.tsx +++ b/frontend/src/container/TraceDetail/index.tsx @@ -1,8 +1,7 @@ import './TraceDetails.styles.scss'; import { FilterOutlined } from '@ant-design/icons'; -import { Button, Col, Typography } from 'antd'; -import Sider from 'antd/es/layout/Sider'; +import { Button, Col, Layout, Typography } from 'antd'; import cx from 'classnames'; import { StyledCol, @@ -42,6 +41,8 @@ import { INTERVAL_UNITS, } from './utils'; +const { Sider } = Layout; + function TraceDetail({ response }: TraceDetailProps): JSX.Element { const spanServiceColors = useMemo( () => spanServiceNameToColorMapping(response[0].events), diff --git a/frontend/src/container/TracesExplorer/QuerySection/styles.ts b/frontend/src/container/TracesExplorer/QuerySection/styles.ts index cdb46bd580..a688b0dbcb 100644 --- a/frontend/src/container/TracesExplorer/QuerySection/styles.ts +++ b/frontend/src/container/TracesExplorer/QuerySection/styles.ts @@ -1,5 +1,4 @@ -import { Col } from 'antd'; -import Card from 'antd/es/card/Card'; +import { Card, Col } from 'antd'; import styled from 'styled-components'; export const Container = styled(Card)` diff --git a/frontend/src/hooks/analytics/useAnalytics.tsx b/frontend/src/hooks/analytics/useAnalytics.tsx index 28213c9579..e3d2081766 100644 --- a/frontend/src/hooks/analytics/useAnalytics.tsx +++ b/frontend/src/hooks/analytics/useAnalytics.tsx @@ -32,16 +32,6 @@ const useAnalytics = (): any => { } }; - // useEffect(() => { - // // Perform any setup or cleanup related to the analytics library - // // For example, initialize analytics library here - - // // Clean-up function (optional) - // return () => { - // // Perform cleanup if needed - // }; - // }, []); // The empty dependency array ensures that this effect runs only once when the component mounts - return { trackPageView, trackEvent }; }; diff --git a/frontend/src/hooks/queryBuilder/useCreateAlerts.tsx b/frontend/src/hooks/queryBuilder/useCreateAlerts.tsx index 0c0d2b0425..c1a87bfc46 100644 --- a/frontend/src/hooks/queryBuilder/useCreateAlerts.tsx +++ b/frontend/src/hooks/queryBuilder/useCreateAlerts.tsx @@ -61,7 +61,10 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => { }); queryRangeMutation.mutate(queryPayload, { onSuccess: (data) => { - const updatedQuery = mapQueryDataFromApi(data.compositeQuery); + const updatedQuery = mapQueryDataFromApi( + data.compositeQuery, + widget?.query, + ); history.push( `${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent( diff --git a/frontend/src/lib/newQueryBuilder/queryBuilderMappers/__tests__/mapQueryDataFromApi.test.tsx b/frontend/src/lib/newQueryBuilder/queryBuilderMappers/__tests__/mapQueryDataFromApi.test.tsx new file mode 100644 index 0000000000..2a72ab5d10 --- /dev/null +++ b/frontend/src/lib/newQueryBuilder/queryBuilderMappers/__tests__/mapQueryDataFromApi.test.tsx @@ -0,0 +1,56 @@ +import { mapQueryDataFromApi } from '../mapQueryDataFromApi'; +import { + compositeQueriesWithFunctions, + compositeQueryWithoutVariables, + compositeQueryWithVariables, + defaultOutput, + outputWithFunctions, + replaceVariables, + stepIntervalUnchanged, + widgetQueriesWithFunctions, + widgetQueryWithoutVariables, + widgetQueryWithVariables, +} from './mapQueryDataFromApiInputs'; + +jest.mock('uuid', () => ({ + v4: (): string => 'test-id', +})); + +describe('mapQueryDataFromApi function tests', () => { + it('should not update the step interval when query is passed', () => { + const output = mapQueryDataFromApi( + compositeQueryWithoutVariables, + widgetQueryWithoutVariables, + ); + + // composite query is the response from the `v3/query_range/format` API call. + // even if the composite query returns stepInterval updated do not modify it + expect(output).toStrictEqual(stepIntervalUnchanged); + }); + + it('should update filter from the composite query', () => { + const output = mapQueryDataFromApi( + compositeQueryWithVariables, + widgetQueryWithVariables, + ); + + // replace the variables in the widget query and leave the rest items untouched + expect(output).toStrictEqual(replaceVariables); + }); + + it('should not update the step intervals with multiple queries and functions', () => { + const output = mapQueryDataFromApi( + compositeQueriesWithFunctions, + widgetQueriesWithFunctions, + ); + + expect(output).toStrictEqual(outputWithFunctions); + }); + + it('should use the default query values and the compositeQuery object when query is not passed', () => { + const output = mapQueryDataFromApi(compositeQueryWithoutVariables); + + // when the query object is not passed take the initial values and merge the composite query on top of it + expect(output).toStrictEqual(defaultOutput); + }); +}); diff --git a/frontend/src/lib/newQueryBuilder/queryBuilderMappers/__tests__/mapQueryDataFromApiInputs.ts b/frontend/src/lib/newQueryBuilder/queryBuilderMappers/__tests__/mapQueryDataFromApiInputs.ts new file mode 100644 index 0000000000..00d054e9bf --- /dev/null +++ b/frontend/src/lib/newQueryBuilder/queryBuilderMappers/__tests__/mapQueryDataFromApiInputs.ts @@ -0,0 +1,741 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; +import { EQueryType } from 'types/common/dashboard'; +import { DataSource } from 'types/common/queryBuilder'; + +export const compositeQueryWithoutVariables = ({ + builderQueries: { + A: { + queryName: 'A', + stepInterval: 240, + dataSource: DataSource.METRICS, + aggregateOperator: 'rate', + aggregateAttribute: { + key: 'system_disk_operations', + dataType: DataTypes.Float64, + type: 'Sum', + isColumn: true, + isJSON: false, + }, + filters: { + op: 'AND', + items: [], + }, + expression: 'A', + disabled: false, + limit: 0, + offset: 0, + pageSize: 0, + reduceTo: 'avg', + timeAggregation: 'rate', + spaceAggregation: 'sum', + ShiftBy: 0, + }, + }, + panelType: PANEL_TYPES.TIME_SERIES, + queryType: EQueryType.QUERY_BUILDER, +} as unknown) as ICompositeMetricQuery; + +export const widgetQueryWithoutVariables = ({ + clickhouse_sql: [ + { + name: 'A', + legend: '', + disabled: false, + query: '', + }, + ], + promql: [ + { + name: 'A', + query: '', + legend: '', + disabled: false, + }, + ], + builder: { + queryData: [ + { + dataSource: 'metrics', + queryName: 'A', + aggregateOperator: 'rate', + aggregateAttribute: { + key: 'system_disk_operations', + dataType: 'float64', + type: 'Sum', + isColumn: true, + isJSON: false, + id: 'system_disk_operations--float64--Sum--true', + }, + timeAggregation: 'rate', + spaceAggregation: 'sum', + functions: [], + filters: { + items: [], + op: 'AND', + }, + expression: 'A', + disabled: false, + stepInterval: 60, + having: [], + limit: null, + orderBy: [], + groupBy: [], + legend: '', + reduceTo: 'avg', + }, + ], + queryFormulas: [], + }, + id: '2bbbd8d8-db99-40be-b9c6-9e197c5bc537', + queryType: 'builder', +} as unknown) as Query; + +export const stepIntervalUnchanged = { + builder: { + queryData: [ + { + aggregateAttribute: { + dataType: 'float64', + id: 'system_disk_operations--float64--Sum--true', + isColumn: true, + isJSON: false, + key: 'system_disk_operations', + type: 'Sum', + }, + aggregateOperator: 'rate', + dataSource: 'metrics', + disabled: false, + expression: 'A', + filters: { + items: [], + op: 'AND', + }, + functions: [], + groupBy: [], + having: [], + legend: '', + limit: null, + orderBy: [], + queryName: 'A', + reduceTo: 'avg', + spaceAggregation: 'sum', + stepInterval: 60, + timeAggregation: 'rate', + }, + ], + queryFormulas: [], + }, + clickhouse_sql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + id: 'test-id', + promql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + queryType: 'builder', + unit: undefined, +}; + +export const compositeQueryWithVariables = ({ + builderQueries: { + A: { + queryName: 'A', + stepInterval: 240, + dataSource: 'metrics', + aggregateOperator: 'sum_rate', + aggregateAttribute: { + key: 'signoz_calls_total', + dataType: 'float64', + type: '', + isColumn: true, + isJSON: false, + }, + filters: { + op: 'AND', + items: [ + { + key: { + key: 'deployment_environment', + dataType: 'string', + type: 'tag', + isColumn: false, + isJSON: false, + }, + value: 'default', + op: 'in', + }, + { + key: { + key: 'service_name', + dataType: 'string', + type: 'tag', + isColumn: false, + isJSON: false, + }, + value: 'frontend', + op: 'in', + }, + { + key: { + key: 'operation', + dataType: 'string', + type: 'tag', + isColumn: false, + isJSON: false, + }, + value: 'HTTP GET /dispatch', + op: 'in', + }, + ], + }, + groupBy: [ + { + key: 'service_name', + dataType: 'string', + type: 'tag', + isColumn: false, + isJSON: false, + }, + { + key: 'operation', + dataType: 'string', + type: 'tag', + isColumn: false, + isJSON: false, + }, + ], + expression: 'A', + disabled: false, + legend: '{{service_name}}-{{operation}}', + limit: 0, + offset: 0, + pageSize: 0, + reduceTo: 'sum', + timeAggregation: 'rate', + spaceAggregation: 'sum', + ShiftBy: 0, + }, + }, + panelType: 'graph', + queryType: 'builder', +} as unknown) as ICompositeMetricQuery; + +export const widgetQueryWithVariables = ({ + clickhouse_sql: [ + { + name: 'A', + legend: '', + disabled: false, + query: '', + }, + ], + promql: [ + { + name: 'A', + query: '', + legend: '', + disabled: false, + }, + ], + builder: { + queryData: [ + { + dataSource: 'metrics', + queryName: 'A', + aggregateOperator: 'sum_rate', + aggregateAttribute: { + dataType: 'float64', + id: 'signoz_calls_total--float64----true', + isColumn: true, + isJSON: false, + key: 'signoz_calls_total', + type: '', + }, + timeAggregation: 'rate', + spaceAggregation: 'sum', + functions: [], + filters: { + items: [ + { + id: 'aa56621e', + key: { + dataType: 'string', + id: 'deployment_environment--string--tag--false', + isColumn: false, + isJSON: false, + key: 'deployment_environment', + type: 'tag', + }, + op: 'in', + value: ['{{.deployment_environment}}'], + }, + { + id: '97055a02', + key: { + dataType: 'string', + id: 'service_name--string--tag--false', + isColumn: false, + isJSON: false, + key: 'service_name', + type: 'tag', + }, + op: 'in', + value: ['{{.service_name}}'], + }, + { + id: '8c4599f2', + key: { + dataType: 'string', + id: 'operation--string--tag--false', + isColumn: false, + isJSON: false, + key: 'operation', + type: 'tag', + }, + op: 'in', + value: ['{{.endpoint}}'], + }, + ], + op: 'AND', + }, + expression: 'A', + disabled: false, + stepInterval: 60, + having: [], + limit: null, + orderBy: [], + groupBy: [ + { + dataType: 'string', + isColumn: false, + isJSON: false, + key: 'service_name', + type: 'tag', + id: 'service_name--string--tag--false', + }, + { + dataType: 'string', + isColumn: false, + isJSON: false, + key: 'operation', + type: 'tag', + id: 'operation--string--tag--false', + }, + ], + legend: '{{service_name}}-{{operation}}', + reduceTo: 'sum', + }, + ], + queryFormulas: [], + }, + id: '64fcd7be-61d0-4f92-bbb2-1449b089f766', + queryType: 'builder', +} as unknown) as Query; + +export const replaceVariables = { + builder: { + queryData: [ + { + aggregateAttribute: { + dataType: 'float64', + id: 'signoz_calls_total--float64----true', + isColumn: true, + isJSON: false, + key: 'signoz_calls_total', + type: '', + }, + aggregateOperator: 'sum_rate', + dataSource: 'metrics', + disabled: false, + expression: 'A', + filters: { + items: [ + { + key: { + dataType: 'string', + isColumn: false, + isJSON: false, + key: 'deployment_environment', + type: 'tag', + }, + op: 'in', + value: 'default', + }, + { + key: { + dataType: 'string', + isColumn: false, + isJSON: false, + key: 'service_name', + type: 'tag', + }, + op: 'in', + value: 'frontend', + }, + { + key: { + dataType: 'string', + isColumn: false, + isJSON: false, + key: 'operation', + type: 'tag', + }, + op: 'in', + value: 'HTTP GET /dispatch', + }, + ], + op: 'AND', + }, + functions: [], + groupBy: [ + { + dataType: 'string', + id: 'service_name--string--tag--false', + isColumn: false, + isJSON: false, + key: 'service_name', + type: 'tag', + }, + { + dataType: 'string', + id: 'operation--string--tag--false', + isColumn: false, + isJSON: false, + key: 'operation', + type: 'tag', + }, + ], + having: [], + legend: '{{service_name}}-{{operation}}', + limit: null, + orderBy: [], + queryName: 'A', + reduceTo: 'sum', + spaceAggregation: 'sum', + stepInterval: 60, + timeAggregation: 'rate', + }, + ], + queryFormulas: [], + }, + clickhouse_sql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + id: 'test-id', + promql: [ + { + disabled: false, + legend: '', + name: 'A', + query: '', + }, + ], + queryType: 'builder', + unit: undefined, +}; + +export const defaultOutput = { + builder: { + queryData: [ + { + ShiftBy: 0, + aggregateAttribute: { + dataType: 'float64', + isColumn: true, + isJSON: false, + key: 'system_disk_operations', + type: 'Sum', + }, + aggregateOperator: 'rate', + dataSource: 'metrics', + disabled: false, + expression: 'A', + filters: { items: [], op: 'AND' }, + functions: [], + groupBy: [], + having: [], + legend: '', + limit: 0, + offset: 0, + orderBy: [], + pageSize: 0, + queryName: 'A', + reduceTo: 'avg', + spaceAggregation: 'sum', + stepInterval: 240, + timeAggregation: 'rate', + }, + ], + queryFormulas: [], + }, + clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }], + id: 'test-id', + promql: [{ disabled: false, legend: '', name: 'A', query: '' }], + queryType: 'builder', + unit: undefined, +}; + +export const compositeQueriesWithFunctions = ({ + builderQueries: { + A: { + queryName: 'A', + stepInterval: 60, + dataSource: 'metrics', + aggregateOperator: 'count', + aggregateAttribute: { + key: 'signoz_latency_bucket', + dataType: 'float64', + type: 'Histogram', + isColumn: true, + isJSON: false, + }, + filters: { + op: 'AND', + items: [], + }, + expression: 'A', + disabled: false, + limit: 0, + offset: 0, + pageSize: 0, + reduceTo: 'avg', + spaceAggregation: 'p90', + ShiftBy: 0, + }, + B: { + queryName: 'B', + stepInterval: 120, + dataSource: 'metrics', + aggregateOperator: 'rate', + aggregateAttribute: { + key: 'system_disk_io', + dataType: 'float64', + type: 'Sum', + isColumn: true, + isJSON: false, + }, + filters: { + op: 'AND', + items: [], + }, + expression: 'B', + disabled: false, + limit: 0, + offset: 0, + pageSize: 0, + reduceTo: 'avg', + timeAggregation: 'rate', + spaceAggregation: 'sum', + ShiftBy: 0, + }, + F1: { + queryName: 'F1', + stepInterval: 1, + dataSource: '', + aggregateOperator: '', + aggregateAttribute: { + key: '', + dataType: '', + type: '', + isColumn: false, + isJSON: false, + }, + expression: 'A / B ', + disabled: false, + limit: 0, + offset: 0, + pageSize: 0, + ShiftBy: 0, + }, + }, + panelType: 'graph', + queryType: 'builder', +} as unknown) as ICompositeMetricQuery; + +export const widgetQueriesWithFunctions = ({ + clickhouse_sql: [ + { + name: 'A', + legend: '', + disabled: false, + query: '', + }, + ], + promql: [ + { + name: 'A', + query: '', + legend: '', + disabled: false, + }, + ], + builder: { + queryData: [ + { + dataSource: 'metrics', + queryName: 'A', + aggregateOperator: 'count', + aggregateAttribute: { + dataType: 'float64', + id: 'signoz_latency_bucket--float64--Histogram--true', + isColumn: true, + isJSON: false, + key: 'signoz_latency_bucket', + type: 'Histogram', + }, + timeAggregation: '', + spaceAggregation: 'p90', + functions: [], + filters: { + items: [], + op: 'AND', + }, + expression: 'A', + disabled: false, + stepInterval: 120, + having: [], + limit: null, + orderBy: [], + groupBy: [], + legend: '', + reduceTo: 'avg', + }, + { + dataSource: 'metrics', + queryName: 'B', + aggregateOperator: 'rate', + aggregateAttribute: { + key: 'system_disk_io', + dataType: 'float64', + type: 'Sum', + isColumn: true, + isJSON: false, + id: 'system_disk_io--float64--Sum--true', + }, + timeAggregation: 'rate', + spaceAggregation: 'sum', + functions: [], + filters: { + items: [], + op: 'AND', + }, + expression: 'B', + disabled: false, + stepInterval: 120, + having: [], + limit: null, + orderBy: [], + groupBy: [], + legend: '', + reduceTo: 'avg', + }, + ], + queryFormulas: [ + { + queryName: 'F1', + expression: 'A / B ', + disabled: false, + legend: '', + }, + ], + }, + id: '5d1844fe-9b44-4f15-b6fe-f1b843550b77', + queryType: 'builder', +} as unknown) as Query; + +export const outputWithFunctions = { + builder: { + queryData: [ + { + dataSource: 'metrics', + queryName: 'A', + aggregateOperator: 'count', + aggregateAttribute: { + dataType: 'float64', + id: 'signoz_latency_bucket--float64--Histogram--true', + isColumn: true, + isJSON: false, + key: 'signoz_latency_bucket', + type: 'Histogram', + }, + timeAggregation: '', + spaceAggregation: 'p90', + functions: [], + filters: { + op: 'AND', + items: [], + }, + expression: 'A', + disabled: false, + stepInterval: 120, + having: [], + limit: null, + orderBy: [], + groupBy: [], + legend: '', + reduceTo: 'avg', + }, + { + dataSource: 'metrics', + queryName: 'B', + aggregateOperator: 'rate', + aggregateAttribute: { + key: 'system_disk_io', + dataType: 'float64', + type: 'Sum', + isColumn: true, + isJSON: false, + id: 'system_disk_io--float64--Sum--true', + }, + timeAggregation: 'rate', + spaceAggregation: 'sum', + functions: [], + filters: { + op: 'AND', + items: [], + }, + expression: 'B', + disabled: false, + stepInterval: 120, + having: [], + limit: null, + orderBy: [], + groupBy: [], + legend: '', + reduceTo: 'avg', + }, + ], + queryFormulas: [ + { + queryName: 'F1', + expression: 'A / B ', + disabled: false, + legend: '', + }, + ], + }, + clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }], + id: 'test-id', + promql: [{ disabled: false, legend: '', name: 'A', query: '' }], + queryType: 'builder', + unit: undefined, +}; diff --git a/frontend/src/lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi.ts b/frontend/src/lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi.ts index 733513b33b..d95127b969 100644 --- a/frontend/src/lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi.ts +++ b/frontend/src/lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi.ts @@ -7,9 +7,13 @@ import { transformQueryBuilderDataModel } from '../transformQueryBuilderDataMode export const mapQueryDataFromApi = ( compositeQuery: ICompositeMetricQuery, + query?: Query, ): Query => { const builder = compositeQuery.builderQueries - ? transformQueryBuilderDataModel(compositeQuery.builderQueries) + ? transformQueryBuilderDataModel( + compositeQuery.builderQueries, + query?.builder, + ) : initialQueryState.builder; const promql = compositeQuery.promQueries diff --git a/frontend/src/lib/newQueryBuilder/transformQueryBuilderDataModel.ts b/frontend/src/lib/newQueryBuilder/transformQueryBuilderDataModel.ts index 3cf545d49b..f2a24eef84 100644 --- a/frontend/src/lib/newQueryBuilder/transformQueryBuilderDataModel.ts +++ b/frontend/src/lib/newQueryBuilder/transformQueryBuilderDataModel.ts @@ -3,6 +3,7 @@ import { initialQueryBuilderFormValuesMap, } from 'constants/queryBuilder'; import { FORMULA_REGEXP } from 'constants/regExp'; +import { isUndefined } from 'lodash-es'; import { BuilderQueryDataResourse, IBuilderFormula, @@ -12,6 +13,7 @@ import { QueryBuilderData } from 'types/common/queryBuilder'; export const transformQueryBuilderDataModel = ( data: BuilderQueryDataResourse, + query?: QueryBuilderData, ): QueryBuilderData => { const queryData: QueryBuilderData['queryData'] = []; const queryFormulas: QueryBuilderData['queryFormulas'] = []; @@ -19,10 +21,37 @@ export const transformQueryBuilderDataModel = ( Object.entries(data).forEach(([, value]) => { if (FORMULA_REGEXP.test(value.queryName)) { const formula = value as IBuilderFormula; - queryFormulas.push({ ...initialFormulaBuilderFormValues, ...formula }); + const baseFormula = query?.queryFormulas?.find( + (f) => f.queryName === value.queryName, + ); + if (!isUndefined(baseFormula)) { + // this is part of the flow where we create alerts from dashboard. + // we pass the formula as is from the widget query as we do not want anything to update in formula from the format api call + queryFormulas.push({ ...baseFormula }); + } else { + queryFormulas.push({ ...initialFormulaBuilderFormValues, ...formula }); + } } else { - const query = value as IBuilderQuery; - queryData.push({ ...initialQueryBuilderFormValuesMap.metrics, ...query }); + const queryFromData = value as IBuilderQuery; + const baseQuery = query?.queryData?.find( + (q) => q.queryName === queryFromData.queryName, + ); + + if (!isUndefined(baseQuery)) { + // this is part of the flow where we create alerts from dashboard. + // we pass the widget query as the base query and accept the filters from the format API response. + // which fills the variable values inside the same and is used to create alerts + // do not accept the full object as the stepInterval field is subject to changes + queryData.push({ + ...baseQuery, + filters: queryFromData.filters, + }); + } else { + queryData.push({ + ...initialQueryBuilderFormValuesMap.metrics, + ...queryFromData, + }); + } } }); diff --git a/frontend/src/mocks-server/__mockdata__/dashboards.ts b/frontend/src/mocks-server/__mockdata__/dashboards.ts new file mode 100644 index 0000000000..40d9cb48d9 --- /dev/null +++ b/frontend/src/mocks-server/__mockdata__/dashboards.ts @@ -0,0 +1,101 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +export const dashboardSuccessResponse = { + status: 'success', + data: [ + { + id: 1, + uuid: '1', + created_at: '2022-11-16T13:29:47.064874419Z', + created_by: null, + updated_at: '2024-05-21T06:41:30.546630961Z', + updated_by: 'thor@avengers.io', + isLocked: 0, + data: { + collapsableRowsMigrated: true, + description: '', + name: '', + panelMap: {}, + tags: ['linux'], + title: 'thor', + uploadedGrafana: false, + uuid: '', + version: '', + }, + }, + { + id: 2, + uuid: '2', + created_at: '2022-11-16T13:20:47.064874419Z', + created_by: null, + updated_at: '2024-05-21T06:42:30.546630961Z', + updated_by: 'captain-america@avengers.io', + isLocked: 0, + data: { + collapsableRowsMigrated: true, + description: '', + name: '', + panelMap: {}, + tags: ['linux'], + title: 'captain america', + uploadedGrafana: false, + uuid: '', + version: '', + }, + }, + ], +}; + +export const dashboardEmptyState = { + status: 'sucsess', + data: [], +}; + +export const getDashboardById = { + status: 'success', + data: { + id: 1, + uuid: '1', + created_at: '2022-11-16T13:29:47.064874419Z', + created_by: 'integration', + updated_at: '2024-05-21T06:41:30.546630961Z', + updated_by: 'thor@avengers.io', + isLocked: true, + data: { + collapsableRowsMigrated: true, + description: '', + name: '', + panelMap: {}, + tags: ['linux'], + title: 'thor', + uploadedGrafana: false, + uuid: '', + version: '', + variables: {}, + }, + }, +}; + +export const getNonIntegrationDashboardById = { + status: 'success', + data: { + id: 1, + uuid: '1', + created_at: '2022-11-16T13:29:47.064874419Z', + created_by: 'thor', + updated_at: '2024-05-21T06:41:30.546630961Z', + updated_by: 'thor@avengers.io', + isLocked: true, + data: { + collapsableRowsMigrated: true, + description: '', + name: '', + panelMap: {}, + tags: ['linux'], + title: 'thor', + uploadedGrafana: false, + uuid: '', + version: '', + variables: {}, + }, + }, +}; diff --git a/frontend/src/mocks-server/__mockdata__/explorer_views.ts b/frontend/src/mocks-server/__mockdata__/explorer_views.ts new file mode 100644 index 0000000000..ae88071e55 --- /dev/null +++ b/frontend/src/mocks-server/__mockdata__/explorer_views.ts @@ -0,0 +1,81 @@ +export const explorerView = { + status: 'success', + data: [ + { + uuid: 'test-uuid-1', + name: 'Table View', + category: '', + createdAt: '2023-08-29T18:04:10.906310033Z', + createdBy: 'test-user-1', + updatedAt: '2024-01-29T10:42:47.346331133Z', + updatedBy: 'test-user-1', + sourcePage: 'traces', + tags: [''], + compositeQuery: { + builderQueries: { + A: { + queryName: 'A', + stepInterval: 60, + dataSource: 'traces', + aggregateOperator: 'count', + aggregateAttribute: { + key: 'component', + dataType: 'string', + type: 'tag', + isColumn: true, + isJSON: false, + }, + filters: { + op: 'AND', + items: [ + { + key: { + key: 'component', + dataType: 'string', + type: 'tag', + isColumn: true, + isJSON: false, + }, + value: 'test-component', + op: '!=', + }, + ], + }, + groupBy: [ + { + key: 'component', + dataType: 'string', + type: 'tag', + isColumn: true, + isJSON: false, + }, + { + key: 'client-uuid', + dataType: 'string', + type: 'resource', + isColumn: false, + isJSON: false, + }, + ], + expression: 'A', + disabled: false, + limit: 0, + offset: 0, + pageSize: 0, + orderBy: [ + { + columnName: 'timestamp', + order: 'desc', + }, + ], + reduceTo: 'sum', + ShiftBy: 0, + }, + }, + panelType: 'table', + queryType: 'builder', + }, + extraData: '{"color":"#00ffd0"}', + }, + ], +}; diff --git a/frontend/src/mocks-server/handlers.ts b/frontend/src/mocks-server/handlers.ts index 1814d215f7..d46aa52420 100644 --- a/frontend/src/mocks-server/handlers.ts +++ b/frontend/src/mocks-server/handlers.ts @@ -1,6 +1,11 @@ import { rest } from 'msw'; import { billingSuccessResponse } from './__mockdata__/billing'; +import { + dashboardSuccessResponse, + getDashboardById, +} from './__mockdata__/dashboards'; +import { explorerView } from './__mockdata__/explorer_views'; import { inviteUser } from './__mockdata__/invite_user'; import { licensesSuccessResponse } from './__mockdata__/licenses'; import { membersResponse } from './__mockdata__/members'; @@ -54,6 +59,51 @@ export const handlers = [ const metricName = req.url.searchParams.get('metricName'); const tagKey = req.url.searchParams.get('tagKey'); + const attributeKey = req.url.searchParams.get('attributeKey'); + + if (attributeKey === 'serviceName') { + return res( + ctx.status(200), + ctx.json({ + status: 'success', + data: { + stringAttributeValues: [ + 'customer', + 'demo-app', + 'driver', + 'frontend', + 'mysql', + 'redis', + 'route', + 'go-grpc-otel-server', + 'test', + ], + numberAttributeValues: null, + boolAttributeValues: null, + }, + }), + ); + } + + if (attributeKey === 'name') { + return res( + ctx.status(200), + ctx.json({ + status: 'success', + data: { + stringAttributeValues: [ + 'HTTP GET', + 'HTTP GET /customer', + 'HTTP GET /dispatch', + 'HTTP GET /route', + ], + numberAttributeValues: null, + boolAttributeValues: null, + }, + }), + ); + } + if ( metricName === 'signoz_calls_total' && tagKey === 'resource_signoz_collector_id' @@ -86,15 +136,49 @@ export const handlers = [ res(ctx.status(200), ctx.json(licensesSuccessResponse)), ), - // ?licenseKey=58707e3d-3bdb-44e7-8c89-a9be237939f4 rest.get('http://localhost/api/v1/billing', (req, res, ctx) => res(ctx.status(200), ctx.json(billingSuccessResponse)), ), + rest.get('http://localhost/api/v1/dashboards', (_, res, ctx) => + res(ctx.status(200), ctx.json(dashboardSuccessResponse)), + ), + + rest.get('http://localhost/api/v1/dashboards/4', (_, res, ctx) => + res(ctx.status(200), ctx.json(getDashboardById)), + ), + rest.get('http://localhost/api/v1/invite', (_, res, ctx) => res(ctx.status(200), ctx.json(inviteUser)), ), rest.post('http://localhost/api/v1/invite', (_, res, ctx) => res(ctx.status(200), ctx.json(inviteUser)), ), + + rest.get( + 'http://localhost/api/v3/autocomplete/aggregate_attributes', + (req, res, ctx) => + res( + ctx.status(200), + ctx.json({ + status: 'success', + data: { attributeKeys: null }, + }), + ), + ), + + rest.get('http://localhost/api/v1/explorer/views', (req, res, ctx) => + res(ctx.status(200), ctx.json(explorerView)), + ), + + rest.post('http://localhost/api/v1/event', (req, res, ctx) => + res( + ctx.status(200), + ctx.json({ + statusCode: 200, + error: null, + payload: 'Event Processed Successfully', + }), + ), + ), ]; diff --git a/frontend/src/pages/DashboardsListPage/__tests__/DashboardListPage.test.tsx b/frontend/src/pages/DashboardsListPage/__tests__/DashboardListPage.test.tsx new file mode 100644 index 0000000000..98bd40ef62 --- /dev/null +++ b/frontend/src/pages/DashboardsListPage/__tests__/DashboardListPage.test.tsx @@ -0,0 +1,207 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import ROUTES from 'constants/routes'; +import DashboardsList from 'container/ListOfDashboard'; +import { dashboardEmptyState } from 'mocks-server/__mockdata__/dashboards'; +import { server } from 'mocks-server/server'; +import { rest } from 'msw'; +import { DashboardProvider } from 'providers/Dashboard/Dashboard'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import { fireEvent, render, waitFor } from 'tests/test-utils'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: jest.fn(), + useRouteMatch: jest.fn().mockReturnValue({ + params: { + dashboardId: 4, + }, + }), +})); + +const mockWindowOpen = jest.fn(); +window.open = mockWindowOpen; + +describe('dashboard list page', () => { + // should render on updatedAt and descend when the column key and order is messed up + it('should render the list even when the columnKey or the order is mismatched', async () => { + const mockLocation = { + pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.ALL_DASHBOARD}/`, + search: `columnKey=asgard&order=stones&page=1`, + }; + (useLocation as jest.Mock).mockReturnValue(mockLocation); + const { getByText, getByTestId } = render( + + + + + , + ); + + await waitFor(() => expect(getByText('All Dashboards')).toBeInTheDocument()); + const firstElement = getByTestId('dashboard-title-0'); + expect(firstElement.textContent).toBe('captain america'); + const secondElement = getByTestId('dashboard-title-1'); + expect(secondElement.textContent).toBe('thor'); + }); + + // should render correctly when the column key is createdAt and order is descend + it('should render the list even when the columnKey and the order are given', async () => { + const mockLocation = { + pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.ALL_DASHBOARD}/`, + search: `columnKey=createdAt&order=descend&page=1`, + }; + (useLocation as jest.Mock).mockReturnValue(mockLocation); + const { getByText, getByTestId } = render( + + + + + , + ); + + await waitFor(() => expect(getByText('All Dashboards')).toBeInTheDocument()); + const firstElement = getByTestId('dashboard-title-0'); + expect(firstElement.textContent).toBe('thor'); + const secondElement = getByTestId('dashboard-title-1'); + expect(secondElement.textContent).toBe('captain america'); + }); + + // change the sort by order and dashboards list ot be updated accordingly + it('dashboards list should be correctly updated on choosing the different sortBy from dropdown values', async () => { + const { getByText, getByTestId } = render( + + + + + , + ); + + await waitFor(() => expect(getByText('All Dashboards')).toBeInTheDocument()); + + const firstElement = getByTestId('dashboard-title-0'); + expect(firstElement.textContent).toBe('thor'); + const secondElement = getByTestId('dashboard-title-1'); + expect(secondElement.textContent).toBe('captain america'); + + // click on the sort button + const sortByButton = getByTestId('sort-by'); + expect(sortByButton).toBeInTheDocument(); + fireEvent.click(sortByButton!); + + // change the sort order + const sortByUpdatedBy = getByTestId('sort-by-last-updated'); + await waitFor(() => expect(sortByUpdatedBy).toBeInTheDocument()); + fireEvent.click(sortByUpdatedBy!); + + // expect the new order + const updatedFirstElement = getByTestId('dashboard-title-0'); + expect(updatedFirstElement.textContent).toBe('captain america'); + const updatedSecondElement = getByTestId('dashboard-title-1'); + expect(updatedSecondElement.textContent).toBe('thor'); + }); + + // should filter correctly on search string + it('should filter dashboards based on search string', async () => { + const mockLocation = { + pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.ALL_DASHBOARD}/`, + search: `columnKey=createdAt&order=descend&page=1&search=tho`, + }; + (useLocation as jest.Mock).mockReturnValue(mockLocation); + const { getByText, getByTestId, queryByText } = render( + + + + + , + ); + + await waitFor(() => expect(getByText('All Dashboards')).toBeInTheDocument()); + const firstElement = getByTestId('dashboard-title-0'); + expect(firstElement.textContent).toBe('thor'); + expect(queryByText('captain america')).not.toBeInTheDocument(); + + // the pagination item should not be present in the list when number of items are less than one page size + expect( + document.querySelector('.ant-table-pagination'), + ).not.toBeInTheDocument(); + }); + + it('dashboard empty search state', async () => { + const mockLocation = { + pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.ALL_DASHBOARD}/`, + search: `columnKey=createdAt&order=descend&page=1&search=someRandomString`, + }; + (useLocation as jest.Mock).mockReturnValue(mockLocation); + const { getByText } = render( + + + + + , + ); + + await waitFor(() => + expect( + getByText( + 'No dashboards found for someRandomString. Create a new dashboard?', + ), + ).toBeInTheDocument(), + ); + }); + + it('dashboard empty state', async () => { + const mockLocation = { + pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.ALL_DASHBOARD}/`, + search: `columnKey=createdAt&order=descend&page=1`, + }; + (useLocation as jest.Mock).mockReturnValue(mockLocation); + server.use( + rest.get('http://localhost/api/v1/dashboards', (_, res, ctx) => + res(ctx.status(200), ctx.json(dashboardEmptyState)), + ), + ); + const { getByText, getByTestId } = render( + + + + + , + ); + + await waitFor(() => + expect(getByText('No dashboards yet.')).toBeInTheDocument(), + ); + + const learnMoreButton = getByTestId('learn-more'); + expect(learnMoreButton).toBeInTheDocument(); + fireEvent.click(learnMoreButton); + + // test the correct link to be added for the dashboards empty state + await waitFor(() => + expect(mockWindowOpen).toHaveBeenCalledWith( + 'https://signoz.io/docs/userguide/manage-dashboards?utm_source=product&utm_medium=dashboard-list-empty-state', + '_blank', + ), + ); + }); +}); diff --git a/frontend/src/pages/Integrations/IntegrationDetailPage/IntegrationDetailContentTabs/Configure.tsx b/frontend/src/pages/Integrations/IntegrationDetailPage/IntegrationDetailContentTabs/Configure.tsx index f645653883..07955018bf 100644 --- a/frontend/src/pages/Integrations/IntegrationDetailPage/IntegrationDetailContentTabs/Configure.tsx +++ b/frontend/src/pages/Integrations/IntegrationDetailPage/IntegrationDetailContentTabs/Configure.tsx @@ -4,7 +4,6 @@ import { Button, Typography } from 'antd'; import logEvent from 'api/common/logEvent'; import cx from 'classnames'; import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer'; -import useAnalytics from 'hooks/analytics/useAnalytics'; import { INTEGRATION_TELEMETRY_EVENTS } from 'pages/Integrations/utils'; import { useEffect, useState } from 'react'; @@ -18,8 +17,6 @@ function Configure(props: ConfigurationProps): JSX.Element { const { configuration, integrationId } = props; const [selectedConfigStep, setSelectedConfigStep] = useState(0); - const { trackEvent } = useAnalytics(); - const handleMenuClick = (index: number, config: any): void => { setSelectedConfigStep(index); logEvent('Integrations Detail Page: Configure tab', { @@ -29,7 +26,7 @@ function Configure(props: ConfigurationProps): JSX.Element { }; useEffect(() => { - trackEvent( + logEvent( INTEGRATION_TELEMETRY_EVENTS.INTEGRATIONS_DETAIL_CONFIGURE_INSTRUCTION, { integration: integrationId, diff --git a/frontend/src/pages/Integrations/IntegrationDetailPage/IntegrationDetailHeader.tsx b/frontend/src/pages/Integrations/IntegrationDetailPage/IntegrationDetailHeader.tsx index 6d15fc8694..97ac3e11c8 100644 --- a/frontend/src/pages/Integrations/IntegrationDetailPage/IntegrationDetailHeader.tsx +++ b/frontend/src/pages/Integrations/IntegrationDetailPage/IntegrationDetailHeader.tsx @@ -2,12 +2,12 @@ import './IntegrationDetailPage.styles.scss'; import { Button, Modal, Tooltip, Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; import installIntegration from 'api/Integrations/installIntegration'; import ConfigureIcon from 'assets/Integrations/ConfigureIcon'; import cx from 'classnames'; import { SOMETHING_WENT_WRONG } from 'constants/api'; import dayjs from 'dayjs'; -import useAnalytics from 'hooks/analytics/useAnalytics'; import { useNotifications } from 'hooks/useNotifications'; import { ArrowLeftRight, Check } from 'lucide-react'; import { useState } from 'react'; @@ -43,8 +43,6 @@ function IntegrationDetailHeader( } = props; const [isModalOpen, setIsModalOpen] = useState(false); - const { trackEvent } = useAnalytics(); - const { notifications } = useNotifications(); const showModal = (): void => { @@ -137,11 +135,11 @@ function IntegrationDetailHeader( disabled={isInstallLoading} onClick={(): void => { if (connectionState === ConnectionStates.NotInstalled) { - trackEvent(INTEGRATION_TELEMETRY_EVENTS.INTEGRATIONS_DETAIL_CONNECT, { + logEvent(INTEGRATION_TELEMETRY_EVENTS.INTEGRATIONS_DETAIL_CONNECT, { integration: id, }); } else { - trackEvent( + logEvent( INTEGRATION_TELEMETRY_EVENTS.INTEGRATIONS_DETAIL_TEST_CONNECTION, { integration: id, diff --git a/frontend/src/pages/Integrations/IntegrationDetailPage/IntegrationsUninstallBar.tsx b/frontend/src/pages/Integrations/IntegrationDetailPage/IntegrationsUninstallBar.tsx index a1ad762ec6..557327a19b 100644 --- a/frontend/src/pages/Integrations/IntegrationDetailPage/IntegrationsUninstallBar.tsx +++ b/frontend/src/pages/Integrations/IntegrationDetailPage/IntegrationsUninstallBar.tsx @@ -1,9 +1,9 @@ import './IntegrationDetailPage.styles.scss'; import { Button, Modal, Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; import unInstallIntegration from 'api/Integrations/uninstallIntegration'; import { SOMETHING_WENT_WRONG } from 'constants/api'; -import useAnalytics from 'hooks/analytics/useAnalytics'; import { useNotifications } from 'hooks/useNotifications'; import { X } from 'lucide-react'; import { useState } from 'react'; @@ -30,8 +30,6 @@ function IntergrationsUninstallBar( const { notifications } = useNotifications(); const [isModalOpen, setIsModalOpen] = useState(false); - const { trackEvent } = useAnalytics(); - const { mutate: uninstallIntegration, isLoading: isUninstallLoading, @@ -52,7 +50,7 @@ function IntergrationsUninstallBar( }; const handleOk = (): void => { - trackEvent( + logEvent( INTEGRATION_TELEMETRY_EVENTS.INTEGRATIONS_DETAIL_REMOVE_INTEGRATION, { integration: integrationId, diff --git a/frontend/src/pages/Integrations/Integrations.tsx b/frontend/src/pages/Integrations/Integrations.tsx index 8fdc943f77..d8bd318ae5 100644 --- a/frontend/src/pages/Integrations/Integrations.tsx +++ b/frontend/src/pages/Integrations/Integrations.tsx @@ -1,6 +1,6 @@ import './Integrations.styles.scss'; -import useAnalytics from 'hooks/analytics/useAnalytics'; +import logEvent from 'api/common/logEvent'; import useUrlQuery from 'hooks/useUrlQuery'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; @@ -16,8 +16,6 @@ function Integrations(): JSX.Element { const history = useHistory(); const location = useLocation(); - const { trackEvent } = useAnalytics(); - const selectedIntegration = useMemo(() => urlQuery.get('integration'), [ urlQuery, ]); @@ -25,7 +23,7 @@ function Integrations(): JSX.Element { const setSelectedIntegration = useCallback( (integration: string | null) => { if (integration) { - trackEvent(INTEGRATION_TELEMETRY_EVENTS.INTEGRATIONS_ITEM_LIST_CLICKED, { + logEvent(INTEGRATION_TELEMETRY_EVENTS.INTEGRATIONS_ITEM_LIST_CLICKED, { integration, }); urlQuery.set('integration', integration); @@ -35,7 +33,7 @@ function Integrations(): JSX.Element { const generatedUrl = `${location.pathname}?${urlQuery.toString()}`; history.push(generatedUrl); }, - [history, location.pathname, trackEvent, urlQuery], + [history, location.pathname, urlQuery], ); const [activeDetailTab, setActiveDetailTab] = useState( @@ -43,7 +41,7 @@ function Integrations(): JSX.Element { ); useEffect(() => { - trackEvent(INTEGRATION_TELEMETRY_EVENTS.INTEGRATIONS_LIST_VISITED); + logEvent(INTEGRATION_TELEMETRY_EVENTS.INTEGRATIONS_LIST_VISITED, {}); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/frontend/src/pages/SaveView/index.tsx b/frontend/src/pages/SaveView/index.tsx index efcd3f2a4b..7088c4a78c 100644 --- a/frontend/src/pages/SaveView/index.tsx +++ b/frontend/src/pages/SaveView/index.tsx @@ -149,11 +149,11 @@ function SaveView(): JSX.Element { if (!logEventCalledRef.current && !isLoading) { if (sourcepage === DataSource.TRACES) { logEvent('Traces Views: Views visited', { - number: viewsData?.data.data.length, + number: viewsData?.data?.data?.length, }); } else if (sourcepage === DataSource.LOGS) { logEvent('Logs Views: Views visited', { - number: viewsData?.data.data.length, + number: viewsData?.data?.data?.length, }); } logEventCalledRef.current = true; diff --git a/frontend/src/pages/Settings/utils.ts b/frontend/src/pages/Settings/utils.ts index 4d54c05603..d789e197d0 100644 --- a/frontend/src/pages/Settings/utils.ts +++ b/frontend/src/pages/Settings/utils.ts @@ -26,7 +26,10 @@ export const getRoutes = ( settings.push(...organizationSettings(t)); } - if (isGatewayEnabled && userRole === USER_ROLES.ADMIN) { + if ( + isGatewayEnabled && + (userRole === USER_ROLES.ADMIN || userRole === USER_ROLES.EDITOR) + ) { settings.push(...multiIngestionSettings(t)); } diff --git a/frontend/src/pages/SignUp/SignUp.tsx b/frontend/src/pages/SignUp/SignUp.tsx index 35cca11603..84329a1626 100644 --- a/frontend/src/pages/SignUp/SignUp.tsx +++ b/frontend/src/pages/SignUp/SignUp.tsx @@ -1,4 +1,5 @@ import { Button, Form, Input, Space, Switch, Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; import editOrg from 'api/user/editOrg'; import getInviteDetails from 'api/user/getInviteDetails'; import loginApi from 'api/user/login'; @@ -7,7 +8,6 @@ import afterLogin from 'AppRoutes/utils'; import WelcomeLeftContainer from 'components/WelcomeLeftContainer'; import { FeatureKeys } from 'constants/features'; import ROUTES from 'constants/routes'; -import useAnalytics from 'hooks/analytics/useAnalytics'; import useFeatureFlag from 'hooks/useFeatureFlag'; import { useNotifications } from 'hooks/useNotifications'; import history from 'lib/history'; @@ -57,7 +57,6 @@ function SignUp({ version }: SignUpProps): JSX.Element { false, ); const { search } = useLocation(); - const { trackEvent } = useAnalytics(); const params = new URLSearchParams(search); const token = params.get('token'); const [isDetailsDisable, setIsDetailsDisable] = useState(false); @@ -88,7 +87,7 @@ function SignUp({ version }: SignUpProps): JSX.Element { form.setFieldValue('organizationName', responseDetails.organization); setIsDetailsDisable(true); - trackEvent('Account Creation Page Visited', { + logEvent('Account Creation Page Visited', { email: responseDetails.email, name: responseDetails.name, company_name: responseDetails.organization, @@ -241,7 +240,7 @@ function SignUp({ version }: SignUpProps): JSX.Element { setLoading(true); if (!isPasswordValid(values.password)) { - trackEvent('Account Creation Page - Invalid Password', { + logEvent('Account Creation Page - Invalid Password', { email: values.email, name: values.firstName, }); @@ -253,7 +252,7 @@ function SignUp({ version }: SignUpProps): JSX.Element { if (isPreferenceVisible) { await commonHandler(values, onAdminAfterLogin); } else { - trackEvent('Account Created Successfully', { + logEvent('Account Created Successfully', { email: values.email, name: values.firstName, }); diff --git a/frontend/src/pages/Support/Support.tsx b/frontend/src/pages/Support/Support.tsx index 2d156a3816..08a8b4602a 100644 --- a/frontend/src/pages/Support/Support.tsx +++ b/frontend/src/pages/Support/Support.tsx @@ -1,7 +1,7 @@ import './Support.styles.scss'; import { Button, Card, Typography } from 'antd'; -import useAnalytics from 'hooks/analytics/useAnalytics'; +import logEvent from 'api/common/logEvent'; import { Book, Cable, @@ -85,7 +85,6 @@ const supportChannels = [ ]; export default function Support(): JSX.Element { - const { trackEvent } = useAnalytics(); const history = useHistory(); const handleChannelWithRedirects = (url: string): void => { @@ -97,7 +96,7 @@ export default function Support(): JSX.Element { const histroyState = history?.location?.state as any; if (histroyState && histroyState?.from) { - trackEvent(`Support : From URL : ${histroyState.from}`); + logEvent(`Support : From URL : ${histroyState.from}`, {}); } } @@ -129,7 +128,7 @@ export default function Support(): JSX.Element { }; const handleChannelClick = (channel: Channel): void => { - trackEvent(`Support : ${channel.name}`); + logEvent(`Support : ${channel.name}`, {}); switch (channel.key) { case channelsMap.documentation: diff --git a/frontend/src/pages/TracesExplorer/Filter/DurationSection.tsx b/frontend/src/pages/TracesExplorer/Filter/DurationSection.tsx index ce124f623e..7cf2441a49 100644 --- a/frontend/src/pages/TracesExplorer/Filter/DurationSection.tsx +++ b/frontend/src/pages/TracesExplorer/Filter/DurationSection.tsx @@ -109,6 +109,7 @@ export function DurationSection(props: DurationProps): JSX.Element { className="min-max-input" onChange={onChangeMinHandler} value={preMin} + data-testid="min-input" addonAfter="ms" />
diff --git a/frontend/src/pages/TracesExplorer/Filter/Filter.tsx b/frontend/src/pages/TracesExplorer/Filter/Filter.tsx index 3d3895e047..2893fca2ba 100644 --- a/frontend/src/pages/TracesExplorer/Filter/Filter.tsx +++ b/frontend/src/pages/TracesExplorer/Filter/Filter.tsx @@ -224,13 +224,18 @@ export function Filter(props: FilterProps): JSX.Element { - diff --git a/frontend/src/pages/TracesExplorer/Filter/Section.tsx b/frontend/src/pages/TracesExplorer/Filter/Section.tsx index 8ce2007ef7..9212f610b0 100644 --- a/frontend/src/pages/TracesExplorer/Filter/Section.tsx +++ b/frontend/src/pages/TracesExplorer/Filter/Section.tsx @@ -64,7 +64,7 @@ export function Section(props: SectionProps): JSX.Element { return (
-
+
-
diff --git a/frontend/src/pages/TracesExplorer/Filter/SectionContent.tsx b/frontend/src/pages/TracesExplorer/Filter/SectionContent.tsx index 4cefaaeca0..2bae1dfe16 100644 --- a/frontend/src/pages/TracesExplorer/Filter/SectionContent.tsx +++ b/frontend/src/pages/TracesExplorer/Filter/SectionContent.tsx @@ -145,6 +145,7 @@ export function SectionBody(props: SectionBodyProps): JSX.Element { key={`${type}-${item}`} onChange={(e): void => onCheckHandler(e, item)} checked={checkboxMatcher(item)} + data-testid={`${type}-${item}`} >
diff --git a/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx b/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx index 1250a3f3cc..1dcaaaa4cf 100644 --- a/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx +++ b/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx @@ -1,16 +1,19 @@ /* eslint-disable sonarjs/no-duplicate-string */ /* eslint-disable no-restricted-syntax */ /* eslint-disable no-await-in-loop */ +import userEvent from '@testing-library/user-event'; import { initialQueriesMap, initialQueryBuilderFormValues, } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; import * as compositeQueryHook from 'hooks/queryBuilder/useGetCompositeQueryParam'; -import { render } from 'tests/test-utils'; +import { QueryBuilderContext } from 'providers/QueryBuilder'; +import { fireEvent, render, screen, waitFor, within } from 'tests/test-utils'; import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { Query } from 'types/api/queryBuilder/queryBuilderData'; +import TracesExplorer from '..'; import { Filter } from '../Filter/Filter'; import { AllTraceFilterKeyValue } from '../Filter/filterUtils'; @@ -37,6 +40,48 @@ jest.mock('uplot', () => { }; }); +jest.mock( + 'container/TopNav/DateTimeSelectionV2/index.tsx', + () => + function MockDateTimeSelection(): JSX.Element { + return
MockDateTimeSelection
; + }, +); + +function checkIfSectionIsOpen( + getByTestId: (testId: string) => HTMLElement, + panelName: string, +): void { + const section = getByTestId(`collapse-${panelName}`); + expect(section.querySelector('.ant-collapse-item-active')).not.toBeNull(); +} + +function checkIfSectionIsNotOpen( + getByTestId: (testId: string) => HTMLElement, + panelName: string, +): void { + const section = getByTestId(`collapse-${panelName}`); + expect(section.querySelector('.ant-collapse-item-active')).toBeNull(); +} + +const defaultOpenSections = ['hasError', 'durationNano', 'serviceName']; + +const defaultClosedSections = Object.keys(AllTraceFilterKeyValue).filter( + (section) => + ![...defaultOpenSections, 'durationNanoMin', 'durationNanoMax'].includes( + section, + ), +); + +async function checkForSectionContent(values: string[]): Promise { + for (const val of values) { + const sectionContent = await screen.findByText(val); + await waitFor(() => expect(sectionContent).toBeInTheDocument()); + } +} + +const redirectWithQueryBuilderData = jest.fn(); + const compositeQuery: Query = { ...initialQueriesMap.traces, builder: { @@ -81,6 +126,157 @@ const compositeQuery: Query = { }; describe('TracesExplorer - ', () => { + // Initial filter panel rendering + // Test the initial state like which filters section are opened, default state of duration slider, etc. + it('should render the Trace filter', async () => { + const { getByText, getByTestId } = render(); + + Object.values(AllTraceFilterKeyValue).forEach((filter) => { + expect(getByText(filter)).toBeInTheDocument(); + }); + + // Check default state of duration slider + const minDuration = getByTestId('min-input') as HTMLInputElement; + const maxDuration = getByTestId('max-input') as HTMLInputElement; + expect(minDuration).toHaveValue(null); + expect(minDuration).toHaveProperty('placeholder', '0'); + expect(maxDuration).toHaveValue(null); + expect(maxDuration).toHaveProperty('placeholder', '100000000'); + + // Check which all filter section are opened by default + defaultOpenSections.forEach((section) => + checkIfSectionIsOpen(getByTestId, section), + ); + + // Check which all filter section are closed by default + defaultClosedSections.forEach((section) => + checkIfSectionIsNotOpen(getByTestId, section), + ); + + // check for the status section content + await checkForSectionContent(['Ok', 'Error']); + + // check for the service name section content from API response + await checkForSectionContent([ + 'customer', + 'demo-app', + 'driver', + 'frontend', + 'mysql', + 'redis', + 'route', + 'go-grpc-otel-server', + 'test', + ]); + }); + + // test the filter panel actions like opening and closing the sections, etc. + it('filter panel actions', async () => { + const { getByTestId } = render(); + + // Check if the section is closed + checkIfSectionIsNotOpen(getByTestId, 'name'); + // Open the section + const name = getByTestId('collapse-name'); + expect(name).toBeInTheDocument(); + + userEvent.click(within(name).getByText(AllTraceFilterKeyValue.name)); + await waitFor(() => checkIfSectionIsOpen(getByTestId, 'name')); + + await checkForSectionContent([ + 'HTTP GET', + 'HTTP GET /customer', + 'HTTP GET /dispatch', + 'HTTP GET /route', + ]); + + // Close the section + userEvent.click(within(name).getByText(AllTraceFilterKeyValue.name)); + await waitFor(() => checkIfSectionIsNotOpen(getByTestId, 'name')); + }); + + it('checking filters should update the query', async () => { + const { getByText } = render( + + + , + ); + + const okCheckbox = getByText('Ok'); + fireEvent.click(okCheckbox); + expect( + redirectWithQueryBuilderData.mock.calls[ + redirectWithQueryBuilderData.mock.calls.length - 1 + ][0].builder.queryData[0].filters.items, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: { + id: expect.any(String), + key: 'hasError', + type: 'tag', + dataType: 'bool', + isColumn: true, + isJSON: false, + }, + op: 'in', + value: ['false'], + }), + ]), + ); + + // Check if the query is updated when the error checkbox is clicked + const errorCheckbox = getByText('Error'); + fireEvent.click(errorCheckbox); + expect( + redirectWithQueryBuilderData.mock.calls[ + redirectWithQueryBuilderData.mock.calls.length - 1 + ][0].builder.queryData[0].filters.items, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: { + id: expect.any(String), + key: 'hasError', + type: 'tag', + dataType: 'bool', + isColumn: true, + isJSON: false, + }, + op: 'in', + value: ['false', 'true'], + }), + ]), + ); + }); + + it('should render the trace filter with the given query', async () => { + jest + .spyOn(compositeQueryHook, 'useGetCompositeQueryParam') + .mockReturnValue(compositeQuery); + + const { findByText, getByTestId } = render(); + + // check if the default query is applied - composite query has filters - serviceName : demo-app and name : HTTP GET /customer + expect(await findByText('demo-app')).toBeInTheDocument(); + expect(getByTestId('serviceName-demo-app')).toBeChecked(); + expect(await findByText('HTTP GET /customer')).toBeInTheDocument(); + expect(getByTestId('name-HTTP GET /customer')).toBeChecked(); + }); + it('test edge cases of undefined filters', async () => { jest.spyOn(compositeQueryHook, 'useGetCompositeQueryParam').mockReturnValue({ ...compositeQuery, @@ -98,7 +294,6 @@ describe('TracesExplorer - ', () => { const { getByText } = render(); - // we should have all the filters Object.values(AllTraceFilterKeyValue).forEach((filter) => { expect(getByText(filter)).toBeInTheDocument(); }); @@ -124,9 +319,141 @@ describe('TracesExplorer - ', () => { const { getByText } = render(); - // we should have all the filters Object.values(AllTraceFilterKeyValue).forEach((filter) => { expect(getByText(filter)).toBeInTheDocument(); }); }); + + it('should clear filter on clear & reset button click', async () => { + const { getByText, getByTestId } = render( + + + , + ); + + // check for the status section content + await checkForSectionContent(['Ok', 'Error']); + + // check for the service name section content from API response + await checkForSectionContent([ + 'customer', + 'demo-app', + 'driver', + 'frontend', + 'mysql', + 'redis', + 'route', + 'go-grpc-otel-server', + 'test', + ]); + + const okCheckbox = getByText('Ok'); + fireEvent.click(okCheckbox); + + const frontendCheckbox = getByText('frontend'); + fireEvent.click(frontendCheckbox); + + // check if checked and present in query + expect( + redirectWithQueryBuilderData.mock.calls[ + redirectWithQueryBuilderData.mock.calls.length - 1 + ][0].builder.queryData[0].filters.items, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: { + id: expect.any(String), + key: 'hasError', + type: 'tag', + dataType: 'bool', + isColumn: true, + isJSON: false, + }, + op: 'in', + value: ['false'], + }), + expect.objectContaining({ + key: { + key: 'serviceName', + dataType: 'string', + type: 'tag', + isColumn: true, + isJSON: false, + id: expect.any(String), + }, + op: 'in', + value: ['frontend'], + }), + ]), + ); + + const clearButton = getByTestId('collapse-serviceName-clearBtn'); + expect(clearButton).toBeInTheDocument(); + fireEvent.click(clearButton); + + // check if cleared and not present in query + expect( + redirectWithQueryBuilderData.mock.calls[ + redirectWithQueryBuilderData.mock.calls.length - 1 + ][0].builder.queryData[0].filters.items, + ).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: { + key: 'serviceName', + dataType: 'string', + type: 'tag', + isColumn: true, + isJSON: false, + id: expect.any(String), + }, + op: 'in', + value: ['frontend'], + }), + ]), + ); + + // check if reset button is present + const resetButton = getByTestId('reset-filters'); + expect(resetButton).toBeInTheDocument(); + fireEvent.click(resetButton); + + // check if reset id done + expect( + redirectWithQueryBuilderData.mock.calls[ + redirectWithQueryBuilderData.mock.calls.length - 1 + ][0].builder.queryData[0].filters.items, + ).toEqual([]); + }); + + it('filter panel should collapse & uncollapsed', async () => { + const { getByText, getByTestId } = render(); + + Object.values(AllTraceFilterKeyValue).forEach((filter) => { + expect(getByText(filter)).toBeInTheDocument(); + }); + + // Filter panel should collapse + const collapseButton = getByTestId('toggle-filter-panel'); + expect(collapseButton).toBeInTheDocument(); + fireEvent.click(collapseButton); + + // uncollapse btn should be present + expect( + await screen.findByTestId('filter-uncollapse-btn'), + ).toBeInTheDocument(); + }); }); diff --git a/frontend/src/pages/TracesExplorer/index.tsx b/frontend/src/pages/TracesExplorer/index.tsx index e598673c28..bb25a37f86 100644 --- a/frontend/src/pages/TracesExplorer/index.tsx +++ b/frontend/src/pages/TracesExplorer/index.tsx @@ -251,6 +251,7 @@ function TracesExplorer(): JSX.Element { diff --git a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx index 4dcc055235..0cc3990af7 100644 --- a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx +++ b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx @@ -8,10 +8,10 @@ import { } from '@ant-design/icons'; import { Button, Card, Skeleton, Typography } from 'antd'; import updateCreditCardApi from 'api/billing/checkout'; +import logEvent from 'api/common/logEvent'; import { SOMETHING_WENT_WRONG } from 'constants/api'; import ROUTES from 'constants/routes'; import FullScreenHeader from 'container/FullScreenHeader/FullScreenHeader'; -import useAnalytics from 'hooks/analytics/useAnalytics'; import useLicense from 'hooks/useLicense'; import { useNotifications } from 'hooks/useNotifications'; import history from 'lib/history'; @@ -27,7 +27,6 @@ export default function WorkspaceBlocked(): JSX.Element { const { role } = useSelector((state) => state.app); const isAdmin = role === 'ADMIN'; const [activeLicense, setActiveLicense] = useState(null); - const { trackEvent } = useAnalytics(); const { notifications } = useNotifications(); @@ -74,7 +73,7 @@ export default function WorkspaceBlocked(): JSX.Element { ); const handleUpdateCreditCard = useCallback(async () => { - trackEvent('Workspace Blocked: User Clicked Update Credit Card'); + logEvent('Workspace Blocked: User Clicked Update Credit Card', {}); updateCreditCard({ licenseKey: activeLicense?.key || '', @@ -85,7 +84,7 @@ export default function WorkspaceBlocked(): JSX.Element { }, [activeLicense?.key, updateCreditCard]); const handleExtendTrial = (): void => { - trackEvent('Workspace Blocked: User Clicked Extend Trial'); + logEvent('Workspace Blocked: User Clicked Extend Trial', {}); notifications.info({ message: 'Extend Trial', diff --git a/frontend/src/providers/Dashboard/Dashboard.tsx b/frontend/src/providers/Dashboard/Dashboard.tsx index 6e15b9e3b2..364fbd1944 100644 --- a/frontend/src/providers/Dashboard/Dashboard.tsx +++ b/frontend/src/providers/Dashboard/Dashboard.tsx @@ -1,3 +1,4 @@ +/* eslint-disable no-nested-ternary */ import { Modal } from 'antd'; import getDashboard from 'api/dashboard/get'; import lockDashboardApi from 'api/dashboard/lockDashboard'; @@ -11,6 +12,7 @@ import useAxiosError from 'hooks/useAxiosError'; import useTabVisibility from 'hooks/useTabFocus'; import useUrlQuery from 'hooks/useUrlQuery'; import { getUpdatedLayout } from 'lib/dashboard/getUpdatedLayout'; +import history from 'lib/history'; import { defaultTo } from 'lodash-es'; import isEqual from 'lodash-es/isEqual'; import isUndefined from 'lodash-es/isUndefined'; @@ -38,7 +40,7 @@ import AppReducer from 'types/reducer/app'; import { GlobalReducer } from 'types/reducer/globalTime'; import { v4 as generateUUID } from 'uuid'; -import { IDashboardContext } from './types'; +import { DashboardSortOrder, IDashboardContext } from './types'; import { sortLayout } from './util'; const DashboardContext = createContext({ @@ -52,7 +54,12 @@ const DashboardContext = createContext({ layouts: [], panelMap: {}, setPanelMap: () => {}, - listSortOrder: { columnKey: 'createdAt', order: 'descend', pagination: '1' }, + listSortOrder: { + columnKey: 'createdAt', + order: 'descend', + pagination: '1', + search: '', + }, setListSortOrder: () => {}, setLayouts: () => {}, setSelectedDashboard: () => {}, @@ -68,6 +75,7 @@ interface Props { dashboardId: string; } +// eslint-disable-next-line sonarjs/cognitive-complexity export function DashboardProvider({ children, }: PropsWithChildren): JSX.Element { @@ -82,17 +90,50 @@ export function DashboardProvider({ exact: true, }); - const params = useUrlQuery(); - const orderColumnParam = params.get('columnKey'); - const orderQueryParam = params.get('order'); - const paginationParam = params.get('page'); - - const [listSortOrder, setListSortOrder] = useState({ - columnKey: orderColumnParam || 'updatedAt', - order: orderQueryParam || 'descend', - pagination: paginationParam || '1', + const isDashboardListPage = useRouteMatch({ + path: ROUTES.ALL_DASHBOARD, + exact: true, }); + // added extra checks here in case wrong values appear use the default values rather than empty dashboards + const supportedOrderColumnKeys = ['createdAt', 'updatedAt']; + + const supportedOrderKeys = ['ascend', 'descend']; + + const params = useUrlQuery(); + // since the dashboard provider is wrapped at the very top of the application hence it initialises these values from other pages as well. + // pick the below params from URL only if the user is on the dashboards list page. + const orderColumnParam = isDashboardListPage && params.get('columnKey'); + const orderQueryParam = isDashboardListPage && params.get('order'); + const paginationParam = isDashboardListPage && params.get('page'); + const searchParam = isDashboardListPage && params.get('search'); + + const [listSortOrder, setListOrder] = useState({ + columnKey: orderColumnParam + ? supportedOrderColumnKeys.includes(orderColumnParam) + ? orderColumnParam + : 'updatedAt' + : 'updatedAt', + order: orderQueryParam + ? supportedOrderKeys.includes(orderQueryParam) + ? orderQueryParam + : 'descend' + : 'descend', + pagination: paginationParam || '1', + search: searchParam || '', + }); + + function setListSortOrder(sortOrder: DashboardSortOrder): void { + if (!isEqual(sortOrder, listSortOrder)) { + setListOrder(sortOrder); + } + params.set('columnKey', sortOrder.columnKey as string); + params.set('order', sortOrder.order as string); + params.set('page', sortOrder.pagination || '1'); + params.set('search', sortOrder.search || ''); + history.replace({ search: params.toString() }); + } + const dispatch = useDispatch>(); const globalTime = useSelector( diff --git a/frontend/src/providers/Dashboard/types.ts b/frontend/src/providers/Dashboard/types.ts index d72c1839f5..e19c00e422 100644 --- a/frontend/src/providers/Dashboard/types.ts +++ b/frontend/src/providers/Dashboard/types.ts @@ -1,9 +1,15 @@ import dayjs from 'dayjs'; -import { Dispatch, SetStateAction } from 'react'; import { Layout } from 'react-grid-layout'; import { UseQueryResult } from 'react-query'; import { Dashboard } from 'types/api/dashboard/getAll'; +export interface DashboardSortOrder { + columnKey: string; + order: string; + pagination: string; + search: string; +} + export interface IDashboardContext { isDashboardSliderOpen: boolean; isDashboardLocked: boolean; @@ -15,18 +21,8 @@ export interface IDashboardContext { layouts: Layout[]; panelMap: Record; setPanelMap: React.Dispatch>>; - listSortOrder: { - columnKey: string; - order: string; - pagination: string; - }; - setListSortOrder: Dispatch< - SetStateAction<{ - columnKey: string; - order: string; - pagination: string; - }> - >; + listSortOrder: DashboardSortOrder; + setListSortOrder: (sortOrder: DashboardSortOrder) => void; setLayouts: React.Dispatch>; setSelectedDashboard: React.Dispatch< React.SetStateAction diff --git a/frontend/src/providers/QueryBuilder.tsx b/frontend/src/providers/QueryBuilder.tsx index 5e1eda5714..c3b50bbc7e 100644 --- a/frontend/src/providers/QueryBuilder.tsx +++ b/frontend/src/providers/QueryBuilder.tsx @@ -27,7 +27,7 @@ import { createIdFromObjectFields } from 'lib/createIdFromObjectFields'; import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName'; import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType'; import { replaceIncorrectObjectFields } from 'lib/replaceIncorrectObjectFields'; -import { get, merge, set } from 'lodash-es'; +import { cloneDeep, get, merge, set } from 'lodash-es'; import { createContext, PropsWithChildren, @@ -532,7 +532,7 @@ export function QueryBuilderProvider({ if (!panelType) { return newQueryItem; } - const queryItem = item as IBuilderQuery; + const queryItem = cloneDeep(item) as IBuilderQuery; const propsRequired = panelTypeDataSourceFormValuesMap[panelType as keyof PartialPanelTypes]?.[ queryItem.dataSource @@ -829,7 +829,7 @@ export function QueryBuilderProvider({ unit, })); }, - [setCurrentQuery], + [setCurrentQuery, setSupersetQuery], ); const query: Query = useMemo( diff --git a/frontend/src/store/reducers/logs.ts b/frontend/src/store/reducers/logs.ts index 0d0a69a1a4..4e46d00e69 100644 --- a/frontend/src/store/reducers/logs.ts +++ b/frontend/src/store/reducers/logs.ts @@ -1,3 +1,4 @@ +import ROUTES from 'constants/routes'; import { parseQuery } from 'lib/logql'; import { OrderPreferenceItems } from 'pages/Logs/config'; import { @@ -29,6 +30,30 @@ import { } from 'types/actions/logs'; import { ILogsReducer } from 'types/reducer/logs'; +const supportedLogsOrder = [ + OrderPreferenceItems.ASC, + OrderPreferenceItems.DESC, +]; + +function getLogsOrder(): OrderPreferenceItems { + // set the value of order from the URL only when order query param is present and the user is landing on the old logs explorer page + if (window.location.pathname === ROUTES.OLD_LOGS_EXPLORER) { + const orderParam = new URLSearchParams(window.location.search).get('order'); + + if (orderParam) { + // check if the order passed is supported else pass the default order + if (supportedLogsOrder.includes(orderParam as OrderPreferenceItems)) { + return orderParam as OrderPreferenceItems; + } + + return OrderPreferenceItems.DESC; + } + return OrderPreferenceItems.DESC; + } + + return OrderPreferenceItems.DESC; +} + const initialState: ILogsReducer = { fields: { interesting: [], @@ -51,10 +76,7 @@ const initialState: ILogsReducer = { liveTailStartRange: 15, selectedLogId: null, detailedLog: null, - order: - (new URLSearchParams(window.location.search).get( - 'order', - ) as ILogsReducer['order']) ?? OrderPreferenceItems.DESC, + order: getLogsOrder(), }; export const LogsReducer = ( diff --git a/frontend/src/tests/test-utils.tsx b/frontend/src/tests/test-utils.tsx index ff2d3c7e51..4eced41eff 100644 --- a/frontend/src/tests/test-utils.tsx +++ b/frontend/src/tests/test-utils.tsx @@ -42,6 +42,7 @@ const mockStored = (role?: string): any => accessJwt: '', refreshJwt: '', }, + isLoggedIn: true, org: [ { createdAt: 0, diff --git a/go.mod b/go.mod index b62ebab95f..f60ee1c6d7 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/ClickHouse/clickhouse-go/v2 v2.20.0 github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd - github.com/SigNoz/signoz-otel-collector v0.102.2 + github.com/SigNoz/signoz-otel-collector v0.102.3 github.com/SigNoz/zap_otlp/zap_otlp_encoder v0.0.0-20230822164844-1b861a431974 github.com/SigNoz/zap_otlp/zap_otlp_sync v0.0.0-20230822164844-1b861a431974 github.com/antonmedv/expr v1.15.3 diff --git a/go.sum b/go.sum index 6638d7b40d..e28886c460 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd h1:Bk43AsDYe0fhkb github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd/go.mod h1:nxRcH/OEdM8QxzH37xkGzomr1O0JpYBRS6pwjsWW6Pc= github.com/SigNoz/prometheus v1.11.1 h1:roM8ugYf4UxaeKKujEeBvoX7ybq3IrS+TB26KiRtIJg= github.com/SigNoz/prometheus v1.11.1/go.mod h1:uv4mQwZQtx7y4GQ6EdHOi8Wsk07uHNn2XHd1zM85m6I= -github.com/SigNoz/signoz-otel-collector v0.102.2 h1:SmjsBZjMjTVVpuOlfJXlsDJQbdefQP/9Wz3CyzSuZuU= -github.com/SigNoz/signoz-otel-collector v0.102.2/go.mod h1:ISAXYhZenojCWg6CdDJtPMpfS6Zwc08+uoxH25tc6Y0= +github.com/SigNoz/signoz-otel-collector v0.102.3 h1:q6iS5kqqwopwC2pS2UvYL3IiJMP75UdyK6d+rculXn4= +github.com/SigNoz/signoz-otel-collector v0.102.3/go.mod h1:61WqwhnrtFjwj1FyfDYMXjxFx8gWgKok1Xy1C6LbjWo= github.com/SigNoz/zap_otlp v0.1.0 h1:T7rRcFN87GavY8lDGZj0Z3Xv6OhJA6Pj3I9dNPmqvRc= github.com/SigNoz/zap_otlp v0.1.0/go.mod h1:lcHvbDbRgvDnPxo9lDlaL1JK2PyOyouP/C3ynnYIvyo= github.com/SigNoz/zap_otlp/zap_otlp_encoder v0.0.0-20230822164844-1b861a431974 h1:PKVgdf83Yw+lZJbFtNGBgqXiXNf3+kOXW2qZ7Ms7OaY= diff --git a/pkg/query-service/app/clickhouseReader/reader.go b/pkg/query-service/app/clickhouseReader/reader.go index ccdffd88bd..6bfa1839ef 100644 --- a/pkg/query-service/app/clickhouseReader/reader.go +++ b/pkg/query-service/app/clickhouseReader/reader.go @@ -706,21 +706,25 @@ func (r *ClickHouseReader) GetServicesList(ctx context.Context) (*[]string, erro return &services, nil } -func (r *ClickHouseReader) GetTopLevelOperations(ctx context.Context, skipConfig *model.SkipConfig, start, end time.Time) (*map[string][]string, *map[string][]string, *model.ApiError) { +func (r *ClickHouseReader) GetTopLevelOperations(ctx context.Context, skipConfig *model.SkipConfig, start, end time.Time, services []string) (*map[string][]string, *model.ApiError) { start = start.In(time.UTC) // The `top_level_operations` that have `time` >= start operations := map[string][]string{} - // All top level operations for a service - allOperations := map[string][]string{} - query := fmt.Sprintf(`SELECT DISTINCT name, serviceName, time FROM %s.%s`, r.TraceDB, r.topLevelOperationsTable) + // We can't use the `end` because the `top_level_operations` table has the most recent instances of the operations + // We can only use the `start` time to filter the operations + query := fmt.Sprintf(`SELECT name, serviceName, max(time) as ts FROM %s.%s WHERE time >= @start`, r.TraceDB, r.topLevelOperationsTable) + if len(services) > 0 { + query += ` AND serviceName IN @services` + } + query += ` GROUP BY name, serviceName ORDER BY ts DESC LIMIT 5000` - rows, err := r.db.Query(ctx, query) + rows, err := r.db.Query(ctx, query, clickhouse.Named("start", start), clickhouse.Named("services", services)) if err != nil { zap.L().Error("Error in processing sql query", zap.Error(err)) - return nil, nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in processing sql query")} + return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in processing sql query")} } defer rows.Close() @@ -728,25 +732,17 @@ func (r *ClickHouseReader) GetTopLevelOperations(ctx context.Context, skipConfig var name, serviceName string var t time.Time if err := rows.Scan(&name, &serviceName, &t); err != nil { - return nil, nil, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error in reading data")} + return nil, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error in reading data")} } if _, ok := operations[serviceName]; !ok { - operations[serviceName] = []string{} - } - if _, ok := allOperations[serviceName]; !ok { - allOperations[serviceName] = []string{} + operations[serviceName] = []string{"overflow_operation"} } if skipConfig.ShouldSkip(serviceName, name) { continue } - allOperations[serviceName] = append(allOperations[serviceName], name) - // We can't use the `end` because the `top_level_operations` table has the most recent instances of the operations - // We can only use the `start` time to filter the operations - if t.After(start) { - operations[serviceName] = append(operations[serviceName], name) - } + operations[serviceName] = append(operations[serviceName], name) } - return &operations, &allOperations, nil + return &operations, nil } func (r *ClickHouseReader) GetServices(ctx context.Context, queryParams *model.GetServicesParams, skipConfig *model.SkipConfig) (*[]model.ServiceItem, *model.ApiError) { @@ -755,7 +751,7 @@ func (r *ClickHouseReader) GetServices(ctx context.Context, queryParams *model.G return nil, &model.ApiError{Typ: model.ErrorExec, Err: ErrNoIndexTable} } - topLevelOps, allTopLevelOps, apiErr := r.GetTopLevelOperations(ctx, skipConfig, *queryParams.Start, *queryParams.End) + topLevelOps, apiErr := r.GetTopLevelOperations(ctx, skipConfig, *queryParams.Start, *queryParams.End, nil) if apiErr != nil { return nil, apiErr } @@ -779,7 +775,7 @@ func (r *ClickHouseReader) GetServices(ctx context.Context, queryParams *model.G // the top level operations are high, we want to warn to let user know the issue // with the instrumentation serviceItem.DataWarning = model.DataWarning{ - TopLevelOps: (*allTopLevelOps)[svc], + TopLevelOps: (*topLevelOps)[svc], } // default max_query_size = 262144 @@ -868,7 +864,7 @@ func (r *ClickHouseReader) GetServices(ctx context.Context, queryParams *model.G func (r *ClickHouseReader) GetServiceOverview(ctx context.Context, queryParams *model.GetServiceOverviewParams, skipConfig *model.SkipConfig) (*[]model.ServiceOverviewItem, *model.ApiError) { - topLevelOps, _, apiErr := r.GetTopLevelOperations(ctx, skipConfig, *queryParams.Start, *queryParams.End) + topLevelOps, apiErr := r.GetTopLevelOperations(ctx, skipConfig, *queryParams.Start, *queryParams.End, nil) if apiErr != nil { return nil, apiErr } @@ -5005,3 +5001,27 @@ func (r *ClickHouseReader) LiveTailLogsV3(ctx context.Context, query string, tim } } } + +func (r *ClickHouseReader) GetMinAndMaxTimestampForTraceID(ctx context.Context, traceID []string) (int64, int64, error) { + var minTime, maxTime time.Time + + query := fmt.Sprintf("SELECT min(timestamp), max(timestamp) FROM %s.%s WHERE traceID IN ('%s')", + r.TraceDB, r.SpansTable, strings.Join(traceID, "','")) + + zap.L().Debug("GetMinAndMaxTimestampForTraceID", zap.String("query", query)) + + err := r.db.QueryRow(ctx, query).Scan(&minTime, &maxTime) + if err != nil { + zap.L().Error("Error while executing query", zap.Error(err)) + return 0, 0, err + } + + if minTime.IsZero() || maxTime.IsZero() { + zap.L().Debug("minTime or maxTime is zero") + return 0, 0, nil + } + + zap.L().Debug("GetMinAndMaxTimestampForTraceID", zap.Any("minTime", minTime), zap.Any("maxTime", maxTime)) + + return minTime.UnixNano(), maxTime.UnixNano(), nil +} diff --git a/pkg/query-service/app/http_handler.go b/pkg/query-service/app/http_handler.go index 42879123ec..4eff84d50c 100644 --- a/pkg/query-service/app/http_handler.go +++ b/pkg/query-service/app/http_handler.go @@ -29,12 +29,14 @@ import ( logsv3 "go.signoz.io/signoz/pkg/query-service/app/logs/v3" "go.signoz.io/signoz/pkg/query-service/app/metrics" metricsv3 "go.signoz.io/signoz/pkg/query-service/app/metrics/v3" + "go.signoz.io/signoz/pkg/query-service/app/preferences" "go.signoz.io/signoz/pkg/query-service/app/querier" querierV2 "go.signoz.io/signoz/pkg/query-service/app/querier/v2" "go.signoz.io/signoz/pkg/query-service/app/queryBuilder" tracesV3 "go.signoz.io/signoz/pkg/query-service/app/traces/v3" "go.signoz.io/signoz/pkg/query-service/auth" "go.signoz.io/signoz/pkg/query-service/cache" + "go.signoz.io/signoz/pkg/query-service/common" "go.signoz.io/signoz/pkg/query-service/constants" v3 "go.signoz.io/signoz/pkg/query-service/model/v3" "go.signoz.io/signoz/pkg/query-service/postprocess" @@ -398,6 +400,22 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *AuthMiddleware) { router.HandleFunc("/api/v1/disks", am.ViewAccess(aH.getDisks)).Methods(http.MethodGet) + // === Preference APIs === + + // user actions + router.HandleFunc("/api/v1/user/preferences", am.ViewAccess(aH.getAllUserPreferences)).Methods(http.MethodGet) + + router.HandleFunc("/api/v1/user/preferences/{preferenceId}", am.ViewAccess(aH.getUserPreference)).Methods(http.MethodGet) + + router.HandleFunc("/api/v1/user/preferences/{preferenceId}", am.ViewAccess(aH.updateUserPreference)).Methods(http.MethodPut) + + // org actions + router.HandleFunc("/api/v1/org/preferences", am.AdminAccess(aH.getAllOrgPreferences)).Methods(http.MethodGet) + + router.HandleFunc("/api/v1/org/preferences/{preferenceId}", am.AdminAccess(aH.getOrgPreference)).Methods(http.MethodGet) + + router.HandleFunc("/api/v1/org/preferences/{preferenceId}", am.AdminAccess(aH.updateOrgPreference)).Methods(http.MethodPut) + // === Authentication APIs === router.HandleFunc("/api/v1/invite", am.AdminAccess(aH.inviteUser)).Methods(http.MethodPost) router.HandleFunc("/api/v1/invite/{token}", am.OpenAccess(aH.getInvite)).Methods(http.MethodGet) @@ -1329,8 +1347,44 @@ func (aH *APIHandler) getServiceOverview(w http.ResponseWriter, r *http.Request) func (aH *APIHandler) getServicesTopLevelOps(w http.ResponseWriter, r *http.Request) { var start, end time.Time + var services []string - result, _, apiErr := aH.reader.GetTopLevelOperations(r.Context(), aH.skipConfig, start, end) + type topLevelOpsParams struct { + Service string `json:"service"` + Start string `json:"start"` + End string `json:"end"` + } + + var params topLevelOpsParams + err := json.NewDecoder(r.Body).Decode(¶ms) + if err != nil { + zap.L().Error("Error in getting req body for get top operations API", zap.Error(err)) + } + + if params.Service != "" { + services = []string{params.Service} + } + + startEpoch := params.Start + if startEpoch != "" { + startEpochInt, err := strconv.ParseInt(startEpoch, 10, 64) + if err != nil { + RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, "Error reading start time") + return + } + start = time.Unix(0, startEpochInt) + } + endEpoch := params.End + if endEpoch != "" { + endEpochInt, err := strconv.ParseInt(endEpoch, 10, 64) + if err != nil { + RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, "Error reading end time") + return + } + end = time.Unix(0, endEpochInt) + } + + result, apiErr := aH.reader.GetTopLevelOperations(r.Context(), aH.skipConfig, start, end, services) if apiErr != nil { RespondError(w, apiErr, nil) return @@ -2192,6 +2246,115 @@ func (aH *APIHandler) WriteJSON(w http.ResponseWriter, r *http.Request, response w.Write(resp) } +// Preferences + +func (ah *APIHandler) getUserPreference( + w http.ResponseWriter, r *http.Request, +) { + preferenceId := mux.Vars(r)["preferenceId"] + user := common.GetUserFromContext(r.Context()) + + preference, apiErr := preferences.GetUserPreference( + r.Context(), preferenceId, user.User.OrgId, user.User.Id, + ) + if apiErr != nil { + RespondError(w, apiErr, nil) + return + } + + ah.Respond(w, preference) +} + +func (ah *APIHandler) updateUserPreference( + w http.ResponseWriter, r *http.Request, +) { + preferenceId := mux.Vars(r)["preferenceId"] + user := common.GetUserFromContext(r.Context()) + req := preferences.UpdatePreference{} + + err := json.NewDecoder(r.Body).Decode(&req) + + if err != nil { + RespondError(w, model.BadRequest(err), nil) + return + } + preference, apiErr := preferences.UpdateUserPreference(r.Context(), preferenceId, req.PreferenceValue, user.User.Id) + if apiErr != nil { + RespondError(w, apiErr, nil) + return + } + + ah.Respond(w, preference) +} + +func (ah *APIHandler) getAllUserPreferences( + w http.ResponseWriter, r *http.Request, +) { + user := common.GetUserFromContext(r.Context()) + preference, apiErr := preferences.GetAllUserPreferences( + r.Context(), user.User.OrgId, user.User.Id, + ) + if apiErr != nil { + RespondError(w, apiErr, nil) + return + } + + ah.Respond(w, preference) +} + +func (ah *APIHandler) getOrgPreference( + w http.ResponseWriter, r *http.Request, +) { + preferenceId := mux.Vars(r)["preferenceId"] + user := common.GetUserFromContext(r.Context()) + preference, apiErr := preferences.GetOrgPreference( + r.Context(), preferenceId, user.User.OrgId, + ) + if apiErr != nil { + RespondError(w, apiErr, nil) + return + } + + ah.Respond(w, preference) +} + +func (ah *APIHandler) updateOrgPreference( + w http.ResponseWriter, r *http.Request, +) { + preferenceId := mux.Vars(r)["preferenceId"] + req := preferences.UpdatePreference{} + user := common.GetUserFromContext(r.Context()) + + err := json.NewDecoder(r.Body).Decode(&req) + + if err != nil { + RespondError(w, model.BadRequest(err), nil) + return + } + preference, apiErr := preferences.UpdateOrgPreference(r.Context(), preferenceId, req.PreferenceValue, user.User.OrgId) + if apiErr != nil { + RespondError(w, apiErr, nil) + return + } + + ah.Respond(w, preference) +} + +func (ah *APIHandler) getAllOrgPreferences( + w http.ResponseWriter, r *http.Request, +) { + user := common.GetUserFromContext(r.Context()) + preference, apiErr := preferences.GetAllOrgPreferences( + r.Context(), user.User.OrgId, + ) + if apiErr != nil { + RespondError(w, apiErr, nil) + return + } + + ah.Respond(w, preference) +} + // Integrations func (ah *APIHandler) RegisterIntegrationRoutes(router *mux.Router, am *AuthMiddleware) { subRouter := router.PathPrefix("/api/v1/integrations").Subrouter() @@ -3050,6 +3213,22 @@ func (aH *APIHandler) queryRangeV3(ctx context.Context, queryRangeParams *v3.Que } } + // WARN: Only works for AND operator in traces query + if queryRangeParams.CompositeQuery.QueryType == v3.QueryTypeBuilder { + // check if traceID is used as filter (with equal/similar operator) in traces query if yes add timestamp filter to queryRange params + isUsed, traceIDs := tracesV3.TraceIdFilterUsedWithEqual(queryRangeParams) + if isUsed == true && len(traceIDs) > 0 { + zap.L().Debug("traceID used as filter in traces query") + // query signoz_spans table with traceID to get min and max timestamp + min, max, err := aH.reader.GetMinAndMaxTimestampForTraceID(ctx, traceIDs) + if err == nil { + // add timestamp filter to queryRange params + tracesV3.AddTimestampFilters(min, max, queryRangeParams) + zap.L().Debug("post adding timestamp filter in traces query", zap.Any("queryRangeParams", queryRangeParams)) + } + } + } + result, errQuriesByName, err = aH.querier.QueryRange(ctx, queryRangeParams, spanKeys) if err != nil { @@ -3319,6 +3498,22 @@ func (aH *APIHandler) queryRangeV4(ctx context.Context, queryRangeParams *v3.Que } } + // WARN: Only works for AND operator in traces query + if queryRangeParams.CompositeQuery.QueryType == v3.QueryTypeBuilder { + // check if traceID is used as filter (with equal/similar operator) in traces query if yes add timestamp filter to queryRange params + isUsed, traceIDs := tracesV3.TraceIdFilterUsedWithEqual(queryRangeParams) + if isUsed == true && len(traceIDs) > 0 { + zap.L().Debug("traceID used as filter in traces query") + // query signoz_spans table with traceID to get min and max timestamp + min, max, err := aH.reader.GetMinAndMaxTimestampForTraceID(ctx, traceIDs) + if err == nil { + // add timestamp filter to queryRange params + tracesV3.AddTimestampFilters(min, max, queryRangeParams) + zap.L().Debug("post adding timestamp filter in traces query", zap.Any("queryRangeParams", queryRangeParams)) + } + } + } + result, errQuriesByName, err = aH.querierV2.QueryRange(ctx, queryRangeParams, spanKeys) if err != nil { diff --git a/pkg/query-service/app/integrations/builtin_integrations/nginx/config/collect-logs.md b/pkg/query-service/app/integrations/builtin_integrations/nginx/config/collect-logs.md index 71712c503b..3e65cbc662 100644 --- a/pkg/query-service/app/integrations/builtin_integrations/nginx/config/collect-logs.md +++ b/pkg/query-service/app/integrations/builtin_integrations/nginx/config/collect-logs.md @@ -110,6 +110,13 @@ service: ``` +### If using non-default nginx log format, adjust log parsing regex + +If you are using a [custom nginx log format](https://docs.nginx.com/nginx/admin-guide/monitoring/logging/#setting-up-the-access-log), +please adjust the regex used for parsing logs in the receivers named +`filelog/nginx-access-logs` and `filelog/nginx-error-logs` in collector config. + + #### Set Environment Variables Set the following environment variables in your otel-collector environment: diff --git a/pkg/query-service/app/logs/v3/json_filter.go b/pkg/query-service/app/logs/v3/json_filter.go index a9acdeaab3..887baaab4c 100644 --- a/pkg/query-service/app/logs/v3/json_filter.go +++ b/pkg/query-service/app/logs/v3/json_filter.go @@ -17,6 +17,7 @@ const ( ARRAY_INT64 = "Array(Int64)" ARRAY_FLOAT64 = "Array(Float64)" ARRAY_BOOL = "Array(Bool)" + NGRAM_SIZE = 4 ) var dataTypeMapping = map[string]string{ @@ -72,6 +73,7 @@ func getPath(keyArr []string) string { func getJSONFilterKey(key v3.AttributeKey, op v3.FilterOperator, isArray bool) (string, error) { keyArr := strings.Split(key.Key, ".") + // i.e it should be at least body.name, and not something like body if len(keyArr) < 2 { return "", fmt.Errorf("incorrect key, should contain at least 2 parts") } @@ -106,6 +108,29 @@ func getJSONFilterKey(key v3.AttributeKey, op v3.FilterOperator, isArray bool) ( return keyname, nil } +// takes the path and the values and generates where clauses for better usage of index +func getPathIndexFilter(path string) string { + filters := []string{} + keyArr := strings.Split(path, ".") + if len(keyArr) < 2 { + return "" + } + + for i, key := range keyArr { + if i == 0 { + continue + } + key = strings.TrimSuffix(key, "[*]") + if len(key) >= NGRAM_SIZE { + filters = append(filters, strings.ToLower(key)) + } + } + if len(filters) > 0 { + return fmt.Sprintf("lower(body) like lower('%%%s%%')", strings.Join(filters, "%")) + } + return "" +} + func GetJSONFilter(item v3.FilterItem) (string, error) { dataType := item.Key.DataType @@ -154,11 +179,28 @@ func GetJSONFilter(item v3.FilterItem) (string, error) { return "", fmt.Errorf("unsupported operator: %s", op) } + filters := []string{} + + pathFilter := getPathIndexFilter(item.Key.Key) + if pathFilter != "" { + filters = append(filters, pathFilter) + } + if op == v3.FilterOperatorContains || + op == v3.FilterOperatorEqual || + op == v3.FilterOperatorHas { + val, ok := item.Value.(string) + if ok && len(val) >= NGRAM_SIZE { + filters = append(filters, fmt.Sprintf("lower(body) like lower('%%%s%%')", utils.QuoteEscapedString(strings.ToLower(val)))) + } + } + // add exists check for non array items as default values of int/float/bool will corrupt the results if !isArray && !(item.Operator == v3.FilterOperatorExists || item.Operator == v3.FilterOperatorNotExists) { existsFilter := fmt.Sprintf("JSON_EXISTS(body, '$.%s')", getPath(strings.Split(item.Key.Key, ".")[1:])) filter = fmt.Sprintf("%s AND %s", existsFilter, filter) } - return filter, nil + filters = append(filters, filter) + + return strings.Join(filters, " AND "), nil } diff --git a/pkg/query-service/app/logs/v3/json_filter_test.go b/pkg/query-service/app/logs/v3/json_filter_test.go index ac9d8edbf4..0a71cd67b2 100644 --- a/pkg/query-service/app/logs/v3/json_filter_test.go +++ b/pkg/query-service/app/logs/v3/json_filter_test.go @@ -168,7 +168,7 @@ var testGetJSONFilterData = []struct { Operator: "has", Value: "index_service", }, - Filter: "has(JSONExtract(JSON_QUERY(body, '$.\"requestor_list\"[*]'), 'Array(String)'), 'index_service')", + Filter: "lower(body) like lower('%requestor_list%') AND lower(body) like lower('%index_service%') AND has(JSONExtract(JSON_QUERY(body, '$.\"requestor_list\"[*]'), 'Array(String)'), 'index_service')", }, { Name: "Array membership int64", @@ -181,7 +181,7 @@ var testGetJSONFilterData = []struct { Operator: "has", Value: 2, }, - Filter: "has(JSONExtract(JSON_QUERY(body, '$.\"int_numbers\"[*]'), '" + ARRAY_INT64 + "'), 2)", + Filter: "lower(body) like lower('%int_numbers%') AND has(JSONExtract(JSON_QUERY(body, '$.\"int_numbers\"[*]'), '" + ARRAY_INT64 + "'), 2)", }, { Name: "Array membership float64", @@ -194,7 +194,7 @@ var testGetJSONFilterData = []struct { Operator: "nhas", Value: 2.2, }, - Filter: "NOT has(JSONExtract(JSON_QUERY(body, '$.\"nested_num\"[*].\"float_nums\"[*]'), '" + ARRAY_FLOAT64 + "'), 2.200000)", + Filter: "lower(body) like lower('%nested_num%float_nums%') AND NOT has(JSONExtract(JSON_QUERY(body, '$.\"nested_num\"[*].\"float_nums\"[*]'), '" + ARRAY_FLOAT64 + "'), 2.200000)", }, { Name: "Array membership bool", @@ -207,7 +207,7 @@ var testGetJSONFilterData = []struct { Operator: "has", Value: true, }, - Filter: "has(JSONExtract(JSON_QUERY(body, '$.\"bool\"[*]'), '" + ARRAY_BOOL + "'), true)", + Filter: "lower(body) like lower('%bool%') AND has(JSONExtract(JSON_QUERY(body, '$.\"bool\"[*]'), '" + ARRAY_BOOL + "'), true)", }, { Name: "eq operator", @@ -220,7 +220,7 @@ var testGetJSONFilterData = []struct { Operator: "=", Value: "hello", }, - Filter: "JSON_EXISTS(body, '$.\"message\"') AND JSON_VALUE(body, '$.\"message\"') = 'hello'", + Filter: "lower(body) like lower('%message%') AND lower(body) like lower('%hello%') AND JSON_EXISTS(body, '$.\"message\"') AND JSON_VALUE(body, '$.\"message\"') = 'hello'", }, { Name: "eq operator number", @@ -233,7 +233,7 @@ var testGetJSONFilterData = []struct { Operator: "=", Value: 1, }, - Filter: "JSON_EXISTS(body, '$.\"status\"') AND JSONExtract(JSON_VALUE(body, '$.\"status\"'), '" + INT64 + "') = 1", + Filter: "lower(body) like lower('%status%') AND JSON_EXISTS(body, '$.\"status\"') AND JSONExtract(JSON_VALUE(body, '$.\"status\"'), '" + INT64 + "') = 1", }, { Name: "neq operator number", @@ -246,7 +246,7 @@ var testGetJSONFilterData = []struct { Operator: "=", Value: 1.1, }, - Filter: "JSON_EXISTS(body, '$.\"status\"') AND JSONExtract(JSON_VALUE(body, '$.\"status\"'), '" + FLOAT64 + "') = 1.100000", + Filter: "lower(body) like lower('%status%') AND JSON_EXISTS(body, '$.\"status\"') AND JSONExtract(JSON_VALUE(body, '$.\"status\"'), '" + FLOAT64 + "') = 1.100000", }, { Name: "eq operator bool", @@ -259,7 +259,7 @@ var testGetJSONFilterData = []struct { Operator: "=", Value: true, }, - Filter: "JSON_EXISTS(body, '$.\"boolkey\"') AND JSONExtract(JSON_VALUE(body, '$.\"boolkey\"'), '" + BOOL + "') = true", + Filter: "lower(body) like lower('%boolkey%') AND JSON_EXISTS(body, '$.\"boolkey\"') AND JSONExtract(JSON_VALUE(body, '$.\"boolkey\"'), '" + BOOL + "') = true", }, { Name: "greater than operator", @@ -272,7 +272,7 @@ var testGetJSONFilterData = []struct { Operator: ">", Value: 1, }, - Filter: "JSON_EXISTS(body, '$.\"status\"') AND JSONExtract(JSON_VALUE(body, '$.\"status\"'), '" + INT64 + "') > 1", + Filter: "lower(body) like lower('%status%') AND JSON_EXISTS(body, '$.\"status\"') AND JSONExtract(JSON_VALUE(body, '$.\"status\"'), '" + INT64 + "') > 1", }, { Name: "regex operator", @@ -285,7 +285,7 @@ var testGetJSONFilterData = []struct { Operator: "regex", Value: "a*", }, - Filter: "JSON_EXISTS(body, '$.\"message\"') AND match(JSON_VALUE(body, '$.\"message\"'), 'a*')", + Filter: "lower(body) like lower('%message%') AND JSON_EXISTS(body, '$.\"message\"') AND match(JSON_VALUE(body, '$.\"message\"'), 'a*')", }, { Name: "contains operator", @@ -298,7 +298,7 @@ var testGetJSONFilterData = []struct { Operator: "contains", Value: "a", }, - Filter: "JSON_EXISTS(body, '$.\"message\"') AND JSON_VALUE(body, '$.\"message\"') ILIKE '%a%'", + Filter: "lower(body) like lower('%message%') AND JSON_EXISTS(body, '$.\"message\"') AND JSON_VALUE(body, '$.\"message\"') ILIKE '%a%'", }, { Name: "contains operator with quotes", @@ -311,7 +311,7 @@ var testGetJSONFilterData = []struct { Operator: "contains", Value: "hello 'world'", }, - Filter: "JSON_EXISTS(body, '$.\"message\"') AND JSON_VALUE(body, '$.\"message\"') ILIKE '%hello \\'world\\'%'", + Filter: "lower(body) like lower('%message%') AND lower(body) like lower('%hello \\'world\\'%') AND JSON_EXISTS(body, '$.\"message\"') AND JSON_VALUE(body, '$.\"message\"') ILIKE '%hello \\'world\\'%'", }, { Name: "exists", @@ -324,7 +324,7 @@ var testGetJSONFilterData = []struct { Operator: "exists", Value: "", }, - Filter: "JSON_EXISTS(body, '$.\"message\"')", + Filter: "lower(body) like lower('%message%') AND JSON_EXISTS(body, '$.\"message\"')", }, } diff --git a/pkg/query-service/app/logs/v3/query_builder.go b/pkg/query-service/app/logs/v3/query_builder.go index 06cacc8f60..2aa56002ff 100644 --- a/pkg/query-service/app/logs/v3/query_builder.go +++ b/pkg/query-service/app/logs/v3/query_builder.go @@ -51,6 +51,8 @@ var logOperators = map[v3.FilterOperator]string{ v3.FilterOperatorNotExists: "not has(%s_%s_key, '%s')", } +const BODY = "body" + func getClickhouseLogsColumnType(columnType v3.AttributeKeyType) string { if columnType == v3.AttributeKeyTypeTag { return "attributes" @@ -193,10 +195,24 @@ func buildLogsTimeSeriesFilterQuery(fs *v3.FilterSet, groupBy []v3.AttributeKey, case v3.FilterOperatorContains, v3.FilterOperatorNotContains: columnName := getClickhouseColumnName(item.Key) val := utils.QuoteEscapedString(fmt.Sprintf("%v", item.Value)) - conditions = append(conditions, fmt.Sprintf("%s %s '%%%s%%'", columnName, logsOp, val)) + if columnName == BODY { + logsOp = strings.Replace(logsOp, "ILIKE", "LIKE", 1) // removing i from ilike and not ilike + conditions = append(conditions, fmt.Sprintf("lower(%s) %s lower('%%%s%%')", columnName, logsOp, val)) + } else { + conditions = append(conditions, fmt.Sprintf("%s %s '%%%s%%'", columnName, logsOp, val)) + } default: columnName := getClickhouseColumnName(item.Key) fmtVal := utils.ClickHouseFormattedValue(value) + + // for use lower for like and ilike + if op == v3.FilterOperatorLike || op == v3.FilterOperatorNotLike { + if columnName == BODY { + logsOp = strings.Replace(logsOp, "ILIKE", "LIKE", 1) // removing i from ilike and not ilike + columnName = fmt.Sprintf("lower(%s)", columnName) + fmtVal = fmt.Sprintf("lower(%s)", fmtVal) + } + } conditions = append(conditions, fmt.Sprintf("%s %s %s", columnName, logsOp, fmtVal)) } } else { @@ -477,7 +493,7 @@ type Options struct { } func isOrderByTs(orderBy []v3.OrderBy) bool { - if len(orderBy) == 1 && orderBy[0].Key == constants.TIMESTAMP { + if len(orderBy) == 1 && (orderBy[0].Key == constants.TIMESTAMP || orderBy[0].ColumnName == constants.TIMESTAMP) { return true } return false diff --git a/pkg/query-service/app/logs/v3/query_builder_test.go b/pkg/query-service/app/logs/v3/query_builder_test.go index 606ccffeef..db57cb2549 100644 --- a/pkg/query-service/app/logs/v3/query_builder_test.go +++ b/pkg/query-service/app/logs/v3/query_builder_test.go @@ -130,6 +130,14 @@ var timeSeriesFilterQueryData = []struct { }}, ExpectedFilter: "attributes_string_value[indexOf(attributes_string_key, 'user_name')] = 'john' AND resources_string_value[indexOf(resources_string_key, 'k8s_namespace')] != 'my_service'", }, + { + Name: "Test attribute and resource attribute with different case", + FilterSet: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{ + {Key: v3.AttributeKey{Key: "user_name", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "%JoHn%", Operator: "like"}, + {Key: v3.AttributeKey{Key: "k8s_namespace", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "%MyService%", Operator: "nlike"}, + }}, + ExpectedFilter: "attributes_string_value[indexOf(attributes_string_key, 'user_name')] ILIKE '%JoHn%' AND resources_string_value[indexOf(resources_string_key, 'k8s_namespace')] NOT ILIKE '%MyService%'", + }, { Name: "Test materialized column", FilterSet: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{ @@ -287,6 +295,22 @@ var timeSeriesFilterQueryData = []struct { }}, ExpectedFilter: "`attribute_int64_status_exists`=false", }, + { + Name: "Test for body contains and ncontains", + FilterSet: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{ + {Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, IsColumn: true}, Operator: "contains", Value: "test"}, + {Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, IsColumn: true}, Operator: "ncontains", Value: "test1"}, + }}, + ExpectedFilter: "lower(body) LIKE lower('%test%') AND lower(body) NOT LIKE lower('%test1%')", + }, + { + Name: "Test for body like and nlike", + FilterSet: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{ + {Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, IsColumn: true}, Operator: "like", Value: "test"}, + {Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, IsColumn: true}, Operator: "nlike", Value: "test1"}, + }}, + ExpectedFilter: "lower(body) LIKE lower('test') AND lower(body) NOT LIKE lower('test1')", + }, } func TestBuildLogsTimeSeriesFilterQuery(t *testing.T) { @@ -851,7 +875,7 @@ var testBuildLogsQueryData = []struct { }, }, TableName: "logs", - ExpectedQuery: "SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toFloat64(count(distinct(attributes_string_value[indexOf(attributes_string_key, 'name')]))) as value from signoz_logs.distributed_logs where (timestamp >= 1680066360726210000 AND timestamp <= 1680066458000000000) AND body ILIKE '%test%' AND has(attributes_string_key, 'name') group by ts having value > 10 order by value DESC", + ExpectedQuery: "SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toFloat64(count(distinct(attributes_string_value[indexOf(attributes_string_key, 'name')]))) as value from signoz_logs.distributed_logs where (timestamp >= 1680066360726210000 AND timestamp <= 1680066458000000000) AND lower(body) LIKE lower('%test%') AND has(attributes_string_key, 'name') group by ts having value > 10 order by value DESC", }, { Name: "Test attribute with same name as top level key", @@ -981,7 +1005,7 @@ var testBuildLogsQueryData = []struct { }, }, TableName: "logs", - ExpectedQuery: "SELECT now() as ts, attributes_string_value[indexOf(attributes_string_key, 'name')] as `name`, toFloat64(count(*)) as value from signoz_logs.distributed_logs where (timestamp >= 1680066360726210000 AND timestamp <= 1680066458000000000) AND JSON_EXISTS(body, '$.\"message\"') AND JSON_VALUE(body, '$.\"message\"') ILIKE '%a%' AND has(attributes_string_key, 'name') group by `name` order by `name` DESC", + ExpectedQuery: "SELECT now() as ts, attributes_string_value[indexOf(attributes_string_key, 'name')] as `name`, toFloat64(count(*)) as value from signoz_logs.distributed_logs where (timestamp >= 1680066360726210000 AND timestamp <= 1680066458000000000) AND lower(body) like lower('%message%') AND JSON_EXISTS(body, '$.\"message\"') AND JSON_VALUE(body, '$.\"message\"') ILIKE '%a%' AND has(attributes_string_key, 'name') group by `name` order by `name` DESC", }, { Name: "TABLE: Test count with JSON Filter Array, groupBy, orderBy", @@ -1015,7 +1039,7 @@ var testBuildLogsQueryData = []struct { }, }, TableName: "logs", - ExpectedQuery: "SELECT now() as ts, attributes_string_value[indexOf(attributes_string_key, 'name')] as `name`, toFloat64(count(*)) as value from signoz_logs.distributed_logs where (timestamp >= 1680066360726210000 AND timestamp <= 1680066458000000000) AND has(JSONExtract(JSON_QUERY(body, '$.\"requestor_list\"[*]'), 'Array(String)'), 'index_service') AND has(attributes_string_key, 'name') group by `name` order by `name` DESC", + ExpectedQuery: "SELECT now() as ts, attributes_string_value[indexOf(attributes_string_key, 'name')] as `name`, toFloat64(count(*)) as value from signoz_logs.distributed_logs where (timestamp >= 1680066360726210000 AND timestamp <= 1680066458000000000) AND lower(body) like lower('%requestor_list%') AND lower(body) like lower('%index_service%') AND has(JSONExtract(JSON_QUERY(body, '$.\"requestor_list\"[*]'), 'Array(String)'), 'index_service') AND has(attributes_string_key, 'name') group by `name` order by `name` DESC", }, } @@ -1380,6 +1404,66 @@ var testPrepLogsQueryData = []struct { ExpectedQuery: "SELECT now() as ts, toFloat64(count(*)) as value from signoz_logs.distributed_logs where (timestamp >= 1680066360726000000 AND timestamp <= 1680066458000000000) order by value DESC LIMIT 10", Options: Options{}, }, + { + Name: "Ignore offset if order by is timestamp in list queries", + PanelType: v3.PanelTypeList, + Start: 1680066360726, + End: 1680066458000, + BuilderQuery: &v3.BuilderQuery{ + QueryName: "A", + StepInterval: 60, + AggregateOperator: v3.AggregateOperatorNoOp, + Expression: "A", + Filters: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{ + {Key: v3.AttributeKey{Key: "id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "logid", Operator: "<"}, + }, + }, + OrderBy: []v3.OrderBy{ + { + ColumnName: "timestamp", + Order: "DESC", + }, + }, + Offset: 100, + PageSize: 100, + }, + TableName: "logs", + ExpectedQuery: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, body,CAST((attributes_string_key, attributes_string_value), 'Map(String, String)') as " + + "attributes_string,CAST((attributes_int64_key, attributes_int64_value), 'Map(String, Int64)') as attributes_int64,CAST((attributes_float64_key, attributes_float64_value), 'Map(String, Float64)') as " + + "attributes_float64,CAST((attributes_bool_key, attributes_bool_value), 'Map(String, Bool)') as attributes_bool,CAST((resources_string_key, resources_string_value), 'Map(String, String)') as resources_string " + + "from signoz_logs.distributed_logs where (timestamp >= 1680066360726000000 AND timestamp <= 1680066458000000000) AND id < 'logid' order by " + + "timestamp DESC LIMIT 100", + }, + { + Name: "Don't ignore offset if order by is not timestamp", + PanelType: v3.PanelTypeList, + Start: 1680066360726, + End: 1680066458000, + BuilderQuery: &v3.BuilderQuery{ + QueryName: "A", + StepInterval: 60, + AggregateOperator: v3.AggregateOperatorNoOp, + Expression: "A", + Filters: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{ + {Key: v3.AttributeKey{Key: "method", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "GET", Operator: "="}, + }, + }, + OrderBy: []v3.OrderBy{ + { + ColumnName: "mycolumn", + Order: "DESC", + }, + }, + Offset: 100, + PageSize: 100, + }, + TableName: "logs", + ExpectedQuery: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, body,CAST((attributes_string_key, attributes_string_value), 'Map(String, String)') as " + + "attributes_string,CAST((attributes_int64_key, attributes_int64_value), 'Map(String, Int64)') as attributes_int64,CAST((attributes_float64_key, attributes_float64_value), 'Map(String, Float64)') as " + + "attributes_float64,CAST((attributes_bool_key, attributes_bool_value), 'Map(String, Bool)') as attributes_bool,CAST((resources_string_key, resources_string_value), 'Map(String, String)') as resources_string " + + "from signoz_logs.distributed_logs where (timestamp >= 1680066360726000000 AND timestamp <= 1680066458000000000) AND attributes_string_value[indexOf(attributes_string_key, 'method')] = 'GET' order by " + + "resources_string_value[indexOf(resources_string_key, 'mycolumn')] DESC LIMIT 100 OFFSET 100", + }, } func TestPrepareLogsQuery(t *testing.T) { diff --git a/pkg/query-service/app/preferences/map.go b/pkg/query-service/app/preferences/map.go new file mode 100644 index 0000000000..219fb6c595 --- /dev/null +++ b/pkg/query-service/app/preferences/map.go @@ -0,0 +1,37 @@ +package preferences + +var preferenceMap = map[string]Preference{ + "DASHBOARDS_LIST_VIEW": { + Key: "DASHBOARDS_LIST_VIEW", + Name: "Dashboards List View", + Description: "", + ValueType: "string", + DefaultValue: "grid", + AllowedValues: []interface{}{"grid", "list"}, + IsDiscreteValues: true, + AllowedScopes: []string{"user", "org"}, + }, + "LOGS_TOOLBAR_COLLAPSED": { + Key: "LOGS_TOOLBAR_COLLAPSED", + Name: "Logs toolbar", + Description: "", + ValueType: "boolean", + DefaultValue: false, + AllowedValues: []interface{}{true, false}, + IsDiscreteValues: true, + AllowedScopes: []string{"user", "org"}, + }, + "MAX_DEPTH_ALLOWED": { + Key: "MAX_DEPTH_ALLOWED", + Name: "Max Depth Allowed", + Description: "", + ValueType: "integer", + DefaultValue: 10, + IsDiscreteValues: false, + Range: Range{ + Min: 0, + Max: 100, + }, + AllowedScopes: []string{"user", "org"}, + }, +} diff --git a/pkg/query-service/app/preferences/model.go b/pkg/query-service/app/preferences/model.go new file mode 100644 index 0000000000..82b8e9c9f6 --- /dev/null +++ b/pkg/query-service/app/preferences/model.go @@ -0,0 +1,544 @@ +package preferences + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/jmoiron/sqlx" + "go.signoz.io/signoz/ee/query-service/model" +) + +type Range struct { + Min int64 `json:"min"` + Max int64 `json:"max"` +} + +type Preference struct { + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + ValueType string `json:"valueType"` + DefaultValue interface{} `json:"defaultValue"` + AllowedValues []interface{} `json:"allowedValues"` + IsDiscreteValues bool `json:"isDiscreteValues"` + Range Range `json:"range"` + AllowedScopes []string `json:"allowedScopes"` +} + +func (p *Preference) ErrorValueTypeMismatch() *model.ApiError { + return &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("the preference value is not of expected type: %s", p.ValueType)} +} + +const ( + PreferenceValueTypeInteger string = "integer" + PreferenceValueTypeFloat string = "float" + PreferenceValueTypeString string = "string" + PreferenceValueTypeBoolean string = "boolean" +) + +const ( + OrgAllowedScope string = "org" + UserAllowedScope string = "user" +) + +func (p *Preference) checkIfInAllowedValues(preferenceValue interface{}) (bool, *model.ApiError) { + + switch p.ValueType { + case PreferenceValueTypeInteger: + _, ok := preferenceValue.(int64) + if !ok { + return false, p.ErrorValueTypeMismatch() + } + case PreferenceValueTypeFloat: + _, ok := preferenceValue.(float64) + if !ok { + return false, p.ErrorValueTypeMismatch() + } + case PreferenceValueTypeString: + _, ok := preferenceValue.(string) + if !ok { + return false, p.ErrorValueTypeMismatch() + } + case PreferenceValueTypeBoolean: + _, ok := preferenceValue.(bool) + if !ok { + return false, p.ErrorValueTypeMismatch() + } + } + isInAllowedValues := false + for _, value := range p.AllowedValues { + switch p.ValueType { + case PreferenceValueTypeInteger: + allowedValue, ok := value.(int64) + if !ok { + return false, p.ErrorValueTypeMismatch() + } + + if allowedValue == preferenceValue { + isInAllowedValues = true + } + case PreferenceValueTypeFloat: + allowedValue, ok := value.(float64) + if !ok { + return false, p.ErrorValueTypeMismatch() + } + + if allowedValue == preferenceValue { + isInAllowedValues = true + } + case PreferenceValueTypeString: + allowedValue, ok := value.(string) + if !ok { + return false, p.ErrorValueTypeMismatch() + } + + if allowedValue == preferenceValue { + isInAllowedValues = true + } + case PreferenceValueTypeBoolean: + allowedValue, ok := value.(bool) + if !ok { + return false, p.ErrorValueTypeMismatch() + } + + if allowedValue == preferenceValue { + isInAllowedValues = true + } + } + } + return isInAllowedValues, nil +} + +func (p *Preference) IsValidValue(preferenceValue interface{}) *model.ApiError { + typeSafeValue := preferenceValue + switch p.ValueType { + case PreferenceValueTypeInteger: + val, ok := preferenceValue.(int64) + if !ok { + floatVal, ok := preferenceValue.(float64) + if !ok || floatVal != float64(int64(floatVal)) { + return p.ErrorValueTypeMismatch() + } + val = int64(floatVal) + typeSafeValue = val + } + if !p.IsDiscreteValues { + if val < p.Range.Min || val > p.Range.Max { + return &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("the preference value is not in the range specified, min: %v , max:%v", p.Range.Min, p.Range.Max)} + } + } + case PreferenceValueTypeString: + _, ok := preferenceValue.(string) + if !ok { + return p.ErrorValueTypeMismatch() + } + case PreferenceValueTypeFloat: + _, ok := preferenceValue.(float64) + if !ok { + return p.ErrorValueTypeMismatch() + } + case PreferenceValueTypeBoolean: + _, ok := preferenceValue.(bool) + if !ok { + return p.ErrorValueTypeMismatch() + } + } + + // check the validity of the value being part of allowed values or the range specified if any + if p.IsDiscreteValues { + if p.AllowedValues != nil { + isInAllowedValues, valueMisMatchErr := p.checkIfInAllowedValues(typeSafeValue) + + if valueMisMatchErr != nil { + return valueMisMatchErr + } + if !isInAllowedValues { + return &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("the preference value is not in the list of allowedValues: %v", p.AllowedValues)} + } + } + } + return nil +} + +func (p *Preference) IsEnabledForScope(scope string) bool { + isPreferenceEnabledForGivenScope := false + if p.AllowedScopes != nil { + for _, allowedScope := range p.AllowedScopes { + if allowedScope == strings.ToLower(scope) { + isPreferenceEnabledForGivenScope = true + } + } + } + return isPreferenceEnabledForGivenScope +} + +func (p *Preference) SanitizeValue(preferenceValue interface{}) interface{} { + switch p.ValueType { + case PreferenceValueTypeBoolean: + if preferenceValue == "1" || preferenceValue == true { + return true + } else { + return false + } + default: + return preferenceValue + } +} + +type AllPreferences struct { + Preference + Value interface{} `json:"value"` +} + +type PreferenceKV struct { + PreferenceId string `json:"preference_id" db:"preference_id"` + PreferenceValue interface{} `json:"preference_value" db:"preference_value"` +} + +type UpdatePreference struct { + PreferenceValue interface{} `json:"preference_value"` +} + +var db *sqlx.DB + +func InitDB(datasourceName string) error { + var err error + db, err = sqlx.Open("sqlite3", datasourceName) + + if err != nil { + return err + } + + // create the user preference table + tableSchema := ` + PRAGMA foreign_keys = ON; + CREATE TABLE IF NOT EXISTS user_preference( + preference_id TEXT NOT NULL, + preference_value TEXT, + user_id TEXT NOT NULL, + PRIMARY KEY (preference_id,user_id), + FOREIGN KEY (user_id) + REFERENCES users(id) + ON UPDATE CASCADE + ON DELETE CASCADE + );` + + _, err = db.Exec(tableSchema) + if err != nil { + return fmt.Errorf("error in creating user_preference table: %s", err.Error()) + } + + // create the org preference table + tableSchema = ` + PRAGMA foreign_keys = ON; + CREATE TABLE IF NOT EXISTS org_preference( + preference_id TEXT NOT NULL, + preference_value TEXT, + org_id TEXT NOT NULL, + PRIMARY KEY (preference_id,org_id), + FOREIGN KEY (org_id) + REFERENCES organizations(id) + ON UPDATE CASCADE + ON DELETE CASCADE + );` + + _, err = db.Exec(tableSchema) + if err != nil { + return fmt.Errorf("error in creating org_preference table: %s", err.Error()) + } + + return nil +} + +// org preference functions +func GetOrgPreference(ctx context.Context, preferenceId string, orgId string) (*PreferenceKV, *model.ApiError) { + // check if the preference key exists or not + preference, seen := preferenceMap[preferenceId] + if !seen { + return nil, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("no such preferenceId exists: %s", preferenceId)} + } + + // check if the preference is enabled for org scope or not + isPreferenceEnabled := preference.IsEnabledForScope(OrgAllowedScope) + if !isPreferenceEnabled { + return nil, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("preference is not enabled at org scope: %s", preferenceId)} + } + + // fetch the value from the database + var orgPreference PreferenceKV + query := `SELECT preference_id , preference_value FROM org_preference WHERE preference_id=$1 AND org_id=$2;` + err := db.Get(&orgPreference, query, preferenceId, orgId) + + // if the value doesn't exist in db then return the default value + if err != nil { + if err == sql.ErrNoRows { + return &PreferenceKV{ + PreferenceId: preferenceId, + PreferenceValue: preference.DefaultValue, + }, nil + } + return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in fetching the org preference: %s", err.Error())} + + } + + // else return the value fetched from the org_preference table + return &PreferenceKV{ + PreferenceId: preferenceId, + PreferenceValue: preference.SanitizeValue(orgPreference.PreferenceValue), + }, nil +} + +func UpdateOrgPreference(ctx context.Context, preferenceId string, preferenceValue interface{}, orgId string) (*PreferenceKV, *model.ApiError) { + // check if the preference key exists or not + preference, seen := preferenceMap[preferenceId] + if !seen { + return nil, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("no such preferenceId exists: %s", preferenceId)} + } + + // check if the preference is enabled at org scope or not + isPreferenceEnabled := preference.IsEnabledForScope(OrgAllowedScope) + if !isPreferenceEnabled { + return nil, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("preference is not enabled at org scope: %s", preferenceId)} + } + + err := preference.IsValidValue(preferenceValue) + if err != nil { + return nil, err + } + + // update the values in the org_preference table and return the key and the value + query := `INSERT INTO org_preference(preference_id,preference_value,org_id) VALUES($1,$2,$3) + ON CONFLICT(preference_id,org_id) DO + UPDATE SET preference_value= $2 WHERE preference_id=$1 AND org_id=$3;` + + _, dberr := db.Exec(query, preferenceId, preferenceValue, orgId) + + if dberr != nil { + return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in setting the preference value: %s", dberr.Error())} + } + + return &PreferenceKV{ + PreferenceId: preferenceId, + PreferenceValue: preferenceValue, + }, nil +} + +func GetAllOrgPreferences(ctx context.Context, orgId string) (*[]AllPreferences, *model.ApiError) { + // filter out all the org enabled preferences from the preference variable + allOrgPreferences := []AllPreferences{} + + // fetch all the org preference values stored in org_preference table + orgPreferenceValues := []PreferenceKV{} + + query := `SELECT preference_id,preference_value FROM org_preference WHERE org_id=$1;` + err := db.Select(&orgPreferenceValues, query, orgId) + + if err != nil { + return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in getting all org preference values: %s", err)} + } + + // create a map of key vs values from the above response + preferenceValueMap := map[string]interface{}{} + + for _, preferenceValue := range orgPreferenceValues { + preferenceValueMap[preferenceValue.PreferenceId] = preferenceValue.PreferenceValue + } + + // update in the above filtered list wherver value present in the map + for _, preference := range preferenceMap { + isEnabledForOrgScope := preference.IsEnabledForScope(OrgAllowedScope) + if isEnabledForOrgScope { + preferenceWithValue := AllPreferences{} + preferenceWithValue.Key = preference.Key + preferenceWithValue.Name = preference.Name + preferenceWithValue.Description = preference.Description + preferenceWithValue.AllowedScopes = preference.AllowedScopes + preferenceWithValue.AllowedValues = preference.AllowedValues + preferenceWithValue.DefaultValue = preference.DefaultValue + preferenceWithValue.Range = preference.Range + preferenceWithValue.ValueType = preference.ValueType + preferenceWithValue.IsDiscreteValues = preference.IsDiscreteValues + value, seen := preferenceValueMap[preference.Key] + + if seen { + preferenceWithValue.Value = value + } else { + preferenceWithValue.Value = preference.DefaultValue + } + + preferenceWithValue.Value = preference.SanitizeValue(preferenceWithValue.Value) + allOrgPreferences = append(allOrgPreferences, preferenceWithValue) + } + } + return &allOrgPreferences, nil +} + +// user preference functions +func GetUserPreference(ctx context.Context, preferenceId string, orgId string, userId string) (*PreferenceKV, *model.ApiError) { + // check if the preference key exists + preference, seen := preferenceMap[preferenceId] + if !seen { + return nil, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("no such preferenceId exists: %s", preferenceId)} + } + + preferenceValue := PreferenceKV{ + PreferenceId: preferenceId, + PreferenceValue: preference.DefaultValue, + } + + // check if the preference is enabled at user scope + isPreferenceEnabledAtUserScope := preference.IsEnabledForScope(UserAllowedScope) + if !isPreferenceEnabledAtUserScope { + return nil, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("preference is not enabled at user scope: %s", preferenceId)} + } + + isPreferenceEnabledAtOrgScope := preference.IsEnabledForScope(OrgAllowedScope) + // get the value from the org scope if enabled at org scope + if isPreferenceEnabledAtOrgScope { + orgPreference := PreferenceKV{} + + query := `SELECT preference_id , preference_value FROM org_preference WHERE preference_id=$1 AND org_id=$2;` + + err := db.Get(&orgPreference, query, preferenceId, orgId) + + // if there is error in getting values and its not an empty rows error return from here + if err != nil && err != sql.ErrNoRows { + return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in getting org preference values: %s", err.Error())} + } + + // if there is no error update the preference value with value from org preference + if err == nil { + preferenceValue.PreferenceValue = orgPreference.PreferenceValue + } + } + + // get the value from the user_preference table, if exists return this value else the one calculated in the above step + userPreference := PreferenceKV{} + + query := `SELECT preference_id, preference_value FROM user_preference WHERE preference_id=$1 AND user_id=$2;` + err := db.Get(&userPreference, query, preferenceId, userId) + + if err != nil && err != sql.ErrNoRows { + return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in getting user preference values: %s", err.Error())} + } + + if err == nil { + preferenceValue.PreferenceValue = userPreference.PreferenceValue + } + + return &PreferenceKV{ + PreferenceId: preferenceValue.PreferenceId, + PreferenceValue: preference.SanitizeValue(preferenceValue.PreferenceValue), + }, nil +} + +func UpdateUserPreference(ctx context.Context, preferenceId string, preferenceValue interface{}, userId string) (*PreferenceKV, *model.ApiError) { + // check if the preference id is valid + preference, seen := preferenceMap[preferenceId] + if !seen { + return nil, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("no such preferenceId exists: %s", preferenceId)} + } + + // check if the preference is enabled at user scope + isPreferenceEnabledAtUserScope := preference.IsEnabledForScope(UserAllowedScope) + if !isPreferenceEnabledAtUserScope { + return nil, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("preference is not enabled at user scope: %s", preferenceId)} + } + + err := preference.IsValidValue(preferenceValue) + if err != nil { + return nil, err + } + // update the user preference values + query := `INSERT INTO user_preference(preference_id,preference_value,user_id) VALUES($1,$2,$3) + ON CONFLICT(preference_id,user_id) DO + UPDATE SET preference_value= $2 WHERE preference_id=$1 AND user_id=$3;` + + _, dberrr := db.Exec(query, preferenceId, preferenceValue, userId) + + if dberrr != nil { + return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in setting the preference value: %s", dberrr.Error())} + } + + return &PreferenceKV{ + PreferenceId: preferenceId, + PreferenceValue: preferenceValue, + }, nil +} + +func GetAllUserPreferences(ctx context.Context, orgId string, userId string) (*[]AllPreferences, *model.ApiError) { + allUserPreferences := []AllPreferences{} + + // fetch all the org preference values stored in org_preference table + orgPreferenceValues := []PreferenceKV{} + + query := `SELECT preference_id,preference_value FROM org_preference WHERE org_id=$1;` + err := db.Select(&orgPreferenceValues, query, orgId) + + if err != nil { + return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in getting all org preference values: %s", err)} + } + + // create a map of key vs values from the above response + preferenceOrgValueMap := map[string]interface{}{} + + for _, preferenceValue := range orgPreferenceValues { + preferenceOrgValueMap[preferenceValue.PreferenceId] = preferenceValue.PreferenceValue + } + + // fetch all the user preference values stored in user_preference table + userPreferenceValues := []PreferenceKV{} + + query = `SELECT preference_id,preference_value FROM user_preference WHERE user_id=$1;` + err = db.Select(&userPreferenceValues, query, userId) + + if err != nil { + return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in getting all user preference values: %s", err)} + } + + // create a map of key vs values from the above response + preferenceUserValueMap := map[string]interface{}{} + + for _, preferenceValue := range userPreferenceValues { + preferenceUserValueMap[preferenceValue.PreferenceId] = preferenceValue.PreferenceValue + } + + // update in the above filtered list wherver value present in the map + for _, preference := range preferenceMap { + isEnabledForUserScope := preference.IsEnabledForScope(UserAllowedScope) + + if isEnabledForUserScope { + preferenceWithValue := AllPreferences{} + preferenceWithValue.Key = preference.Key + preferenceWithValue.Name = preference.Name + preferenceWithValue.Description = preference.Description + preferenceWithValue.AllowedScopes = preference.AllowedScopes + preferenceWithValue.AllowedValues = preference.AllowedValues + preferenceWithValue.DefaultValue = preference.DefaultValue + preferenceWithValue.Range = preference.Range + preferenceWithValue.ValueType = preference.ValueType + preferenceWithValue.IsDiscreteValues = preference.IsDiscreteValues + preferenceWithValue.Value = preference.DefaultValue + + isEnabledForOrgScope := preference.IsEnabledForScope(OrgAllowedScope) + if isEnabledForOrgScope { + value, seen := preferenceOrgValueMap[preference.Key] + if seen { + preferenceWithValue.Value = value + } + } + + value, seen := preferenceUserValueMap[preference.Key] + + if seen { + preferenceWithValue.Value = value + } + + preferenceWithValue.Value = preference.SanitizeValue(preferenceWithValue.Value) + allUserPreferences = append(allUserPreferences, preferenceWithValue) + } + } + return &allUserPreferences, nil +} diff --git a/pkg/query-service/app/server.go b/pkg/query-service/app/server.go index 2260045f4d..5120cd0039 100644 --- a/pkg/query-service/app/server.go +++ b/pkg/query-service/app/server.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net" @@ -27,6 +28,7 @@ import ( "go.signoz.io/signoz/pkg/query-service/app/logparsingpipeline" "go.signoz.io/signoz/pkg/query-service/app/opamp" opAmpModel "go.signoz.io/signoz/pkg/query-service/app/opamp/model" + "go.signoz.io/signoz/pkg/query-service/app/preferences" "go.signoz.io/signoz/pkg/query-service/common" v3 "go.signoz.io/signoz/pkg/query-service/model/v3" @@ -94,6 +96,10 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { return nil, err } + if err := preferences.InitDB(constants.RELATIONAL_DATASOURCE_PATH); err != nil { + return nil, err + } + localDB, err := dashboards.InitDB(constants.RELATIONAL_DATASOURCE_PATH) explorer.InitWithDSN(constants.RELATIONAL_DATASOURCE_PATH) @@ -268,7 +274,21 @@ func (s *Server) createPublicServer(api *APIHandler) (*http.Server, error) { r.Use(s.analyticsMiddleware) r.Use(loggingMiddleware) - am := NewAuthMiddleware(auth.GetUserFromRequest) + // add auth middleware + getUserFromRequest := func(r *http.Request) (*model.UserPayload, error) { + user, err := auth.GetUserFromRequest(r) + + if err != nil { + return nil, err + } + + if user.User.OrgId == "" { + return nil, model.UnauthorizedError(errors.New("orgId is missing in the claims")) + } + + return user, nil + } + am := NewAuthMiddleware(getUserFromRequest) api.RegisterRoutes(r, am) api.RegisterLogsRoutes(r, am) diff --git a/pkg/query-service/app/traces/v3/utils.go b/pkg/query-service/app/traces/v3/utils.go new file mode 100644 index 0000000000..624458f919 --- /dev/null +++ b/pkg/query-service/app/traces/v3/utils.go @@ -0,0 +1,183 @@ +package v3 + +import ( + "strconv" + + v3 "go.signoz.io/signoz/pkg/query-service/model/v3" + "go.signoz.io/signoz/pkg/query-service/utils" + "go.uber.org/zap" +) + +// check if traceId filter is used in traces query and return the list of traceIds +func TraceIdFilterUsedWithEqual(params *v3.QueryRangeParamsV3) (bool, []string) { + compositeQuery := params.CompositeQuery + if compositeQuery == nil { + return false, []string{} + } + var traceIds []string + var traceIdFilterUsed bool + + // Build queries for each builder query + for queryName, query := range compositeQuery.BuilderQueries { + if query.Expression != queryName && query.DataSource != v3.DataSourceTraces { + continue + } + + // check filter attribute + if query.Filters != nil && len(query.Filters.Items) != 0 { + for _, item := range query.Filters.Items { + + if item.Key.Key == "traceID" && (item.Operator == v3.FilterOperatorIn || + item.Operator == v3.FilterOperatorEqual) { + traceIdFilterUsed = true + // validate value + var err error + val := item.Value + val, err = utils.ValidateAndCastValue(val, item.Key.DataType) + if err != nil { + zap.L().Error("invalid value for key", zap.String("key", item.Key.Key), zap.Error(err)) + return false, []string{} + } + if val != nil { + fmtVal := extractFormattedStringValues(val) + traceIds = append(traceIds, fmtVal...) + } + } + } + } + + } + + zap.L().Debug("traceIds", zap.Any("traceIds", traceIds)) + return traceIdFilterUsed, traceIds +} + +func extractFormattedStringValues(v interface{}) []string { + // if it's pointer convert it to a value + v = getPointerValue(v) + + switch x := v.(type) { + case string: + return []string{x} + + case []interface{}: + if len(x) == 0 { + return []string{} + } + switch x[0].(type) { + case string: + values := []string{} + for _, val := range x { + values = append(values, val.(string)) + } + return values + default: + return []string{} + } + default: + return []string{} + } +} + +func getPointerValue(v interface{}) interface{} { + switch x := v.(type) { + case *uint8: + return *x + case *uint16: + return *x + case *uint32: + return *x + case *uint64: + return *x + case *int: + return *x + case *int8: + return *x + case *int16: + return *x + case *int32: + return *x + case *int64: + return *x + case *float32: + return *x + case *float64: + return *x + case *string: + return *x + case *bool: + return *x + case []interface{}: + values := []interface{}{} + for _, val := range x { + values = append(values, getPointerValue(val)) + } + return values + default: + return v + } +} + +func AddTimestampFilters(minTime int64, maxTime int64, params *v3.QueryRangeParamsV3) { + if minTime == 0 && maxTime == 0 { + return + } + + compositeQuery := params.CompositeQuery + if compositeQuery == nil { + return + } + // Build queries for each builder query + for queryName, query := range compositeQuery.BuilderQueries { + if query.Expression != queryName && query.DataSource != v3.DataSourceTraces { + continue + } + + addTimeStampFilter := false + + // check filter attribute + if query.Filters != nil && len(query.Filters.Items) != 0 { + for _, item := range query.Filters.Items { + if item.Key.Key == "traceID" && (item.Operator == v3.FilterOperatorIn || + item.Operator == v3.FilterOperatorEqual) { + addTimeStampFilter = true + } + } + } + + // add timestamp filter to query only if traceID filter along with equal/similar operator is used + if addTimeStampFilter { + timeFilters := []v3.FilterItem{ + { + Key: v3.AttributeKey{ + Key: "timestamp", + Type: v3.AttributeKeyTypeTag, + DataType: v3.AttributeKeyDataTypeString, + IsColumn: true, + }, + Value: strconv.FormatUint(uint64(minTime), 10), + Operator: v3.FilterOperatorGreaterThanOrEq, + }, + { + Key: v3.AttributeKey{ + Key: "timestamp", + Type: v3.AttributeKeyTypeTag, + DataType: v3.AttributeKeyDataTypeString, + IsColumn: true, + }, + Value: strconv.FormatUint(uint64(maxTime), 10), + Operator: v3.FilterOperatorLessThanOrEq, + }, + } + + // add new timestamp filter to query + if query.Filters == nil { + query.Filters = &v3.FilterSet{ + Items: timeFilters, + } + } else { + query.Filters.Items = append(query.Filters.Items, timeFilters...) + } + } + } +} diff --git a/pkg/query-service/auth/auth.go b/pkg/query-service/auth/auth.go index 6041b3c1af..16eea6a5f3 100644 --- a/pkg/query-service/auth/auth.go +++ b/pkg/query-service/auth/auth.go @@ -467,6 +467,10 @@ func authenticateLogin(ctx context.Context, req *model.LoginRequest) (*model.Use return nil, errors.Wrap(err, "failed to validate refresh token") } + if user.OrgId == "" { + return nil, model.UnauthorizedError(errors.New("orgId is missing in the claims")) + } + return user, nil } @@ -505,6 +509,7 @@ func GenerateJWTForUser(user *model.User) (model.UserJwtObject, error) { "gid": user.GroupId, "email": user.Email, "exp": j.AccessJwtExpiry, + "orgId": user.OrgId, }) j.AccessJwt, err = token.SignedString([]byte(JwtSecret)) @@ -518,6 +523,7 @@ func GenerateJWTForUser(user *model.User) (model.UserJwtObject, error) { "gid": user.GroupId, "email": user.Email, "exp": j.RefreshJwtExpiry, + "orgId": user.OrgId, }) j.RefreshJwt, err = token.SignedString([]byte(JwtSecret)) diff --git a/pkg/query-service/auth/jwt.go b/pkg/query-service/auth/jwt.go index 7fe70e2c71..f57bb2ae18 100644 --- a/pkg/query-service/auth/jwt.go +++ b/pkg/query-service/auth/jwt.go @@ -20,6 +20,8 @@ var ( ) func ParseJWT(jwtStr string) (jwt.MapClaims, error) { + // TODO[@vikrantgupta25] : to update this to the claims check function for better integrity of JWT + // reference - https://pkg.go.dev/github.com/golang-jwt/jwt/v5#Parser.ParseWithClaims token, err := jwt.Parse(jwtStr, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, errors.Errorf("unknown signing algo: %v", token.Header["alg"]) @@ -35,6 +37,7 @@ func ParseJWT(jwtStr string) (jwt.MapClaims, error) { if !ok || !token.Valid { return nil, errors.Errorf("Not a valid jwt claim") } + return claims, nil } @@ -47,11 +50,18 @@ func validateUser(tok string) (*model.UserPayload, error) { if !claims.VerifyExpiresAt(now, true) { return nil, model.ErrorTokenExpired } + + var orgId string + if claims["orgId"] != nil { + orgId = claims["orgId"].(string) + } + return &model.UserPayload{ User: model.User{ Id: claims["id"].(string), GroupId: claims["gid"].(string), Email: claims["email"].(string), + OrgId: orgId, }, }, nil } diff --git a/pkg/query-service/interfaces/interface.go b/pkg/query-service/interfaces/interface.go index 385d48173b..fea923ac27 100644 --- a/pkg/query-service/interfaces/interface.go +++ b/pkg/query-service/interfaces/interface.go @@ -23,7 +23,7 @@ type Reader interface { GetInstantQueryMetricsResult(ctx context.Context, query *model.InstantQueryMetricsParams) (*promql.Result, *stats.QueryStats, *model.ApiError) GetQueryRangeResult(ctx context.Context, query *model.QueryRangeParams) (*promql.Result, *stats.QueryStats, *model.ApiError) GetServiceOverview(ctx context.Context, query *model.GetServiceOverviewParams, skipConfig *model.SkipConfig) (*[]model.ServiceOverviewItem, *model.ApiError) - GetTopLevelOperations(ctx context.Context, skipConfig *model.SkipConfig, start, end time.Time) (*map[string][]string, *map[string][]string, *model.ApiError) + GetTopLevelOperations(ctx context.Context, skipConfig *model.SkipConfig, start, end time.Time, services []string) (*map[string][]string, *model.ApiError) GetServices(ctx context.Context, query *model.GetServicesParams, skipConfig *model.SkipConfig) (*[]model.ServiceItem, *model.ApiError) GetTopOperations(ctx context.Context, query *model.GetTopOperationsParams) (*[]model.TopOperationsItem, *model.ApiError) GetUsage(ctx context.Context, query *model.GetUsageParams) (*[]model.UsageItem, error) @@ -103,6 +103,8 @@ type Reader interface { CheckClickHouse(ctx context.Context) error GetMetricMetadata(context.Context, string, string) (*v3.MetricMetadataResponse, error) + + GetMinAndMaxTimestampForTraceID(ctx context.Context, traceID []string) (int64, int64, error) } type Querier interface { diff --git a/pkg/query-service/model/response.go b/pkg/query-service/model/response.go index d13ebd0cdb..7e8e883164 100644 --- a/pkg/query-service/model/response.go +++ b/pkg/query-service/model/response.go @@ -638,6 +638,12 @@ type AlertsInfo struct { LogsBasedAlerts int `json:"logsBasedAlerts"` MetricBasedAlerts int `json:"metricBasedAlerts"` TracesBasedAlerts int `json:"tracesBasedAlerts"` + SlackChannels int `json:"slackChannels"` + WebHookChannels int `json:"webHookChannels"` + PagerDutyChannels int `json:"pagerDutyChannels"` + OpsGenieChannels int `json:"opsGenieChannels"` + EmailChannels int `json:"emailChannels"` + MSTeamsChannels int `json:"microsoftTeamsChannels"` } type SavedViewsInfo struct { diff --git a/pkg/query-service/model/v3/v3.go b/pkg/query-service/model/v3/v3.go index 7facd2ff50..e6ac8441d6 100644 --- a/pkg/query-service/model/v3/v3.go +++ b/pkg/query-service/model/v3/v3.go @@ -907,7 +907,8 @@ const ( FilterOperatorNotContains FilterOperator = "ncontains" FilterOperatorRegex FilterOperator = "regex" FilterOperatorNotRegex FilterOperator = "nregex" - // (I)LIKE is faster than REGEX and supports index + // (I)LIKE is faster than REGEX + // ilike doesn't support index so internally we use lower(body) like for query FilterOperatorLike FilterOperator = "like" FilterOperatorNotLike FilterOperator = "nlike" diff --git a/pkg/query-service/telemetry/telemetry.go b/pkg/query-service/telemetry/telemetry.go index e8675b3b90..8cfa7aaec4 100644 --- a/pkg/query-service/telemetry/telemetry.go +++ b/pkg/query-service/telemetry/telemetry.go @@ -293,6 +293,22 @@ func createTelemetry() { if err == nil { channels, err := telemetry.reader.GetChannels() if err == nil { + for _, channel := range *channels { + switch channel.Type { + case "slack": + alertsInfo.SlackChannels++ + case "webhook": + alertsInfo.WebHookChannels++ + case "pagerduty": + alertsInfo.PagerDutyChannels++ + case "opsgenie": + alertsInfo.OpsGenieChannels++ + case "email": + alertsInfo.EmailChannels++ + case "msteams": + alertsInfo.MSTeamsChannels++ + } + } savedViewsInfo, err := telemetry.reader.GetSavedViewsInfo(ctx) if err == nil { dashboardsAlertsData := map[string]interface{}{ @@ -309,6 +325,12 @@ func createTelemetry() { "totalSavedViews": savedViewsInfo.TotalSavedViews, "logsSavedViews": savedViewsInfo.LogsSavedViews, "tracesSavedViews": savedViewsInfo.TracesSavedViews, + "slackChannels": alertsInfo.SlackChannels, + "webHookChannels": alertsInfo.WebHookChannels, + "pagerDutyChannels": alertsInfo.PagerDutyChannels, + "opsGenieChannels": alertsInfo.OpsGenieChannels, + "emailChannels": alertsInfo.EmailChannels, + "msteamsChannels": alertsInfo.MSTeamsChannels, } // send event only if there are dashboards or alerts or channels if (dashboardsInfo.TotalDashboards > 0 || alertsInfo.TotalAlerts > 0 || len(*channels) > 0 || savedViewsInfo.TotalSavedViews > 0) && apiErr == nil { diff --git a/pkg/query-service/tests/test-deploy/docker-compose.yaml b/pkg/query-service/tests/test-deploy/docker-compose.yaml index 19d088dc7a..4199a6bc6d 100644 --- a/pkg/query-service/tests/test-deploy/docker-compose.yaml +++ b/pkg/query-service/tests/test-deploy/docker-compose.yaml @@ -192,7 +192,7 @@ services: <<: *db-depend otel-collector-migrator: - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.2} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.3} container_name: otel-migrator command: - "--dsn=tcp://clickhouse:9000" @@ -205,7 +205,7 @@ services: # condition: service_healthy otel-collector: - image: signoz/signoz-otel-collector:0.102.2 + image: signoz/signoz-otel-collector:0.102.3 container_name: signoz-otel-collector command: [ diff --git a/pkg/query-service/version/version.go b/pkg/query-service/version/version.go index 68c37a4e0e..3e2f984d4c 100644 --- a/pkg/query-service/version/version.go +++ b/pkg/query-service/version/version.go @@ -25,12 +25,12 @@ Commit timestamp : %v Branch : %v Go version : %v -For SigNoz Official Documentation, visit https://signoz.io/docs -For SigNoz Community Slack, visit http://signoz.io/slack -For discussions about SigNoz, visit https://community.signoz.io +For SigNoz Official Documentation, visit https://signoz.io/docs/ +For SigNoz Community Slack, visit http://signoz.io/slack/ +For archive of discussions about SigNoz, visit https://knowledgebase.signoz.io/ %s. -Copyright 2022 SigNoz +Copyright 2024 SigNoz `, buildVersion, buildHash, buildTime, gitBranch, runtime.Version(), licenseInfo)