mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-09 03:02:20 +00:00
Compare commits
12 Commits
variable-u
...
launch-wee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05369a2bfa | ||
|
|
88e1e42bf0 | ||
|
|
1af0739070 | ||
|
|
a0d896557e | ||
|
|
285f3eec72 | ||
|
|
652c7a78a2 | ||
|
|
cd98295ccf | ||
|
|
b230687134 | ||
|
|
c5d7623a98 | ||
|
|
2b28c5f2e2 | ||
|
|
6dbcc5fb9d | ||
|
|
175e9a4c5e |
@@ -311,6 +311,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{"*"},
|
||||
|
||||
@@ -62,13 +62,6 @@ var BasicPlan = basemodel.FeatureSet{
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
basemodel.Feature{
|
||||
Name: basemodel.TraceFunnels,
|
||||
Active: false,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
}
|
||||
|
||||
var EnterprisePlan = basemodel.FeatureSet{
|
||||
@@ -121,11 +114,4 @@ var EnterprisePlan = basemodel.FeatureSet{
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
basemodel.Feature{
|
||||
Name: basemodel.TraceFunnels,
|
||||
Active: false,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { ApDexPayloadAndSettingsProps } from 'types/api/metrics/getApDex';
|
||||
|
||||
export const getApDexSettings = (
|
||||
servicename: string,
|
||||
): Promise<AxiosResponse<ApDexPayloadAndSettingsProps[]>> =>
|
||||
axios.get(`/settings/apdex?services=${servicename}`);
|
||||
@@ -22,7 +22,7 @@ export const createFunnel = async (
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Funnel created successfully',
|
||||
payload: response.data,
|
||||
payload: response.data.data,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -196,7 +196,9 @@ export interface FunnelOverviewResponse {
|
||||
avg_rate: number;
|
||||
conversion_rate: number | null;
|
||||
errors: number;
|
||||
// TODO(shaheer): remove p99_latency once we have support for latency
|
||||
p99_latency: number;
|
||||
latency: number;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
@@ -222,13 +224,6 @@ export const getFunnelOverview = async (
|
||||
};
|
||||
};
|
||||
|
||||
export interface SlowTracesPayload {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
step_a_order: number;
|
||||
step_b_order: number;
|
||||
}
|
||||
|
||||
export interface SlowTraceData {
|
||||
status: string;
|
||||
data: Array<{
|
||||
@@ -243,7 +238,7 @@ export interface SlowTraceData {
|
||||
|
||||
export const getFunnelSlowTraces = async (
|
||||
funnelId: string,
|
||||
payload: SlowTracesPayload,
|
||||
payload: FunnelOverviewPayload,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SuccessResponse<SlowTraceData> | ErrorResponse> => {
|
||||
const response = await axios.post(
|
||||
@@ -261,12 +256,6 @@ export const getFunnelSlowTraces = async (
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
export interface ErrorTracesPayload {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
step_a_order: number;
|
||||
step_b_order: number;
|
||||
}
|
||||
|
||||
export interface ErrorTraceData {
|
||||
status: string;
|
||||
@@ -282,7 +271,7 @@ export interface ErrorTraceData {
|
||||
|
||||
export const getFunnelErrorTraces = async (
|
||||
funnelId: string,
|
||||
payload: ErrorTracesPayload,
|
||||
payload: FunnelOverviewPayload,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SuccessResponse<ErrorTraceData> | ErrorResponse> => {
|
||||
const response: AxiosResponse = await axios.post(
|
||||
@@ -337,3 +326,37 @@ export const getFunnelSteps = async (
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
export interface FunnelStepsOverviewPayload {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
step_start?: number;
|
||||
step_end?: number;
|
||||
}
|
||||
|
||||
export interface FunnelStepsOverviewResponse {
|
||||
status: string;
|
||||
data: Array<{
|
||||
timestamp: string;
|
||||
data: Record<string, number>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const getFunnelStepsOverview = async (
|
||||
funnelId: string,
|
||||
payload: FunnelStepsOverviewPayload,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SuccessResponse<FunnelStepsOverviewResponse> | ErrorResponse> => {
|
||||
const response = await axios.post(
|
||||
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/steps/overview`,
|
||||
payload,
|
||||
{ signal },
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
26
frontend/src/api/v1/settings/apdex/services/get.ts
Normal file
26
frontend/src/api/v1/settings/apdex/services/get.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import {
|
||||
ApDexPayloadAndSettingsProps,
|
||||
PayloadProps,
|
||||
} from 'types/api/metrics/getApDex';
|
||||
|
||||
const getApDexSettings = async (
|
||||
servicename: string,
|
||||
): Promise<SuccessResponseV2<ApDexPayloadAndSettingsProps[]>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(
|
||||
`/settings/apdex?services=${servicename}`,
|
||||
);
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default getApDexSettings;
|
||||
@@ -28,4 +28,5 @@ export enum LOCALSTORAGE {
|
||||
DONT_SHOW_SLOW_API_WARNING = 'DONT_SHOW_SLOW_API_WARNING',
|
||||
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
|
||||
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
|
||||
UNEXECUTED_FUNNELS = 'UNEXECUTED_FUNNELS',
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export const REACT_QUERY_KEY = {
|
||||
VALIDATE_FUNNEL_STEPS: 'VALIDATE_FUNNEL_STEPS',
|
||||
UPDATE_FUNNEL_STEP_DETAILS: 'UPDATE_FUNNEL_STEP_DETAILS',
|
||||
GET_FUNNEL_OVERVIEW: 'GET_FUNNEL_OVERVIEW',
|
||||
GET_FUNNEL_STEPS_OVERVIEW: 'GET_FUNNEL_STEPS_OVERVIEW',
|
||||
GET_FUNNEL_SLOW_TRACES: 'GET_FUNNEL_SLOW_TRACES',
|
||||
GET_FUNNEL_ERROR_TRACES: 'GET_FUNNEL_ERROR_TRACES',
|
||||
GET_FUNNEL_STEPS_GRAPH_DATA: 'GET_FUNNEL_STEPS_GRAPH_DATA',
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
/* eslint-disable prefer-destructuring */
|
||||
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Tooltip, Typography } from 'antd';
|
||||
import Table, { ColumnsType } from 'antd/es/table';
|
||||
import { Table, Tooltip, Typography } from 'antd';
|
||||
import { Progress } from 'antd/lib';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Spinner from 'components/Spinner';
|
||||
import { Card, GraphContainer } from 'container/MetricsApplication/styles';
|
||||
import { useGetApDexSettings } from 'hooks/apDex/useGetApDexSettings';
|
||||
import useErrorNotification from 'hooks/useErrorNotification';
|
||||
import { memo } from 'react';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { memo, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { IServiceName } from '../../types';
|
||||
@@ -17,11 +17,20 @@ function ApDexApplication({
|
||||
}: ApDexApplicationProps): JSX.Element {
|
||||
const { servicename: encodedServiceName } = useParams<IServiceName>();
|
||||
const servicename = decodeURIComponent(encodedServiceName);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { data, isLoading, error, isRefetching } = useGetApDexSettings(
|
||||
servicename,
|
||||
);
|
||||
useErrorNotification(error);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
notifications.error({
|
||||
message: error.getErrorCode(),
|
||||
description: error.getErrorMessage(),
|
||||
});
|
||||
}
|
||||
}, [error, notifications]);
|
||||
|
||||
if (isLoading || isRefetching) {
|
||||
return (
|
||||
|
||||
@@ -15,8 +15,11 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { COMPOSITE_QUERY_KEY } from './constants';
|
||||
|
||||
function MetricNameSearch(): JSX.Element {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { handleChangeQueryData } = useQueryOperations({
|
||||
@@ -24,6 +27,7 @@ function MetricNameSearch(): JSX.Element {
|
||||
query: currentQuery.builder.queryData[0],
|
||||
entityVersion: '',
|
||||
});
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
const [searchString, setSearchString] = useState<string>('');
|
||||
@@ -66,7 +70,7 @@ function MetricNameSearch(): JSX.Element {
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(selectedMetricName: string): void => {
|
||||
handleChangeQueryData('filters', {
|
||||
const newFilter = {
|
||||
items: [
|
||||
...currentQuery.builder.queryData[0].filters.items,
|
||||
{
|
||||
@@ -81,10 +85,26 @@ function MetricNameSearch(): JSX.Element {
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
const compositeQuery = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
filters: newFilter,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
handleChangeQueryData('filters', newFilter);
|
||||
setSearchParams({
|
||||
[COMPOSITE_QUERY_KEY]: JSON.stringify(compositeQuery),
|
||||
});
|
||||
setIsPopoverOpen(false);
|
||||
},
|
||||
[currentQuery.builder.queryData, handleChangeQueryData],
|
||||
[currentQuery, handleChangeQueryData, setSearchParams],
|
||||
);
|
||||
|
||||
const metricNameFilterValues = useMemo(
|
||||
|
||||
@@ -4,8 +4,13 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { METRIC_TYPE_LABEL_MAP, METRIC_TYPE_VALUES_MAP } from './constants';
|
||||
import {
|
||||
COMPOSITE_QUERY_KEY,
|
||||
METRIC_TYPE_LABEL_MAP,
|
||||
METRIC_TYPE_VALUES_MAP,
|
||||
} from './constants';
|
||||
|
||||
function MetricTypeSearch(): JSX.Element {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
@@ -15,6 +20,7 @@ function MetricTypeSearch(): JSX.Element {
|
||||
entityVersion: '',
|
||||
});
|
||||
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
|
||||
const menuItems = useMemo(
|
||||
@@ -34,7 +40,7 @@ function MetricTypeSearch(): JSX.Element {
|
||||
const handleSelect = useCallback(
|
||||
(selectedMetricType: string): void => {
|
||||
if (selectedMetricType !== 'all') {
|
||||
handleChangeQueryData('filters', {
|
||||
const newFilter = {
|
||||
items: [
|
||||
...currentQuery.builder.queryData[0].filters.items,
|
||||
{
|
||||
@@ -49,18 +55,50 @@ function MetricTypeSearch(): JSX.Element {
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
const compositeQuery = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
filters: newFilter,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
handleChangeQueryData('filters', newFilter);
|
||||
setSearchParams({
|
||||
[COMPOSITE_QUERY_KEY]: JSON.stringify(compositeQuery),
|
||||
});
|
||||
} else {
|
||||
handleChangeQueryData('filters', {
|
||||
const newFilter = {
|
||||
items: currentQuery.builder.queryData[0].filters.items.filter(
|
||||
(item) => item.id !== 'metric_type',
|
||||
),
|
||||
op: 'AND',
|
||||
};
|
||||
const compositeQuery = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
filters: newFilter,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
handleChangeQueryData('filters', newFilter);
|
||||
setSearchParams({
|
||||
[COMPOSITE_QUERY_KEY]: JSON.stringify(compositeQuery),
|
||||
});
|
||||
}
|
||||
setIsPopoverOpen(false);
|
||||
},
|
||||
[currentQuery.builder.queryData, handleChangeQueryData],
|
||||
[currentQuery, handleChangeQueryData, setSearchParams],
|
||||
);
|
||||
|
||||
const menu = (
|
||||
|
||||
@@ -1,32 +1,13 @@
|
||||
import { Select, Tooltip } from 'antd';
|
||||
import { Tooltip } from 'antd';
|
||||
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { HardHat, Info } from 'lucide-react';
|
||||
|
||||
import { TREEMAP_VIEW_OPTIONS } from './constants';
|
||||
import { MetricsSearchProps } from './types';
|
||||
|
||||
function MetricsSearch({
|
||||
query,
|
||||
onChange,
|
||||
heatmapView,
|
||||
setHeatmapView,
|
||||
}: MetricsSearchProps): JSX.Element {
|
||||
function MetricsSearch({ query, onChange }: MetricsSearchProps): JSX.Element {
|
||||
return (
|
||||
<div className="metrics-search-container">
|
||||
<div className="metrics-search-options">
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
options={TREEMAP_VIEW_OPTIONS}
|
||||
value={heatmapView}
|
||||
onChange={setHeatmapView}
|
||||
/>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh={false}
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
/>
|
||||
</div>
|
||||
<div className="qb-search-container">
|
||||
<Tooltip
|
||||
title="Use filters to refine metrics based on attributes. Example: service_name=api - Shows all metrics associated with the API service"
|
||||
@@ -41,6 +22,13 @@ function MetricsSearch({
|
||||
isMetricsExplorer
|
||||
/>
|
||||
</div>
|
||||
<div className="metrics-search-options">
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh={false}
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Group } from '@visx/group';
|
||||
import { Treemap } from '@visx/hierarchy';
|
||||
import { Empty, Skeleton, Tooltip, Typography } from 'antd';
|
||||
import { Empty, Select, Skeleton, Tooltip, Typography } from 'antd';
|
||||
import { stratify, treemapBinary } from 'd3-hierarchy';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
TREEMAP_HEIGHT,
|
||||
TREEMAP_MARGINS,
|
||||
TREEMAP_SQUARE_PADDING,
|
||||
TREEMAP_VIEW_OPTIONS,
|
||||
} from './constants';
|
||||
import { MetricsTreemapProps, TreemapTile, TreemapViewType } from './types';
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ function MetricsTreemap({
|
||||
isLoading,
|
||||
isError,
|
||||
openMetricDetails,
|
||||
setHeatmapView,
|
||||
}: MetricsTreemapProps): JSX.Element {
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
@@ -55,7 +57,10 @@ function MetricsTreemap({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div data-testid="metrics-treemap-loading-state">
|
||||
<Skeleton style={{ width: treemapWidth, height: TREEMAP_HEIGHT }} active />
|
||||
<Skeleton
|
||||
style={{ width: treemapWidth, height: TREEMAP_HEIGHT + 55 }}
|
||||
active
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -90,13 +95,20 @@ function MetricsTreemap({
|
||||
data-testid="metrics-treemap-container"
|
||||
>
|
||||
<div className="metrics-treemap-title">
|
||||
<Typography.Title level={4}>Proportion View</Typography.Title>
|
||||
<Tooltip
|
||||
title="The treemap displays the proportion of samples/timeseries in the selected time range. Each tile represents a unique metric, and its size indicates the percentage of samples/timeseries it contributes to the total."
|
||||
placement="right"
|
||||
>
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
<div className="metrics-treemap-title-left">
|
||||
<Typography.Title level={4}>Proportion View</Typography.Title>
|
||||
<Tooltip
|
||||
title="The treemap displays the proportion of samples/timeseries in the selected time range. Each tile represents a unique metric, and its size indicates the percentage of samples/timeseries it contributes to the total."
|
||||
placement="right"
|
||||
>
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Select
|
||||
options={TREEMAP_VIEW_OPTIONS}
|
||||
value={viewType}
|
||||
onChange={setHeatmapView}
|
||||
/>
|
||||
</div>
|
||||
<svg
|
||||
width={treemapWidth}
|
||||
|
||||
@@ -21,9 +21,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-treemap-title {
|
||||
justify-content: space-between;
|
||||
|
||||
.metrics-treemap-title-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-search-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.metrics-search-options {
|
||||
@@ -35,6 +48,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
|
||||
.lucide-info {
|
||||
cursor: pointer;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -18,6 +19,12 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import InspectModal from '../Inspect';
|
||||
import MetricDetails from '../MetricDetails';
|
||||
import {
|
||||
COMPOSITE_QUERY_KEY,
|
||||
IS_INSPECT_MODAL_OPEN_KEY,
|
||||
IS_METRIC_DETAILS_OPEN_KEY,
|
||||
SELECTED_METRIC_NAME_KEY,
|
||||
} from './constants';
|
||||
import MetricsSearch from './MetricsSearch';
|
||||
import MetricsTable from './MetricsTable';
|
||||
import MetricsTreemap from './MetricsTreemap';
|
||||
@@ -40,10 +47,16 @@ function Summary(): JSX.Element {
|
||||
const [heatmapView, setHeatmapView] = useState<TreemapViewType>(
|
||||
TreemapViewType.TIMESERIES,
|
||||
);
|
||||
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(false);
|
||||
const [isInspectModalOpen, setIsInspectModalOpen] = useState(false);
|
||||
const [selectedMetricName, setSelectedMetricName] = useState<string | null>(
|
||||
null,
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(
|
||||
() => searchParams.get(IS_METRIC_DETAILS_OPEN_KEY) === 'true' || false,
|
||||
);
|
||||
const [isInspectModalOpen, setIsInspectModalOpen] = useState(
|
||||
() => searchParams.get(IS_INSPECT_MODAL_OPEN_KEY) === 'true' || false,
|
||||
);
|
||||
const [selectedMetricName, setSelectedMetricName] = useState(
|
||||
() => searchParams.get(SELECTED_METRIC_NAME_KEY) || null,
|
||||
);
|
||||
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
@@ -75,13 +88,25 @@ function Summary(): JSX.Element {
|
||||
|
||||
useShareBuilderUrl(defaultQuery);
|
||||
|
||||
// This is used to avoid the filters from being serialized with the id
|
||||
const currentQueryFiltersString = useMemo(() => {
|
||||
const filters = currentQuery?.builder?.queryData[0]?.filters;
|
||||
if (!filters) return '';
|
||||
const filtersWithoutId = {
|
||||
...filters,
|
||||
items: filters.items.map(({ id, ...rest }) => rest),
|
||||
};
|
||||
return JSON.stringify(filtersWithoutId);
|
||||
}, [currentQuery]);
|
||||
|
||||
const queryFilters = useMemo(
|
||||
() =>
|
||||
currentQuery?.builder?.queryData[0]?.filters || {
|
||||
items: [],
|
||||
op: 'and',
|
||||
},
|
||||
[currentQuery],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[currentQueryFiltersString],
|
||||
);
|
||||
|
||||
const { handleChangeQueryData } = useQueryOperations({
|
||||
@@ -145,9 +170,24 @@ function Summary(): JSX.Element {
|
||||
const handleFilterChange = useCallback(
|
||||
(value: TagFilter) => {
|
||||
handleChangeQueryData('filters', value);
|
||||
const compositeQuery = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
filters: value,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
setSearchParams({
|
||||
[COMPOSITE_QUERY_KEY]: JSON.stringify(compositeQuery),
|
||||
});
|
||||
setCurrentPage(1);
|
||||
},
|
||||
[handleChangeQueryData],
|
||||
[handleChangeQueryData, currentQuery, setSearchParams],
|
||||
);
|
||||
|
||||
const updatedCurrentQuery = useMemo(
|
||||
@@ -184,17 +224,29 @@ function Summary(): JSX.Element {
|
||||
const openMetricDetails = (metricName: string): void => {
|
||||
setSelectedMetricName(metricName);
|
||||
setIsMetricDetailsOpen(true);
|
||||
setSearchParams({
|
||||
[IS_METRIC_DETAILS_OPEN_KEY]: 'true',
|
||||
[SELECTED_METRIC_NAME_KEY]: metricName,
|
||||
});
|
||||
};
|
||||
|
||||
const closeMetricDetails = (): void => {
|
||||
setSelectedMetricName(null);
|
||||
setIsMetricDetailsOpen(false);
|
||||
setSearchParams({
|
||||
[IS_METRIC_DETAILS_OPEN_KEY]: 'false',
|
||||
[SELECTED_METRIC_NAME_KEY]: '',
|
||||
});
|
||||
};
|
||||
|
||||
const openInspectModal = (metricName: string): void => {
|
||||
setSelectedMetricName(metricName);
|
||||
setIsInspectModalOpen(true);
|
||||
setIsMetricDetailsOpen(false);
|
||||
setSearchParams({
|
||||
[IS_INSPECT_MODAL_OPEN_KEY]: 'true',
|
||||
[SELECTED_METRIC_NAME_KEY]: metricName,
|
||||
});
|
||||
};
|
||||
|
||||
const closeInspectModal = (): void => {
|
||||
@@ -204,23 +256,23 @@ function Summary(): JSX.Element {
|
||||
});
|
||||
setIsInspectModalOpen(false);
|
||||
setSelectedMetricName(null);
|
||||
setSearchParams({
|
||||
[IS_INSPECT_MODAL_OPEN_KEY]: 'false',
|
||||
[SELECTED_METRIC_NAME_KEY]: '',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className="metrics-explorer-summary-tab">
|
||||
<MetricsSearch
|
||||
query={searchQuery}
|
||||
onChange={handleFilterChange}
|
||||
heatmapView={heatmapView}
|
||||
setHeatmapView={setHeatmapView}
|
||||
/>
|
||||
<MetricsSearch query={searchQuery} onChange={handleFilterChange} />
|
||||
<MetricsTreemap
|
||||
data={treeMapData?.payload}
|
||||
isLoading={isTreeMapLoading || isTreeMapFetching}
|
||||
isError={isProportionViewError}
|
||||
viewType={heatmapView}
|
||||
openMetricDetails={openMetricDetails}
|
||||
setHeatmapView={setHeatmapView}
|
||||
/>
|
||||
<MetricsTable
|
||||
isLoading={isMetricsLoading || isMetricsFetching}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import * as useGetMetricsListFilterValues from 'hooks/metricsExplorer/useGetMetricsListFilterValues';
|
||||
import * as useQueryBuilderOperationsHooks from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import store from 'store';
|
||||
@@ -28,7 +29,23 @@ const mockData: MetricsListItemRowData[] = [
|
||||
},
|
||||
];
|
||||
|
||||
jest.mock('react-router-dom-v5-compat', () => {
|
||||
const actual = jest.requireActual('react-router-dom-v5-compat');
|
||||
return {
|
||||
...actual,
|
||||
useSearchParams: jest.fn().mockReturnValue([{}, jest.fn()]),
|
||||
useNavigationType: (): any => 'PUSH',
|
||||
};
|
||||
});
|
||||
describe('MetricsTable', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(useQueryBuilderOperationsHooks, 'useQueryOperations')
|
||||
.mockReturnValue({
|
||||
handleChangeQueryData: jest.fn(),
|
||||
} as any);
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(useGetMetricsListFilterValues, 'useGetMetricsListFilterValues')
|
||||
.mockReturnValue({
|
||||
|
||||
@@ -55,6 +55,7 @@ describe('MetricsTreemap', () => {
|
||||
}}
|
||||
openMetricDetails={jest.fn()}
|
||||
viewType={TreemapViewType.SAMPLES}
|
||||
setHeatmapView={jest.fn()}
|
||||
/>
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
@@ -79,6 +80,7 @@ describe('MetricsTreemap', () => {
|
||||
}}
|
||||
openMetricDetails={jest.fn()}
|
||||
viewType={TreemapViewType.SAMPLES}
|
||||
setHeatmapView={jest.fn()}
|
||||
/>
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
@@ -105,6 +107,7 @@ describe('MetricsTreemap', () => {
|
||||
}}
|
||||
openMetricDetails={jest.fn()}
|
||||
viewType={TreemapViewType.SAMPLES}
|
||||
setHeatmapView={jest.fn()}
|
||||
/>
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
@@ -128,6 +131,7 @@ describe('MetricsTreemap', () => {
|
||||
data={null}
|
||||
openMetricDetails={jest.fn()}
|
||||
viewType={TreemapViewType.SAMPLES}
|
||||
setHeatmapView={jest.fn()}
|
||||
/>
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import ROUTES from 'constants/routes';
|
||||
import * as useGetMetricsListHooks from 'hooks/metricsExplorer/useGetMetricsList';
|
||||
import * as useGetMetricsTreeMapHooks from 'hooks/metricsExplorer/useGetMetricsTreeMap';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import store from 'store';
|
||||
|
||||
import Summary from '../Summary';
|
||||
import { TreemapViewType } from '../types';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
jest.mock('d3-hierarchy', () => ({
|
||||
stratify: jest.fn().mockReturnValue({
|
||||
id: jest.fn().mockReturnValue({
|
||||
parentId: jest.fn().mockReturnValue(
|
||||
jest.fn().mockReturnValue({
|
||||
sum: jest.fn().mockReturnValue({
|
||||
descendants: jest.fn().mockReturnValue([]),
|
||||
eachBefore: jest.fn().mockReturnValue([]),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
treemapBinary: jest.fn(),
|
||||
}));
|
||||
jest.mock('react-use', () => ({
|
||||
useWindowSize: jest.fn().mockReturnValue({ width: 1000, height: 1000 }),
|
||||
}));
|
||||
jest.mock('react-router-dom-v5-compat', () => {
|
||||
const actual = jest.requireActual('react-router-dom-v5-compat');
|
||||
return {
|
||||
...actual,
|
||||
useSearchParams: jest.fn(),
|
||||
useNavigationType: (): any => 'PUSH',
|
||||
};
|
||||
});
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${ROUTES.METRICS_EXPLORER_BASE}`,
|
||||
}),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const mockMetricName = 'test-metric';
|
||||
jest.spyOn(useGetMetricsListHooks, 'useGetMetricsList').mockReturnValue({
|
||||
data: {
|
||||
payload: {
|
||||
status: 'success',
|
||||
data: {
|
||||
metrics: [
|
||||
{
|
||||
metric_name: mockMetricName,
|
||||
description: 'description for a test metric',
|
||||
type: MetricType.GAUGE,
|
||||
unit: 'count',
|
||||
lastReceived: '1715702400',
|
||||
[TreemapViewType.TIMESERIES]: 100,
|
||||
[TreemapViewType.SAMPLES]: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
jest.spyOn(useGetMetricsTreeMapHooks, 'useGetMetricsTreeMap').mockReturnValue({
|
||||
data: {
|
||||
payload: {
|
||||
status: 'success',
|
||||
data: {
|
||||
[TreemapViewType.TIMESERIES]: [
|
||||
{
|
||||
metric_name: mockMetricName,
|
||||
percentage: 100,
|
||||
total_value: 100,
|
||||
},
|
||||
],
|
||||
[TreemapViewType.SAMPLES]: [
|
||||
{
|
||||
metric_name: mockMetricName,
|
||||
percentage: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
const mockSetSearchParams = jest.fn();
|
||||
|
||||
describe('Summary', () => {
|
||||
it('persists inspect modal open state across page refresh', () => {
|
||||
(useSearchParams as jest.Mock).mockReturnValue([
|
||||
new URLSearchParams({
|
||||
isInspectModalOpen: 'true',
|
||||
selectedMetricName: 'test-metric',
|
||||
}),
|
||||
mockSetSearchParams,
|
||||
]);
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<Summary />
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Proportion View')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('persists metric details modal state across page refresh', () => {
|
||||
(useSearchParams as jest.Mock).mockReturnValue([
|
||||
new URLSearchParams({
|
||||
isMetricDetailsOpen: 'true',
|
||||
selectedMetricName: mockMetricName,
|
||||
}),
|
||||
mockSetSearchParams,
|
||||
]);
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<Summary />
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Proportion View')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -32,3 +32,8 @@ export const METRIC_TYPE_VALUES_MAP = {
|
||||
[MetricType.SUMMARY]: 'Summary',
|
||||
[MetricType.EXPONENTIAL_HISTOGRAM]: 'ExponentialHistogram',
|
||||
};
|
||||
|
||||
export const IS_METRIC_DETAILS_OPEN_KEY = 'isMetricDetailsOpen';
|
||||
export const IS_INSPECT_MODAL_OPEN_KEY = 'isInspectModalOpen';
|
||||
export const SELECTED_METRIC_NAME_KEY = 'selectedMetricName';
|
||||
export const COMPOSITE_QUERY_KEY = 'compositeQuery';
|
||||
|
||||
@@ -20,8 +20,6 @@ export interface MetricsTableProps {
|
||||
export interface MetricsSearchProps {
|
||||
query: IBuilderQuery;
|
||||
onChange: (value: TagFilter) => void;
|
||||
heatmapView: TreemapViewType;
|
||||
setHeatmapView: (value: TreemapViewType) => void;
|
||||
}
|
||||
|
||||
export interface MetricsTreemapProps {
|
||||
@@ -30,6 +28,7 @@ export interface MetricsTreemapProps {
|
||||
isError: boolean;
|
||||
viewType: TreemapViewType;
|
||||
openMetricDetails: (metricName: string) => void;
|
||||
setHeatmapView: (value: TreemapViewType) => void;
|
||||
}
|
||||
|
||||
export interface OrderByPayload {
|
||||
|
||||
@@ -149,30 +149,28 @@ function SpanOverview({
|
||||
<Typography.Text className="service-name">
|
||||
{span.serviceName}
|
||||
</Typography.Text>
|
||||
{!!span.serviceName &&
|
||||
!!span.name &&
|
||||
process.env.NODE_ENV === 'development' && (
|
||||
<div className="add-funnel-button">
|
||||
<span className="add-funnel-button__separator">·</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="add-funnel-button__button"
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleAddSpanToFunnel(span);
|
||||
}}
|
||||
icon={
|
||||
<img
|
||||
className="add-funnel-button__icon"
|
||||
src="/Icons/funnel-add.svg"
|
||||
alt="funnel-icon"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!!span.serviceName && !!span.name && (
|
||||
<div className="add-funnel-button">
|
||||
<span className="add-funnel-button__separator">·</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="add-funnel-button__button"
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleAddSpanToFunnel(span);
|
||||
}}
|
||||
icon={
|
||||
<img
|
||||
className="add-funnel-button__icon"
|
||||
src="/Icons/funnel-add.svg"
|
||||
alt="funnel-icon"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -450,7 +448,7 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
virtualiserRef={virtualizerRef}
|
||||
setColumnWidths={setTraceFlamegraphStatsWidth}
|
||||
/>
|
||||
{selectedSpanToAddToFunnel && process.env.NODE_ENV === 'development' && (
|
||||
{selectedSpanToAddToFunnel && (
|
||||
<AddSpanToFunnelModal
|
||||
span={selectedSpanToAddToFunnel}
|
||||
isOpen={isAddSpanToFunnelModalOpen}
|
||||
|
||||
@@ -43,8 +43,7 @@ export default function useFunnelConfiguration({
|
||||
const {
|
||||
steps,
|
||||
initialSteps,
|
||||
setHasIncompleteStepFields,
|
||||
setHasAllEmptyStepFields,
|
||||
hasIncompleteStepFields,
|
||||
handleRestoreSteps,
|
||||
} = useFunnelContext();
|
||||
|
||||
@@ -74,14 +73,16 @@ export default function useFunnelConfiguration({
|
||||
return !isEqual(normalizedDebouncedSteps, normalizedLastSavedSteps);
|
||||
}, [debouncedSteps]);
|
||||
|
||||
const hasStepServiceOrSpanNameChanged = useCallback(
|
||||
const hasFunnelStepDefinitionsChanged = useCallback(
|
||||
(prevSteps: FunnelStepData[], nextSteps: FunnelStepData[]): boolean => {
|
||||
if (prevSteps.length !== nextSteps.length) return true;
|
||||
return prevSteps.some((step, index) => {
|
||||
const nextStep = nextSteps[index];
|
||||
return (
|
||||
step.service_name !== nextStep.service_name ||
|
||||
step.span_name !== nextStep.span_name
|
||||
step.span_name !== nextStep.span_name ||
|
||||
!isEqual(step.filters, nextStep.filters) ||
|
||||
step.has_errors !== nextStep.has_errors
|
||||
);
|
||||
});
|
||||
},
|
||||
@@ -106,12 +107,7 @@ export default function useFunnelConfiguration({
|
||||
[funnel.funnel_id, selectedTime],
|
||||
);
|
||||
useEffect(() => {
|
||||
// Check if all steps have both service_name and span_name defined
|
||||
const shouldUpdate = debouncedSteps.every(
|
||||
(step) => step.service_name !== '' && step.span_name !== '',
|
||||
);
|
||||
|
||||
if (hasStepsChanged() && shouldUpdate) {
|
||||
if (hasStepsChanged() && !hasIncompleteStepFields) {
|
||||
updateStepsMutation.mutate(getUpdatePayload(), {
|
||||
onSuccess: (data) => {
|
||||
const updatedFunnelSteps = data?.payload?.steps;
|
||||
@@ -135,17 +131,10 @@ export default function useFunnelConfiguration({
|
||||
(step) => step.service_name === '' || step.span_name === '',
|
||||
);
|
||||
|
||||
const hasAllEmptyStepsData = updatedFunnelSteps.every(
|
||||
(step) => step.service_name === '' && step.span_name === '',
|
||||
);
|
||||
|
||||
setHasIncompleteStepFields(hasIncompleteStepFields);
|
||||
setHasAllEmptyStepFields(hasAllEmptyStepsData);
|
||||
|
||||
// Only validate if service_name or span_name changed
|
||||
if (
|
||||
!hasIncompleteStepFields &&
|
||||
hasStepServiceOrSpanNameChanged(lastValidatedSteps, debouncedSteps)
|
||||
hasFunnelStepDefinitionsChanged(lastValidatedSteps, debouncedSteps)
|
||||
) {
|
||||
queryClient.refetchQueries(validateStepsQueryKey);
|
||||
setLastValidatedSteps(debouncedSteps);
|
||||
@@ -171,7 +160,7 @@ export default function useFunnelConfiguration({
|
||||
}, [
|
||||
debouncedSteps,
|
||||
getUpdatePayload,
|
||||
hasStepServiceOrSpanNameChanged,
|
||||
hasFunnelStepDefinitionsChanged,
|
||||
hasStepsChanged,
|
||||
lastValidatedSteps,
|
||||
queryClient,
|
||||
|
||||
@@ -2,8 +2,9 @@ import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { MetricItem } from 'pages/TracesFunnelDetails/components/FunnelResults/FunnelMetricsTable';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useMemo } from 'react';
|
||||
import { LatencyOptions } from 'types/api/traceFunnels';
|
||||
|
||||
import { useFunnelOverview } from './useFunnels';
|
||||
import { useFunnelOverview, useFunnelStepsOverview } from './useFunnels';
|
||||
|
||||
interface FunnelMetricsParams {
|
||||
funnelId: string;
|
||||
@@ -13,8 +14,6 @@ interface FunnelMetricsParams {
|
||||
|
||||
export function useFunnelMetrics({
|
||||
funnelId,
|
||||
stepStart,
|
||||
stepEnd,
|
||||
}: FunnelMetricsParams): {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
@@ -25,8 +24,6 @@ export function useFunnelMetrics({
|
||||
const payload = {
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
...(stepStart !== undefined && { step_start: stepStart }),
|
||||
...(stepEnd !== undefined && { step_end: stepEnd }),
|
||||
};
|
||||
|
||||
const {
|
||||
@@ -48,14 +45,18 @@ export function useFunnelMetrics({
|
||||
{ title: 'Errors', value: sourceData.errors },
|
||||
{
|
||||
title: 'Avg. Duration',
|
||||
value: getYAxisFormattedValue(sourceData.avg_duration.toString(), 'ns'),
|
||||
value: getYAxisFormattedValue(sourceData.avg_duration.toString(), 'ms'),
|
||||
},
|
||||
{
|
||||
title: 'P99 Latency',
|
||||
value: getYAxisFormattedValue(sourceData.p99_latency.toString(), 'ns'),
|
||||
title: `P99 Latency`,
|
||||
value: getYAxisFormattedValue(
|
||||
// TODO(shaheer): remove p99_latency once we have support for latency
|
||||
(sourceData.latency ?? sourceData.p99_latency).toString(),
|
||||
'ms',
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [overviewData]);
|
||||
}, [overviewData?.payload?.data]);
|
||||
|
||||
const conversionRate =
|
||||
overviewData?.payload?.data?.[0]?.data?.conversion_rate ?? 0;
|
||||
@@ -67,3 +68,72 @@ export function useFunnelMetrics({
|
||||
conversionRate,
|
||||
};
|
||||
}
|
||||
export function useFunnelStepsMetrics({
|
||||
funnelId,
|
||||
stepStart,
|
||||
stepEnd,
|
||||
}: FunnelMetricsParams): {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
metricsData: MetricItem[];
|
||||
conversionRate: number;
|
||||
} {
|
||||
const { startTime, endTime, steps } = useFunnelContext();
|
||||
|
||||
const payload = {
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
step_start: stepStart,
|
||||
step_end: stepEnd,
|
||||
};
|
||||
|
||||
const {
|
||||
data: stepsOverviewData,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
} = useFunnelStepsOverview(funnelId, payload);
|
||||
|
||||
const latencyType = useMemo(
|
||||
() => (stepStart ? steps[stepStart]?.latency_type : LatencyOptions.P99),
|
||||
[stepStart, steps],
|
||||
);
|
||||
|
||||
const metricsData = useMemo(() => {
|
||||
const sourceData = stepsOverviewData?.payload?.data?.[0]?.data;
|
||||
if (!sourceData) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
title: 'Avg. Rate',
|
||||
value: `${Number(sourceData.avg_rate.toFixed(2))} req/s`,
|
||||
},
|
||||
{ title: 'Errors', value: sourceData.errors },
|
||||
{
|
||||
title: 'Avg. Duration',
|
||||
value: getYAxisFormattedValue(
|
||||
(sourceData.avg_duration * 1_000_000).toString(),
|
||||
'ns',
|
||||
),
|
||||
},
|
||||
{
|
||||
title: `${latencyType?.toUpperCase()} Latency`,
|
||||
value: getYAxisFormattedValue(
|
||||
// TODO(shaheer): remove p99_latency once we have support for latency
|
||||
((sourceData.latency ?? sourceData.p99_latency) * 1_000_000).toString(),
|
||||
'ns',
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [stepsOverviewData, latencyType]);
|
||||
|
||||
const conversionRate =
|
||||
stepsOverviewData?.payload?.data?.[0]?.data?.conversion_rate ?? 0;
|
||||
|
||||
return {
|
||||
isLoading: isLoading || isFetching,
|
||||
isError,
|
||||
metricsData,
|
||||
conversionRate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import {
|
||||
createFunnel,
|
||||
deleteFunnel,
|
||||
ErrorTraceData,
|
||||
ErrorTracesPayload,
|
||||
FunnelOverviewPayload,
|
||||
FunnelOverviewResponse,
|
||||
FunnelStepsOverviewPayload,
|
||||
FunnelStepsOverviewResponse,
|
||||
FunnelStepsResponse,
|
||||
getFunnelById,
|
||||
getFunnelErrorTraces,
|
||||
@@ -13,11 +14,11 @@ import {
|
||||
getFunnelsList,
|
||||
getFunnelSlowTraces,
|
||||
getFunnelSteps,
|
||||
getFunnelStepsOverview,
|
||||
renameFunnel,
|
||||
RenameFunnelPayload,
|
||||
saveFunnelDescription,
|
||||
SlowTraceData,
|
||||
SlowTracesPayload,
|
||||
updateFunnelSteps,
|
||||
UpdateFunnelStepsPayload,
|
||||
ValidateFunnelResponse,
|
||||
@@ -115,11 +116,13 @@ export const useValidateFunnelSteps = ({
|
||||
selectedTime,
|
||||
startTime,
|
||||
endTime,
|
||||
enabled,
|
||||
}: {
|
||||
funnelId: string;
|
||||
selectedTime: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
enabled: boolean;
|
||||
}): UseQueryResult<
|
||||
SuccessResponse<ValidateFunnelResponse> | ErrorResponse,
|
||||
Error
|
||||
@@ -132,8 +135,8 @@ export const useValidateFunnelSteps = ({
|
||||
signal,
|
||||
),
|
||||
queryKey: [REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS, funnelId, selectedTime],
|
||||
enabled: !!funnelId && !!selectedTime && !!startTime && !!endTime,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
enabled,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
interface SaveFunnelDescriptionPayload {
|
||||
@@ -157,7 +160,11 @@ export const useFunnelOverview = (
|
||||
SuccessResponse<FunnelOverviewResponse> | ErrorResponse,
|
||||
Error
|
||||
> => {
|
||||
const { selectedTime, validTracesCount } = useFunnelContext();
|
||||
const {
|
||||
selectedTime,
|
||||
validTracesCount,
|
||||
hasFunnelBeenExecuted,
|
||||
} = useFunnelContext();
|
||||
return useQuery({
|
||||
queryFn: ({ signal }) => getFunnelOverview(funnelId, payload, signal),
|
||||
queryKey: [
|
||||
@@ -167,31 +174,51 @@ export const useFunnelOverview = (
|
||||
payload.step_start ?? '',
|
||||
payload.step_end ?? '',
|
||||
],
|
||||
enabled: !!funnelId && validTracesCount > 0,
|
||||
enabled: !!funnelId && validTracesCount > 0 && hasFunnelBeenExecuted,
|
||||
});
|
||||
};
|
||||
|
||||
export const useFunnelSlowTraces = (
|
||||
funnelId: string,
|
||||
payload: SlowTracesPayload,
|
||||
payload: FunnelOverviewPayload,
|
||||
): UseQueryResult<SuccessResponse<SlowTraceData> | ErrorResponse, Error> => {
|
||||
const { selectedTime, validTracesCount } = useFunnelContext();
|
||||
const {
|
||||
selectedTime,
|
||||
validTracesCount,
|
||||
hasFunnelBeenExecuted,
|
||||
} = useFunnelContext();
|
||||
return useQuery<SuccessResponse<SlowTraceData> | ErrorResponse, Error>({
|
||||
queryFn: ({ signal }) => getFunnelSlowTraces(funnelId, payload, signal),
|
||||
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_SLOW_TRACES, funnelId, selectedTime],
|
||||
enabled: !!funnelId && validTracesCount > 0,
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_FUNNEL_SLOW_TRACES,
|
||||
funnelId,
|
||||
selectedTime,
|
||||
payload.step_start ?? '',
|
||||
payload.step_end ?? '',
|
||||
],
|
||||
enabled: !!funnelId && validTracesCount > 0 && hasFunnelBeenExecuted,
|
||||
});
|
||||
};
|
||||
|
||||
export const useFunnelErrorTraces = (
|
||||
funnelId: string,
|
||||
payload: ErrorTracesPayload,
|
||||
payload: FunnelOverviewPayload,
|
||||
): UseQueryResult<SuccessResponse<ErrorTraceData> | ErrorResponse, Error> => {
|
||||
const { selectedTime, validTracesCount } = useFunnelContext();
|
||||
const {
|
||||
selectedTime,
|
||||
validTracesCount,
|
||||
hasFunnelBeenExecuted,
|
||||
} = useFunnelContext();
|
||||
return useQuery({
|
||||
queryFn: ({ signal }) => getFunnelErrorTraces(funnelId, payload, signal),
|
||||
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_ERROR_TRACES, funnelId, selectedTime],
|
||||
enabled: !!funnelId && validTracesCount > 0,
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_FUNNEL_ERROR_TRACES,
|
||||
funnelId,
|
||||
selectedTime,
|
||||
payload.step_start ?? '',
|
||||
payload.step_end ?? '',
|
||||
],
|
||||
enabled: !!funnelId && validTracesCount > 0 && hasFunnelBeenExecuted,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -203,6 +230,7 @@ export function useFunnelStepsGraphData(
|
||||
endTime,
|
||||
selectedTime,
|
||||
validTracesCount,
|
||||
hasFunnelBeenExecuted,
|
||||
} = useFunnelContext();
|
||||
|
||||
return useQuery({
|
||||
@@ -217,6 +245,31 @@ export function useFunnelStepsGraphData(
|
||||
funnelId,
|
||||
selectedTime,
|
||||
],
|
||||
enabled: !!funnelId && validTracesCount > 0,
|
||||
enabled: !!funnelId && validTracesCount > 0 && hasFunnelBeenExecuted,
|
||||
});
|
||||
}
|
||||
|
||||
export const useFunnelStepsOverview = (
|
||||
funnelId: string,
|
||||
payload: FunnelStepsOverviewPayload,
|
||||
): UseQueryResult<
|
||||
SuccessResponse<FunnelStepsOverviewResponse> | ErrorResponse,
|
||||
Error
|
||||
> => {
|
||||
const {
|
||||
selectedTime,
|
||||
validTracesCount,
|
||||
hasFunnelBeenExecuted,
|
||||
} = useFunnelContext();
|
||||
return useQuery({
|
||||
queryFn: ({ signal }) => getFunnelStepsOverview(funnelId, payload, signal),
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_FUNNEL_STEPS_OVERVIEW,
|
||||
funnelId,
|
||||
selectedTime,
|
||||
payload.step_start ?? '',
|
||||
payload.step_end ?? '',
|
||||
],
|
||||
enabled: !!funnelId && validTracesCount > 0 && hasFunnelBeenExecuted,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { getApDexSettings } from 'api/metrics/ApDex/getApDexSettings';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import getApDexSettings from 'api/v1/settings/apdex/services/get';
|
||||
import { useQuery, UseQueryResult } from 'react-query';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { ApDexPayloadAndSettingsProps } from 'types/api/metrics/getApDex';
|
||||
|
||||
export const useGetApDexSettings = (
|
||||
servicename: string,
|
||||
): UseQueryResult<AxiosResponse<ApDexPayloadAndSettingsProps[]>, AxiosError> =>
|
||||
useQuery<AxiosResponse<ApDexPayloadAndSettingsProps[]>, AxiosError>({
|
||||
): UseQueryResult<
|
||||
SuccessResponseV2<ApDexPayloadAndSettingsProps[]>,
|
||||
APIError
|
||||
> =>
|
||||
useQuery<SuccessResponseV2<ApDexPayloadAndSettingsProps[]>, APIError>({
|
||||
queryKey: [{ servicename }],
|
||||
queryFn: async () => getApDexSettings(servicename),
|
||||
});
|
||||
|
||||
83
frontend/src/hooks/useLocalStorage.ts
Normal file
83
frontend/src/hooks/useLocalStorage.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* A React hook for interacting with localStorage.
|
||||
* It allows getting, setting, and removing items from localStorage.
|
||||
*
|
||||
* @template T The type of the value to be stored.
|
||||
* @param {string} key The localStorage key.
|
||||
* @param {T | (() => T)} initialValue The initial value to use if no value is found in localStorage,
|
||||
* @returns {[T, (value: T | ((prevState: T) => T)) => void, () => void]}
|
||||
* A tuple containing:
|
||||
* - The current value from state (and localStorage).
|
||||
* - A function to set the value (updates state and localStorage).
|
||||
* - A function to remove the value from localStorage and reset state to initialValue.
|
||||
*/
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T | (() => T),
|
||||
): [T, (value: T | ((prevState: T) => T)) => void, () => void] {
|
||||
// This function resolves the initialValue if it's a function,
|
||||
// and handles potential errors during localStorage access or JSON parsing.
|
||||
const readValueFromStorage = useCallback((): T => {
|
||||
const resolvedInitialValue =
|
||||
initialValue instanceof Function ? initialValue() : initialValue;
|
||||
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
// If item exists, parse it, otherwise return the resolved initial value.
|
||||
if (item) {
|
||||
return JSON.parse(item) as T;
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error and fall back to initial value if reading/parsing fails.
|
||||
console.warn(`Error reading localStorage key "${key}":`, error);
|
||||
}
|
||||
return resolvedInitialValue;
|
||||
}, [key, initialValue]);
|
||||
|
||||
// Initialize state by reading from localStorage.
|
||||
const [storedValue, setStoredValue] = useState<T>(readValueFromStorage);
|
||||
|
||||
// This function updates both localStorage and the React state.
|
||||
const setValue = useCallback(
|
||||
(value: T | ((prevState: T) => T)) => {
|
||||
try {
|
||||
// If a function is passed to setValue, it receives the latest value from storage.
|
||||
const latestValueFromStorage = readValueFromStorage();
|
||||
const valueToStore =
|
||||
value instanceof Function ? value(latestValueFromStorage) : value;
|
||||
|
||||
// Save to localStorage.
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
// Update React state.
|
||||
setStoredValue(valueToStore);
|
||||
} catch (error) {
|
||||
console.warn(`Error setting localStorage key "${key}":`, error);
|
||||
}
|
||||
},
|
||||
[key, readValueFromStorage],
|
||||
);
|
||||
|
||||
// This function removes the item from localStorage and resets the React state.
|
||||
const removeValue = useCallback(() => {
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
// Reset state to the (potentially resolved) initialValue.
|
||||
setStoredValue(
|
||||
initialValue instanceof Function ? initialValue() : initialValue,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(`Error removing localStorage key "${key}":`, error);
|
||||
}
|
||||
}, [key, initialValue]);
|
||||
|
||||
// useEffect to update the storedValue if the key changes,
|
||||
// or if the initialValue prop changes causing readValueFromStorage to change.
|
||||
// This ensures the hook reflects the correct localStorage item if its key prop dynamically changes.
|
||||
useEffect(() => {
|
||||
setStoredValue(readValueFromStorage());
|
||||
}, [key, readValueFromStorage]); // Re-run if key or the read function changes.
|
||||
|
||||
return [storedValue, setValue, removeValue];
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import { SettingOutlined } from '@ant-design/icons';
|
||||
import { Popover } from 'antd';
|
||||
import { IServiceName } from 'container/MetricsApplication/Tabs/types';
|
||||
import { useGetApDexSettings } from 'hooks/apDex/useGetApDexSettings';
|
||||
import useErrorNotification from 'hooks/useErrorNotification';
|
||||
import { useState } from 'react';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { Button } from '../styles';
|
||||
@@ -20,7 +20,16 @@ function ApDexApplication(): JSX.Element {
|
||||
refetch: refetchGetApDexSetting,
|
||||
} = useGetApDexSettings(servicename);
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
useErrorNotification(error);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
notifications.error({
|
||||
message: error.getErrorCode(),
|
||||
description: error.getErrorMessage(),
|
||||
});
|
||||
}
|
||||
}, [error, notifications]);
|
||||
|
||||
const handlePopOverClose = (): void => {
|
||||
setIsOpen(false);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { ApDexPayloadAndSettingsProps } from 'types/api/metrics/getApDex';
|
||||
|
||||
export const axiosResponseThresholdData = {
|
||||
data: [
|
||||
@@ -6,4 +8,5 @@ export const axiosResponseThresholdData = {
|
||||
threshold: 0.5,
|
||||
},
|
||||
],
|
||||
} as AxiosResponse;
|
||||
httpStatusCode: StatusCodes.OK,
|
||||
} as SuccessResponseV2<ApDexPayloadAndSettingsProps[]>;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { ApDexPayloadAndSettingsProps } from 'types/api/metrics/getApDex';
|
||||
|
||||
export interface ApDexSettingsProps {
|
||||
servicename: string;
|
||||
handlePopOverClose: () => void;
|
||||
isLoading?: boolean;
|
||||
data?: AxiosResponse<ApDexPayloadAndSettingsProps[]>;
|
||||
data?: SuccessResponseV2<ApDexPayloadAndSettingsProps[]>;
|
||||
refetchGetApDexSetting?: () => void;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import './TraceDetailV2.styles.scss';
|
||||
|
||||
import { Button, Tabs } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { Compass, Cone, TowerControl, Undo } from 'lucide-react';
|
||||
@@ -34,6 +35,7 @@ function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
|
||||
history.push(ROUTES.TRACES_EXPLORER);
|
||||
}
|
||||
if (activeKey === 'funnels') {
|
||||
logEvent('Trace Funnels: visited from trace details page', {});
|
||||
history.push(ROUTES.TRACES_FUNNELS);
|
||||
}
|
||||
}}
|
||||
@@ -65,19 +67,15 @@ export default function TraceDetailsPage(): JSX.Element {
|
||||
key: 'trace-details',
|
||||
children: <TraceDetailsV2 />,
|
||||
},
|
||||
...(process.env.NODE_ENV === 'development'
|
||||
? [
|
||||
{
|
||||
label: (
|
||||
<div className="tab-item">
|
||||
<Cone className="funnel-icon" size={16} /> Funnels
|
||||
</div>
|
||||
),
|
||||
key: 'funnels',
|
||||
children: <div />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: (
|
||||
<div className="tab-item">
|
||||
<Cone className="funnel-icon" size={16} /> Funnels
|
||||
</div>
|
||||
),
|
||||
key: 'funnels',
|
||||
children: <div />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="tab-item">
|
||||
|
||||
@@ -39,7 +39,7 @@ function AddFunnelStepDetailsModal({
|
||||
setStepName(stepData?.name || '');
|
||||
setDescription(stepData?.description || '');
|
||||
}
|
||||
}, [isOpen, stepData]);
|
||||
}, [isOpen, stepData?.name, stepData?.description]);
|
||||
|
||||
const handleCancel = (): void => {
|
||||
setStepName('');
|
||||
|
||||
@@ -26,7 +26,7 @@ function InterStepConfig({
|
||||
</div>
|
||||
<div className="inter-step-config__latency-options">
|
||||
<SignozRadioGroup
|
||||
value={step.latency_type}
|
||||
value={step.latency_type ?? LatencyOptions.P99}
|
||||
options={options}
|
||||
onChange={(e): void =>
|
||||
onStepChange(index, {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import './StepsContent.styles.scss';
|
||||
|
||||
import { Button, Steps } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { PlusIcon, Undo2 } from 'lucide-react';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
@@ -28,6 +29,10 @@ function StepsContent({
|
||||
if (stepWasAdded) {
|
||||
handleReplaceStep(steps.length, span.serviceName, span.name);
|
||||
}
|
||||
logEvent(
|
||||
'Trace Funnels: span added for a new step from trace details page',
|
||||
{},
|
||||
);
|
||||
}, [span, handleAddStep, handleReplaceStep, steps.length]);
|
||||
|
||||
return (
|
||||
@@ -60,7 +65,8 @@ function StepsContent({
|
||||
</div>
|
||||
{/* Display InterStepConfig only between steps */}
|
||||
{index < steps.length - 1 && (
|
||||
<InterStepConfig index={index} step={step} />
|
||||
// the latency type should be sent with the n+1th step
|
||||
<InterStepConfig index={index + 1} step={steps[index + 1]} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -34,12 +34,16 @@
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0 !important;
|
||||
}
|
||||
&--save {
|
||||
background-color: var(--bg-slate-400);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 10px; /* 83.333% */
|
||||
letter-spacing: 0.12px;
|
||||
border-radius: 2px;
|
||||
|
||||
&--sync {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
&--run {
|
||||
background-color: var(--bg-robin-500);
|
||||
|
||||
@@ -1,8 +1,49 @@
|
||||
import './StepsFooter.styles.scss';
|
||||
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Cone, Play } from 'lucide-react';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { Cone, Play, RefreshCcw } from 'lucide-react';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useMemo } from 'react';
|
||||
import { useIsFetching } from 'react-query';
|
||||
|
||||
const useFunnelResultsLoading = (): boolean => {
|
||||
const { funnelId } = useFunnelContext();
|
||||
|
||||
const isFetchingFunnelOverview = useIsFetching({
|
||||
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_OVERVIEW, funnelId],
|
||||
});
|
||||
|
||||
const isFetchingStepsGraphData = useIsFetching({
|
||||
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_STEPS_GRAPH_DATA, funnelId],
|
||||
});
|
||||
|
||||
const isFetchingErrorTraces = useIsFetching({
|
||||
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_ERROR_TRACES, funnelId],
|
||||
});
|
||||
|
||||
const isFetchingSlowTraces = useIsFetching({
|
||||
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_SLOW_TRACES, funnelId],
|
||||
});
|
||||
|
||||
return useMemo(() => {
|
||||
if (!funnelId) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
!!isFetchingFunnelOverview ||
|
||||
!!isFetchingStepsGraphData ||
|
||||
!!isFetchingErrorTraces ||
|
||||
!!isFetchingSlowTraces
|
||||
);
|
||||
}, [
|
||||
funnelId,
|
||||
isFetchingFunnelOverview,
|
||||
isFetchingStepsGraphData,
|
||||
isFetchingErrorTraces,
|
||||
isFetchingSlowTraces,
|
||||
]);
|
||||
};
|
||||
|
||||
interface StepsFooterProps {
|
||||
stepsCount: number;
|
||||
@@ -45,7 +86,13 @@ function ValidTracesCount(): JSX.Element {
|
||||
}
|
||||
|
||||
function StepsFooter({ stepsCount }: StepsFooterProps): JSX.Element {
|
||||
const { validTracesCount, handleRunFunnel } = useFunnelContext();
|
||||
const {
|
||||
validTracesCount,
|
||||
handleRunFunnel,
|
||||
hasFunnelBeenExecuted,
|
||||
} = useFunnelContext();
|
||||
|
||||
const isFunnelResultsLoading = useFunnelResultsLoading();
|
||||
|
||||
return (
|
||||
<div className="steps-footer">
|
||||
@@ -56,15 +103,28 @@ function StepsFooter({ stepsCount }: StepsFooterProps): JSX.Element {
|
||||
<ValidTracesCount />
|
||||
</div>
|
||||
<div className="steps-footer__right">
|
||||
<Button
|
||||
disabled={validTracesCount === 0}
|
||||
onClick={handleRunFunnel}
|
||||
type="primary"
|
||||
className="steps-footer__button steps-footer__button--run"
|
||||
icon={<Play size={16} />}
|
||||
>
|
||||
Run funnel
|
||||
</Button>
|
||||
{!hasFunnelBeenExecuted ? (
|
||||
<Button
|
||||
disabled={validTracesCount === 0}
|
||||
onClick={handleRunFunnel}
|
||||
type="primary"
|
||||
className="steps-footer__button steps-footer__button--run"
|
||||
icon={<Play size={16} />}
|
||||
>
|
||||
Run funnel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="text"
|
||||
className="steps-footer__button steps-footer__button--sync"
|
||||
icon={<RefreshCcw size={16} />}
|
||||
onClick={handleRunFunnel}
|
||||
loading={isFunnelResultsLoading}
|
||||
disabled={validTracesCount === 0}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ function EmptyFunnelResults({
|
||||
<div className="empty-funnel-results__title">{title}</div>
|
||||
<div className="empty-funnel-results__description">{description}</div>
|
||||
<div className="empty-funnel-results__learn-more">
|
||||
<LearnMore />
|
||||
<LearnMore url="https://signoz.io/blog/tracing-funnels-observability-distributed-systems/" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ function FunnelResults(): JSX.Element {
|
||||
isValidateStepsLoading,
|
||||
hasIncompleteStepFields,
|
||||
hasAllEmptyStepFields,
|
||||
hasFunnelBeenExecuted,
|
||||
} = useFunnelContext();
|
||||
|
||||
if (isValidateStepsLoading) {
|
||||
@@ -38,6 +39,14 @@ function FunnelResults(): JSX.Element {
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!hasFunnelBeenExecuted) {
|
||||
return (
|
||||
<EmptyFunnelResults
|
||||
title="Funnel has not been run yet."
|
||||
description="Run the funnel to see the results"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="funnel-results">
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { ErrorTraceData, SlowTraceData } from 'api/traceFunnels';
|
||||
import {
|
||||
ErrorTraceData,
|
||||
FunnelOverviewPayload,
|
||||
SlowTraceData,
|
||||
} from 'api/traceFunnels';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useMemo } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
@@ -15,12 +19,7 @@ interface FunnelTopTracesTableProps {
|
||||
tooltip: string;
|
||||
useQueryHook: (
|
||||
funnelId: string,
|
||||
payload: {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
step_a_order: number;
|
||||
step_b_order: number;
|
||||
},
|
||||
payload: FunnelOverviewPayload,
|
||||
) => UseQueryResult<
|
||||
SuccessResponse<SlowTraceData | ErrorTraceData> | ErrorResponse,
|
||||
Error
|
||||
@@ -40,8 +39,8 @@ function FunnelTopTracesTable({
|
||||
() => ({
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
step_a_order: stepAOrder,
|
||||
step_b_order: stepBOrder,
|
||||
step_start: stepAOrder,
|
||||
step_end: stepBOrder,
|
||||
}),
|
||||
[startTime, endTime, stepAOrder, stepBOrder],
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useFunnelMetrics } from 'hooks/TracesFunnels/useFunnelMetrics';
|
||||
import { useFunnelStepsMetrics } from 'hooks/TracesFunnels/useFunnelMetrics';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import FunnelMetricsTable from './FunnelMetricsTable';
|
||||
@@ -22,7 +22,7 @@ function StepsTransitionMetrics({
|
||||
(transition) => transition.value === selectedTransition,
|
||||
);
|
||||
|
||||
const { isLoading, metricsData, conversionRate } = useFunnelMetrics({
|
||||
const { isLoading, metricsData, conversionRate } = useFunnelStepsMetrics({
|
||||
funnelId: funnelId || '',
|
||||
stepStart: startStep,
|
||||
stepEnd: endStep,
|
||||
|
||||
@@ -13,7 +13,7 @@ export const topTracesTableColumns = [
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'DURATION',
|
||||
title: 'STEP TRANSITION DURATION',
|
||||
dataIndex: 'duration_ms',
|
||||
key: 'duration_ms',
|
||||
render: (value: string): string => getYAxisFormattedValue(value, 'ms'),
|
||||
|
||||
@@ -12,7 +12,7 @@ export const initialStepsData: FunnelStepData[] = [
|
||||
op: 'and',
|
||||
},
|
||||
latency_pointer: 'start',
|
||||
latency_type: LatencyOptions.P95,
|
||||
latency_type: undefined,
|
||||
has_errors: false,
|
||||
name: '',
|
||||
description: '',
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ValidateFunnelResponse } from 'api/traceFunnels';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { Time } from 'container/TopNav/DateTimeSelection/config';
|
||||
import {
|
||||
@@ -6,6 +8,7 @@ import {
|
||||
Time as TimeV2,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/config';
|
||||
import { useValidateFunnelSteps } from 'hooks/TracesFunnels/useFunnels';
|
||||
import { useLocalStorage } from 'hooks/useLocalStorage';
|
||||
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
|
||||
import { initialStepsData } from 'pages/TracesFunnelDetails/constants';
|
||||
import {
|
||||
@@ -44,15 +47,15 @@ interface FunnelContextType {
|
||||
| undefined;
|
||||
isValidateStepsLoading: boolean;
|
||||
hasIncompleteStepFields: boolean;
|
||||
setHasIncompleteStepFields: Dispatch<SetStateAction<boolean>>;
|
||||
hasAllEmptyStepFields: boolean;
|
||||
setHasAllEmptyStepFields: Dispatch<SetStateAction<boolean>>;
|
||||
handleReplaceStep: (
|
||||
index: number,
|
||||
serviceName: string,
|
||||
spanName: string,
|
||||
) => void;
|
||||
handleRestoreSteps: (oldSteps: FunnelStepData[]) => void;
|
||||
hasFunnelBeenExecuted: boolean;
|
||||
setHasFunnelBeenExecuted: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const FunnelContext = createContext<FunnelContextType | undefined>(undefined);
|
||||
@@ -83,12 +86,27 @@ export function FunnelProvider({
|
||||
const funnel = data?.payload;
|
||||
const initialSteps = funnel?.steps?.length ? funnel.steps : initialStepsData;
|
||||
const [steps, setSteps] = useState<FunnelStepData[]>(initialSteps);
|
||||
const [hasIncompleteStepFields, setHasIncompleteStepFields] = useState(
|
||||
steps.some((step) => step.service_name === '' || step.span_name === ''),
|
||||
const { hasIncompleteStepFields, hasAllEmptyStepFields } = useMemo(
|
||||
() => ({
|
||||
hasAllEmptyStepFields: steps.every(
|
||||
(step) => step.service_name === '' && step.span_name === '',
|
||||
),
|
||||
hasIncompleteStepFields: steps.some(
|
||||
(step) => step.service_name === '' || step.span_name === '',
|
||||
),
|
||||
}),
|
||||
[steps],
|
||||
);
|
||||
const [hasAllEmptyStepFields, setHasAllEmptyStepFields] = useState(
|
||||
steps.every((step) => step.service_name === '' && step.span_name === ''),
|
||||
|
||||
const [unexecutedFunnels, setUnexecutedFunnels] = useLocalStorage<string[]>(
|
||||
LOCALSTORAGE.UNEXECUTED_FUNNELS,
|
||||
[],
|
||||
);
|
||||
|
||||
const [hasFunnelBeenExecuted, setHasFunnelBeenExecuted] = useState(
|
||||
!unexecutedFunnels.includes(funnelId),
|
||||
);
|
||||
|
||||
const {
|
||||
data: validationResponse,
|
||||
isLoading: isValidationLoading,
|
||||
@@ -98,6 +116,12 @@ export function FunnelProvider({
|
||||
selectedTime,
|
||||
startTime,
|
||||
endTime,
|
||||
enabled:
|
||||
!!funnelId &&
|
||||
!!selectedTime &&
|
||||
!!startTime &&
|
||||
!!endTime &&
|
||||
!hasIncompleteStepFields,
|
||||
});
|
||||
|
||||
const validTracesCount = useMemo(
|
||||
@@ -152,6 +176,7 @@ export function FunnelProvider({
|
||||
service_name: serviceName,
|
||||
span_name: spanName,
|
||||
});
|
||||
logEvent('Trace Funnels: span added (replaced) from trace details page', {});
|
||||
},
|
||||
[handleStepUpdate],
|
||||
);
|
||||
@@ -161,6 +186,11 @@ export function FunnelProvider({
|
||||
|
||||
const handleRunFunnel = useCallback(async (): Promise<void> => {
|
||||
if (validTracesCount === 0) return;
|
||||
if (!hasFunnelBeenExecuted) {
|
||||
setUnexecutedFunnels(unexecutedFunnels.filter((id) => id !== funnelId));
|
||||
|
||||
setHasFunnelBeenExecuted(true);
|
||||
}
|
||||
queryClient.refetchQueries([
|
||||
REACT_QUERY_KEY.GET_FUNNEL_OVERVIEW,
|
||||
funnelId,
|
||||
@@ -181,7 +211,15 @@ export function FunnelProvider({
|
||||
funnelId,
|
||||
selectedTime,
|
||||
]);
|
||||
}, [funnelId, queryClient, selectedTime, validTracesCount]);
|
||||
}, [
|
||||
funnelId,
|
||||
hasFunnelBeenExecuted,
|
||||
unexecutedFunnels,
|
||||
queryClient,
|
||||
selectedTime,
|
||||
setUnexecutedFunnels,
|
||||
validTracesCount,
|
||||
]);
|
||||
|
||||
const value = useMemo<FunnelContextType>(
|
||||
() => ({
|
||||
@@ -200,11 +238,11 @@ export function FunnelProvider({
|
||||
validationResponse,
|
||||
isValidateStepsLoading: isValidationLoading || isValidationFetching,
|
||||
hasIncompleteStepFields,
|
||||
setHasIncompleteStepFields,
|
||||
hasAllEmptyStepFields,
|
||||
setHasAllEmptyStepFields,
|
||||
handleReplaceStep,
|
||||
handleRestoreSteps,
|
||||
hasFunnelBeenExecuted,
|
||||
setHasFunnelBeenExecuted,
|
||||
}),
|
||||
[
|
||||
funnelId,
|
||||
@@ -222,11 +260,11 @@ export function FunnelProvider({
|
||||
isValidationLoading,
|
||||
isValidationFetching,
|
||||
hasIncompleteStepFields,
|
||||
setHasIncompleteStepFields,
|
||||
hasAllEmptyStepFields,
|
||||
setHasAllEmptyStepFields,
|
||||
handleReplaceStep,
|
||||
handleRestoreSteps,
|
||||
hasFunnelBeenExecuted,
|
||||
setHasFunnelBeenExecuted,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import '../RenameFunnel/RenameFunnel.styles.scss';
|
||||
|
||||
import { Input } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { AxiosError } from 'axios';
|
||||
import SignozModal from 'components/SignozModal/SignozModal';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useCreateFunnel } from 'hooks/TracesFunnels/useFunnels';
|
||||
import { useLocalStorage } from 'hooks/useLocalStorage';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { generatePath, matchPath, useLocation } from 'react-router-dom';
|
||||
|
||||
interface CreateFunnelProps {
|
||||
isOpen: boolean;
|
||||
@@ -29,6 +32,12 @@ function CreateFunnel({
|
||||
const { notifications } = useNotifications();
|
||||
const queryClient = useQueryClient();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const [unexecutedFunnels, setUnexecutedFunnels] = useLocalStorage<string[]>(
|
||||
LOCALSTORAGE.UNEXECUTED_FUNNELS,
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCreate = (): void => {
|
||||
createFunnelMutation.mutate(
|
||||
@@ -41,13 +50,26 @@ function CreateFunnel({
|
||||
notifications.success({
|
||||
message: 'Funnel created successfully',
|
||||
});
|
||||
|
||||
const eventMessage = matchPath(pathname, ROUTES.TRACE_DETAIL)
|
||||
? 'Trace Funnels: Funnel created from trace details page'
|
||||
: 'Trace Funnels: Funnel created from trace funnels list page';
|
||||
|
||||
logEvent(eventMessage, {});
|
||||
|
||||
setFunnelName('');
|
||||
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_FUNNELS_LIST]);
|
||||
onClose(data?.payload?.funnel_id);
|
||||
if (data?.payload?.funnel_id && redirectToDetails) {
|
||||
|
||||
const funnelId = data?.payload?.funnel_id;
|
||||
if (funnelId) {
|
||||
setUnexecutedFunnels([...unexecutedFunnels, funnelId]);
|
||||
}
|
||||
|
||||
onClose(funnelId);
|
||||
if (funnelId && redirectToDetails) {
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.TRACES_FUNNELS_DETAIL, {
|
||||
funnelId: data.payload.funnel_id,
|
||||
funnelId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ function FunnelsEmptyState({
|
||||
>
|
||||
New funnel
|
||||
</Button>
|
||||
<LearnMore />
|
||||
<LearnMore url="https://signoz.io/blog/tracing-funnels-observability-distributed-systems/" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,10 @@ function RenameFunnel({
|
||||
message: 'Funnel renamed successfully',
|
||||
});
|
||||
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_FUNNELS_LIST]);
|
||||
queryClient.invalidateQueries([
|
||||
REACT_QUERY_KEY.GET_FUNNEL_DETAILS,
|
||||
funnelId,
|
||||
]);
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import './TracesModulePage.styles.scss';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
import { TabRoutes } from 'components/RouteTab/types';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -14,9 +15,15 @@ function TracesModulePage(): JSX.Element {
|
||||
const routes: TabRoutes[] = [
|
||||
tracesExplorer,
|
||||
// TODO(shaheer): remove this check after everything is ready
|
||||
process.env.NODE_ENV === 'development' ? tracesFunnel(pathname) : null,
|
||||
tracesFunnel(pathname),
|
||||
tracesSaveView,
|
||||
].filter(Boolean) as TabRoutes[];
|
||||
];
|
||||
|
||||
const handleTabChange = (activeRoute: string): void => {
|
||||
if (activeRoute === ROUTES.TRACES_FUNNELS) {
|
||||
logEvent('Trace Funnels: visited from trace explorer page', {});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="traces-module-container">
|
||||
@@ -26,6 +33,7 @@ function TracesModulePage(): JSX.Element {
|
||||
pathname.includes(ROUTES.TRACES_FUNNELS) ? ROUTES.TRACES_FUNNELS : pathname
|
||||
}
|
||||
history={history}
|
||||
onChangeHandler={handleTabChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,3 +12,8 @@ export interface MetricMetaProps {
|
||||
delta: boolean;
|
||||
le: number[] | null;
|
||||
}
|
||||
|
||||
export interface PayloadProps {
|
||||
data: ApDexPayloadAndSettingsProps[];
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface FunnelStepData {
|
||||
span_name: string;
|
||||
filters: TagFilter;
|
||||
latency_pointer: 'start' | 'end';
|
||||
latency_type: LatencyOptionsType;
|
||||
latency_type?: LatencyOptionsType;
|
||||
has_errors: boolean;
|
||||
name?: string;
|
||||
description?: string;
|
||||
|
||||
1232
pkg/modules/tracefunnel/clickhouse_queries.go
Normal file
1232
pkg/modules/tracefunnel/clickhouse_queries.go
Normal file
File diff suppressed because it is too large
Load Diff
294
pkg/modules/tracefunnel/impltracefunnel/handler.go
Normal file
294
pkg/modules/tracefunnel/impltracefunnel/handler.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package impltracefunnel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
tf "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
module tracefunnel.Module
|
||||
}
|
||||
|
||||
func NewHandler(module tracefunnel.Module) tracefunnel.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
func (handler *handler) New(rw http.ResponseWriter, r *http.Request) {
|
||||
var req tf.FunnelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := tracefunnel.GetClaims(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
funnel, err := handler.module.Create(r.Context(), req.Timestamp, req.Name, claims.UserID, claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to create funnel: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
response := tracefunnel.ConstructFunnelResponse(funnel, claims)
|
||||
render.Success(rw, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateSteps(rw http.ResponseWriter, r *http.Request) {
|
||||
var req tf.FunnelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := tracefunnel.GetClaims(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
updatedAt, err := tracefunnel.ValidateAndConvertTimestamp(req.Timestamp)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
funnel, err := handler.module.Get(r.Context(), req.FunnelID.String())
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"funnel not found: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
steps, err := tracefunnel.ProcessFunnelSteps(req.Steps)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
funnel.Steps = steps
|
||||
funnel.UpdatedAt = updatedAt
|
||||
funnel.UpdatedBy = claims.UserID
|
||||
|
||||
if req.Name != "" {
|
||||
funnel.Name = req.Name
|
||||
}
|
||||
if req.Description != "" {
|
||||
funnel.Description = req.Description
|
||||
}
|
||||
|
||||
if err := handler.module.Update(r.Context(), funnel, claims.UserID); err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to update funnel in database: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
updatedFunnel, err := handler.module.Get(r.Context(), funnel.ID.String())
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to get updated funnel: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
response := tracefunnel.ConstructFunnelResponse(updatedFunnel, claims)
|
||||
render.Success(rw, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateFunnel(rw http.ResponseWriter, r *http.Request) {
|
||||
var req tf.FunnelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := tracefunnel.GetClaims(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
updatedAt, err := tracefunnel.ValidateAndConvertTimestamp(req.Timestamp)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := handler.module.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"funnel not found: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
funnel.UpdatedAt = updatedAt
|
||||
funnel.UpdatedBy = claims.UserID
|
||||
|
||||
if req.Name != "" {
|
||||
funnel.Name = req.Name
|
||||
}
|
||||
if req.Description != "" {
|
||||
funnel.Description = req.Description
|
||||
}
|
||||
|
||||
if err := handler.module.Update(r.Context(), funnel, claims.UserID); err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to update funnel in database: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
updatedFunnel, err := handler.module.Get(r.Context(), funnel.ID.String())
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to get updated funnel: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
response := tracefunnel.ConstructFunnelResponse(updatedFunnel, claims)
|
||||
render.Success(rw, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := tracefunnel.GetClaims(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
funnels, err := handler.module.List(r.Context(), claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to list funnels: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
var response []tf.FunnelResponse
|
||||
for _, f := range funnels {
|
||||
response = append(response, tracefunnel.ConstructFunnelResponse(f, claims))
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := handler.module.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"funnel not found: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
claims, _ := tracefunnel.GetClaims(r) // Ignore error as email is optional
|
||||
response := tracefunnel.ConstructFunnelResponse(funnel, claims)
|
||||
render.Success(rw, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (handler *handler) Delete(rw http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
if err := handler.module.Delete(r.Context(), funnelID); err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to delete funnel: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) Save(rw http.ResponseWriter, r *http.Request) {
|
||||
var req tf.FunnelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"invalid request: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := tracefunnel.GetClaims(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
funnel, err := handler.module.Get(r.Context(), req.FunnelID.String())
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"funnel not found: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
updateTimestamp := req.Timestamp
|
||||
if updateTimestamp == 0 {
|
||||
updateTimestamp = time.Now().UnixMilli()
|
||||
} else if !tracefunnel.ValidateTimestampIsMilliseconds(updateTimestamp) {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"timestamp must be in milliseconds format (13 digits)"))
|
||||
return
|
||||
}
|
||||
|
||||
updatedAt, err := tracefunnel.ValidateAndConvertTimestamp(updateTimestamp)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
funnel.UpdatedAt = updatedAt
|
||||
funnel.UpdatedBy = claims.UserID
|
||||
funnel.Description = req.Description
|
||||
|
||||
if err := handler.module.Save(r.Context(), funnel, funnel.UpdatedBy, claims.OrgID); err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to save funnel: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
createdAtMillis, updatedAtMillis, extraDataFromDB, err := handler.module.GetFunnelMetadata(r.Context(), funnel.ID.String())
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to get funnel metadata: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
resp := tf.FunnelResponse{
|
||||
FunnelName: funnel.Name,
|
||||
CreatedAt: createdAtMillis,
|
||||
UpdatedAt: updatedAtMillis,
|
||||
CreatedBy: funnel.CreatedBy,
|
||||
UpdatedBy: funnel.UpdatedBy,
|
||||
OrgID: funnel.OrgID.String(),
|
||||
Description: extraDataFromDB,
|
||||
UserEmail: claims.Email,
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, resp)
|
||||
}
|
||||
421
pkg/modules/tracefunnel/impltracefunnel/handler_test.go
Normal file
421
pkg/modules/tracefunnel/impltracefunnel/handler_test.go
Normal file
@@ -0,0 +1,421 @@
|
||||
package impltracefunnel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type MockModule struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockModule) Create(ctx context.Context, timestamp int64, name string, userID string, orgID string) (*traceFunnels.Funnel, error) {
|
||||
args := m.Called(ctx, timestamp, name, userID, orgID)
|
||||
return args.Get(0).(*traceFunnels.Funnel), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockModule) Get(ctx context.Context, funnelID string) (*traceFunnels.Funnel, error) {
|
||||
args := m.Called(ctx, funnelID)
|
||||
return args.Get(0).(*traceFunnels.Funnel), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockModule) Update(ctx context.Context, funnel *traceFunnels.Funnel, userID string) error {
|
||||
args := m.Called(ctx, funnel, userID)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockModule) List(ctx context.Context, orgID string) ([]*traceFunnels.Funnel, error) {
|
||||
args := m.Called(ctx, orgID)
|
||||
return args.Get(0).([]*traceFunnels.Funnel), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockModule) Delete(ctx context.Context, funnelID string) error {
|
||||
args := m.Called(ctx, funnelID)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockModule) Save(ctx context.Context, funnel *traceFunnels.Funnel, userID string, orgID string) error {
|
||||
args := m.Called(ctx, funnel, userID, orgID)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockModule) GetFunnelMetadata(ctx context.Context, funnelID string) (int64, int64, string, error) {
|
||||
args := m.Called(ctx, funnelID)
|
||||
return args.Get(0).(int64), args.Get(1).(int64), args.String(2), args.Error(3)
|
||||
}
|
||||
|
||||
func TestHandler_New(t *testing.T) {
|
||||
mockModule := new(MockModule)
|
||||
handler := NewHandler(mockModule)
|
||||
|
||||
reqBody := traceFunnels.FunnelRequest{
|
||||
Name: "test-funnel",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
jsonBody, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/trace-funnels/new", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
orgID := valuer.GenerateUUID().String()
|
||||
claims := authtypes.Claims{
|
||||
UserID: "user-123",
|
||||
OrgID: orgID,
|
||||
Email: "test@example.com",
|
||||
}
|
||||
req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), claims))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
funnelID := valuer.GenerateUUID()
|
||||
expectedFunnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnelID,
|
||||
},
|
||||
Name: reqBody.Name,
|
||||
OrgID: valuer.MustNewUUID(orgID),
|
||||
},
|
||||
}
|
||||
|
||||
mockModule.On("List", req.Context(), orgID).Return([]*traceFunnels.Funnel{}, nil)
|
||||
mockModule.On("Create", req.Context(), reqBody.Timestamp, reqBody.Name, "user-123", orgID).Return(expectedFunnel, nil)
|
||||
|
||||
handler.New(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Data traceFunnels.FunnelResponse `json:"data"`
|
||||
}
|
||||
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "success", response.Status)
|
||||
assert.Equal(t, reqBody.Name, response.Data.FunnelName)
|
||||
assert.Equal(t, orgID, response.Data.OrgID)
|
||||
assert.Equal(t, "test@example.com", response.Data.UserEmail)
|
||||
|
||||
mockModule.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestHandler_Update(t *testing.T) {
|
||||
mockModule := new(MockModule)
|
||||
handler := NewHandler(mockModule)
|
||||
|
||||
// Create a valid UUID for the funnel ID
|
||||
funnelID := valuer.GenerateUUID()
|
||||
orgID := valuer.GenerateUUID().String()
|
||||
|
||||
reqBody := traceFunnels.FunnelRequest{
|
||||
FunnelID: funnelID,
|
||||
Name: "updated-funnel",
|
||||
Steps: []traceFunnels.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut, "/api/v1/trace-funnels/steps/update", bytes.NewBuffer(body))
|
||||
assert.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Set up context with claims
|
||||
claims := authtypes.Claims{
|
||||
UserID: "user-123",
|
||||
OrgID: orgID,
|
||||
Email: "test@example.com",
|
||||
}
|
||||
ctx := authtypes.NewContextWithClaims(req.Context(), claims)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
// Set up mock expectations
|
||||
existingFunnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnelID,
|
||||
},
|
||||
Name: "test-funnel",
|
||||
OrgID: valuer.MustNewUUID(orgID),
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
UserAuditable: types.UserAuditable{
|
||||
CreatedBy: "user-123",
|
||||
UpdatedBy: "user-123",
|
||||
},
|
||||
},
|
||||
CreatedByUser: &types.User{
|
||||
ID: "user-123",
|
||||
Email: "test@example.com",
|
||||
},
|
||||
}
|
||||
|
||||
updatedFunnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnelID,
|
||||
},
|
||||
Name: reqBody.Name,
|
||||
OrgID: valuer.MustNewUUID(orgID),
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Unix(0, reqBody.Timestamp*1000000),
|
||||
},
|
||||
UserAuditable: types.UserAuditable{
|
||||
CreatedBy: "user-123",
|
||||
UpdatedBy: "user-123",
|
||||
},
|
||||
},
|
||||
Steps: reqBody.Steps,
|
||||
CreatedByUser: &types.User{
|
||||
ID: "user-123",
|
||||
Email: "test@example.com",
|
||||
},
|
||||
}
|
||||
|
||||
// First Get call to validate the funnel exists
|
||||
mockModule.On("Get", req.Context(), funnelID.String()).Return(existingFunnel, nil).Once()
|
||||
// List call to check for name conflicts
|
||||
mockModule.On("List", req.Context(), orgID).Return([]*traceFunnels.Funnel{}, nil).Once()
|
||||
// Update call to save the changes
|
||||
mockModule.On("Update", req.Context(), mock.MatchedBy(func(f *traceFunnels.Funnel) bool {
|
||||
return f.Name == reqBody.Name &&
|
||||
f.ID.String() == funnelID.String() &&
|
||||
len(f.Steps) == len(reqBody.Steps) &&
|
||||
f.Steps[0].Name == reqBody.Steps[0].Name &&
|
||||
f.Steps[0].ServiceName == reqBody.Steps[0].ServiceName &&
|
||||
f.Steps[0].SpanName == reqBody.Steps[0].SpanName &&
|
||||
f.Steps[1].Name == reqBody.Steps[1].Name &&
|
||||
f.Steps[1].ServiceName == reqBody.Steps[1].ServiceName &&
|
||||
f.Steps[1].SpanName == reqBody.Steps[1].SpanName &&
|
||||
f.UpdatedAt.UnixNano()/1000000 == reqBody.Timestamp &&
|
||||
f.UpdatedBy == "user-123"
|
||||
}), "user-123").Return(nil).Once()
|
||||
// Second Get call to get the updated funnel for the response
|
||||
mockModule.On("Get", req.Context(), funnelID.String()).Return(updatedFunnel, nil).Once()
|
||||
|
||||
handler.UpdateSteps(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Data traceFunnels.FunnelResponse `json:"data"`
|
||||
}
|
||||
err = json.Unmarshal(rr.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "success", response.Status)
|
||||
assert.Equal(t, "updated-funnel", response.Data.FunnelName)
|
||||
assert.Equal(t, funnelID.String(), response.Data.FunnelID)
|
||||
assert.Equal(t, "test@example.com", response.Data.UserEmail)
|
||||
|
||||
mockModule.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestHandler_List(t *testing.T) {
|
||||
mockModule := new(MockModule)
|
||||
handler := NewHandler(mockModule)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/trace-funnels/list", nil)
|
||||
|
||||
orgID := valuer.GenerateUUID().String()
|
||||
claims := authtypes.Claims{
|
||||
OrgID: orgID,
|
||||
}
|
||||
req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), claims))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
funnel1ID := valuer.GenerateUUID()
|
||||
funnel2ID := valuer.GenerateUUID()
|
||||
expectedFunnels := []*traceFunnels.Funnel{
|
||||
{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnel1ID,
|
||||
},
|
||||
Name: "funnel-1",
|
||||
OrgID: valuer.MustNewUUID(orgID),
|
||||
},
|
||||
},
|
||||
{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnel2ID,
|
||||
},
|
||||
Name: "funnel-2",
|
||||
OrgID: valuer.MustNewUUID(orgID),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockModule.On("List", req.Context(), orgID).Return(expectedFunnels, nil)
|
||||
|
||||
handler.List(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Data []traceFunnels.FunnelResponse `json:"data"`
|
||||
}
|
||||
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "success", response.Status)
|
||||
assert.Len(t, response.Data, 2)
|
||||
assert.Equal(t, "funnel-1", response.Data[0].FunnelName)
|
||||
assert.Equal(t, "funnel-2", response.Data[1].FunnelName)
|
||||
|
||||
mockModule.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestHandler_Get(t *testing.T) {
|
||||
mockModule := new(MockModule)
|
||||
handler := NewHandler(mockModule)
|
||||
|
||||
funnelID := valuer.GenerateUUID()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/trace-funnels/"+funnelID.String(), nil)
|
||||
req = mux.SetURLVars(req, map[string]string{"funnel_id": funnelID.String()})
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
expectedFunnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnelID,
|
||||
},
|
||||
Name: "test-funnel",
|
||||
OrgID: valuer.GenerateUUID(),
|
||||
},
|
||||
}
|
||||
|
||||
mockModule.On("Get", req.Context(), funnelID.String()).Return(expectedFunnel, nil)
|
||||
|
||||
handler.Get(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Data traceFunnels.FunnelResponse `json:"data"`
|
||||
}
|
||||
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "success", response.Status)
|
||||
assert.Equal(t, "test-funnel", response.Data.FunnelName)
|
||||
assert.Equal(t, expectedFunnel.OrgID.String(), response.Data.OrgID)
|
||||
|
||||
mockModule.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestHandler_Delete(t *testing.T) {
|
||||
mockModule := new(MockModule)
|
||||
handler := NewHandler(mockModule)
|
||||
|
||||
funnelID := valuer.GenerateUUID()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/trace-funnels/"+funnelID.String(), nil)
|
||||
req = mux.SetURLVars(req, map[string]string{"funnel_id": funnelID.String()})
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
mockModule.On("Delete", req.Context(), funnelID.String()).Return(nil)
|
||||
|
||||
handler.Delete(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
mockModule.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestHandler_Save(t *testing.T) {
|
||||
mockModule := new(MockModule)
|
||||
handler := NewHandler(mockModule)
|
||||
|
||||
reqBody := traceFunnels.FunnelRequest{
|
||||
FunnelID: valuer.GenerateUUID(),
|
||||
Description: "updated description",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
UserID: "user-123",
|
||||
}
|
||||
|
||||
jsonBody, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/trace-funnels/save", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
orgID := valuer.GenerateUUID().String()
|
||||
claims := authtypes.Claims{
|
||||
UserID: "user-123",
|
||||
OrgID: orgID,
|
||||
}
|
||||
req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), claims))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
existingFunnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: reqBody.FunnelID,
|
||||
},
|
||||
Name: "test-funnel",
|
||||
OrgID: valuer.MustNewUUID(orgID),
|
||||
},
|
||||
}
|
||||
|
||||
mockModule.On("Get", req.Context(), reqBody.FunnelID.String()).Return(existingFunnel, nil)
|
||||
mockModule.On("Save", req.Context(), mock.MatchedBy(func(f *traceFunnels.Funnel) bool {
|
||||
return f.ID.String() == reqBody.FunnelID.String() &&
|
||||
f.Name == existingFunnel.Name &&
|
||||
f.Description == reqBody.Description &&
|
||||
f.UpdatedBy == "user-123" &&
|
||||
f.OrgID.String() == orgID
|
||||
}), "user-123", orgID).Return(nil)
|
||||
mockModule.On("GetFunnelMetadata", req.Context(), reqBody.FunnelID.String()).Return(int64(0), int64(0), reqBody.Description, nil)
|
||||
|
||||
handler.Save(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Data traceFunnels.FunnelResponse `json:"data"`
|
||||
}
|
||||
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "success", response.Status)
|
||||
assert.Equal(t, reqBody.Description, response.Data.Description)
|
||||
|
||||
mockModule.AssertExpectations(t)
|
||||
}
|
||||
117
pkg/modules/tracefunnel/impltracefunnel/module.go
Normal file
117
pkg/modules/tracefunnel/impltracefunnel/module.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package impltracefunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store traceFunnels.FunnelStore
|
||||
}
|
||||
|
||||
func NewModule(store traceFunnels.FunnelStore) tracefunnel.Module {
|
||||
return &module{
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (module *module) Create(ctx context.Context, timestamp int64, name string, userID string, orgID string) (*traceFunnels.Funnel, error) {
|
||||
orgUUID, err := valuer.NewUUID(orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid org ID: %v", err)
|
||||
}
|
||||
|
||||
funnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Name: name,
|
||||
OrgID: orgUUID,
|
||||
},
|
||||
}
|
||||
funnel.CreatedAt = time.Unix(0, timestamp*1000000) // Convert to nanoseconds
|
||||
funnel.CreatedBy = userID
|
||||
|
||||
// Set up the user relationship
|
||||
funnel.CreatedByUser = &types.User{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.MustNewUUID(userID),
|
||||
},
|
||||
}
|
||||
|
||||
if err := module.store.Create(ctx, funnel); err != nil {
|
||||
return nil, fmt.Errorf("failed to create funnel: %v", err)
|
||||
}
|
||||
|
||||
return funnel, nil
|
||||
}
|
||||
|
||||
// Get gets a funnel by ID
|
||||
func (module *module) Get(ctx context.Context, funnelID string) (*traceFunnels.Funnel, error) {
|
||||
uuid, err := valuer.NewUUID(funnelID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid funnel ID: %v", err)
|
||||
}
|
||||
return module.store.Get(ctx, uuid)
|
||||
}
|
||||
|
||||
// Update updates a funnel
|
||||
func (module *module) Update(ctx context.Context, funnel *traceFunnels.Funnel, userID string) error {
|
||||
funnel.UpdatedBy = userID
|
||||
return module.store.Update(ctx, funnel)
|
||||
}
|
||||
|
||||
// List lists all funnels for an organization
|
||||
func (module *module) List(ctx context.Context, orgID string) ([]*traceFunnels.Funnel, error) {
|
||||
orgUUID, err := valuer.NewUUID(orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid org ID: %v", err)
|
||||
}
|
||||
|
||||
funnels, err := module.store.List(ctx, orgUUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list funnels: %v", err)
|
||||
}
|
||||
|
||||
return funnels, nil
|
||||
}
|
||||
|
||||
// Delete deletes a funnel
|
||||
func (module *module) Delete(ctx context.Context, funnelID string) error {
|
||||
uuid, err := valuer.NewUUID(funnelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid funnel ID: %v", err)
|
||||
}
|
||||
return module.store.Delete(ctx, uuid)
|
||||
}
|
||||
|
||||
// Save saves a funnel
|
||||
func (module *module) Save(ctx context.Context, funnel *traceFunnels.Funnel, userID string, orgID string) error {
|
||||
orgUUID, err := valuer.NewUUID(orgID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid org ID: %v", err)
|
||||
}
|
||||
|
||||
funnel.UpdatedBy = userID
|
||||
funnel.OrgID = orgUUID
|
||||
return module.store.Update(ctx, funnel)
|
||||
}
|
||||
|
||||
// GetFunnelMetadata gets metadata for a funnel
|
||||
func (module *module) GetFunnelMetadata(ctx context.Context, funnelID string) (int64, int64, string, error) {
|
||||
uuid, err := valuer.NewUUID(funnelID)
|
||||
if err != nil {
|
||||
return 0, 0, "", fmt.Errorf("invalid funnel ID: %v", err)
|
||||
}
|
||||
|
||||
funnel, err := module.store.Get(ctx, uuid)
|
||||
if err != nil {
|
||||
return 0, 0, "", err
|
||||
}
|
||||
|
||||
return funnel.CreatedAt.UnixNano() / 1000000, funnel.UpdatedAt.UnixNano() / 1000000, funnel.Description, nil
|
||||
}
|
||||
213
pkg/modules/tracefunnel/impltracefunnel/module_test.go
Normal file
213
pkg/modules/tracefunnel/impltracefunnel/module_test.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package impltracefunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type MockStore struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockStore) Create(ctx context.Context, funnel *traceFunnels.Funnel) error {
|
||||
args := m.Called(ctx, funnel)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockStore) Get(ctx context.Context, uuid valuer.UUID) (*traceFunnels.Funnel, error) {
|
||||
args := m.Called(ctx, uuid)
|
||||
return args.Get(0).(*traceFunnels.Funnel), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockStore) List(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.Funnel, error) {
|
||||
args := m.Called(ctx, orgID)
|
||||
return args.Get(0).([]*traceFunnels.Funnel), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockStore) Update(ctx context.Context, funnel *traceFunnels.Funnel) error {
|
||||
args := m.Called(ctx, funnel)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockStore) Delete(ctx context.Context, uuid valuer.UUID) error {
|
||||
args := m.Called(ctx, uuid)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func TestModule_Create(t *testing.T) {
|
||||
mockStore := new(MockStore)
|
||||
module := NewModule(mockStore)
|
||||
|
||||
ctx := context.Background()
|
||||
timestamp := time.Now().UnixMilli()
|
||||
name := "test-funnel"
|
||||
userID := "user-123"
|
||||
orgID := valuer.GenerateUUID().String()
|
||||
|
||||
mockStore.On("Create", ctx, mock.MatchedBy(func(f *traceFunnels.Funnel) bool {
|
||||
return f.Name == name &&
|
||||
f.CreatedBy == userID &&
|
||||
f.OrgID.String() == orgID &&
|
||||
f.CreatedByUser != nil &&
|
||||
f.CreatedByUser.ID == userID &&
|
||||
f.CreatedAt.UnixNano()/1000000 == timestamp
|
||||
})).Return(nil)
|
||||
|
||||
funnel, err := module.Create(ctx, timestamp, name, userID, orgID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, funnel)
|
||||
assert.Equal(t, name, funnel.Name)
|
||||
assert.Equal(t, userID, funnel.CreatedBy)
|
||||
assert.Equal(t, orgID, funnel.OrgID.String())
|
||||
assert.NotNil(t, funnel.CreatedByUser)
|
||||
assert.Equal(t, userID, funnel.CreatedByUser.ID)
|
||||
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestModule_Get(t *testing.T) {
|
||||
mockStore := new(MockStore)
|
||||
module := NewModule(mockStore)
|
||||
|
||||
ctx := context.Background()
|
||||
funnelID := valuer.GenerateUUID().String()
|
||||
expectedFunnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Name: "test-funnel",
|
||||
},
|
||||
}
|
||||
|
||||
mockStore.On("Get", ctx, mock.AnythingOfType("valuer.UUID")).Return(expectedFunnel, nil)
|
||||
|
||||
funnel, err := module.Get(ctx, funnelID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedFunnel, funnel)
|
||||
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestModule_Update(t *testing.T) {
|
||||
mockStore := new(MockStore)
|
||||
module := NewModule(mockStore)
|
||||
|
||||
ctx := context.Background()
|
||||
userID := "user-123"
|
||||
funnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Name: "test-funnel",
|
||||
},
|
||||
}
|
||||
|
||||
mockStore.On("Update", ctx, funnel).Return(nil)
|
||||
|
||||
err := module.Update(ctx, funnel, userID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, userID, funnel.UpdatedBy)
|
||||
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestModule_List(t *testing.T) {
|
||||
mockStore := new(MockStore)
|
||||
module := NewModule(mockStore)
|
||||
|
||||
ctx := context.Background()
|
||||
orgID := valuer.GenerateUUID().String()
|
||||
orgUUID := valuer.MustNewUUID(orgID)
|
||||
expectedFunnels := []*traceFunnels.Funnel{
|
||||
{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Name: "funnel-1",
|
||||
OrgID: orgUUID,
|
||||
},
|
||||
},
|
||||
{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Name: "funnel-2",
|
||||
OrgID: orgUUID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockStore.On("List", ctx, orgUUID).Return(expectedFunnels, nil)
|
||||
|
||||
funnels, err := module.List(ctx, orgID)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, funnels, 2)
|
||||
assert.Equal(t, expectedFunnels, funnels)
|
||||
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestModule_Delete(t *testing.T) {
|
||||
mockStore := new(MockStore)
|
||||
module := NewModule(mockStore)
|
||||
|
||||
ctx := context.Background()
|
||||
funnelID := valuer.GenerateUUID().String()
|
||||
|
||||
mockStore.On("Delete", ctx, mock.AnythingOfType("valuer.UUID")).Return(nil)
|
||||
|
||||
err := module.Delete(ctx, funnelID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestModule_Save(t *testing.T) {
|
||||
mockStore := new(MockStore)
|
||||
module := NewModule(mockStore)
|
||||
|
||||
ctx := context.Background()
|
||||
userID := "user-123"
|
||||
orgID := valuer.GenerateUUID().String()
|
||||
funnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Name: "test-funnel",
|
||||
},
|
||||
}
|
||||
|
||||
mockStore.On("Update", ctx, funnel).Return(nil)
|
||||
|
||||
err := module.Save(ctx, funnel, userID, orgID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, userID, funnel.UpdatedBy)
|
||||
assert.Equal(t, orgID, funnel.OrgID.String())
|
||||
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestModule_GetFunnelMetadata(t *testing.T) {
|
||||
mockStore := new(MockStore)
|
||||
module := NewModule(mockStore)
|
||||
|
||||
ctx := context.Background()
|
||||
funnelID := valuer.GenerateUUID().String()
|
||||
now := time.Now()
|
||||
expectedFunnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Description: "test description",
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockStore.On("Get", ctx, mock.AnythingOfType("valuer.UUID")).Return(expectedFunnel, nil)
|
||||
|
||||
createdAt, updatedAt, description, err := module.GetFunnelMetadata(ctx, funnelID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, now.UnixNano()/1000000, createdAt)
|
||||
assert.Equal(t, now.UnixNano()/1000000, updatedAt)
|
||||
assert.Equal(t, "test description", description)
|
||||
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
114
pkg/modules/tracefunnel/impltracefunnel/store.go
Normal file
114
pkg/modules/tracefunnel/impltracefunnel/store.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package impltracefunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) traceFunnels.FunnelStore {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (store *store) Create(ctx context.Context, funnel *traceFunnels.Funnel) error {
|
||||
if funnel.ID.IsZero() {
|
||||
funnel.ID = valuer.GenerateUUID()
|
||||
}
|
||||
|
||||
if funnel.CreatedAt.IsZero() {
|
||||
funnel.CreatedAt = time.Now()
|
||||
}
|
||||
if funnel.UpdatedAt.IsZero() {
|
||||
funnel.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// Set created_by if CreatedByUser is present
|
||||
if funnel.CreatedByUser != nil {
|
||||
funnel.CreatedBy = funnel.CreatedByUser.Identifiable.ID.String()
|
||||
}
|
||||
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewInsert().
|
||||
Model(funnel).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, traceFunnels.ErrFunnelAlreadyExists, "a funnel with name '%s' already exists in this organization", funnel.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves a funnel by ID
|
||||
func (store *store) Get(ctx context.Context, uuid valuer.UUID) (*traceFunnels.Funnel, error) {
|
||||
funnel := &traceFunnels.Funnel{}
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(funnel).
|
||||
Relation("CreatedByUser").
|
||||
Where("?TableAlias.id = ?", uuid).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get funnel: %v", err)
|
||||
}
|
||||
return funnel, nil
|
||||
}
|
||||
|
||||
// Update updates an existing funnel
|
||||
func (store *store) Update(ctx context.Context, funnel *traceFunnels.Funnel) error {
|
||||
funnel.UpdatedAt = time.Now()
|
||||
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(funnel).
|
||||
WherePK().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, traceFunnels.ErrFunnelAlreadyExists, "a funnel with name '%s' already exists in this organization", funnel.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List retrieves all funnels for a given organization
|
||||
func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.Funnel, error) {
|
||||
var funnels []*traceFunnels.Funnel
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&funnels).
|
||||
Relation("CreatedByUser").
|
||||
Where("?TableAlias.org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list funnels: %v", err)
|
||||
}
|
||||
return funnels, nil
|
||||
}
|
||||
|
||||
// Delete removes a funnel by ID
|
||||
func (store *store) Delete(ctx context.Context, uuid valuer.UUID) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewDelete().
|
||||
Model((*traceFunnels.Funnel)(nil)).
|
||||
Where("id = ?", uuid).Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete funnel: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
420
pkg/modules/tracefunnel/query.go
Normal file
420
pkg/modules/tracefunnel/query.go
Normal file
@@ -0,0 +1,420 @@
|
||||
package tracefunnel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
tracev4 "github.com/SigNoz/signoz/pkg/query-service/app/traces/v4"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ValidateTraces(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange) (*v3.ClickHouseQuery, error) {
|
||||
var query string
|
||||
var err error
|
||||
|
||||
funnelSteps := funnel.Steps
|
||||
containsErrorT1 := 0
|
||||
containsErrorT2 := 0
|
||||
containsErrorT3 := 0
|
||||
|
||||
if funnelSteps[0].HasErrors {
|
||||
containsErrorT1 = 1
|
||||
}
|
||||
if funnelSteps[1].HasErrors {
|
||||
containsErrorT2 = 1
|
||||
}
|
||||
if len(funnel.Steps) > 2 && funnelSteps[2].HasErrors {
|
||||
containsErrorT3 = 1
|
||||
}
|
||||
|
||||
// Build filter clauses for each step
|
||||
clauseStep1, err := tracev4.BuildTracesFilterQuery(funnelSteps[0].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauseStep2, err := tracev4.BuildTracesFilterQuery(funnelSteps[1].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauseStep3 := ""
|
||||
if len(funnel.Steps) > 2 {
|
||||
clauseStep3, err = tracev4.BuildTracesFilterQuery(funnelSteps[2].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(funnel.Steps) > 2 {
|
||||
query = BuildThreeStepFunnelValidationQuery(
|
||||
containsErrorT1, // containsErrorT1
|
||||
containsErrorT2, // containsErrorT2
|
||||
containsErrorT3, // containsErrorT3
|
||||
timeRange.StartTime, // startTs
|
||||
timeRange.EndTime, // endTs
|
||||
funnelSteps[0].ServiceName, // serviceNameT1
|
||||
funnelSteps[0].SpanName, // spanNameT1
|
||||
funnelSteps[1].ServiceName, // serviceNameT1
|
||||
funnelSteps[1].SpanName, // spanNameT2
|
||||
funnelSteps[2].ServiceName, // serviceNameT1
|
||||
funnelSteps[2].SpanName, // spanNameT3
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
clauseStep3,
|
||||
)
|
||||
} else {
|
||||
query = BuildTwoStepFunnelValidationQuery(
|
||||
containsErrorT1, // containsErrorT1
|
||||
containsErrorT2, // containsErrorT2
|
||||
timeRange.StartTime, // startTs
|
||||
timeRange.EndTime, // endTs
|
||||
funnelSteps[0].ServiceName, // serviceNameT1
|
||||
funnelSteps[0].SpanName, // spanNameT1
|
||||
funnelSteps[1].ServiceName, // serviceNameT1
|
||||
funnelSteps[1].SpanName, // spanNameT2
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
)
|
||||
}
|
||||
|
||||
return &v3.ClickHouseQuery{
|
||||
Query: query,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func GetFunnelAnalytics(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange) (*v3.ClickHouseQuery, error) {
|
||||
var query string
|
||||
var err error
|
||||
|
||||
funnelSteps := funnel.Steps
|
||||
containsErrorT1 := 0
|
||||
containsErrorT2 := 0
|
||||
containsErrorT3 := 0
|
||||
latencyPointerT1 := funnelSteps[0].LatencyPointer
|
||||
latencyPointerT2 := funnelSteps[1].LatencyPointer
|
||||
latencyPointerT3 := "start"
|
||||
if len(funnel.Steps) > 2 {
|
||||
latencyPointerT3 = funnelSteps[2].LatencyPointer
|
||||
}
|
||||
|
||||
if funnelSteps[0].HasErrors {
|
||||
containsErrorT1 = 1
|
||||
}
|
||||
if funnelSteps[1].HasErrors {
|
||||
containsErrorT2 = 1
|
||||
}
|
||||
if len(funnel.Steps) > 2 && funnelSteps[2].HasErrors {
|
||||
containsErrorT3 = 1
|
||||
}
|
||||
|
||||
// Build filter clauses for each step
|
||||
clauseStep1, err := tracev4.BuildTracesFilterQuery(funnelSteps[0].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauseStep2, err := tracev4.BuildTracesFilterQuery(funnelSteps[1].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauseStep3 := ""
|
||||
if len(funnel.Steps) > 2 {
|
||||
clauseStep3, err = tracev4.BuildTracesFilterQuery(funnelSteps[2].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(funnel.Steps) > 2 {
|
||||
query = BuildThreeStepFunnelOverviewQuery(
|
||||
containsErrorT1, // containsErrorT1
|
||||
containsErrorT2, // containsErrorT2
|
||||
containsErrorT3, // containsErrorT3
|
||||
latencyPointerT1,
|
||||
latencyPointerT2,
|
||||
latencyPointerT3,
|
||||
timeRange.StartTime, // startTs
|
||||
timeRange.EndTime, // endTs
|
||||
funnelSteps[0].ServiceName, // serviceNameT1
|
||||
funnelSteps[0].SpanName, // spanNameT1
|
||||
funnelSteps[1].ServiceName, // serviceNameT1
|
||||
funnelSteps[1].SpanName, // spanNameT2
|
||||
funnelSteps[2].ServiceName, // serviceNameT1
|
||||
funnelSteps[2].SpanName, // spanNameT3
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
clauseStep3,
|
||||
)
|
||||
} else {
|
||||
query = BuildTwoStepFunnelOverviewQuery(
|
||||
containsErrorT1, // containsErrorT1
|
||||
containsErrorT2, // containsErrorT2
|
||||
latencyPointerT1,
|
||||
latencyPointerT2,
|
||||
timeRange.StartTime, // startTs
|
||||
timeRange.EndTime, // endTs
|
||||
funnelSteps[0].ServiceName, // serviceNameT1
|
||||
funnelSteps[0].SpanName, // spanNameT1
|
||||
funnelSteps[1].ServiceName, // serviceNameT1
|
||||
funnelSteps[1].SpanName, // spanNameT2
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
)
|
||||
}
|
||||
return &v3.ClickHouseQuery{Query: query}, nil
|
||||
}
|
||||
|
||||
func GetFunnelStepAnalytics(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange, stepStart, stepEnd int64) (*v3.ClickHouseQuery, error) {
|
||||
var query string
|
||||
var err error
|
||||
|
||||
funnelSteps := funnel.Steps
|
||||
containsErrorT1 := 0
|
||||
containsErrorT2 := 0
|
||||
containsErrorT3 := 0
|
||||
latencyPointerT1 := funnelSteps[0].LatencyPointer
|
||||
latencyPointerT2 := funnelSteps[1].LatencyPointer
|
||||
latencyPointerT3 := "start"
|
||||
if len(funnel.Steps) > 2 {
|
||||
latencyPointerT3 = funnelSteps[2].LatencyPointer
|
||||
}
|
||||
latencyTypeT2 := "p99"
|
||||
latencyTypeT3 := "p99"
|
||||
|
||||
if stepStart == stepEnd {
|
||||
return nil, fmt.Errorf("step start and end cannot be the same for /step/overview")
|
||||
}
|
||||
|
||||
if funnelSteps[0].HasErrors {
|
||||
containsErrorT1 = 1
|
||||
}
|
||||
if funnelSteps[1].HasErrors {
|
||||
containsErrorT2 = 1
|
||||
}
|
||||
if len(funnel.Steps) > 2 && funnelSteps[2].HasErrors {
|
||||
containsErrorT3 = 1
|
||||
}
|
||||
|
||||
if funnelSteps[1].LatencyType != "" {
|
||||
latencyTypeT2 = strings.ToLower(funnelSteps[1].LatencyType)
|
||||
}
|
||||
if len(funnel.Steps) > 2 && funnelSteps[2].LatencyType != "" {
|
||||
latencyTypeT3 = strings.ToLower(funnelSteps[2].LatencyType)
|
||||
}
|
||||
|
||||
// Build filter clauses for each step
|
||||
clauseStep1, err := tracev4.BuildTracesFilterQuery(funnelSteps[0].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauseStep2, err := tracev4.BuildTracesFilterQuery(funnelSteps[1].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauseStep3 := ""
|
||||
if len(funnel.Steps) > 2 {
|
||||
clauseStep3, err = tracev4.BuildTracesFilterQuery(funnelSteps[2].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(funnel.Steps) > 2 {
|
||||
query = BuildThreeStepFunnelStepOverviewQuery(
|
||||
containsErrorT1, // containsErrorT1
|
||||
containsErrorT2, // containsErrorT2
|
||||
containsErrorT3, // containsErrorT3
|
||||
latencyPointerT1,
|
||||
latencyPointerT2,
|
||||
latencyPointerT3,
|
||||
timeRange.StartTime, // startTs
|
||||
timeRange.EndTime, // endTs
|
||||
funnelSteps[0].ServiceName, // serviceNameT1
|
||||
funnelSteps[0].SpanName, // spanNameT1
|
||||
funnelSteps[1].ServiceName, // serviceNameT1
|
||||
funnelSteps[1].SpanName, // spanNameT2
|
||||
funnelSteps[2].ServiceName, // serviceNameT1
|
||||
funnelSteps[2].SpanName, // spanNameT3
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
clauseStep3,
|
||||
stepStart,
|
||||
stepEnd,
|
||||
latencyTypeT2,
|
||||
latencyTypeT3,
|
||||
)
|
||||
} else {
|
||||
query = BuildTwoStepFunnelStepOverviewQuery(
|
||||
containsErrorT1, // containsErrorT1
|
||||
containsErrorT2, // containsErrorT2
|
||||
latencyPointerT1,
|
||||
latencyPointerT2,
|
||||
timeRange.StartTime, // startTs
|
||||
timeRange.EndTime, // endTs
|
||||
funnelSteps[0].ServiceName, // serviceNameT1
|
||||
funnelSteps[0].SpanName, // spanNameT1
|
||||
funnelSteps[1].ServiceName, // serviceNameT1
|
||||
funnelSteps[1].SpanName, // spanNameT2
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
latencyTypeT2,
|
||||
)
|
||||
}
|
||||
return &v3.ClickHouseQuery{Query: query}, nil
|
||||
}
|
||||
|
||||
func GetStepAnalytics(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange) (*v3.ClickHouseQuery, error) {
|
||||
var query string
|
||||
|
||||
funnelSteps := funnel.Steps
|
||||
containsErrorT1 := 0
|
||||
containsErrorT2 := 0
|
||||
containsErrorT3 := 0
|
||||
|
||||
if funnelSteps[0].HasErrors {
|
||||
containsErrorT1 = 1
|
||||
}
|
||||
if funnelSteps[1].HasErrors {
|
||||
containsErrorT2 = 1
|
||||
}
|
||||
if len(funnel.Steps) > 2 && funnelSteps[2].HasErrors {
|
||||
containsErrorT3 = 1
|
||||
}
|
||||
|
||||
// Build filter clauses for each step
|
||||
clauseStep1, err := tracev4.BuildTracesFilterQuery(funnelSteps[0].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauseStep2, err := tracev4.BuildTracesFilterQuery(funnelSteps[1].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauseStep3 := ""
|
||||
if len(funnel.Steps) > 2 {
|
||||
clauseStep3, err = tracev4.BuildTracesFilterQuery(funnelSteps[2].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(funnel.Steps) > 2 {
|
||||
query = BuildThreeStepFunnelCountQuery(
|
||||
containsErrorT1, // containsErrorT1
|
||||
containsErrorT2, // containsErrorT2
|
||||
containsErrorT3, // containsErrorT3
|
||||
timeRange.StartTime, // startTs
|
||||
timeRange.EndTime, // endTs
|
||||
funnelSteps[0].ServiceName, // serviceNameT1
|
||||
funnelSteps[0].SpanName, // spanNameT1
|
||||
funnelSteps[1].ServiceName, // serviceNameT1
|
||||
funnelSteps[1].SpanName, // spanNameT2
|
||||
funnelSteps[2].ServiceName, // serviceNameT1
|
||||
funnelSteps[2].SpanName, // spanNameT3
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
clauseStep3,
|
||||
)
|
||||
} else {
|
||||
query = BuildTwoStepFunnelCountQuery(
|
||||
containsErrorT1, // containsErrorT1
|
||||
containsErrorT2, // containsErrorT2
|
||||
timeRange.StartTime, // startTs
|
||||
timeRange.EndTime, // endTs
|
||||
funnelSteps[0].ServiceName, // serviceNameT1
|
||||
funnelSteps[0].SpanName, // spanNameT1
|
||||
funnelSteps[1].ServiceName, // serviceNameT1
|
||||
funnelSteps[1].SpanName, // spanNameT2
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
)
|
||||
}
|
||||
|
||||
return &v3.ClickHouseQuery{
|
||||
Query: query,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func GetSlowestTraces(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange, stepStart, stepEnd int64) (*v3.ClickHouseQuery, error) {
|
||||
funnelSteps := funnel.Steps
|
||||
containsErrorT1 := 0
|
||||
containsErrorT2 := 0
|
||||
stepStartOrder := 0
|
||||
stepEndOrder := 1
|
||||
|
||||
if stepStart != stepEnd {
|
||||
stepStartOrder = int(stepStart) - 1
|
||||
stepEndOrder = int(stepEnd) - 1
|
||||
if funnelSteps[stepStartOrder].HasErrors {
|
||||
containsErrorT1 = 1
|
||||
}
|
||||
if funnelSteps[stepEndOrder].HasErrors {
|
||||
containsErrorT2 = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Build filter clauses for the steps
|
||||
clauseStep1, err := tracev4.BuildTracesFilterQuery(funnelSteps[stepStartOrder].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauseStep2, err := tracev4.BuildTracesFilterQuery(funnelSteps[stepEndOrder].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := BuildTwoStepFunnelTopSlowTracesQuery(
|
||||
containsErrorT1, // containsErrorT1
|
||||
containsErrorT2, // containsErrorT2
|
||||
timeRange.StartTime, // startTs
|
||||
timeRange.EndTime, // endTs
|
||||
funnelSteps[stepStartOrder].ServiceName, // serviceNameT1
|
||||
funnelSteps[stepStartOrder].SpanName, // spanNameT1
|
||||
funnelSteps[stepEndOrder].ServiceName, // serviceNameT1
|
||||
funnelSteps[stepEndOrder].SpanName, // spanNameT2
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
)
|
||||
return &v3.ClickHouseQuery{Query: query}, nil
|
||||
}
|
||||
|
||||
func GetErroredTraces(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange, stepStart, stepEnd int64) (*v3.ClickHouseQuery, error) {
|
||||
funnelSteps := funnel.Steps
|
||||
containsErrorT1 := 0
|
||||
containsErrorT2 := 0
|
||||
stepStartOrder := 0
|
||||
stepEndOrder := 1
|
||||
|
||||
if stepStart != stepEnd {
|
||||
stepStartOrder = int(stepStart) - 1
|
||||
stepEndOrder = int(stepEnd) - 1
|
||||
if funnelSteps[stepStartOrder].HasErrors {
|
||||
containsErrorT1 = 1
|
||||
}
|
||||
if funnelSteps[stepEndOrder].HasErrors {
|
||||
containsErrorT2 = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Build filter clauses for the steps
|
||||
clauseStep1, err := tracev4.BuildTracesFilterQuery(funnelSteps[stepStartOrder].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauseStep2, err := tracev4.BuildTracesFilterQuery(funnelSteps[stepEndOrder].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := BuildTwoStepFunnelTopSlowErrorTracesQuery(
|
||||
containsErrorT1, // containsErrorT1
|
||||
containsErrorT2, // containsErrorT2
|
||||
timeRange.StartTime, // startTs
|
||||
timeRange.EndTime, // endTs
|
||||
funnelSteps[stepStartOrder].ServiceName, // serviceNameT1
|
||||
funnelSteps[stepStartOrder].SpanName, // spanNameT1
|
||||
funnelSteps[stepEndOrder].ServiceName, // serviceNameT1
|
||||
funnelSteps[stepEndOrder].SpanName, // spanNameT2
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
)
|
||||
return &v3.ClickHouseQuery{Query: query}, nil
|
||||
}
|
||||
41
pkg/modules/tracefunnel/tracefunnel.go
Normal file
41
pkg/modules/tracefunnel/tracefunnel.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package tracefunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
)
|
||||
|
||||
// Module defines the interface for trace funnel operations
|
||||
type Module interface {
|
||||
Create(ctx context.Context, timestamp int64, name string, userID string, orgID string) (*traceFunnels.Funnel, error)
|
||||
|
||||
Get(ctx context.Context, funnelID string) (*traceFunnels.Funnel, error)
|
||||
|
||||
Update(ctx context.Context, funnel *traceFunnels.Funnel, userID string) error
|
||||
|
||||
List(ctx context.Context, orgID string) ([]*traceFunnels.Funnel, error)
|
||||
|
||||
Delete(ctx context.Context, funnelID string) error
|
||||
|
||||
Save(ctx context.Context, funnel *traceFunnels.Funnel, userID string, orgID string) error
|
||||
|
||||
GetFunnelMetadata(ctx context.Context, funnelID string) (int64, int64, string, error)
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
New(http.ResponseWriter, *http.Request)
|
||||
|
||||
UpdateSteps(http.ResponseWriter, *http.Request)
|
||||
|
||||
UpdateFunnel(http.ResponseWriter, *http.Request)
|
||||
|
||||
List(http.ResponseWriter, *http.Request)
|
||||
|
||||
Get(http.ResponseWriter, *http.Request)
|
||||
|
||||
Delete(http.ResponseWriter, *http.Request)
|
||||
|
||||
Save(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
132
pkg/modules/tracefunnel/utils.go
Normal file
132
pkg/modules/tracefunnel/utils.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package tracefunnel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// ValidateTimestamp validates a timestamp
|
||||
func ValidateTimestamp(timestamp int64, fieldName string) error {
|
||||
if timestamp == 0 {
|
||||
return fmt.Errorf("%s is required", fieldName)
|
||||
}
|
||||
if timestamp < 0 {
|
||||
return fmt.Errorf("%s must be positive", fieldName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateTimestampIsMilliseconds validates that a timestamp is in milliseconds
|
||||
func ValidateTimestampIsMilliseconds(timestamp int64) bool {
|
||||
return timestamp >= 1000000000000 && timestamp <= 9999999999999
|
||||
}
|
||||
|
||||
func ValidateFunnelSteps(steps []tracefunnel.FunnelStep) error {
|
||||
if len(steps) < 2 {
|
||||
return fmt.Errorf("funnel must have at least 2 steps")
|
||||
}
|
||||
|
||||
for i, step := range steps {
|
||||
if step.ServiceName == "" {
|
||||
return fmt.Errorf("step %d: service name is required", i+1)
|
||||
}
|
||||
if step.SpanName == "" {
|
||||
return fmt.Errorf("step %d: span name is required", i+1)
|
||||
}
|
||||
if step.Order < 0 {
|
||||
return fmt.Errorf("step %d: order must be non-negative", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeFunnelSteps normalizes step orders to be sequential starting from 1.
|
||||
// Returns a new slice with normalized step orders, leaving the input slice unchanged.
|
||||
func NormalizeFunnelSteps(steps []tracefunnel.FunnelStep) []tracefunnel.FunnelStep {
|
||||
if len(steps) == 0 {
|
||||
return []tracefunnel.FunnelStep{}
|
||||
}
|
||||
|
||||
newSteps := make([]tracefunnel.FunnelStep, len(steps))
|
||||
copy(newSteps, steps)
|
||||
|
||||
sort.Slice(newSteps, func(i, j int) bool {
|
||||
return newSteps[i].Order < newSteps[j].Order
|
||||
})
|
||||
|
||||
for i := range newSteps {
|
||||
newSteps[i].Order = int64(i + 1)
|
||||
}
|
||||
|
||||
return newSteps
|
||||
}
|
||||
|
||||
func GetClaims(r *http.Request) (*authtypes.Claims, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"unauthenticated")
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
func ValidateAndConvertTimestamp(timestamp int64) (time.Time, error) {
|
||||
if err := ValidateTimestamp(timestamp, "timestamp"); err != nil {
|
||||
return time.Time{}, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"timestamp is invalid: %v", err)
|
||||
}
|
||||
return time.Unix(0, timestamp*1000000), nil // Convert to nanoseconds
|
||||
}
|
||||
|
||||
func ConstructFunnelResponse(funnel *tracefunnel.Funnel, claims *authtypes.Claims) tracefunnel.FunnelResponse {
|
||||
resp := tracefunnel.FunnelResponse{
|
||||
FunnelName: funnel.Name,
|
||||
FunnelID: funnel.ID.String(),
|
||||
Steps: funnel.Steps,
|
||||
CreatedAt: funnel.CreatedAt.UnixNano() / 1000000,
|
||||
CreatedBy: funnel.CreatedBy,
|
||||
OrgID: funnel.OrgID.String(),
|
||||
UpdatedBy: funnel.UpdatedBy,
|
||||
UpdatedAt: funnel.UpdatedAt.UnixNano() / 1000000,
|
||||
Description: funnel.Description,
|
||||
}
|
||||
|
||||
if funnel.CreatedByUser != nil {
|
||||
resp.UserEmail = funnel.CreatedByUser.Email
|
||||
} else if claims != nil {
|
||||
resp.UserEmail = claims.Email
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func ProcessFunnelSteps(steps []tracefunnel.FunnelStep) ([]tracefunnel.FunnelStep, error) {
|
||||
// First validate the steps
|
||||
if err := ValidateFunnelSteps(steps); err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"invalid funnel steps: %v", err)
|
||||
}
|
||||
|
||||
// Then process the steps
|
||||
for i := range steps {
|
||||
if steps[i].Order < 1 {
|
||||
steps[i].Order = int64(i + 1)
|
||||
}
|
||||
if steps[i].ID.IsZero() {
|
||||
steps[i].ID = valuer.GenerateUUID()
|
||||
}
|
||||
}
|
||||
|
||||
return NormalizeFunnelSteps(steps), nil
|
||||
}
|
||||
657
pkg/modules/tracefunnel/utils_test.go
Normal file
657
pkg/modules/tracefunnel/utils_test.go
Normal file
@@ -0,0 +1,657 @@
|
||||
package tracefunnel
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestValidateTimestamp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
timestamp int64
|
||||
fieldName string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid timestamp",
|
||||
timestamp: time.Now().UnixMilli(),
|
||||
fieldName: "timestamp",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "zero timestamp",
|
||||
timestamp: 0,
|
||||
fieldName: "timestamp",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "negative timestamp",
|
||||
timestamp: -1,
|
||||
fieldName: "timestamp",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateTimestamp(tt.timestamp, tt.fieldName)
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTimestampIsMilliseconds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
timestamp int64
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "valid millisecond timestamp",
|
||||
timestamp: 1700000000000, // 2023-11-14 12:00:00 UTC
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "too small timestamp",
|
||||
timestamp: 999999999999,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "too large timestamp",
|
||||
timestamp: 10000000000000,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "second precision timestamp",
|
||||
timestamp: 1700000000,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ValidateTimestampIsMilliseconds(tt.timestamp)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFunnelSteps(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
steps []tracefunnel.FunnelStep
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid steps",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "too few steps",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "missing service name",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "missing span name",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "negative order",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: -1,
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateFunnelSteps(tt.steps)
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFunnelSteps(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
steps []tracefunnel.FunnelStep
|
||||
expected []tracefunnel.FunnelStep
|
||||
}{
|
||||
{
|
||||
name: "already normalized steps",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
expected: []tracefunnel.FunnelStep{
|
||||
{
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unordered steps",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
},
|
||||
expected: []tracefunnel.FunnelStep{
|
||||
{
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "steps with gaps in order",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 3",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-3",
|
||||
Order: 3,
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
expected: []tracefunnel.FunnelStep{
|
||||
{
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
{
|
||||
Name: "Step 3",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-3",
|
||||
Order: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Make a copy of the steps to avoid modifying the original
|
||||
steps := make([]tracefunnel.FunnelStep, len(tt.steps))
|
||||
copy(steps, tt.steps)
|
||||
|
||||
result := NormalizeFunnelSteps(steps)
|
||||
|
||||
// Compare only the relevant fields
|
||||
for i := range result {
|
||||
assert.Equal(t, tt.expected[i].Name, result[i].Name)
|
||||
assert.Equal(t, tt.expected[i].ServiceName, result[i].ServiceName)
|
||||
assert.Equal(t, tt.expected[i].SpanName, result[i].SpanName)
|
||||
assert.Equal(t, tt.expected[i].Order, result[i].Order)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClaims(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*http.Request)
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid claims",
|
||||
setup: func(r *http.Request) {
|
||||
claims := authtypes.Claims{
|
||||
UserID: "user-123",
|
||||
OrgID: "org-123",
|
||||
Email: "test@example.com",
|
||||
}
|
||||
*r = *r.WithContext(authtypes.NewContextWithClaims(r.Context(), claims))
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "no claims in context",
|
||||
setup: func(r *http.Request) {},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
tt.setup(req)
|
||||
|
||||
claims, err := GetClaims(req)
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, claims)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, claims)
|
||||
assert.Equal(t, "user-123", claims.UserID)
|
||||
assert.Equal(t, "org-123", claims.OrgID)
|
||||
assert.Equal(t, "test@example.com", claims.Email)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAndConvertTimestamp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
timestamp int64
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid timestamp",
|
||||
timestamp: time.Now().UnixMilli(),
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "zero timestamp",
|
||||
timestamp: 0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "negative timestamp",
|
||||
timestamp: -1,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ValidateAndConvertTimestamp(tt.timestamp)
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
assert.True(t, result.IsZero())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, result.IsZero())
|
||||
// Verify the conversion from milliseconds to nanoseconds
|
||||
assert.Equal(t, tt.timestamp*1000000, result.UnixNano())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstructFunnelResponse(t *testing.T) {
|
||||
now := time.Now()
|
||||
funnelID := valuer.GenerateUUID()
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
funnel *tracefunnel.Funnel
|
||||
claims *authtypes.Claims
|
||||
expected tracefunnel.FunnelResponse
|
||||
}{
|
||||
{
|
||||
name: "with user email from funnel",
|
||||
funnel: &tracefunnel.Funnel{
|
||||
BaseMetadata: tracefunnel.BaseMetadata{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnelID,
|
||||
},
|
||||
Name: "test-funnel",
|
||||
OrgID: orgID,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
UserAuditable: types.UserAuditable{
|
||||
CreatedBy: "user-123",
|
||||
UpdatedBy: "user-123",
|
||||
},
|
||||
},
|
||||
CreatedByUser: &types.User{
|
||||
ID: "user-123",
|
||||
Email: "funnel@example.com",
|
||||
},
|
||||
Steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
claims: &authtypes.Claims{
|
||||
UserID: "user-123",
|
||||
OrgID: orgID.String(),
|
||||
Email: "claims@example.com",
|
||||
},
|
||||
expected: tracefunnel.FunnelResponse{
|
||||
FunnelName: "test-funnel",
|
||||
FunnelID: funnelID.String(),
|
||||
Steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
},
|
||||
CreatedAt: now.UnixNano() / 1000000,
|
||||
CreatedBy: "user-123",
|
||||
UpdatedAt: now.UnixNano() / 1000000,
|
||||
UpdatedBy: "user-123",
|
||||
OrgID: orgID.String(),
|
||||
UserEmail: "funnel@example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with user email from claims",
|
||||
funnel: &tracefunnel.Funnel{
|
||||
BaseMetadata: tracefunnel.BaseMetadata{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnelID,
|
||||
},
|
||||
Name: "test-funnel",
|
||||
OrgID: orgID,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
UserAuditable: types.UserAuditable{
|
||||
CreatedBy: "user-123",
|
||||
UpdatedBy: "user-123",
|
||||
},
|
||||
},
|
||||
Steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
claims: &authtypes.Claims{
|
||||
UserID: "user-123",
|
||||
OrgID: orgID.String(),
|
||||
Email: "claims@example.com",
|
||||
},
|
||||
expected: tracefunnel.FunnelResponse{
|
||||
FunnelName: "test-funnel",
|
||||
FunnelID: funnelID.String(),
|
||||
Steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
},
|
||||
CreatedAt: now.UnixNano() / 1000000,
|
||||
CreatedBy: "user-123",
|
||||
UpdatedAt: now.UnixNano() / 1000000,
|
||||
UpdatedBy: "user-123",
|
||||
OrgID: orgID.String(),
|
||||
UserEmail: "claims@example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ConstructFunnelResponse(tt.funnel, tt.claims)
|
||||
|
||||
// Compare top-level fields
|
||||
assert.Equal(t, tt.expected.FunnelName, result.FunnelName)
|
||||
assert.Equal(t, tt.expected.FunnelID, result.FunnelID)
|
||||
assert.Equal(t, tt.expected.CreatedAt, result.CreatedAt)
|
||||
assert.Equal(t, tt.expected.CreatedBy, result.CreatedBy)
|
||||
assert.Equal(t, tt.expected.UpdatedAt, result.UpdatedAt)
|
||||
assert.Equal(t, tt.expected.UpdatedBy, result.UpdatedBy)
|
||||
assert.Equal(t, tt.expected.OrgID, result.OrgID)
|
||||
assert.Equal(t, tt.expected.UserEmail, result.UserEmail)
|
||||
|
||||
// Compare steps
|
||||
assert.Len(t, result.Steps, len(tt.expected.Steps))
|
||||
for i, step := range result.Steps {
|
||||
expectedStep := tt.expected.Steps[i]
|
||||
assert.Equal(t, expectedStep.Name, step.Name)
|
||||
assert.Equal(t, expectedStep.ServiceName, step.ServiceName)
|
||||
assert.Equal(t, expectedStep.SpanName, step.SpanName)
|
||||
assert.Equal(t, expectedStep.Order, step.Order)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFunnelSteps(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
steps []tracefunnel.FunnelStep
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid steps with missing IDs",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 0, // Will be normalized to 1
|
||||
},
|
||||
{
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 0, // Will be normalized to 2
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid steps - missing service name",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
Name: "Step 1",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid steps - negative order",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: -1,
|
||||
},
|
||||
{
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ProcessFunnelSteps(tt.steps)
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Len(t, result, len(tt.steps))
|
||||
|
||||
// Verify IDs are generated
|
||||
for _, step := range result {
|
||||
assert.False(t, step.ID.IsZero())
|
||||
}
|
||||
|
||||
// Verify orders are normalized
|
||||
for i, step := range result {
|
||||
assert.Equal(t, int64(i+1), step.Order)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,851 @@
|
||||
{
|
||||
"description": "View key AWS ECS metrics with an out of the box dashboard.\n",
|
||||
"image":"data:image/svg+xml,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22UTF-8%22%3F%3E%3Csvg%20width%3D%2280px%22%20height%3D%2280px%22%20viewBox%3D%220%200%2080%2080%22%20version%3D%221.1%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%3E%3C!--%20Generator%3A%20Sketch%2064%20(93537)%20-%20https%3A%2F%2Fsketch.com%20--%3E%3Ctitle%3EIcon-Architecture%2F64%2FArch_Amazon-Elastic-Container-Service_64%3C%2Ftitle%3E%3Cdesc%3ECreated%20with%20Sketch.%3C%2Fdesc%3E%3Cdefs%3E%3ClinearGradient%20x1%3D%220%25%22%20y1%3D%22100%25%22%20x2%3D%22100%25%22%20y2%3D%220%25%22%20id%3D%22linearGradient-1%22%3E%3Cstop%20stop-color%3D%22%23C8511B%22%20offset%3D%220%25%22%3E%3C%2Fstop%3E%3Cstop%20stop-color%3D%22%23FF9900%22%20offset%3D%22100%25%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cg%20id%3D%22Icon-Architecture%2F64%2FArch_Amazon-Elastic-Container-Service_64%22%20stroke%3D%22none%22%20stroke-width%3D%221%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Cg%20id%3D%22Icon-Architecture-BG%2F64%2FContainers%22%20fill%3D%22url(%23linearGradient-1)%22%3E%3Crect%20id%3D%22Rectangle%22%20x%3D%220%22%20y%3D%220%22%20width%3D%2280%22%20height%3D%2280%22%3E%3C%2Frect%3E%3C%2Fg%3E%3Cpath%20d%3D%22M64%2C48.2340095%20L56%2C43.4330117%20L56%2C32.0000169%20C56%2C31.6440171%2055.812%2C31.3150172%2055.504%2C31.1360173%20L44%2C24.4260204%20L44%2C14.7520248%20L64%2C26.5710194%20L64%2C48.2340095%20Z%20M65.509%2C25.13902%20L43.509%2C12.139026%20C43.199%2C11.9560261%2042.818%2C11.9540261%2042.504%2C12.131026%20C42.193%2C12.3090259%2042%2C12.6410257%2042%2C13.0000256%20L42%2C25.0000201%20C42%2C25.3550199%2042.189%2C25.6840198%2042.496%2C25.8640197%20L54%2C32.5740166%20L54%2C44.0000114%20C54%2C44.3510113%2054.185%2C44.6770111%2054.486%2C44.857011%20L64.486%2C50.8570083%20C64.644%2C50.9520082%2064.822%2C51%2065%2C51%20C65.17%2C51%2065.34%2C50.9570082%2065.493%2C50.8700083%20C65.807%2C50.6930084%2066%2C50.3600085%2066%2C50%20L66%2C26.0000196%20C66%2C25.6460198%2065.814%2C25.31902%2065.509%2C25.13902%20L65.509%2C25.13902%20Z%20M40.445%2C66.863001%20L17%2C54.3990067%20L17%2C26.5710194%20L37%2C14.7520248%20L37%2C24.4510204%20L26.463%2C31.1560173%20C26.175%2C31.3400172%2026%2C31.6580171%2026%2C32.0000169%20L26%2C49.0000091%20C26%2C49.373009%2026.208%2C49.7150088%2026.538%2C49.8870087%20L39.991%2C56.8870055%20C40.28%2C57.0370055%2040.624%2C57.0380055%2040.912%2C56.8880055%20L53.964%2C50.1440086%20L61.996%2C54.9640064%20L40.445%2C66.863001%20Z%20M64.515%2C54.1420068%20L54.515%2C48.1420095%20C54.217%2C47.9640096%2053.849%2C47.9520096%2053.541%2C48.1120095%20L40.455%2C54.8730065%20L28%2C48.3930094%20L28%2C32.5490167%20L38.537%2C25.8440197%20C38.825%2C25.6600198%2039%2C25.3420199%2039%2C25.0000201%20L39%2C13.0000256%20C39%2C12.6410257%2038.808%2C12.3090259%2038.496%2C12.131026%20C38.184%2C11.9540261%2037.802%2C11.9560261%2037.491%2C12.139026%20L15.491%2C25.13902%20C15.187%2C25.31902%2015%2C25.6460198%2015%2C26.0000196%20L15%2C55%20C15%2C55.3690062%2015.204%2C55.7090061%2015.53%2C55.883006%20L39.984%2C68.8830001%20C40.131%2C68.961%2040.292%2C69%2040.453%2C69%20C40.62%2C69%2040.786%2C68.958%2040.937%2C68.8750001%20L64.484%2C55.875006%20C64.797%2C55.7020061%2064.993%2C55.3750062%2065.0001416%2C55.0180064%20C65.006%2C54.6600066%2064.821%2C54.3260067%2064.515%2C54.1420068%20L64.515%2C54.1420068%20Z%22%20id%3D%22Amazon-Elastic-Container-Service_Icon_64_Squid%22%20fill%3D%22%23FFFFFF%22%3E%3C%2Fpath%3E%3C%2Fg%3E%3C%2Fsvg%3E",
|
||||
"layout": [
|
||||
{
|
||||
"h": 6,
|
||||
"i": "f78becf8-0328-48b4-84b6-ff4dac325940",
|
||||
"moved": false,
|
||||
"static": false,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"h": 6,
|
||||
"i": "2b4eac06-b426-4f78-b874-2e1734c4104b",
|
||||
"moved": false,
|
||||
"static": false,
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"h": 6,
|
||||
"i": "5bea2bc0-13a2-4937-bccb-60ffe8a43ad5",
|
||||
"moved": false,
|
||||
"static": false,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 6
|
||||
},
|
||||
{
|
||||
"h": 6,
|
||||
"i": "6fac67b0-50ec-4b43-ac4b-320a303d0369",
|
||||
"moved": false,
|
||||
"static": false,
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 6
|
||||
}
|
||||
],
|
||||
"panelMap": {},
|
||||
"tags": [],
|
||||
"title": "AWS ECS Overview",
|
||||
"uploadedGrafana": false,
|
||||
"variables": {
|
||||
"51f4fa2b-89c7-47c2-9795-f32cffaab985": {
|
||||
"allSelected": false,
|
||||
"customValue": "",
|
||||
"description": "AWS Account ID",
|
||||
"id": "51f4fa2b-89c7-47c2-9795-f32cffaab985",
|
||||
"key": "51f4fa2b-89c7-47c2-9795-f32cffaab985",
|
||||
"modificationUUID": "7b814d17-8fff-4ed6-a4ea-90e3b1a97584",
|
||||
"multiSelect": false,
|
||||
"name": "Account",
|
||||
"order": 0,
|
||||
"queryValue": "SELECT DISTINCT JSONExtractString(labels, 'cloud_account_id') AS cloud_account_id\nFROM signoz_metrics.distributed_time_series_v4_1day\nWHERE metric_name = 'aws_ECS_MemoryUtilization_max' GROUP BY cloud_account_id",
|
||||
"showALLOption": false,
|
||||
"sort": "DISABLED",
|
||||
"textboxValue": "",
|
||||
"type": "QUERY"
|
||||
},
|
||||
"9faf0f4b-b245-4b3c-83a3-60cfa76dfeb0": {
|
||||
"allSelected": false,
|
||||
"customValue": "",
|
||||
"description": "Account Region",
|
||||
"id": "9faf0f4b-b245-4b3c-83a3-60cfa76dfeb0",
|
||||
"key": "9faf0f4b-b245-4b3c-83a3-60cfa76dfeb0",
|
||||
"modificationUUID": "3b5f499b-22a3-4c8a-847c-8d3811c9e6b2",
|
||||
"multiSelect": false,
|
||||
"name": "Region",
|
||||
"order": 1,
|
||||
"queryValue": "SELECT DISTINCT JSONExtractString(labels, 'cloud_region') AS region\nFROM signoz_metrics.distributed_time_series_v4_1day\nWHERE metric_name = 'aws_ECS_MemoryUtilization_max' AND JSONExtractString(labels, 'cloud_account_id') IN {{.Account}} GROUP BY region",
|
||||
"showALLOption": false,
|
||||
"sort": "ASC",
|
||||
"textboxValue": "",
|
||||
"type": "QUERY"
|
||||
},
|
||||
"bfbdbcbe-a168-4d81-b108-36339e249116": {
|
||||
"allSelected": true,
|
||||
"customValue": "",
|
||||
"description": "ECS Cluster Name",
|
||||
"id": "bfbdbcbe-a168-4d81-b108-36339e249116",
|
||||
"key": "bfbdbcbe-a168-4d81-b108-36339e249116",
|
||||
"modificationUUID": "9fb0d63c-ac6c-497d-82b3-17d95944e245",
|
||||
"multiSelect": true,
|
||||
"name": "Cluster",
|
||||
"order": 2,
|
||||
"queryValue": "SELECT DISTINCT JSONExtractString(labels, 'ClusterName') AS cluster\nFROM signoz_metrics.distributed_time_series_v4_1day\nWHERE metric_name = 'aws_ECS_MemoryUtilization_max' AND JSONExtractString(labels, 'cloud_account_id') IN {{.Account}} AND JSONExtractString(labels, 'cloud_region') IN {{.Region}}\nGROUP BY cluster",
|
||||
"showALLOption": true,
|
||||
"sort": "ASC",
|
||||
"textboxValue": "",
|
||||
"type": "QUERY"
|
||||
}
|
||||
},
|
||||
"version": "v4",
|
||||
"widgets": [
|
||||
{
|
||||
"bucketCount": 30,
|
||||
"bucketWidth": 0,
|
||||
"columnUnits": {},
|
||||
"description": "",
|
||||
"fillSpans": false,
|
||||
"id": "f78becf8-0328-48b4-84b6-ff4dac325940",
|
||||
"isLogScale": false,
|
||||
"isStacked": false,
|
||||
"mergeAllActiveQueries": false,
|
||||
"nullZeroValues": "zero",
|
||||
"opacity": "1",
|
||||
"panelTypes": "graph",
|
||||
"query": {
|
||||
"builder": {
|
||||
"queryData": [
|
||||
{
|
||||
"aggregateAttribute": {
|
||||
"dataType": "float64",
|
||||
"id": "aws_ECS_MemoryUtilization_max--float64--Gauge--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "aws_ECS_MemoryUtilization_max",
|
||||
"type": "Gauge"
|
||||
},
|
||||
"aggregateOperator": "max",
|
||||
"dataSource": "metrics",
|
||||
"disabled": false,
|
||||
"expression": "A",
|
||||
"filters": {
|
||||
"items": [
|
||||
{
|
||||
"id": "26ac617d",
|
||||
"key": {
|
||||
"dataType": "string",
|
||||
"id": "cloud_region--string--tag--false",
|
||||
"isColumn": false,
|
||||
"isJSON": false,
|
||||
"key": "cloud_region",
|
||||
"type": "tag"
|
||||
},
|
||||
"op": "=",
|
||||
"value": "$Region"
|
||||
},
|
||||
{
|
||||
"id": "57172ed9",
|
||||
"key": {
|
||||
"dataType": "string",
|
||||
"id": "cloud_account_id--string--tag--false",
|
||||
"isColumn": false,
|
||||
"isJSON": false,
|
||||
"key": "cloud_account_id",
|
||||
"type": "tag"
|
||||
},
|
||||
"op": "=",
|
||||
"value": "$Account"
|
||||
},
|
||||
{
|
||||
"id": "49b9f85e",
|
||||
"key": {
|
||||
"dataType": "string",
|
||||
"id": "ClusterName--string--tag--false",
|
||||
"isColumn": false,
|
||||
"isJSON": false,
|
||||
"key": "ClusterName",
|
||||
"type": "tag"
|
||||
},
|
||||
"op": "in",
|
||||
"value": [
|
||||
"$Cluster"
|
||||
]
|
||||
}
|
||||
],
|
||||
"op": "AND"
|
||||
},
|
||||
"functions": [],
|
||||
"groupBy": [
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "ServiceName--string--tag--false",
|
||||
"isColumn": false,
|
||||
"isJSON": false,
|
||||
"key": "ServiceName",
|
||||
"type": "tag"
|
||||
},
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "ClusterName--string--tag--false",
|
||||
"isColumn": false,
|
||||
"isJSON": false,
|
||||
"key": "ClusterName",
|
||||
"type": "tag"
|
||||
}
|
||||
],
|
||||
"having": [],
|
||||
"legend": "{{ServiceName}} ({{ClusterName}})",
|
||||
"limit": null,
|
||||
"orderBy": [],
|
||||
"queryName": "A",
|
||||
"reduceTo": "avg",
|
||||
"spaceAggregation": "max",
|
||||
"stepInterval": 60,
|
||||
"timeAggregation": "max"
|
||||
}
|
||||
],
|
||||
"queryFormulas": []
|
||||
},
|
||||
"clickhouse_sql": [
|
||||
{
|
||||
"disabled": false,
|
||||
"legend": "",
|
||||
"name": "A",
|
||||
"query": ""
|
||||
}
|
||||
],
|
||||
"id": "56068fdd-d523-4117-92fa-87c6518ad07c",
|
||||
"promql": [
|
||||
{
|
||||
"disabled": false,
|
||||
"legend": "",
|
||||
"name": "A",
|
||||
"query": ""
|
||||
}
|
||||
],
|
||||
"queryType": "builder"
|
||||
},
|
||||
"selectedLogFields": [
|
||||
{
|
||||
"dataType": "string",
|
||||
"name": "body",
|
||||
"type": ""
|
||||
},
|
||||
{
|
||||
"dataType": "string",
|
||||
"name": "timestamp",
|
||||
"type": ""
|
||||
}
|
||||
],
|
||||
"selectedTracesFields": [
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "serviceName--string--tag--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "serviceName",
|
||||
"type": "tag"
|
||||
},
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "name--string--tag--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "name",
|
||||
"type": "tag"
|
||||
},
|
||||
{
|
||||
"dataType": "float64",
|
||||
"id": "durationNano--float64--tag--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "durationNano",
|
||||
"type": "tag"
|
||||
},
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "httpMethod--string--tag--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "httpMethod",
|
||||
"type": "tag"
|
||||
},
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "responseStatusCode--string--tag--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "responseStatusCode",
|
||||
"type": "tag"
|
||||
}
|
||||
],
|
||||
"softMax": 0,
|
||||
"softMin": 0,
|
||||
"stackedBarChart": false,
|
||||
"thresholds": [],
|
||||
"timePreferance": "GLOBAL_TIME",
|
||||
"title": "Maximum Memory Utilization",
|
||||
"yAxisUnit": "none"
|
||||
},
|
||||
{
|
||||
"bucketCount": 30,
|
||||
"bucketWidth": 0,
|
||||
"columnUnits": {},
|
||||
"description": "",
|
||||
"fillSpans": false,
|
||||
"id": "2b4eac06-b426-4f78-b874-2e1734c4104b",
|
||||
"isLogScale": false,
|
||||
"isStacked": false,
|
||||
"mergeAllActiveQueries": false,
|
||||
"nullZeroValues": "zero",
|
||||
"opacity": "1",
|
||||
"panelTypes": "graph",
|
||||
"query": {
|
||||
"builder": {
|
||||
"queryData": [
|
||||
{
|
||||
"aggregateAttribute": {
|
||||
"dataType": "float64",
|
||||
"id": "aws_ECS_MemoryUtilization_min--float64--Gauge--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "aws_ECS_MemoryUtilization_min",
|
||||
"type": "Gauge"
|
||||
},
|
||||
"aggregateOperator": "min",
|
||||
"dataSource": "metrics",
|
||||
"disabled": false,
|
||||
"expression": "A",
|
||||
"filters": {
|
||||
"items": [
|
||||
{
|
||||
"id": "cd4b8848",
|
||||
"key": {
|
||||
"dataType": "string",
|
||||
"id": "cloud_region--string--tag--false",
|
||||
"isColumn": false,
|
||||
"isJSON": false,
|
||||
"key": "cloud_region",
|
||||
"type": "tag"
|
||||
},
|
||||
"op": "=",
|
||||
"value": "$Region"
|
||||
},
|
||||
{
|
||||
"id": "aa5115c6",
|
||||
"key": {
|
||||
"dataType": "string",
|
||||
"id": "cloud_account_id--string--tag--false",
|
||||
"isColumn": false,
|
||||
"isJSON": false,
|
||||
"key": "cloud_account_id",
|
||||
"type": "tag"
|
||||
},
|
||||
"op": "=",
|
||||
"value": "$Account"
|
||||
},
|
||||
{
|
||||
"id": "f60677b6",
|
||||
"key": {
|
||||
"dataType": "string",
|
||||
"id": "ClusterName--string--tag--false",
|
||||
"isColumn": false,
|
||||
"isJSON": false,
|
||||
"key": "ClusterName",
|
||||
"type": "tag"
|
||||
},
|
||||
"op": "in",
|
||||
"value": [
|
||||
"$Cluster"
|
||||
]
|
||||
}
|
||||
],
|
||||
"op": "AND"
|
||||
},
|
||||
"functions": [],
|
||||
"groupBy": [
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "ServiceName--string--tag--false",
|
||||
"isColumn": false,
|
||||
"isJSON": false,
|
||||
"key": "ServiceName",
|
||||
"type": "tag"
|
||||
},
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "ClusterName--string--tag--false",
|
||||
"isColumn": false,
|
||||
"isJSON": false,
|
||||
"key": "ClusterName",
|
||||
"type": "tag"
|
||||
}
|
||||
],
|
||||
"having": [],
|
||||
"legend": "{{ServiceName}} ({{ClusterName}})",
|
||||
"limit": null,
|
||||
"orderBy": [],
|
||||
"queryName": "A",
|
||||
"reduceTo": "avg",
|
||||
"spaceAggregation": "min",
|
||||
"stepInterval": 60,
|
||||
"timeAggregation": "min"
|
||||
}
|
||||
],
|
||||
"queryFormulas": []
|
||||
},
|
||||
"clickhouse_sql": [
|
||||
{
|
||||
"disabled": false,
|
||||
"legend": "",
|
||||
"name": "A",
|
||||
"query": ""
|
||||
}
|
||||
],
|
||||
"id": "fb19342e-cbde-40d8-b12f-ad108698356b",
|
||||
"promql": [
|
||||
{
|
||||
"disabled": false,
|
||||
"legend": "",
|
||||
"name": "A",
|
||||
"query": ""
|
||||
}
|
||||
],
|
||||
"queryType": "builder"
|
||||
},
|
||||
"selectedLogFields": [
|
||||
{
|
||||
"dataType": "string",
|
||||
"name": "body",
|
||||
"type": ""
|
||||
},
|
||||
{
|
||||
"dataType": "string",
|
||||
"name": "timestamp",
|
||||
"type": ""
|
||||
}
|
||||
],
|
||||
"selectedTracesFields": [
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "serviceName--string--tag--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "serviceName",
|
||||
"type": "tag"
|
||||
},
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "name--string--tag--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "name",
|
||||
"type": "tag"
|
||||
},
|
||||
{
|
||||
"dataType": "float64",
|
||||
"id": "durationNano--float64--tag--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "durationNano",
|
||||
"type": "tag"
|
||||
},
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "httpMethod--string--tag--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "httpMethod",
|
||||
"type": "tag"
|
||||
},
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "responseStatusCode--string--tag--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "responseStatusCode",
|
||||
"type": "tag"
|
||||
}
|
||||
],
|
||||
"softMax": 0,
|
||||
"softMin": 0,
|
||||
"stackedBarChart": false,
|
||||
"thresholds": [],
|
||||
"timePreferance": "GLOBAL_TIME",
|
||||
"title": "Minimum Memory Utilization",
|
||||
"yAxisUnit": "none"
|
||||
},
|
||||
{
|
||||
"bucketCount": 30,
|
||||
"bucketWidth": 0,
|
||||
"columnUnits": {},
|
||||
"description": "",
|
||||
"fillSpans": false,
|
||||
"id": "5bea2bc0-13a2-4937-bccb-60ffe8a43ad5",
|
||||
"isLogScale": false,
|
||||
"isStacked": false,
|
||||
"mergeAllActiveQueries": false,
|
||||
"nullZeroValues": "zero",
|
||||
"opacity": "1",
|
||||
"panelTypes": "graph",
|
||||
"query": {
|
||||
"builder": {
|
||||
"queryData": [
|
||||
{
|
||||
"aggregateAttribute": {
|
||||
"dataType": "float64",
|
||||
"id": "aws_ECS_CPUUtilization_max--float64--Gauge--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "aws_ECS_CPUUtilization_max",
|
||||
"type": "Gauge"
|
||||
},
|
||||
"aggregateOperator": "max",
|
||||
"dataSource": "metrics",
|
||||
"disabled": false,
|
||||
"expression": "A",
|
||||
"filters": {
|
||||
"items": [
|
||||
{
|
||||
"id": "2c13c8ee",
|
||||
"key": {
|
||||
"dataType": "string",
|
||||
"id": "cloud_region--string--tag--false",
|
||||
"isColumn": false,
|
||||
"isJSON": false,
|
||||
"key": "cloud_region",
|
||||
"type": "tag"
|
||||
},
|
||||
"op": "=",
|
||||
"value": "$Region"
|
||||
},
|
||||
{
|
||||
"id": "f489f6a8",
|
||||
"key": {
|
||||
"dataType": "string",
|
||||
"id": "cloud_account_id--string--tag--false",
|
||||
"isColumn": false,
|
||||
"isJSON": false,
|
||||
"key": "cloud_account_id",
|
||||
"type": "tag"
|
||||
},
|
||||
"op": "=",
|
||||
"value": "$Account"
|
||||
},
|
||||
{
|
||||
"id": "94012320",
|
||||
"key": {
|
||||
"dataType": "string",
|
||||
"id": "ClusterName--string--tag--false",
|
||||
"isColumn": false,
|
||||
"isJSON": false,
|
||||
"key": "ClusterName",
|
||||
"type": "tag"
|
||||
},
|
||||
"op": "in",
|
||||
"value": [
|
||||
"$Cluster"
|
||||
]
|
||||
}
|
||||
],
|
||||
"op": "AND"
|
||||
},
|
||||
"functions": [],
|
||||
"groupBy": [
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "ServiceName--string--tag--false",
|
||||
"isColumn": false,
|
||||
"isJSON": false,
|
||||
"key": "ServiceName",
|
||||
"type": "tag"
|
||||
},
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "ClusterName--string--tag--false",
|
||||
"isColumn": false,
|
||||
"isJSON": false,
|
||||
"key": "ClusterName",
|
||||
"type": "tag"
|
||||
}
|
||||
],
|
||||
"having": [],
|
||||
"legend": "{{ServiceName}} ({{ClusterName}})",
|
||||
"limit": null,
|
||||
"orderBy": [],
|
||||
"queryName": "A",
|
||||
"reduceTo": "avg",
|
||||
"spaceAggregation": "max",
|
||||
"stepInterval": 60,
|
||||
"timeAggregation": "max"
|
||||
}
|
||||
],
|
||||
"queryFormulas": []
|
||||
},
|
||||
"clickhouse_sql": [
|
||||
{
|
||||
"disabled": false,
|
||||
"legend": "",
|
||||
"name": "A",
|
||||
"query": ""
|
||||
}
|
||||
],
|
||||
"id": "273e0a76-c780-4b9a-9b03-2649d4227173",
|
||||
"promql": [
|
||||
{
|
||||
"disabled": false,
|
||||
"legend": "",
|
||||
"name": "A",
|
||||
"query": ""
|
||||
}
|
||||
],
|
||||
"queryType": "builder"
|
||||
},
|
||||
"selectedLogFields": [
|
||||
{
|
||||
"dataType": "string",
|
||||
"name": "body",
|
||||
"type": ""
|
||||
},
|
||||
{
|
||||
"dataType": "string",
|
||||
"name": "timestamp",
|
||||
"type": ""
|
||||
}
|
||||
],
|
||||
"selectedTracesFields": [
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "serviceName--string--tag--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "serviceName",
|
||||
"type": "tag"
|
||||
},
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "name--string--tag--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "name",
|
||||
"type": "tag"
|
||||
},
|
||||
{
|
||||
"dataType": "float64",
|
||||
"id": "durationNano--float64--tag--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "durationNano",
|
||||
"type": "tag"
|
||||
},
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "httpMethod--string--tag--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "httpMethod",
|
||||
"type": "tag"
|
||||
},
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "responseStatusCode--string--tag--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "responseStatusCode",
|
||||
"type": "tag"
|
||||
}
|
||||
],
|
||||
"softMax": 0,
|
||||
"softMin": 0,
|
||||
"stackedBarChart": false,
|
||||
"thresholds": [],
|
||||
"timePreferance": "GLOBAL_TIME",
|
||||
"title": "Maximum CPU Utilization",
|
||||
"yAxisUnit": "none"
|
||||
},
|
||||
{
|
||||
"bucketCount": 30,
|
||||
"bucketWidth": 0,
|
||||
"columnUnits": {},
|
||||
"description": "",
|
||||
"fillSpans": false,
|
||||
"id": "6fac67b0-50ec-4b43-ac4b-320a303d0369",
|
||||
"isLogScale": false,
|
||||
"isStacked": false,
|
||||
"mergeAllActiveQueries": false,
|
||||
"nullZeroValues": "zero",
|
||||
"opacity": "1",
|
||||
"panelTypes": "graph",
|
||||
"query": {
|
||||
"builder": {
|
||||
"queryData": [
|
||||
{
|
||||
"aggregateAttribute": {
|
||||
"dataType": "float64",
|
||||
"id": "aws_ECS_CPUUtilization_min--float64--Gauge--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "aws_ECS_CPUUtilization_min",
|
||||
"type": "Gauge"
|
||||
},
|
||||
"aggregateOperator": "min",
|
||||
"dataSource": "metrics",
|
||||
"disabled": false,
|
||||
"expression": "A",
|
||||
"filters": {
|
||||
"items": [
|
||||
{
|
||||
"id": "758ba906",
|
||||
"key": {
|
||||
"dataType": "string",
|
||||
"id": "cloud_region--string--tag--false",
|
||||
"isColumn": false,
|
||||
"isJSON": false,
|
||||
"key": "cloud_region",
|
||||
"type": "tag"
|
||||
},
|
||||
"op": "=",
|
||||
"value": "$Region"
|
||||
},
|
||||
{
|
||||
"id": "4ffe6bf7",
|
||||
"key": {
|
||||
"dataType": "string",
|
||||
"id": "cloud_account_id--string--tag--false",
|
||||
"isColumn": false,
|
||||
"isJSON": false,
|
||||
"key": "cloud_account_id",
|
||||
"type": "tag"
|
||||
},
|
||||
"op": "=",
|
||||
"value": "$Account"
|
||||
},
|
||||
{
|
||||
"id": "53d98059",
|
||||
"key": {
|
||||
"dataType": "string",
|
||||
"id": "ClusterName--string--tag--false",
|
||||
"isColumn": false,
|
||||
"isJSON": false,
|
||||
"key": "ClusterName",
|
||||
"type": "tag"
|
||||
},
|
||||
"op": "in",
|
||||
"value": [
|
||||
"$Cluster"
|
||||
]
|
||||
}
|
||||
],
|
||||
"op": "AND"
|
||||
},
|
||||
"functions": [],
|
||||
"groupBy": [
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "ServiceName--string--tag--false",
|
||||
"isColumn": false,
|
||||
"isJSON": false,
|
||||
"key": "ServiceName",
|
||||
"type": "tag"
|
||||
},
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "ClusterName--string--tag--false",
|
||||
"isColumn": false,
|
||||
"isJSON": false,
|
||||
"key": "ClusterName",
|
||||
"type": "tag"
|
||||
}
|
||||
],
|
||||
"having": [],
|
||||
"legend": "{{ServiceName}} ({{ClusterName}})",
|
||||
"limit": null,
|
||||
"orderBy": [],
|
||||
"queryName": "A",
|
||||
"reduceTo": "avg",
|
||||
"spaceAggregation": "min",
|
||||
"stepInterval": 60,
|
||||
"timeAggregation": "min"
|
||||
}
|
||||
],
|
||||
"queryFormulas": []
|
||||
},
|
||||
"clickhouse_sql": [
|
||||
{
|
||||
"disabled": false,
|
||||
"legend": "",
|
||||
"name": "A",
|
||||
"query": ""
|
||||
}
|
||||
],
|
||||
"id": "c89482b3-5a98-4e2c-be0d-ef036d7dac05",
|
||||
"promql": [
|
||||
{
|
||||
"disabled": false,
|
||||
"legend": "",
|
||||
"name": "A",
|
||||
"query": ""
|
||||
}
|
||||
],
|
||||
"queryType": "builder"
|
||||
},
|
||||
"selectedLogFields": [
|
||||
{
|
||||
"dataType": "string",
|
||||
"name": "body",
|
||||
"type": ""
|
||||
},
|
||||
{
|
||||
"dataType": "string",
|
||||
"name": "timestamp",
|
||||
"type": ""
|
||||
}
|
||||
],
|
||||
"selectedTracesFields": [
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "serviceName--string--tag--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "serviceName",
|
||||
"type": "tag"
|
||||
},
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "name--string--tag--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "name",
|
||||
"type": "tag"
|
||||
},
|
||||
{
|
||||
"dataType": "float64",
|
||||
"id": "durationNano--float64--tag--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "durationNano",
|
||||
"type": "tag"
|
||||
},
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "httpMethod--string--tag--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "httpMethod",
|
||||
"type": "tag"
|
||||
},
|
||||
{
|
||||
"dataType": "string",
|
||||
"id": "responseStatusCode--string--tag--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "responseStatusCode",
|
||||
"type": "tag"
|
||||
}
|
||||
],
|
||||
"softMax": 0,
|
||||
"softMin": 0,
|
||||
"stackedBarChart": false,
|
||||
"thresholds": [],
|
||||
"timePreferance": "GLOBAL_TIME",
|
||||
"title": "Minimum CPU Utilization",
|
||||
"yAxisUnit": "none"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 350 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="80px" height="80px" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 64 (93537) - https://sketch.com -->
|
||||
<title>Icon-Architecture/64/Arch_Amazon-Elastic-Container-Service_64</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#C8511B" offset="0%"></stop>
|
||||
<stop stop-color="#FF9900" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Icon-Architecture/64/Arch_Amazon-Elastic-Container-Service_64" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Icon-Architecture-BG/64/Containers" fill="url(#linearGradient-1)">
|
||||
<rect id="Rectangle" x="0" y="0" width="80" height="80"></rect>
|
||||
</g>
|
||||
<path d="M64,48.2340095 L56,43.4330117 L56,32.0000169 C56,31.6440171 55.812,31.3150172 55.504,31.1360173 L44,24.4260204 L44,14.7520248 L64,26.5710194 L64,48.2340095 Z M65.509,25.13902 L43.509,12.139026 C43.199,11.9560261 42.818,11.9540261 42.504,12.131026 C42.193,12.3090259 42,12.6410257 42,13.0000256 L42,25.0000201 C42,25.3550199 42.189,25.6840198 42.496,25.8640197 L54,32.5740166 L54,44.0000114 C54,44.3510113 54.185,44.6770111 54.486,44.857011 L64.486,50.8570083 C64.644,50.9520082 64.822,51 65,51 C65.17,51 65.34,50.9570082 65.493,50.8700083 C65.807,50.6930084 66,50.3600085 66,50 L66,26.0000196 C66,25.6460198 65.814,25.31902 65.509,25.13902 L65.509,25.13902 Z M40.445,66.863001 L17,54.3990067 L17,26.5710194 L37,14.7520248 L37,24.4510204 L26.463,31.1560173 C26.175,31.3400172 26,31.6580171 26,32.0000169 L26,49.0000091 C26,49.373009 26.208,49.7150088 26.538,49.8870087 L39.991,56.8870055 C40.28,57.0370055 40.624,57.0380055 40.912,56.8880055 L53.964,50.1440086 L61.996,54.9640064 L40.445,66.863001 Z M64.515,54.1420068 L54.515,48.1420095 C54.217,47.9640096 53.849,47.9520096 53.541,48.1120095 L40.455,54.8730065 L28,48.3930094 L28,32.5490167 L38.537,25.8440197 C38.825,25.6600198 39,25.3420199 39,25.0000201 L39,13.0000256 C39,12.6410257 38.808,12.3090259 38.496,12.131026 C38.184,11.9540261 37.802,11.9560261 37.491,12.139026 L15.491,25.13902 C15.187,25.31902 15,25.6460198 15,26.0000196 L15,55 C15,55.3690062 15.204,55.7090061 15.53,55.883006 L39.984,68.8830001 C40.131,68.961 40.292,69 40.453,69 C40.62,69 40.786,68.958 40.937,68.8750001 L64.484,55.875006 C64.797,55.7020061 64.993,55.3750062 65.0001416,55.0180064 C65.006,54.6600066 64.821,54.3260067 64.515,54.1420068 L64.515,54.1420068 Z" id="Amazon-Elastic-Container-Service_Icon_64_Squid" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"id": "ecs",
|
||||
"title": "ECS",
|
||||
"icon": "file://icon.svg",
|
||||
"overview": "file://overview.md",
|
||||
"supported_signals": {
|
||||
"metrics": true,
|
||||
"logs": true
|
||||
},
|
||||
"data_collected": {
|
||||
"metrics": [
|
||||
{
|
||||
"name": "aws_ECS_CPUUtilization_count",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_ECS_CPUUtilization_max",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_ECS_CPUUtilization_min",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_ECS_CPUUtilization_sum",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_ECS_MemoryUtilization_count",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_ECS_MemoryUtilization_max",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_ECS_MemoryUtilization_min",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_ECS_MemoryUtilization_sum",
|
||||
"unit": "Percent",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"logs": [
|
||||
{
|
||||
"name": "Account ID",
|
||||
"path": "resources.cloud.account.id",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "Log Group Name",
|
||||
"path": "resources.aws.cloudwatch.log_group_name",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "Log Stream Name",
|
||||
"path": "resources.aws.cloudwatch.log_stream_name",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"telemetry_collection_strategy": {
|
||||
"aws_metrics": {
|
||||
"cloudwatch_metric_stream_filters": [
|
||||
{
|
||||
"Namespace": "AWS/ECS"
|
||||
}
|
||||
]
|
||||
},
|
||||
"aws_logs": {
|
||||
"cloudwatch_logs_subscriptions": [
|
||||
{
|
||||
"log_group_name_prefix": "/ecs",
|
||||
"filter_pattern": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "overview",
|
||||
"title": "AWS ECS Overview",
|
||||
"description": "Overview of ECS",
|
||||
"image": "file://assets/dashboards/overview.png",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
### Monitor Elastic Container Service with SigNoz
|
||||
|
||||
Collect ECS Logs and key Metrics and view them with an out of the box dashboard.
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 309 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.3 KiB |
@@ -0,0 +1,250 @@
|
||||
{
|
||||
"id": "sqs",
|
||||
"title": "SQS",
|
||||
"icon": "file://icon.svg",
|
||||
"overview": "file://overview.md",
|
||||
"supported_signals": {
|
||||
"metrics": true,
|
||||
"logs": false
|
||||
},
|
||||
"data_collected": {
|
||||
"metrics": [
|
||||
{
|
||||
"name": "aws_SQS_ApproximateAgeOfOldestMessage_count",
|
||||
"unit": "Seconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_ApproximateAgeOfOldestMessage_max",
|
||||
"unit": "Seconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_ApproximateAgeOfOldestMessage_min",
|
||||
"unit": "Seconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_ApproximateAgeOfOldestMessage_sum",
|
||||
"unit": "Seconds",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_ApproximateNumberOfMessagesDelayed_count",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_ApproximateNumberOfMessagesDelayed_max",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_ApproximateNumberOfMessagesDelayed_min",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_ApproximateNumberOfMessagesDelayed_sum",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_ApproximateNumberOfMessagesNotVisible_count",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_ApproximateNumberOfMessagesNotVisible_max",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_ApproximateNumberOfMessagesNotVisible_min",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_ApproximateNumberOfMessagesNotVisible_sum",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_ApproximateNumberOfMessagesVisible_count",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_ApproximateNumberOfMessagesVisible_max",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_ApproximateNumberOfMessagesVisible_min",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_ApproximateNumberOfMessagesVisible_sum",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_NumberOfEmptyReceives_count",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_NumberOfEmptyReceives_max",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_NumberOfEmptyReceives_min",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_NumberOfEmptyReceives_sum",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_NumberOfMessagesDeleted_count",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_NumberOfMessagesDeleted_max",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_NumberOfMessagesDeleted_min",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_NumberOfMessagesDeleted_sum",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_NumberOfMessagesReceived_count",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_NumberOfMessagesReceived_max",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_NumberOfMessagesReceived_min",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_NumberOfMessagesReceived_sum",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_NumberOfMessagesSent_count",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_NumberOfMessagesSent_max",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_NumberOfMessagesSent_min",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_NumberOfMessagesSent_sum",
|
||||
"unit": "Count",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_SentMessageSize_sum",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_SentMessageSize_max",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_SentMessageSize_min",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "aws_SQS_SentMessageSize_count",
|
||||
"unit": "Bytes",
|
||||
"type": "Gauge",
|
||||
"description": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"telemetry_collection_strategy": {
|
||||
"aws_metrics": {
|
||||
"cloudwatch_metric_stream_filters": [
|
||||
{
|
||||
"Namespace": "AWS/SQS"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"assets": {
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "overview",
|
||||
"title": "AWS SQS Overview",
|
||||
"description": "Overview of SQS",
|
||||
"image": "file://assets/dashboards/overview.png",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
### Monitor Simple Queue Service with SigNoz
|
||||
|
||||
Collect SQS key Metrics and view them with an out of the box dashboard.
|
||||
@@ -24,10 +24,12 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
|
||||
tracefunnels "github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations/services"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
|
||||
@@ -5108,3 +5110,226 @@ 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 *middleware.AuthZ) {
|
||||
// Main trace funnels router
|
||||
traceFunnelsRouter := router.PathPrefix("/api/v1/trace-funnels").Subrouter()
|
||||
|
||||
// API endpoints
|
||||
traceFunnelsRouter.HandleFunc("/new",
|
||||
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.New)).
|
||||
Methods(http.MethodPost)
|
||||
traceFunnelsRouter.HandleFunc("/list",
|
||||
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.List)).
|
||||
Methods(http.MethodGet)
|
||||
traceFunnelsRouter.HandleFunc("/steps/update",
|
||||
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.UpdateSteps)).
|
||||
Methods(http.MethodPut)
|
||||
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}",
|
||||
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.Get)).
|
||||
Methods(http.MethodGet)
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}",
|
||||
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.Delete)).
|
||||
Methods(http.MethodDelete)
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}",
|
||||
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.UpdateFunnel)).
|
||||
Methods(http.MethodPut)
|
||||
traceFunnelsRouter.HandleFunc("/save",
|
||||
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.Save)).
|
||||
Methods(http.MethodPost)
|
||||
// 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/steps/overview", aH.handleFunnelStepAnalytics).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/slow-traces", aH.handleFunnelSlowTraces).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/error-traces", aH.handleFunnelErrorTraces).Methods("POST")
|
||||
|
||||
}
|
||||
|
||||
func (aH *APIHandler) handleValidateTraces(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var timeRange traceFunnels.TimeRange
|
||||
if err := json.NewDecoder(r.Body).Decode(&timeRange); err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("error decoding time range: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if len(funnel.Steps) < 2 {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("funnel must have at least 2 steps")}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
chq, err := tracefunnels.ValidateTraces(funnel, timeRange)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, results)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) handleFunnelAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var stepTransition traceFunnels.StepTransitionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&stepTransition); err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("error decoding time range: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
chq, err := tracefunnels.GetFunnelAnalytics(funnel, stepTransition.TimeRange)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, results)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) handleFunnelStepAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var stepTransition traceFunnels.StepTransitionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&stepTransition); err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("error decoding time range: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
chq, err := tracefunnels.GetFunnelStepAnalytics(funnel, stepTransition.TimeRange, stepTransition.StepAOrder, stepTransition.StepBOrder)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, results)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) handleStepAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var timeRange traceFunnels.TimeRange
|
||||
if err := json.NewDecoder(r.Body).Decode(&timeRange); err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("error decoding time range: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
chq, err := tracefunnels.GetStepAnalytics(funnel, timeRange)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, results)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) handleFunnelSlowTraces(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var req traceFunnels.StepTransitionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("invalid request body: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
chq, err := tracefunnels.GetSlowestTraces(funnel, req.TimeRange, req.StepAOrder, req.StepBOrder)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, results)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) handleFunnelErrorTraces(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var req traceFunnels.StepTransitionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("invalid request body: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
chq, err := tracefunnels.GetErroredTraces(funnel, req.TimeRange, req.StepAOrder, req.StepBOrder)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, results)
|
||||
}
|
||||
|
||||
@@ -273,6 +273,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{"*"},
|
||||
|
||||
@@ -87,7 +87,7 @@ func existsSubQueryForFixedColumn(key v3.AttributeKey, op v3.FilterOperator) (st
|
||||
}
|
||||
}
|
||||
|
||||
func buildTracesFilterQuery(fs *v3.FilterSet) (string, error) {
|
||||
func BuildTracesFilterQuery(fs *v3.FilterSet) (string, error) {
|
||||
var conditions []string
|
||||
|
||||
if fs != nil && len(fs.Items) != 0 {
|
||||
@@ -167,7 +167,7 @@ func handleEmptyValuesInGroupBy(groupBy []v3.AttributeKey) (string, error) {
|
||||
Operator: "AND",
|
||||
Items: filterItems,
|
||||
}
|
||||
return buildTracesFilterQuery(&filterSet)
|
||||
return BuildTracesFilterQuery(&filterSet)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
@@ -248,7 +248,7 @@ func buildTracesQuery(start, end, step int64, mq *v3.BuilderQuery, panelType v3.
|
||||
|
||||
timeFilter := fmt.Sprintf("(timestamp >= '%d' AND timestamp <= '%d') AND (ts_bucket_start >= %d AND ts_bucket_start <= %d)", tracesStart, tracesEnd, bucketStart, bucketEnd)
|
||||
|
||||
filterSubQuery, err := buildTracesFilterQuery(mq.Filters)
|
||||
filterSubQuery, err := BuildTracesFilterQuery(mq.Filters)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ func Test_buildTracesFilterQuery(t *testing.T) {
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "Test buildTracesFilterQuery in, nin",
|
||||
name: "Test BuildTracesFilterQuery in, nin",
|
||||
args: args{
|
||||
fs: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
|
||||
{Key: v3.AttributeKey{Key: "method", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: []interface{}{"GET", "POST"}, Operator: v3.FilterOperatorIn},
|
||||
@@ -226,7 +226,7 @@ func Test_buildTracesFilterQuery(t *testing.T) {
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Test buildTracesFilterQuery not eq, neq, gt, lt, gte, lte",
|
||||
name: "Test BuildTracesFilterQuery not eq, neq, gt, lt, gte, lte",
|
||||
args: args{
|
||||
fs: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
|
||||
{Key: v3.AttributeKey{Key: "duration", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag}, Value: 102, Operator: v3.FilterOperatorEqual},
|
||||
@@ -274,13 +274,13 @@ func Test_buildTracesFilterQuery(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := buildTracesFilterQuery(tt.args.fs)
|
||||
got, err := BuildTracesFilterQuery(tt.args.fs)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("buildTracesFilterQuery() error = %v, wantErr %v", err, tt.wantErr)
|
||||
t.Errorf("BuildTracesFilterQuery() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("buildTracesFilterQuery() = %v, want %v", got, tt.want)
|
||||
t.Errorf("BuildTracesFilterQuery() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ type Feature struct {
|
||||
|
||||
const UseSpanMetrics = "USE_SPAN_METRICS"
|
||||
const AnomalyDetection = "ANOMALY_DETECTION"
|
||||
const TraceFunnels = "TRACE_FUNNELS"
|
||||
|
||||
var BasicPlan = FeatureSet{
|
||||
Feature{
|
||||
@@ -28,11 +27,4 @@ var BasicPlan = FeatureSet{
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
Feature{
|
||||
Name: TraceFunnels,
|
||||
Active: false,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
type TelemetryUser struct {
|
||||
types.User
|
||||
Organization string `json:"organization"`
|
||||
Organization string
|
||||
}
|
||||
|
||||
func GetUsers(ctx context.Context, sqlstore sqlstore.SQLStore) ([]TelemetryUser, error) {
|
||||
@@ -27,20 +27,37 @@ func GetUserCount(ctx context.Context, sqlstore sqlstore.SQLStore) (int, error)
|
||||
|
||||
// GetUsersWithOpts fetches users and supports additional search options
|
||||
func GetUsersWithOpts(ctx context.Context, limit int, sqlstore sqlstore.SQLStore) ([]TelemetryUser, error) {
|
||||
users := []TelemetryUser{}
|
||||
var displayName string
|
||||
err := sqlstore.BunDB().NewSelect().
|
||||
Model(&types.Organization{}).
|
||||
Column("display_name").
|
||||
Scan(ctx, &displayName)
|
||||
if err != nil {
|
||||
return nil, sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "cannot find organization")
|
||||
}
|
||||
|
||||
query := sqlstore.BunDB().NewSelect().
|
||||
Table("user").
|
||||
Column("user.id", "user.display_name", "user.email", "user.created_at", "user.org_id").
|
||||
ColumnExpr("o.display_name as organization").
|
||||
Join("JOIN organizations o ON o.id = user.org_id")
|
||||
users := []types.User{}
|
||||
|
||||
query := sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&users)
|
||||
if limit > 0 {
|
||||
query.Limit(limit)
|
||||
}
|
||||
err := query.Scan(ctx, &users)
|
||||
|
||||
err = query.Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WrapNotFoundf(err, errors.CodeNotFound, "failed to get users")
|
||||
return nil, sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "failed to get users")
|
||||
}
|
||||
return users, nil
|
||||
|
||||
telemetryUsers := []TelemetryUser{}
|
||||
for _, user := range users {
|
||||
telemetryUsers = append(telemetryUsers, TelemetryUser{
|
||||
User: user,
|
||||
Organization: displayName,
|
||||
})
|
||||
}
|
||||
|
||||
return telemetryUsers, nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference/implpreference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
)
|
||||
|
||||
@@ -18,6 +20,7 @@ type Handlers struct {
|
||||
Organization organization.Handler
|
||||
Preference preference.Handler
|
||||
User user.Handler
|
||||
TraceFunnel tracefunnel.Handler
|
||||
SavedView savedview.Handler
|
||||
Apdex apdex.Handler
|
||||
Dashboard dashboard.Handler
|
||||
@@ -27,6 +30,7 @@ func NewHandlers(modules Modules, user user.Handler) Handlers {
|
||||
return Handlers{
|
||||
Organization: implorganization.NewHandler(modules.Organization),
|
||||
Preference: implpreference.NewHandler(modules.Preference),
|
||||
TraceFunnel: impltracefunnel.NewHandler(modules.TraceFunnel),
|
||||
User: user,
|
||||
SavedView: implsavedview.NewHandler(modules.SavedView),
|
||||
Apdex: implapdex.NewHandler(modules.Apdex),
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference/implpreference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
@@ -19,6 +21,7 @@ import (
|
||||
type Modules struct {
|
||||
Organization organization.Module
|
||||
Preference preference.Module
|
||||
TraceFunnel tracefunnel.Module
|
||||
User user.Module
|
||||
SavedView savedview.Module
|
||||
Apdex apdex.Module
|
||||
@@ -29,6 +32,7 @@ func NewModules(sqlstore sqlstore.SQLStore, user user.Module) Modules {
|
||||
return Modules{
|
||||
Organization: implorganization.NewModule(implorganization.NewStore(sqlstore)),
|
||||
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewDefaultPreferenceMap()),
|
||||
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
|
||||
User: user,
|
||||
SavedView: implsavedview.NewModule(sqlstore),
|
||||
Apdex: implapdex.NewModule(sqlstore),
|
||||
|
||||
@@ -76,6 +76,7 @@ func NewSQLMigrationProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedM
|
||||
sqlmigration.NewDropGroupsFactory(sqlstore),
|
||||
sqlmigration.NewCreateQuickFiltersFactory(sqlstore),
|
||||
sqlmigration.NewUpdateQuickFiltersFactory(sqlstore),
|
||||
sqlmigration.NewAddTraceFunnelsFactory(sqlstore),
|
||||
sqlmigration.NewAuthRefactorFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
114
pkg/sqlmigration/050_add_trace_funnels.go
Normal file
114
pkg/sqlmigration/050_add_trace_funnels.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addTraceFunnels struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddTraceFunnelsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.
|
||||
NewProviderFactory(factory.
|
||||
MustNewName("add_trace_funnels"),
|
||||
func(ctx context.Context, providerSettings factory.ProviderSettings, config Config) (SQLMigration, error) {
|
||||
return newAddTraceFunnels(ctx, providerSettings, config, sqlstore)
|
||||
})
|
||||
}
|
||||
|
||||
func newAddTraceFunnels(_ context.Context, _ factory.ProviderSettings, _ Config, sqlstore sqlstore.SQLStore) (SQLMigration, error) {
|
||||
return &addTraceFunnels{sqlstore: sqlstore}, nil
|
||||
}
|
||||
|
||||
func (migration *addTraceFunnels) Register(migrations *migrate.Migrations) error {
|
||||
if err := migrations.
|
||||
Register(migration.Up, migration.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *addTraceFunnels) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Create trace_funnel table with foreign key constraint inline
|
||||
_, err = tx.NewCreateTable().
|
||||
Model((*traceFunnels.Funnel)(nil)).
|
||||
ForeignKey(`("org_id") REFERENCES "organizations" ("id") ON DELETE CASCADE`).
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create trace_funnel table: %v", err)
|
||||
}
|
||||
|
||||
// Add unique constraint for org_id and name
|
||||
//_, err = tx.NewRaw(`
|
||||
// CREATE UNIQUE INDEX IF NOT EXISTS idx_trace_funnel_org_id_name
|
||||
// ON trace_funnel (org_id, name)
|
||||
//`).Exec(ctx)
|
||||
//if err != nil {
|
||||
// return fmt.Errorf("failed to create unique constraint: %v", err)
|
||||
//}
|
||||
|
||||
// Create indexes
|
||||
_, err = tx.NewCreateIndex().
|
||||
Model((*traceFunnels.Funnel)(nil)).
|
||||
Index("idx_trace_funnel_org_id").
|
||||
Column("org_id").
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create org_id index: %v", err)
|
||||
}
|
||||
|
||||
_, err = tx.NewCreateIndex().
|
||||
Model((*traceFunnels.Funnel)(nil)).
|
||||
Index("idx_trace_funnel_created_at").
|
||||
Column("created_at").
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create created_at index: %v", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *addTraceFunnels) Down(ctx context.Context, db *bun.DB) error {
|
||||
//tx, err := db.BeginTx(ctx, nil)
|
||||
//if err != nil {
|
||||
// return err
|
||||
//}
|
||||
//defer tx.Rollback()
|
||||
//
|
||||
//// Drop trace_funnel table
|
||||
//_, err = tx.NewDropTable().
|
||||
// Model((*traceFunnels.Funnel)(nil)).
|
||||
// IfExists().
|
||||
// Exec(ctx)
|
||||
//if err != nil {
|
||||
// return fmt.Errorf("failed to drop trace_funnel table: %v", err)
|
||||
//}
|
||||
//
|
||||
//if err := tx.Commit(); err != nil {
|
||||
// return err
|
||||
//}
|
||||
|
||||
return nil
|
||||
}
|
||||
15
pkg/types/tracefunnel/store.go
Normal file
15
pkg/types/tracefunnel/store.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package tracefunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type FunnelStore interface {
|
||||
Create(context.Context, *Funnel) error
|
||||
Get(context.Context, valuer.UUID) (*Funnel, error)
|
||||
List(context.Context, valuer.UUID) ([]*Funnel, error)
|
||||
Update(context.Context, *Funnel) error
|
||||
Delete(context.Context, valuer.UUID) error
|
||||
}
|
||||
103
pkg/types/tracefunnel/tracefunnel.go
Normal file
103
pkg/types/tracefunnel/tracefunnel.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package tracefunnel
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFunnelAlreadyExists = errors.MustNewCode("funnel_already_exists")
|
||||
)
|
||||
|
||||
// BaseMetadata metadata for funnels
|
||||
type BaseMetadata struct {
|
||||
types.Identifiable // funnel id
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
Name string `json:"funnel_name" bun:"name,type:text,notnull"` // funnel name
|
||||
Description string `json:"description" bun:"description,type:text"` // funnel description
|
||||
OrgID valuer.UUID `json:"org_id" bun:"org_id,type:varchar,notnull"`
|
||||
}
|
||||
|
||||
// Funnel Core Data Structure (Funnel and FunnelStep)
|
||||
type Funnel struct {
|
||||
bun.BaseModel `bun:"table:trace_funnel"`
|
||||
BaseMetadata
|
||||
Steps []FunnelStep `json:"steps" bun:"steps,type:text,notnull"`
|
||||
Tags string `json:"tags" bun:"tags,type:text"`
|
||||
CreatedByUser *types.User `json:"user" bun:"rel:belongs-to,join:created_by=id"`
|
||||
}
|
||||
|
||||
type FunnelStep struct {
|
||||
ID valuer.UUID `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"` // step name
|
||||
Description string `json:"description,omitempty"` // step description
|
||||
Order int64 `json:"step_order"`
|
||||
ServiceName string `json:"service_name"`
|
||||
SpanName string `json:"span_name"`
|
||||
Filters *v3.FilterSet `json:"filters,omitempty"`
|
||||
LatencyPointer string `json:"latency_pointer,omitempty"`
|
||||
LatencyType string `json:"latency_type,omitempty"`
|
||||
HasErrors bool `json:"has_errors"`
|
||||
}
|
||||
|
||||
// FunnelRequest represents all possible funnel-related requests
|
||||
type FunnelRequest struct {
|
||||
FunnelID valuer.UUID `json:"funnel_id,omitempty"`
|
||||
Name string `json:"funnel_name,omitempty"`
|
||||
Timestamp int64 `json:"timestamp,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Steps []FunnelStep `json:"steps,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
|
||||
// Analytics specific fields
|
||||
StartTime int64 `json:"start_time,omitempty"`
|
||||
EndTime int64 `json:"end_time,omitempty"`
|
||||
StepAOrder int64 `json:"step_a_order,omitempty"`
|
||||
StepBOrder int64 `json:"step_b_order,omitempty"`
|
||||
}
|
||||
|
||||
// FunnelResponse represents all possible funnel-related responses
|
||||
type FunnelResponse struct {
|
||||
FunnelID string `json:"funnel_id,omitempty"`
|
||||
FunnelName string `json:"funnel_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
CreatedAt int64 `json:"created_at,omitempty"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"`
|
||||
UpdatedBy string `json:"updated_by,omitempty"`
|
||||
OrgID string `json:"org_id,omitempty"`
|
||||
UserEmail string `json:"user_email,omitempty"`
|
||||
Funnel *Funnel `json:"funnel,omitempty"`
|
||||
Steps []FunnelStep `json:"steps,omitempty"`
|
||||
}
|
||||
|
||||
// TimeRange represents a time range for analytics
|
||||
type TimeRange struct {
|
||||
StartTime int64 `json:"start_time"`
|
||||
EndTime int64 `json:"end_time"`
|
||||
}
|
||||
|
||||
// StepTransitionRequest represents a request for step transition analytics
|
||||
type StepTransitionRequest struct {
|
||||
TimeRange
|
||||
StepAOrder int64 `json:"step_start,omitempty"`
|
||||
StepBOrder int64 `json:"step_end,omitempty"`
|
||||
}
|
||||
|
||||
// UserInfo represents basic user information
|
||||
type UserInfo struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type FunnelStepFilter struct {
|
||||
StepNumber int
|
||||
ServiceName string
|
||||
SpanName string
|
||||
LatencyPointer string // "start" or "end"
|
||||
CustomFilters *v3.FilterSet
|
||||
}
|
||||
Reference in New Issue
Block a user