Compare commits

..

3 Commits

Author SHA1 Message Date
Nityananda Gohain
567bb8cced Merge branch 'main' into fix/opamp 2025-03-24 17:25:31 +05:30
nityanandagohain
0f7e6da613 fix: update log 2025-03-20 19:16:47 +05:30
nityanandagohain
eec45da84b fix: add exponential backoff to opamp onmessage 2025-03-20 19:09:28 +05:30
100 changed files with 1804 additions and 6402 deletions

View File

@@ -385,6 +385,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
apiHandler.RegisterMessagingQueuesRoutes(r, am)
apiHandler.RegisterThirdPartyApiRoutes(r, am)
apiHandler.MetricExplorerRoutes(r, am)
apiHandler.RegisterTraceFunnelsRoutes(r, am)
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},

View File

@@ -24,6 +24,7 @@ import (
func initZapLog() *zap.Logger {
config := zap.NewProductionConfig()
config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
config.EncoderConfig.TimeKey = "timestamp"
config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
logger, _ := config.Build()

View File

@@ -90,7 +90,7 @@
"less": "^4.1.2",
"less-loader": "^10.2.0",
"lodash-es": "^4.17.21",
"lucide-react": "0.427.0",
"lucide-react": "0.379.0",
"mini-css-extract-plugin": "2.4.5",
"motion": "12.4.13",
"overlayscrollbars": "^2.8.1",

View File

@@ -295,7 +295,3 @@ export const MetricsExplorer = Loadable(
() =>
import(/* webpackChunkName: "MetricsExplorer" */ 'pages/MetricsExplorer'),
);
export const ApiMonitoring = Loadable(
() => import(/* webpackChunkName: "ApiMonitoring" */ 'pages/ApiMonitoring'),
);

View File

@@ -8,7 +8,6 @@ import {
AllAlertChannels,
AllErrors,
APIKeys,
ApiMonitoring,
BillingPage,
CreateAlertChannelAlerts,
CreateNewAlerts,
@@ -498,13 +497,6 @@ const routes: AppRoutes[] = [
key: 'METRICS_EXPLORER_VIEWS',
isPrivate: true,
},
{
path: ROUTES.API_MONITORING,
exact: true,
component: ApiMonitoring,
key: 'API_MONITORING',
isPrivate: true,
},
];
export const SUPPORT_ROUTE: AppRoutes = {

View File

@@ -199,12 +199,12 @@ function ExplorerCard({
value={viewName || undefined}
>
{viewsData?.data.data.map((view) => (
<Select.Option key={view.id} value={view.name}>
<Select.Option key={view.uuid} value={view.name}>
<MenuItemGenerator
viewName={view.name}
viewKey={viewKey}
createdBy={view.createdBy}
uuid={view.id}
uuid={view.uuid}
refetchAllView={refetchAllView}
viewData={viewsData.data.data}
sourcePage={sourcepage}

View File

@@ -53,12 +53,17 @@ function MenuItemGenerator({
({ key }: { key: string }): void => {
const currentViewDetails = getViewDetailsUsingViewKey(key, viewData);
if (!currentViewDetails) return;
const { query, name, id, panelType: currentPanelType } = currentViewDetails;
const {
query,
name,
uuid,
panelType: currentPanelType,
} = currentViewDetails;
handleExplorerTabChange(currentPanelType, {
query,
name,
id,
uuid,
});
},
[viewData, handleExplorerTabChange],

View File

@@ -4,7 +4,7 @@ import { DataSource } from 'types/common/queryBuilder';
export const viewMockData: ViewProps[] = [
{
id: 'view1',
uuid: 'view1',
name: 'View 1',
createdBy: 'User 1',
category: 'category 1',
@@ -17,7 +17,7 @@ export const viewMockData: ViewProps[] = [
updatedBy: 'User 1',
},
{
id: 'view2',
uuid: 'view2',
name: 'View 2',
createdBy: 'User 2',
category: 'category 2',

View File

@@ -25,9 +25,9 @@ describe('MenuItemGenerator', () => {
<MockQueryClientProvider>
<MenuItemGenerator
viewName={viewMockData[0].name}
viewKey={viewMockData[0].id}
viewKey={viewMockData[0].uuid}
createdBy={viewMockData[0].createdBy}
uuid={viewMockData[0].id}
uuid={viewMockData[0].uuid}
refetchAllView={jest.fn()}
viewData={viewMockData}
sourcePage={DataSource.TRACES}
@@ -43,9 +43,9 @@ describe('MenuItemGenerator', () => {
<MockQueryClientProvider>
<MenuItemGenerator
viewName={viewMockData[0].name}
viewKey={viewMockData[0].id}
viewKey={viewMockData[0].uuid}
createdBy={viewMockData[0].createdBy}
uuid={viewMockData[0].id}
uuid={viewMockData[0].uuid}
refetchAllView={jest.fn()}
viewData={viewMockData}
sourcePage={DataSource.TRACES}

View File

@@ -26,7 +26,7 @@ export type GetViewDetailsUsingViewKey = (
| {
query: Query;
name: string;
id: string;
uuid: string;
panelType: PANEL_TYPES;
extraData?: string;
}

View File

@@ -27,11 +27,11 @@ export const getViewDetailsUsingViewKey: GetViewDetailsUsingViewKey = (
viewKey,
data,
) => {
const selectedView = data?.find((view) => view.id === viewKey);
const selectedView = data?.find((view) => view.uuid === viewKey);
if (selectedView) {
const { compositeQuery, name, id, extraData } = selectedView;
const { compositeQuery, name, uuid, extraData } = selectedView;
const query = mapQueryDataFromApi(compositeQuery);
return { query, name, id, panelType: compositeQuery.panelType, extraData };
return { query, name, uuid, panelType: compositeQuery.panelType, extraData };
}
return undefined;
};

View File

@@ -63,31 +63,30 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
return (
<div className="quick-filters">
{source !== QuickFiltersSource.INFRA_MONITORING &&
source !== QuickFiltersSource.API_MONITORING && (
<section className="header">
<section className="left-actions">
<FilterOutlined />
<Typography.Text className="text">Filters for</Typography.Text>
<Tooltip title={`Filter currently in sync with query ${lastQueryName}`}>
<Typography.Text className="sync-tag">{lastQueryName}</Typography.Text>
</Tooltip>
</section>
<section className="right-actions">
<Tooltip title="Reset All">
<SyncOutlined className="sync-icon" onClick={handleReset} />
</Tooltip>
<div className="divider-filter" />
<Tooltip title="Collapse Filters">
<VerticalAlignTopOutlined
rotate={270}
onClick={handleFilterVisibilityChange}
/>
</Tooltip>
</section>
{source !== QuickFiltersSource.INFRA_MONITORING && (
<section className="header">
<section className="left-actions">
<FilterOutlined />
<Typography.Text className="text">Filters for</Typography.Text>
<Tooltip title={`Filter currently in sync with query ${lastQueryName}`}>
<Typography.Text className="sync-tag">{lastQueryName}</Typography.Text>
</Tooltip>
</section>
)}
<section className="right-actions">
<Tooltip title="Reset All">
<SyncOutlined className="sync-icon" onClick={handleReset} />
</Tooltip>
<div className="divider-filter" />
<Tooltip title="Collapse Filters">
<VerticalAlignTopOutlined
rotate={270}
onClick={handleFilterVisibilityChange}
/>
</Tooltip>
</section>
</section>
)}
<section className="filters">
{config.map((filter) => {

View File

@@ -39,5 +39,4 @@ export enum QuickFiltersSource {
LOGS_EXPLORER = 'logs-explorer',
INFRA_MONITORING = 'infra-monitoring',
TRACES_EXPLORER = 'traces-explorer',
API_MONITORING = 'api-monitoring',
}

View File

@@ -51,21 +51,6 @@ export const REACT_QUERY_KEY = {
GET_METRICS_LIST_FILTER_VALUES: 'GET_METRICS_LIST_FILTER_VALUES',
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',
GET_RELATED_METRICS: 'GET_RELATED_METRICS',
// API Monitoring Query Keys
GET_DOMAINS_LIST: 'GET_DOMAINS_LIST',
GET_ENDPOINTS_LIST_BY_DOMAIN: 'GET_ENDPOINTS_LIST_BY_DOMAIN',
GET_NESTED_ENDPOINTS_LIST: 'GET_NESTED_ENDPOINTS_LIST',
GET_ENDPOINT_METRICS_DATA: 'GET_ENDPOINT_METRICS_DATA',
GET_ENDPOINT_STATUS_CODE_DATA: 'GET_ENDPOINT_STATUS_CODE_DATA',
GET_ENDPOINT_RATE_OVER_TIME_DATA: 'GET_ENDPOINT_RATE_OVER_TIME_DATA',
GET_ENDPOINT_LATENCY_OVER_TIME_DATA: 'GET_ENDPOINT_LATENCY_OVER_TIME_DATA',
GET_ENDPOINT_DROPDOWN_DATA: 'GET_ENDPOINT_DROPDOWN_DATA',
GET_ENDPOINT_DEPENDENT_SERVICES_DATA: 'GET_ENDPOINT_DEPENDENT_SERVICES_DATA',
GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA:
'GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA',
GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA:
'GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA',
GET_FUNNELS_LIST: 'GET_FUNNELS_LIST',
GET_FUNNEL_DETAILS: 'GET_FUNNEL_DETAILS',
} as const;

View File

@@ -71,7 +71,6 @@ const ROUTES = {
METRICS_EXPLORER: '/metrics-explorer/summary',
METRICS_EXPLORER_EXPLORER: '/metrics-explorer/explorer',
METRICS_EXPLORER_VIEWS: '/metrics-explorer/views',
API_MONITORING: '/api-monitoring/explorer',
METRICS_EXPLORER_BASE: '/metrics-explorer',
WORKSPACE_ACCESS_RESTRICTED: '/workspace-access-restricted',
HOME_PAGE: '/',

View File

@@ -1,239 +0,0 @@
import { LoadingOutlined } from '@ant-design/icons';
import { Select, Spin, Table, Typography } from 'antd';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
EndPointsTableRowData,
formatEndPointsDataForTable,
getEndPointsColumnsConfig,
getEndPointsQueryPayload,
} from 'container/ApiMonitoring/utils';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueries } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import ErrorState from './components/ErrorState';
import ExpandedRow from './components/ExpandedRow';
import { VIEW_TYPES, VIEWS } from './constants';
function AllEndPoints({
domainName,
setSelectedEndPointName,
setSelectedView,
groupBy,
setGroupBy,
}: {
domainName: string;
setSelectedEndPointName: (name: string) => void;
setSelectedView: (tab: VIEWS) => void;
groupBy: IBuilderQuery['groupBy'];
setGroupBy: (groupBy: IBuilderQuery['groupBy']) => void;
}): JSX.Element {
const {
data: groupByFiltersData,
isLoading: isLoadingGroupByFilters,
} = useGetAggregateKeys({
dataSource: DataSource.TRACES,
aggregateAttribute: '',
aggregateOperator: 'noop',
searchText: '',
tagType: '',
});
const [groupByOptions, setGroupByOptions] = useState<
{ value: string; label: string }[]
>([]);
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([]);
const handleGroupByChange = useCallback(
(value: IBuilderQuery['groupBy']) => {
const groupBy = [];
for (let index = 0; index < value.length; index++) {
const element = (value[index] as unknown) as string;
const key = groupByFiltersData?.payload?.attributeKeys?.find(
(key) => key.key === element,
);
if (key) {
groupBy.push(key);
}
}
setGroupBy(groupBy);
},
[groupByFiltersData, setGroupBy],
);
useEffect(() => {
if (groupByFiltersData?.payload) {
setGroupByOptions(
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
value: filter.key,
label: filter.key,
})) || [],
);
}
}, [groupByFiltersData]);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const queryPayloads = useMemo(
() =>
getEndPointsQueryPayload(
groupBy,
domainName,
Math.floor(minTime / 1e9),
Math.floor(maxTime / 1e9),
),
[groupBy, domainName, minTime, maxTime],
);
// Since only one query here
const endPointsDataQueries = useQueries(
queryPayloads.map((payload) => ({
queryKey: [
REACT_QUERY_KEY.GET_ENDPOINTS_LIST_BY_DOMAIN,
payload,
ENTITY_VERSION_V4,
groupBy,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
enabled: !!payload,
staleTime: 60 * 1000, // 1 minute stale time : optimize this part
})),
);
const endPointsDataQuery = endPointsDataQueries[0];
const {
data: allEndPointsData,
isLoading,
isRefetching,
isError,
refetch,
} = endPointsDataQuery;
const endPointsColumnsConfig = useMemo(
() => getEndPointsColumnsConfig(groupBy.length > 0, expandedRowKeys),
[groupBy.length, expandedRowKeys],
);
const expandedRowRender = (record: EndPointsTableRowData): JSX.Element => (
<ExpandedRow
domainName={domainName}
selectedRowData={record}
setSelectedEndPointName={setSelectedEndPointName}
setSelectedView={setSelectedView}
/>
);
const handleGroupByRowClick = (record: EndPointsTableRowData): void => {
if (expandedRowKeys.includes(record.key)) {
setExpandedRowKeys(expandedRowKeys.filter((key) => key !== record.key));
} else {
setExpandedRowKeys((expandedRowKeys) => [...expandedRowKeys, record.key]);
}
};
const handleRowClick = (record: EndPointsTableRowData): void => {
if (groupBy.length === 0) {
setSelectedEndPointName(record.endpointName); // this will open up the endpoint details tab
setSelectedView(VIEW_TYPES.ENDPOINT_DETAILS);
} else {
handleGroupByRowClick(record); // this will prepare the nested query payload
}
};
const formattedEndPointsData = useMemo(
() =>
formatEndPointsDataForTable(
allEndPointsData?.payload?.data?.result[0]?.table?.rows,
groupBy,
),
[groupBy, allEndPointsData],
);
if (isError) {
return (
<div className="all-endpoints-error-state-wrapper">
<ErrorState refetch={refetch} />
</div>
);
}
return (
<div className="all-endpoints-container">
<div className="group-by-container">
<div className="group-by-label"> Group by </div>
<Select
className="group-by-select"
loading={isLoadingGroupByFilters}
mode="multiple"
value={groupBy}
allowClear
maxTagCount="responsive"
placeholder="Search for attribute"
options={groupByOptions}
onChange={handleGroupByChange}
/>{' '}
</div>
<div className="endpoints-table-container">
<div className="endpoints-table-header">Endpoint overview</div>
<Table
columns={endPointsColumnsConfig}
loading={{
spinning: isLoading || isRefetching,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
dataSource={isLoading || isRefetching ? [] : formattedEndPointsData}
locale={{
emptyText:
isLoading || isRefetching ? null : (
<div className="no-filtered-endpoints-message-container">
<div className="no-filtered-endpoints-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Typography.Text className="no-filtered-endpoints-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>
),
}}
scroll={{ x: true }}
tableLayout="fixed"
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className: 'clickable-row',
})}
expandable={{
expandedRowRender: groupBy.length > 0 ? expandedRowRender : undefined,
expandedRowKeys,
expandIconColumnIndex: -1,
}}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
}
/>
</div>
</div>
);
}
export default AllEndPoints;

View File

@@ -1,143 +0,0 @@
import './DomainDetails.styles.scss';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Button, Divider, Drawer, Radio, Typography } from 'antd';
import { RadioChangeEvent } from 'antd/lib';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { ArrowDown, ArrowUp, X } from 'lucide-react';
import { useState } from 'react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import AllEndPoints from './AllEndPoints';
import DomainMetrics from './components/DomainMetrics';
import { VIEW_TYPES, VIEWS } from './constants';
import EndPointDetailsWrapper from './EndPointDetailsWrapper';
function DomainDetails({
domainData,
handleClose,
selectedDomainIndex,
setSelectedDomainIndex,
domainListLength,
}: {
domainData: any;
handleClose: () => void;
selectedDomainIndex: number;
setSelectedDomainIndex: (index: number) => void;
domainListLength: number;
}): JSX.Element {
const [selectedView, setSelectedView] = useState<VIEWS>(VIEWS.ALL_ENDPOINTS);
const [selectedEndPointName, setSelectedEndPointName] = useState<string>('');
const [endPointsGroupBy, setEndPointsGroupBy] = useState<
IBuilderQuery['groupBy']
>([]);
const isDarkMode = useIsDarkMode();
const handleTabChange = (e: RadioChangeEvent): void => {
setSelectedView(e.target.value);
};
return (
<Drawer
width="60%"
title={
<div className="domain-details-drawer-header">
<div className="domain-details-drawer-header-title">
<Divider type="vertical" />
<Typography.Text className="title">
{domainData.domainName}
</Typography.Text>
</div>
<Button.Group className="domain-details-drawer-header-ctas">
<Button
className="domain-navigate-cta"
onClick={(): void => {
setSelectedDomainIndex(selectedDomainIndex - 1);
setSelectedEndPointName('');
setEndPointsGroupBy([]);
setSelectedView(VIEW_TYPES.ALL_ENDPOINTS);
}}
icon={<ArrowUp size={16} />}
disabled={selectedDomainIndex === 0}
title="Previous domain"
/>
<Button
className="domain-navigate-cta"
onClick={(): void => {
setSelectedDomainIndex(selectedDomainIndex + 1);
setSelectedEndPointName('');
setEndPointsGroupBy([]);
setSelectedView(VIEW_TYPES.ALL_ENDPOINTS);
}}
icon={<ArrowDown size={16} />}
disabled={selectedDomainIndex === domainListLength - 1}
title="Next domain"
/>
</Button.Group>
</div>
}
placement="right"
onClose={handleClose}
open={!!domainData}
style={{
overscrollBehavior: 'contain',
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
}}
className="domain-detail-drawer"
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
{domainData && (
<>
<DomainMetrics domainData={domainData} />
<div className="views-tabs-container">
<Radio.Group
className="views-tabs"
onChange={handleTabChange}
value={selectedView}
>
<Radio.Button
className={
// eslint-disable-next-line sonarjs/no-duplicate-string
selectedView === VIEW_TYPES.ALL_ENDPOINTS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.ALL_ENDPOINTS}
>
<div className="view-title">All Endpoints</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.ENDPOINT_DETAILS
? 'tab selected_view'
: 'tab'
}
value={VIEW_TYPES.ENDPOINT_DETAILS}
>
<div className="view-title">Endpoint Details</div>
</Radio.Button>
</Radio.Group>
</div>
{selectedView === VIEW_TYPES.ALL_ENDPOINTS && (
<AllEndPoints
domainName={domainData.domainName}
setSelectedEndPointName={setSelectedEndPointName}
setSelectedView={setSelectedView}
groupBy={endPointsGroupBy}
setGroupBy={setEndPointsGroupBy}
/>
)}
{selectedView === VIEW_TYPES.ENDPOINT_DETAILS && (
<EndPointDetailsWrapper
domainName={domainData.domainName}
endPointName={selectedEndPointName}
setSelectedEndPointName={setSelectedEndPointName}
/>
)}
</>
)}
</Drawer>
);
}
export default DomainDetails;

View File

@@ -1,171 +0,0 @@
import { ENTITY_VERSION_V4 } from 'constants/app';
import { initialQueriesMap } from 'constants/queryBuilder';
import {
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
getEndPointDetailsQueryPayload,
} from 'container/ApiMonitoring/utils';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { useMemo, useState } from 'react';
import { useQueries } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import DependentServices from './components/DependentServices';
import EndPointMetrics from './components/EndPointMetrics';
import EndPointsDropDown from './components/EndPointsDropDown';
import MetricOverTimeGraph from './components/MetricOverTimeGraph';
import StatusCodeBarCharts from './components/StatusCodeBarCharts';
import StatusCodeTable from './components/StatusCodeTable';
function EndPointDetails({
domainName,
endPointName,
setSelectedEndPointName,
}: {
domainName: string;
endPointName: string;
setSelectedEndPointName: (value: string) => void;
}): JSX.Element {
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const currentQuery = initialQueriesMap[DataSource.TRACES];
const [filters, setFilters] = useState<IBuilderQuery['filters']>({
op: 'AND',
items: [],
});
// Manually update the query to include the filters
// Because using the hook is causing the global domain
// query to be updated and causing main domain list to
// refetch with the filters of endpoints
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.TRACES,
filters,
},
],
},
}),
[filters, currentQuery],
);
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
const isServicesFilterApplied = useMemo(
() => filters.items.some((item) => item.key?.key === 'service.name'),
[filters],
);
const endPointDetailsQueryPayload = useMemo(
() =>
getEndPointDetailsQueryPayload(
domainName,
endPointName,
Math.floor(minTime / 1e9),
Math.floor(maxTime / 1e9),
filters,
),
[domainName, endPointName, filters, minTime, maxTime],
);
const endPointDetailsDataQueries = useQueries(
endPointDetailsQueryPayload.map((payload, index) => ({
queryKey: [
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index],
payload,
filters.items,
ENTITY_VERSION_V4,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
enabled: !!payload,
})),
);
const [
endPointMetricsDataQuery,
endPointStatusCodeDataQuery,
endPointRateOverTimeDataQuery,
endPointLatencyOverTimeDataQuery,
endPointDropDownDataQuery,
endPointDependentServicesDataQuery,
endPointStatusCodeBarChartsDataQuery,
endPointStatusCodeLatencyBarChartsDataQuery,
] = useMemo(
() => [
endPointDetailsDataQueries[0],
endPointDetailsDataQueries[1],
endPointDetailsDataQueries[2],
endPointDetailsDataQueries[3],
endPointDetailsDataQueries[4],
endPointDetailsDataQueries[5],
endPointDetailsDataQueries[6],
endPointDetailsDataQueries[7],
],
[endPointDetailsDataQueries],
);
return (
<div className="endpoint-details-container">
<div className="endpoint-details-filters-container">
<div className="endpoint-details-filters-container-dropdown">
<EndPointsDropDown
selectedEndPointName={endPointName}
setSelectedEndPointName={setSelectedEndPointName}
endPointDropDownDataQuery={endPointDropDownDataQuery}
/>
</div>
<div className="endpoint-details-filters-container-search">
<QueryBuilderSearchV2
query={query}
onChange={(searchFilters): void => {
setFilters(searchFilters);
}}
placeholder="Search for filters..."
/>
</div>
</div>
<EndPointMetrics endPointMetricsDataQuery={endPointMetricsDataQuery} />
{!isServicesFilterApplied && (
<DependentServices
dependentServicesQuery={endPointDependentServicesDataQuery}
/>
)}
<StatusCodeBarCharts
endPointStatusCodeBarChartsDataQuery={endPointStatusCodeBarChartsDataQuery}
endPointStatusCodeLatencyBarChartsDataQuery={
endPointStatusCodeLatencyBarChartsDataQuery
}
/>
<StatusCodeTable endPointStatusCodeDataQuery={endPointStatusCodeDataQuery} />
<MetricOverTimeGraph
metricOverTimeDataQuery={endPointRateOverTimeDataQuery}
widgetInfoIndex={0}
endPointName={endPointName}
/>
<MetricOverTimeGraph
metricOverTimeDataQuery={endPointLatencyOverTimeDataQuery}
widgetInfoIndex={1}
endPointName={endPointName}
/>
</div>
);
}
export default EndPointDetails;

View File

@@ -1,76 +0,0 @@
import { ENTITY_VERSION_V4 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { getEndPointZeroStateQueryPayload } from 'container/ApiMonitoring/utils';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { useMemo } from 'react';
import { useQueries } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { GlobalReducer } from 'types/reducer/globalTime';
import EndPointDetailsZeroState from './components/EndPointDetailsZeroState';
import EndPointDetails from './EndPointDetails';
function EndPointDetailsWrapper({
domainName,
endPointName,
setSelectedEndPointName,
}: {
domainName: string;
endPointName: string;
setSelectedEndPointName: (value: string) => void;
}): JSX.Element {
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const endPointZeroStateQueryPayload = useMemo(
() =>
getEndPointZeroStateQueryPayload(
domainName,
Math.floor(minTime / 1e9),
Math.floor(maxTime / 1e9),
),
[domainName, minTime, maxTime],
);
const endPointZeroStateDataQueries = useQueries(
endPointZeroStateQueryPayload.map((payload) => ({
queryKey: [
// Since only one query here
REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA,
payload,
ENTITY_VERSION_V4,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
enabled: !!payload,
})),
);
const [endPointZeroStateDataQuery] = useMemo(
() => [endPointZeroStateDataQueries[0]],
[endPointZeroStateDataQueries],
);
if (endPointName === '') {
return (
<EndPointDetailsZeroState
setSelectedEndPointName={setSelectedEndPointName}
endPointDropDownDataQuery={endPointZeroStateDataQuery}
/>
);
}
return (
<EndPointDetails
domainName={domainName}
endPointName={endPointName}
setSelectedEndPointName={setSelectedEndPointName}
/>
);
}
export default EndPointDetailsWrapper;

View File

@@ -1,108 +0,0 @@
import { Typography } from 'antd';
import Skeleton from 'antd/lib/skeleton';
import { getFormattedDependentServicesData } from 'container/ApiMonitoring/utils';
import { UnfoldVertical } from 'lucide-react';
import { useMemo, useState } from 'react';
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
import ErrorState from './ErrorState';
interface DependentServicesProps {
dependentServicesQuery: UseQueryResult<SuccessResponse<any>, unknown>;
}
function DependentServices({
dependentServicesQuery,
}: DependentServicesProps): JSX.Element {
const {
data,
refetch,
isError,
isLoading,
isRefetching,
} = dependentServicesQuery;
const [currentRenderCount, setCurrentRenderCount] = useState(0);
const dependentServicesData = useMemo(() => {
const formattedDependentServicesData = getFormattedDependentServicesData(
data?.payload?.data?.result[0].table.rows,
);
setCurrentRenderCount(Math.min(formattedDependentServicesData.length, 5));
return formattedDependentServicesData;
}, [data]);
const renderItems = useMemo(
() => dependentServicesData.slice(0, currentRenderCount),
[currentRenderCount, dependentServicesData],
);
if (isLoading || isRefetching) {
return <Skeleton />;
}
if (isError) {
return <ErrorState refetch={refetch} />;
}
return (
<div className="top-services-content">
<div className="top-services-title">
<span className="title-wrapper">Dependent Services</span>
</div>
<div className="dependent-services-container">
{renderItems.length === 0 ? (
<div className="no-dependent-services-message-container">
<div className="no-dependent-services-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Typography.Text className="no-dependent-services-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>
) : (
renderItems.map((item) => (
<div className="top-services-item" key={item.key}>
<div className="top-services-item-progress">
<div className="top-services-item-key">{item.serviceName}</div>
<div className="top-services-item-count">{item.count}</div>
<div
className="top-services-item-progress-bar"
style={{ width: `${item.percentage}%` }}
/>
</div>
<div className="top-services-item-percentage">
{item.percentage.toFixed(2)}%
</div>
</div>
))
)}
{currentRenderCount < dependentServicesData.length && (
<div
className="top-services-load-more"
onClick={(): void => setCurrentRenderCount(dependentServicesData.length)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
setCurrentRenderCount(dependentServicesData.length);
}
}}
role="button"
tabIndex={0}
>
<UnfoldVertical size={14} />
Show more...
</div>
)}
</div>
</div>
);
}
export default DependentServices;

View File

@@ -1,82 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { Progress, Tooltip, Typography } from 'antd';
import { getLastUsedRelativeTime } from 'container/ApiMonitoring/utils';
function DomainMetrics({ domainData }: { domainData: any }): JSX.Element {
return (
<div className="domain-detail-drawer__endpoint">
<div className="domain-details-grid">
<div className="labels-row">
<Typography.Text
type="secondary"
className="domain-details-metadata-label"
>
EXTERNAL API
</Typography.Text>
<Typography.Text
type="secondary"
className="domain-details-metadata-label"
>
AVERAGE LATENCY
</Typography.Text>
<Typography.Text
type="secondary"
className="domain-details-metadata-label"
>
ERROR RATE
</Typography.Text>
<Typography.Text
type="secondary"
className="domain-details-metadata-label"
>
LAST USED
</Typography.Text>
</div>
<div className="values-row">
<Typography.Text className="domain-details-metadata-value">
<Tooltip title={domainData.endpointCount}>
<span className="round-metric-tag">{domainData.endpointCount}</span>
</Tooltip>
</Typography.Text>
{/* // update the tooltip as well */}
<Typography.Text className="domain-details-metadata-value">
<Tooltip title={domainData.latency}>
<span className="round-metric-tag">
{(domainData.latency / 1000).toFixed(3)}s
</span>
</Tooltip>
</Typography.Text>
{/* // update the tooltip as well */}
<Typography.Text className="domain-details-metadata-value error-rate">
<Tooltip title={domainData.errorRate}>
<Progress
status="active"
percent={Number((domainData.errorRate * 100).toFixed(1))}
strokeLinecap="butt"
size="small"
strokeColor={((): string => {
const errorRatePercent = Number(
(domainData.errorRate * 100).toFixed(1),
);
if (errorRatePercent >= 90) return Color.BG_SAKURA_500;
if (errorRatePercent >= 60) return Color.BG_AMBER_500;
return Color.BG_FOREST_500;
})()}
className="progress-bar"
/>
</Tooltip>
</Typography.Text>
{/* // update the tooltip as well */}
<Typography.Text className="domain-details-metadata-value">
<Tooltip title={domainData.lastUsed}>
{getLastUsedRelativeTime(domainData.lastUsed)}
</Tooltip>
</Typography.Text>
</div>
</div>
</div>
);
}
export default DomainMetrics;

View File

@@ -1,38 +0,0 @@
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
import EndPointsDropDown from './EndPointsDropDown';
function EndPointDetailsZeroState({
setSelectedEndPointName,
endPointDropDownDataQuery,
}: {
setSelectedEndPointName: (endPointName: string) => void;
endPointDropDownDataQuery: UseQueryResult<SuccessResponse<any>>;
}): JSX.Element {
return (
<div className="end-point-details-zero-state-wrapper">
<div className="end-point-details-zero-state-content">
<img
src="/Icons/no-data.svg"
alt="no-data"
width={32}
height={32}
className="end-point-details-zero-state-icon"
/>
<div className="end-point-details-zero-state-content-wrapper">
<div className="end-point-details-zero-state-text-content">
<div className="title">No endpoint selected yet</div>
<div className="description">Select an endpoint to see the details</div>
</div>
<EndPointsDropDown
setSelectedEndPointName={setSelectedEndPointName}
endPointDropDownDataQuery={endPointDropDownDataQuery}
/>
</div>
</div>
</div>
);
}
export default EndPointDetailsZeroState;

View File

@@ -1,121 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { Progress, Skeleton, Tooltip, Typography } from 'antd';
import { getFormattedEndPointMetricsData } from 'container/ApiMonitoring/utils';
import { useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
import ErrorState from './ErrorState';
function EndPointMetrics({
endPointMetricsDataQuery,
}: {
endPointMetricsDataQuery: UseQueryResult<SuccessResponse<any>, unknown>;
}): JSX.Element {
const {
isLoading,
isRefetching,
isError,
data,
refetch,
} = endPointMetricsDataQuery;
const metricsData = useMemo(() => {
if (isLoading || isRefetching || isError) {
return null;
}
return getFormattedEndPointMetricsData(
data?.payload?.data?.result[0].table.rows,
);
}, [data?.payload?.data?.result, isLoading, isRefetching, isError]);
if (isError) {
return <ErrorState refetch={refetch} />;
}
return (
<div className="domain-detail-drawer__endpoint">
<div className="domain-details-grid">
<div className="labels-row">
<Typography.Text
type="secondary"
className="domain-details-metadata-label"
>
Rate
</Typography.Text>
<Typography.Text
type="secondary"
className="domain-details-metadata-label"
>
AVERAGE LATENCY
</Typography.Text>
<Typography.Text
type="secondary"
className="domain-details-metadata-label"
>
ERROR RATE
</Typography.Text>
<Typography.Text
type="secondary"
className="domain-details-metadata-label"
>
LAST USED
</Typography.Text>
</div>
<div className="values-row">
<Typography.Text className="domain-details-metadata-value">
{isLoading || isRefetching ? (
<Skeleton.Button active size="small" />
) : (
<Tooltip title={metricsData?.rate}>
<span className="round-metric-tag">{metricsData?.rate}/sec</span>
</Tooltip>
)}
</Typography.Text>
<Typography.Text className="domain-details-metadata-value">
{isLoading || isRefetching ? (
<Skeleton.Button active size="small" />
) : (
<Tooltip title={metricsData?.latency}>
<span className="round-metric-tag">{metricsData?.latency}ms</span>
</Tooltip>
)}
</Typography.Text>
<Typography.Text className="domain-details-metadata-value error-rate">
{isLoading || isRefetching ? (
<Skeleton.Button active size="small" />
) : (
<Tooltip title={metricsData?.errorRate}>
<Progress
percent={Number((metricsData?.errorRate ?? 0 * 100).toFixed(1))}
strokeLinecap="butt"
size="small"
strokeColor={((): string => {
const errorRatePercent = Number(
(metricsData?.errorRate ?? 0 * 100).toFixed(1),
);
if (errorRatePercent >= 90) return Color.BG_SAKURA_500;
if (errorRatePercent >= 60) return Color.BG_AMBER_500;
return Color.BG_FOREST_500;
})()}
className="progress-bar"
/>
</Tooltip>
)}
</Typography.Text>
<Typography.Text className="domain-details-metadata-value">
{isLoading || isRefetching ? (
<Skeleton.Button active size="small" />
) : (
<Tooltip title={metricsData?.lastUsed}>{metricsData?.lastUsed}</Tooltip>
)}
</Typography.Text>
</div>
</div>
</div>
);
}
export default EndPointMetrics;

View File

@@ -1,48 +0,0 @@
import { Select } from 'antd';
import { getFormattedEndPointDropDownData } from 'container/ApiMonitoring/utils';
import { useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
interface EndPointsDropDownProps {
selectedEndPointName?: string;
setSelectedEndPointName: (value: string) => void;
endPointDropDownDataQuery: UseQueryResult<SuccessResponse<any>, unknown>;
}
const defaultProps = {
selectedEndPointName: '',
};
function EndPointsDropDown({
selectedEndPointName,
setSelectedEndPointName,
endPointDropDownDataQuery,
}: EndPointsDropDownProps): JSX.Element {
const { data, isLoading, isFetching } = endPointDropDownDataQuery;
const handleChange = (value: string): void => {
setSelectedEndPointName(value);
};
const formattedData = useMemo(
() =>
getFormattedEndPointDropDownData(data?.payload.data.result[0].table.rows),
[data?.payload.data.result],
);
return (
<Select
value={selectedEndPointName || undefined}
placeholder="Select endpoint"
loading={isLoading || isFetching}
style={{ width: '100%' }}
onChange={handleChange}
options={formattedData}
/>
);
}
EndPointsDropDown.defaultProps = defaultProps;
export default EndPointsDropDown;

View File

@@ -1,31 +0,0 @@
import { Button, Typography } from 'antd';
import { RotateCw } from 'lucide-react';
function ErrorState({ refetch }: { refetch: () => void }): JSX.Element {
return (
<div className="error-state-container">
<div className="error-state-content-wrapper">
<div className="error-state-content">
<div className="icon">
<img src="/Icons/awwSnap.svg" alt="awwSnap" width={32} height={32} />
</div>
<div className="error-state-text">
<Typography.Text>Uh-oh :/ We ran into an error.</Typography.Text>
<Typography.Text type="secondary">
Please refresh this panel.
</Typography.Text>
</div>
</div>
<Button
className="refresh-cta"
onClick={(): void => refetch()}
icon={<RotateCw size={16} />}
>
Refresh this panel
</Button>
</div>
</div>
);
}
export default ErrorState;

View File

@@ -1,127 +0,0 @@
import { LoadingOutlined } from '@ant-design/icons';
import { Spin, Table } from 'antd';
import { ColumnType } from 'antd/lib/table';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
createFiltersForSelectedRowData,
EndPointsTableRowData,
formatEndPointsDataForTable,
getEndPointsColumnsConfig,
getEndPointsQueryPayload,
} from 'container/ApiMonitoring/utils';
import LoadingContainer from 'container/InfraMonitoringK8s/LoadingContainer';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { useMemo } from 'react';
import { useQueries } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { GlobalReducer } from 'types/reducer/globalTime';
import { VIEW_TYPES, VIEWS } from '../constants';
function ExpandedRow({
domainName,
selectedRowData,
setSelectedEndPointName,
setSelectedView,
}: {
domainName: string;
selectedRowData: EndPointsTableRowData;
setSelectedEndPointName: (name: string) => void;
setSelectedView: (view: VIEWS) => void;
}): JSX.Element {
const nestedColumns = useMemo(() => getEndPointsColumnsConfig(false, []), []);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const groupedByRowDataQueryPayload = useMemo(() => {
if (!selectedRowData) return null;
const filters = createFiltersForSelectedRowData(selectedRowData);
const baseQueryPayload = getEndPointsQueryPayload(
[],
domainName,
Math.floor(minTime / 1e9),
Math.floor(maxTime / 1e9),
);
return baseQueryPayload.map((currentQueryPayload) => ({
...currentQueryPayload,
query: {
...currentQueryPayload.query,
builder: {
...currentQueryPayload.query.builder,
queryData: currentQueryPayload.query.builder.queryData.map(
(queryData) => ({
...queryData,
filters: {
items: [...(queryData.filters?.items || []), ...filters.items],
op: 'AND',
},
}),
),
},
},
}));
}, [domainName, minTime, maxTime, selectedRowData]);
const groupedByRowQueries = useQueries(
groupedByRowDataQueryPayload
? groupedByRowDataQueryPayload.map((payload) => ({
queryKey: [
`${REACT_QUERY_KEY.GET_NESTED_ENDPOINTS_LIST}-${domainName}-${selectedRowData?.key}`,
payload,
ENTITY_VERSION_V4,
selectedRowData?.key,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
enabled: !!payload && !!selectedRowData,
}))
: [],
);
const groupedByRowQuery = groupedByRowQueries[0];
return (
<div className="expanded-table-container">
{groupedByRowQuery?.isFetching || groupedByRowQuery?.isLoading ? (
<LoadingContainer />
) : (
<div className="expanded-table">
<Table
columns={nestedColumns as ColumnType<EndPointsTableRowData>[]}
dataSource={
groupedByRowQuery?.data
? formatEndPointsDataForTable(
groupedByRowQuery.data?.payload.data.result[0].table?.rows,
[],
)
: []
}
pagination={false}
scroll={{ x: true }}
tableLayout="fixed"
showHeader={false}
loading={{
spinning: groupedByRowQuery?.isFetching || groupedByRowQuery?.isLoading,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
setSelectedEndPointName(record.endpointName);
setSelectedView(VIEW_TYPES.ENDPOINT_DETAILS);
},
className: 'expanded-clickable-row',
})}
/>
</div>
)}
</div>
);
}
export default ExpandedRow;

View File

@@ -1,114 +0,0 @@
import { Card, Skeleton, Typography } from 'antd';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
apiWidgetInfo,
extractPortAndEndpoint,
getFormattedChartData,
} from 'container/ApiMonitoring/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { useCallback, useMemo, useRef } from 'react';
import { UseQueryResult } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { GlobalReducer } from 'types/reducer/globalTime';
import { Options } from 'uplot';
import ErrorState from './ErrorState';
function MetricOverTimeGraph({
metricOverTimeDataQuery,
widgetInfoIndex,
endPointName,
}: {
metricOverTimeDataQuery: UseQueryResult<SuccessResponse<any>, unknown>;
widgetInfoIndex: number;
endPointName: string;
}): JSX.Element {
const { data } = metricOverTimeDataQuery;
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
const { endpoint } = extractPortAndEndpoint(endPointName);
const formattedChartData = useMemo(
() => getFormattedChartData(data?.payload, [endpoint]),
[data?.payload, endpoint],
);
const chartData = useMemo(() => getUPlotChartData(formattedChartData), [
formattedChartData,
]);
const isDarkMode = useIsDarkMode();
const options = useMemo(
() =>
getUPlotChartOptions({
apiResponse: formattedChartData,
isDarkMode,
dimensions,
yAxisUnit: apiWidgetInfo[widgetInfoIndex].yAxisUnit,
softMax: null,
softMin: null,
minTimeScale: Math.floor(minTime / 1e9),
maxTimeScale: Math.floor(maxTime / 1e9),
panelType: PANEL_TYPES.TIME_SERIES,
}),
[
formattedChartData,
minTime,
maxTime,
widgetInfoIndex,
dimensions,
isDarkMode,
],
);
const renderCardContent = useCallback(
(query: UseQueryResult<SuccessResponse<any>, unknown>): JSX.Element => {
if (query.isLoading) {
return <Skeleton />;
}
if (query.error) {
return <ErrorState refetch={query.refetch} />;
}
return (
<div
className={cx('chart-container', {
'no-data-container':
!query.isLoading && !query?.data?.payload?.data?.result?.length,
})}
>
<Uplot options={options as Options} data={chartData} />
</div>
);
},
[options, chartData],
);
return (
<div>
<Card bordered className="endpoint-details-card">
<Typography.Text>{apiWidgetInfo[widgetInfoIndex].title}</Typography.Text>
<div className="graph-container" ref={graphRef}>
{renderCardContent(metricOverTimeDataQuery)}
</div>
</Card>
</div>
);
}
export default MetricOverTimeGraph;

View File

@@ -1,168 +0,0 @@
import { Button, Card, Skeleton, Typography } from 'antd';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
getFormattedEndPointStatusCodeChartData,
statusCodeWidgetInfo,
} from 'container/ApiMonitoring/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { useCallback, useMemo, useRef, useState } from 'react';
import { UseQueryResult } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { GlobalReducer } from 'types/reducer/globalTime';
import { Options } from 'uplot';
import ErrorState from './ErrorState';
function StatusCodeBarCharts({
endPointStatusCodeBarChartsDataQuery,
endPointStatusCodeLatencyBarChartsDataQuery,
}: {
endPointStatusCodeBarChartsDataQuery: UseQueryResult<
SuccessResponse<any>,
unknown
>;
endPointStatusCodeLatencyBarChartsDataQuery: UseQueryResult<
SuccessResponse<any>,
unknown
>;
}): JSX.Element {
// 0 : Status Code Count
// 1 : Status Code Latency
const [currentWidgetInfoIndex, setCurrentWidgetInfoIndex] = useState(0);
const {
data: endPointStatusCodeBarChartsData,
} = endPointStatusCodeBarChartsDataQuery;
const {
data: endPointStatusCodeLatencyBarChartsData,
} = endPointStatusCodeLatencyBarChartsDataQuery;
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
const formattedEndPointStatusCodeBarChartsDataPayload = useMemo(
() =>
getFormattedEndPointStatusCodeChartData(
endPointStatusCodeBarChartsData?.payload,
'sum',
),
[endPointStatusCodeBarChartsData?.payload],
);
const formattedEndPointStatusCodeLatencyBarChartsDataPayload = useMemo(
() =>
getFormattedEndPointStatusCodeChartData(
endPointStatusCodeLatencyBarChartsData?.payload,
'average',
),
[endPointStatusCodeLatencyBarChartsData?.payload],
);
const chartData = useMemo(
() =>
getUPlotChartData(
currentWidgetInfoIndex === 0
? formattedEndPointStatusCodeBarChartsDataPayload
: formattedEndPointStatusCodeLatencyBarChartsDataPayload,
),
[
currentWidgetInfoIndex,
formattedEndPointStatusCodeBarChartsDataPayload,
formattedEndPointStatusCodeLatencyBarChartsDataPayload,
],
);
const isDarkMode = useIsDarkMode();
const options = useMemo(
() =>
getUPlotChartOptions({
apiResponse:
currentWidgetInfoIndex === 0
? formattedEndPointStatusCodeBarChartsDataPayload
: formattedEndPointStatusCodeLatencyBarChartsDataPayload,
isDarkMode,
dimensions,
yAxisUnit: statusCodeWidgetInfo[currentWidgetInfoIndex].yAxisUnit,
softMax: null,
softMin: null,
minTimeScale: Math.floor(minTime / 1e9),
maxTimeScale: Math.floor(maxTime / 1e9),
panelType: PANEL_TYPES.BAR,
}),
[
minTime,
maxTime,
currentWidgetInfoIndex,
dimensions,
formattedEndPointStatusCodeBarChartsDataPayload,
formattedEndPointStatusCodeLatencyBarChartsDataPayload,
isDarkMode,
],
);
const renderCardContent = useCallback(
(query: UseQueryResult<SuccessResponse<any>, unknown>): JSX.Element => {
if (query.isLoading) {
return <Skeleton />;
}
if (query.error) {
return <ErrorState refetch={query.refetch} />;
}
return (
<div
className={cx('chart-container', {
'no-data-container':
!query.isLoading && !query?.data?.payload?.data?.result?.length,
})}
>
<Uplot options={options as Options} data={chartData} />
</div>
);
},
[options, chartData],
);
return (
<div>
<Card bordered className="endpoint-details-card">
<div className="header">
<Typography.Text>Call response status</Typography.Text>
<Button.Group className="views-tabs">
<Button
value={0}
className={currentWidgetInfoIndex === 0 ? 'selected_view tab' : 'tab'}
disabled={false}
onClick={(): void => setCurrentWidgetInfoIndex(0)}
>
Number of calls
</Button>
<Button
value={1}
className={currentWidgetInfoIndex === 1 ? 'selected_view tab' : 'tab'}
onClick={(): void => setCurrentWidgetInfoIndex(1)}
>
Latency
</Button>
</Button.Group>
</div>
<div className="graph-container" ref={graphRef}>
{renderCardContent(endPointStatusCodeBarChartsDataQuery)}
</div>
</Card>
</div>
);
}
export default StatusCodeBarCharts;

View File

@@ -1,72 +0,0 @@
import { Table, Typography } from 'antd';
import {
endPointStatusCodeColumns,
getFormattedEndPointStatusCodeData,
} from 'container/ApiMonitoring/utils';
import { useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
import ErrorState from './ErrorState';
function StatusCodeTable({
endPointStatusCodeDataQuery,
}: {
endPointStatusCodeDataQuery: UseQueryResult<SuccessResponse<any>, unknown>;
}): JSX.Element {
const {
isLoading,
isRefetching,
isError,
data,
refetch,
} = endPointStatusCodeDataQuery;
const statusCodeData = useMemo(() => {
if (isLoading || isRefetching || isError) {
return [];
}
return getFormattedEndPointStatusCodeData(
data?.payload?.data?.result[0].table.rows,
);
}, [data?.payload?.data?.result, isLoading, isRefetching, isError]);
if (isError) {
return <ErrorState refetch={refetch} />;
}
return (
<div className="status-code-table-container">
<Table
loading={isLoading || isRefetching}
dataSource={statusCodeData || []}
columns={endPointStatusCodeColumns}
pagination={false}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
}
locale={{
emptyText:
isLoading || isRefetching ? null : (
<div className="no-status-code-data-message-container">
<div className="no-status-code-data-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Typography.Text className="no-status-code-data-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>
),
}}
/>
</div>
);
}
export default StatusCodeTable;

View File

@@ -1,9 +0,0 @@
export enum VIEWS {
ALL_ENDPOINTS = 'all_endpoints',
ENDPOINT_DETAILS = 'endpoint_details',
}
export const VIEW_TYPES = {
ALL_ENDPOINTS: VIEWS.ALL_ENDPOINTS,
ENDPOINT_DETAILS: VIEWS.ENDPOINT_DETAILS,
};

View File

@@ -1,156 +0,0 @@
import '../Explorer.styles.scss';
import { LoadingOutlined } from '@ant-design/icons';
import { Spin, Table, Typography } from 'antd';
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import cx from 'classnames';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { HandleChangeQueryData } from 'types/common/operations.types';
import { GlobalReducer } from 'types/reducer/globalTime';
import {
columnsConfig,
formatDataForTable,
hardcodedAttributeKeys,
} from '../../utils';
import DomainDetails from './DomainDetails/DomainDetails';
function DomainList({
query,
showIP,
handleChangeQueryData,
}: {
query: IBuilderQuery;
showIP: boolean;
handleChangeQueryData: HandleChangeQueryData;
}): JSX.Element {
const [selectedDomainIndex, setSelectedDomainIndex] = useState<number>(-1);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const fetchApiOverview = async (): Promise<
SuccessResponse<any> | ErrorResponse
> => {
const requestBody = {
start: minTime,
end: maxTime,
show_ip: showIP,
filters: {
op: 'AND',
items: query?.filters.items,
},
};
try {
const response = await axios.post(
'/third-party-apis/overview/list',
requestBody,
);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
const { data, isLoading, isFetching } = useQuery(
[REACT_QUERY_KEY.GET_DOMAINS_LIST, minTime, maxTime, query, showIP],
fetchApiOverview,
);
const formattedDataForTable = useMemo(
() => formatDataForTable(data?.payload?.data?.result[0]?.table?.rows),
[data],
);
return (
<section className={cx('api-module-right-section')}>
<div className={cx('api-monitoring-list-header')}>
<QueryBuilderSearchV2
query={query}
onChange={(searchFilters): void =>
handleChangeQueryData('filters', searchFilters)
}
placeholder="Search filters..."
hardcodedAttributeKeys={hardcodedAttributeKeys}
/>
<DateTimeSelectionV2
showAutoRefresh={false}
showRefreshText={false}
hideShareModal
/>
</div>
<Table
className={cx('api-monitoring-domain-list-table')}
dataSource={isFetching || isLoading ? [] : formattedDataForTable}
columns={columnsConfig}
loading={{
spinning: isFetching || isLoading,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
locale={{
emptyText:
isFetching || isLoading ? null : (
<div className="no-filtered-domains-message-container">
<div className="no-filtered-domains-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Typography.Text className="no-filtered-domains-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>
),
}}
scroll={{ x: true }}
tableLayout="fixed"
onRow={(record, index): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (index !== undefined) {
const dataIndex = formattedDataForTable.findIndex(
(item) => item.key === record.key,
);
setSelectedDomainIndex(dataIndex);
}
},
className: 'expanded-clickable-row',
})}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
}
/>
{selectedDomainIndex !== -1 && (
<DomainDetails
domainData={formattedDataForTable[selectedDomainIndex]}
selectedDomainIndex={selectedDomainIndex}
setSelectedDomainIndex={setSelectedDomainIndex}
domainListLength={formattedDataForTable.length}
handleClose={(): void => {
setSelectedDomainIndex(-1);
}}
/>
)}
</section>
);
}
export default DomainList;

View File

@@ -1,219 +0,0 @@
.api-monitoring-page {
display: flex;
height: 100%;
.api-quick-filter-left-section {
width: 0%;
flex-shrink: 0;
.api-quick-filters-header {
padding: 12px;
border-bottom: 1px solid var(--bg-slate-400);
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
line-height: 18px;
}
}
.api-module-right-section {
display: flex;
flex-direction: column;
width: 100%;
.api-monitoring-list-header {
width: 100%;
padding: 8px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.query-builder-search-v2 {
min-width: 80%;
flex: 1;
}
}
.api-monitoring-domain-list-table {
.ant-table {
.ant-table-thead > tr > th {
padding: 12px;
font-weight: 500;
font-size: 12px;
line-height: 18px;
border-bottom: none;
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px;
/* 163.636% */
letter-spacing: 0.44px;
text-transform: uppercase;
background: none;
&::before {
background-color: transparent;
}
}
.ant-table-thead > tr > th:has(.domain-list-name-col-header) {
background: var(--bg-ink-300);
opacity: 0.6;
}
.ant-table-cell {
padding: 12px;
font-size: 13px;
line-height: 20px;
color: var(--bg-vanilla-100);
border-bottom: none;
}
.ant-table-cell:has(.domain-list-name-col-value) {
background: var(--bg-ink-300);
opacity: 0.6;
}
.round-metric-tag {
display: inline-flex;
padding: 2px 8px;
align-items: center;
gap: 6px;
width: fit-content;
border-radius: 50px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-slate-500);
text-transform: lowercase;
}
.ant-table-tbody > tr:hover > td {
background: rgba(255, 255, 255, 0.04);
}
.ant-table-cell:first-child {
text-align: justify;
}
.ant-table-cell:nth-child(2) {
padding-left: 16px;
padding-right: 16px;
}
.ant-table-cell:nth-child(n + 3) {
padding-right: 24px;
}
.column-header-right {
text-align: right;
}
.ant-table-tbody > tr > td {
border-bottom: none;
}
.ant-table-thead
> tr
> th:not(:last-child):not(.ant-table-selection-column):not(.ant-table-row-expand-icon-cell):not([colspan])::before {
background-color: transparent;
}
.ant-empty-normal {
visibility: hidden;
}
.table-row-light {
background: none;
}
.table-row-dark {
background: var(--bg-ink-300);
}
.error-rate {
width: 120px;
}
}
}
&.filter-visible {
.api-quick-filter-left-section {
width: 260px;
}
.api-module-right-section {
width: calc(100% - 260px);
}
}
}
.no-filtered-domains-message-container {
height: 30vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.no-filtered-domains-message-content {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
width: fit-content;
padding: 24px;
}
.no-filtered-domains-message {
margin-top: 8px;
}
}
.lightMode {
.api-monitoring-domain-list-table {
.ant-table {
.ant-table-thead > tr > th {
background: var(--bg-vanilla-100);
color: var(--text-ink-300);
}
.ant-table-thead > tr > th:has(.domain-list-name-col-header) {
background: var(--bg-vanilla-100);
}
.ant-table-cell {
background: var(--bg-vanilla-100);
color: var(--bg-ink-500);
}
.ant-table-cell:has(.domain-list-name-col-value) {
background: var(--bg-vanilla-100);
}
.ant-table-tbody > tr:hover > td {
background: rgba(0, 0, 0, 0.04);
}
.table-row-light {
background: none;
}
.table-row-dark {
background: none;
}
.round-metric-tag {
color: var(--bg-vanilla-100);
}
}
}
}

View File

@@ -1,91 +0,0 @@
import './Explorer.styles.scss';
import { FilterOutlined } from '@ant-design/icons';
import * as Sentry from '@sentry/react';
import { Switch, Typography } from 'antd';
import cx from 'classnames';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { useMemo, useState } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { ApiMonitoringQuickFiltersConfig } from '../utils';
import DomainList from './Domains/DomainList';
function Explorer(): JSX.Element {
const [showIP, setShowIP] = useState<boolean>(true);
const { currentQuery } = useQueryBuilder();
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: currentQuery.builder.queryData[0],
entityVersion: '',
});
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.TRACES,
aggregateOperator: 'noop',
aggregateAttribute: {
...currentQuery.builder.queryData[0].aggregateAttribute,
},
},
],
},
}),
[currentQuery],
);
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className={cx('api-monitoring-page', 'filter-visible')}>
<section className="api-quick-filter-left-section">
<div className="api-quick-filters-header">
<FilterOutlined />
<Typography.Text>Filters</Typography.Text>
</div>
<div className="api-quick-filters-header">
<Typography.Text>Show IP addresses</Typography.Text>
<Switch
size="small"
style={{ marginLeft: 'auto' }}
checked={showIP}
onClick={(): void => {
setShowIP((showIP) => !showIP);
}}
/>
</div>
<QuickFilters
source={QuickFiltersSource.API_MONITORING}
config={ApiMonitoringQuickFiltersConfig}
handleFilterVisibilityChange={(): void => {}}
onFilterChange={(query: Query): void =>
handleChangeQueryData('filters', query.builder.queryData[0].filters)
}
/>
</section>
<DomainList
query={query}
showIP={showIP}
handleChangeQueryData={handleChangeQueryData}
/>
</div>
</Sentry.ErrorBoundary>
);
}
export default Explorer;

File diff suppressed because it is too large Load Diff

View File

@@ -337,8 +337,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
routeKey === 'LOGS_PIPELINES' ||
routeKey === 'LOGS_SAVE_VIEWS';
const isApiMonitoringView = (): boolean => routeKey === 'API_MONITORING';
const isTracesView = (): boolean =>
routeKey === 'TRACES_EXPLORER' || routeKey === 'TRACES_SAVE_VIEWS';
@@ -660,8 +658,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
isAlertOverview() ||
isMessagingQueues() ||
isCloudIntegrationPage() ||
isInfraMonitoring() ||
isApiMonitoringView()
isInfraMonitoring()
? 0
: '0 1rem',

View File

@@ -223,7 +223,7 @@ function ExplorerOptions({
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
const viewKey = useGetSearchQueryParam(QueryParams.viewKey) || '';
const extraData = viewsData?.data?.data?.find((view) => view.id === viewKey)
const extraData = viewsData?.data?.data?.find((view) => view.uuid === viewKey)
?.extraData;
const extraDataColor = extraData ? JSON.parse(extraData).color : '';
@@ -357,12 +357,17 @@ function ExplorerOptions({
viewsData?.data?.data,
);
if (!currentViewDetails) return;
const { query, name, id, panelType: currentPanelType } = currentViewDetails;
const {
query,
name,
uuid,
panelType: currentPanelType,
} = currentViewDetails;
handleExplorerTabChange(currentPanelType, {
query,
name,
id,
uuid,
});
},
[viewsData, handleExplorerTabChange],
@@ -689,7 +694,7 @@ function ExplorerOptions({
bgColor = extraData.color;
}
return (
<Select.Option key={view.id} value={view.name}>
<Select.Option key={view.uuid} value={view.name}>
<div className="render-options">
<span
className="dot"

View File

@@ -63,17 +63,17 @@ export default function SavedViews({
const handleRedirectQuery = (view: ViewProps): void => {
logEvent('Homepage: Saved view clicked', {
viewId: view.id,
viewId: view.uuid,
viewName: view.name,
entity: selectedEntity,
});
const currentViewDetails = getViewDetailsUsingViewKey(
view.id,
view.uuid,
selectedEntity === 'logs' ? logsViews : tracesViews,
);
if (!currentViewDetails) return;
const { query, name, id, panelType: currentPanelType } = currentViewDetails;
const { query, name, uuid, panelType: currentPanelType } = currentViewDetails;
if (selectedEntity) {
handleExplorerTabChange(
@@ -81,7 +81,7 @@ export default function SavedViews({
{
query,
name,
id,
uuid,
},
SOURCEPAGE_VS_ROUTES[selectedEntity],
);

View File

@@ -702,36 +702,29 @@
}
}
.infra-monitoring-container {
.ant-table-cell {
min-width: 140px !important;
max-width: 140px !important;
.ant-table-cell {
min-width: 140px !important;
max-width: 140px !important;
}
.ant-table-cell {
&:has(.pod-name-header) {
min-width: 250px !important;
max-width: 250px !important;
}
}
.ant-table-cell {
&:has(.pod-name-header) {
min-width: 250px !important;
max-width: 250px !important;
}
.ant-table-cell {
&:has(.med-col) {
min-width: 180px !important;
max-width: 180px !important;
}
}
.expanded-k8s-list-table {
.ant-table-cell {
&:has(.med-col) {
min-width: 180px !important;
max-width: 180px !important;
}
}
.expanded-k8s-list-table {
.ant-table-cell {
min-width: 180px !important;
max-width: 180px !important;
}
.ant-table-row-expand-icon-cell {
min-width: 30px !important;
max-width: 30px !important;
}
min-width: 180px !important;
max-width: 180px !important;
}
.ant-table-row-expand-icon-cell {
@@ -740,6 +733,11 @@
}
}
.ant-table-row-expand-icon-cell {
min-width: 30px !important;
max-width: 30px !important;
}
.event-content-container {
.ant-table {
background: var(--bg-ink-400);

View File

@@ -159,14 +159,6 @@
}
}
.log-scale {
margin-top: 16px;
display: flex;
justify-content: space-between;
flex-direction: column;
gap: 8px;
}
.panel-time-text {
margin-top: 16px;
color: var(--bg-vanilla-400);

View File

@@ -61,18 +61,6 @@ export const panelTypeVsFillSpan: { [key in PANEL_TYPES]: boolean } = {
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;
export const panelTypeVsLogScale: { [key in PANEL_TYPES]: boolean } = {
[PANEL_TYPES.TIME_SERIES]: true,
[PANEL_TYPES.VALUE]: false,
[PANEL_TYPES.TABLE]: false,
[PANEL_TYPES.LIST]: false,
[PANEL_TYPES.PIE]: false,
[PANEL_TYPES.BAR]: true,
[PANEL_TYPES.HISTOGRAM]: false,
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;
export const panelTypeVsYAxisUnit: { [key in PANEL_TYPES]: boolean } = {
[PANEL_TYPES.TIME_SERIES]: true,
[PANEL_TYPES.VALUE]: true,

View File

@@ -10,7 +10,7 @@ import GraphTypes, {
} from 'container/NewDashboard/ComponentsSlider/menuItems';
import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ConciergeBell, LineChart, Plus, Spline } from 'lucide-react';
import { ConciergeBell, Plus } from 'lucide-react';
import {
Dispatch,
SetStateAction,
@@ -27,7 +27,6 @@ import {
panelTypeVsColumnUnitPreferences,
panelTypeVsCreateAlert,
panelTypeVsFillSpan,
panelTypeVsLogScale,
panelTypeVsPanelTimePreferences,
panelTypeVsSoftMinMax,
panelTypeVsStackingChartPreferences,
@@ -42,12 +41,6 @@ import YAxisUnitSelector from './YAxisUnitSelector';
const { TextArea } = Input;
const { Option } = Select;
enum LogScale {
LINEAR = 'linear',
LOGARITHMIC = 'logarithmic',
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function RightContainer({
description,
setDescription,
@@ -78,8 +71,6 @@ function RightContainer({
setSoftMin,
columnUnits,
setColumnUnits,
isLogScale,
setIsLogScale,
}: RightContainerProps): JSX.Element {
const onChangeHandler = useCallback(
(setFunc: Dispatch<SetStateAction<string>>, value: string) => {
@@ -96,7 +87,6 @@ function RightContainer({
const allowThreshold = panelTypeVsThreshold[selectedGraph];
const allowSoftMinMax = panelTypeVsSoftMinMax[selectedGraph];
const allowFillSpans = panelTypeVsFillSpan[selectedGraph];
const allowLogScale = panelTypeVsLogScale[selectedGraph];
const allowYAxisUnit = panelTypeVsYAxisUnit[selectedGraph];
const allowCreateAlerts = panelTypeVsCreateAlert[selectedGraph];
const allowBucketConfig = panelTypeVsBucketConfig[selectedGraph];
@@ -303,36 +293,6 @@ function RightContainer({
</section>
</section>
)}
{allowLogScale && (
<section className="log-scale">
<Typography.Text className="typography">Y Axis Scale</Typography.Text>
<Select
onChange={(value): void => setIsLogScale(value === LogScale.LOGARITHMIC)}
value={isLogScale ? LogScale.LOGARITHMIC : LogScale.LINEAR}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LogScale.LINEAR}
>
<Option value={LogScale.LINEAR}>
<div className="select-option">
<div className="icon">
<LineChart size={16} />
</div>
<Typography.Text className="display">Linear</Typography.Text>
</div>
</Option>
<Option value={LogScale.LOGARITHMIC}>
<div className="select-option">
<div className="icon">
<Spline size={16} />
</div>
<Typography.Text className="display">Logarithmic</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
</section>
{allowCreateAlerts && (
@@ -396,8 +356,6 @@ interface RightContainerProps {
setColumnUnits: Dispatch<SetStateAction<ColumnUnit>>;
setSoftMin: Dispatch<SetStateAction<number | null>>;
setSoftMax: Dispatch<SetStateAction<number | null>>;
isLogScale: boolean;
setIsLogScale: Dispatch<SetStateAction<boolean>>;
}
RightContainer.defaultProps = {

View File

@@ -170,9 +170,6 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
const [isFillSpans, setIsFillSpans] = useState<boolean>(
selectedWidget?.fillSpans || false,
);
const [isLogScale, setIsLogScale] = useState<boolean>(
selectedWidget?.isLogScale || false,
);
const [saveModal, setSaveModal] = useState(false);
const [discardModal, setDiscardModal] = useState(false);
@@ -237,7 +234,6 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
mergeAllActiveQueries: combineHistogram,
selectedLogFields,
selectedTracesFields,
isLogScale,
};
});
}, [
@@ -259,7 +255,6 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
bucketCount,
combineHistogram,
stackedBarChart,
isLogScale,
]);
const closeModal = (): void => {
@@ -374,7 +369,6 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
graphType: getGraphType(selectedGraph || selectedWidget.panelTypes),
query: stagedQuery,
fillGaps: selectedWidget.fillSpans || false,
isLogScale: selectedWidget.isLogScale || false,
formatForWeb:
getGraphTypeForFormat(selectedGraph || selectedWidget.panelTypes) ===
PANEL_TYPES.TABLE,
@@ -385,7 +379,6 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
stagedQuery,
selectedTime,
selectedWidget.fillSpans,
selectedWidget.isLogScale,
globalSelectedInterval,
]);
@@ -449,7 +442,6 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
softMin: selectedWidget?.softMin || 0,
softMax: selectedWidget?.softMax || 0,
fillSpans: selectedWidget?.fillSpans,
isLogScale: selectedWidget?.isLogScale || false,
bucketWidth: selectedWidget?.bucketWidth || 0,
bucketCount: selectedWidget?.bucketCount || 0,
mergeAllActiveQueries: selectedWidget?.mergeAllActiveQueries || false,
@@ -476,7 +468,6 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
softMin: selectedWidget?.softMin || 0,
softMax: selectedWidget?.softMax || 0,
fillSpans: selectedWidget?.fillSpans,
isLogScale: selectedWidget?.isLogScale || false,
bucketWidth: selectedWidget?.bucketWidth || 0,
bucketCount: selectedWidget?.bucketCount || 0,
mergeAllActiveQueries: selectedWidget?.mergeAllActiveQueries || false,
@@ -739,8 +730,6 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
selectedWidget={selectedWidget}
isFillSpans={isFillSpans}
setIsFillSpans={setIsFillSpans}
isLogScale={isLogScale}
setIsLogScale={setIsLogScale}
softMin={softMin}
setSoftMin={setSoftMin}
softMax={softMax}

View File

@@ -137,7 +137,6 @@ function UplotPanelWrapper({
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
timezone: timezone.value,
customSeries,
isLogScale: widget?.isLogScale,
}),
[
widget?.id,
@@ -162,7 +161,6 @@ function UplotPanelWrapper({
customTooltipElement,
timezone.value,
customSeries,
widget?.isLogScale,
],
);

View File

@@ -87,7 +87,6 @@ interface QueryBuilderSearchV2Props {
placeholder?: string;
className?: string;
suffixIcon?: React.ReactNode;
hardcodedAttributeKeys?: BaseAutocompleteData[];
}
export interface Option {
@@ -120,7 +119,6 @@ function QueryBuilderSearchV2(
className,
suffixIcon,
whereClauseConfig,
hardcodedAttributeKeys,
} = props;
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
@@ -235,7 +233,7 @@ function QueryBuilderSearchV2(
},
{
queryKey: [searchParams],
enabled: isQueryEnabled && !isLogsDataSource && !hardcodedAttributeKeys,
enabled: isQueryEnabled && !isLogsDataSource,
},
);
@@ -676,18 +674,6 @@ function QueryBuilderSearchV2(
value: key,
})) || []),
]);
} else if (hardcodedAttributeKeys) {
const filteredKeys = hardcodedAttributeKeys.filter((key) =>
key.key
.toLowerCase()
.includes((searchValue?.split(' ')[0] || '').toLowerCase()),
);
setDropdownOptions(
filteredKeys.map((key) => ({
label: key.key,
value: key,
})),
);
} else {
setDropdownOptions(
data?.payload?.attributeKeys?.map((key) => ({
@@ -766,7 +752,6 @@ function QueryBuilderSearchV2(
);
}
}, [
hardcodedAttributeKeys,
attributeValues?.payload,
currentFilterItem?.key?.dataType,
currentState,
@@ -999,7 +984,6 @@ QueryBuilderSearchV2.defaultProps = {
className: '',
suffixIcon: null,
whereClauseConfig: {},
hardcodedAttributeKeys: undefined,
};
export default QueryBuilderSearchV2;

View File

@@ -3,7 +3,6 @@ import ROUTES from 'constants/routes';
import {
BarChart2,
BellDot,
Binoculars,
Boxes,
BugIcon,
Cloudy,
@@ -124,11 +123,6 @@ const menuItems: SidebarItem[] = [
label: 'Messaging Queues',
icon: <ListMinus size={16} />,
},
{
key: ROUTES.API_MONITORING,
label: 'API Monitoring',
icon: <Binoculars size={16} />,
},
{
key: ROUTES.LIST_ALL_ALERT,
label: 'Alerts',

View File

@@ -226,7 +226,6 @@ export const routesToSkip = [
ROUTES.METRICS_EXPLORER,
ROUTES.METRICS_EXPLORER_EXPLORER,
ROUTES.METRICS_EXPLORER_VIEWS,
ROUTES.API_MONITORING,
ROUTES.CHANNELS_NEW,
ROUTES.CHANNELS_EDIT,
ROUTES.WORKSPACE_ACCESS_RESTRICTED,

View File

@@ -70,7 +70,7 @@ export const useHandleExplorerTabChange = (): {
{
[QueryParams.panelTypes]: newPanelType,
[QueryParams.viewName]: currentQueryData?.name || viewName,
[QueryParams.viewKey]: currentQueryData?.id || viewKey,
[QueryParams.viewKey]: currentQueryData?.uuid || viewKey,
},
redirectToUrl,
);
@@ -78,7 +78,7 @@ export const useHandleExplorerTabChange = (): {
redirectWithQueryBuilderData(query, {
[QueryParams.panelTypes]: newPanelType,
[QueryParams.viewName]: currentQueryData?.name || viewName,
[QueryParams.viewKey]: currentQueryData?.id || viewKey,
[QueryParams.viewKey]: currentQueryData?.uuid || viewKey,
});
}
},
@@ -90,6 +90,6 @@ export const useHandleExplorerTabChange = (): {
interface ICurrentQueryData {
name: string;
id: string;
uuid: string;
query: Query;
}

View File

@@ -102,5 +102,4 @@ export interface GetQueryResultsProps {
};
start?: number;
end?: number;
step?: number;
}

View File

@@ -58,7 +58,6 @@ export interface GetUPlotChartOptions {
tzDate?: (timestamp: number) => Date;
timezone?: string;
customSeries?: (data: QueryData[]) => uPlot.Series[];
isLogScale?: boolean;
}
/** the function converts series A , series B , series C to
@@ -165,7 +164,6 @@ export const getUPlotChartOptions = ({
tzDate,
timezone,
customSeries,
isLogScale,
}: GetUPlotChartOptions): uPlot.Options => {
const timeScaleProps = getXAxisScale(minTimeScale, maxTimeScale);
@@ -222,7 +220,6 @@ export const getUPlotChartOptions = ({
softMax,
softMin,
}),
distr: isLogScale ? 3 : 1,
},
},
plugins: [
@@ -390,6 +387,6 @@ export const getUPlotChartOptions = ({
hiddenGraph,
isDarkMode,
}),
axes: getAxes({ isDarkMode, yAxisUnit, panelType, isLogScale }),
axes: getAxes({ isDarkMode, yAxisUnit, panelType }),
};
};

View File

@@ -1,51 +0,0 @@
/**
* Checks if a value is invalid for plotting
*
* @param value - The value to check
* @returns true if the value is invalid (should be replaced with null), false otherwise
*/
export function isInvalidPlotValue(value: unknown): boolean {
// Check for null or undefined
if (value === null || value === undefined) {
return true;
}
// Handle number checks
if (typeof value === 'number') {
// Check for NaN, Infinity, -Infinity
return !Number.isFinite(value);
}
// Handle string values
if (typeof value === 'string') {
// Check for string representations of infinity
if (['+Inf', '-Inf', 'Infinity', '-Infinity', 'NaN'].includes(value)) {
return true;
}
// Try to parse the string as a number
const numValue = parseFloat(value);
// If parsing failed or resulted in a non-finite number, it's invalid
if (Number.isNaN(numValue) || !Number.isFinite(numValue)) {
return true;
}
}
// Value is valid for plotting
return false;
}
export function normalizePlotValue(value: unknown): number | null {
if (isInvalidPlotValue(value)) {
return null;
}
// Convert string numbers to actual numbers
if (typeof value === 'string') {
return parseFloat(value);
}
// Already a valid number
return value as number;
}

View File

@@ -17,19 +17,16 @@ const getAxes = ({
isDarkMode,
yAxisUnit,
panelType,
isLogScale,
}: {
isDarkMode: boolean;
yAxisUnit?: string;
panelType?: PANEL_TYPES;
isLogScale?: boolean;
// eslint-disable-next-line sonarjs/cognitive-complexity
}): any => [
{
stroke: isDarkMode ? 'white' : 'black', // Color of the axis line
grid: {
stroke: getGridColor(isDarkMode), // Color of the grid lines
width: isLogScale ? 0.1 : 0.2, // Width of the grid lines,
width: 0.2, // Width of the grid lines,
show: true,
},
ticks: {
@@ -48,20 +45,17 @@ const getAxes = ({
stroke: isDarkMode ? 'white' : 'black', // Color of the axis line
grid: {
stroke: getGridColor(isDarkMode), // Color of the grid lines
width: isLogScale ? 0.1 : 0.2, // Width of the grid lines
width: 0.2, // Width of the grid lines
},
ticks: {
// stroke: isDarkMode ? 'white' : 'black', // Color of the tick lines
width: 0.3, // Width of the tick lines
show: true,
},
...(isLogScale ? { space: 20 } : {}),
values: (_, t): string[] =>
t.map((v) => {
if (v === null || v === undefined || Number.isNaN(v)) {
return '';
}
const value = getToolTipValue(v.toString(), yAxisUnit);
return `${value}`;
}),
gap: 5,

View File

@@ -4,7 +4,6 @@ import { cloneDeep, isUndefined } from 'lodash-es';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import { normalizePlotValue } from './dataUtils';
import { generateColor } from './generateColor';
function getXAxisTimestamps(seriesList: QueryData[]): number[] {
@@ -44,8 +43,16 @@ function fillMissingXAxisTimestamps(timestampArr: number[], data: any[]): any {
});
entry.values.forEach((v) => {
// eslint-disable-next-line no-param-reassign
v[1] = normalizePlotValue(v[1]);
if (Number.isNaN(v[1])) {
const replaceValue = null;
// eslint-disable-next-line no-param-reassign
v[1] = replaceValue;
} else if (v[1] !== null) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line no-param-reassign
v[1] = parseFloat(v[1]);
}
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment

View File

@@ -2,7 +2,7 @@ export const explorerView = {
status: 'success',
data: [
{
id: 'test-uuid-1',
uuid: 'test-uuid-1',
name: 'Table View',
category: '',
createdAt: '2023-08-29T18:04:10.906310033Z',
@@ -78,7 +78,7 @@ export const explorerView = {
extraData: '{"color":"#00ffd0"}',
},
{
id: '8c4bf492-d54d-4ab2-a8d6-9c1563f46e1f',
uuid: '8c4bf492-d54d-4ab2-a8d6-9c1563f46e1f',
name: 'R-test panel',
category: '',
createdAt: '2024-07-01T13:45:57.924686766Z',

View File

@@ -1,50 +0,0 @@
.api-monitoring-page {
flex: 1;
display: flex;
.ant-tabs {
flex: 1;
}
.ant-tabs-nav {
padding: 0 16px;
margin-bottom: 0px;
&::before {
border-bottom: 1px solid var(--bg-slate-400) !important;
}
}
.ant-tabs-content-holder {
display: flex;
.ant-tabs-content {
flex: 1;
display: flex;
flex-direction: column;
.ant-tabs-tabpane {
flex: 1;
display: flex;
flex-direction: column;
}
}
}
.tab-item {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
}
}
.lightMode {
.api-monitoring-page {
.ant-tabs-nav {
&::before {
border-bottom: 1px solid var(--bg-vanilla-300) !important;
}
}
}
}

View File

@@ -1,22 +0,0 @@
import './ApiMonitoringPage.styles.scss';
import RouteTab from 'components/RouteTab';
import { TabRoutes } from 'components/RouteTab/types';
import history from 'lib/history';
import { useLocation } from 'react-use';
import { Explorer } from './constants';
function ApiMonitoringPage(): JSX.Element {
const { pathname } = useLocation();
const routes: TabRoutes[] = [Explorer];
return (
<div className="api-monitoring-page">
<RouteTab routes={routes} activeKey={pathname} history={history} />
</div>
);
}
export default ApiMonitoringPage;

View File

@@ -1,15 +0,0 @@
import { TabRoutes } from 'components/RouteTab/types';
import ROUTES from 'constants/routes';
import ExplorerPage from 'container/ApiMonitoring/Explorer/Explorer';
import { Compass } from 'lucide-react';
export const Explorer: TabRoutes = {
Component: ExplorerPage,
name: (
<div className="tab-item">
<Compass size={16} /> Explorer
</div>
),
route: ROUTES.API_MONITORING,
key: ROUTES.API_MONITORING,
};

View File

@@ -1,3 +0,0 @@
import ApiMonitoring from './ApiMonitoringPage';
export default ApiMonitoring;

View File

@@ -81,7 +81,7 @@ function SaveView(): JSX.Element {
};
const handleEditModelOpen = (view: ViewProps, color: string): void => {
setActiveViewKey(view.id);
setActiveViewKey(view.uuid);
setColor(color);
setActiveViewName(view.name);
setNewViewName(view.name);
@@ -188,11 +188,11 @@ function SaveView(): JSX.Element {
const handleRedirectQuery = (view: ViewProps): void => {
const currentViewDetails = getViewDetailsUsingViewKey(
view.id,
view.uuid,
viewsData?.data.data,
);
if (!currentViewDetails) return;
const { query, name, id, panelType: currentPanelType } = currentViewDetails;
const { query, name, uuid, panelType: currentPanelType } = currentViewDetails;
if (sourcepage) {
handleExplorerTabChange(
@@ -200,7 +200,7 @@ function SaveView(): JSX.Element {
{
query,
name,
id,
uuid,
},
SOURCEPAGE_VS_ROUTES[sourcepage],
);
@@ -258,7 +258,7 @@ function SaveView(): JSX.Element {
className={isEditDeleteSupported ? '' : 'hidden'}
color={Color.BG_CHERRY_500}
data-testid="delete-view"
onClick={(): void => handleDeleteModelOpen(view.id, view.name)}
onClick={(): void => handleDeleteModelOpen(view.uuid, view.name)}
/>
</div>
</div>

View File

@@ -108,7 +108,6 @@ export interface IBaseWidget {
columnUnits?: ColumnUnit;
selectedLogFields: IField[] | null;
selectedTracesFields: BaseAutocompleteData[] | null;
isLogScale?: boolean;
}
export interface Widgets extends IBaseWidget {
query: Query;

View File

@@ -3,7 +3,7 @@ import { DataSource } from 'types/common/queryBuilder';
import { ICompositeMetricQuery } from '../alerts/compositeQuery';
export interface ViewProps {
id: string;
uuid: string;
name: string;
category: string;
createdAt: string;

View File

@@ -20,16 +20,6 @@ export interface QueryData {
values: [number, string][];
quantity?: number[];
unit?: string;
table?: {
rows: {
data: {
[key: string]: any;
};
}[];
columns: {
[key: string]: string;
}[];
};
}
export interface SeriesItem {

View File

@@ -117,7 +117,6 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
METRICS_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
METRICS_EXPLORER_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
METRICS_EXPLORER_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
API_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'],
WORKSPACE_ACCESS_RESTRICTED: ['ADMIN', 'EDITOR', 'VIEWER'],
METRICS_EXPLORER_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
};

View File

@@ -11856,10 +11856,10 @@ lru-cache@^6.0.0:
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.0.tgz#0bd445ca57363465900f4d1f9bd8db343a4d95c3"
integrity sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==
lucide-react@0.427.0:
version "0.427.0"
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.427.0.tgz#e06974514bbd591049f9d736b3d3ae99d4ede8c9"
integrity sha512-lv9s6c5BDF/ccuA0EgTdskTxIe11qpwBDmzRZHJAKtp8LTewAvDvOM+pTES9IpbBuTqkjiMhOmGpJ/CB+mKjFw==
lucide-react@0.379.0:
version "0.379.0"
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.379.0.tgz#29e34eeffae7fb241b64b09868cbe3ab888ef7cc"
integrity sha512-KcdeVPqmhRldldAAgptb8FjIunM2x2Zy26ZBh1RsEUcdLIvsEmbcw7KpzFYUy5BbpGeWhPu9Z9J5YXfStiXwhg==
lz-string@^1.4.4:
version "1.5.0"

View File

@@ -66,7 +66,7 @@ type Server struct {
func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registerer, srvConfig Config, orgID string, stateStore alertmanagertypes.StateStore) (*Server, error) {
server := &Server{
logger: logger.With("pkg", "go.signoz.io/pkg/alertmanager/alertmanagerserver"),
logger: logger.With("pkg", "github.com/SigNoz/pkg/alertmanager/alertmanagerserver"),
registry: registry,
srvConfig: srvConfig,
orgID: orgID,

View File

@@ -33,7 +33,7 @@ func NewRegistry(logger *slog.Logger, services ...NamedService) (*Registry, erro
}
return &Registry{
logger: logger.With("pkg", "go.signoz.io/pkg/factory"),
logger: logger.With("pkg", "github.com/SigNoz/pkg/factory"),
services: m,
startCh: make(chan error, 1),
stopCh: make(chan error, len(services)),

View File

@@ -3,7 +3,7 @@ package middleware
import "net/http"
const (
pkgname string = "go.signoz.io/pkg/http/middleware"
pkgname string = "github.com/SigNoz/pkg/http/middleware"
)
// Wrapper is an interface implemented by all middlewares

View File

@@ -38,7 +38,7 @@ func New(logger *zap.Logger, cfg Config, handler http.Handler) (*Server, error)
return &Server{
srv: srv,
logger: logger.Named("go.signoz.io/pkg/http/server"),
logger: logger.Named("github.com/SigNoz/pkg/http/server"),
handler: handler,
cfg: cfg,
}, nil

View File

@@ -5982,10 +5982,10 @@ func (r *ClickHouseReader) ListSummaryMetrics(ctx context.Context, req *metrics_
}
firstQueryLimit := req.Limit
samplesOrder := false
dataPointsOrder := false
var orderByClauseFirstQuery string
if req.OrderBy.ColumnName == "samples" {
samplesOrder = true
dataPointsOrder = true
orderByClauseFirstQuery = fmt.Sprintf("ORDER BY timeseries %s", req.OrderBy.Order)
if req.Limit < 50 {
firstQueryLimit = 50
@@ -5995,33 +5995,30 @@ func (r *ClickHouseReader) ListSummaryMetrics(ctx context.Context, req *metrics_
}
// Determine which tables to use
start, end, tsTable, localTsTable := utils.WhichTSTableToUse(req.Start, req.End)
sampleTable, countExp := utils.WhichSampleTableToUse(req.Start, req.End)
start, end, tsTable, localTsTable := utils.WhichTSTableToUse(req.Start, req.EndD)
sampleTable, countExp := utils.WhichSampleTableToUse(req.Start, req.EndD)
metricsQuery := fmt.Sprintf(
`SELECT
metric_name,
ANY_VALUE(description) AS description,
ANY_VALUE(type) AS metric_type,
ANY_VALUE(unit) AS metric_unit,
uniq(fingerprint) AS timeseries,
t.metric_name AS metric_name,
ANY_VALUE(t.description) AS description,
ANY_VALUE(t.type) AS metric_type,
ANY_VALUE(t.unit) AS metric_unit,
uniq(t.fingerprint) AS timeseries,
uniq(metric_name) OVER() AS total
FROM %s.%s
FROM %s.%s AS t
WHERE unix_milli BETWEEN ? AND ?
AND NOT startsWith(metric_name, 'signoz_')
AND __normalized = true
%s
GROUP BY metric_name
AND NOT startsWith(metric_name, 'signoz_')
AND __normalized = true
%s
GROUP BY t.metric_name
%s
LIMIT %d OFFSET %d;`,
signozMetricDBName, tsTable, whereClause, orderByClauseFirstQuery, firstQueryLimit, req.Offset)
args = append(args, start, end)
valueCtx := context.WithValue(ctx, "clickhouse_max_threads", constants.MetricsExplorerClickhouseThreads)
begin := time.Now()
rows, err := r.db.Query(valueCtx, metricsQuery, args...)
duration := time.Since(begin)
zap.L().Info("Time taken to execute metrics query to fetch metrics with high time series", zap.String("query", metricsQuery), zap.Any("args", args), zap.Duration("duration", duration))
if err != nil {
zap.L().Error("Error executing metrics query", zap.Error(err))
return &metrics_explorer.SummaryListMetricsResponse{}, &model.ApiError{Typ: "ClickHouseError", Err: err}
@@ -6052,14 +6049,12 @@ func (r *ClickHouseReader) ListSummaryMetrics(ctx context.Context, req *metrics_
// Build a comma-separated list of quoted metric names.
metricsList := "'" + strings.Join(metricNames, "', '") + "'"
// If samples are being sorted by datapoints, update the ORDER clause.
if samplesOrder {
if dataPointsOrder {
orderByClauseFirstQuery = fmt.Sprintf("ORDER BY s.samples %s", req.OrderBy.Order)
} else {
orderByClauseFirstQuery = ""
}
// reset the args for main query
args = make([]interface{}, 0)
var sampleQuery string
var sb strings.Builder
@@ -6067,19 +6062,20 @@ func (r *ClickHouseReader) ListSummaryMetrics(ctx context.Context, req *metrics_
sb.WriteString(fmt.Sprintf(
`SELECT
s.samples,
s.metric_name
s.metric_name,
s.lastReceived
FROM (
SELECT
SELECT
dm.metric_name,
%s AS samples
%s AS samples,
MAX(dm.unix_milli) AS lastReceived
FROM %s.%s AS dm
WHERE dm.metric_name IN (%s)
AND dm.fingerprint IN (
SELECT fingerprint
FROM %s.%s
WHERE metric_name IN (%s)
AND __normalized = true
AND unix_milli BETWEEN ? AND ?
AND __normalized = true
%s
GROUP BY fingerprint
)
@@ -6093,27 +6089,26 @@ func (r *ClickHouseReader) ListSummaryMetrics(ctx context.Context, req *metrics_
metricsList,
whereClause,
))
args = append(args, start, end)
args = append(args, req.Start, req.End)
} else {
// If no filters, it is a simpler query.
sb.WriteString(fmt.Sprintf(
`SELECT
s.samples,
s.metric_name
FROM (
SELECT
metric_name,
%s AS samples
FROM %s.%s
WHERE metric_name IN (%s)
AND unix_milli BETWEEN ? AND ?
GROUP BY metric_name
) AS s `,
s.samples,
s.metric_name,
s.lastReceived
FROM (
SELECT
metric_name,
%s AS samples,
MAX(unix_milli) AS lastReceived
FROM %s.%s
WHERE metric_name IN (%s)
AND unix_milli BETWEEN ? AND ?
GROUP BY metric_name
) AS s `,
countExp,
signozMetricDBName, sampleTable,
metricsList))
args = append(args, req.Start, req.End)
}
// Append ORDER BY clause if provided.
@@ -6125,10 +6120,9 @@ func (r *ClickHouseReader) ListSummaryMetrics(ctx context.Context, req *metrics_
sb.WriteString(fmt.Sprintf("LIMIT %d;", req.Limit))
sampleQuery = sb.String()
begin = time.Now()
// Append the time boundaries for sampleQuery.
args = append(args, start, end)
rows, err = r.db.Query(valueCtx, sampleQuery, args...)
duration = time.Since(begin)
zap.L().Info("Time taken to execute samples query", zap.String("query", sampleQuery), zap.Any("args", args), zap.Duration("duration", duration))
if err != nil {
zap.L().Error("Error executing samples query", zap.Error(err))
return &response, &model.ApiError{Typ: "ClickHouseError", Err: err}
@@ -6136,15 +6130,18 @@ func (r *ClickHouseReader) ListSummaryMetrics(ctx context.Context, req *metrics_
defer rows.Close()
samplesMap := make(map[string]uint64)
lastReceivedMap := make(map[string]int64)
for rows.Next() {
var samples uint64
var metricName string
if err := rows.Scan(&samples, &metricName); err != nil {
var lastReceived int64
if err := rows.Scan(&samples, &metricName, &lastReceived); err != nil {
zap.L().Error("Error scanning sample row", zap.Error(err))
return &response, &model.ApiError{Typ: "ClickHouseError", Err: err}
}
samplesMap[metricName] = samples
lastReceivedMap[metricName] = lastReceived
}
if err := rows.Err(); err != nil {
zap.L().Error("Error iterating over sample rows", zap.Error(err))
@@ -6170,13 +6167,16 @@ func (r *ClickHouseReader) ListSummaryMetrics(ctx context.Context, req *metrics_
}
if samples, exists := samplesMap[response.Metrics[i].MetricName]; exists {
response.Metrics[i].Samples = samples
if lastReceived, exists := lastReceivedMap[response.Metrics[i].MetricName]; exists {
response.Metrics[i].LastReceived = lastReceived
}
filteredMetrics = append(filteredMetrics, response.Metrics[i])
}
}
response.Metrics = filteredMetrics
// If ordering by samples, sort in-memory.
if samplesOrder {
if dataPointsOrder {
sort.Slice(response.Metrics, func(i, j int) bool {
return response.Metrics[i].Samples > response.Metrics[j].Samples
})
@@ -6194,7 +6194,7 @@ func (r *ClickHouseReader) GetMetricsTimeSeriesPercentage(ctx context.Context, r
if len(conditions) > 0 {
whereClause = "AND " + strings.Join(conditions, " AND ")
}
start, end, tsTable, _ := utils.WhichTSTableToUse(req.Start, req.End)
start, end, tsTable, _ := utils.WhichTSTableToUse(req.Start, req.EndD)
// Construct the query without backticks
query := fmt.Sprintf(`
@@ -6204,17 +6204,17 @@ func (r *ClickHouseReader) GetMetricsTimeSeriesPercentage(ctx context.Context, r
(total_value * 100.0 / total_time_series) AS percentage
FROM (
SELECT
metric_name,
uniq(fingerprint) AS total_value,
(SELECT uniq(fingerprint)
FROM %s.%s
WHERE unix_milli BETWEEN ? AND ? AND __normalized = true) AS total_time_series
FROM %s.%s
WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz_') AND __normalized = true %s
GROUP BY metric_name
)
ORDER BY percentage DESC
LIMIT %d;`,
metric_name,
uniq(fingerprint) AS total_value,
(SELECT uniq(fingerprint)
FROM %s.%s
WHERE unix_milli BETWEEN ? AND ? AND __normalized = true) AS total_time_series
FROM %s.%s
WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz_') AND __normalized = true %s
GROUP BY metric_name
)
ORDER BY percentage DESC
LIMIT %d;`,
signozMetricDBName,
tsTable,
signozMetricDBName,
@@ -6224,29 +6224,26 @@ func (r *ClickHouseReader) GetMetricsTimeSeriesPercentage(ctx context.Context, r
)
args = append(args,
start, end, // For total_time_series subquery
start, end, // For total_cardinality subquery
start, end, // For main query
)
valueCtx := context.WithValue(ctx, "clickhouse_max_threads", constants.MetricsExplorerClickhouseThreads)
begin := time.Now()
rows, err := r.db.Query(valueCtx, query, args...)
duration := time.Since(begin)
zap.L().Info("Time taken to execute time series percentage query", zap.String("query", query), zap.Any("args", args), zap.Duration("duration", duration))
if err != nil {
zap.L().Error("Error executing time series percentage query", zap.Error(err), zap.String("query", query))
zap.L().Error("Error executing cardinality query", zap.Error(err), zap.String("query", query))
return nil, &model.ApiError{Typ: "ClickHouseError", Err: err}
}
defer rows.Close()
var treeMap []metrics_explorer.TreeMapResponseItem
var heatmap []metrics_explorer.TreeMapResponseItem
for rows.Next() {
var item metrics_explorer.TreeMapResponseItem
if err := rows.Scan(&item.MetricName, &item.TotalValue, &item.Percentage); err != nil {
zap.L().Error("Error scanning row", zap.Error(err))
return nil, &model.ApiError{Typ: "ClickHouseError", Err: err}
}
treeMap = append(treeMap, item)
heatmap = append(heatmap, item)
}
if err := rows.Err(); err != nil {
@@ -6254,7 +6251,7 @@ func (r *ClickHouseReader) GetMetricsTimeSeriesPercentage(ctx context.Context, r
return nil, &model.ApiError{Typ: "ClickHouseError", Err: err}
}
return &treeMap, nil
return &heatmap, nil
}
func (r *ClickHouseReader) GetMetricsSamplesPercentage(ctx context.Context, req *metrics_explorer.TreeMapMetricsRequest) (*[]metrics_explorer.TreeMapResponseItem, *model.ApiError) {
@@ -6267,30 +6264,27 @@ func (r *ClickHouseReader) GetMetricsSamplesPercentage(ctx context.Context, req
}
// Determine time range and tables to use
start, end, tsTable, localTsTable := utils.WhichTSTableToUse(req.Start, req.End)
sampleTable, countExp := utils.WhichSampleTableToUse(req.Start, req.End)
start, end, tsTable, localTsTable := utils.WhichTSTableToUse(req.Start, req.EndD)
sampleTable, countExp := utils.WhichSampleTableToUse(req.Start, req.EndD)
queryLimit := 50 + req.Limit
metricsQuery := fmt.Sprintf(`
SELECT
metric_name,
uniq(fingerprint) AS timeSeries
FROM %s.%s
WHERE NOT startsWith(metric_name, 'signoz_')
AND __normalized = true
AND unix_milli BETWEEN ? AND ?
%s
GROUP BY metric_name
metricsQuery := fmt.Sprintf(
`SELECT
ts.metric_name AS metric_name,
uniq(ts.fingerprint) AS timeSeries
FROM %s.%s AS ts
WHERE NOT startsWith(ts.metric_name, 'signoz_')
AND __normalized = true
AND unix_milli BETWEEN ? AND ?
%s
GROUP BY ts.metric_name
ORDER BY timeSeries DESC
LIMIT %d;`,
signozMetricDBName, tsTable, whereClause, queryLimit,
)
valueCtx := context.WithValue(ctx, "clickhouse_max_threads", constants.MetricsExplorerClickhouseThreads)
begin := time.Now()
rows, err := r.db.Query(valueCtx, metricsQuery, start, end)
duration := time.Since(begin)
zap.L().Info("Time taken to execute metrics query to reduce search space", zap.String("query", metricsQuery), zap.Any("start", start), zap.Any("end", end), zap.Duration("duration", duration))
if err != nil {
zap.L().Error("Error executing metrics query", zap.Error(err))
return nil, &model.ApiError{Typ: "ClickHouseError", Err: err}
@@ -6351,13 +6345,12 @@ func (r *ClickHouseReader) GetMetricsSamplesPercentage(ctx context.Context, req
if whereClause != "" {
sb.WriteString(fmt.Sprintf(
` AND dm.fingerprint IN (
SELECT fingerprint
FROM %s.%s
WHERE metric_name IN (%s)
AND unix_milli BETWEEN ? AND ?
AND __normalized = true
%s
GROUP BY fingerprint
SELECT ts.fingerprint
FROM %s.%s AS ts
WHERE ts.metric_name IN (%s)
AND __normalized = true
%s
GROUP BY ts.fingerprint
)`,
signozMetricDBName, localTsTable, metricsList, whereClause,
))
@@ -6377,18 +6370,10 @@ func (r *ClickHouseReader) GetMetricsSamplesPercentage(ctx context.Context, req
sampleQuery := sb.String()
// Add start and end time to args (only for sample table)
args = append(args,
req.Start, req.End, // For total_samples subquery
req.Start, req.End, // For main query
start, end, // For where clause time series fingerprint query
req.Limit,
)
args = append(args, start, end, start, end, req.Limit)
begin = time.Now()
// Execute the sample percentage query
rows, err = r.db.Query(valueCtx, sampleQuery, args...)
duration = time.Since(begin)
zap.L().Info("Time taken to execute samples percentage query", zap.String("query", sampleQuery), zap.Any("args", args), zap.Duration("duration", duration))
if err != nil {
zap.L().Error("Error executing samples query", zap.Error(err))
return nil, &model.ApiError{Typ: "ClickHouseError", Err: err}

View File

@@ -14,7 +14,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/google/uuid"
"go.uber.org/zap"
)
@@ -47,7 +47,7 @@ func GetViews(ctx context.Context, orgID string) ([]*v3.SavedView, error) {
return nil, fmt.Errorf("error in unmarshalling explorer query data: %s", err.Error())
}
savedViews = append(savedViews, &v3.SavedView{
ID: view.ID,
UUID: view.UUID,
Name: view.Name,
Category: view.Category,
CreatedAt: view.CreatedAt,
@@ -83,7 +83,7 @@ func GetViewsForFilters(ctx context.Context, orgID string, sourcePage string, na
return nil, fmt.Errorf("error in unmarshalling explorer query data: %s", err.Error())
}
savedViews = append(savedViews, &v3.SavedView{
ID: view.ID,
UUID: view.UUID,
Name: view.Name,
CreatedAt: view.CreatedAt,
CreatedBy: view.CreatedBy,
@@ -98,19 +98,23 @@ func GetViewsForFilters(ctx context.Context, orgID string, sourcePage string, na
return savedViews, nil
}
func CreateView(ctx context.Context, orgID string, view v3.SavedView) (valuer.UUID, error) {
func CreateView(ctx context.Context, orgID string, view v3.SavedView) (string, error) {
data, err := json.Marshal(view.CompositeQuery)
if err != nil {
return valuer.UUID{}, fmt.Errorf("error in marshalling explorer query data: %s", err.Error())
return "", fmt.Errorf("error in marshalling explorer query data: %s", err.Error())
}
uuid := valuer.GenerateUUID()
uuid_ := view.UUID
if uuid_ == "" {
uuid_ = uuid.New().String()
}
createdAt := time.Now()
updatedAt := time.Now()
claims, ok := authtypes.ClaimsFromContext(ctx)
if !ok {
return valuer.UUID{}, fmt.Errorf("error in getting email from context")
return "", fmt.Errorf("error in getting email from context")
}
createBy := claims.Email
@@ -125,10 +129,8 @@ func CreateView(ctx context.Context, orgID string, view v3.SavedView) (valuer.UU
CreatedBy: createBy,
UpdatedBy: updatedBy,
},
OrgID: orgID,
Identifiable: types.Identifiable{
ID: uuid,
},
OrgID: orgID,
UUID: uuid_,
Name: view.Name,
Category: view.Category,
SourcePage: view.SourcePage,
@@ -139,14 +141,14 @@ func CreateView(ctx context.Context, orgID string, view v3.SavedView) (valuer.UU
_, err = store.BunDB().NewInsert().Model(&dbView).Exec(ctx)
if err != nil {
return valuer.UUID{}, fmt.Errorf("error in creating saved view: %s", err.Error())
return "", fmt.Errorf("error in creating saved view: %s", err.Error())
}
return uuid, nil
return uuid_, nil
}
func GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*v3.SavedView, error) {
func GetView(ctx context.Context, orgID string, uuid_ string) (*v3.SavedView, error) {
var view types.SavedView
err := store.BunDB().NewSelect().Model(&view).Where("org_id = ? AND id = ?", orgID, uuid.StringValue()).Scan(ctx)
err := store.BunDB().NewSelect().Model(&view).Where("org_id = ? AND uuid = ?", orgID, uuid_).Scan(ctx)
if err != nil {
return nil, fmt.Errorf("error in getting saved view: %s", err.Error())
}
@@ -157,7 +159,7 @@ func GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*v3.SavedView
return nil, fmt.Errorf("error in unmarshalling explorer query data: %s", err.Error())
}
return &v3.SavedView{
ID: view.ID,
UUID: view.UUID,
Name: view.Name,
Category: view.Category,
CreatedAt: view.CreatedAt,
@@ -171,7 +173,7 @@ func GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*v3.SavedView
}, nil
}
func UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view v3.SavedView) error {
func UpdateView(ctx context.Context, orgID string, uuid_ string, view v3.SavedView) error {
data, err := json.Marshal(view.CompositeQuery)
if err != nil {
return fmt.Errorf("error in marshalling explorer query data: %s", err.Error())
@@ -189,7 +191,7 @@ func UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view v3.Sav
Model(&types.SavedView{}).
Set("updated_at = ?, updated_by = ?, name = ?, category = ?, source_page = ?, tags = ?, data = ?, extra_data = ?",
updatedAt, updatedBy, view.Name, view.Category, view.SourcePage, strings.Join(view.Tags, ","), data, view.ExtraData).
Where("id = ?", uuid.StringValue()).
Where("uuid = ?", uuid_).
Where("org_id = ?", orgID).
Exec(ctx)
if err != nil {
@@ -198,10 +200,10 @@ func UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view v3.Sav
return nil
}
func DeleteView(ctx context.Context, orgID string, uuid valuer.UUID) error {
func DeleteView(ctx context.Context, orgID string, uuid_ string) error {
_, err := store.BunDB().NewDelete().
Model(&types.SavedView{}).
Where("id = ?", uuid.StringValue()).
Where("uuid = ?", uuid_).
Where("org_id = ?", orgID).
Exec(ctx)
if err != nil {

View File

@@ -18,12 +18,14 @@ import (
"text/template"
"time"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations/traceFunnels"
"github.com/google/uuid"
"github.com/SigNoz/signoz/pkg/alertmanager"
errorsV2 "github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/query-service/app/metricsexplorer"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
@@ -4626,18 +4628,12 @@ func (aH *APIHandler) createSavedViews(w http.ResponseWriter, r *http.Request) {
func (aH *APIHandler) getSavedView(w http.ResponseWriter, r *http.Request) {
viewID := mux.Vars(r)["viewId"]
viewUUID, err := valuer.NewUUID(viewID)
if err != nil {
render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, err.Error()))
return
}
claims, ok := authtypes.ClaimsFromContext(r.Context())
if !ok {
render.Error(w, errorsV2.Newf(errorsV2.TypeUnauthenticated, errorsV2.CodeUnauthenticated, "unauthenticated"))
return
}
view, err := explorer.GetView(r.Context(), claims.OrgID, viewUUID)
view, err := explorer.GetView(r.Context(), claims.OrgID, viewID)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
@@ -4648,13 +4644,8 @@ func (aH *APIHandler) getSavedView(w http.ResponseWriter, r *http.Request) {
func (aH *APIHandler) updateSavedView(w http.ResponseWriter, r *http.Request) {
viewID := mux.Vars(r)["viewId"]
viewUUID, err := valuer.NewUUID(viewID)
if err != nil {
render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, err.Error()))
return
}
var view v3.SavedView
err = json.NewDecoder(r.Body).Decode(&view)
err := json.NewDecoder(r.Body).Decode(&view)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
@@ -4670,7 +4661,7 @@ func (aH *APIHandler) updateSavedView(w http.ResponseWriter, r *http.Request) {
render.Error(w, errorsV2.Newf(errorsV2.TypeUnauthenticated, errorsV2.CodeUnauthenticated, "unauthenticated"))
return
}
err = explorer.UpdateView(r.Context(), claims.OrgID, viewUUID, view)
err = explorer.UpdateView(r.Context(), claims.OrgID, viewID, view)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
@@ -4682,17 +4673,12 @@ func (aH *APIHandler) updateSavedView(w http.ResponseWriter, r *http.Request) {
func (aH *APIHandler) deleteSavedView(w http.ResponseWriter, r *http.Request) {
viewID := mux.Vars(r)["viewId"]
viewUUID, err := valuer.NewUUID(viewID)
if err != nil {
render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, err.Error()))
return
}
claims, ok := authtypes.ClaimsFromContext(r.Context())
if !ok {
render.Error(w, errorsV2.Newf(errorsV2.TypeUnauthenticated, errorsV2.CodeUnauthenticated, "unauthenticated"))
return
}
err = explorer.DeleteView(r.Context(), claims.OrgID, viewUUID)
err := explorer.DeleteView(r.Context(), claims.OrgID, viewID)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
@@ -5590,3 +5576,546 @@ func (aH *APIHandler) getDomainInfo(w http.ResponseWriter, r *http.Request) {
}
aH.Respond(w, resp)
}
// RegisterTraceFunnelsRoutes adds trace funnels routes
func (aH *APIHandler) RegisterTraceFunnelsRoutes(router *mux.Router, am *AuthMiddleware) {
// Main messaging queues router
traceFunnelsRouter := router.PathPrefix("/api/v1/trace-funnels").Subrouter()
// API endpoints
traceFunnelsRouter.HandleFunc("/new-funnel", aH.handleNewFunnel).Methods("POST")
traceFunnelsRouter.HandleFunc("/steps/update", aH.handleUpdateFunnelStep).Methods("PUT")
traceFunnelsRouter.HandleFunc("/list", aH.handleListFunnels).Methods("GET")
traceFunnelsRouter.HandleFunc("/get/{funnel_id}", aH.handleGetFunnel).Methods("GET")
traceFunnelsRouter.HandleFunc("/delete/{funnel_id}", aH.handleDeleteFunnel).Methods("DELETE")
traceFunnelsRouter.HandleFunc("/save", aH.handleSaveFunnel).Methods("POST")
//// Analytics endpoints
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/validate", aH.handleValidateTraces).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/overview", aH.handleFunnelAnalytics).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/steps", aH.handleStepAnalytics).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/slow-traces", func(w http.ResponseWriter, r *http.Request) {
aH.handleSlowTraces(w, r, false)
}).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/error-traces", func(w http.ResponseWriter, r *http.Request) {
aH.handleSlowTraces(w, r, true)
}).Methods("POST")
}
// handleNewFunnel creates a new funnel without steps
// Steps should be added separately using the update endpoint
func (aH *APIHandler) handleNewFunnel(w http.ResponseWriter, r *http.Request) {
var req traceFunnels.NewFunnelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
claims, ok := authtypes.ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
userID := claims.UserID
orgID := claims.OrgID
// Validate timestamp is provided and in milliseconds format
if err := traceFunnels.ValidateTimestamp(req.Timestamp, "creation_timestamp"); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Check for name collision in the SQLite database
var count int
err := aH.Signoz.SQLStore.SQLDB().QueryRow(
"SELECT COUNT(*) FROM saved_views WHERE name = ? AND created_by = ? AND category = 'funnel'",
req.Name, userID,
).Scan(&count)
if err != nil {
zap.L().Error("Error checking for funnel name collision in SQLite: %v", zap.Error(err))
} else if count > 0 {
http.Error(w, fmt.Sprintf("funnel with name '%s' already exists for user '%s' in the database", req.Name, userID), http.StatusBadRequest)
return
}
funnel := &traceFunnels.Funnel{
ID: uuid.New().String(),
Name: req.Name,
CreatedAt: req.Timestamp * 1000000, // Convert milliseconds to nanoseconds for internal storage
CreatedBy: userID,
OrgID: orgID,
Steps: make([]traceFunnels.FunnelStep, 0),
}
funnelData, err := json.Marshal(funnel)
if err != nil {
http.Error(w, fmt.Sprintf("failed to marshal funnel data: %v", err), http.StatusInternalServerError)
return
}
createdAt := time.Unix(0, funnel.CreatedAt).UTC().Format(time.RFC3339)
// Insert new funnel
_, err = aH.Signoz.SQLStore.SQLDB().Exec(
"INSERT INTO saved_views (uuid, name, category, created_by, updated_by, source_page, data, created_at, updated_at, org_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
funnel.ID, funnel.Name, "funnel", userID, userID, "trace-funnels", string(funnelData), createdAt, createdAt, orgID,
)
if err != nil {
http.Error(w, fmt.Sprintf("failed to save funnel to database: %v", err), http.StatusInternalServerError)
return
}
response := traceFunnels.NewFunnelResponse{
ID: funnel.ID,
Name: funnel.Name,
CreatedAt: funnel.CreatedAt / 1000000,
CreatedBy: funnel.CreatedBy,
OrgID: orgID,
}
json.NewEncoder(w).Encode(response)
}
// handleUpdateFunnelStep adds or updates steps for an existing funnel
// Steps are identified by their step_order, which must be unique within a funnel
func (aH *APIHandler) handleUpdateFunnelStep(w http.ResponseWriter, r *http.Request) {
var req traceFunnels.FunnelStepRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
claims, ok := authtypes.ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
userID := claims.UserID
if err := traceFunnels.ValidateTimestamp(req.Timestamp, "updated_timestamp"); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
aH.Signoz.SQLStore.SQLxDB()
dbClient, _ := traceFunnels.NewSQLClient(aH.Signoz.SQLStore)
funnel, err := dbClient.GetFunnelFromDB(req.FunnelID)
if err != nil {
http.Error(w, fmt.Sprintf("funnel not found: %v", err), http.StatusNotFound)
return
}
// Process each step in the request
for i := range req.Steps {
if req.Steps[i].StepOrder < 1 {
req.Steps[i].StepOrder = int64(i + 1) // Default to sequential ordering if not specified
}
}
if err := traceFunnels.ValidateFunnelSteps(req.Steps); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Normalize step orders
req.Steps = traceFunnels.NormalizeFunnelSteps(req.Steps)
// Update the funnel with new steps
funnel.Steps = req.Steps
funnel.UpdatedAt = req.Timestamp * 1000000
funnel.UpdatedBy = userID
funnelData, err := json.Marshal(funnel)
if err != nil {
http.Error(w, fmt.Sprintf("failed to marshal funnel data: %v", err), http.StatusInternalServerError)
return
}
updatedAt := time.Unix(0, funnel.UpdatedAt).UTC().Format(time.RFC3339)
_, err = aH.Signoz.SQLStore.SQLDB().Exec(
"UPDATE saved_views SET data = ?, updated_by = ?, updated_at = ? WHERE uuid = ? AND category = 'funnel'",
string(funnelData), userID, updatedAt, req.FunnelID,
)
if err != nil {
http.Error(w, fmt.Sprintf("failed to update funnel in database: %v", err), http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"id": funnel.ID,
"funnel_name": funnel.Name,
"creation_timestamp": funnel.CreatedAt / 1000000,
"user_id": funnel.CreatedBy,
"org_id": funnel.OrgID,
"updated_timestamp": req.Timestamp,
"updated_by": userID,
"steps": funnel.Steps,
}
json.NewEncoder(w).Encode(response)
}
func (aH *APIHandler) handleListFunnels(w http.ResponseWriter, r *http.Request) {
orgID := r.URL.Query().Get("org_id")
var dbFunnels []*traceFunnels.Funnel
var err error
dbClient, _ := traceFunnels.NewSQLClient(aH.Signoz.SQLStore)
if orgID != "" {
dbFunnels, err = dbClient.ListFunnelsFromDB(orgID)
} else {
dbFunnels, err = dbClient.ListAllFunnelsFromDB()
}
if err != nil {
http.Error(w, fmt.Sprintf("error fetching funnels from database: %v", err), http.StatusInternalServerError)
return
}
// Convert to response format with additional metadata
response := make([]map[string]interface{}, 0, len(dbFunnels))
for _, f := range dbFunnels {
funnelInfo := map[string]interface{}{
"id": f.ID,
"funnel_name": f.Name,
"creation_timestamp": f.CreatedAt / 1000000,
"user_id": f.CreatedBy,
"org_id": f.OrgID,
}
if f.UpdatedAt > 0 {
funnelInfo["updated_timestamp"] = f.UpdatedAt / 1000000
}
if f.UpdatedBy != "" {
funnelInfo["updated_by"] = f.UpdatedBy
}
var extraData, tags string
err := aH.Signoz.SQLStore.SQLDB().QueryRow(
"SELECT IFNULL(extra_data, ''), IFNULL(tags, '') FROM saved_views WHERE uuid = ? AND category = 'funnel'",
f.ID,
).Scan(&extraData, &tags)
if err == nil && tags != "" {
funnelInfo["tags"] = tags
}
if err == nil && extraData != "" {
var extraDataMap map[string]interface{}
if err := json.Unmarshal([]byte(extraData), &extraDataMap); err == nil {
if description, ok := extraDataMap["description"].(string); ok {
funnelInfo["description"] = description
}
}
}
response = append(response, funnelInfo)
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}
func (aH *APIHandler) handleGetFunnel(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
dbClient, _ := traceFunnels.NewSQLClient(aH.Signoz.SQLStore)
funnel, err := dbClient.GetFunnelFromDB(funnelID)
if err != nil {
http.Error(w, fmt.Sprintf("funnel not found: %v", err), http.StatusNotFound)
return
}
var extraData, tags string
err = aH.Signoz.SQLStore.SQLDB().QueryRow(
"SELECT IFNULL(extra_data, ''), IFNULL(tags, '') FROM saved_views WHERE uuid = ? AND category = 'funnel'",
funnel.ID,
).Scan(&extraData, &tags)
response := map[string]interface{}{
"id": funnel.ID,
"funnel_name": funnel.Name,
"creation_timestamp": funnel.CreatedAt / 1000000,
"user_id": funnel.CreatedBy,
"org_id": funnel.OrgID,
"steps": funnel.Steps,
}
if funnel.UpdatedAt > 0 {
response["updated_timestamp"] = funnel.UpdatedAt / 1000000
}
if funnel.UpdatedBy != "" {
response["updated_by"] = funnel.UpdatedBy
}
if err == nil && tags != "" {
response["tags"] = tags
}
if err == nil && extraData != "" {
var extraDataMap map[string]interface{}
if err := json.Unmarshal([]byte(extraData), &extraDataMap); err == nil {
if description, ok := extraDataMap["description"].(string); ok {
response["description"] = description
}
}
}
json.NewEncoder(w).Encode(response)
}
func (aH *APIHandler) handleDeleteFunnel(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
dbClient, _ := traceFunnels.NewSQLClient(aH.Signoz.SQLStore)
err := dbClient.DeleteFunnelFromDB(funnelID)
if err != nil {
http.Error(w, fmt.Sprintf("failed to delete funnel: %v", err), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
// handleSaveFunnel saves a funnel to the SQLite database
// Only requires funnel_id and optional description
func (aH *APIHandler) handleSaveFunnel(w http.ResponseWriter, r *http.Request) {
var req traceFunnels.SaveFunnelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
dbClient, _ := traceFunnels.NewSQLClient(aH.Signoz.SQLStore)
funnel, err := dbClient.GetFunnelFromDB(req.FunnelID)
if err != nil {
http.Error(w, fmt.Sprintf("funnel not found: %v", err), http.StatusNotFound)
return
}
updateTimestamp := req.Timestamp
if updateTimestamp == 0 {
updateTimestamp = time.Now().UnixMilli()
} else {
if !traceFunnels.ValidateTimestampIsMilliseconds(updateTimestamp) {
http.Error(w, "timestamp must be in milliseconds format (13 digits)", http.StatusBadRequest)
return
}
}
funnel.UpdatedAt = updateTimestamp * 1000000 // Convert ms to ns
if req.UserID != "" {
funnel.UpdatedBy = req.UserID
}
extraData := ""
if req.Description != "" {
descriptionJSON, err := json.Marshal(map[string]string{"description": req.Description})
if err != nil {
http.Error(w, "failed to marshal description: "+err.Error(), http.StatusInternalServerError)
return
}
extraData = string(descriptionJSON)
}
orgID := req.OrgID
if orgID == "" {
orgID = funnel.OrgID
}
if err := dbClient.SaveFunnel(funnel, funnel.UpdatedBy, orgID, req.Tags, extraData); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var createdAt, updatedAt, tags, extraDataFromDB string
err = aH.Signoz.SQLStore.SQLDB().QueryRow(
"SELECT created_at, updated_at, IFNULL(tags, ''), IFNULL(extra_data, '') FROM saved_views WHERE uuid = ? AND category = 'funnel'",
funnel.ID,
).Scan(&createdAt, &updatedAt, &tags, &extraDataFromDB)
if err != nil {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "success",
"id": funnel.ID,
"name": funnel.Name,
})
return
}
response := map[string]string{
"status": "success",
"id": funnel.ID,
"name": funnel.Name,
"created_at": createdAt,
"updated_at": updatedAt,
"created_by": funnel.CreatedBy,
"updated_by": funnel.UpdatedBy,
"org_id": funnel.OrgID,
}
if tags != "" {
response["tags"] = tags
}
if extraDataFromDB != "" {
var extraDataMap map[string]interface{}
if err := json.Unmarshal([]byte(extraDataFromDB), &extraDataMap); err == nil {
if description, ok := extraDataMap["description"].(string); ok {
response["description"] = description
}
}
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}
func (aH *APIHandler) handleValidateTraces(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
dbClient, _ := traceFunnels.NewSQLClient(aH.Signoz.SQLStore)
funnel, err := dbClient.GetFunnelFromDB(funnelID)
if err != nil {
http.Error(w, fmt.Sprintf("funnel not found: %v", err), http.StatusNotFound)
return
}
var timeRange traceFunnels.TimeRange
if err := json.NewDecoder(r.Body).Decode(&timeRange); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(funnel.Steps) < 2 {
http.Error(w, "funnel must have at least 2 steps", http.StatusBadRequest)
return
}
chq, err := traceFunnels.ValidateTraces(funnel, timeRange)
if err != nil {
zap.L().Error(err.Error())
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
return
}
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
aH.Respond(w, results)
}
// Analytics handlers
func (aH *APIHandler) handleFunnelAnalytics(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
dbClient, _ := traceFunnels.NewSQLClient(aH.Signoz.SQLStore)
funnel, err := dbClient.GetFunnelFromDB(funnelID)
if err != nil {
http.Error(w, fmt.Sprintf("funnel not found: %v", err), http.StatusNotFound)
return
}
var timeRange traceFunnels.TimeRange
if err := json.NewDecoder(r.Body).Decode(&timeRange); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
chq, err := traceFunnels.ValidateTracesWithLatency(funnel, timeRange)
if err != nil {
zap.L().Error(err.Error())
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
return
}
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
aH.Respond(w, results)
}
func (aH *APIHandler) handleStepAnalytics(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
// Get funnel directly from SQLite database
dbClient, _ := traceFunnels.NewSQLClient(aH.Signoz.SQLStore)
funnel, err := dbClient.GetFunnelFromDB(funnelID)
if err != nil {
http.Error(w, fmt.Sprintf("funnel not found: %v", err), http.StatusNotFound)
return
}
var timeRange traceFunnels.TimeRange
if err := json.NewDecoder(r.Body).Decode(&timeRange); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
chq, err := traceFunnels.GetStepAnalytics(funnel, timeRange)
if err != nil {
zap.L().Error(err.Error())
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
return
}
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
aH.Respond(w, results)
}
func (aH *APIHandler) handleSlowTraces(w http.ResponseWriter, r *http.Request, withErrors bool) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
dbClient, _ := traceFunnels.NewSQLClient(aH.Signoz.SQLStore)
funnel, err := dbClient.GetFunnelFromDB(funnelID)
if err != nil {
http.Error(w, fmt.Sprintf("funnel not found: %v", err), http.StatusNotFound)
return
}
var req traceFunnels.StepTransitionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
stepAExists, stepBExists := false, false
for _, step := range funnel.Steps {
if step.StepOrder == req.StepAOrder {
stepAExists = true
}
if step.StepOrder == req.StepBOrder {
stepBExists = true
}
}
if !stepAExists || !stepBExists {
http.Error(w, fmt.Sprintf("One or both steps not found. Step A Order: %d, Step B Order: %d", req.StepAOrder, req.StepBOrder), http.StatusBadRequest)
return
}
chq, err := traceFunnels.GetSlowestTraces(funnel, req.StepAOrder, req.StepBOrder, req.TimeRange, withErrors)
if err != nil {
zap.L().Error(err.Error())
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
return
}
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
aH.Respond(w, results)
}

View File

@@ -70,18 +70,7 @@ func BuildDomainList(thirdPartyApis *ThirdPartyApis) (*v3.QueryRangeParamsV3, er
SpaceAggregation: v3.SpaceAggregationSum,
Filters: &v3.FilterSet{
Operator: "AND",
Items: getFilterSet([]v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "http.url",
DataType: v3.AttributeKeyDataTypeString,
IsColumn: false,
Type: v3.AttributeKeyTypeTag,
},
Operator: v3.FilterOperatorExists,
Value: "",
},
}, thirdPartyApis.Filters),
Items: getFilterSet([]v3.FilterItem{}, thirdPartyApis.Filters),
},
Expression: "endpoints",
GroupBy: getGroupBy([]v3.AttributeKey{
@@ -106,18 +95,7 @@ func BuildDomainList(thirdPartyApis *ThirdPartyApis) (*v3.QueryRangeParamsV3, er
SpaceAggregation: v3.SpaceAggregationSum,
Filters: &v3.FilterSet{
Operator: "AND",
Items: getFilterSet([]v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "http.url",
DataType: v3.AttributeKeyDataTypeString,
IsColumn: false,
Type: v3.AttributeKeyTypeTag,
},
Operator: v3.FilterOperatorExists,
Value: "",
},
}, thirdPartyApis.Filters),
Items: getFilterSet([]v3.FilterItem{}, thirdPartyApis.Filters),
},
Expression: "lastseen",
GroupBy: getGroupBy([]v3.AttributeKey{
@@ -142,18 +120,7 @@ func BuildDomainList(thirdPartyApis *ThirdPartyApis) (*v3.QueryRangeParamsV3, er
SpaceAggregation: v3.SpaceAggregationSum,
Filters: &v3.FilterSet{
Operator: "AND",
Items: getFilterSet([]v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "http.url",
DataType: v3.AttributeKeyDataTypeString,
IsColumn: false,
Type: v3.AttributeKeyTypeTag,
},
Operator: v3.FilterOperatorExists,
Value: "",
},
}, thirdPartyApis.Filters),
Items: getFilterSet([]v3.FilterItem{}, thirdPartyApis.Filters),
},
Expression: "rps",
GroupBy: getGroupBy([]v3.AttributeKey{
@@ -188,16 +155,6 @@ func BuildDomainList(thirdPartyApis *ThirdPartyApis) (*v3.QueryRangeParamsV3, er
Operator: "=",
Value: "true",
},
{
Key: v3.AttributeKey{
Key: "http.url",
DataType: v3.AttributeKeyDataTypeString,
IsColumn: false,
Type: v3.AttributeKeyTypeTag,
},
Operator: v3.FilterOperatorExists,
Value: "",
},
}, thirdPartyApis.Filters),
},
Expression: "error_rate",
@@ -225,18 +182,7 @@ func BuildDomainList(thirdPartyApis *ThirdPartyApis) (*v3.QueryRangeParamsV3, er
SpaceAggregation: v3.SpaceAggregationSum,
Filters: &v3.FilterSet{
Operator: "AND",
Items: getFilterSet([]v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "http.url",
DataType: v3.AttributeKeyDataTypeString,
IsColumn: false,
Type: v3.AttributeKeyTypeTag,
},
Operator: v3.FilterOperatorExists,
Value: "",
},
}, thirdPartyApis.Filters),
Items: getFilterSet([]v3.FilterItem{}, thirdPartyApis.Filters),
},
Expression: "p99",
GroupBy: getGroupBy([]v3.AttributeKey{
@@ -288,18 +234,7 @@ func BuildDomainInfo(thirdPartyApis *ThirdPartyApis) (*v3.QueryRangeParamsV3, er
SpaceAggregation: v3.SpaceAggregationSum,
Filters: &v3.FilterSet{
Operator: "AND",
Items: getFilterSet([]v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "http.url",
DataType: v3.AttributeKeyDataTypeString,
IsColumn: false,
Type: v3.AttributeKeyTypeTag,
},
Operator: v3.FilterOperatorExists,
Value: "",
},
}, thirdPartyApis.Filters),
Items: getFilterSet([]v3.FilterItem{}, thirdPartyApis.Filters),
},
Expression: "endpoints",
Disabled: false,
@@ -328,18 +263,7 @@ func BuildDomainInfo(thirdPartyApis *ThirdPartyApis) (*v3.QueryRangeParamsV3, er
SpaceAggregation: v3.SpaceAggregationSum,
Filters: &v3.FilterSet{
Operator: "AND",
Items: getFilterSet([]v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "http.url",
DataType: v3.AttributeKeyDataTypeString,
IsColumn: false,
Type: v3.AttributeKeyTypeTag,
},
Operator: v3.FilterOperatorExists,
Value: "",
},
}, thirdPartyApis.Filters),
Items: getFilterSet([]v3.FilterItem{}, thirdPartyApis.Filters),
},
Expression: "p99",
Disabled: false,
@@ -361,18 +285,7 @@ func BuildDomainInfo(thirdPartyApis *ThirdPartyApis) (*v3.QueryRangeParamsV3, er
SpaceAggregation: v3.SpaceAggregationSum,
Filters: &v3.FilterSet{
Operator: "AND",
Items: getFilterSet([]v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "http.url",
DataType: v3.AttributeKeyDataTypeString,
IsColumn: false,
Type: v3.AttributeKeyTypeTag,
},
Operator: v3.FilterOperatorExists,
Value: "",
},
}, thirdPartyApis.Filters),
Items: getFilterSet([]v3.FilterItem{}, thirdPartyApis.Filters),
},
Expression: "error_rate",
Disabled: false,
@@ -393,18 +306,7 @@ func BuildDomainInfo(thirdPartyApis *ThirdPartyApis) (*v3.QueryRangeParamsV3, er
SpaceAggregation: v3.SpaceAggregationSum,
Filters: &v3.FilterSet{
Operator: "AND",
Items: getFilterSet([]v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "http.url",
DataType: v3.AttributeKeyDataTypeString,
IsColumn: false,
Type: v3.AttributeKeyTypeTag,
},
Operator: v3.FilterOperatorExists,
Value: "",
},
}, thirdPartyApis.Filters),
Items: getFilterSet([]v3.FilterItem{}, thirdPartyApis.Filters),
},
Expression: "lastseen",
Disabled: false,

View File

@@ -0,0 +1,145 @@
package traceFunnels
import (
"fmt"
"sort"
"sync"
"github.com/google/uuid"
)
type FunnelStore struct {
sync.RWMutex
funnels map[string]*Funnel
}
func (s *FunnelStore) CreateFunnel(name, userID, orgID string, timestamp int64) (*Funnel, error) {
s.Lock()
defer s.Unlock()
for _, existingFunnel := range s.funnels {
if existingFunnel.Name == name && existingFunnel.CreatedBy == userID {
return nil, fmt.Errorf("funnel with name '%s' already exists for user '%s'", name, userID)
}
}
if timestamp == 0 {
return nil, fmt.Errorf("timestamp is required")
}
funnel := &Funnel{
ID: uuid.New().String(),
Name: name,
CreatedAt: timestamp * 1000000, // Convert milliseconds to nanoseconds for internal storage
CreatedBy: userID,
OrgID: orgID,
Steps: make([]FunnelStep, 0),
}
s.funnels[funnel.ID] = funnel
return funnel, nil
}
func (s *FunnelStore) GetFunnel(id string) (*Funnel, error) {
s.RLock()
defer s.RUnlock()
funnel, ok := s.funnels[id]
if !ok {
return nil, fmt.Errorf("funnel not found")
}
return funnel, nil
}
func (s *FunnelStore) ListFunnels() []*Funnel {
s.RLock()
defer s.RUnlock()
funnels := make([]*Funnel, 0, len(s.funnels))
for _, funnel := range s.funnels {
funnels = append(funnels, funnel)
}
return funnels
}
func (s *FunnelStore) UpdateFunnelSteps(id string, steps []FunnelStep, updatedBy string, updatedAt int64) error {
s.Lock()
defer s.Unlock()
funnel, ok := s.funnels[id]
if !ok {
return fmt.Errorf("funnel with ID %s not found", id)
}
funnel.Steps = steps
funnel.UpdatedAt = updatedAt * 1000000
funnel.UpdatedBy = updatedBy
return nil
}
// DeleteFunnel removes a funnel from the in-memory store
func (s *FunnelStore) DeleteFunnel(id string) error {
s.Lock()
defer s.Unlock()
if _, ok := s.funnels[id]; !ok {
return fmt.Errorf("funnel with ID %s not found", id)
}
delete(s.funnels, id)
return nil
}
// ValidateFunnelSteps validates funnel steps and ensures they have unique and correct order
// Rules: At least 2 steps, max 3 steps, orders must be unique and include 1 and 2
func ValidateFunnelSteps(steps []FunnelStep) error {
if len(steps) < 2 {
return fmt.Errorf("at least 2 funnel steps are required")
}
if len(steps) > 3 {
return fmt.Errorf("maximum 3 funnel steps are allowed")
}
orderMap := make(map[int64]bool)
for _, step := range steps {
if orderMap[step.StepOrder] {
return fmt.Errorf("duplicate step order: %d", step.StepOrder)
}
orderMap[step.StepOrder] = true
if step.StepOrder < 1 || step.StepOrder > 3 {
return fmt.Errorf("step order must be between 1 and 3, got: %d", step.StepOrder)
}
}
if !orderMap[1] || !orderMap[2] {
return fmt.Errorf("funnel steps with orders 1 and 2 are mandatory")
}
return nil
}
// NormalizeFunnelSteps ensures steps have sequential orders starting from 1
// This sorts steps by order and then reassigns orders to be sequential
func NormalizeFunnelSteps(steps []FunnelStep) []FunnelStep {
// Create a copy of the input slice
sortedSteps := make([]FunnelStep, len(steps))
copy(sortedSteps, steps)
// Sort using Go's built-in sort.Slice function
sort.Slice(sortedSteps, func(i, j int) bool {
return sortedSteps[i].StepOrder < sortedSteps[j].StepOrder
})
// Normalize orders to be sequential starting from 1
for i := 0; i < len(sortedSteps); i++ {
sortedSteps[i].StepOrder = int64(i + 1)
}
return sortedSteps
}

View File

@@ -0,0 +1,81 @@
package traceFunnels
import v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
type Funnel struct {
ID string `json:"id"`
Name string `json:"funnel_name"`
CreatedAt int64 `json:"creation_timestamp"`
CreatedBy string `json:"user_id"`
OrgID string `json:"org_id"`
UpdatedAt int64 `json:"updated_timestamp,omitempty"`
UpdatedBy string `json:"updated_by,omitempty"`
Steps []FunnelStep `json:"steps"`
}
// FunnelStep Models
type FunnelStep struct {
StepOrder int64 `json:"step_order"` // Order of the step in the funnel (1-based)
ServiceName string `json:"service_name"` // Service name for the span
SpanName string `json:"span_name"` // Span name to match
Filters *v3.FilterSet `json:"filters"` // Additional SQL filters
LatencyPointer string `json:"latency_pointer"` // "start" or "end"
LatencyType string `json:"latency_type"` // "p99", "p95", "p90"
HasErrors bool `json:"has_errors"` // Whether to include error spans
}
// NewFunnelRequest Request/Response structures
// NewFunnelRequest is used to create a new funnel without steps
// Steps should be added separately using the update endpoint
type NewFunnelRequest struct {
Name string `json:"funnel_name"`
Timestamp int64 `json:"creation_timestamp"` // Unix milliseconds timestamp
}
type NewFunnelResponse struct {
ID string `json:"funnel_id"`
Name string `json:"funnel_name"`
CreatedAt int64 `json:"creation_timestamp"`
CreatedBy string `json:"user_id"`
OrgID string `json:"org_id"`
}
type FunnelListResponse struct {
ID string `json:"id"`
Name string `json:"funnel_name"`
CreatedAt int64 `json:"creation_timestamp"` // Unix nano timestamp
CreatedBy string `json:"user_id"`
OrgID string `json:"org_id,omitempty"`
}
// FunnelStepRequest is used to add or update steps for an existing funnel
type FunnelStepRequest struct {
FunnelID string `json:"funnel_id"`
Steps []FunnelStep `json:"steps"`
Timestamp int64 `json:"updated_timestamp"` // Unix milliseconds timestamp for update time
}
// TimeRange Analytics request/response types
type TimeRange struct {
StartTime int64 `json:"start_time"` // Unix nano
EndTime int64 `json:"end_time"` // Unix nano
}
type StepTransitionRequest struct {
TimeRange
StepAOrder int64 `json:"step_a_order"` // First step in transition
StepBOrder int64 `json:"step_b_order"` // Second step in transition
}
type ValidTracesResponse struct {
TraceIDs []string `json:"trace_ids"`
}
type FunnelAnalytics struct {
TotalStart int64 `json:"total_start"`
TotalComplete int64 `json:"total_complete"`
ErrorCount int64 `json:"error_count"`
AvgDurationMs float64 `json:"avg_duration_ms"`
P99LatencyMs float64 `json:"p99_latency_ms"`
ConversionRate float64 `json:"conversion_rate"`
}

View File

@@ -0,0 +1,474 @@
package traceFunnels
import (
"fmt"
"strings"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
)
// TracesTable is the ClickHouse table name for traces
const TracesTable = "signoz_traces.signoz_index_v3"
// StepAnalytics represents analytics data for a single step in a funnel
type StepAnalytics struct {
StepOrder int64 `json:"stepOrder"`
TotalSpans int64 `json:"totalSpans"`
ErroredSpans int64 `json:"erroredSpans"`
AvgDurationMs string `json:"avgDurationMs"`
}
// FunnelStepFilter represents filters for a single step in the funnel
type FunnelStepFilter struct {
StepNumber int
ServiceName string
SpanName string
LatencyPointer string // "start" or "end"
CustomFilters *v3.FilterSet
}
// SlowTrace represents a trace with its duration and span count
type SlowTrace struct {
TraceID string `json:"traceId"`
DurationMs string `json:"durationMs"`
SpanCount int64 `json:"spanCount"`
}
// ValidateTraces parses the Funnel and builds a query to validate traces
func ValidateTraces(funnel *Funnel, timeRange TimeRange) (*v3.ClickHouseQuery, error) {
filters, err := buildFunnelFilters(funnel)
if err != nil {
return nil, fmt.Errorf("error building funnel filters: %w", err)
}
query := generateFunnelSQL(timeRange.StartTime, timeRange.EndTime, filters)
return &v3.ClickHouseQuery{
Query: query,
}, nil
}
// ValidateTracesWithLatency builds a query that considers the latency pointer for trace calculations
func ValidateTracesWithLatency(funnel *Funnel, timeRange TimeRange) (*v3.ClickHouseQuery, error) {
filters, err := buildFunnelFiltersWithLatency(funnel)
if err != nil {
return nil, fmt.Errorf("error building funnel filters with latency: %w", err)
}
query := generateFunnelSQLWithLatency(timeRange.StartTime, timeRange.EndTime, filters)
return &v3.ClickHouseQuery{
Query: query,
}, nil
}
// buildFunnelFilters extracts filters from funnel steps (without latency pointer)
func buildFunnelFilters(funnel *Funnel) ([]FunnelStepFilter, error) {
if funnel == nil {
return nil, fmt.Errorf("funnel cannot be nil")
}
if len(funnel.Steps) == 0 {
return nil, fmt.Errorf("funnel must have at least one step")
}
filters := make([]FunnelStepFilter, len(funnel.Steps))
for i, step := range funnel.Steps {
filters[i] = FunnelStepFilter{
StepNumber: i + 1,
ServiceName: step.ServiceName,
SpanName: step.SpanName,
CustomFilters: step.Filters,
}
}
return filters, nil
}
// buildFunnelFiltersWithLatency extracts filters including the latency pointer
func buildFunnelFiltersWithLatency(funnel *Funnel) ([]FunnelStepFilter, error) {
if funnel == nil {
return nil, fmt.Errorf("funnel cannot be nil")
}
if len(funnel.Steps) == 0 {
return nil, fmt.Errorf("funnel must have at least one step")
}
filters := make([]FunnelStepFilter, len(funnel.Steps))
for i, step := range funnel.Steps {
latencyPointer := "start" // Default value
if step.LatencyPointer != "" {
latencyPointer = step.LatencyPointer
}
filters[i] = FunnelStepFilter{
StepNumber: i + 1,
ServiceName: step.ServiceName,
SpanName: step.SpanName,
LatencyPointer: latencyPointer,
CustomFilters: step.Filters,
}
}
return filters, nil
}
// escapeString escapes a string for safe use in SQL queries
func escapeString(s string) string {
// Replace single quotes with double single quotes to escape them in SQL
return strings.ReplaceAll(s, "'", "''")
}
// generateFunnelSQL builds the ClickHouse SQL query for funnel validation
func generateFunnelSQL(start, end int64, filters []FunnelStepFilter) string {
var expressions []string
// Basic time expressions.
expressions = append(expressions, fmt.Sprintf("toUInt64(%d) AS start_time", start))
expressions = append(expressions, fmt.Sprintf("toUInt64(%d) AS end_time", end))
expressions = append(expressions, "toString(intDiv(start_time, 1000000000) - 1800) AS tsBucketStart")
expressions = append(expressions, "toString(intDiv(end_time, 1000000000)) AS tsBucketEnd")
// Add service and span alias definitions from each filter.
for _, f := range filters {
expressions = append(expressions, fmt.Sprintf("'%s' AS service_%d", escapeString(f.ServiceName), f.StepNumber))
expressions = append(expressions, fmt.Sprintf("'%s' AS span_%d", escapeString(f.SpanName), f.StepNumber))
}
// Add the CTE for each step.
for _, f := range filters {
cte := fmt.Sprintf(`step%d_traces AS (
SELECT DISTINCT trace_id
FROM %s
WHERE serviceName = service_%d
AND name = span_%d
AND timestamp BETWEEN toString(start_time) AND toString(end_time)
AND ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
)`, f.StepNumber, TracesTable, f.StepNumber, f.StepNumber)
expressions = append(expressions, cte)
}
// Join all expressions with commas and newlines
withClause := "WITH \n" + strings.Join(expressions, ",\n") + "\n"
// Build the intersect clause for each step.
var intersectQueries []string
for _, f := range filters {
intersectQueries = append(intersectQueries, fmt.Sprintf("SELECT trace_id FROM step%d_traces", f.StepNumber))
}
intersectClause := strings.Join(intersectQueries, "\nINTERSECT\n")
query := withClause + `
SELECT trace_id
FROM ` + TracesTable + `
WHERE trace_id IN (
` + intersectClause + `
)
AND timestamp BETWEEN toString(start_time) AND toString(end_time)
AND ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
GROUP BY trace_id
LIMIT 5
`
return query
}
// generateFunnelSQLWithLatency correctly applies latency pointer logic for trace duration
func generateFunnelSQLWithLatency(start, end int64, filters []FunnelStepFilter) string {
var expressions []string
// Define the base time variables
expressions = append(expressions, fmt.Sprintf("toUInt64(%d) AS start_time", start))
expressions = append(expressions, fmt.Sprintf("toUInt64(%d) AS end_time", end))
expressions = append(expressions, "toString(intDiv(start_time, 1000000000) - 1800) AS tsBucketStart")
expressions = append(expressions, "toString(intDiv(end_time, 1000000000)) AS tsBucketEnd")
expressions = append(expressions, "(end_time - start_time) / 1e9 AS total_time_seconds")
// Define service, span, and latency pointer mappings
for _, f := range filters {
expressions = append(expressions, fmt.Sprintf("('%s', '%s', '%s') AS s%d_config",
escapeString(f.ServiceName),
escapeString(f.SpanName),
escapeString(f.LatencyPointer),
f.StepNumber))
}
// Construct the WITH clause
withClause := "WITH \n" + strings.Join(expressions, ",\n") + "\n"
// Latency calculation logic
var latencyCases []string
for _, f := range filters {
if f.LatencyPointer == "end" {
latencyCases = append(latencyCases, fmt.Sprintf(`
WHEN (resource_string_service$$name, name) = (s%d_config.1, s%d_config.2)
THEN toUnixTimestamp64Nano(timestamp) + duration_nano`, f.StepNumber, f.StepNumber))
} else {
latencyCases = append(latencyCases, fmt.Sprintf(`
WHEN (resource_string_service$$name, name) = (s%d_config.1, s%d_config.2)
THEN toUnixTimestamp64Nano(timestamp)`, f.StepNumber, f.StepNumber))
}
}
latencyComputation := fmt.Sprintf(`
MAX(
CASE %s
ELSE toUnixTimestamp64Nano(timestamp)
END
) -
MIN(
CASE %s
ELSE toUnixTimestamp64Nano(timestamp)
END
) AS trace_duration`, strings.Join(latencyCases, ""), strings.Join(latencyCases, ""))
query := withClause + `
SELECT
COUNT(DISTINCT CASE WHEN in_funnel_s1 = 1 THEN trace_id END) AS total_s1,
COUNT(DISTINCT CASE WHEN in_funnel_s3 = 1 THEN trace_id END) AS total_s3,
COUNT(DISTINCT CASE WHEN in_funnel_s3 = 1 THEN trace_id END) / total_time_seconds AS avg_rate,
COUNT(DISTINCT CASE WHEN in_funnel_s3 = 1 AND has_error = true THEN trace_id END) AS errors,
avg(trace_duration) AS avg_duration,
quantile(0.99)(trace_duration) AS p99_latency,
100 - (
(COUNT(DISTINCT CASE WHEN in_funnel_s1 = 1 THEN trace_id END) -
COUNT(DISTINCT CASE WHEN in_funnel_s3 = 1 THEN trace_id END))
/ NULLIF(COUNT(DISTINCT CASE WHEN in_funnel_s1 = 1 THEN trace_id END), 0) * 100
) AS conversion_rate
FROM (
SELECT
trace_id,
` + latencyComputation + `,
MAX(has_error) AS has_error,
MAX(CASE WHEN (resource_string_service$$name, name) = (s1_config.1, s1_config.2) THEN 1 ELSE 0 END) AS in_funnel_s1,
MAX(CASE WHEN (resource_string_service$$name, name) = (s3_config.1, s3_config.2) THEN 1 ELSE 0 END) AS in_funnel_s3
FROM ` + TracesTable + `
WHERE timestamp BETWEEN toString(start_time) AND toString(end_time)
AND ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
AND (resource_string_service$$name, name) IN (` + generateFilterConditions(filters) + `)
GROUP BY trace_id
) AS trace_metrics;
`
return query
}
// generateFilterConditions creates the filtering conditions dynamically
func generateFilterConditions(filters []FunnelStepFilter) string {
var conditions []string
for _, f := range filters {
conditions = append(conditions, fmt.Sprintf("(s%d_config.1, s%d_config.2)", f.StepNumber, f.StepNumber))
}
return strings.Join(conditions, ", ")
}
// GetStepAnalytics builds a query to get analytics for each step in a funnel
func GetStepAnalytics(funnel *Funnel, timeRange TimeRange) (*v3.ClickHouseQuery, error) {
if len(funnel.Steps) == 0 {
return nil, fmt.Errorf("funnel has no steps")
}
// Build funnel steps array
var steps []string
for _, step := range funnel.Steps {
steps = append(steps, fmt.Sprintf("('%s', '%s')",
escapeString(step.ServiceName), escapeString(step.SpanName)))
}
stepsArray := fmt.Sprintf("array(%s)", strings.Join(steps, ","))
// Build step CTEs
var stepCTEs []string
for i, step := range funnel.Steps {
filterStr := ""
if step.Filters != nil && len(step.Filters.Items) > 0 {
// This is a placeholder - in a real implementation, you would convert
// the filter set to a SQL WHERE clause string
filterStr = "/* Custom filters would be applied here */"
}
cte := fmt.Sprintf(`
step%d_traces AS (
SELECT DISTINCT trace_id
FROM %s
WHERE resource_string_service$$name = '%s'
AND name = '%s'
AND timestamp BETWEEN toString(start_time) AND toString(end_time)
AND ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
%s
)`,
i+1,
TracesTable,
escapeString(step.ServiceName),
escapeString(step.SpanName),
filterStr,
)
stepCTEs = append(stepCTEs, cte)
}
// Build intersecting traces CTE
var intersections []string
for i := 1; i <= len(funnel.Steps); i++ {
intersections = append(intersections, fmt.Sprintf("SELECT trace_id FROM step%d_traces", i))
}
intersectingTracesCTE := fmt.Sprintf(`
intersecting_traces AS (
%s
)`,
strings.Join(intersections, "\nINTERSECT\n"),
)
// Build CASE expressions for each step
var caseExpressions []string
for i, step := range funnel.Steps {
totalSpansExpr := fmt.Sprintf(`
COUNT(CASE WHEN resource_string_service$$name = '%s'
AND name = '%s'
THEN trace_id END) AS total_s%d_spans`,
escapeString(step.ServiceName), escapeString(step.SpanName), i+1)
erroredSpansExpr := fmt.Sprintf(`
COUNT(CASE WHEN resource_string_service$$name = '%s'
AND name = '%s'
AND has_error = true
THEN trace_id END) AS total_s%d_errored_spans`,
escapeString(step.ServiceName), escapeString(step.SpanName), i+1)
caseExpressions = append(caseExpressions, totalSpansExpr, erroredSpansExpr)
}
query := fmt.Sprintf(`
WITH
toUInt64(%d) AS start_time,
toUInt64(%d) AS end_time,
toString(intDiv(start_time, 1000000000) - 1800) AS tsBucketStart,
toString(intDiv(end_time, 1000000000)) AS tsBucketEnd,
%s AS funnel_steps,
%s,
%s
SELECT
%s
FROM %s
WHERE trace_id IN (SELECT trace_id FROM intersecting_traces)
AND timestamp BETWEEN toString(start_time) AND toString(end_time)
AND ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd`,
timeRange.StartTime,
timeRange.EndTime,
stepsArray,
strings.Join(stepCTEs, ",\n"),
intersectingTracesCTE,
strings.Join(caseExpressions, ",\n "),
TracesTable,
)
return &v3.ClickHouseQuery{
Query: query,
}, nil
}
// buildFilters converts a step's filters to a SQL WHERE clause string
func buildFilters(step FunnelStep) string {
if step.Filters == nil || len(step.Filters.Items) == 0 {
return ""
}
// This is a placeholder - in a real implementation, you would convert
// the filter set to a SQL WHERE clause string
return "/* Custom filters would be applied here */"
}
// GetSlowestTraces builds a query to get the slowest traces for a transition between two steps
func GetSlowestTraces(funnel *Funnel, stepAOrder int64, stepBOrder int64, timeRange TimeRange, withErrors bool) (*v3.ClickHouseQuery, error) {
// Find steps by order
var stepA, stepB *FunnelStep
for i := range funnel.Steps {
if funnel.Steps[i].StepOrder == stepAOrder {
stepA = &funnel.Steps[i]
}
if funnel.Steps[i].StepOrder == stepBOrder {
stepB = &funnel.Steps[i]
}
}
if stepA == nil || stepB == nil {
return nil, fmt.Errorf("step not found")
}
// Build having clause based on withErrors flag
havingClause := ""
if withErrors {
havingClause = "HAVING has_error = 1"
}
// Build filter strings for each step
stepAFilters := ""
if stepA.Filters != nil && len(stepA.Filters.Items) > 0 {
// This is a placeholder - in a real implementation, you would convert
// the filter set to a SQL WHERE clause string
stepAFilters = "/* Custom filters for step A would be applied here */"
}
stepBFilters := ""
if stepB.Filters != nil && len(stepB.Filters.Items) > 0 {
// This is a placeholder - in a real implementation, you would convert
// the filter set to a SQL WHERE clause string
stepBFilters = "/* Custom filters for step B would be applied here */"
}
query := fmt.Sprintf(`
WITH
toUInt64(%d) AS start_time,
toUInt64(%d) AS end_time,
toString(intDiv(start_time, 1000000000) - 1800) AS tsBucketStart,
toString(intDiv(end_time, 1000000000)) AS tsBucketEnd
SELECT
trace_id,
concat(toString((max_end_time_ns - min_start_time_ns) / 1e6), ' ms') AS duration_ms,
COUNT(*) AS span_count
FROM (
SELECT
s1.trace_id,
MIN(toUnixTimestamp64Nano(s1.timestamp)) AS min_start_time_ns,
MAX(toUnixTimestamp64Nano(s2.timestamp) + s2.duration_nano) AS max_end_time_ns,
MAX(s1.has_error OR s2.has_error) AS has_error
FROM %s AS s1
JOIN %s AS s2
ON s1.trace_id = s2.trace_id
WHERE s1.resource_string_service$$name = '%s'
AND s1.name = '%s'
AND s2.resource_string_service$$name = '%s'
AND s2.name = '%s'
AND s1.timestamp BETWEEN toString(start_time) AND toString(end_time)
AND s1.ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
AND s2.timestamp BETWEEN toString(start_time) AND toString(end_time)
AND s2.ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
%s
%s
GROUP BY s1.trace_id
%s
) AS trace_durations
JOIN %s AS spans
ON spans.trace_id = trace_durations.trace_id
WHERE spans.timestamp BETWEEN toString(start_time) AND toString(end_time)
AND spans.ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
GROUP BY trace_id, duration_ms
ORDER BY CAST(replaceRegexpAll(duration_ms, ' ms$', '') AS Float64) DESC
LIMIT 5`,
timeRange.StartTime,
timeRange.EndTime,
TracesTable,
TracesTable,
escapeString(stepA.ServiceName),
escapeString(stepA.SpanName),
escapeString(stepB.ServiceName),
escapeString(stepB.SpanName),
stepAFilters,
stepBFilters,
havingClause,
TracesTable,
)
return &v3.ClickHouseQuery{
Query: query,
}, nil
}

View File

@@ -0,0 +1,206 @@
package traceFunnels
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
)
// SQLClient handles persistence of funnels to the database
type SQLClient struct {
store sqlstore.SQLStore
}
// NewSQLClient creates a new SQL client
func NewSQLClient(store sqlstore.SQLStore) (*SQLClient, error) {
return &SQLClient{store: store}, nil
}
// SaveFunnelRequest is used to save a funnel to the database
type SaveFunnelRequest struct {
FunnelID string `json:"funnel_id"` // Required: ID of the funnel to save
UserID string `json:"user_id,omitempty"` // Optional: will use existing user ID if not provided
OrgID string `json:"org_id,omitempty"` // Optional: will use existing org ID if not provided
Tags string `json:"tags,omitempty"` // Optional: comma-separated tags
Description string `json:"description,omitempty"` // Optional: human-readable description
Timestamp int64 `json:"timestamp,omitempty"` // Optional: timestamp for update in milliseconds (uses current time if not provided)
}
// SaveFunnel saves a funnel to the database in the saved_views table
// Handles both creating new funnels and updating existing ones
func (c *SQLClient) SaveFunnel(funnel *Funnel, userID, orgID string, tags, extraData string) error {
ctx := context.Background()
db := c.store.BunDB()
// Convert funnel to JSON for storage
funnelData, err := json.Marshal(funnel)
if err != nil {
return fmt.Errorf("failed to marshal funnel data: %v", err)
}
// Format timestamps as RFC3339
// Convert nanoseconds to milliseconds for display, then to time.Time for formatting
createdAt := time.Unix(0, funnel.CreatedAt).UTC().Format(time.RFC3339)
updatedAt := createdAt
updatedBy := userID
// If funnel has update metadata, use it
if funnel.UpdatedAt > 0 {
updatedAt = time.Unix(0, funnel.UpdatedAt).UTC().Format(time.RFC3339)
}
if funnel.UpdatedBy != "" {
updatedBy = funnel.UpdatedBy
}
// Check if the funnel already exists
var count int
var existingCreatedBy string
var existingCreatedAt string
err = db.NewRaw("SELECT COUNT(*), IFNULL(created_by, ''), IFNULL(created_at, '') FROM saved_views WHERE uuid = ? AND category = 'funnel'", funnel.ID).
Scan(ctx, &count, &existingCreatedBy, &existingCreatedAt)
if err != nil {
return fmt.Errorf("failed to check if funnel exists: %v", err)
}
if count > 0 {
// Update existing funnel - preserve created_by and created_at
_, err = db.NewRaw(
"UPDATE saved_views SET name = ?, data = ?, updated_by = ?, updated_at = ?, tags = ?, extra_data = ? WHERE uuid = ? AND category = 'funnel'",
funnel.Name, string(funnelData), updatedBy, updatedAt, tags, extraData, funnel.ID,
).Exec(ctx)
if err != nil {
return fmt.Errorf("failed to update funnel: %v", err)
}
} else {
// Insert new funnel - set both created and updated fields
savedView := &types.SavedView{
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
UserAuditable: types.UserAuditable{
CreatedBy: userID,
UpdatedBy: updatedBy,
},
UUID: funnel.ID,
Name: funnel.Name,
Category: "funnel",
SourcePage: "trace-funnels",
OrgID: orgID,
Tags: tags,
Data: string(funnelData),
ExtraData: extraData,
}
_, err = db.NewInsert().Model(savedView).Exec(ctx)
if err != nil {
return fmt.Errorf("failed to insert funnel: %v", err)
}
}
return nil
}
// GetFunnelFromDB retrieves a funnel from the database
func (c *SQLClient) GetFunnelFromDB(funnelID string) (*Funnel, error) {
ctx := context.Background()
db := c.store.BunDB()
var savedView types.SavedView
err := db.NewSelect().
Model(&savedView).
Where("uuid = ? AND category = 'funnel'", funnelID).
Scan(ctx)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("funnel not found")
}
return nil, fmt.Errorf("failed to get funnel: %v", err)
}
var funnel Funnel
if err := json.Unmarshal([]byte(savedView.Data), &funnel); err != nil {
return nil, fmt.Errorf("failed to unmarshal funnel data: %v", err)
}
return &funnel, nil
}
// ListFunnelsFromDB lists all funnels from the database
func (c *SQLClient) ListFunnelsFromDB(orgID string) ([]*Funnel, error) {
ctx := context.Background()
db := c.store.BunDB()
var savedViews []types.SavedView
err := db.NewSelect().
Model(&savedViews).
Where("category = 'funnel' AND org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list funnels: %v", err)
}
var funnels []*Funnel
for _, view := range savedViews {
var funnel Funnel
if err := json.Unmarshal([]byte(view.Data), &funnel); err != nil {
return nil, fmt.Errorf("failed to unmarshal funnel data: %v", err)
}
funnels = append(funnels, &funnel)
}
return funnels, nil
}
// ListAllFunnelsFromDB lists all funnels from the database without org_id filter
func (c *SQLClient) ListAllFunnelsFromDB() ([]*Funnel, error) {
ctx := context.Background()
db := c.store.BunDB()
var savedViews []types.SavedView
err := db.NewSelect().
Model(&savedViews).
Where("category = 'funnel'").
Scan(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list all funnels: %v", err)
}
var funnels []*Funnel
for _, view := range savedViews {
var funnel Funnel
if err := json.Unmarshal([]byte(view.Data), &funnel); err != nil {
return nil, fmt.Errorf("failed to unmarshal funnel data: %v", err)
}
funnels = append(funnels, &funnel)
}
return funnels, nil
}
// DeleteFunnelFromDB deletes a funnel from the database
func (c *SQLClient) DeleteFunnelFromDB(funnelID string) error {
ctx := context.Background()
db := c.store.BunDB()
_, err := db.NewDelete().
Model(&types.SavedView{}).
Where("uuid = ? AND category = 'funnel'", funnelID).
Exec(ctx)
if err != nil {
return fmt.Errorf("failed to delete funnel: %v", err)
}
return nil
}

View File

@@ -0,0 +1,32 @@
package traceFunnels
import (
"fmt"
"strconv"
)
// ValidateTimestampIsMilliseconds checks if a timestamp is likely in milliseconds format
func ValidateTimestampIsMilliseconds(timestamp int64) bool {
// If timestamp is 0, it's not valid
if timestamp == 0 {
return false
}
timestampStr := strconv.FormatInt(timestamp, 10)
return len(timestampStr) >= 12 && len(timestampStr) <= 14
}
// ValidateTimestamp checks if a timestamp is provided and in milliseconds format
// Returns an error if validation fails
func ValidateTimestamp(timestamp int64, fieldName string) error {
if timestamp == 0 {
return fmt.Errorf("%s is required", fieldName)
}
if !ValidateTimestampIsMilliseconds(timestamp) {
return fmt.Errorf("%s must be in milliseconds format (13 digits)", fieldName)
}
return nil
}

View File

@@ -225,21 +225,21 @@ func (receiver *SummaryService) GetMetricsTreemap(ctx context.Context, params *m
var response metrics_explorer.TreeMap
switch params.Treemap {
case metrics_explorer.TimeSeriesTeeMap:
ts, apiError := receiver.reader.GetMetricsTimeSeriesPercentage(ctx, params)
cardinality, apiError := receiver.reader.GetMetricsTimeSeriesPercentage(ctx, params)
if apiError != nil {
return nil, apiError
}
if ts != nil {
response.TimeSeries = *ts
if cardinality != nil {
response.TimeSeries = *cardinality
}
return &response, nil
case metrics_explorer.SamplesTreeMap:
samples, apiError := receiver.reader.GetMetricsSamplesPercentage(ctx, params)
dataPoints, apiError := receiver.reader.GetMetricsSamplesPercentage(ctx, params)
if apiError != nil {
return nil, apiError
}
if samples != nil {
response.Samples = *samples
if dataPoints != nil {
response.Samples = *dataPoints
}
return &response, nil
default:

View File

@@ -2,6 +2,7 @@ package opamp
import (
"context"
"time"
model "github.com/SigNoz/signoz/pkg/query-service/app/opamp/model"
"github.com/open-telemetry/opamp-go/protobufs"
@@ -81,10 +82,18 @@ func (srv *Server) onDisconnect(conn types.Connection) {
func (srv *Server) OnMessage(conn types.Connection, msg *protobufs.AgentToServer) *protobufs.ServerToAgent {
agentID := msg.InstanceUid
agent, created, err := srv.agents.FindOrCreateAgent(agentID, conn)
if err != nil {
zap.L().Error("Failed to find or create agent", zap.String("agentID", agentID), zap.Error(err))
// TODO: handle error
sleep := 1 * time.Second
var agent *model.Agent
var created bool
var err error
for {
agent, created, err = srv.agents.FindOrCreateAgent(agentID, conn)
if err == nil {
break
}
zap.L().Error("Failed to find or create agent retrying....", zap.String("agentID", agentID), zap.Error(err), zap.Duration("backoff", sleep))
time.Sleep(sleep)
sleep *= 2
}
if created {

View File

@@ -332,6 +332,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
api.RegisterMessagingQueuesRoutes(r, am)
api.RegisterThirdPartyApiRoutes(r, am)
api.MetricExplorerRoutes(r, am)
api.RegisterTraceFunnelsRoutes(r, am)
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},

View File

@@ -89,17 +89,18 @@ func (aH *APIHandler) GetTreeMap(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
params, apiError := explorer.ParseTreeMapMetricsParams(r)
if apiError != nil {
zap.L().Error("error parsing tree map metric params", zap.Error(apiError.Err))
zap.L().Error("error parsing heatmap metric params", zap.Error(apiError.Err))
RespondError(w, apiError, nil)
return
}
result, apiError := aH.SummaryService.GetMetricsTreemap(ctx, params)
if apiError != nil {
zap.L().Error("error getting tree map data", zap.Error(apiError.Err))
zap.L().Error("error getting heatmap data", zap.Error(apiError.Err))
RespondError(w, apiError, nil)
return
}
aH.Respond(w, result)
}
func (aH *APIHandler) GetRelatedMetrics(w http.ResponseWriter, r *http.Request) {

View File

@@ -20,7 +20,6 @@ import (
smtpservice "github.com/SigNoz/signoz/pkg/query-service/utils/smtpService"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)
@@ -88,18 +87,12 @@ func Invite(ctx context.Context, req *model.InviteRequest) (*model.InviteRespons
}
inv := &types.Invite{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Name: req.Name,
Email: req.Email,
Token: token,
Role: req.Role,
OrgID: au.OrgID,
Name: req.Name,
Email: req.Email,
Token: token,
CreatedAt: time.Now(),
Role: req.Role,
OrgID: au.OrgID,
}
if err := dao.DB().CreateInviteEntry(ctx, inv); err != nil {
@@ -195,18 +188,12 @@ func inviteUser(ctx context.Context, req *model.InviteRequest, au *types.Gettabl
}
inv := &types.Invite{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Name: req.Name,
Email: req.Email,
Token: token,
Role: req.Role,
OrgID: au.OrgID,
Name: req.Name,
Email: req.Email,
Token: token,
CreatedAt: time.Now(),
Role: req.Role,
OrgID: au.OrgID,
}
if err := dao.DB().CreateInviteEntry(ctx, inv); err != nil {

View File

@@ -23,6 +23,7 @@ import (
func initZapLog() *zap.Logger {
config := zap.NewProductionConfig()
config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
config.EncoderConfig.TimeKey = "timestamp"
config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
logger, _ := config.Build()

View File

@@ -9,7 +9,7 @@ type SummaryListMetricsRequest struct {
Limit int `json:"limit"`
OrderBy v3.OrderBy `json:"orderBy"`
Start int64 `json:"start"`
End int64 `json:"end"`
EndD int64 `json:"end"`
Filters v3.FilterSet `json:"filters"`
}
@@ -24,7 +24,7 @@ type TreeMapMetricsRequest struct {
Limit int `json:"limit"`
Treemap TreeMapType `json:"treemap"`
Start int64 `json:"start"`
End int64 `json:"end"`
EndD int64 `json:"end"`
Filters v3.FilterSet `json:"filters"`
}

View File

@@ -9,7 +9,7 @@ import (
"strings"
"time"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/google/uuid"
"github.com/pkg/errors"
"go.uber.org/zap"
)
@@ -1412,7 +1412,7 @@ func (p *Point) UnmarshalJSON(data []byte) error {
// The source page name is used to identify the page that initiated the query
// The source page could be "traces", "logs", "metrics".
type SavedView struct {
ID valuer.UUID `json:"id,omitempty"`
UUID string `json:"uuid,omitempty"`
Name string `json:"name"`
Category string `json:"category"`
CreatedAt time.Time `json:"createdAt"`
@@ -1432,6 +1432,9 @@ func (eq *SavedView) Validate() error {
return fmt.Errorf("composite query is required")
}
if eq.UUID == "" {
eq.UUID = uuid.New().String()
}
return eq.CompositeQuery.Validate()
}

View File

@@ -62,8 +62,6 @@ func NewSQLMigrationProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedM
sqlmigration.NewUpdateDashboardAndSavedViewsFactory(sqlstore),
sqlmigration.NewUpdatePatAndOrgDomainsFactory(sqlstore),
sqlmigration.NewUpdatePipelines(sqlstore),
sqlmigration.NewDropLicensesSitesFactory(sqlstore),
sqlmigration.NewUpdateInvitesFactory(sqlstore),
)
}

View File

@@ -1,62 +0,0 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type dropLicensesSites struct {
store sqlstore.SQLStore
}
func NewDropLicensesSitesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("drop_licenses_sites"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return newDropLicensesSites(ctx, ps, c, sqlstore)
})
}
func newDropLicensesSites(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) {
return &dropLicensesSites{store: store}, nil
}
func (migration *dropLicensesSites) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *dropLicensesSites) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.NewDropTable().IfExists().Table("sites").Exec(ctx); err != nil {
return err
}
if _, err := tx.NewDropTable().IfExists().Table("licenses").Exec(ctx); err != nil {
return err
}
_, err = migration.store.Dialect().RenameColumn(ctx, tx, "saved_views", "uuid", "id")
if err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (migration *dropLicensesSites) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -1,139 +0,0 @@
package sqlmigration
import (
"context"
"database/sql"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type updateInvites struct {
store sqlstore.SQLStore
}
type existingInvite struct {
bun.BaseModel `bun:"table:invites"`
OrgID string `bun:"org_id,type:text,notnull" json:"orgId"`
ID int `bun:"id,pk,autoincrement" json:"id"`
Name string `bun:"name,type:text,notnull" json:"name"`
Email string `bun:"email,type:text,notnull,unique" json:"email"`
Token string `bun:"token,type:text,notnull" json:"token"`
CreatedAt time.Time `bun:"created_at,notnull" json:"createdAt"`
Role string `bun:"role,type:text,notnull" json:"role"`
}
type newInvite struct {
bun.BaseModel `bun:"table:user_invite"`
types.Identifiable
types.TimeAuditable
Name string `bun:"name,type:text,notnull" json:"name"`
Email string `bun:"email,type:text,notnull,unique" json:"email"`
Token string `bun:"token,type:text,notnull" json:"token"`
Role string `bun:"role,type:text,notnull" json:"role"`
OrgID string `bun:"org_id,type:text,notnull" json:"orgId"`
}
func NewUpdateInvitesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.
NewProviderFactory(
factory.MustNewName("update_invites"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return newUpdateInvites(ctx, ps, c, sqlstore)
})
}
func newUpdateInvites(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) {
return &updateInvites{store: store}, nil
}
func (migration *updateInvites) Register(migrations *migrate.Migrations) error {
if err := migrations.
Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *updateInvites) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.
BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
err = migration.
store.
Dialect().
RenameTableAndModifyModel(ctx, tx, new(existingInvite), new(newInvite), func(ctx context.Context) error {
existingInvites := make([]*existingInvite, 0)
err = tx.
NewSelect().
Model(&existingInvites).
Scan(ctx)
if err != nil {
if err != sql.ErrNoRows {
return err
}
}
if err == nil && len(existingInvites) > 0 {
newInvites := migration.
CopyOldInvitesToNewInvites(existingInvites)
_, err = tx.
NewInsert().
Model(&newInvites).
Exec(ctx)
if err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
func (migration *updateInvites) Down(context.Context, *bun.DB) error {
return nil
}
func (migration *updateInvites) CopyOldInvitesToNewInvites(existingInvites []*existingInvite) []*newInvite {
newInvites := make([]*newInvite, 0)
for _, invite := range existingInvites {
newInvites = append(newInvites, &newInvite{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
TimeAuditable: types.TimeAuditable{
CreatedAt: invite.CreatedAt,
UpdatedAt: time.Now(),
},
Name: invite.Name,
Email: invite.Email,
Token: invite.Token,
Role: invite.Role,
OrgID: invite.OrgID,
})
}
return newInvites
}

View File

@@ -2,15 +2,14 @@ package postgressqlstore
import (
"context"
"reflect"
"github.com/uptrace/bun"
)
type dialect struct {
type PGDialect struct {
}
func (dialect *dialect) MigrateIntToTimestamp(ctx context.Context, bun bun.IDB, table string, column string) error {
func (dialect *PGDialect) MigrateIntToTimestamp(ctx context.Context, bun bun.IDB, table string, column string) error {
columnType, err := dialect.GetColumnType(ctx, bun, table, column)
if err != nil {
return err
@@ -22,22 +21,16 @@ func (dialect *dialect) MigrateIntToTimestamp(ctx context.Context, bun bun.IDB,
}
// if the columns is integer then do this
if _, err := bun.
ExecContext(ctx, `ALTER TABLE `+table+` RENAME COLUMN `+column+` TO `+column+`_old`); err != nil {
if _, err := bun.ExecContext(ctx, `ALTER TABLE `+table+` RENAME COLUMN `+column+` TO `+column+`_old`); err != nil {
return err
}
// add new timestamp column
if _, err := bun.
NewAddColumn().
Table(table).
ColumnExpr(column + " TIMESTAMP").
Exec(ctx); err != nil {
if _, err := bun.NewAddColumn().Table(table).ColumnExpr(column + " TIMESTAMP").Exec(ctx); err != nil {
return err
}
if _, err := bun.
NewUpdate().
if _, err := bun.NewUpdate().
Table(table).
Set(column + " = to_timestamp(cast(" + column + "_old as INTEGER))").
Where("1=1").
@@ -46,18 +39,14 @@ func (dialect *dialect) MigrateIntToTimestamp(ctx context.Context, bun bun.IDB,
}
// drop old column
if _, err := bun.
NewDropColumn().
Table(table).
Column(column + "_old").
Exec(ctx); err != nil {
if _, err := bun.NewDropColumn().Table(table).Column(column + "_old").Exec(ctx); err != nil {
return err
}
return nil
}
func (dialect *dialect) MigrateIntToBoolean(ctx context.Context, bun bun.IDB, table string, column string) error {
func (dialect *PGDialect) MigrateIntToBoolean(ctx context.Context, bun bun.IDB, table string, column string) error {
columnType, err := dialect.GetColumnType(ctx, bun, table, column)
if err != nil {
return err
@@ -67,17 +56,12 @@ func (dialect *dialect) MigrateIntToBoolean(ctx context.Context, bun bun.IDB, ta
return nil
}
if _, err := bun.
ExecContext(ctx, `ALTER TABLE `+table+` RENAME COLUMN `+column+` TO `+column+`_old`); err != nil {
if _, err := bun.ExecContext(ctx, `ALTER TABLE `+table+` RENAME COLUMN `+column+` TO `+column+`_old`); err != nil {
return err
}
// add new boolean column
if _, err := bun.
NewAddColumn().
Table(table).
ColumnExpr(column + " BOOLEAN").
Exec(ctx); err != nil {
if _, err := bun.NewAddColumn().Table(table).ColumnExpr(column + " BOOLEAN").Exec(ctx); err != nil {
return err
}
@@ -98,7 +82,7 @@ func (dialect *dialect) MigrateIntToBoolean(ctx context.Context, bun bun.IDB, ta
return nil
}
func (dialect *dialect) GetColumnType(ctx context.Context, bun bun.IDB, table string, column string) (string, error) {
func (dialect *PGDialect) GetColumnType(ctx context.Context, bun bun.IDB, table string, column string) (string, error) {
var columnType string
err := bun.NewSelect().
@@ -114,7 +98,7 @@ func (dialect *dialect) GetColumnType(ctx context.Context, bun bun.IDB, table st
return columnType, nil
}
func (dialect *dialect) ColumnExists(ctx context.Context, bun bun.IDB, table string, column string) (bool, error) {
func (dialect *PGDialect) ColumnExists(ctx context.Context, bun bun.IDB, table string, column string) (bool, error) {
var count int
err := bun.NewSelect().
ColumnExpr("COUNT(*)").
@@ -129,83 +113,3 @@ func (dialect *dialect) ColumnExists(ctx context.Context, bun bun.IDB, table str
return count > 0, nil
}
func (dialect *dialect) RenameColumn(ctx context.Context, bun bun.IDB, table string, oldColumnName string, newColumnName string) (bool, error) {
oldColumnExists, err := dialect.ColumnExists(ctx, bun, table, oldColumnName)
if err != nil {
return false, err
}
newColumnExists, err := dialect.ColumnExists(ctx, bun, table, newColumnName)
if err != nil {
return false, err
}
if !oldColumnExists && newColumnExists {
return true, nil
}
_, err = bun.
ExecContext(ctx, "ALTER TABLE "+table+" RENAME COLUMN "+oldColumnName+" TO "+newColumnName)
if err != nil {
return false, err
}
return true, nil
}
func (dialect *dialect) TableExists(ctx context.Context, bun bun.IDB, table interface{}) (bool, error) {
count := 0
err := bun.
NewSelect().
ColumnExpr("count(*)").
Table("pg_catalog.pg_tables").
Where("tablename = ?", bun.Dialect().Tables().Get(reflect.TypeOf(table)).Name).
Scan(ctx, &count)
if err != nil {
return false, err
}
if count == 0 {
return false, nil
}
return true, nil
}
func (dialect *dialect) RenameTableAndModifyModel(ctx context.Context, bun bun.IDB, oldModel interface{}, newModel interface{}, cb func(context.Context) error) error {
exists, err := dialect.TableExists(ctx, bun, newModel)
if err != nil {
return err
}
if exists {
return nil
}
_, err = bun.
NewCreateTable().
IfNotExists().
Model(newModel).
Exec(ctx)
if err != nil {
return err
}
err = cb(ctx)
if err != nil {
return err
}
_, err = bun.
NewDropTable().
IfExists().
Model(oldModel).
Exec(ctx)
if err != nil {
return err
}
return nil
}

View File

@@ -18,7 +18,7 @@ type provider struct {
sqldb *sql.DB
bundb *sqlstore.BunDB
sqlxdb *sqlx.DB
dialect *dialect
dialect *PGDialect
}
func NewFactory(hookFactories ...factory.ProviderFactory[sqlstore.SQLStoreHook, sqlstore.Config]) factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config] {
@@ -60,7 +60,7 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
sqldb: sqldb,
bundb: sqlstore.NewBunDB(settings, sqldb, pgdialect.New(), hooks),
sqlxdb: sqlx.NewDb(sqldb, "postgres"),
dialect: new(dialect),
dialect: &PGDialect{},
}, nil
}

View File

@@ -2,15 +2,14 @@ package sqlitesqlstore
import (
"context"
"reflect"
"github.com/uptrace/bun"
)
type dialect struct {
type SQLiteDialect struct {
}
func (dialect *dialect) MigrateIntToTimestamp(ctx context.Context, bun bun.IDB, table string, column string) error {
func (dialect *SQLiteDialect) MigrateIntToTimestamp(ctx context.Context, bun bun.IDB, table string, column string) error {
columnType, err := dialect.GetColumnType(ctx, bun, table, column)
if err != nil {
return err
@@ -26,17 +25,12 @@ func (dialect *dialect) MigrateIntToTimestamp(ctx context.Context, bun bun.IDB,
}
// add new timestamp column
if _, err := bun.
NewAddColumn().
Table(table).
ColumnExpr(column + " TIMESTAMP").
Exec(ctx); err != nil {
if _, err := bun.NewAddColumn().Table(table).ColumnExpr(column + " TIMESTAMP").Exec(ctx); err != nil {
return err
}
// copy data from old column to new column, converting from int (unix timestamp) to timestamp
if _, err := bun.
NewUpdate().
if _, err := bun.NewUpdate().
Table(table).
Set(column + " = datetime(" + column + "_old, 'unixepoch')").
Where("1=1").
@@ -52,7 +46,7 @@ func (dialect *dialect) MigrateIntToTimestamp(ctx context.Context, bun bun.IDB,
return nil
}
func (dialect *dialect) MigrateIntToBoolean(ctx context.Context, bun bun.IDB, table string, column string) error {
func (dialect *SQLiteDialect) MigrateIntToBoolean(ctx context.Context, bun bun.IDB, table string, column string) error {
columnType, err := dialect.GetColumnType(ctx, bun, table, column)
if err != nil {
return err
@@ -72,8 +66,7 @@ func (dialect *dialect) MigrateIntToBoolean(ctx context.Context, bun bun.IDB, ta
}
// copy data from old column to new column, converting from int to boolean
if _, err := bun.
NewUpdate().
if _, err := bun.NewUpdate().
Table(table).
Set(column + " = CASE WHEN " + column + "_old = 1 THEN true ELSE false END").
Where("1=1").
@@ -89,11 +82,10 @@ func (dialect *dialect) MigrateIntToBoolean(ctx context.Context, bun bun.IDB, ta
return nil
}
func (dialect *dialect) GetColumnType(ctx context.Context, bun bun.IDB, table string, column string) (string, error) {
func (dialect *SQLiteDialect) GetColumnType(ctx context.Context, bun bun.IDB, table string, column string) (string, error) {
var columnType string
err := bun.
NewSelect().
err := bun.NewSelect().
ColumnExpr("type").
TableExpr("pragma_table_info(?)", table).
Where("name = ?", column).
@@ -105,7 +97,7 @@ func (dialect *dialect) GetColumnType(ctx context.Context, bun bun.IDB, table st
return columnType, nil
}
func (dialect *dialect) ColumnExists(ctx context.Context, bun bun.IDB, table string, column string) (bool, error) {
func (dialect *SQLiteDialect) ColumnExists(ctx context.Context, bun bun.IDB, table string, column string) (bool, error) {
var count int
err := bun.NewSelect().
ColumnExpr("COUNT(*)").
@@ -119,85 +111,3 @@ func (dialect *dialect) ColumnExists(ctx context.Context, bun bun.IDB, table str
return count > 0, nil
}
func (dialect *dialect) RenameColumn(ctx context.Context, bun bun.IDB, table string, oldColumnName string, newColumnName string) (bool, error) {
oldColumnExists, err := dialect.ColumnExists(ctx, bun, table, oldColumnName)
if err != nil {
return false, err
}
newColumnExists, err := dialect.ColumnExists(ctx, bun, table, newColumnName)
if err != nil {
return false, err
}
if !oldColumnExists && newColumnExists {
return true, nil
}
_, err = bun.
ExecContext(ctx, "ALTER TABLE "+table+" RENAME COLUMN "+oldColumnName+" TO "+newColumnName)
if err != nil {
return false, err
}
return true, nil
}
func (dialect *dialect) TableExists(ctx context.Context, bun bun.IDB, table interface{}) (bool, error) {
count := 0
err := bun.
NewSelect().
ColumnExpr("count(*)").
Table("sqlite_master").
Where("type = ?", "table").
Where("name = ?", bun.Dialect().Tables().Get(reflect.TypeOf(table)).Name).
Scan(ctx, &count)
if err != nil {
return false, err
}
if count == 0 {
return false, nil
}
return true, nil
}
func (dialect *dialect) RenameTableAndModifyModel(ctx context.Context, bun bun.IDB, oldModel interface{}, newModel interface{}, cb func(context.Context) error) error {
exists, err := dialect.TableExists(ctx, bun, newModel)
if err != nil {
return err
}
if exists {
return nil
}
_, err = bun.
NewCreateTable().
IfNotExists().
Model(newModel).
ForeignKey(`("org_id") REFERENCES "organizations" ("id")`).
Exec(ctx)
if err != nil {
return err
}
err = cb(ctx)
if err != nil {
return err
}
_, err = bun.
NewDropTable().
IfExists().
Model(oldModel).
Exec(ctx)
if err != nil {
return err
}
return nil
}

View File

@@ -17,7 +17,7 @@ type provider struct {
sqldb *sql.DB
bundb *sqlstore.BunDB
sqlxdb *sqlx.DB
dialect *dialect
dialect *SQLiteDialect
}
func NewFactory(hookFactories ...factory.ProviderFactory[sqlstore.SQLStoreHook, sqlstore.Config]) factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config] {
@@ -50,7 +50,7 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
sqldb: sqldb,
bundb: sqlstore.NewBunDB(settings, sqldb, sqlitedialect.New(), hooks),
sqlxdb: sqlx.NewDb(sqldb, "sqlite3"),
dialect: new(dialect),
dialect: &SQLiteDialect{},
}, nil
}

View File

@@ -37,10 +37,8 @@ type SQLStoreHook interface {
}
type SQLDialect interface {
MigrateIntToTimestamp(context.Context, bun.IDB, string, string) error
MigrateIntToBoolean(context.Context, bun.IDB, string, string) error
GetColumnType(context.Context, bun.IDB, string, string) (string, error)
ColumnExists(context.Context, bun.IDB, string, string) (bool, error)
RenameColumn(context.Context, bun.IDB, string, string, string) (bool, error)
RenameTableAndModifyModel(context.Context, bun.IDB, interface{}, interface{}, func(context.Context) error) error
MigrateIntToTimestamp(ctx context.Context, bun bun.IDB, table string, column string) error
MigrateIntToBoolean(ctx context.Context, bun bun.IDB, table string, column string) error
GetColumnType(ctx context.Context, bun bun.IDB, table string, column string) (string, error)
ColumnExists(ctx context.Context, bun bun.IDB, table string, column string) (bool, error)
}

View File

@@ -6,29 +6,21 @@ import (
"github.com/uptrace/bun"
)
type dialect struct {
type TestDialect struct {
}
func (dialect *dialect) MigrateIntToTimestamp(ctx context.Context, bun bun.IDB, table string, column string) error {
func (dialect *TestDialect) MigrateIntToTimestamp(ctx context.Context, bun bun.IDB, table string, column string) error {
return nil
}
func (dialect *dialect) MigrateIntToBoolean(ctx context.Context, bun bun.IDB, table string, column string) error {
func (dialect *TestDialect) MigrateIntToBoolean(ctx context.Context, bun bun.IDB, table string, column string) error {
return nil
}
func (dialect *dialect) GetColumnType(ctx context.Context, bun bun.IDB, table string, column string) (string, error) {
func (dialect *TestDialect) GetColumnType(ctx context.Context, bun bun.IDB, table string, column string) (string, error) {
return "", nil
}
func (dialect *dialect) ColumnExists(ctx context.Context, bun bun.IDB, table string, column string) (bool, error) {
func (dialect *TestDialect) ColumnExists(ctx context.Context, bun bun.IDB, table string, column string) (bool, error) {
return false, nil
}
func (dialect *dialect) RenameColumn(ctx context.Context, bun bun.IDB, table string, oldColumnName string, newColumnName string) (bool, error) {
return true, nil
}
func (dialect *dialect) RenameTableAndModifyModel(ctx context.Context, bun bun.IDB, oldModel interface{}, newModel interface{}, cb func(context.Context) error) error {
return nil
}

View File

@@ -19,7 +19,7 @@ type Provider struct {
mock sqlmock.Sqlmock
bunDB *bun.DB
sqlxDB *sqlx.DB
dialect *dialect
dialect *TestDialect
}
func New(config sqlstore.Config, matcher sqlmock.QueryMatcher) *Provider {
@@ -43,7 +43,7 @@ func New(config sqlstore.Config, matcher sqlmock.QueryMatcher) *Provider {
mock: mock,
bunDB: bunDB,
sqlxDB: sqlxDB,
dialect: new(dialect),
dialect: &TestDialect{},
}
}

View File

@@ -1,9 +0,0 @@
package types
import (
"github.com/SigNoz/signoz/pkg/valuer"
)
type Identifiable struct {
ID valuer.UUID `json:"id" bun:"id,pk,type:text"`
}

View File

@@ -7,10 +7,10 @@ import (
type SavedView struct {
bun.BaseModel `bun:"table:saved_views"`
Identifiable
TimeAuditable
UserAuditable
OrgID string `json:"orgId" bun:"org_id,notnull"`
UUID string `json:"uuid" bun:"uuid,pk,type:text"`
Name string `json:"name" bun:"name,type:text,notnull"`
Category string `json:"category" bun:"category,type:text,notnull"`
SourcePage string `json:"sourcePage" bun:"source_page,type:text,notnull"`

View File

@@ -1,19 +1,21 @@
package types
import (
"time"
"github.com/uptrace/bun"
)
type Invite struct {
bun.BaseModel `bun:"table:user_invite"`
bun.BaseModel `bun:"table:invites"`
Identifiable
TimeAuditable
OrgID string `bun:"org_id,type:text,notnull" json:"orgId"`
Name string `bun:"name,type:text,notnull" json:"name"`
Email string `bun:"email,type:text,notnull,unique" json:"email"`
Token string `bun:"token,type:text,notnull" json:"token"`
Role string `bun:"role,type:text,notnull" json:"role"`
OrgID string `bun:"org_id,type:text,notnull" json:"orgId"`
ID int `bun:"id,pk,autoincrement" json:"id"`
Name string `bun:"name,type:text,notnull" json:"name"`
Email string `bun:"email,type:text,notnull,unique" json:"email"`
Token string `bun:"token,type:text,notnull" json:"token"`
CreatedAt time.Time `bun:"created_at,notnull" json:"createdAt"`
Role string `bun:"role,type:text,notnull" json:"role"`
}
type Group struct {

View File

@@ -1,120 +0,0 @@
package valuer
import (
"database/sql/driver"
"encoding/json"
"fmt"
"reflect"
"github.com/google/uuid"
)
var _ Valuer = (*UUID)(nil)
type UUID struct {
val uuid.UUID
}
func NewUUID(value string) (UUID, error) {
val, err := uuid.Parse(value)
if err != nil {
return UUID{}, err
}
return UUID{
val: val,
}, nil
}
func NewUUIDFromBytes(value []byte) (UUID, error) {
val, err := uuid.ParseBytes(value)
if err != nil {
return UUID{}, err
}
return UUID{
val: val,
}, nil
}
func MustNewUUID(val string) UUID {
uuid, err := NewUUID(val)
if err != nil {
panic(err)
}
return uuid
}
func GenerateUUID() UUID {
val, err := uuid.NewV7()
if err != nil {
panic(err)
}
return UUID{
val: val,
}
}
func (enum UUID) IsZero() bool {
return enum.val == uuid.UUID{}
}
func (enum UUID) StringValue() string {
return enum.val.String()
}
func (enum UUID) MarshalJSON() ([]byte, error) {
return json.Marshal(enum.StringValue())
}
func (enum *UUID) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
uuid, err := NewUUID(str)
if err != nil {
return err
}
*enum = uuid
return nil
}
func (enum UUID) Value() (driver.Value, error) {
return enum.StringValue(), nil
}
func (enum *UUID) Scan(val interface{}) error {
if enum == nil {
return fmt.Errorf("uuid: (nil \"%s\")", reflect.TypeOf(enum).String())
}
if val == nil {
return fmt.Errorf("uuid: (nil \"%s\")", reflect.TypeOf(val).String())
}
var enumVal UUID
switch val := val.(type) {
case string:
_enumVal, err := NewUUID(val)
if err != nil {
return fmt.Errorf("uuid: (invalid-uuid \"%s\")", err.Error())
}
enumVal = _enumVal
case []byte:
_enumVal, err := NewUUIDFromBytes(val)
if err != nil {
return fmt.Errorf("uuid: (invalid-uuid \"%s\")", err.Error())
}
enumVal = _enumVal
default:
return fmt.Errorf("uuid: (non-uuid \"%s\")", reflect.TypeOf(val).String())
}
*enum = enumVal
return nil
}

View File

@@ -1,22 +0,0 @@
package valuer
import (
"database/sql"
"database/sql/driver"
"encoding/json"
)
type Valuer interface {
// IsZero returns true if the value is considered empty or zero
IsZero() bool
// StringValue returns the string representation of the value
StringValue() string
// MarshalJSON returns the JSON encoding of the value.
json.Marshaler
// UnmarshalJSON returns the JSON decoding of the value.
json.Unmarshaler
// Scan into underlying struct from a database driver's value
sql.Scanner
// Convert the struct to a database driver's value
driver.Valuer
}