Compare commits
7 Commits
main
...
launch-wee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05369a2bfa | ||
|
|
1af0739070 | ||
|
|
285f3eec72 | ||
|
|
652c7a78a2 | ||
|
|
cd98295ccf | ||
|
|
b230687134 | ||
|
|
c5d7623a98 |
@@ -311,6 +311,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
apiHandler.RegisterMessagingQueuesRoutes(r, am)
|
||||
apiHandler.RegisterThirdPartyApiRoutes(r, am)
|
||||
apiHandler.MetricExplorerRoutes(r, am)
|
||||
apiHandler.RegisterTraceFunnelsRoutes(r, am)
|
||||
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
|
||||
@@ -62,13 +62,6 @@ var BasicPlan = basemodel.FeatureSet{
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
basemodel.Feature{
|
||||
Name: basemodel.TraceFunnels,
|
||||
Active: false,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
}
|
||||
|
||||
var EnterprisePlan = basemodel.FeatureSet{
|
||||
@@ -121,11 +114,4 @@ var EnterprisePlan = basemodel.FeatureSet{
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
basemodel.Feature{
|
||||
Name: basemodel.TraceFunnels,
|
||||
Active: false,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export const createFunnel = async (
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Funnel created successfully',
|
||||
payload: response.data,
|
||||
payload: response.data.data,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -196,7 +196,9 @@ export interface FunnelOverviewResponse {
|
||||
avg_rate: number;
|
||||
conversion_rate: number | null;
|
||||
errors: number;
|
||||
// TODO(shaheer): remove p99_latency once we have support for latency
|
||||
p99_latency: number;
|
||||
latency: number;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
@@ -222,13 +224,6 @@ export const getFunnelOverview = async (
|
||||
};
|
||||
};
|
||||
|
||||
export interface SlowTracesPayload {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
step_a_order: number;
|
||||
step_b_order: number;
|
||||
}
|
||||
|
||||
export interface SlowTraceData {
|
||||
status: string;
|
||||
data: Array<{
|
||||
@@ -243,7 +238,7 @@ export interface SlowTraceData {
|
||||
|
||||
export const getFunnelSlowTraces = async (
|
||||
funnelId: string,
|
||||
payload: SlowTracesPayload,
|
||||
payload: FunnelOverviewPayload,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SuccessResponse<SlowTraceData> | ErrorResponse> => {
|
||||
const response = await axios.post(
|
||||
@@ -261,12 +256,6 @@ export const getFunnelSlowTraces = async (
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
export interface ErrorTracesPayload {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
step_a_order: number;
|
||||
step_b_order: number;
|
||||
}
|
||||
|
||||
export interface ErrorTraceData {
|
||||
status: string;
|
||||
@@ -282,7 +271,7 @@ export interface ErrorTraceData {
|
||||
|
||||
export const getFunnelErrorTraces = async (
|
||||
funnelId: string,
|
||||
payload: ErrorTracesPayload,
|
||||
payload: FunnelOverviewPayload,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SuccessResponse<ErrorTraceData> | ErrorResponse> => {
|
||||
const response: AxiosResponse = await axios.post(
|
||||
@@ -337,3 +326,37 @@ export const getFunnelSteps = async (
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
export interface FunnelStepsOverviewPayload {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
step_start?: number;
|
||||
step_end?: number;
|
||||
}
|
||||
|
||||
export interface FunnelStepsOverviewResponse {
|
||||
status: string;
|
||||
data: Array<{
|
||||
timestamp: string;
|
||||
data: Record<string, number>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const getFunnelStepsOverview = async (
|
||||
funnelId: string,
|
||||
payload: FunnelStepsOverviewPayload,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SuccessResponse<FunnelStepsOverviewResponse> | ErrorResponse> => {
|
||||
const response = await axios.post(
|
||||
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/steps/overview`,
|
||||
payload,
|
||||
{ signal },
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -28,4 +28,5 @@ export enum LOCALSTORAGE {
|
||||
DONT_SHOW_SLOW_API_WARNING = 'DONT_SHOW_SLOW_API_WARNING',
|
||||
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
|
||||
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
|
||||
UNEXECUTED_FUNNELS = 'UNEXECUTED_FUNNELS',
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export const REACT_QUERY_KEY = {
|
||||
VALIDATE_FUNNEL_STEPS: 'VALIDATE_FUNNEL_STEPS',
|
||||
UPDATE_FUNNEL_STEP_DETAILS: 'UPDATE_FUNNEL_STEP_DETAILS',
|
||||
GET_FUNNEL_OVERVIEW: 'GET_FUNNEL_OVERVIEW',
|
||||
GET_FUNNEL_STEPS_OVERVIEW: 'GET_FUNNEL_STEPS_OVERVIEW',
|
||||
GET_FUNNEL_SLOW_TRACES: 'GET_FUNNEL_SLOW_TRACES',
|
||||
GET_FUNNEL_ERROR_TRACES: 'GET_FUNNEL_ERROR_TRACES',
|
||||
GET_FUNNEL_STEPS_GRAPH_DATA: 'GET_FUNNEL_STEPS_GRAPH_DATA',
|
||||
|
||||
@@ -149,30 +149,28 @@ function SpanOverview({
|
||||
<Typography.Text className="service-name">
|
||||
{span.serviceName}
|
||||
</Typography.Text>
|
||||
{!!span.serviceName &&
|
||||
!!span.name &&
|
||||
process.env.NODE_ENV === 'development' && (
|
||||
<div className="add-funnel-button">
|
||||
<span className="add-funnel-button__separator">·</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="add-funnel-button__button"
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleAddSpanToFunnel(span);
|
||||
}}
|
||||
icon={
|
||||
<img
|
||||
className="add-funnel-button__icon"
|
||||
src="/Icons/funnel-add.svg"
|
||||
alt="funnel-icon"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!!span.serviceName && !!span.name && (
|
||||
<div className="add-funnel-button">
|
||||
<span className="add-funnel-button__separator">·</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="add-funnel-button__button"
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleAddSpanToFunnel(span);
|
||||
}}
|
||||
icon={
|
||||
<img
|
||||
className="add-funnel-button__icon"
|
||||
src="/Icons/funnel-add.svg"
|
||||
alt="funnel-icon"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -450,7 +448,7 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
virtualiserRef={virtualizerRef}
|
||||
setColumnWidths={setTraceFlamegraphStatsWidth}
|
||||
/>
|
||||
{selectedSpanToAddToFunnel && process.env.NODE_ENV === 'development' && (
|
||||
{selectedSpanToAddToFunnel && (
|
||||
<AddSpanToFunnelModal
|
||||
span={selectedSpanToAddToFunnel}
|
||||
isOpen={isAddSpanToFunnelModalOpen}
|
||||
|
||||
@@ -43,8 +43,7 @@ export default function useFunnelConfiguration({
|
||||
const {
|
||||
steps,
|
||||
initialSteps,
|
||||
setHasIncompleteStepFields,
|
||||
setHasAllEmptyStepFields,
|
||||
hasIncompleteStepFields,
|
||||
handleRestoreSteps,
|
||||
} = useFunnelContext();
|
||||
|
||||
@@ -74,14 +73,16 @@ export default function useFunnelConfiguration({
|
||||
return !isEqual(normalizedDebouncedSteps, normalizedLastSavedSteps);
|
||||
}, [debouncedSteps]);
|
||||
|
||||
const hasStepServiceOrSpanNameChanged = useCallback(
|
||||
const hasFunnelStepDefinitionsChanged = useCallback(
|
||||
(prevSteps: FunnelStepData[], nextSteps: FunnelStepData[]): boolean => {
|
||||
if (prevSteps.length !== nextSteps.length) return true;
|
||||
return prevSteps.some((step, index) => {
|
||||
const nextStep = nextSteps[index];
|
||||
return (
|
||||
step.service_name !== nextStep.service_name ||
|
||||
step.span_name !== nextStep.span_name
|
||||
step.span_name !== nextStep.span_name ||
|
||||
!isEqual(step.filters, nextStep.filters) ||
|
||||
step.has_errors !== nextStep.has_errors
|
||||
);
|
||||
});
|
||||
},
|
||||
@@ -106,12 +107,7 @@ export default function useFunnelConfiguration({
|
||||
[funnel.funnel_id, selectedTime],
|
||||
);
|
||||
useEffect(() => {
|
||||
// Check if all steps have both service_name and span_name defined
|
||||
const shouldUpdate = debouncedSteps.every(
|
||||
(step) => step.service_name !== '' && step.span_name !== '',
|
||||
);
|
||||
|
||||
if (hasStepsChanged() && shouldUpdate) {
|
||||
if (hasStepsChanged() && !hasIncompleteStepFields) {
|
||||
updateStepsMutation.mutate(getUpdatePayload(), {
|
||||
onSuccess: (data) => {
|
||||
const updatedFunnelSteps = data?.payload?.steps;
|
||||
@@ -135,17 +131,10 @@ export default function useFunnelConfiguration({
|
||||
(step) => step.service_name === '' || step.span_name === '',
|
||||
);
|
||||
|
||||
const hasAllEmptyStepsData = updatedFunnelSteps.every(
|
||||
(step) => step.service_name === '' && step.span_name === '',
|
||||
);
|
||||
|
||||
setHasIncompleteStepFields(hasIncompleteStepFields);
|
||||
setHasAllEmptyStepFields(hasAllEmptyStepsData);
|
||||
|
||||
// Only validate if service_name or span_name changed
|
||||
if (
|
||||
!hasIncompleteStepFields &&
|
||||
hasStepServiceOrSpanNameChanged(lastValidatedSteps, debouncedSteps)
|
||||
hasFunnelStepDefinitionsChanged(lastValidatedSteps, debouncedSteps)
|
||||
) {
|
||||
queryClient.refetchQueries(validateStepsQueryKey);
|
||||
setLastValidatedSteps(debouncedSteps);
|
||||
@@ -171,7 +160,7 @@ export default function useFunnelConfiguration({
|
||||
}, [
|
||||
debouncedSteps,
|
||||
getUpdatePayload,
|
||||
hasStepServiceOrSpanNameChanged,
|
||||
hasFunnelStepDefinitionsChanged,
|
||||
hasStepsChanged,
|
||||
lastValidatedSteps,
|
||||
queryClient,
|
||||
|
||||
@@ -2,8 +2,9 @@ import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { MetricItem } from 'pages/TracesFunnelDetails/components/FunnelResults/FunnelMetricsTable';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useMemo } from 'react';
|
||||
import { LatencyOptions } from 'types/api/traceFunnels';
|
||||
|
||||
import { useFunnelOverview } from './useFunnels';
|
||||
import { useFunnelOverview, useFunnelStepsOverview } from './useFunnels';
|
||||
|
||||
interface FunnelMetricsParams {
|
||||
funnelId: string;
|
||||
@@ -13,8 +14,6 @@ interface FunnelMetricsParams {
|
||||
|
||||
export function useFunnelMetrics({
|
||||
funnelId,
|
||||
stepStart,
|
||||
stepEnd,
|
||||
}: FunnelMetricsParams): {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
@@ -25,8 +24,6 @@ export function useFunnelMetrics({
|
||||
const payload = {
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
...(stepStart !== undefined && { step_start: stepStart }),
|
||||
...(stepEnd !== undefined && { step_end: stepEnd }),
|
||||
};
|
||||
|
||||
const {
|
||||
@@ -48,14 +45,18 @@ export function useFunnelMetrics({
|
||||
{ title: 'Errors', value: sourceData.errors },
|
||||
{
|
||||
title: 'Avg. Duration',
|
||||
value: getYAxisFormattedValue(sourceData.avg_duration.toString(), 'ns'),
|
||||
value: getYAxisFormattedValue(sourceData.avg_duration.toString(), 'ms'),
|
||||
},
|
||||
{
|
||||
title: 'P99 Latency',
|
||||
value: getYAxisFormattedValue(sourceData.p99_latency.toString(), 'ns'),
|
||||
title: `P99 Latency`,
|
||||
value: getYAxisFormattedValue(
|
||||
// TODO(shaheer): remove p99_latency once we have support for latency
|
||||
(sourceData.latency ?? sourceData.p99_latency).toString(),
|
||||
'ms',
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [overviewData]);
|
||||
}, [overviewData?.payload?.data]);
|
||||
|
||||
const conversionRate =
|
||||
overviewData?.payload?.data?.[0]?.data?.conversion_rate ?? 0;
|
||||
@@ -67,3 +68,72 @@ export function useFunnelMetrics({
|
||||
conversionRate,
|
||||
};
|
||||
}
|
||||
export function useFunnelStepsMetrics({
|
||||
funnelId,
|
||||
stepStart,
|
||||
stepEnd,
|
||||
}: FunnelMetricsParams): {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
metricsData: MetricItem[];
|
||||
conversionRate: number;
|
||||
} {
|
||||
const { startTime, endTime, steps } = useFunnelContext();
|
||||
|
||||
const payload = {
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
step_start: stepStart,
|
||||
step_end: stepEnd,
|
||||
};
|
||||
|
||||
const {
|
||||
data: stepsOverviewData,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
} = useFunnelStepsOverview(funnelId, payload);
|
||||
|
||||
const latencyType = useMemo(
|
||||
() => (stepStart ? steps[stepStart]?.latency_type : LatencyOptions.P99),
|
||||
[stepStart, steps],
|
||||
);
|
||||
|
||||
const metricsData = useMemo(() => {
|
||||
const sourceData = stepsOverviewData?.payload?.data?.[0]?.data;
|
||||
if (!sourceData) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
title: 'Avg. Rate',
|
||||
value: `${Number(sourceData.avg_rate.toFixed(2))} req/s`,
|
||||
},
|
||||
{ title: 'Errors', value: sourceData.errors },
|
||||
{
|
||||
title: 'Avg. Duration',
|
||||
value: getYAxisFormattedValue(
|
||||
(sourceData.avg_duration * 1_000_000).toString(),
|
||||
'ns',
|
||||
),
|
||||
},
|
||||
{
|
||||
title: `${latencyType?.toUpperCase()} Latency`,
|
||||
value: getYAxisFormattedValue(
|
||||
// TODO(shaheer): remove p99_latency once we have support for latency
|
||||
((sourceData.latency ?? sourceData.p99_latency) * 1_000_000).toString(),
|
||||
'ns',
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [stepsOverviewData, latencyType]);
|
||||
|
||||
const conversionRate =
|
||||
stepsOverviewData?.payload?.data?.[0]?.data?.conversion_rate ?? 0;
|
||||
|
||||
return {
|
||||
isLoading: isLoading || isFetching,
|
||||
isError,
|
||||
metricsData,
|
||||
conversionRate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import {
|
||||
createFunnel,
|
||||
deleteFunnel,
|
||||
ErrorTraceData,
|
||||
ErrorTracesPayload,
|
||||
FunnelOverviewPayload,
|
||||
FunnelOverviewResponse,
|
||||
FunnelStepsOverviewPayload,
|
||||
FunnelStepsOverviewResponse,
|
||||
FunnelStepsResponse,
|
||||
getFunnelById,
|
||||
getFunnelErrorTraces,
|
||||
@@ -13,11 +14,11 @@ import {
|
||||
getFunnelsList,
|
||||
getFunnelSlowTraces,
|
||||
getFunnelSteps,
|
||||
getFunnelStepsOverview,
|
||||
renameFunnel,
|
||||
RenameFunnelPayload,
|
||||
saveFunnelDescription,
|
||||
SlowTraceData,
|
||||
SlowTracesPayload,
|
||||
updateFunnelSteps,
|
||||
UpdateFunnelStepsPayload,
|
||||
ValidateFunnelResponse,
|
||||
@@ -115,11 +116,13 @@ export const useValidateFunnelSteps = ({
|
||||
selectedTime,
|
||||
startTime,
|
||||
endTime,
|
||||
enabled,
|
||||
}: {
|
||||
funnelId: string;
|
||||
selectedTime: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
enabled: boolean;
|
||||
}): UseQueryResult<
|
||||
SuccessResponse<ValidateFunnelResponse> | ErrorResponse,
|
||||
Error
|
||||
@@ -132,8 +135,8 @@ export const useValidateFunnelSteps = ({
|
||||
signal,
|
||||
),
|
||||
queryKey: [REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS, funnelId, selectedTime],
|
||||
enabled: !!funnelId && !!selectedTime && !!startTime && !!endTime,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
enabled,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
interface SaveFunnelDescriptionPayload {
|
||||
@@ -157,7 +160,11 @@ export const useFunnelOverview = (
|
||||
SuccessResponse<FunnelOverviewResponse> | ErrorResponse,
|
||||
Error
|
||||
> => {
|
||||
const { selectedTime, validTracesCount } = useFunnelContext();
|
||||
const {
|
||||
selectedTime,
|
||||
validTracesCount,
|
||||
hasFunnelBeenExecuted,
|
||||
} = useFunnelContext();
|
||||
return useQuery({
|
||||
queryFn: ({ signal }) => getFunnelOverview(funnelId, payload, signal),
|
||||
queryKey: [
|
||||
@@ -167,31 +174,51 @@ export const useFunnelOverview = (
|
||||
payload.step_start ?? '',
|
||||
payload.step_end ?? '',
|
||||
],
|
||||
enabled: !!funnelId && validTracesCount > 0,
|
||||
enabled: !!funnelId && validTracesCount > 0 && hasFunnelBeenExecuted,
|
||||
});
|
||||
};
|
||||
|
||||
export const useFunnelSlowTraces = (
|
||||
funnelId: string,
|
||||
payload: SlowTracesPayload,
|
||||
payload: FunnelOverviewPayload,
|
||||
): UseQueryResult<SuccessResponse<SlowTraceData> | ErrorResponse, Error> => {
|
||||
const { selectedTime, validTracesCount } = useFunnelContext();
|
||||
const {
|
||||
selectedTime,
|
||||
validTracesCount,
|
||||
hasFunnelBeenExecuted,
|
||||
} = useFunnelContext();
|
||||
return useQuery<SuccessResponse<SlowTraceData> | ErrorResponse, Error>({
|
||||
queryFn: ({ signal }) => getFunnelSlowTraces(funnelId, payload, signal),
|
||||
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_SLOW_TRACES, funnelId, selectedTime],
|
||||
enabled: !!funnelId && validTracesCount > 0,
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_FUNNEL_SLOW_TRACES,
|
||||
funnelId,
|
||||
selectedTime,
|
||||
payload.step_start ?? '',
|
||||
payload.step_end ?? '',
|
||||
],
|
||||
enabled: !!funnelId && validTracesCount > 0 && hasFunnelBeenExecuted,
|
||||
});
|
||||
};
|
||||
|
||||
export const useFunnelErrorTraces = (
|
||||
funnelId: string,
|
||||
payload: ErrorTracesPayload,
|
||||
payload: FunnelOverviewPayload,
|
||||
): UseQueryResult<SuccessResponse<ErrorTraceData> | ErrorResponse, Error> => {
|
||||
const { selectedTime, validTracesCount } = useFunnelContext();
|
||||
const {
|
||||
selectedTime,
|
||||
validTracesCount,
|
||||
hasFunnelBeenExecuted,
|
||||
} = useFunnelContext();
|
||||
return useQuery({
|
||||
queryFn: ({ signal }) => getFunnelErrorTraces(funnelId, payload, signal),
|
||||
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_ERROR_TRACES, funnelId, selectedTime],
|
||||
enabled: !!funnelId && validTracesCount > 0,
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_FUNNEL_ERROR_TRACES,
|
||||
funnelId,
|
||||
selectedTime,
|
||||
payload.step_start ?? '',
|
||||
payload.step_end ?? '',
|
||||
],
|
||||
enabled: !!funnelId && validTracesCount > 0 && hasFunnelBeenExecuted,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -203,6 +230,7 @@ export function useFunnelStepsGraphData(
|
||||
endTime,
|
||||
selectedTime,
|
||||
validTracesCount,
|
||||
hasFunnelBeenExecuted,
|
||||
} = useFunnelContext();
|
||||
|
||||
return useQuery({
|
||||
@@ -217,6 +245,31 @@ export function useFunnelStepsGraphData(
|
||||
funnelId,
|
||||
selectedTime,
|
||||
],
|
||||
enabled: !!funnelId && validTracesCount > 0,
|
||||
enabled: !!funnelId && validTracesCount > 0 && hasFunnelBeenExecuted,
|
||||
});
|
||||
}
|
||||
|
||||
export const useFunnelStepsOverview = (
|
||||
funnelId: string,
|
||||
payload: FunnelStepsOverviewPayload,
|
||||
): UseQueryResult<
|
||||
SuccessResponse<FunnelStepsOverviewResponse> | ErrorResponse,
|
||||
Error
|
||||
> => {
|
||||
const {
|
||||
selectedTime,
|
||||
validTracesCount,
|
||||
hasFunnelBeenExecuted,
|
||||
} = useFunnelContext();
|
||||
return useQuery({
|
||||
queryFn: ({ signal }) => getFunnelStepsOverview(funnelId, payload, signal),
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_FUNNEL_STEPS_OVERVIEW,
|
||||
funnelId,
|
||||
selectedTime,
|
||||
payload.step_start ?? '',
|
||||
payload.step_end ?? '',
|
||||
],
|
||||
enabled: !!funnelId && validTracesCount > 0 && hasFunnelBeenExecuted,
|
||||
});
|
||||
};
|
||||
|
||||
83
frontend/src/hooks/useLocalStorage.ts
Normal file
83
frontend/src/hooks/useLocalStorage.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* A React hook for interacting with localStorage.
|
||||
* It allows getting, setting, and removing items from localStorage.
|
||||
*
|
||||
* @template T The type of the value to be stored.
|
||||
* @param {string} key The localStorage key.
|
||||
* @param {T | (() => T)} initialValue The initial value to use if no value is found in localStorage,
|
||||
* @returns {[T, (value: T | ((prevState: T) => T)) => void, () => void]}
|
||||
* A tuple containing:
|
||||
* - The current value from state (and localStorage).
|
||||
* - A function to set the value (updates state and localStorage).
|
||||
* - A function to remove the value from localStorage and reset state to initialValue.
|
||||
*/
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T | (() => T),
|
||||
): [T, (value: T | ((prevState: T) => T)) => void, () => void] {
|
||||
// This function resolves the initialValue if it's a function,
|
||||
// and handles potential errors during localStorage access or JSON parsing.
|
||||
const readValueFromStorage = useCallback((): T => {
|
||||
const resolvedInitialValue =
|
||||
initialValue instanceof Function ? initialValue() : initialValue;
|
||||
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
// If item exists, parse it, otherwise return the resolved initial value.
|
||||
if (item) {
|
||||
return JSON.parse(item) as T;
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error and fall back to initial value if reading/parsing fails.
|
||||
console.warn(`Error reading localStorage key "${key}":`, error);
|
||||
}
|
||||
return resolvedInitialValue;
|
||||
}, [key, initialValue]);
|
||||
|
||||
// Initialize state by reading from localStorage.
|
||||
const [storedValue, setStoredValue] = useState<T>(readValueFromStorage);
|
||||
|
||||
// This function updates both localStorage and the React state.
|
||||
const setValue = useCallback(
|
||||
(value: T | ((prevState: T) => T)) => {
|
||||
try {
|
||||
// If a function is passed to setValue, it receives the latest value from storage.
|
||||
const latestValueFromStorage = readValueFromStorage();
|
||||
const valueToStore =
|
||||
value instanceof Function ? value(latestValueFromStorage) : value;
|
||||
|
||||
// Save to localStorage.
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
// Update React state.
|
||||
setStoredValue(valueToStore);
|
||||
} catch (error) {
|
||||
console.warn(`Error setting localStorage key "${key}":`, error);
|
||||
}
|
||||
},
|
||||
[key, readValueFromStorage],
|
||||
);
|
||||
|
||||
// This function removes the item from localStorage and resets the React state.
|
||||
const removeValue = useCallback(() => {
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
// Reset state to the (potentially resolved) initialValue.
|
||||
setStoredValue(
|
||||
initialValue instanceof Function ? initialValue() : initialValue,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(`Error removing localStorage key "${key}":`, error);
|
||||
}
|
||||
}, [key, initialValue]);
|
||||
|
||||
// useEffect to update the storedValue if the key changes,
|
||||
// or if the initialValue prop changes causing readValueFromStorage to change.
|
||||
// This ensures the hook reflects the correct localStorage item if its key prop dynamically changes.
|
||||
useEffect(() => {
|
||||
setStoredValue(readValueFromStorage());
|
||||
}, [key, readValueFromStorage]); // Re-run if key or the read function changes.
|
||||
|
||||
return [storedValue, setValue, removeValue];
|
||||
}
|
||||
@@ -67,19 +67,15 @@ export default function TraceDetailsPage(): JSX.Element {
|
||||
key: 'trace-details',
|
||||
children: <TraceDetailsV2 />,
|
||||
},
|
||||
...(process.env.NODE_ENV === 'development'
|
||||
? [
|
||||
{
|
||||
label: (
|
||||
<div className="tab-item">
|
||||
<Cone className="funnel-icon" size={16} /> Funnels
|
||||
</div>
|
||||
),
|
||||
key: 'funnels',
|
||||
children: <div />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: (
|
||||
<div className="tab-item">
|
||||
<Cone className="funnel-icon" size={16} /> Funnels
|
||||
</div>
|
||||
),
|
||||
key: 'funnels',
|
||||
children: <div />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="tab-item">
|
||||
|
||||
@@ -39,7 +39,7 @@ function AddFunnelStepDetailsModal({
|
||||
setStepName(stepData?.name || '');
|
||||
setDescription(stepData?.description || '');
|
||||
}
|
||||
}, [isOpen, stepData]);
|
||||
}, [isOpen, stepData?.name, stepData?.description]);
|
||||
|
||||
const handleCancel = (): void => {
|
||||
setStepName('');
|
||||
|
||||
@@ -26,7 +26,7 @@ function InterStepConfig({
|
||||
</div>
|
||||
<div className="inter-step-config__latency-options">
|
||||
<SignozRadioGroup
|
||||
value={step.latency_type}
|
||||
value={step.latency_type ?? LatencyOptions.P99}
|
||||
options={options}
|
||||
onChange={(e): void =>
|
||||
onStepChange(index, {
|
||||
|
||||
@@ -65,7 +65,8 @@ function StepsContent({
|
||||
</div>
|
||||
{/* Display InterStepConfig only between steps */}
|
||||
{index < steps.length - 1 && (
|
||||
<InterStepConfig index={index} step={step} />
|
||||
// the latency type should be sent with the n+1th step
|
||||
<InterStepConfig index={index + 1} step={steps[index + 1]} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -34,12 +34,16 @@
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0 !important;
|
||||
}
|
||||
&--save {
|
||||
background-color: var(--bg-slate-400);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 10px; /* 83.333% */
|
||||
letter-spacing: 0.12px;
|
||||
border-radius: 2px;
|
||||
|
||||
&--sync {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
&--run {
|
||||
background-color: var(--bg-robin-500);
|
||||
|
||||
@@ -1,8 +1,49 @@
|
||||
import './StepsFooter.styles.scss';
|
||||
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Cone, Play } from 'lucide-react';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { Cone, Play, RefreshCcw } from 'lucide-react';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useMemo } from 'react';
|
||||
import { useIsFetching } from 'react-query';
|
||||
|
||||
const useFunnelResultsLoading = (): boolean => {
|
||||
const { funnelId } = useFunnelContext();
|
||||
|
||||
const isFetchingFunnelOverview = useIsFetching({
|
||||
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_OVERVIEW, funnelId],
|
||||
});
|
||||
|
||||
const isFetchingStepsGraphData = useIsFetching({
|
||||
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_STEPS_GRAPH_DATA, funnelId],
|
||||
});
|
||||
|
||||
const isFetchingErrorTraces = useIsFetching({
|
||||
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_ERROR_TRACES, funnelId],
|
||||
});
|
||||
|
||||
const isFetchingSlowTraces = useIsFetching({
|
||||
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_SLOW_TRACES, funnelId],
|
||||
});
|
||||
|
||||
return useMemo(() => {
|
||||
if (!funnelId) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
!!isFetchingFunnelOverview ||
|
||||
!!isFetchingStepsGraphData ||
|
||||
!!isFetchingErrorTraces ||
|
||||
!!isFetchingSlowTraces
|
||||
);
|
||||
}, [
|
||||
funnelId,
|
||||
isFetchingFunnelOverview,
|
||||
isFetchingStepsGraphData,
|
||||
isFetchingErrorTraces,
|
||||
isFetchingSlowTraces,
|
||||
]);
|
||||
};
|
||||
|
||||
interface StepsFooterProps {
|
||||
stepsCount: number;
|
||||
@@ -45,7 +86,13 @@ function ValidTracesCount(): JSX.Element {
|
||||
}
|
||||
|
||||
function StepsFooter({ stepsCount }: StepsFooterProps): JSX.Element {
|
||||
const { validTracesCount, handleRunFunnel } = useFunnelContext();
|
||||
const {
|
||||
validTracesCount,
|
||||
handleRunFunnel,
|
||||
hasFunnelBeenExecuted,
|
||||
} = useFunnelContext();
|
||||
|
||||
const isFunnelResultsLoading = useFunnelResultsLoading();
|
||||
|
||||
return (
|
||||
<div className="steps-footer">
|
||||
@@ -56,15 +103,28 @@ function StepsFooter({ stepsCount }: StepsFooterProps): JSX.Element {
|
||||
<ValidTracesCount />
|
||||
</div>
|
||||
<div className="steps-footer__right">
|
||||
<Button
|
||||
disabled={validTracesCount === 0}
|
||||
onClick={handleRunFunnel}
|
||||
type="primary"
|
||||
className="steps-footer__button steps-footer__button--run"
|
||||
icon={<Play size={16} />}
|
||||
>
|
||||
Run funnel
|
||||
</Button>
|
||||
{!hasFunnelBeenExecuted ? (
|
||||
<Button
|
||||
disabled={validTracesCount === 0}
|
||||
onClick={handleRunFunnel}
|
||||
type="primary"
|
||||
className="steps-footer__button steps-footer__button--run"
|
||||
icon={<Play size={16} />}
|
||||
>
|
||||
Run funnel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="text"
|
||||
className="steps-footer__button steps-footer__button--sync"
|
||||
icon={<RefreshCcw size={16} />}
|
||||
onClick={handleRunFunnel}
|
||||
loading={isFunnelResultsLoading}
|
||||
disabled={validTracesCount === 0}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ function EmptyFunnelResults({
|
||||
<div className="empty-funnel-results__title">{title}</div>
|
||||
<div className="empty-funnel-results__description">{description}</div>
|
||||
<div className="empty-funnel-results__learn-more">
|
||||
<LearnMore />
|
||||
<LearnMore url="https://signoz.io/blog/tracing-funnels-observability-distributed-systems/" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ function FunnelResults(): JSX.Element {
|
||||
isValidateStepsLoading,
|
||||
hasIncompleteStepFields,
|
||||
hasAllEmptyStepFields,
|
||||
hasFunnelBeenExecuted,
|
||||
} = useFunnelContext();
|
||||
|
||||
if (isValidateStepsLoading) {
|
||||
@@ -38,6 +39,14 @@ function FunnelResults(): JSX.Element {
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!hasFunnelBeenExecuted) {
|
||||
return (
|
||||
<EmptyFunnelResults
|
||||
title="Funnel has not been run yet."
|
||||
description="Run the funnel to see the results"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="funnel-results">
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { ErrorTraceData, SlowTraceData } from 'api/traceFunnels';
|
||||
import {
|
||||
ErrorTraceData,
|
||||
FunnelOverviewPayload,
|
||||
SlowTraceData,
|
||||
} from 'api/traceFunnels';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useMemo } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
@@ -15,12 +19,7 @@ interface FunnelTopTracesTableProps {
|
||||
tooltip: string;
|
||||
useQueryHook: (
|
||||
funnelId: string,
|
||||
payload: {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
step_a_order: number;
|
||||
step_b_order: number;
|
||||
},
|
||||
payload: FunnelOverviewPayload,
|
||||
) => UseQueryResult<
|
||||
SuccessResponse<SlowTraceData | ErrorTraceData> | ErrorResponse,
|
||||
Error
|
||||
@@ -40,8 +39,8 @@ function FunnelTopTracesTable({
|
||||
() => ({
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
step_a_order: stepAOrder,
|
||||
step_b_order: stepBOrder,
|
||||
step_start: stepAOrder,
|
||||
step_end: stepBOrder,
|
||||
}),
|
||||
[startTime, endTime, stepAOrder, stepBOrder],
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useFunnelMetrics } from 'hooks/TracesFunnels/useFunnelMetrics';
|
||||
import { useFunnelStepsMetrics } from 'hooks/TracesFunnels/useFunnelMetrics';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import FunnelMetricsTable from './FunnelMetricsTable';
|
||||
@@ -22,7 +22,7 @@ function StepsTransitionMetrics({
|
||||
(transition) => transition.value === selectedTransition,
|
||||
);
|
||||
|
||||
const { isLoading, metricsData, conversionRate } = useFunnelMetrics({
|
||||
const { isLoading, metricsData, conversionRate } = useFunnelStepsMetrics({
|
||||
funnelId: funnelId || '',
|
||||
stepStart: startStep,
|
||||
stepEnd: endStep,
|
||||
|
||||
@@ -13,7 +13,7 @@ export const topTracesTableColumns = [
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'DURATION',
|
||||
title: 'STEP TRANSITION DURATION',
|
||||
dataIndex: 'duration_ms',
|
||||
key: 'duration_ms',
|
||||
render: (value: string): string => getYAxisFormattedValue(value, 'ms'),
|
||||
|
||||
@@ -12,7 +12,7 @@ export const initialStepsData: FunnelStepData[] = [
|
||||
op: 'and',
|
||||
},
|
||||
latency_pointer: 'start',
|
||||
latency_type: LatencyOptions.P95,
|
||||
latency_type: undefined,
|
||||
has_errors: false,
|
||||
name: '',
|
||||
description: '',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ValidateFunnelResponse } from 'api/traceFunnels';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { Time } from 'container/TopNav/DateTimeSelection/config';
|
||||
import {
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
Time as TimeV2,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/config';
|
||||
import { useValidateFunnelSteps } from 'hooks/TracesFunnels/useFunnels';
|
||||
import { useLocalStorage } from 'hooks/useLocalStorage';
|
||||
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
|
||||
import { initialStepsData } from 'pages/TracesFunnelDetails/constants';
|
||||
import {
|
||||
@@ -45,15 +47,15 @@ interface FunnelContextType {
|
||||
| undefined;
|
||||
isValidateStepsLoading: boolean;
|
||||
hasIncompleteStepFields: boolean;
|
||||
setHasIncompleteStepFields: Dispatch<SetStateAction<boolean>>;
|
||||
hasAllEmptyStepFields: boolean;
|
||||
setHasAllEmptyStepFields: Dispatch<SetStateAction<boolean>>;
|
||||
handleReplaceStep: (
|
||||
index: number,
|
||||
serviceName: string,
|
||||
spanName: string,
|
||||
) => void;
|
||||
handleRestoreSteps: (oldSteps: FunnelStepData[]) => void;
|
||||
hasFunnelBeenExecuted: boolean;
|
||||
setHasFunnelBeenExecuted: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const FunnelContext = createContext<FunnelContextType | undefined>(undefined);
|
||||
@@ -84,12 +86,27 @@ export function FunnelProvider({
|
||||
const funnel = data?.payload;
|
||||
const initialSteps = funnel?.steps?.length ? funnel.steps : initialStepsData;
|
||||
const [steps, setSteps] = useState<FunnelStepData[]>(initialSteps);
|
||||
const [hasIncompleteStepFields, setHasIncompleteStepFields] = useState(
|
||||
steps.some((step) => step.service_name === '' || step.span_name === ''),
|
||||
const { hasIncompleteStepFields, hasAllEmptyStepFields } = useMemo(
|
||||
() => ({
|
||||
hasAllEmptyStepFields: steps.every(
|
||||
(step) => step.service_name === '' && step.span_name === '',
|
||||
),
|
||||
hasIncompleteStepFields: steps.some(
|
||||
(step) => step.service_name === '' || step.span_name === '',
|
||||
),
|
||||
}),
|
||||
[steps],
|
||||
);
|
||||
const [hasAllEmptyStepFields, setHasAllEmptyStepFields] = useState(
|
||||
steps.every((step) => step.service_name === '' && step.span_name === ''),
|
||||
|
||||
const [unexecutedFunnels, setUnexecutedFunnels] = useLocalStorage<string[]>(
|
||||
LOCALSTORAGE.UNEXECUTED_FUNNELS,
|
||||
[],
|
||||
);
|
||||
|
||||
const [hasFunnelBeenExecuted, setHasFunnelBeenExecuted] = useState(
|
||||
!unexecutedFunnels.includes(funnelId),
|
||||
);
|
||||
|
||||
const {
|
||||
data: validationResponse,
|
||||
isLoading: isValidationLoading,
|
||||
@@ -99,6 +116,12 @@ export function FunnelProvider({
|
||||
selectedTime,
|
||||
startTime,
|
||||
endTime,
|
||||
enabled:
|
||||
!!funnelId &&
|
||||
!!selectedTime &&
|
||||
!!startTime &&
|
||||
!!endTime &&
|
||||
!hasIncompleteStepFields,
|
||||
});
|
||||
|
||||
const validTracesCount = useMemo(
|
||||
@@ -163,6 +186,11 @@ export function FunnelProvider({
|
||||
|
||||
const handleRunFunnel = useCallback(async (): Promise<void> => {
|
||||
if (validTracesCount === 0) return;
|
||||
if (!hasFunnelBeenExecuted) {
|
||||
setUnexecutedFunnels(unexecutedFunnels.filter((id) => id !== funnelId));
|
||||
|
||||
setHasFunnelBeenExecuted(true);
|
||||
}
|
||||
queryClient.refetchQueries([
|
||||
REACT_QUERY_KEY.GET_FUNNEL_OVERVIEW,
|
||||
funnelId,
|
||||
@@ -183,7 +211,15 @@ export function FunnelProvider({
|
||||
funnelId,
|
||||
selectedTime,
|
||||
]);
|
||||
}, [funnelId, queryClient, selectedTime, validTracesCount]);
|
||||
}, [
|
||||
funnelId,
|
||||
hasFunnelBeenExecuted,
|
||||
unexecutedFunnels,
|
||||
queryClient,
|
||||
selectedTime,
|
||||
setUnexecutedFunnels,
|
||||
validTracesCount,
|
||||
]);
|
||||
|
||||
const value = useMemo<FunnelContextType>(
|
||||
() => ({
|
||||
@@ -202,11 +238,11 @@ export function FunnelProvider({
|
||||
validationResponse,
|
||||
isValidateStepsLoading: isValidationLoading || isValidationFetching,
|
||||
hasIncompleteStepFields,
|
||||
setHasIncompleteStepFields,
|
||||
hasAllEmptyStepFields,
|
||||
setHasAllEmptyStepFields,
|
||||
handleReplaceStep,
|
||||
handleRestoreSteps,
|
||||
hasFunnelBeenExecuted,
|
||||
setHasFunnelBeenExecuted,
|
||||
}),
|
||||
[
|
||||
funnelId,
|
||||
@@ -224,11 +260,11 @@ export function FunnelProvider({
|
||||
isValidationLoading,
|
||||
isValidationFetching,
|
||||
hasIncompleteStepFields,
|
||||
setHasIncompleteStepFields,
|
||||
hasAllEmptyStepFields,
|
||||
setHasAllEmptyStepFields,
|
||||
handleReplaceStep,
|
||||
handleRestoreSteps,
|
||||
hasFunnelBeenExecuted,
|
||||
setHasFunnelBeenExecuted,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ import { Input } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { AxiosError } from 'axios';
|
||||
import SignozModal from 'components/SignozModal/SignozModal';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useCreateFunnel } from 'hooks/TracesFunnels/useFunnels';
|
||||
import { useLocalStorage } from 'hooks/useLocalStorage';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { Check, X } from 'lucide-react';
|
||||
@@ -32,6 +34,11 @@ function CreateFunnel({
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const [unexecutedFunnels, setUnexecutedFunnels] = useLocalStorage<string[]>(
|
||||
LOCALSTORAGE.UNEXECUTED_FUNNELS,
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCreate = (): void => {
|
||||
createFunnelMutation.mutate(
|
||||
{
|
||||
@@ -52,11 +59,17 @@ function CreateFunnel({
|
||||
|
||||
setFunnelName('');
|
||||
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_FUNNELS_LIST]);
|
||||
onClose(data?.payload?.funnel_id);
|
||||
if (data?.payload?.funnel_id && redirectToDetails) {
|
||||
|
||||
const funnelId = data?.payload?.funnel_id;
|
||||
if (funnelId) {
|
||||
setUnexecutedFunnels([...unexecutedFunnels, funnelId]);
|
||||
}
|
||||
|
||||
onClose(funnelId);
|
||||
if (funnelId && redirectToDetails) {
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.TRACES_FUNNELS_DETAIL, {
|
||||
funnelId: data.payload.funnel_id,
|
||||
funnelId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ function FunnelsEmptyState({
|
||||
>
|
||||
New funnel
|
||||
</Button>
|
||||
<LearnMore />
|
||||
<LearnMore url="https://signoz.io/blog/tracing-funnels-observability-distributed-systems/" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,10 @@ function RenameFunnel({
|
||||
message: 'Funnel renamed successfully',
|
||||
});
|
||||
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_FUNNELS_LIST]);
|
||||
queryClient.invalidateQueries([
|
||||
REACT_QUERY_KEY.GET_FUNNEL_DETAILS,
|
||||
funnelId,
|
||||
]);
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
|
||||
@@ -15,9 +15,9 @@ function TracesModulePage(): JSX.Element {
|
||||
const routes: TabRoutes[] = [
|
||||
tracesExplorer,
|
||||
// TODO(shaheer): remove this check after everything is ready
|
||||
process.env.NODE_ENV === 'development' ? tracesFunnel(pathname) : null,
|
||||
tracesFunnel(pathname),
|
||||
tracesSaveView,
|
||||
].filter(Boolean) as TabRoutes[];
|
||||
];
|
||||
|
||||
const handleTabChange = (activeRoute: string): void => {
|
||||
if (activeRoute === ROUTES.TRACES_FUNNELS) {
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface FunnelStepData {
|
||||
span_name: string;
|
||||
filters: TagFilter;
|
||||
latency_pointer: 'start' | 'end';
|
||||
latency_type: LatencyOptionsType;
|
||||
latency_type?: LatencyOptionsType;
|
||||
has_errors: boolean;
|
||||
name?: string;
|
||||
description?: string;
|
||||
|
||||
1232
pkg/modules/tracefunnel/clickhouse_queries.go
Normal file
1232
pkg/modules/tracefunnel/clickhouse_queries.go
Normal file
File diff suppressed because it is too large
Load Diff
294
pkg/modules/tracefunnel/impltracefunnel/handler.go
Normal file
294
pkg/modules/tracefunnel/impltracefunnel/handler.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package impltracefunnel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
tf "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
module tracefunnel.Module
|
||||
}
|
||||
|
||||
func NewHandler(module tracefunnel.Module) tracefunnel.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
func (handler *handler) New(rw http.ResponseWriter, r *http.Request) {
|
||||
var req tf.FunnelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := tracefunnel.GetClaims(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
funnel, err := handler.module.Create(r.Context(), req.Timestamp, req.Name, claims.UserID, claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to create funnel: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
response := tracefunnel.ConstructFunnelResponse(funnel, claims)
|
||||
render.Success(rw, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateSteps(rw http.ResponseWriter, r *http.Request) {
|
||||
var req tf.FunnelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := tracefunnel.GetClaims(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
updatedAt, err := tracefunnel.ValidateAndConvertTimestamp(req.Timestamp)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
funnel, err := handler.module.Get(r.Context(), req.FunnelID.String())
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"funnel not found: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
steps, err := tracefunnel.ProcessFunnelSteps(req.Steps)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
funnel.Steps = steps
|
||||
funnel.UpdatedAt = updatedAt
|
||||
funnel.UpdatedBy = claims.UserID
|
||||
|
||||
if req.Name != "" {
|
||||
funnel.Name = req.Name
|
||||
}
|
||||
if req.Description != "" {
|
||||
funnel.Description = req.Description
|
||||
}
|
||||
|
||||
if err := handler.module.Update(r.Context(), funnel, claims.UserID); err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to update funnel in database: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
updatedFunnel, err := handler.module.Get(r.Context(), funnel.ID.String())
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to get updated funnel: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
response := tracefunnel.ConstructFunnelResponse(updatedFunnel, claims)
|
||||
render.Success(rw, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateFunnel(rw http.ResponseWriter, r *http.Request) {
|
||||
var req tf.FunnelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := tracefunnel.GetClaims(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
updatedAt, err := tracefunnel.ValidateAndConvertTimestamp(req.Timestamp)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := handler.module.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"funnel not found: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
funnel.UpdatedAt = updatedAt
|
||||
funnel.UpdatedBy = claims.UserID
|
||||
|
||||
if req.Name != "" {
|
||||
funnel.Name = req.Name
|
||||
}
|
||||
if req.Description != "" {
|
||||
funnel.Description = req.Description
|
||||
}
|
||||
|
||||
if err := handler.module.Update(r.Context(), funnel, claims.UserID); err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to update funnel in database: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
updatedFunnel, err := handler.module.Get(r.Context(), funnel.ID.String())
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to get updated funnel: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
response := tracefunnel.ConstructFunnelResponse(updatedFunnel, claims)
|
||||
render.Success(rw, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := tracefunnel.GetClaims(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
funnels, err := handler.module.List(r.Context(), claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to list funnels: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
var response []tf.FunnelResponse
|
||||
for _, f := range funnels {
|
||||
response = append(response, tracefunnel.ConstructFunnelResponse(f, claims))
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := handler.module.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"funnel not found: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
claims, _ := tracefunnel.GetClaims(r) // Ignore error as email is optional
|
||||
response := tracefunnel.ConstructFunnelResponse(funnel, claims)
|
||||
render.Success(rw, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (handler *handler) Delete(rw http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
if err := handler.module.Delete(r.Context(), funnelID); err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to delete funnel: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) Save(rw http.ResponseWriter, r *http.Request) {
|
||||
var req tf.FunnelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"invalid request: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := tracefunnel.GetClaims(r)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
funnel, err := handler.module.Get(r.Context(), req.FunnelID.String())
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"funnel not found: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
updateTimestamp := req.Timestamp
|
||||
if updateTimestamp == 0 {
|
||||
updateTimestamp = time.Now().UnixMilli()
|
||||
} else if !tracefunnel.ValidateTimestampIsMilliseconds(updateTimestamp) {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"timestamp must be in milliseconds format (13 digits)"))
|
||||
return
|
||||
}
|
||||
|
||||
updatedAt, err := tracefunnel.ValidateAndConvertTimestamp(updateTimestamp)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
funnel.UpdatedAt = updatedAt
|
||||
funnel.UpdatedBy = claims.UserID
|
||||
funnel.Description = req.Description
|
||||
|
||||
if err := handler.module.Save(r.Context(), funnel, funnel.UpdatedBy, claims.OrgID); err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to save funnel: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
createdAtMillis, updatedAtMillis, extraDataFromDB, err := handler.module.GetFunnelMetadata(r.Context(), funnel.ID.String())
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"failed to get funnel metadata: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
resp := tf.FunnelResponse{
|
||||
FunnelName: funnel.Name,
|
||||
CreatedAt: createdAtMillis,
|
||||
UpdatedAt: updatedAtMillis,
|
||||
CreatedBy: funnel.CreatedBy,
|
||||
UpdatedBy: funnel.UpdatedBy,
|
||||
OrgID: funnel.OrgID.String(),
|
||||
Description: extraDataFromDB,
|
||||
UserEmail: claims.Email,
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, resp)
|
||||
}
|
||||
421
pkg/modules/tracefunnel/impltracefunnel/handler_test.go
Normal file
421
pkg/modules/tracefunnel/impltracefunnel/handler_test.go
Normal file
@@ -0,0 +1,421 @@
|
||||
package impltracefunnel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type MockModule struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockModule) Create(ctx context.Context, timestamp int64, name string, userID string, orgID string) (*traceFunnels.Funnel, error) {
|
||||
args := m.Called(ctx, timestamp, name, userID, orgID)
|
||||
return args.Get(0).(*traceFunnels.Funnel), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockModule) Get(ctx context.Context, funnelID string) (*traceFunnels.Funnel, error) {
|
||||
args := m.Called(ctx, funnelID)
|
||||
return args.Get(0).(*traceFunnels.Funnel), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockModule) Update(ctx context.Context, funnel *traceFunnels.Funnel, userID string) error {
|
||||
args := m.Called(ctx, funnel, userID)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockModule) List(ctx context.Context, orgID string) ([]*traceFunnels.Funnel, error) {
|
||||
args := m.Called(ctx, orgID)
|
||||
return args.Get(0).([]*traceFunnels.Funnel), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockModule) Delete(ctx context.Context, funnelID string) error {
|
||||
args := m.Called(ctx, funnelID)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockModule) Save(ctx context.Context, funnel *traceFunnels.Funnel, userID string, orgID string) error {
|
||||
args := m.Called(ctx, funnel, userID, orgID)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockModule) GetFunnelMetadata(ctx context.Context, funnelID string) (int64, int64, string, error) {
|
||||
args := m.Called(ctx, funnelID)
|
||||
return args.Get(0).(int64), args.Get(1).(int64), args.String(2), args.Error(3)
|
||||
}
|
||||
|
||||
func TestHandler_New(t *testing.T) {
|
||||
mockModule := new(MockModule)
|
||||
handler := NewHandler(mockModule)
|
||||
|
||||
reqBody := traceFunnels.FunnelRequest{
|
||||
Name: "test-funnel",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
jsonBody, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/trace-funnels/new", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
orgID := valuer.GenerateUUID().String()
|
||||
claims := authtypes.Claims{
|
||||
UserID: "user-123",
|
||||
OrgID: orgID,
|
||||
Email: "test@example.com",
|
||||
}
|
||||
req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), claims))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
funnelID := valuer.GenerateUUID()
|
||||
expectedFunnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnelID,
|
||||
},
|
||||
Name: reqBody.Name,
|
||||
OrgID: valuer.MustNewUUID(orgID),
|
||||
},
|
||||
}
|
||||
|
||||
mockModule.On("List", req.Context(), orgID).Return([]*traceFunnels.Funnel{}, nil)
|
||||
mockModule.On("Create", req.Context(), reqBody.Timestamp, reqBody.Name, "user-123", orgID).Return(expectedFunnel, nil)
|
||||
|
||||
handler.New(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Data traceFunnels.FunnelResponse `json:"data"`
|
||||
}
|
||||
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "success", response.Status)
|
||||
assert.Equal(t, reqBody.Name, response.Data.FunnelName)
|
||||
assert.Equal(t, orgID, response.Data.OrgID)
|
||||
assert.Equal(t, "test@example.com", response.Data.UserEmail)
|
||||
|
||||
mockModule.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestHandler_Update(t *testing.T) {
|
||||
mockModule := new(MockModule)
|
||||
handler := NewHandler(mockModule)
|
||||
|
||||
// Create a valid UUID for the funnel ID
|
||||
funnelID := valuer.GenerateUUID()
|
||||
orgID := valuer.GenerateUUID().String()
|
||||
|
||||
reqBody := traceFunnels.FunnelRequest{
|
||||
FunnelID: funnelID,
|
||||
Name: "updated-funnel",
|
||||
Steps: []traceFunnels.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut, "/api/v1/trace-funnels/steps/update", bytes.NewBuffer(body))
|
||||
assert.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Set up context with claims
|
||||
claims := authtypes.Claims{
|
||||
UserID: "user-123",
|
||||
OrgID: orgID,
|
||||
Email: "test@example.com",
|
||||
}
|
||||
ctx := authtypes.NewContextWithClaims(req.Context(), claims)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
// Set up mock expectations
|
||||
existingFunnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnelID,
|
||||
},
|
||||
Name: "test-funnel",
|
||||
OrgID: valuer.MustNewUUID(orgID),
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
UserAuditable: types.UserAuditable{
|
||||
CreatedBy: "user-123",
|
||||
UpdatedBy: "user-123",
|
||||
},
|
||||
},
|
||||
CreatedByUser: &types.User{
|
||||
ID: "user-123",
|
||||
Email: "test@example.com",
|
||||
},
|
||||
}
|
||||
|
||||
updatedFunnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnelID,
|
||||
},
|
||||
Name: reqBody.Name,
|
||||
OrgID: valuer.MustNewUUID(orgID),
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Unix(0, reqBody.Timestamp*1000000),
|
||||
},
|
||||
UserAuditable: types.UserAuditable{
|
||||
CreatedBy: "user-123",
|
||||
UpdatedBy: "user-123",
|
||||
},
|
||||
},
|
||||
Steps: reqBody.Steps,
|
||||
CreatedByUser: &types.User{
|
||||
ID: "user-123",
|
||||
Email: "test@example.com",
|
||||
},
|
||||
}
|
||||
|
||||
// First Get call to validate the funnel exists
|
||||
mockModule.On("Get", req.Context(), funnelID.String()).Return(existingFunnel, nil).Once()
|
||||
// List call to check for name conflicts
|
||||
mockModule.On("List", req.Context(), orgID).Return([]*traceFunnels.Funnel{}, nil).Once()
|
||||
// Update call to save the changes
|
||||
mockModule.On("Update", req.Context(), mock.MatchedBy(func(f *traceFunnels.Funnel) bool {
|
||||
return f.Name == reqBody.Name &&
|
||||
f.ID.String() == funnelID.String() &&
|
||||
len(f.Steps) == len(reqBody.Steps) &&
|
||||
f.Steps[0].Name == reqBody.Steps[0].Name &&
|
||||
f.Steps[0].ServiceName == reqBody.Steps[0].ServiceName &&
|
||||
f.Steps[0].SpanName == reqBody.Steps[0].SpanName &&
|
||||
f.Steps[1].Name == reqBody.Steps[1].Name &&
|
||||
f.Steps[1].ServiceName == reqBody.Steps[1].ServiceName &&
|
||||
f.Steps[1].SpanName == reqBody.Steps[1].SpanName &&
|
||||
f.UpdatedAt.UnixNano()/1000000 == reqBody.Timestamp &&
|
||||
f.UpdatedBy == "user-123"
|
||||
}), "user-123").Return(nil).Once()
|
||||
// Second Get call to get the updated funnel for the response
|
||||
mockModule.On("Get", req.Context(), funnelID.String()).Return(updatedFunnel, nil).Once()
|
||||
|
||||
handler.UpdateSteps(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Data traceFunnels.FunnelResponse `json:"data"`
|
||||
}
|
||||
err = json.Unmarshal(rr.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "success", response.Status)
|
||||
assert.Equal(t, "updated-funnel", response.Data.FunnelName)
|
||||
assert.Equal(t, funnelID.String(), response.Data.FunnelID)
|
||||
assert.Equal(t, "test@example.com", response.Data.UserEmail)
|
||||
|
||||
mockModule.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestHandler_List(t *testing.T) {
|
||||
mockModule := new(MockModule)
|
||||
handler := NewHandler(mockModule)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/trace-funnels/list", nil)
|
||||
|
||||
orgID := valuer.GenerateUUID().String()
|
||||
claims := authtypes.Claims{
|
||||
OrgID: orgID,
|
||||
}
|
||||
req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), claims))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
funnel1ID := valuer.GenerateUUID()
|
||||
funnel2ID := valuer.GenerateUUID()
|
||||
expectedFunnels := []*traceFunnels.Funnel{
|
||||
{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnel1ID,
|
||||
},
|
||||
Name: "funnel-1",
|
||||
OrgID: valuer.MustNewUUID(orgID),
|
||||
},
|
||||
},
|
||||
{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnel2ID,
|
||||
},
|
||||
Name: "funnel-2",
|
||||
OrgID: valuer.MustNewUUID(orgID),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockModule.On("List", req.Context(), orgID).Return(expectedFunnels, nil)
|
||||
|
||||
handler.List(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Data []traceFunnels.FunnelResponse `json:"data"`
|
||||
}
|
||||
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "success", response.Status)
|
||||
assert.Len(t, response.Data, 2)
|
||||
assert.Equal(t, "funnel-1", response.Data[0].FunnelName)
|
||||
assert.Equal(t, "funnel-2", response.Data[1].FunnelName)
|
||||
|
||||
mockModule.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestHandler_Get(t *testing.T) {
|
||||
mockModule := new(MockModule)
|
||||
handler := NewHandler(mockModule)
|
||||
|
||||
funnelID := valuer.GenerateUUID()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/trace-funnels/"+funnelID.String(), nil)
|
||||
req = mux.SetURLVars(req, map[string]string{"funnel_id": funnelID.String()})
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
expectedFunnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnelID,
|
||||
},
|
||||
Name: "test-funnel",
|
||||
OrgID: valuer.GenerateUUID(),
|
||||
},
|
||||
}
|
||||
|
||||
mockModule.On("Get", req.Context(), funnelID.String()).Return(expectedFunnel, nil)
|
||||
|
||||
handler.Get(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Data traceFunnels.FunnelResponse `json:"data"`
|
||||
}
|
||||
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "success", response.Status)
|
||||
assert.Equal(t, "test-funnel", response.Data.FunnelName)
|
||||
assert.Equal(t, expectedFunnel.OrgID.String(), response.Data.OrgID)
|
||||
|
||||
mockModule.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestHandler_Delete(t *testing.T) {
|
||||
mockModule := new(MockModule)
|
||||
handler := NewHandler(mockModule)
|
||||
|
||||
funnelID := valuer.GenerateUUID()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/trace-funnels/"+funnelID.String(), nil)
|
||||
req = mux.SetURLVars(req, map[string]string{"funnel_id": funnelID.String()})
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
mockModule.On("Delete", req.Context(), funnelID.String()).Return(nil)
|
||||
|
||||
handler.Delete(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
mockModule.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestHandler_Save(t *testing.T) {
|
||||
mockModule := new(MockModule)
|
||||
handler := NewHandler(mockModule)
|
||||
|
||||
reqBody := traceFunnels.FunnelRequest{
|
||||
FunnelID: valuer.GenerateUUID(),
|
||||
Description: "updated description",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
UserID: "user-123",
|
||||
}
|
||||
|
||||
jsonBody, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/trace-funnels/save", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
orgID := valuer.GenerateUUID().String()
|
||||
claims := authtypes.Claims{
|
||||
UserID: "user-123",
|
||||
OrgID: orgID,
|
||||
}
|
||||
req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), claims))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
existingFunnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: reqBody.FunnelID,
|
||||
},
|
||||
Name: "test-funnel",
|
||||
OrgID: valuer.MustNewUUID(orgID),
|
||||
},
|
||||
}
|
||||
|
||||
mockModule.On("Get", req.Context(), reqBody.FunnelID.String()).Return(existingFunnel, nil)
|
||||
mockModule.On("Save", req.Context(), mock.MatchedBy(func(f *traceFunnels.Funnel) bool {
|
||||
return f.ID.String() == reqBody.FunnelID.String() &&
|
||||
f.Name == existingFunnel.Name &&
|
||||
f.Description == reqBody.Description &&
|
||||
f.UpdatedBy == "user-123" &&
|
||||
f.OrgID.String() == orgID
|
||||
}), "user-123", orgID).Return(nil)
|
||||
mockModule.On("GetFunnelMetadata", req.Context(), reqBody.FunnelID.String()).Return(int64(0), int64(0), reqBody.Description, nil)
|
||||
|
||||
handler.Save(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Data traceFunnels.FunnelResponse `json:"data"`
|
||||
}
|
||||
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "success", response.Status)
|
||||
assert.Equal(t, reqBody.Description, response.Data.Description)
|
||||
|
||||
mockModule.AssertExpectations(t)
|
||||
}
|
||||
117
pkg/modules/tracefunnel/impltracefunnel/module.go
Normal file
117
pkg/modules/tracefunnel/impltracefunnel/module.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package impltracefunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store traceFunnels.FunnelStore
|
||||
}
|
||||
|
||||
func NewModule(store traceFunnels.FunnelStore) tracefunnel.Module {
|
||||
return &module{
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (module *module) Create(ctx context.Context, timestamp int64, name string, userID string, orgID string) (*traceFunnels.Funnel, error) {
|
||||
orgUUID, err := valuer.NewUUID(orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid org ID: %v", err)
|
||||
}
|
||||
|
||||
funnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Name: name,
|
||||
OrgID: orgUUID,
|
||||
},
|
||||
}
|
||||
funnel.CreatedAt = time.Unix(0, timestamp*1000000) // Convert to nanoseconds
|
||||
funnel.CreatedBy = userID
|
||||
|
||||
// Set up the user relationship
|
||||
funnel.CreatedByUser = &types.User{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.MustNewUUID(userID),
|
||||
},
|
||||
}
|
||||
|
||||
if err := module.store.Create(ctx, funnel); err != nil {
|
||||
return nil, fmt.Errorf("failed to create funnel: %v", err)
|
||||
}
|
||||
|
||||
return funnel, nil
|
||||
}
|
||||
|
||||
// Get gets a funnel by ID
|
||||
func (module *module) Get(ctx context.Context, funnelID string) (*traceFunnels.Funnel, error) {
|
||||
uuid, err := valuer.NewUUID(funnelID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid funnel ID: %v", err)
|
||||
}
|
||||
return module.store.Get(ctx, uuid)
|
||||
}
|
||||
|
||||
// Update updates a funnel
|
||||
func (module *module) Update(ctx context.Context, funnel *traceFunnels.Funnel, userID string) error {
|
||||
funnel.UpdatedBy = userID
|
||||
return module.store.Update(ctx, funnel)
|
||||
}
|
||||
|
||||
// List lists all funnels for an organization
|
||||
func (module *module) List(ctx context.Context, orgID string) ([]*traceFunnels.Funnel, error) {
|
||||
orgUUID, err := valuer.NewUUID(orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid org ID: %v", err)
|
||||
}
|
||||
|
||||
funnels, err := module.store.List(ctx, orgUUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list funnels: %v", err)
|
||||
}
|
||||
|
||||
return funnels, nil
|
||||
}
|
||||
|
||||
// Delete deletes a funnel
|
||||
func (module *module) Delete(ctx context.Context, funnelID string) error {
|
||||
uuid, err := valuer.NewUUID(funnelID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid funnel ID: %v", err)
|
||||
}
|
||||
return module.store.Delete(ctx, uuid)
|
||||
}
|
||||
|
||||
// Save saves a funnel
|
||||
func (module *module) Save(ctx context.Context, funnel *traceFunnels.Funnel, userID string, orgID string) error {
|
||||
orgUUID, err := valuer.NewUUID(orgID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid org ID: %v", err)
|
||||
}
|
||||
|
||||
funnel.UpdatedBy = userID
|
||||
funnel.OrgID = orgUUID
|
||||
return module.store.Update(ctx, funnel)
|
||||
}
|
||||
|
||||
// GetFunnelMetadata gets metadata for a funnel
|
||||
func (module *module) GetFunnelMetadata(ctx context.Context, funnelID string) (int64, int64, string, error) {
|
||||
uuid, err := valuer.NewUUID(funnelID)
|
||||
if err != nil {
|
||||
return 0, 0, "", fmt.Errorf("invalid funnel ID: %v", err)
|
||||
}
|
||||
|
||||
funnel, err := module.store.Get(ctx, uuid)
|
||||
if err != nil {
|
||||
return 0, 0, "", err
|
||||
}
|
||||
|
||||
return funnel.CreatedAt.UnixNano() / 1000000, funnel.UpdatedAt.UnixNano() / 1000000, funnel.Description, nil
|
||||
}
|
||||
213
pkg/modules/tracefunnel/impltracefunnel/module_test.go
Normal file
213
pkg/modules/tracefunnel/impltracefunnel/module_test.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package impltracefunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type MockStore struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockStore) Create(ctx context.Context, funnel *traceFunnels.Funnel) error {
|
||||
args := m.Called(ctx, funnel)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockStore) Get(ctx context.Context, uuid valuer.UUID) (*traceFunnels.Funnel, error) {
|
||||
args := m.Called(ctx, uuid)
|
||||
return args.Get(0).(*traceFunnels.Funnel), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockStore) List(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.Funnel, error) {
|
||||
args := m.Called(ctx, orgID)
|
||||
return args.Get(0).([]*traceFunnels.Funnel), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockStore) Update(ctx context.Context, funnel *traceFunnels.Funnel) error {
|
||||
args := m.Called(ctx, funnel)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockStore) Delete(ctx context.Context, uuid valuer.UUID) error {
|
||||
args := m.Called(ctx, uuid)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func TestModule_Create(t *testing.T) {
|
||||
mockStore := new(MockStore)
|
||||
module := NewModule(mockStore)
|
||||
|
||||
ctx := context.Background()
|
||||
timestamp := time.Now().UnixMilli()
|
||||
name := "test-funnel"
|
||||
userID := "user-123"
|
||||
orgID := valuer.GenerateUUID().String()
|
||||
|
||||
mockStore.On("Create", ctx, mock.MatchedBy(func(f *traceFunnels.Funnel) bool {
|
||||
return f.Name == name &&
|
||||
f.CreatedBy == userID &&
|
||||
f.OrgID.String() == orgID &&
|
||||
f.CreatedByUser != nil &&
|
||||
f.CreatedByUser.ID == userID &&
|
||||
f.CreatedAt.UnixNano()/1000000 == timestamp
|
||||
})).Return(nil)
|
||||
|
||||
funnel, err := module.Create(ctx, timestamp, name, userID, orgID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, funnel)
|
||||
assert.Equal(t, name, funnel.Name)
|
||||
assert.Equal(t, userID, funnel.CreatedBy)
|
||||
assert.Equal(t, orgID, funnel.OrgID.String())
|
||||
assert.NotNil(t, funnel.CreatedByUser)
|
||||
assert.Equal(t, userID, funnel.CreatedByUser.ID)
|
||||
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestModule_Get(t *testing.T) {
|
||||
mockStore := new(MockStore)
|
||||
module := NewModule(mockStore)
|
||||
|
||||
ctx := context.Background()
|
||||
funnelID := valuer.GenerateUUID().String()
|
||||
expectedFunnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Name: "test-funnel",
|
||||
},
|
||||
}
|
||||
|
||||
mockStore.On("Get", ctx, mock.AnythingOfType("valuer.UUID")).Return(expectedFunnel, nil)
|
||||
|
||||
funnel, err := module.Get(ctx, funnelID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedFunnel, funnel)
|
||||
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestModule_Update(t *testing.T) {
|
||||
mockStore := new(MockStore)
|
||||
module := NewModule(mockStore)
|
||||
|
||||
ctx := context.Background()
|
||||
userID := "user-123"
|
||||
funnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Name: "test-funnel",
|
||||
},
|
||||
}
|
||||
|
||||
mockStore.On("Update", ctx, funnel).Return(nil)
|
||||
|
||||
err := module.Update(ctx, funnel, userID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, userID, funnel.UpdatedBy)
|
||||
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestModule_List(t *testing.T) {
|
||||
mockStore := new(MockStore)
|
||||
module := NewModule(mockStore)
|
||||
|
||||
ctx := context.Background()
|
||||
orgID := valuer.GenerateUUID().String()
|
||||
orgUUID := valuer.MustNewUUID(orgID)
|
||||
expectedFunnels := []*traceFunnels.Funnel{
|
||||
{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Name: "funnel-1",
|
||||
OrgID: orgUUID,
|
||||
},
|
||||
},
|
||||
{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Name: "funnel-2",
|
||||
OrgID: orgUUID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockStore.On("List", ctx, orgUUID).Return(expectedFunnels, nil)
|
||||
|
||||
funnels, err := module.List(ctx, orgID)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, funnels, 2)
|
||||
assert.Equal(t, expectedFunnels, funnels)
|
||||
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestModule_Delete(t *testing.T) {
|
||||
mockStore := new(MockStore)
|
||||
module := NewModule(mockStore)
|
||||
|
||||
ctx := context.Background()
|
||||
funnelID := valuer.GenerateUUID().String()
|
||||
|
||||
mockStore.On("Delete", ctx, mock.AnythingOfType("valuer.UUID")).Return(nil)
|
||||
|
||||
err := module.Delete(ctx, funnelID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestModule_Save(t *testing.T) {
|
||||
mockStore := new(MockStore)
|
||||
module := NewModule(mockStore)
|
||||
|
||||
ctx := context.Background()
|
||||
userID := "user-123"
|
||||
orgID := valuer.GenerateUUID().String()
|
||||
funnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Name: "test-funnel",
|
||||
},
|
||||
}
|
||||
|
||||
mockStore.On("Update", ctx, funnel).Return(nil)
|
||||
|
||||
err := module.Save(ctx, funnel, userID, orgID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, userID, funnel.UpdatedBy)
|
||||
assert.Equal(t, orgID, funnel.OrgID.String())
|
||||
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestModule_GetFunnelMetadata(t *testing.T) {
|
||||
mockStore := new(MockStore)
|
||||
module := NewModule(mockStore)
|
||||
|
||||
ctx := context.Background()
|
||||
funnelID := valuer.GenerateUUID().String()
|
||||
now := time.Now()
|
||||
expectedFunnel := &traceFunnels.Funnel{
|
||||
BaseMetadata: traceFunnels.BaseMetadata{
|
||||
Description: "test description",
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockStore.On("Get", ctx, mock.AnythingOfType("valuer.UUID")).Return(expectedFunnel, nil)
|
||||
|
||||
createdAt, updatedAt, description, err := module.GetFunnelMetadata(ctx, funnelID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, now.UnixNano()/1000000, createdAt)
|
||||
assert.Equal(t, now.UnixNano()/1000000, updatedAt)
|
||||
assert.Equal(t, "test description", description)
|
||||
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
114
pkg/modules/tracefunnel/impltracefunnel/store.go
Normal file
114
pkg/modules/tracefunnel/impltracefunnel/store.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package impltracefunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) traceFunnels.FunnelStore {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (store *store) Create(ctx context.Context, funnel *traceFunnels.Funnel) error {
|
||||
if funnel.ID.IsZero() {
|
||||
funnel.ID = valuer.GenerateUUID()
|
||||
}
|
||||
|
||||
if funnel.CreatedAt.IsZero() {
|
||||
funnel.CreatedAt = time.Now()
|
||||
}
|
||||
if funnel.UpdatedAt.IsZero() {
|
||||
funnel.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// Set created_by if CreatedByUser is present
|
||||
if funnel.CreatedByUser != nil {
|
||||
funnel.CreatedBy = funnel.CreatedByUser.Identifiable.ID.String()
|
||||
}
|
||||
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewInsert().
|
||||
Model(funnel).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, traceFunnels.ErrFunnelAlreadyExists, "a funnel with name '%s' already exists in this organization", funnel.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves a funnel by ID
|
||||
func (store *store) Get(ctx context.Context, uuid valuer.UUID) (*traceFunnels.Funnel, error) {
|
||||
funnel := &traceFunnels.Funnel{}
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(funnel).
|
||||
Relation("CreatedByUser").
|
||||
Where("?TableAlias.id = ?", uuid).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get funnel: %v", err)
|
||||
}
|
||||
return funnel, nil
|
||||
}
|
||||
|
||||
// Update updates an existing funnel
|
||||
func (store *store) Update(ctx context.Context, funnel *traceFunnels.Funnel) error {
|
||||
funnel.UpdatedAt = time.Now()
|
||||
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewUpdate().
|
||||
Model(funnel).
|
||||
WherePK().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, traceFunnels.ErrFunnelAlreadyExists, "a funnel with name '%s' already exists in this organization", funnel.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List retrieves all funnels for a given organization
|
||||
func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.Funnel, error) {
|
||||
var funnels []*traceFunnels.Funnel
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&funnels).
|
||||
Relation("CreatedByUser").
|
||||
Where("?TableAlias.org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list funnels: %v", err)
|
||||
}
|
||||
return funnels, nil
|
||||
}
|
||||
|
||||
// Delete removes a funnel by ID
|
||||
func (store *store) Delete(ctx context.Context, uuid valuer.UUID) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewDelete().
|
||||
Model((*traceFunnels.Funnel)(nil)).
|
||||
Where("id = ?", uuid).Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete funnel: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
420
pkg/modules/tracefunnel/query.go
Normal file
420
pkg/modules/tracefunnel/query.go
Normal file
@@ -0,0 +1,420 @@
|
||||
package tracefunnel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
tracev4 "github.com/SigNoz/signoz/pkg/query-service/app/traces/v4"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ValidateTraces(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange) (*v3.ClickHouseQuery, error) {
|
||||
var query string
|
||||
var err error
|
||||
|
||||
funnelSteps := funnel.Steps
|
||||
containsErrorT1 := 0
|
||||
containsErrorT2 := 0
|
||||
containsErrorT3 := 0
|
||||
|
||||
if funnelSteps[0].HasErrors {
|
||||
containsErrorT1 = 1
|
||||
}
|
||||
if funnelSteps[1].HasErrors {
|
||||
containsErrorT2 = 1
|
||||
}
|
||||
if len(funnel.Steps) > 2 && funnelSteps[2].HasErrors {
|
||||
containsErrorT3 = 1
|
||||
}
|
||||
|
||||
// Build filter clauses for each step
|
||||
clauseStep1, err := tracev4.BuildTracesFilterQuery(funnelSteps[0].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauseStep2, err := tracev4.BuildTracesFilterQuery(funnelSteps[1].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauseStep3 := ""
|
||||
if len(funnel.Steps) > 2 {
|
||||
clauseStep3, err = tracev4.BuildTracesFilterQuery(funnelSteps[2].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(funnel.Steps) > 2 {
|
||||
query = BuildThreeStepFunnelValidationQuery(
|
||||
containsErrorT1, // containsErrorT1
|
||||
containsErrorT2, // containsErrorT2
|
||||
containsErrorT3, // containsErrorT3
|
||||
timeRange.StartTime, // startTs
|
||||
timeRange.EndTime, // endTs
|
||||
funnelSteps[0].ServiceName, // serviceNameT1
|
||||
funnelSteps[0].SpanName, // spanNameT1
|
||||
funnelSteps[1].ServiceName, // serviceNameT1
|
||||
funnelSteps[1].SpanName, // spanNameT2
|
||||
funnelSteps[2].ServiceName, // serviceNameT1
|
||||
funnelSteps[2].SpanName, // spanNameT3
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
clauseStep3,
|
||||
)
|
||||
} else {
|
||||
query = BuildTwoStepFunnelValidationQuery(
|
||||
containsErrorT1, // containsErrorT1
|
||||
containsErrorT2, // containsErrorT2
|
||||
timeRange.StartTime, // startTs
|
||||
timeRange.EndTime, // endTs
|
||||
funnelSteps[0].ServiceName, // serviceNameT1
|
||||
funnelSteps[0].SpanName, // spanNameT1
|
||||
funnelSteps[1].ServiceName, // serviceNameT1
|
||||
funnelSteps[1].SpanName, // spanNameT2
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
)
|
||||
}
|
||||
|
||||
return &v3.ClickHouseQuery{
|
||||
Query: query,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func GetFunnelAnalytics(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange) (*v3.ClickHouseQuery, error) {
|
||||
var query string
|
||||
var err error
|
||||
|
||||
funnelSteps := funnel.Steps
|
||||
containsErrorT1 := 0
|
||||
containsErrorT2 := 0
|
||||
containsErrorT3 := 0
|
||||
latencyPointerT1 := funnelSteps[0].LatencyPointer
|
||||
latencyPointerT2 := funnelSteps[1].LatencyPointer
|
||||
latencyPointerT3 := "start"
|
||||
if len(funnel.Steps) > 2 {
|
||||
latencyPointerT3 = funnelSteps[2].LatencyPointer
|
||||
}
|
||||
|
||||
if funnelSteps[0].HasErrors {
|
||||
containsErrorT1 = 1
|
||||
}
|
||||
if funnelSteps[1].HasErrors {
|
||||
containsErrorT2 = 1
|
||||
}
|
||||
if len(funnel.Steps) > 2 && funnelSteps[2].HasErrors {
|
||||
containsErrorT3 = 1
|
||||
}
|
||||
|
||||
// Build filter clauses for each step
|
||||
clauseStep1, err := tracev4.BuildTracesFilterQuery(funnelSteps[0].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauseStep2, err := tracev4.BuildTracesFilterQuery(funnelSteps[1].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauseStep3 := ""
|
||||
if len(funnel.Steps) > 2 {
|
||||
clauseStep3, err = tracev4.BuildTracesFilterQuery(funnelSteps[2].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(funnel.Steps) > 2 {
|
||||
query = BuildThreeStepFunnelOverviewQuery(
|
||||
containsErrorT1, // containsErrorT1
|
||||
containsErrorT2, // containsErrorT2
|
||||
containsErrorT3, // containsErrorT3
|
||||
latencyPointerT1,
|
||||
latencyPointerT2,
|
||||
latencyPointerT3,
|
||||
timeRange.StartTime, // startTs
|
||||
timeRange.EndTime, // endTs
|
||||
funnelSteps[0].ServiceName, // serviceNameT1
|
||||
funnelSteps[0].SpanName, // spanNameT1
|
||||
funnelSteps[1].ServiceName, // serviceNameT1
|
||||
funnelSteps[1].SpanName, // spanNameT2
|
||||
funnelSteps[2].ServiceName, // serviceNameT1
|
||||
funnelSteps[2].SpanName, // spanNameT3
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
clauseStep3,
|
||||
)
|
||||
} else {
|
||||
query = BuildTwoStepFunnelOverviewQuery(
|
||||
containsErrorT1, // containsErrorT1
|
||||
containsErrorT2, // containsErrorT2
|
||||
latencyPointerT1,
|
||||
latencyPointerT2,
|
||||
timeRange.StartTime, // startTs
|
||||
timeRange.EndTime, // endTs
|
||||
funnelSteps[0].ServiceName, // serviceNameT1
|
||||
funnelSteps[0].SpanName, // spanNameT1
|
||||
funnelSteps[1].ServiceName, // serviceNameT1
|
||||
funnelSteps[1].SpanName, // spanNameT2
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
)
|
||||
}
|
||||
return &v3.ClickHouseQuery{Query: query}, nil
|
||||
}
|
||||
|
||||
func GetFunnelStepAnalytics(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange, stepStart, stepEnd int64) (*v3.ClickHouseQuery, error) {
|
||||
var query string
|
||||
var err error
|
||||
|
||||
funnelSteps := funnel.Steps
|
||||
containsErrorT1 := 0
|
||||
containsErrorT2 := 0
|
||||
containsErrorT3 := 0
|
||||
latencyPointerT1 := funnelSteps[0].LatencyPointer
|
||||
latencyPointerT2 := funnelSteps[1].LatencyPointer
|
||||
latencyPointerT3 := "start"
|
||||
if len(funnel.Steps) > 2 {
|
||||
latencyPointerT3 = funnelSteps[2].LatencyPointer
|
||||
}
|
||||
latencyTypeT2 := "p99"
|
||||
latencyTypeT3 := "p99"
|
||||
|
||||
if stepStart == stepEnd {
|
||||
return nil, fmt.Errorf("step start and end cannot be the same for /step/overview")
|
||||
}
|
||||
|
||||
if funnelSteps[0].HasErrors {
|
||||
containsErrorT1 = 1
|
||||
}
|
||||
if funnelSteps[1].HasErrors {
|
||||
containsErrorT2 = 1
|
||||
}
|
||||
if len(funnel.Steps) > 2 && funnelSteps[2].HasErrors {
|
||||
containsErrorT3 = 1
|
||||
}
|
||||
|
||||
if funnelSteps[1].LatencyType != "" {
|
||||
latencyTypeT2 = strings.ToLower(funnelSteps[1].LatencyType)
|
||||
}
|
||||
if len(funnel.Steps) > 2 && funnelSteps[2].LatencyType != "" {
|
||||
latencyTypeT3 = strings.ToLower(funnelSteps[2].LatencyType)
|
||||
}
|
||||
|
||||
// Build filter clauses for each step
|
||||
clauseStep1, err := tracev4.BuildTracesFilterQuery(funnelSteps[0].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauseStep2, err := tracev4.BuildTracesFilterQuery(funnelSteps[1].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauseStep3 := ""
|
||||
if len(funnel.Steps) > 2 {
|
||||
clauseStep3, err = tracev4.BuildTracesFilterQuery(funnelSteps[2].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(funnel.Steps) > 2 {
|
||||
query = BuildThreeStepFunnelStepOverviewQuery(
|
||||
containsErrorT1, // containsErrorT1
|
||||
containsErrorT2, // containsErrorT2
|
||||
containsErrorT3, // containsErrorT3
|
||||
latencyPointerT1,
|
||||
latencyPointerT2,
|
||||
latencyPointerT3,
|
||||
timeRange.StartTime, // startTs
|
||||
timeRange.EndTime, // endTs
|
||||
funnelSteps[0].ServiceName, // serviceNameT1
|
||||
funnelSteps[0].SpanName, // spanNameT1
|
||||
funnelSteps[1].ServiceName, // serviceNameT1
|
||||
funnelSteps[1].SpanName, // spanNameT2
|
||||
funnelSteps[2].ServiceName, // serviceNameT1
|
||||
funnelSteps[2].SpanName, // spanNameT3
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
clauseStep3,
|
||||
stepStart,
|
||||
stepEnd,
|
||||
latencyTypeT2,
|
||||
latencyTypeT3,
|
||||
)
|
||||
} else {
|
||||
query = BuildTwoStepFunnelStepOverviewQuery(
|
||||
containsErrorT1, // containsErrorT1
|
||||
containsErrorT2, // containsErrorT2
|
||||
latencyPointerT1,
|
||||
latencyPointerT2,
|
||||
timeRange.StartTime, // startTs
|
||||
timeRange.EndTime, // endTs
|
||||
funnelSteps[0].ServiceName, // serviceNameT1
|
||||
funnelSteps[0].SpanName, // spanNameT1
|
||||
funnelSteps[1].ServiceName, // serviceNameT1
|
||||
funnelSteps[1].SpanName, // spanNameT2
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
latencyTypeT2,
|
||||
)
|
||||
}
|
||||
return &v3.ClickHouseQuery{Query: query}, nil
|
||||
}
|
||||
|
||||
func GetStepAnalytics(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange) (*v3.ClickHouseQuery, error) {
|
||||
var query string
|
||||
|
||||
funnelSteps := funnel.Steps
|
||||
containsErrorT1 := 0
|
||||
containsErrorT2 := 0
|
||||
containsErrorT3 := 0
|
||||
|
||||
if funnelSteps[0].HasErrors {
|
||||
containsErrorT1 = 1
|
||||
}
|
||||
if funnelSteps[1].HasErrors {
|
||||
containsErrorT2 = 1
|
||||
}
|
||||
if len(funnel.Steps) > 2 && funnelSteps[2].HasErrors {
|
||||
containsErrorT3 = 1
|
||||
}
|
||||
|
||||
// Build filter clauses for each step
|
||||
clauseStep1, err := tracev4.BuildTracesFilterQuery(funnelSteps[0].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauseStep2, err := tracev4.BuildTracesFilterQuery(funnelSteps[1].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauseStep3 := ""
|
||||
if len(funnel.Steps) > 2 {
|
||||
clauseStep3, err = tracev4.BuildTracesFilterQuery(funnelSteps[2].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(funnel.Steps) > 2 {
|
||||
query = BuildThreeStepFunnelCountQuery(
|
||||
containsErrorT1, // containsErrorT1
|
||||
containsErrorT2, // containsErrorT2
|
||||
containsErrorT3, // containsErrorT3
|
||||
timeRange.StartTime, // startTs
|
||||
timeRange.EndTime, // endTs
|
||||
funnelSteps[0].ServiceName, // serviceNameT1
|
||||
funnelSteps[0].SpanName, // spanNameT1
|
||||
funnelSteps[1].ServiceName, // serviceNameT1
|
||||
funnelSteps[1].SpanName, // spanNameT2
|
||||
funnelSteps[2].ServiceName, // serviceNameT1
|
||||
funnelSteps[2].SpanName, // spanNameT3
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
clauseStep3,
|
||||
)
|
||||
} else {
|
||||
query = BuildTwoStepFunnelCountQuery(
|
||||
containsErrorT1, // containsErrorT1
|
||||
containsErrorT2, // containsErrorT2
|
||||
timeRange.StartTime, // startTs
|
||||
timeRange.EndTime, // endTs
|
||||
funnelSteps[0].ServiceName, // serviceNameT1
|
||||
funnelSteps[0].SpanName, // spanNameT1
|
||||
funnelSteps[1].ServiceName, // serviceNameT1
|
||||
funnelSteps[1].SpanName, // spanNameT2
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
)
|
||||
}
|
||||
|
||||
return &v3.ClickHouseQuery{
|
||||
Query: query,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func GetSlowestTraces(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange, stepStart, stepEnd int64) (*v3.ClickHouseQuery, error) {
|
||||
funnelSteps := funnel.Steps
|
||||
containsErrorT1 := 0
|
||||
containsErrorT2 := 0
|
||||
stepStartOrder := 0
|
||||
stepEndOrder := 1
|
||||
|
||||
if stepStart != stepEnd {
|
||||
stepStartOrder = int(stepStart) - 1
|
||||
stepEndOrder = int(stepEnd) - 1
|
||||
if funnelSteps[stepStartOrder].HasErrors {
|
||||
containsErrorT1 = 1
|
||||
}
|
||||
if funnelSteps[stepEndOrder].HasErrors {
|
||||
containsErrorT2 = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Build filter clauses for the steps
|
||||
clauseStep1, err := tracev4.BuildTracesFilterQuery(funnelSteps[stepStartOrder].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauseStep2, err := tracev4.BuildTracesFilterQuery(funnelSteps[stepEndOrder].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := BuildTwoStepFunnelTopSlowTracesQuery(
|
||||
containsErrorT1, // containsErrorT1
|
||||
containsErrorT2, // containsErrorT2
|
||||
timeRange.StartTime, // startTs
|
||||
timeRange.EndTime, // endTs
|
||||
funnelSteps[stepStartOrder].ServiceName, // serviceNameT1
|
||||
funnelSteps[stepStartOrder].SpanName, // spanNameT1
|
||||
funnelSteps[stepEndOrder].ServiceName, // serviceNameT1
|
||||
funnelSteps[stepEndOrder].SpanName, // spanNameT2
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
)
|
||||
return &v3.ClickHouseQuery{Query: query}, nil
|
||||
}
|
||||
|
||||
func GetErroredTraces(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange, stepStart, stepEnd int64) (*v3.ClickHouseQuery, error) {
|
||||
funnelSteps := funnel.Steps
|
||||
containsErrorT1 := 0
|
||||
containsErrorT2 := 0
|
||||
stepStartOrder := 0
|
||||
stepEndOrder := 1
|
||||
|
||||
if stepStart != stepEnd {
|
||||
stepStartOrder = int(stepStart) - 1
|
||||
stepEndOrder = int(stepEnd) - 1
|
||||
if funnelSteps[stepStartOrder].HasErrors {
|
||||
containsErrorT1 = 1
|
||||
}
|
||||
if funnelSteps[stepEndOrder].HasErrors {
|
||||
containsErrorT2 = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Build filter clauses for the steps
|
||||
clauseStep1, err := tracev4.BuildTracesFilterQuery(funnelSteps[stepStartOrder].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clauseStep2, err := tracev4.BuildTracesFilterQuery(funnelSteps[stepEndOrder].Filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := BuildTwoStepFunnelTopSlowErrorTracesQuery(
|
||||
containsErrorT1, // containsErrorT1
|
||||
containsErrorT2, // containsErrorT2
|
||||
timeRange.StartTime, // startTs
|
||||
timeRange.EndTime, // endTs
|
||||
funnelSteps[stepStartOrder].ServiceName, // serviceNameT1
|
||||
funnelSteps[stepStartOrder].SpanName, // spanNameT1
|
||||
funnelSteps[stepEndOrder].ServiceName, // serviceNameT1
|
||||
funnelSteps[stepEndOrder].SpanName, // spanNameT2
|
||||
clauseStep1,
|
||||
clauseStep2,
|
||||
)
|
||||
return &v3.ClickHouseQuery{Query: query}, nil
|
||||
}
|
||||
41
pkg/modules/tracefunnel/tracefunnel.go
Normal file
41
pkg/modules/tracefunnel/tracefunnel.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package tracefunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
)
|
||||
|
||||
// Module defines the interface for trace funnel operations
|
||||
type Module interface {
|
||||
Create(ctx context.Context, timestamp int64, name string, userID string, orgID string) (*traceFunnels.Funnel, error)
|
||||
|
||||
Get(ctx context.Context, funnelID string) (*traceFunnels.Funnel, error)
|
||||
|
||||
Update(ctx context.Context, funnel *traceFunnels.Funnel, userID string) error
|
||||
|
||||
List(ctx context.Context, orgID string) ([]*traceFunnels.Funnel, error)
|
||||
|
||||
Delete(ctx context.Context, funnelID string) error
|
||||
|
||||
Save(ctx context.Context, funnel *traceFunnels.Funnel, userID string, orgID string) error
|
||||
|
||||
GetFunnelMetadata(ctx context.Context, funnelID string) (int64, int64, string, error)
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
New(http.ResponseWriter, *http.Request)
|
||||
|
||||
UpdateSteps(http.ResponseWriter, *http.Request)
|
||||
|
||||
UpdateFunnel(http.ResponseWriter, *http.Request)
|
||||
|
||||
List(http.ResponseWriter, *http.Request)
|
||||
|
||||
Get(http.ResponseWriter, *http.Request)
|
||||
|
||||
Delete(http.ResponseWriter, *http.Request)
|
||||
|
||||
Save(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
132
pkg/modules/tracefunnel/utils.go
Normal file
132
pkg/modules/tracefunnel/utils.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package tracefunnel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// ValidateTimestamp validates a timestamp
|
||||
func ValidateTimestamp(timestamp int64, fieldName string) error {
|
||||
if timestamp == 0 {
|
||||
return fmt.Errorf("%s is required", fieldName)
|
||||
}
|
||||
if timestamp < 0 {
|
||||
return fmt.Errorf("%s must be positive", fieldName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateTimestampIsMilliseconds validates that a timestamp is in milliseconds
|
||||
func ValidateTimestampIsMilliseconds(timestamp int64) bool {
|
||||
return timestamp >= 1000000000000 && timestamp <= 9999999999999
|
||||
}
|
||||
|
||||
func ValidateFunnelSteps(steps []tracefunnel.FunnelStep) error {
|
||||
if len(steps) < 2 {
|
||||
return fmt.Errorf("funnel must have at least 2 steps")
|
||||
}
|
||||
|
||||
for i, step := range steps {
|
||||
if step.ServiceName == "" {
|
||||
return fmt.Errorf("step %d: service name is required", i+1)
|
||||
}
|
||||
if step.SpanName == "" {
|
||||
return fmt.Errorf("step %d: span name is required", i+1)
|
||||
}
|
||||
if step.Order < 0 {
|
||||
return fmt.Errorf("step %d: order must be non-negative", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeFunnelSteps normalizes step orders to be sequential starting from 1.
|
||||
// Returns a new slice with normalized step orders, leaving the input slice unchanged.
|
||||
func NormalizeFunnelSteps(steps []tracefunnel.FunnelStep) []tracefunnel.FunnelStep {
|
||||
if len(steps) == 0 {
|
||||
return []tracefunnel.FunnelStep{}
|
||||
}
|
||||
|
||||
newSteps := make([]tracefunnel.FunnelStep, len(steps))
|
||||
copy(newSteps, steps)
|
||||
|
||||
sort.Slice(newSteps, func(i, j int) bool {
|
||||
return newSteps[i].Order < newSteps[j].Order
|
||||
})
|
||||
|
||||
for i := range newSteps {
|
||||
newSteps[i].Order = int64(i + 1)
|
||||
}
|
||||
|
||||
return newSteps
|
||||
}
|
||||
|
||||
func GetClaims(r *http.Request) (*authtypes.Claims, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"unauthenticated")
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
func ValidateAndConvertTimestamp(timestamp int64) (time.Time, error) {
|
||||
if err := ValidateTimestamp(timestamp, "timestamp"); err != nil {
|
||||
return time.Time{}, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"timestamp is invalid: %v", err)
|
||||
}
|
||||
return time.Unix(0, timestamp*1000000), nil // Convert to nanoseconds
|
||||
}
|
||||
|
||||
func ConstructFunnelResponse(funnel *tracefunnel.Funnel, claims *authtypes.Claims) tracefunnel.FunnelResponse {
|
||||
resp := tracefunnel.FunnelResponse{
|
||||
FunnelName: funnel.Name,
|
||||
FunnelID: funnel.ID.String(),
|
||||
Steps: funnel.Steps,
|
||||
CreatedAt: funnel.CreatedAt.UnixNano() / 1000000,
|
||||
CreatedBy: funnel.CreatedBy,
|
||||
OrgID: funnel.OrgID.String(),
|
||||
UpdatedBy: funnel.UpdatedBy,
|
||||
UpdatedAt: funnel.UpdatedAt.UnixNano() / 1000000,
|
||||
Description: funnel.Description,
|
||||
}
|
||||
|
||||
if funnel.CreatedByUser != nil {
|
||||
resp.UserEmail = funnel.CreatedByUser.Email
|
||||
} else if claims != nil {
|
||||
resp.UserEmail = claims.Email
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func ProcessFunnelSteps(steps []tracefunnel.FunnelStep) ([]tracefunnel.FunnelStep, error) {
|
||||
// First validate the steps
|
||||
if err := ValidateFunnelSteps(steps); err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"invalid funnel steps: %v", err)
|
||||
}
|
||||
|
||||
// Then process the steps
|
||||
for i := range steps {
|
||||
if steps[i].Order < 1 {
|
||||
steps[i].Order = int64(i + 1)
|
||||
}
|
||||
if steps[i].ID.IsZero() {
|
||||
steps[i].ID = valuer.GenerateUUID()
|
||||
}
|
||||
}
|
||||
|
||||
return NormalizeFunnelSteps(steps), nil
|
||||
}
|
||||
657
pkg/modules/tracefunnel/utils_test.go
Normal file
657
pkg/modules/tracefunnel/utils_test.go
Normal file
@@ -0,0 +1,657 @@
|
||||
package tracefunnel
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestValidateTimestamp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
timestamp int64
|
||||
fieldName string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid timestamp",
|
||||
timestamp: time.Now().UnixMilli(),
|
||||
fieldName: "timestamp",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "zero timestamp",
|
||||
timestamp: 0,
|
||||
fieldName: "timestamp",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "negative timestamp",
|
||||
timestamp: -1,
|
||||
fieldName: "timestamp",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateTimestamp(tt.timestamp, tt.fieldName)
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTimestampIsMilliseconds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
timestamp int64
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "valid millisecond timestamp",
|
||||
timestamp: 1700000000000, // 2023-11-14 12:00:00 UTC
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "too small timestamp",
|
||||
timestamp: 999999999999,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "too large timestamp",
|
||||
timestamp: 10000000000000,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "second precision timestamp",
|
||||
timestamp: 1700000000,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ValidateTimestampIsMilliseconds(tt.timestamp)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFunnelSteps(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
steps []tracefunnel.FunnelStep
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid steps",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "too few steps",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "missing service name",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "missing span name",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "negative order",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: -1,
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateFunnelSteps(tt.steps)
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFunnelSteps(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
steps []tracefunnel.FunnelStep
|
||||
expected []tracefunnel.FunnelStep
|
||||
}{
|
||||
{
|
||||
name: "already normalized steps",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
expected: []tracefunnel.FunnelStep{
|
||||
{
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unordered steps",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
},
|
||||
expected: []tracefunnel.FunnelStep{
|
||||
{
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "steps with gaps in order",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 3",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-3",
|
||||
Order: 3,
|
||||
},
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
expected: []tracefunnel.FunnelStep{
|
||||
{
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
{
|
||||
Name: "Step 3",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-3",
|
||||
Order: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Make a copy of the steps to avoid modifying the original
|
||||
steps := make([]tracefunnel.FunnelStep, len(tt.steps))
|
||||
copy(steps, tt.steps)
|
||||
|
||||
result := NormalizeFunnelSteps(steps)
|
||||
|
||||
// Compare only the relevant fields
|
||||
for i := range result {
|
||||
assert.Equal(t, tt.expected[i].Name, result[i].Name)
|
||||
assert.Equal(t, tt.expected[i].ServiceName, result[i].ServiceName)
|
||||
assert.Equal(t, tt.expected[i].SpanName, result[i].SpanName)
|
||||
assert.Equal(t, tt.expected[i].Order, result[i].Order)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClaims(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*http.Request)
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid claims",
|
||||
setup: func(r *http.Request) {
|
||||
claims := authtypes.Claims{
|
||||
UserID: "user-123",
|
||||
OrgID: "org-123",
|
||||
Email: "test@example.com",
|
||||
}
|
||||
*r = *r.WithContext(authtypes.NewContextWithClaims(r.Context(), claims))
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "no claims in context",
|
||||
setup: func(r *http.Request) {},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
tt.setup(req)
|
||||
|
||||
claims, err := GetClaims(req)
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, claims)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, claims)
|
||||
assert.Equal(t, "user-123", claims.UserID)
|
||||
assert.Equal(t, "org-123", claims.OrgID)
|
||||
assert.Equal(t, "test@example.com", claims.Email)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAndConvertTimestamp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
timestamp int64
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid timestamp",
|
||||
timestamp: time.Now().UnixMilli(),
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "zero timestamp",
|
||||
timestamp: 0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "negative timestamp",
|
||||
timestamp: -1,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ValidateAndConvertTimestamp(tt.timestamp)
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
assert.True(t, result.IsZero())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, result.IsZero())
|
||||
// Verify the conversion from milliseconds to nanoseconds
|
||||
assert.Equal(t, tt.timestamp*1000000, result.UnixNano())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstructFunnelResponse(t *testing.T) {
|
||||
now := time.Now()
|
||||
funnelID := valuer.GenerateUUID()
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
funnel *tracefunnel.Funnel
|
||||
claims *authtypes.Claims
|
||||
expected tracefunnel.FunnelResponse
|
||||
}{
|
||||
{
|
||||
name: "with user email from funnel",
|
||||
funnel: &tracefunnel.Funnel{
|
||||
BaseMetadata: tracefunnel.BaseMetadata{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnelID,
|
||||
},
|
||||
Name: "test-funnel",
|
||||
OrgID: orgID,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
UserAuditable: types.UserAuditable{
|
||||
CreatedBy: "user-123",
|
||||
UpdatedBy: "user-123",
|
||||
},
|
||||
},
|
||||
CreatedByUser: &types.User{
|
||||
ID: "user-123",
|
||||
Email: "funnel@example.com",
|
||||
},
|
||||
Steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
claims: &authtypes.Claims{
|
||||
UserID: "user-123",
|
||||
OrgID: orgID.String(),
|
||||
Email: "claims@example.com",
|
||||
},
|
||||
expected: tracefunnel.FunnelResponse{
|
||||
FunnelName: "test-funnel",
|
||||
FunnelID: funnelID.String(),
|
||||
Steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
},
|
||||
CreatedAt: now.UnixNano() / 1000000,
|
||||
CreatedBy: "user-123",
|
||||
UpdatedAt: now.UnixNano() / 1000000,
|
||||
UpdatedBy: "user-123",
|
||||
OrgID: orgID.String(),
|
||||
UserEmail: "funnel@example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with user email from claims",
|
||||
funnel: &tracefunnel.Funnel{
|
||||
BaseMetadata: tracefunnel.BaseMetadata{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: funnelID,
|
||||
},
|
||||
Name: "test-funnel",
|
||||
OrgID: orgID,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
UserAuditable: types.UserAuditable{
|
||||
CreatedBy: "user-123",
|
||||
UpdatedBy: "user-123",
|
||||
},
|
||||
},
|
||||
Steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
claims: &authtypes.Claims{
|
||||
UserID: "user-123",
|
||||
OrgID: orgID.String(),
|
||||
Email: "claims@example.com",
|
||||
},
|
||||
expected: tracefunnel.FunnelResponse{
|
||||
FunnelName: "test-funnel",
|
||||
FunnelID: funnelID.String(),
|
||||
Steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
},
|
||||
CreatedAt: now.UnixNano() / 1000000,
|
||||
CreatedBy: "user-123",
|
||||
UpdatedAt: now.UnixNano() / 1000000,
|
||||
UpdatedBy: "user-123",
|
||||
OrgID: orgID.String(),
|
||||
UserEmail: "claims@example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ConstructFunnelResponse(tt.funnel, tt.claims)
|
||||
|
||||
// Compare top-level fields
|
||||
assert.Equal(t, tt.expected.FunnelName, result.FunnelName)
|
||||
assert.Equal(t, tt.expected.FunnelID, result.FunnelID)
|
||||
assert.Equal(t, tt.expected.CreatedAt, result.CreatedAt)
|
||||
assert.Equal(t, tt.expected.CreatedBy, result.CreatedBy)
|
||||
assert.Equal(t, tt.expected.UpdatedAt, result.UpdatedAt)
|
||||
assert.Equal(t, tt.expected.UpdatedBy, result.UpdatedBy)
|
||||
assert.Equal(t, tt.expected.OrgID, result.OrgID)
|
||||
assert.Equal(t, tt.expected.UserEmail, result.UserEmail)
|
||||
|
||||
// Compare steps
|
||||
assert.Len(t, result.Steps, len(tt.expected.Steps))
|
||||
for i, step := range result.Steps {
|
||||
expectedStep := tt.expected.Steps[i]
|
||||
assert.Equal(t, expectedStep.Name, step.Name)
|
||||
assert.Equal(t, expectedStep.ServiceName, step.ServiceName)
|
||||
assert.Equal(t, expectedStep.SpanName, step.SpanName)
|
||||
assert.Equal(t, expectedStep.Order, step.Order)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFunnelSteps(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
steps []tracefunnel.FunnelStep
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid steps with missing IDs",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: 0, // Will be normalized to 1
|
||||
},
|
||||
{
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 0, // Will be normalized to 2
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid steps - missing service name",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
Name: "Step 1",
|
||||
SpanName: "test-span",
|
||||
Order: 1,
|
||||
},
|
||||
{
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid steps - negative order",
|
||||
steps: []tracefunnel.FunnelStep{
|
||||
{
|
||||
Name: "Step 1",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span",
|
||||
Order: -1,
|
||||
},
|
||||
{
|
||||
Name: "Step 2",
|
||||
ServiceName: "test-service",
|
||||
SpanName: "test-span-2",
|
||||
Order: 2,
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ProcessFunnelSteps(tt.steps)
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Len(t, result, len(tt.steps))
|
||||
|
||||
// Verify IDs are generated
|
||||
for _, step := range result {
|
||||
assert.False(t, step.ID.IsZero())
|
||||
}
|
||||
|
||||
// Verify orders are normalized
|
||||
for i, step := range result {
|
||||
assert.Equal(t, int64(i+1), step.Order)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,12 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
|
||||
tracefunnels "github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations/services"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
|
||||
@@ -5108,3 +5110,226 @@ func (aH *APIHandler) getDomainInfo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
aH.Respond(w, resp)
|
||||
}
|
||||
|
||||
// RegisterTraceFunnelsRoutes adds trace funnels routes
|
||||
func (aH *APIHandler) RegisterTraceFunnelsRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
// Main trace funnels router
|
||||
traceFunnelsRouter := router.PathPrefix("/api/v1/trace-funnels").Subrouter()
|
||||
|
||||
// API endpoints
|
||||
traceFunnelsRouter.HandleFunc("/new",
|
||||
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.New)).
|
||||
Methods(http.MethodPost)
|
||||
traceFunnelsRouter.HandleFunc("/list",
|
||||
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.List)).
|
||||
Methods(http.MethodGet)
|
||||
traceFunnelsRouter.HandleFunc("/steps/update",
|
||||
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.UpdateSteps)).
|
||||
Methods(http.MethodPut)
|
||||
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}",
|
||||
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.Get)).
|
||||
Methods(http.MethodGet)
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}",
|
||||
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.Delete)).
|
||||
Methods(http.MethodDelete)
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}",
|
||||
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.UpdateFunnel)).
|
||||
Methods(http.MethodPut)
|
||||
traceFunnelsRouter.HandleFunc("/save",
|
||||
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.Save)).
|
||||
Methods(http.MethodPost)
|
||||
// Analytics endpoints
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/validate", aH.handleValidateTraces).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/overview", aH.handleFunnelAnalytics).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/steps", aH.handleStepAnalytics).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/steps/overview", aH.handleFunnelStepAnalytics).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/slow-traces", aH.handleFunnelSlowTraces).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/error-traces", aH.handleFunnelErrorTraces).Methods("POST")
|
||||
|
||||
}
|
||||
|
||||
func (aH *APIHandler) handleValidateTraces(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var timeRange traceFunnels.TimeRange
|
||||
if err := json.NewDecoder(r.Body).Decode(&timeRange); err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("error decoding time range: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
if len(funnel.Steps) < 2 {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("funnel must have at least 2 steps")}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
chq, err := tracefunnels.ValidateTraces(funnel, timeRange)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, results)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) handleFunnelAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var stepTransition traceFunnels.StepTransitionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&stepTransition); err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("error decoding time range: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
chq, err := tracefunnels.GetFunnelAnalytics(funnel, stepTransition.TimeRange)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, results)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) handleFunnelStepAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var stepTransition traceFunnels.StepTransitionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&stepTransition); err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("error decoding time range: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
chq, err := tracefunnels.GetFunnelStepAnalytics(funnel, stepTransition.TimeRange, stepTransition.StepAOrder, stepTransition.StepBOrder)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, results)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) handleStepAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var timeRange traceFunnels.TimeRange
|
||||
if err := json.NewDecoder(r.Body).Decode(&timeRange); err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("error decoding time range: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
chq, err := tracefunnels.GetStepAnalytics(funnel, timeRange)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, results)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) handleFunnelSlowTraces(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var req traceFunnels.StepTransitionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("invalid request body: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
chq, err := tracefunnels.GetSlowestTraces(funnel, req.TimeRange, req.StepAOrder, req.StepBOrder)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, results)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) handleFunnelErrorTraces(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
funnelID := vars["funnel_id"]
|
||||
|
||||
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
var req traceFunnels.StepTransitionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("invalid request body: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
chq, err := tracefunnels.GetErroredTraces(funnel, req.TimeRange, req.StepAOrder, req.StepBOrder)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, results)
|
||||
}
|
||||
|
||||
@@ -273,6 +273,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
|
||||
api.RegisterMessagingQueuesRoutes(r, am)
|
||||
api.RegisterThirdPartyApiRoutes(r, am)
|
||||
api.MetricExplorerRoutes(r, am)
|
||||
api.RegisterTraceFunnelsRoutes(r, am)
|
||||
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
|
||||
@@ -87,7 +87,7 @@ func existsSubQueryForFixedColumn(key v3.AttributeKey, op v3.FilterOperator) (st
|
||||
}
|
||||
}
|
||||
|
||||
func buildTracesFilterQuery(fs *v3.FilterSet) (string, error) {
|
||||
func BuildTracesFilterQuery(fs *v3.FilterSet) (string, error) {
|
||||
var conditions []string
|
||||
|
||||
if fs != nil && len(fs.Items) != 0 {
|
||||
@@ -167,7 +167,7 @@ func handleEmptyValuesInGroupBy(groupBy []v3.AttributeKey) (string, error) {
|
||||
Operator: "AND",
|
||||
Items: filterItems,
|
||||
}
|
||||
return buildTracesFilterQuery(&filterSet)
|
||||
return BuildTracesFilterQuery(&filterSet)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
@@ -248,7 +248,7 @@ func buildTracesQuery(start, end, step int64, mq *v3.BuilderQuery, panelType v3.
|
||||
|
||||
timeFilter := fmt.Sprintf("(timestamp >= '%d' AND timestamp <= '%d') AND (ts_bucket_start >= %d AND ts_bucket_start <= %d)", tracesStart, tracesEnd, bucketStart, bucketEnd)
|
||||
|
||||
filterSubQuery, err := buildTracesFilterQuery(mq.Filters)
|
||||
filterSubQuery, err := BuildTracesFilterQuery(mq.Filters)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ func Test_buildTracesFilterQuery(t *testing.T) {
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "Test buildTracesFilterQuery in, nin",
|
||||
name: "Test BuildTracesFilterQuery in, nin",
|
||||
args: args{
|
||||
fs: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
|
||||
{Key: v3.AttributeKey{Key: "method", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: []interface{}{"GET", "POST"}, Operator: v3.FilterOperatorIn},
|
||||
@@ -226,7 +226,7 @@ func Test_buildTracesFilterQuery(t *testing.T) {
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Test buildTracesFilterQuery not eq, neq, gt, lt, gte, lte",
|
||||
name: "Test BuildTracesFilterQuery not eq, neq, gt, lt, gte, lte",
|
||||
args: args{
|
||||
fs: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
|
||||
{Key: v3.AttributeKey{Key: "duration", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag}, Value: 102, Operator: v3.FilterOperatorEqual},
|
||||
@@ -274,13 +274,13 @@ func Test_buildTracesFilterQuery(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := buildTracesFilterQuery(tt.args.fs)
|
||||
got, err := BuildTracesFilterQuery(tt.args.fs)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("buildTracesFilterQuery() error = %v, wantErr %v", err, tt.wantErr)
|
||||
t.Errorf("BuildTracesFilterQuery() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("buildTracesFilterQuery() = %v, want %v", got, tt.want)
|
||||
t.Errorf("BuildTracesFilterQuery() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ type Feature struct {
|
||||
|
||||
const UseSpanMetrics = "USE_SPAN_METRICS"
|
||||
const AnomalyDetection = "ANOMALY_DETECTION"
|
||||
const TraceFunnels = "TRACE_FUNNELS"
|
||||
|
||||
var BasicPlan = FeatureSet{
|
||||
Feature{
|
||||
@@ -28,11 +27,4 @@ var BasicPlan = FeatureSet{
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
Feature{
|
||||
Name: TraceFunnels,
|
||||
Active: false,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference/implpreference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
)
|
||||
|
||||
@@ -18,6 +20,7 @@ type Handlers struct {
|
||||
Organization organization.Handler
|
||||
Preference preference.Handler
|
||||
User user.Handler
|
||||
TraceFunnel tracefunnel.Handler
|
||||
SavedView savedview.Handler
|
||||
Apdex apdex.Handler
|
||||
Dashboard dashboard.Handler
|
||||
@@ -27,6 +30,7 @@ func NewHandlers(modules Modules, user user.Handler) Handlers {
|
||||
return Handlers{
|
||||
Organization: implorganization.NewHandler(modules.Organization),
|
||||
Preference: implpreference.NewHandler(modules.Preference),
|
||||
TraceFunnel: impltracefunnel.NewHandler(modules.TraceFunnel),
|
||||
User: user,
|
||||
SavedView: implsavedview.NewHandler(modules.SavedView),
|
||||
Apdex: implapdex.NewHandler(modules.Apdex),
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference/implpreference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
@@ -19,6 +21,7 @@ import (
|
||||
type Modules struct {
|
||||
Organization organization.Module
|
||||
Preference preference.Module
|
||||
TraceFunnel tracefunnel.Module
|
||||
User user.Module
|
||||
SavedView savedview.Module
|
||||
Apdex apdex.Module
|
||||
@@ -29,6 +32,7 @@ func NewModules(sqlstore sqlstore.SQLStore, user user.Module) Modules {
|
||||
return Modules{
|
||||
Organization: implorganization.NewModule(implorganization.NewStore(sqlstore)),
|
||||
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewDefaultPreferenceMap()),
|
||||
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
|
||||
User: user,
|
||||
SavedView: implsavedview.NewModule(sqlstore),
|
||||
Apdex: implapdex.NewModule(sqlstore),
|
||||
|
||||
@@ -76,6 +76,7 @@ func NewSQLMigrationProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedM
|
||||
sqlmigration.NewDropGroupsFactory(sqlstore),
|
||||
sqlmigration.NewCreateQuickFiltersFactory(sqlstore),
|
||||
sqlmigration.NewUpdateQuickFiltersFactory(sqlstore),
|
||||
sqlmigration.NewAddTraceFunnelsFactory(sqlstore),
|
||||
sqlmigration.NewAuthRefactorFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
114
pkg/sqlmigration/050_add_trace_funnels.go
Normal file
114
pkg/sqlmigration/050_add_trace_funnels.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addTraceFunnels struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddTraceFunnelsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.
|
||||
NewProviderFactory(factory.
|
||||
MustNewName("add_trace_funnels"),
|
||||
func(ctx context.Context, providerSettings factory.ProviderSettings, config Config) (SQLMigration, error) {
|
||||
return newAddTraceFunnels(ctx, providerSettings, config, sqlstore)
|
||||
})
|
||||
}
|
||||
|
||||
func newAddTraceFunnels(_ context.Context, _ factory.ProviderSettings, _ Config, sqlstore sqlstore.SQLStore) (SQLMigration, error) {
|
||||
return &addTraceFunnels{sqlstore: sqlstore}, nil
|
||||
}
|
||||
|
||||
func (migration *addTraceFunnels) Register(migrations *migrate.Migrations) error {
|
||||
if err := migrations.
|
||||
Register(migration.Up, migration.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *addTraceFunnels) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Create trace_funnel table with foreign key constraint inline
|
||||
_, err = tx.NewCreateTable().
|
||||
Model((*traceFunnels.Funnel)(nil)).
|
||||
ForeignKey(`("org_id") REFERENCES "organizations" ("id") ON DELETE CASCADE`).
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create trace_funnel table: %v", err)
|
||||
}
|
||||
|
||||
// Add unique constraint for org_id and name
|
||||
//_, err = tx.NewRaw(`
|
||||
// CREATE UNIQUE INDEX IF NOT EXISTS idx_trace_funnel_org_id_name
|
||||
// ON trace_funnel (org_id, name)
|
||||
//`).Exec(ctx)
|
||||
//if err != nil {
|
||||
// return fmt.Errorf("failed to create unique constraint: %v", err)
|
||||
//}
|
||||
|
||||
// Create indexes
|
||||
_, err = tx.NewCreateIndex().
|
||||
Model((*traceFunnels.Funnel)(nil)).
|
||||
Index("idx_trace_funnel_org_id").
|
||||
Column("org_id").
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create org_id index: %v", err)
|
||||
}
|
||||
|
||||
_, err = tx.NewCreateIndex().
|
||||
Model((*traceFunnels.Funnel)(nil)).
|
||||
Index("idx_trace_funnel_created_at").
|
||||
Column("created_at").
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create created_at index: %v", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *addTraceFunnels) Down(ctx context.Context, db *bun.DB) error {
|
||||
//tx, err := db.BeginTx(ctx, nil)
|
||||
//if err != nil {
|
||||
// return err
|
||||
//}
|
||||
//defer tx.Rollback()
|
||||
//
|
||||
//// Drop trace_funnel table
|
||||
//_, err = tx.NewDropTable().
|
||||
// Model((*traceFunnels.Funnel)(nil)).
|
||||
// IfExists().
|
||||
// Exec(ctx)
|
||||
//if err != nil {
|
||||
// return fmt.Errorf("failed to drop trace_funnel table: %v", err)
|
||||
//}
|
||||
//
|
||||
//if err := tx.Commit(); err != nil {
|
||||
// return err
|
||||
//}
|
||||
|
||||
return nil
|
||||
}
|
||||
15
pkg/types/tracefunnel/store.go
Normal file
15
pkg/types/tracefunnel/store.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package tracefunnel
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type FunnelStore interface {
|
||||
Create(context.Context, *Funnel) error
|
||||
Get(context.Context, valuer.UUID) (*Funnel, error)
|
||||
List(context.Context, valuer.UUID) ([]*Funnel, error)
|
||||
Update(context.Context, *Funnel) error
|
||||
Delete(context.Context, valuer.UUID) error
|
||||
}
|
||||
103
pkg/types/tracefunnel/tracefunnel.go
Normal file
103
pkg/types/tracefunnel/tracefunnel.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package tracefunnel
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFunnelAlreadyExists = errors.MustNewCode("funnel_already_exists")
|
||||
)
|
||||
|
||||
// BaseMetadata metadata for funnels
|
||||
type BaseMetadata struct {
|
||||
types.Identifiable // funnel id
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
Name string `json:"funnel_name" bun:"name,type:text,notnull"` // funnel name
|
||||
Description string `json:"description" bun:"description,type:text"` // funnel description
|
||||
OrgID valuer.UUID `json:"org_id" bun:"org_id,type:varchar,notnull"`
|
||||
}
|
||||
|
||||
// Funnel Core Data Structure (Funnel and FunnelStep)
|
||||
type Funnel struct {
|
||||
bun.BaseModel `bun:"table:trace_funnel"`
|
||||
BaseMetadata
|
||||
Steps []FunnelStep `json:"steps" bun:"steps,type:text,notnull"`
|
||||
Tags string `json:"tags" bun:"tags,type:text"`
|
||||
CreatedByUser *types.User `json:"user" bun:"rel:belongs-to,join:created_by=id"`
|
||||
}
|
||||
|
||||
type FunnelStep struct {
|
||||
ID valuer.UUID `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"` // step name
|
||||
Description string `json:"description,omitempty"` // step description
|
||||
Order int64 `json:"step_order"`
|
||||
ServiceName string `json:"service_name"`
|
||||
SpanName string `json:"span_name"`
|
||||
Filters *v3.FilterSet `json:"filters,omitempty"`
|
||||
LatencyPointer string `json:"latency_pointer,omitempty"`
|
||||
LatencyType string `json:"latency_type,omitempty"`
|
||||
HasErrors bool `json:"has_errors"`
|
||||
}
|
||||
|
||||
// FunnelRequest represents all possible funnel-related requests
|
||||
type FunnelRequest struct {
|
||||
FunnelID valuer.UUID `json:"funnel_id,omitempty"`
|
||||
Name string `json:"funnel_name,omitempty"`
|
||||
Timestamp int64 `json:"timestamp,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Steps []FunnelStep `json:"steps,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
|
||||
// Analytics specific fields
|
||||
StartTime int64 `json:"start_time,omitempty"`
|
||||
EndTime int64 `json:"end_time,omitempty"`
|
||||
StepAOrder int64 `json:"step_a_order,omitempty"`
|
||||
StepBOrder int64 `json:"step_b_order,omitempty"`
|
||||
}
|
||||
|
||||
// FunnelResponse represents all possible funnel-related responses
|
||||
type FunnelResponse struct {
|
||||
FunnelID string `json:"funnel_id,omitempty"`
|
||||
FunnelName string `json:"funnel_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
CreatedAt int64 `json:"created_at,omitempty"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"`
|
||||
UpdatedBy string `json:"updated_by,omitempty"`
|
||||
OrgID string `json:"org_id,omitempty"`
|
||||
UserEmail string `json:"user_email,omitempty"`
|
||||
Funnel *Funnel `json:"funnel,omitempty"`
|
||||
Steps []FunnelStep `json:"steps,omitempty"`
|
||||
}
|
||||
|
||||
// TimeRange represents a time range for analytics
|
||||
type TimeRange struct {
|
||||
StartTime int64 `json:"start_time"`
|
||||
EndTime int64 `json:"end_time"`
|
||||
}
|
||||
|
||||
// StepTransitionRequest represents a request for step transition analytics
|
||||
type StepTransitionRequest struct {
|
||||
TimeRange
|
||||
StepAOrder int64 `json:"step_start,omitempty"`
|
||||
StepBOrder int64 `json:"step_end,omitempty"`
|
||||
}
|
||||
|
||||
// UserInfo represents basic user information
|
||||
type UserInfo struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type FunnelStepFilter struct {
|
||||
StepNumber int
|
||||
ServiceName string
|
||||
SpanName string
|
||||
LatencyPointer string // "start" or "end"
|
||||
CustomFilters *v3.FilterSet
|
||||
}
|
||||
Reference in New Issue
Block a user