Compare commits

...

12 Commits

Author SHA1 Message Date
Shivanshu Raj Shrivastava
05369a2bfa Merge branch 'main' into launch-week-demo 2025-05-19 14:57:43 +05:30
Shaheer Kochai
88e1e42bf0 chore: add analytics events for trace funnels (#7638) 2025-05-19 09:22:35 +00:00
ahmadshaheer
1af0739070 fix: trace funnel overall improvements and bugfixes 2025-05-19 13:51:55 +04:30
Piyush Singariya
a0d896557e feat: introducing ECS + SQS integration (#7840)
* feat: introducing S3 sync as AWS integration

* chore: trying restructuring

* chore: in progress

* chore: restructuring looks ok

* chore: minor fix in tests

* feat: integration with Agent check-in complete

* chore: minor change in validation

* fix: removing validation and altering overview

* fix: aftermath of merge conflicts

* test: updating agent version

* test: updating agent version

* test: updating agent version 3

* test: updating agent version 11

* test: updating agent version 14

* chore: replace with newer error utility

* feat: introducing ECS integration (AWS Integrations)

* chore: adding metrics to ecs integration

* feat: adding base SQS files

* feat: adding metrics for SQS

* feat: adding ECS dashboard

* feat: adding dashboards for SQS

* fix: adding SentMessageSize metrics in SQS

* fix: for calculating log connection status for S3 Sync

* fix: adding check for svc type, fixing cw logs integration.json S3 Sync

* fix: in compiledCollectionStrat for servicetype s3sync

* test: testing agent version

* fix: change in data collected for S3 Sync logs

* test: testing agent 19

* chore: replace fmt.Errorf

* fix: tests and adding validation in S3 buckets

* fix: test TestAvailableServices

* chore: replacing fmt.Errorf

* chore: updating the agent version to latest

* chore: reverting some changes

* fix: remove services from Variables

* chore: change overview.png
2025-05-19 14:17:52 +05:30
Shivanshu Raj Shrivastava
285f3eec72 fix: remove dev node check
Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-05-19 13:21:36 +05:30
Shivanshu Raj Shrivastava
652c7a78a2 fix: user identifiable
Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-05-19 13:21:36 +05:30
Shivanshu Raj Shrivastava
cd98295ccf feat: update migration number
Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-05-19 13:21:36 +05:30
ahmadshaheer
b230687134 frontend changes 2025-05-19 13:21:36 +05:30
Shivanshu Raj Shrivastava
c5d7623a98 feat: tf changes
Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-05-19 13:21:27 +05:30
Amlan Kumar Nandy
2b28c5f2e2 chore: persist the filters and time selection, modal open state in summary view (#7942) 2025-05-19 11:54:05 +07:00
Vibhu Pandey
6dbcc5fb9d fix(analytics): fix heartbeat event (#7975) 2025-05-19 08:04:33 +05:30
Vikrant Gupta
175e9a4c5e fix(apm): update the apdex to latest response structure (#7966)
* fix(apm): update the apdex to latest response structure

* fix(apm): update the apdex to latest response structure
2025-05-17 16:23:11 +05:30
80 changed files with 8093 additions and 265 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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];
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,7 +39,7 @@ function AddFunnelStepDetailsModal({
setStepName(stepData?.name || '');
setDescription(stepData?.description || '');
}
}, [isOpen, stepData]);
}, [isOpen, stepData?.name, stepData?.description]);
const handleCancel = (): void => {
setStepName('');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,7 +37,7 @@ function FunnelsEmptyState({
>
New funnel
</Button>
<LearnMore />
<LearnMore url="https://signoz.io/blog/tracing-funnels-observability-distributed-systems/" />
</div>
</div>
</div>

View File

@@ -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: () => {

View File

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

View File

@@ -12,3 +12,8 @@ export interface MetricMetaProps {
delta: boolean;
le: number[] | null;
}
export interface PayloadProps {
data: ApDexPayloadAndSettingsProps[];
status: string;
}

View File

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
### Monitor Simple Queue Service with SigNoz
Collect SQS key Metrics and view them with an out of the box dashboard.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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