mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-19 23:10:25 +01:00
Compare commits
2 Commits
chore/tf/t
...
SIG-5761
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
520a3f93c6 | ||
|
|
f9cb9f10be |
@@ -298,6 +298,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{"*"},
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
.custom-data-controls {
|
||||
margin: 8px 0;
|
||||
|
||||
.custom-data-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.custom-data-inputs {
|
||||
margin-top: 8px;
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.input-label {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.global-custom-data-controls {
|
||||
background-color: #f0f2f5;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.ant-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
&.dark-mode {
|
||||
background-color: #141414;
|
||||
border: 1px solid #303030;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&.inline-layout {
|
||||
.custom-data-inline-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
|
||||
.title-switch-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 180px;
|
||||
|
||||
.ant-typography {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.inputs-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
|
||||
.input-label {
|
||||
margin-right: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reduce vertical padding for the card
|
||||
.ant-card-body {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import './CustomDataControls.styles.scss';
|
||||
|
||||
import { Card, InputNumber, Switch, Typography } from 'antd';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
|
||||
interface CustomDataControlsProps {
|
||||
widget: Widgets;
|
||||
onUpdate: (updatedWidget: Partial<Widgets>) => void;
|
||||
}
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function CustomDataControls({
|
||||
widget,
|
||||
onUpdate,
|
||||
}: CustomDataControlsProps): JSX.Element {
|
||||
const handleCustomDataModeChange = (checked: boolean): void => {
|
||||
onUpdate({
|
||||
customDataMode: checked,
|
||||
customXData: checked ? widget.customXData || 15 : undefined,
|
||||
customYData: checked ? widget.customYData || 4 : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDataPointsChange = (value: number | null): void => {
|
||||
if (value !== null && value > 0) {
|
||||
onUpdate({ customXData: value });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeriesCountChange = (value: number | null): void => {
|
||||
if (value !== null && value > 0) {
|
||||
onUpdate({ customYData: value });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="custom-data-controls" size="small">
|
||||
<div className="custom-data-header">
|
||||
<Text strong>Custom Data Generator</Text>
|
||||
<Switch
|
||||
checked={widget.customDataMode || false}
|
||||
onChange={handleCustomDataModeChange}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{widget.customDataMode && (
|
||||
<div className="custom-data-inputs">
|
||||
<div className="input-group">
|
||||
<Text className="input-label">Data Points (X):</Text>
|
||||
<InputNumber
|
||||
value={widget.customXData || 15}
|
||||
onChange={handleDataPointsChange}
|
||||
size="small"
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<Text className="input-label">Series Count (Y):</Text>
|
||||
<InputNumber
|
||||
value={widget.customYData || 4}
|
||||
onChange={handleSeriesCountChange}
|
||||
size="small"
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomDataControls;
|
||||
@@ -0,0 +1,91 @@
|
||||
import './CustomDataControls.styles.scss';
|
||||
|
||||
import { Card, InputNumber, Switch, Typography } from 'antd';
|
||||
|
||||
interface GlobalCustomDataControlsProps {
|
||||
customDataMode: boolean;
|
||||
setCustomDataMode: (value: boolean) => void;
|
||||
customXData: number;
|
||||
setCustomXData: (value: number) => void;
|
||||
customYData: number;
|
||||
setCustomYData: (value: number) => void;
|
||||
}
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function GlobalCustomDataControls({
|
||||
customDataMode,
|
||||
setCustomDataMode,
|
||||
customXData,
|
||||
setCustomXData,
|
||||
customYData,
|
||||
setCustomYData,
|
||||
}: GlobalCustomDataControlsProps): JSX.Element {
|
||||
const handleCustomDataModeChange = (checked: boolean): void => {
|
||||
setCustomDataMode(checked);
|
||||
|
||||
// Set default values if not already set
|
||||
if (checked) {
|
||||
if (!customXData) setCustomXData(15);
|
||||
if (!customYData) setCustomYData(4);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDataPointsChange = (value: number | null): void => {
|
||||
if (value !== null && value > 0) {
|
||||
setCustomXData(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeriesCountChange = (value: number | null): void => {
|
||||
if (value !== null && value > 0) {
|
||||
setCustomYData(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="custom-data-controls global-custom-data-controls"
|
||||
size="small"
|
||||
>
|
||||
<div className="custom-data-header">
|
||||
<Text strong>Global Custom Data Generator</Text>
|
||||
<Switch
|
||||
checked={customDataMode}
|
||||
onChange={handleCustomDataModeChange}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{customDataMode && (
|
||||
<div className="custom-data-inputs">
|
||||
<div className="input-group">
|
||||
<Text className="input-label">Data Points (X):</Text>
|
||||
<InputNumber
|
||||
value={customXData || 15}
|
||||
onChange={handleDataPointsChange}
|
||||
size="small"
|
||||
style={{ width: 80 }}
|
||||
min={1}
|
||||
max={1000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<Text className="input-label">Series Count (Y):</Text>
|
||||
<InputNumber
|
||||
value={customYData || 4}
|
||||
onChange={handleSeriesCountChange}
|
||||
size="small"
|
||||
style={{ width: 80 }}
|
||||
min={1}
|
||||
max={20}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default GlobalCustomDataControls;
|
||||
@@ -0,0 +1,88 @@
|
||||
import './CustomDataControls.styles.scss';
|
||||
|
||||
import { Card, InputNumber, Switch, Typography } from 'antd';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function GlobalCustomDataControlsHeader(): JSX.Element {
|
||||
const {
|
||||
globalCustomDataMode,
|
||||
setGlobalCustomDataMode,
|
||||
globalCustomXData,
|
||||
setGlobalCustomXData,
|
||||
globalCustomYData,
|
||||
setGlobalCustomYData,
|
||||
} = useDashboard();
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const handleCustomDataModeChange = (checked: boolean): void => {
|
||||
setGlobalCustomDataMode(checked);
|
||||
|
||||
// Set default values if not already set
|
||||
if (checked) {
|
||||
if (!globalCustomXData) setGlobalCustomXData(15);
|
||||
if (!globalCustomYData) setGlobalCustomYData(4);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDataPointsChange = (value: number | null): void => {
|
||||
if (value !== null && value > 0) {
|
||||
setGlobalCustomXData(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeriesCountChange = (value: number | null): void => {
|
||||
if (value !== null && value > 0) {
|
||||
setGlobalCustomYData(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`custom-data-controls global-custom-data-controls inline-layout ${
|
||||
isDarkMode ? 'dark-mode' : ''
|
||||
}`}
|
||||
size="small"
|
||||
>
|
||||
<div className="custom-data-inline-container">
|
||||
<div className="title-switch-group">
|
||||
<Text strong>Custom Data Generator</Text>
|
||||
<Switch
|
||||
checked={globalCustomDataMode}
|
||||
onChange={handleCustomDataModeChange}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{globalCustomDataMode && (
|
||||
<div className="inputs-container">
|
||||
<div className="input-group">
|
||||
<Text className="input-label">Points (X):</Text>
|
||||
<InputNumber
|
||||
value={globalCustomXData || 15}
|
||||
onChange={handleDataPointsChange}
|
||||
size="small"
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<Text className="input-label">Series (Y):</Text>
|
||||
<InputNumber
|
||||
value={globalCustomYData || 4}
|
||||
onChange={handleSeriesCountChange}
|
||||
size="small"
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default GlobalCustomDataControlsHeader;
|
||||
@@ -121,6 +121,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.global-custom-data-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dashboard-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import GlobalCustomDataControlsHeader from 'components/CustomDataControls/GlobalCustomDataControlsHeader';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -478,6 +479,9 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<section className="global-custom-data-section">
|
||||
<GlobalCustomDataControlsHeader />
|
||||
</section>
|
||||
{(tags?.length || 0) > 0 && (
|
||||
<div className="dashboard-tags">
|
||||
{tags?.map((tag) => (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Switch,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import CustomDataControls from 'components/CustomDataControls/CustomDataControls';
|
||||
import TimePreference from 'components/TimePreferenceDropDown';
|
||||
import { PANEL_TYPES, PanelDisplay } from 'constants/queryBuilder';
|
||||
import GraphTypes, {
|
||||
@@ -113,6 +114,12 @@ function RightContainer({
|
||||
customLegendColors,
|
||||
setCustomLegendColors,
|
||||
queryResponse,
|
||||
customDataMode,
|
||||
setCustomDataMode,
|
||||
customXData,
|
||||
setCustomXData,
|
||||
customYData,
|
||||
setCustomYData,
|
||||
}: RightContainerProps): JSX.Element {
|
||||
const { selectedDashboard } = useDashboard();
|
||||
const [inputValue, setInputValue] = useState(title);
|
||||
@@ -280,6 +287,30 @@ function RightContainer({
|
||||
rootClassName="description-input"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Custom Data Controls */}
|
||||
{selectedWidget && (
|
||||
<CustomDataControls
|
||||
widget={{
|
||||
...selectedWidget,
|
||||
customDataMode,
|
||||
customXData,
|
||||
customYData,
|
||||
}}
|
||||
onUpdate={(updatedWidget: Partial<Widgets>): void => {
|
||||
if (updatedWidget.customDataMode !== undefined) {
|
||||
setCustomDataMode(updatedWidget.customDataMode);
|
||||
}
|
||||
if (updatedWidget.customXData !== undefined) {
|
||||
setCustomXData(updatedWidget.customXData);
|
||||
}
|
||||
if (updatedWidget.customYData !== undefined) {
|
||||
setCustomYData(updatedWidget.customYData);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="panel-config">
|
||||
<Typography.Text className="typography">Panel Type</Typography.Text>
|
||||
<Select
|
||||
@@ -554,6 +585,12 @@ interface RightContainerProps {
|
||||
SuccessResponse<MetricRangePayloadProps, unknown>,
|
||||
Error
|
||||
>;
|
||||
customDataMode: boolean;
|
||||
setCustomDataMode: Dispatch<SetStateAction<boolean>>;
|
||||
customXData: number;
|
||||
setCustomXData: Dispatch<SetStateAction<number>>;
|
||||
customYData: number;
|
||||
setCustomYData: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
RightContainer.defaultProps = {
|
||||
|
||||
@@ -239,6 +239,17 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
selectedWidget?.columnUnits || {},
|
||||
);
|
||||
|
||||
// Custom data generator state
|
||||
const [customDataMode, setCustomDataMode] = useState<boolean>(
|
||||
selectedWidget?.customDataMode || false,
|
||||
);
|
||||
const [customXData, setCustomXData] = useState<number>(
|
||||
selectedWidget?.customXData || 15,
|
||||
);
|
||||
const [customYData, setCustomYData] = useState<number>(
|
||||
selectedWidget?.customYData || 4,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedWidget((prev) => {
|
||||
if (!prev) {
|
||||
@@ -268,6 +279,9 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
legendPosition,
|
||||
customLegendColors,
|
||||
columnWidths: columnWidths?.[selectedWidget?.id],
|
||||
customDataMode,
|
||||
customXData,
|
||||
customYData,
|
||||
};
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -294,6 +308,9 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
legendPosition,
|
||||
customLegendColors,
|
||||
columnWidths,
|
||||
customDataMode,
|
||||
customXData,
|
||||
customYData,
|
||||
]);
|
||||
|
||||
const closeModal = (): void => {
|
||||
@@ -503,6 +520,9 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
selectedTracesFields: selectedWidget?.selectedTracesFields || [],
|
||||
legendPosition: selectedWidget?.legendPosition || LegendPosition.BOTTOM,
|
||||
customLegendColors: selectedWidget?.customLegendColors || {},
|
||||
customDataMode: selectedWidget?.customDataMode || false,
|
||||
customXData: selectedWidget?.customXData || 15,
|
||||
customYData: selectedWidget?.customYData || 4,
|
||||
},
|
||||
]
|
||||
: [
|
||||
@@ -532,6 +552,9 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
selectedTracesFields: selectedWidget?.selectedTracesFields || [],
|
||||
legendPosition: selectedWidget?.legendPosition || LegendPosition.BOTTOM,
|
||||
customLegendColors: selectedWidget?.customLegendColors || {},
|
||||
customDataMode: selectedWidget?.customDataMode || false,
|
||||
customXData: selectedWidget?.customXData || 15,
|
||||
customYData: selectedWidget?.customYData || 4,
|
||||
},
|
||||
...afterWidgets,
|
||||
],
|
||||
@@ -796,6 +819,12 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
setSoftMin={setSoftMin}
|
||||
softMax={softMax}
|
||||
setSoftMax={setSoftMax}
|
||||
customDataMode={customDataMode}
|
||||
setCustomDataMode={setCustomDataMode}
|
||||
customXData={customXData}
|
||||
setCustomXData={setCustomXData}
|
||||
customYData={customYData}
|
||||
setCustomYData={setCustomYData}
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</RightContainerWrapper>
|
||||
|
||||
@@ -10,12 +10,19 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import {
|
||||
getCustomApiResponse,
|
||||
getCustomChartData,
|
||||
} from 'lib/uPlotLib/utils/getCustomChartData';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { cloneDeep, isEqual, isUndefined } from 'lodash-es';
|
||||
import _noop from 'lodash-es/noop';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import uPlot from 'uplot';
|
||||
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
@@ -35,7 +42,14 @@ function UplotPanelWrapper({
|
||||
customTooltipElement,
|
||||
customSeries,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const {
|
||||
toScrollWidgetId,
|
||||
setToScrollWidgetId,
|
||||
globalCustomDataMode,
|
||||
globalCustomXData,
|
||||
globalCustomYData,
|
||||
} = useDashboard();
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const lineChartRef = useRef<ToggleGraphProps>();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
@@ -43,6 +57,11 @@ function UplotPanelWrapper({
|
||||
const [maxTimeScale, setMaxTimeScale] = useState<number>();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
// Get global time for custom data generation
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const [hiddenGraph, setHiddenGraph] = useState<{ [key: string]: boolean }>();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -56,12 +75,64 @@ function UplotPanelWrapper({
|
||||
}
|
||||
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
|
||||
|
||||
// Create custom response when custom data mode is enabled (either via widget or global setting)
|
||||
const effectiveQueryResponse = useMemo(() => {
|
||||
// If global custom data mode is enabled, it should override widget settings
|
||||
const isCustomDataEnabled = globalCustomDataMode || widget.customDataMode;
|
||||
|
||||
// When global custom data is enabled, use global values regardless of widget settings
|
||||
let xData = null;
|
||||
let yData = null;
|
||||
|
||||
if (globalCustomDataMode) {
|
||||
// Global settings override widget settings
|
||||
xData = globalCustomXData;
|
||||
yData = globalCustomYData;
|
||||
} else if (widget.customDataMode) {
|
||||
// Only use widget settings if global is not enabled
|
||||
xData = widget.customXData;
|
||||
yData = widget.customYData;
|
||||
}
|
||||
|
||||
if (isCustomDataEnabled && xData && yData) {
|
||||
// Convert nanoseconds to seconds for custom data generation
|
||||
const startTimeSeconds = Math.floor(minTime / 1000000000);
|
||||
const endTimeSeconds = Math.floor(maxTime / 1000000000);
|
||||
|
||||
const customResponse = getCustomApiResponse(
|
||||
xData,
|
||||
yData,
|
||||
startTimeSeconds,
|
||||
endTimeSeconds,
|
||||
);
|
||||
// Return a properly structured response that matches the expected type
|
||||
return {
|
||||
...queryResponse,
|
||||
data: customResponse,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
} as typeof queryResponse;
|
||||
}
|
||||
return queryResponse;
|
||||
}, [
|
||||
queryResponse,
|
||||
widget.customDataMode,
|
||||
widget.customXData,
|
||||
widget.customYData,
|
||||
globalCustomDataMode,
|
||||
globalCustomXData,
|
||||
globalCustomYData,
|
||||
minTime,
|
||||
maxTime,
|
||||
]);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
const { startTime, endTime } = getTimeRange(effectiveQueryResponse);
|
||||
|
||||
setMinTimeScale(startTime);
|
||||
setMaxTimeScale(endTime);
|
||||
}, [queryResponse]);
|
||||
}, [effectiveQueryResponse]);
|
||||
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
|
||||
@@ -69,28 +140,71 @@ function UplotPanelWrapper({
|
||||
const {
|
||||
graphVisibilityStates: localStoredVisibilityState,
|
||||
} = getLocalStorageGraphVisibilityState({
|
||||
apiResponse: queryResponse.data?.payload.data.result || [],
|
||||
apiResponse: effectiveQueryResponse.data?.payload.data.result || [],
|
||||
name: widget.id,
|
||||
});
|
||||
if (setGraphVisibility) {
|
||||
setGraphVisibility(localStoredVisibilityState);
|
||||
}
|
||||
}, [queryResponse.data?.payload.data.result, setGraphVisibility, widget.id]);
|
||||
}, [
|
||||
effectiveQueryResponse.data?.payload.data.result,
|
||||
setGraphVisibility,
|
||||
widget.id,
|
||||
]);
|
||||
|
||||
if (queryResponse.data && widget.panelTypes === PANEL_TYPES.BAR) {
|
||||
if (effectiveQueryResponse.data && widget.panelTypes === PANEL_TYPES.BAR) {
|
||||
const sortedSeriesData = getSortedSeriesData(
|
||||
queryResponse.data?.payload.data.result,
|
||||
effectiveQueryResponse.data?.payload.data.result,
|
||||
);
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
queryResponse.data.payload.data.result = sortedSeriesData;
|
||||
effectiveQueryResponse.data.payload.data.result = sortedSeriesData;
|
||||
}
|
||||
|
||||
const chartData = getUPlotChartData(
|
||||
queryResponse?.data?.payload,
|
||||
const chartData = useMemo(() => {
|
||||
// If global custom data mode is enabled, it should override widget settings
|
||||
const isCustomDataEnabled = globalCustomDataMode || widget.customDataMode;
|
||||
|
||||
// When global custom data is enabled, use global values regardless of widget settings
|
||||
let xData = null;
|
||||
let yData = null;
|
||||
|
||||
if (globalCustomDataMode) {
|
||||
// Global settings override widget settings
|
||||
xData = globalCustomXData;
|
||||
yData = globalCustomYData;
|
||||
} else if (widget.customDataMode) {
|
||||
// Only use widget settings if global is not enabled
|
||||
xData = widget.customXData;
|
||||
yData = widget.customYData;
|
||||
}
|
||||
|
||||
if (isCustomDataEnabled && xData && yData) {
|
||||
// Convert nanoseconds to seconds for custom data generation
|
||||
const startTimeSeconds = Math.floor(minTime / 1000000000);
|
||||
const endTimeSeconds = Math.floor(maxTime / 1000000000);
|
||||
|
||||
return getCustomChartData(xData, yData, startTimeSeconds, endTimeSeconds);
|
||||
}
|
||||
return getUPlotChartData(
|
||||
effectiveQueryResponse?.data?.payload,
|
||||
widget.fillSpans,
|
||||
widget?.stackedBarChart,
|
||||
hiddenGraph,
|
||||
);
|
||||
}, [
|
||||
widget.customDataMode,
|
||||
widget.customXData,
|
||||
widget.customYData,
|
||||
globalCustomDataMode,
|
||||
globalCustomXData,
|
||||
globalCustomYData,
|
||||
minTime,
|
||||
maxTime,
|
||||
effectiveQueryResponse?.data?.payload,
|
||||
widget.fillSpans,
|
||||
widget?.stackedBarChart,
|
||||
hiddenGraph,
|
||||
);
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (widget.panelTypes === PANEL_TYPES.BAR && widget?.stackedBarChart) {
|
||||
@@ -110,16 +224,22 @@ function UplotPanelWrapper({
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// Standard click handler without performance monitoring
|
||||
const enhancedClickHandler = useMemo(() => {
|
||||
if (!onClickHandler) return _noop;
|
||||
return onClickHandler;
|
||||
}, [onClickHandler]);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
getUPlotChartOptions({
|
||||
id: widget?.id,
|
||||
apiResponse: queryResponse.data?.payload,
|
||||
apiResponse: effectiveQueryResponse.data?.payload,
|
||||
dimensions: containerDimensions,
|
||||
isDarkMode,
|
||||
onDragSelect,
|
||||
yAxisUnit: widget?.yAxisUnit,
|
||||
onClickHandler: onClickHandler || _noop,
|
||||
onClickHandler: enhancedClickHandler,
|
||||
thresholds: widget.thresholds,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
@@ -150,11 +270,11 @@ function UplotPanelWrapper({
|
||||
widget.softMin,
|
||||
widget.panelTypes,
|
||||
widget?.stackedBarChart,
|
||||
queryResponse.data?.payload,
|
||||
effectiveQueryResponse.data?.payload,
|
||||
containerDimensions,
|
||||
isDarkMode,
|
||||
onDragSelect,
|
||||
onClickHandler,
|
||||
enhancedClickHandler,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
graphVisibility,
|
||||
@@ -171,6 +291,14 @@ function UplotPanelWrapper({
|
||||
],
|
||||
);
|
||||
|
||||
console.log(
|
||||
'chartData',
|
||||
'x-axis',
|
||||
chartData[0].length,
|
||||
'y-axis',
|
||||
chartData.length,
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<Uplot options={options} data={chartData} ref={lineChartRef} />
|
||||
@@ -183,7 +311,10 @@ function UplotPanelWrapper({
|
||||
)}
|
||||
{isFullViewMode && setGraphVisibility && !widget?.stackedBarChart && (
|
||||
<GraphManager
|
||||
data={getUPlotChartData(queryResponse?.data?.payload, widget.fillSpans)}
|
||||
data={getUPlotChartData(
|
||||
effectiveQueryResponse?.data?.payload,
|
||||
widget.fillSpans,
|
||||
)}
|
||||
name={widget.id}
|
||||
options={options}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
|
||||
@@ -431,9 +431,10 @@ export const getUPlotChartOptions = ({
|
||||
// Add single global cleanup listener for this chart
|
||||
const globalCleanupHandler = (e: MouseEvent): void => {
|
||||
const target = e.target as HTMLElement;
|
||||
console.log('target', target);
|
||||
if (
|
||||
!target.closest('.u-legend') &&
|
||||
!target.classList.contains('legend-tooltip')
|
||||
!target?.closest?.('.u-legend') &&
|
||||
!target?.classList?.contains('legend-tooltip')
|
||||
) {
|
||||
cleanupAllTooltips();
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, UseQueryResult } from 'react-query';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useRouteMatch } from 'react-router-dom';
|
||||
import { Dispatch } from 'redux';
|
||||
import { Dispatch as ReduxDispatch } from 'redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import AppActions from 'types/actions';
|
||||
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
|
||||
@@ -51,7 +51,7 @@ const DashboardContext = createContext<IDashboardContext>({
|
||||
isDashboardSliderOpen: false,
|
||||
isDashboardLocked: false,
|
||||
handleToggleDashboardSlider: () => {},
|
||||
handleDashboardLockToggle: () => {},
|
||||
handleDashboardLockToggle: async () => {},
|
||||
dashboardResponse: {} as UseQueryResult<Dashboard, unknown>,
|
||||
selectedDashboard: {} as Dashboard,
|
||||
dashboardId: '',
|
||||
@@ -80,6 +80,13 @@ const DashboardContext = createContext<IDashboardContext>({
|
||||
isDashboardFetching: false,
|
||||
columnWidths: {},
|
||||
setColumnWidths: () => {},
|
||||
// Global custom data state
|
||||
globalCustomDataMode: false,
|
||||
setGlobalCustomDataMode: () => {},
|
||||
globalCustomXData: 15,
|
||||
setGlobalCustomXData: () => {},
|
||||
globalCustomYData: 4,
|
||||
setGlobalCustomYData: () => {},
|
||||
});
|
||||
|
||||
interface Props {
|
||||
@@ -146,7 +153,7 @@ export function DashboardProvider({
|
||||
|
||||
function setListSortOrder(sortOrder: DashboardSortOrder): void {
|
||||
if (!isEqual(sortOrder, listSortOrder)) {
|
||||
setListOrder(sortOrder);
|
||||
setListOrder(sortOrder as any);
|
||||
}
|
||||
params.set('columnKey', sortOrder.columnKey as string);
|
||||
params.set('order', sortOrder.order as string);
|
||||
@@ -155,7 +162,7 @@ export function DashboardProvider({
|
||||
safeNavigate({ search: params.toString() });
|
||||
}
|
||||
|
||||
const dispatch = useDispatch<Dispatch<AppActions>>();
|
||||
const dispatch = useDispatch<ReduxDispatch<AppActions>>();
|
||||
|
||||
const globalTime = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
@@ -416,6 +423,13 @@ export function DashboardProvider({
|
||||
|
||||
const [columnWidths, setColumnWidths] = useState<WidgetColumnWidths>({});
|
||||
|
||||
// Global custom data state
|
||||
const [globalCustomDataMode, setGlobalCustomDataMode] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
const [globalCustomXData, setGlobalCustomXData] = useState<number>(15);
|
||||
const [globalCustomYData, setGlobalCustomYData] = useState<number>(4);
|
||||
|
||||
const value: IDashboardContext = useMemo(
|
||||
() => ({
|
||||
toScrollWidgetId,
|
||||
@@ -424,7 +438,7 @@ export function DashboardProvider({
|
||||
handleToggleDashboardSlider,
|
||||
handleDashboardLockToggle,
|
||||
dashboardResponse,
|
||||
selectedDashboard,
|
||||
selectedDashboard: selectedDashboard as Dashboard,
|
||||
dashboardId,
|
||||
layouts,
|
||||
listSortOrder,
|
||||
@@ -445,6 +459,13 @@ export function DashboardProvider({
|
||||
isDashboardFetching,
|
||||
columnWidths,
|
||||
setColumnWidths,
|
||||
// Global custom data state
|
||||
globalCustomDataMode,
|
||||
setGlobalCustomDataMode,
|
||||
globalCustomXData,
|
||||
setGlobalCustomXData,
|
||||
globalCustomYData,
|
||||
setGlobalCustomYData,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
@@ -469,6 +490,10 @@ export function DashboardProvider({
|
||||
isDashboardFetching,
|
||||
columnWidths,
|
||||
setColumnWidths,
|
||||
// Global custom data state
|
||||
globalCustomDataMode,
|
||||
globalCustomXData,
|
||||
globalCustomYData,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,39 +1,40 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { Dayjs } from 'dayjs';
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { Layout } from 'react-grid-layout';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
|
||||
export interface DashboardSortOrder {
|
||||
columnKey: string;
|
||||
order: string;
|
||||
pagination: string;
|
||||
search: string;
|
||||
columnKey?: string | null;
|
||||
order?: string | null;
|
||||
pagination?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export type WidgetColumnWidths = {
|
||||
[widgetId: string]: Record<string, number>;
|
||||
};
|
||||
export interface WidgetColumnWidths {
|
||||
[key: string]: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface IDashboardContext {
|
||||
isDashboardSliderOpen: boolean;
|
||||
isDashboardLocked: boolean;
|
||||
handleToggleDashboardSlider: (value: boolean) => void;
|
||||
handleDashboardLockToggle: (value: boolean) => void;
|
||||
handleDashboardLockToggle: (value: boolean) => Promise<void>;
|
||||
dashboardResponse: UseQueryResult<Dashboard, unknown>;
|
||||
selectedDashboard: Dashboard | undefined;
|
||||
selectedDashboard: Dashboard;
|
||||
dashboardId: string;
|
||||
layouts: Layout[];
|
||||
panelMap: Record<string, { widgets: Layout[]; collapsed: boolean }>;
|
||||
setPanelMap: React.Dispatch<React.SetStateAction<Record<string, any>>>;
|
||||
listSortOrder: DashboardSortOrder;
|
||||
setListSortOrder: (sortOrder: DashboardSortOrder) => void;
|
||||
setLayouts: React.Dispatch<React.SetStateAction<Layout[]>>;
|
||||
setSelectedDashboard: React.Dispatch<
|
||||
React.SetStateAction<Dashboard | undefined>
|
||||
setPanelMap: Dispatch<
|
||||
SetStateAction<Record<string, { widgets: Layout[]; collapsed: boolean }>>
|
||||
>;
|
||||
updatedTimeRef: React.MutableRefObject<dayjs.Dayjs | null>;
|
||||
listSortOrder: DashboardSortOrder;
|
||||
setListSortOrder: (value: DashboardSortOrder) => void;
|
||||
setLayouts: Dispatch<SetStateAction<Layout[]>>;
|
||||
setSelectedDashboard: Dispatch<SetStateAction<Dashboard | undefined>>;
|
||||
updatedTimeRef: React.MutableRefObject<Dayjs | null>;
|
||||
toScrollWidgetId: string;
|
||||
setToScrollWidgetId: React.Dispatch<React.SetStateAction<string>>;
|
||||
setToScrollWidgetId: Dispatch<SetStateAction<string>>;
|
||||
updateLocalStorageDashboardVariables: (
|
||||
id: string,
|
||||
selectedValue:
|
||||
@@ -46,12 +47,19 @@ export interface IDashboardContext {
|
||||
allSelected: boolean,
|
||||
) => void;
|
||||
variablesToGetUpdated: string[];
|
||||
setVariablesToGetUpdated: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
setVariablesToGetUpdated: Dispatch<SetStateAction<string[]>>;
|
||||
dashboardQueryRangeCalled: boolean;
|
||||
setDashboardQueryRangeCalled: (value: boolean) => void;
|
||||
setDashboardQueryRangeCalled: Dispatch<SetStateAction<boolean>>;
|
||||
selectedRowWidgetId: string | null;
|
||||
setSelectedRowWidgetId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setSelectedRowWidgetId: Dispatch<SetStateAction<string | null>>;
|
||||
isDashboardFetching: boolean;
|
||||
columnWidths: WidgetColumnWidths;
|
||||
setColumnWidths: React.Dispatch<React.SetStateAction<WidgetColumnWidths>>;
|
||||
setColumnWidths: Dispatch<SetStateAction<WidgetColumnWidths>>;
|
||||
// Global custom data state
|
||||
globalCustomDataMode: boolean;
|
||||
setGlobalCustomDataMode: Dispatch<SetStateAction<boolean>>;
|
||||
globalCustomXData: number;
|
||||
setGlobalCustomXData: Dispatch<SetStateAction<number>>;
|
||||
globalCustomYData: number;
|
||||
setGlobalCustomYData: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
@@ -118,6 +118,9 @@ export interface IBaseWidget {
|
||||
columnWidths?: Record<string, number>;
|
||||
legendPosition?: LegendPosition;
|
||||
customLegendColors?: Record<string, string>;
|
||||
customDataMode?: boolean;
|
||||
customXData?: number; // number of data points
|
||||
customYData?: number; // number of series
|
||||
}
|
||||
export interface Widgets extends IBaseWidget {
|
||||
query: Query;
|
||||
|
||||
235
pkg/modules/tracefunnel/impltracefunnel/handler.go
Normal file
235
pkg/modules/tracefunnel/impltracefunnel/handler.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package impltracefunnel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
tf "github.com/SigNoz/signoz/pkg/types/tracefunneltypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"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.PostableFunnel
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
funnel, err := handler.module.Create(r.Context(), req.Timestamp, req.Name, valuer.MustNewUUID(claims.UserID), valuer.MustNewUUID(claims.OrgID))
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to create funnel: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
response := tf.ConstructFunnelResponse(funnel, &claims)
|
||||
render.Success(rw, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateSteps(rw http.ResponseWriter, r *http.Request) {
|
||||
var req tf.PostableFunnel
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
updatedAt, err := tf.ValidateAndConvertTimestamp(req.Timestamp)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
funnel, err := handler.module.Get(r.Context(), req.FunnelID, valuer.MustNewUUID(claims.OrgID))
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"funnel not found: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
steps, err := tf.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, valuer.MustNewUUID(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, valuer.MustNewUUID(claims.OrgID))
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to get updated funnel: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
response := tf.ConstructFunnelResponse(updatedFunnel, &claims)
|
||||
render.Success(rw, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateFunnel(rw http.ResponseWriter, r *http.Request) {
|
||||
var req tf.PostableFunnel
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
updatedAt, err := tf.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(), valuer.MustNewUUID(funnelID), valuer.MustNewUUID(claims.OrgID))
|
||||
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, valuer.MustNewUUID(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, valuer.MustNewUUID(claims.OrgID))
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to get updated funnel: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
response := tf.ConstructFunnelResponse(updatedFunnel, &claims)
|
||||
render.Success(rw, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
funnels, err := handler.module.List(r.Context(), valuer.MustNewUUID(claims.OrgID))
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to list funnels: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
var response []tf.GettableFunnel
|
||||
for _, f := range funnels {
|
||||
response = append(response, tf.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"]
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
funnel, err := handler.module.Get(r.Context(), valuer.MustNewUUID(funnelID), valuer.MustNewUUID(claims.OrgID))
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"funnel not found: %v", err))
|
||||
return
|
||||
}
|
||||
response := tf.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"]
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := handler.module.Delete(r.Context(), valuer.MustNewUUID(funnelID), valuer.MustNewUUID(claims.OrgID)); err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to delete funnel: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, nil)
|
||||
}
|
||||
173
pkg/modules/tracefunnel/impltracefunnel/handler_test.go
Normal file
173
pkg/modules/tracefunnel/impltracefunnel/handler_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package impltracefunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunneltypes"
|
||||
"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 valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error) {
|
||||
args := m.Called(ctx, timestamp, name, userID, orgID)
|
||||
return args.Get(0).(*traceFunnels.StorableFunnel), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockModule) Get(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error) {
|
||||
args := m.Called(ctx, funnelID, orgID)
|
||||
return args.Get(0).(*traceFunnels.StorableFunnel), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockModule) Update(ctx context.Context, funnel *traceFunnels.StorableFunnel, userID valuer.UUID) error {
|
||||
args := m.Called(ctx, funnel, userID)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockModule) List(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.StorableFunnel, error) {
|
||||
args := m.Called(ctx, orgID)
|
||||
return args.Get(0).([]*traceFunnels.StorableFunnel), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockModule) Delete(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) error {
|
||||
args := m.Called(ctx, funnelID, orgID)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockModule) Save(ctx context.Context, funnel *traceFunnels.StorableFunnel, userID valuer.UUID, orgID valuer.UUID) error {
|
||||
args := m.Called(ctx, funnel, userID, orgID)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockModule) GetFunnelMetadata(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) (int64, int64, string, error) {
|
||||
args := m.Called(ctx, funnelID, orgID)
|
||||
return args.Get(0).(int64), args.Get(1).(int64), args.String(2), args.Error(3)
|
||||
}
|
||||
|
||||
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()
|
||||
claims := authtypes.Claims{
|
||||
OrgID: orgID.String(),
|
||||
}
|
||||
req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), claims))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
funnel1ID := valuer.GenerateUUID()
|
||||
funnel2ID := valuer.GenerateUUID()
|
||||
expectedFunnels := []*traceFunnels.StorableFunnel{
|
||||
{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnel1ID,
|
||||
},
|
||||
Name: "funnel-1",
|
||||
OrgID: orgID,
|
||||
},
|
||||
{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnel2ID,
|
||||
},
|
||||
Name: "funnel-2",
|
||||
OrgID: 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.GettableFunnel `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()
|
||||
orgID := 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()})
|
||||
req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), authtypes.Claims{
|
||||
OrgID: orgID.String(),
|
||||
}))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
expectedFunnel := &traceFunnels.StorableFunnel{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnelID,
|
||||
},
|
||||
Name: "test-funnel",
|
||||
OrgID: orgID,
|
||||
}
|
||||
|
||||
mockModule.On("Get", req.Context(), funnelID, orgID).Return(expectedFunnel, nil)
|
||||
|
||||
handler.Get(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Data traceFunnels.GettableFunnel `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()
|
||||
orgID := 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()})
|
||||
req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), authtypes.Claims{
|
||||
OrgID: orgID.String(),
|
||||
}))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
mockModule.On("Delete", req.Context(), funnelID, orgID).Return(nil)
|
||||
|
||||
handler.Delete(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
mockModule.AssertExpectations(t)
|
||||
}
|
||||
96
pkg/modules/tracefunnel/impltracefunnel/module.go
Normal file
96
pkg/modules/tracefunnel/impltracefunnel/module.go
Normal file
@@ -0,0 +1,96 @@
|
||||
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/tracefunneltypes"
|
||||
"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 valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error) {
|
||||
funnel := &traceFunnels.StorableFunnel{
|
||||
Name: name,
|
||||
OrgID: orgID,
|
||||
}
|
||||
funnel.CreatedAt = time.Unix(0, timestamp*1000000) // Convert to nanoseconds
|
||||
funnel.CreatedBy = userID.String()
|
||||
|
||||
// Set up the user relationship
|
||||
funnel.CreatedByUser = &types.User{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: userID,
|
||||
},
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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 valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error) {
|
||||
return module.store.Get(ctx, funnelID, orgID)
|
||||
}
|
||||
|
||||
// Update updates a funnel
|
||||
func (module *module) Update(ctx context.Context, funnel *traceFunnels.StorableFunnel, userID valuer.UUID) error {
|
||||
funnel.UpdatedBy = userID.String()
|
||||
return module.store.Update(ctx, funnel)
|
||||
}
|
||||
|
||||
// List lists all funnels for an organization
|
||||
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.StorableFunnel, error) {
|
||||
funnels, err := module.store.List(ctx, orgID)
|
||||
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 valuer.UUID, orgID valuer.UUID) error {
|
||||
return module.store.Delete(ctx, funnelID, orgID)
|
||||
}
|
||||
|
||||
// GetFunnelMetadata gets metadata for a funnel
|
||||
func (module *module) GetFunnelMetadata(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) (int64, int64, string, error) {
|
||||
funnel, err := module.store.Get(ctx, funnelID, orgID)
|
||||
if err != nil {
|
||||
return 0, 0, "", err
|
||||
}
|
||||
|
||||
return funnel.CreatedAt.UnixNano() / 1000000, funnel.UpdatedAt.UnixNano() / 1000000, funnel.Description, nil
|
||||
}
|
||||
114
pkg/modules/tracefunnel/impltracefunnel/store.go
Normal file
114
pkg/modules/tracefunnel/impltracefunnel/store.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package impltracefunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunneltypes"
|
||||
"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.StorableFunnel) error {
|
||||
// Check if a funnel with the same name already exists in the organization
|
||||
exists, err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(new(traceFunnels.StorableFunnel)).
|
||||
Where("name = ? AND org_id = ?", funnel.Name, funnel.OrgID.String()).
|
||||
Exists(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to check for existing funnelr")
|
||||
}
|
||||
if exists {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(nil, traceFunnels.ErrFunnelAlreadyExists, "a funnel with name '%s' already exists in this organization", funnel.Name)
|
||||
}
|
||||
|
||||
_, err = store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewInsert().
|
||||
Model(funnel).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to create funnels")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves a funnel by ID
|
||||
func (store *store) Get(ctx context.Context, uuid valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error) {
|
||||
funnel := &traceFunnels.StorableFunnel{}
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(funnel).
|
||||
Relation("CreatedByUser").
|
||||
Where("?TableAlias.id = ? AND ?TableAlias.org_id = ?", uuid.String(), orgID.String()).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to get funnels")
|
||||
}
|
||||
return funnel, nil
|
||||
}
|
||||
|
||||
// Update updates an existing funnel
|
||||
func (store *store) Update(ctx context.Context, funnel *traceFunnels.StorableFunnel) 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.StorableFunnel, error) {
|
||||
var funnels []*traceFunnels.StorableFunnel
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&funnels).
|
||||
Relation("CreatedByUser").
|
||||
Where("?TableAlias.org_id = ?", orgID.String()).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to list funnels")
|
||||
}
|
||||
return funnels, nil
|
||||
}
|
||||
|
||||
// Delete removes a funnel by ID
|
||||
func (store *store) Delete(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewDelete().
|
||||
Model(new(traceFunnels.StorableFunnel)).
|
||||
Where("id = ? AND org_id = ?", funnelID.String(), orgID.String()).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete funnel")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
38
pkg/modules/tracefunnel/tracefunnel.go
Normal file
38
pkg/modules/tracefunnel/tracefunnel.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package tracefunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"net/http"
|
||||
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunneltypes"
|
||||
)
|
||||
|
||||
// Module defines the interface for trace funnel operations
|
||||
type Module interface {
|
||||
Create(ctx context.Context, timestamp int64, name string, userID valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error)
|
||||
|
||||
Get(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error)
|
||||
|
||||
Update(ctx context.Context, funnel *traceFunnels.StorableFunnel, userID valuer.UUID) error
|
||||
|
||||
List(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.StorableFunnel, error)
|
||||
|
||||
Delete(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) error
|
||||
|
||||
GetFunnelMetadata(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) (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)
|
||||
}
|
||||
183
pkg/modules/tracefunnel/tracefunneltest/module_test.go
Normal file
183
pkg/modules/tracefunnel/tracefunneltest/module_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package tracefunneltest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunneltypes"
|
||||
"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.StorableFunnel) error {
|
||||
args := m.Called(ctx, funnel)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockStore) Get(ctx context.Context, uuid valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error) {
|
||||
args := m.Called(ctx, uuid, orgID)
|
||||
return args.Get(0).(*traceFunnels.StorableFunnel), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockStore) List(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.StorableFunnel, error) {
|
||||
args := m.Called(ctx, orgID)
|
||||
return args.Get(0).([]*traceFunnels.StorableFunnel), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockStore) Update(ctx context.Context, funnel *traceFunnels.StorableFunnel) error {
|
||||
args := m.Called(ctx, funnel)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockStore) Delete(ctx context.Context, uuid valuer.UUID, orgID valuer.UUID) error {
|
||||
args := m.Called(ctx, uuid, orgID)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func TestModule_Create(t *testing.T) {
|
||||
mockStore := new(MockStore)
|
||||
module := impltracefunnel.NewModule(mockStore)
|
||||
|
||||
ctx := context.Background()
|
||||
timestamp := time.Now().UnixMilli()
|
||||
name := "test-funnel"
|
||||
userID := valuer.GenerateUUID()
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
mockStore.On("Create", ctx, mock.MatchedBy(func(f *traceFunnels.StorableFunnel) bool {
|
||||
return f.Name == name &&
|
||||
f.CreatedBy == userID.String() &&
|
||||
f.OrgID == 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.String(), funnel.CreatedBy)
|
||||
assert.Equal(t, orgID, funnel.OrgID)
|
||||
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 := impltracefunnel.NewModule(mockStore)
|
||||
|
||||
ctx := context.Background()
|
||||
funnelID := valuer.GenerateUUID()
|
||||
orgID := valuer.GenerateUUID()
|
||||
expectedFunnel := &traceFunnels.StorableFunnel{
|
||||
Name: "test-funnel",
|
||||
}
|
||||
|
||||
mockStore.On("Get", ctx, funnelID, orgID).Return(expectedFunnel, nil)
|
||||
|
||||
funnel, err := module.Get(ctx, funnelID, orgID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedFunnel, funnel)
|
||||
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestModule_Update(t *testing.T) {
|
||||
mockStore := new(MockStore)
|
||||
module := impltracefunnel.NewModule(mockStore)
|
||||
|
||||
ctx := context.Background()
|
||||
userID := valuer.GenerateUUID()
|
||||
funnel := &traceFunnels.StorableFunnel{
|
||||
Name: "test-funnel",
|
||||
}
|
||||
|
||||
mockStore.On("Update", ctx, funnel).Return(nil)
|
||||
|
||||
err := module.Update(ctx, funnel, userID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, userID.String(), funnel.UpdatedBy)
|
||||
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestModule_List(t *testing.T) {
|
||||
mockStore := new(MockStore)
|
||||
module := impltracefunnel.NewModule(mockStore)
|
||||
|
||||
ctx := context.Background()
|
||||
orgID := valuer.GenerateUUID()
|
||||
expectedFunnels := []*traceFunnels.StorableFunnel{
|
||||
{
|
||||
Name: "funnel-1",
|
||||
OrgID: orgID,
|
||||
},
|
||||
{
|
||||
Name: "funnel-2",
|
||||
OrgID: orgID,
|
||||
},
|
||||
}
|
||||
|
||||
mockStore.On("List", ctx, orgID).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 := impltracefunnel.NewModule(mockStore)
|
||||
|
||||
ctx := context.Background()
|
||||
funnelID := valuer.GenerateUUID()
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
mockStore.On("Delete", ctx, funnelID, orgID).Return(nil)
|
||||
|
||||
err := module.Delete(ctx, funnelID, orgID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestModule_GetFunnelMetadata(t *testing.T) {
|
||||
mockStore := new(MockStore)
|
||||
module := impltracefunnel.NewModule(mockStore)
|
||||
|
||||
ctx := context.Background()
|
||||
funnelID := valuer.GenerateUUID()
|
||||
orgID := valuer.GenerateUUID()
|
||||
now := time.Now()
|
||||
expectedFunnel := &traceFunnels.StorableFunnel{
|
||||
Description: "test description",
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
|
||||
mockStore.On("Get", ctx, funnelID, orgID).Return(expectedFunnel, nil)
|
||||
|
||||
createdAt, updatedAt, description, err := module.GetFunnelMetadata(ctx, funnelID, orgID)
|
||||
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)
|
||||
}
|
||||
@@ -5229,3 +5229,30 @@ 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.EditAccess(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.EditAccess(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.EditAccess(aH.Signoz.Handlers.TraceFunnel.Delete)).
|
||||
Methods(http.MethodDelete)
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}",
|
||||
am.EditAccess(aH.Signoz.Handlers.TraceFunnel.UpdateFunnel)).
|
||||
Methods(http.MethodPut)
|
||||
}
|
||||
|
||||
@@ -269,6 +269,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
|
||||
api.RegisterMessagingQueuesRoutes(r, am)
|
||||
api.RegisterThirdPartyApiRoutes(r, am)
|
||||
api.MetricExplorerRoutes(r, am)
|
||||
api.RegisterTraceFunnelsRoutes(r, am)
|
||||
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
|
||||
@@ -87,7 +87,7 @@ func existsSubQueryForFixedColumn(key v3.AttributeKey, op v3.FilterOperator) (st
|
||||
}
|
||||
}
|
||||
|
||||
func buildTracesFilterQuery(fs *v3.FilterSet) (string, error) {
|
||||
func BuildTracesFilterQuery(fs *v3.FilterSet) (string, error) {
|
||||
var conditions []string
|
||||
|
||||
if fs != nil && len(fs.Items) != 0 {
|
||||
@@ -167,7 +167,7 @@ func handleEmptyValuesInGroupBy(groupBy []v3.AttributeKey) (string, error) {
|
||||
Operator: "AND",
|
||||
Items: filterItems,
|
||||
}
|
||||
return buildTracesFilterQuery(&filterSet)
|
||||
return BuildTracesFilterQuery(&filterSet)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
@@ -248,7 +248,7 @@ func buildTracesQuery(start, end, step int64, mq *v3.BuilderQuery, panelType v3.
|
||||
|
||||
timeFilter := fmt.Sprintf("(timestamp >= '%d' AND timestamp <= '%d') AND (ts_bucket_start >= %d AND ts_bucket_start <= %d)", tracesStart, tracesEnd, bucketStart, bucketEnd)
|
||||
|
||||
filterSubQuery, err := buildTracesFilterQuery(mq.Filters)
|
||||
filterSubQuery, err := BuildTracesFilterQuery(mq.Filters)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ func Test_buildTracesFilterQuery(t *testing.T) {
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "Test buildTracesFilterQuery in, nin",
|
||||
name: "Test BuildTracesFilterQuery in, nin",
|
||||
args: args{
|
||||
fs: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
|
||||
{Key: v3.AttributeKey{Key: "method", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: []interface{}{"GET", "POST"}, Operator: v3.FilterOperatorIn},
|
||||
@@ -226,7 +226,7 @@ func Test_buildTracesFilterQuery(t *testing.T) {
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Test buildTracesFilterQuery not eq, neq, gt, lt, gte, lte",
|
||||
name: "Test BuildTracesFilterQuery not eq, neq, gt, lt, gte, lte",
|
||||
args: args{
|
||||
fs: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
|
||||
{Key: v3.AttributeKey{Key: "duration", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag}, Value: 102, Operator: v3.FilterOperatorEqual},
|
||||
@@ -274,13 +274,13 @@ func Test_buildTracesFilterQuery(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := buildTracesFilterQuery(tt.args.fs)
|
||||
got, err := BuildTracesFilterQuery(tt.args.fs)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("buildTracesFilterQuery() error = %v, wantErr %v", err, tt.wantErr)
|
||||
t.Errorf("BuildTracesFilterQuery() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("buildTracesFilterQuery() = %v, want %v", got, tt.want)
|
||||
t.Errorf("BuildTracesFilterQuery() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter/implquickfilter"
|
||||
"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"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
)
|
||||
@@ -25,6 +27,7 @@ type Handlers struct {
|
||||
Apdex apdex.Handler
|
||||
Dashboard dashboard.Handler
|
||||
QuickFilter quickfilter.Handler
|
||||
TraceFunnel tracefunnel.Handler
|
||||
}
|
||||
|
||||
func NewHandlers(modules Modules) Handlers {
|
||||
@@ -36,5 +39,6 @@ func NewHandlers(modules Modules) Handlers {
|
||||
Apdex: implapdex.NewHandler(modules.Apdex),
|
||||
Dashboard: impldashboard.NewHandler(modules.Dashboard),
|
||||
QuickFilter: implquickfilter.NewHandler(modules.QuickFilter),
|
||||
TraceFunnel: impltracefunnel.NewHandler(modules.TraceFunnel),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter/implquickfilter"
|
||||
"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"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
@@ -32,6 +34,7 @@ type Modules struct {
|
||||
Apdex apdex.Module
|
||||
Dashboard dashboard.Module
|
||||
QuickFilter quickfilter.Module
|
||||
TraceFunnel tracefunnel.Module
|
||||
}
|
||||
|
||||
func NewModules(
|
||||
@@ -54,5 +57,6 @@ func NewModules(
|
||||
Dashboard: impldashboard.NewModule(sqlstore),
|
||||
User: user,
|
||||
QuickFilter: quickfilter,
|
||||
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ func NewSQLMigrationProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedM
|
||||
sqlmigration.NewMigratePATToFactorAPIKey(sqlstore),
|
||||
sqlmigration.NewUpdateApiMonitoringFiltersFactory(sqlstore),
|
||||
sqlmigration.NewAddKeyOrganizationFactory(sqlstore),
|
||||
sqlmigration.NewAddTraceFunnelsFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
89
pkg/sqlmigration/037_add_trace_funnels.go
Normal file
89
pkg/sqlmigration/037_add_trace_funnels.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
// funnel Core Data Structure (funnel and funnelStep)
|
||||
type funnel struct {
|
||||
bun.BaseModel `bun:"table:trace_funnel"`
|
||||
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"`
|
||||
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 {
|
||||
types.Identifiable
|
||||
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 string `json:"filters,omitempty"`
|
||||
LatencyPointer string `json:"latency_pointer,omitempty"`
|
||||
LatencyType string `json:"latency_type,omitempty"`
|
||||
HasErrors bool `json:"has_errors"`
|
||||
}
|
||||
|
||||
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 func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
_, err = tx.NewCreateTable().
|
||||
Model(new(funnel)).
|
||||
ForeignKey(`("org_id") REFERENCES "organizations" ("id") ON DELETE CASCADE`).
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *addTraceFunnels) Down(ctx context.Context, db *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
15
pkg/types/tracefunneltypes/store.go
Normal file
15
pkg/types/tracefunneltypes/store.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package tracefunneltypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type FunnelStore interface {
|
||||
Create(context.Context, *StorableFunnel) error
|
||||
Get(context.Context, valuer.UUID, valuer.UUID) (*StorableFunnel, error)
|
||||
List(context.Context, valuer.UUID) ([]*StorableFunnel, error)
|
||||
Update(context.Context, *StorableFunnel) error
|
||||
Delete(context.Context, valuer.UUID, valuer.UUID) error
|
||||
}
|
||||
98
pkg/types/tracefunneltypes/tracefunnel.go
Normal file
98
pkg/types/tracefunneltypes/tracefunnel.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package tracefunneltypes
|
||||
|
||||
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")
|
||||
)
|
||||
|
||||
// StorableFunnel Core Data Structure (StorableFunnel and FunnelStep)
|
||||
type StorableFunnel struct {
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
bun.BaseModel `bun:"table:trace_funnel"`
|
||||
Name string `json:"funnel_name" bun:"name,type:text,notnull"`
|
||||
Description string `json:"description" bun:"description,type:text"`
|
||||
OrgID valuer.UUID `json:"org_id" bun:"org_id,type:varchar,notnull"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// PostableFunnel represents all possible funnel-related requests
|
||||
type PostableFunnel 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"`
|
||||
}
|
||||
|
||||
// GettableFunnel represents all possible funnel-related responses
|
||||
type GettableFunnel 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 *StorableFunnel `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
|
||||
StepStart int64 `json:"step_start,omitempty"`
|
||||
StepEnd 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
|
||||
}
|
||||
139
pkg/types/tracefunneltypes/utils.go
Normal file
139
pkg/types/tracefunneltypes/utils.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package tracefunneltypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"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 []*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.
|
||||
// The function takes a slice of pointers to FunnelStep and returns a new slice with normalized step orders.
|
||||
// The input slice is left unchanged. If the input slice contains nil pointers, they will be filtered out.
|
||||
// Returns an empty slice if the input is empty or contains only nil pointers.
|
||||
func NormalizeFunnelSteps(steps []*FunnelStep) []*FunnelStep {
|
||||
if len(steps) == 0 {
|
||||
return []*FunnelStep{}
|
||||
}
|
||||
|
||||
// Filter out nil pointers and create a new slice
|
||||
validSteps := make([]*FunnelStep, 0, len(steps))
|
||||
for _, step := range steps {
|
||||
if step != nil {
|
||||
validSteps = append(validSteps, step)
|
||||
}
|
||||
}
|
||||
|
||||
if len(validSteps) == 0 {
|
||||
return []*FunnelStep{}
|
||||
}
|
||||
|
||||
// Create a defensive copy of the valid steps
|
||||
newSteps := make([]*FunnelStep, len(validSteps))
|
||||
for i, step := range validSteps {
|
||||
// Create a copy of each step to avoid modifying the original
|
||||
stepCopy := *step
|
||||
newSteps[i] = &stepCopy
|
||||
}
|
||||
|
||||
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 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 *StorableFunnel, claims *authtypes.Claims) GettableFunnel {
|
||||
resp := GettableFunnel{
|
||||
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 []*FunnelStep) ([]*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
|
||||
}
|
||||
698
pkg/types/tracefunneltypes/utils_test.go
Normal file
698
pkg/types/tracefunneltypes/utils_test.go
Normal file
@@ -0,0 +1,698 @@
|
||||
package tracefunneltypes
|
||||
|
||||
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/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 []*FunnelStep
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid steps",
|
||||
steps: []*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: []*FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "missing service name",
|
||||
steps: []*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: []*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: []*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 []*FunnelStep
|
||||
expected []*FunnelStep
|
||||
}{
|
||||
{
|
||||
name: "already normalized steps",
|
||||
steps: []*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: []*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: []*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: []*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: []*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: []*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,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "steps with nil pointers",
|
||||
steps: []*FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
nil,
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
expected: []*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: "empty steps",
|
||||
steps: []*FunnelStep{},
|
||||
expected: []*FunnelStep{},
|
||||
},
|
||||
{
|
||||
name: "all nil steps",
|
||||
steps: []*FunnelStep{nil, nil},
|
||||
expected: []*FunnelStep{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := NormalizeFunnelSteps(tt.steps)
|
||||
|
||||
// Compare only the relevant fields
|
||||
assert.Len(t, result, len(tt.expected))
|
||||
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) {
|
||||
claims := authtypes.Claims{}
|
||||
*r = *r.WithContext(authtypes.NewContextWithClaims(r.Context(), claims))
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
tt.setup(req)
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if tt.expectError {
|
||||
assert.Equal(t, authtypes.Claims{}, 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()
|
||||
userID := valuer.GenerateUUID()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
funnel *StorableFunnel
|
||||
claims *authtypes.Claims
|
||||
expected GettableFunnel
|
||||
}{
|
||||
{
|
||||
name: "with user email from funnel",
|
||||
funnel: &StorableFunnel{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnelID,
|
||||
},
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
UserAuditable: types.UserAuditable{
|
||||
CreatedBy: userID.String(),
|
||||
UpdatedBy: userID.String(),
|
||||
},
|
||||
Name: "test-funnel",
|
||||
OrgID: orgID,
|
||||
CreatedByUser: &types.User{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: userID,
|
||||
},
|
||||
Email: "funnel@example.com",
|
||||
},
|
||||
Steps: []*FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
claims: &authtypes.Claims{
|
||||
UserID: userID.String(),
|
||||
OrgID: orgID.String(),
|
||||
Email: "claims@example.com",
|
||||
},
|
||||
expected: GettableFunnel{
|
||||
FunnelName: "test-funnel",
|
||||
FunnelID: funnelID.String(),
|
||||
Steps: []*FunnelStep{
|
||||
{
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
},
|
||||
CreatedAt: now.UnixNano() / 1000000,
|
||||
CreatedBy: userID.String(),
|
||||
UpdatedAt: now.UnixNano() / 1000000,
|
||||
UpdatedBy: userID.String(),
|
||||
OrgID: orgID.String(),
|
||||
UserEmail: "funnel@example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with user email from claims",
|
||||
funnel: &StorableFunnel{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnelID,
|
||||
},
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
UserAuditable: types.UserAuditable{
|
||||
CreatedBy: userID.String(),
|
||||
UpdatedBy: userID.String(),
|
||||
},
|
||||
Name: "test-funnel",
|
||||
OrgID: orgID,
|
||||
Steps: []*FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
claims: &authtypes.Claims{
|
||||
UserID: userID.String(),
|
||||
OrgID: orgID.String(),
|
||||
Email: "claims@example.com",
|
||||
},
|
||||
expected: GettableFunnel{
|
||||
FunnelName: "test-funnel",
|
||||
FunnelID: funnelID.String(),
|
||||
Steps: []*FunnelStep{
|
||||
{
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
},
|
||||
CreatedAt: now.UnixNano() / 1000000,
|
||||
CreatedBy: userID.String(),
|
||||
UpdatedAt: now.UnixNano() / 1000000,
|
||||
UpdatedBy: userID.String(),
|
||||
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 []*FunnelStep
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid steps with missing IDs",
|
||||
steps: []*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: []*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: []*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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user