fix: trace funnel bugfixes and improvements (#7922)

* fix: display the inter-step latency type in step metrics table

* chore: send latency type with n+1th step + make latency type optional

* fix: fetch and format get funnel steps overview metrics

* chore: remove dev env check

* fix: overall fixes

* fix: don't cache validate query + trigger validate on changing error and where clause as well

* fix: display the latency type in step overview metrics table + p99_latency to latency

* chore: revert dev env check removal (remove after BE changes are merged)

* fix: adjust create API response

* chore: useLocalStorage custom hook

* feat: improve the run funnel flow

- for the initial fetch of funnel results, require the user to run the funnel
- subsequently change the run funnel button to a refresh button
- display loading state while any of the funnel results APIs are being fetched

* fix: fix the issue of add step details breaking

* fix: refetch funnel details on rename success

* fix: redirect 'learn more' to trace funnels docs

* fix: handle potential undefined step in latency type calculation

* fix: properly handle incomplete steps state

* fix: fix the edge case of stale validation state on transitioning from invalid steps to valid steps

* fix: remove the side effect from render and move to useEffect
This commit is contained in:
Shaheer Kochai 2025-05-20 19:44:05 +04:30 committed by GitHub
parent 354e4b4b8f
commit 57f96574ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 488 additions and 115 deletions

View File

@ -22,7 +22,7 @@ export const createFunnel = async (
statusCode: 200,
error: null,
message: 'Funnel created successfully',
payload: response.data,
payload: response.data.data,
};
};
@ -196,7 +196,9 @@ export interface FunnelOverviewResponse {
avg_rate: number;
conversion_rate: number | null;
errors: number;
// TODO(shaheer): remove p99_latency once we have support for latency
p99_latency: number;
latency: number;
};
}>;
}
@ -222,13 +224,6 @@ export const getFunnelOverview = async (
};
};
export interface SlowTracesPayload {
start_time: number;
end_time: number;
step_a_order: number;
step_b_order: number;
}
export interface SlowTraceData {
status: string;
data: Array<{
@ -243,7 +238,7 @@ export interface SlowTraceData {
export const getFunnelSlowTraces = async (
funnelId: string,
payload: SlowTracesPayload,
payload: FunnelOverviewPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<SlowTraceData> | ErrorResponse> => {
const response = await axios.post(
@ -261,12 +256,6 @@ export const getFunnelSlowTraces = async (
payload: response.data,
};
};
export interface ErrorTracesPayload {
start_time: number;
end_time: number;
step_a_order: number;
step_b_order: number;
}
export interface ErrorTraceData {
status: string;
@ -282,7 +271,7 @@ export interface ErrorTraceData {
export const getFunnelErrorTraces = async (
funnelId: string,
payload: ErrorTracesPayload,
payload: FunnelOverviewPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<ErrorTraceData> | ErrorResponse> => {
const response: AxiosResponse = await axios.post(
@ -337,3 +326,37 @@ export const getFunnelSteps = async (
payload: response.data,
};
};
export interface FunnelStepsOverviewPayload {
start_time: number;
end_time: number;
step_start?: number;
step_end?: number;
}
export interface FunnelStepsOverviewResponse {
status: string;
data: Array<{
timestamp: string;
data: Record<string, number>;
}>;
}
export const getFunnelStepsOverview = async (
funnelId: string,
payload: FunnelStepsOverviewPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<FunnelStepsOverviewResponse> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/steps/overview`,
payload,
{ signal },
);
return {
statusCode: 200,
error: null,
message: '',
payload: response.data,
};
};

View File

@ -30,4 +30,5 @@ export enum LOCALSTORAGE {
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
BANNER_DISMISSED = 'BANNER_DISMISSED',
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
UNEXECUTED_FUNNELS = 'UNEXECUTED_FUNNELS',
}

View File

@ -75,6 +75,7 @@ export const REACT_QUERY_KEY = {
VALIDATE_FUNNEL_STEPS: 'VALIDATE_FUNNEL_STEPS',
UPDATE_FUNNEL_STEP_DETAILS: 'UPDATE_FUNNEL_STEP_DETAILS',
GET_FUNNEL_OVERVIEW: 'GET_FUNNEL_OVERVIEW',
GET_FUNNEL_STEPS_OVERVIEW: 'GET_FUNNEL_STEPS_OVERVIEW',
GET_FUNNEL_SLOW_TRACES: 'GET_FUNNEL_SLOW_TRACES',
GET_FUNNEL_ERROR_TRACES: 'GET_FUNNEL_ERROR_TRACES',
GET_FUNNEL_STEPS_GRAPH_DATA: 'GET_FUNNEL_STEPS_GRAPH_DATA',

View File

@ -43,8 +43,7 @@ export default function useFunnelConfiguration({
const {
steps,
initialSteps,
setHasIncompleteStepFields,
setHasAllEmptyStepFields,
hasIncompleteStepFields,
handleRestoreSteps,
} = useFunnelContext();
@ -74,14 +73,16 @@ export default function useFunnelConfiguration({
return !isEqual(normalizedDebouncedSteps, normalizedLastSavedSteps);
}, [debouncedSteps]);
const hasStepServiceOrSpanNameChanged = useCallback(
const hasFunnelStepDefinitionsChanged = useCallback(
(prevSteps: FunnelStepData[], nextSteps: FunnelStepData[]): boolean => {
if (prevSteps.length !== nextSteps.length) return true;
return prevSteps.some((step, index) => {
const nextStep = nextSteps[index];
return (
step.service_name !== nextStep.service_name ||
step.span_name !== nextStep.span_name
step.span_name !== nextStep.span_name ||
!isEqual(step.filters, nextStep.filters) ||
step.has_errors !== nextStep.has_errors
);
});
},
@ -106,12 +107,7 @@ export default function useFunnelConfiguration({
[funnel.funnel_id, selectedTime],
);
useEffect(() => {
// Check if all steps have both service_name and span_name defined
const shouldUpdate = debouncedSteps.every(
(step) => step.service_name !== '' && step.span_name !== '',
);
if (hasStepsChanged() && shouldUpdate) {
if (hasStepsChanged() && !hasIncompleteStepFields) {
updateStepsMutation.mutate(getUpdatePayload(), {
onSuccess: (data) => {
const updatedFunnelSteps = data?.payload?.steps;
@ -135,17 +131,10 @@ export default function useFunnelConfiguration({
(step) => step.service_name === '' || step.span_name === '',
);
const hasAllEmptyStepsData = updatedFunnelSteps.every(
(step) => step.service_name === '' && step.span_name === '',
);
setHasIncompleteStepFields(hasIncompleteStepFields);
setHasAllEmptyStepFields(hasAllEmptyStepsData);
// Only validate if service_name or span_name changed
if (
!hasIncompleteStepFields &&
hasStepServiceOrSpanNameChanged(lastValidatedSteps, debouncedSteps)
hasFunnelStepDefinitionsChanged(lastValidatedSteps, debouncedSteps)
) {
queryClient.refetchQueries(validateStepsQueryKey);
setLastValidatedSteps(debouncedSteps);
@ -171,7 +160,7 @@ export default function useFunnelConfiguration({
}, [
debouncedSteps,
getUpdatePayload,
hasStepServiceOrSpanNameChanged,
hasFunnelStepDefinitionsChanged,
hasStepsChanged,
lastValidatedSteps,
queryClient,

View File

@ -2,8 +2,9 @@ import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { MetricItem } from 'pages/TracesFunnelDetails/components/FunnelResults/FunnelMetricsTable';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useMemo } from 'react';
import { LatencyOptions } from 'types/api/traceFunnels';
import { useFunnelOverview } from './useFunnels';
import { useFunnelOverview, useFunnelStepsOverview } from './useFunnels';
interface FunnelMetricsParams {
funnelId: string;
@ -13,8 +14,6 @@ interface FunnelMetricsParams {
export function useFunnelMetrics({
funnelId,
stepStart,
stepEnd,
}: FunnelMetricsParams): {
isLoading: boolean;
isError: boolean;
@ -25,8 +24,6 @@ export function useFunnelMetrics({
const payload = {
start_time: startTime,
end_time: endTime,
...(stepStart !== undefined && { step_start: stepStart }),
...(stepEnd !== undefined && { step_end: stepEnd }),
};
const {
@ -48,14 +45,18 @@ export function useFunnelMetrics({
{ title: 'Errors', value: sourceData.errors },
{
title: 'Avg. Duration',
value: getYAxisFormattedValue(sourceData.avg_duration.toString(), 'ns'),
value: getYAxisFormattedValue(sourceData.avg_duration.toString(), 'ms'),
},
{
title: 'P99 Latency',
value: getYAxisFormattedValue(sourceData.p99_latency.toString(), 'ns'),
title: `P99 Latency`,
value: getYAxisFormattedValue(
// TODO(shaheer): remove p99_latency once we have support for latency
(sourceData.latency ?? sourceData.p99_latency).toString(),
'ms',
),
},
];
}, [overviewData]);
}, [overviewData?.payload?.data]);
const conversionRate =
overviewData?.payload?.data?.[0]?.data?.conversion_rate ?? 0;
@ -67,3 +68,72 @@ export function useFunnelMetrics({
conversionRate,
};
}
export function useFunnelStepsMetrics({
funnelId,
stepStart,
stepEnd,
}: FunnelMetricsParams): {
isLoading: boolean;
isError: boolean;
metricsData: MetricItem[];
conversionRate: number;
} {
const { startTime, endTime, steps } = useFunnelContext();
const payload = {
start_time: startTime,
end_time: endTime,
step_start: stepStart,
step_end: stepEnd,
};
const {
data: stepsOverviewData,
isLoading,
isFetching,
isError,
} = useFunnelStepsOverview(funnelId, payload);
const latencyType = useMemo(
() => (stepStart ? steps[stepStart]?.latency_type : LatencyOptions.P99),
[stepStart, steps],
);
const metricsData = useMemo(() => {
const sourceData = stepsOverviewData?.payload?.data?.[0]?.data;
if (!sourceData) return [];
return [
{
title: 'Avg. Rate',
value: `${Number(sourceData.avg_rate.toFixed(2))} req/s`,
},
{ title: 'Errors', value: sourceData.errors },
{
title: 'Avg. Duration',
value: getYAxisFormattedValue(
(sourceData.avg_duration * 1_000_000).toString(),
'ns',
),
},
{
title: `${latencyType?.toUpperCase()} Latency`,
value: getYAxisFormattedValue(
// TODO(shaheer): remove p99_latency once we have support for latency
((sourceData.latency ?? sourceData.p99_latency) * 1_000_000).toString(),
'ns',
),
},
];
}, [stepsOverviewData, latencyType]);
const conversionRate =
stepsOverviewData?.payload?.data?.[0]?.data?.conversion_rate ?? 0;
return {
isLoading: isLoading || isFetching,
isError,
metricsData,
conversionRate,
};
}

View File

@ -3,9 +3,10 @@ import {
createFunnel,
deleteFunnel,
ErrorTraceData,
ErrorTracesPayload,
FunnelOverviewPayload,
FunnelOverviewResponse,
FunnelStepsOverviewPayload,
FunnelStepsOverviewResponse,
FunnelStepsResponse,
getFunnelById,
getFunnelErrorTraces,
@ -13,11 +14,11 @@ import {
getFunnelsList,
getFunnelSlowTraces,
getFunnelSteps,
getFunnelStepsOverview,
renameFunnel,
RenameFunnelPayload,
saveFunnelDescription,
SlowTraceData,
SlowTracesPayload,
updateFunnelSteps,
UpdateFunnelStepsPayload,
ValidateFunnelResponse,
@ -115,11 +116,13 @@ export const useValidateFunnelSteps = ({
selectedTime,
startTime,
endTime,
enabled,
}: {
funnelId: string;
selectedTime: string;
startTime: number;
endTime: number;
enabled: boolean;
}): UseQueryResult<
SuccessResponse<ValidateFunnelResponse> | ErrorResponse,
Error
@ -132,8 +135,8 @@ export const useValidateFunnelSteps = ({
signal,
),
queryKey: [REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS, funnelId, selectedTime],
enabled: !!funnelId && !!selectedTime && !!startTime && !!endTime,
staleTime: 1000 * 60 * 5,
enabled,
staleTime: 0,
});
interface SaveFunnelDescriptionPayload {
@ -157,7 +160,11 @@ export const useFunnelOverview = (
SuccessResponse<FunnelOverviewResponse> | ErrorResponse,
Error
> => {
const { selectedTime, validTracesCount } = useFunnelContext();
const {
selectedTime,
validTracesCount,
hasFunnelBeenExecuted,
} = useFunnelContext();
return useQuery({
queryFn: ({ signal }) => getFunnelOverview(funnelId, payload, signal),
queryKey: [
@ -167,31 +174,51 @@ export const useFunnelOverview = (
payload.step_start ?? '',
payload.step_end ?? '',
],
enabled: !!funnelId && validTracesCount > 0,
enabled: !!funnelId && validTracesCount > 0 && hasFunnelBeenExecuted,
});
};
export const useFunnelSlowTraces = (
funnelId: string,
payload: SlowTracesPayload,
payload: FunnelOverviewPayload,
): UseQueryResult<SuccessResponse<SlowTraceData> | ErrorResponse, Error> => {
const { selectedTime, validTracesCount } = useFunnelContext();
const {
selectedTime,
validTracesCount,
hasFunnelBeenExecuted,
} = useFunnelContext();
return useQuery<SuccessResponse<SlowTraceData> | ErrorResponse, Error>({
queryFn: ({ signal }) => getFunnelSlowTraces(funnelId, payload, signal),
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_SLOW_TRACES, funnelId, selectedTime],
enabled: !!funnelId && validTracesCount > 0,
queryKey: [
REACT_QUERY_KEY.GET_FUNNEL_SLOW_TRACES,
funnelId,
selectedTime,
payload.step_start ?? '',
payload.step_end ?? '',
],
enabled: !!funnelId && validTracesCount > 0 && hasFunnelBeenExecuted,
});
};
export const useFunnelErrorTraces = (
funnelId: string,
payload: ErrorTracesPayload,
payload: FunnelOverviewPayload,
): UseQueryResult<SuccessResponse<ErrorTraceData> | ErrorResponse, Error> => {
const { selectedTime, validTracesCount } = useFunnelContext();
const {
selectedTime,
validTracesCount,
hasFunnelBeenExecuted,
} = useFunnelContext();
return useQuery({
queryFn: ({ signal }) => getFunnelErrorTraces(funnelId, payload, signal),
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_ERROR_TRACES, funnelId, selectedTime],
enabled: !!funnelId && validTracesCount > 0,
queryKey: [
REACT_QUERY_KEY.GET_FUNNEL_ERROR_TRACES,
funnelId,
selectedTime,
payload.step_start ?? '',
payload.step_end ?? '',
],
enabled: !!funnelId && validTracesCount > 0 && hasFunnelBeenExecuted,
});
};
@ -203,6 +230,7 @@ export function useFunnelStepsGraphData(
endTime,
selectedTime,
validTracesCount,
hasFunnelBeenExecuted,
} = useFunnelContext();
return useQuery({
@ -217,6 +245,31 @@ export function useFunnelStepsGraphData(
funnelId,
selectedTime,
],
enabled: !!funnelId && validTracesCount > 0,
enabled: !!funnelId && validTracesCount > 0 && hasFunnelBeenExecuted,
});
}
export const useFunnelStepsOverview = (
funnelId: string,
payload: FunnelStepsOverviewPayload,
): UseQueryResult<
SuccessResponse<FunnelStepsOverviewResponse> | ErrorResponse,
Error
> => {
const {
selectedTime,
validTracesCount,
hasFunnelBeenExecuted,
} = useFunnelContext();
return useQuery({
queryFn: ({ signal }) => getFunnelStepsOverview(funnelId, payload, signal),
queryKey: [
REACT_QUERY_KEY.GET_FUNNEL_STEPS_OVERVIEW,
funnelId,
selectedTime,
payload.step_start ?? '',
payload.step_end ?? '',
],
enabled: !!funnelId && validTracesCount > 0 && hasFunnelBeenExecuted,
});
};

View File

@ -0,0 +1,83 @@
import { useCallback, useEffect, useState } from 'react';
/**
* A React hook for interacting with localStorage.
* It allows getting, setting, and removing items from localStorage.
*
* @template T The type of the value to be stored.
* @param {string} key The localStorage key.
* @param {T | (() => T)} initialValue The initial value to use if no value is found in localStorage,
* @returns {[T, (value: T | ((prevState: T) => T)) => void, () => void]}
* A tuple containing:
* - The current value from state (and localStorage).
* - A function to set the value (updates state and localStorage).
* - A function to remove the value from localStorage and reset state to initialValue.
*/
export function useLocalStorage<T>(
key: string,
initialValue: T | (() => T),
): [T, (value: T | ((prevState: T) => T)) => void, () => void] {
// This function resolves the initialValue if it's a function,
// and handles potential errors during localStorage access or JSON parsing.
const readValueFromStorage = useCallback((): T => {
const resolvedInitialValue =
initialValue instanceof Function ? initialValue() : initialValue;
try {
const item = window.localStorage.getItem(key);
// If item exists, parse it, otherwise return the resolved initial value.
if (item) {
return JSON.parse(item) as T;
}
} catch (error) {
// Log error and fall back to initial value if reading/parsing fails.
console.warn(`Error reading localStorage key "${key}":`, error);
}
return resolvedInitialValue;
}, [key, initialValue]);
// Initialize state by reading from localStorage.
const [storedValue, setStoredValue] = useState<T>(readValueFromStorage);
// This function updates both localStorage and the React state.
const setValue = useCallback(
(value: T | ((prevState: T) => T)) => {
try {
// If a function is passed to setValue, it receives the latest value from storage.
const latestValueFromStorage = readValueFromStorage();
const valueToStore =
value instanceof Function ? value(latestValueFromStorage) : value;
// Save to localStorage.
window.localStorage.setItem(key, JSON.stringify(valueToStore));
// Update React state.
setStoredValue(valueToStore);
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
},
[key, readValueFromStorage],
);
// This function removes the item from localStorage and resets the React state.
const removeValue = useCallback(() => {
try {
window.localStorage.removeItem(key);
// Reset state to the (potentially resolved) initialValue.
setStoredValue(
initialValue instanceof Function ? initialValue() : initialValue,
);
} catch (error) {
console.warn(`Error removing localStorage key "${key}":`, error);
}
}, [key, initialValue]);
// useEffect to update the storedValue if the key changes,
// or if the initialValue prop changes causing readValueFromStorage to change.
// This ensures the hook reflects the correct localStorage item if its key prop dynamically changes.
useEffect(() => {
setStoredValue(readValueFromStorage());
}, [key, readValueFromStorage]); // Re-run if key or the read function changes.
return [storedValue, setValue, removeValue];
}

View File

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

View File

@ -26,7 +26,7 @@ function InterStepConfig({
</div>
<div className="inter-step-config__latency-options">
<SignozRadioGroup
value={step.latency_type}
value={step.latency_type ?? LatencyOptions.P99}
options={options}
onChange={(e): void =>
onStepChange(index, {

View File

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

View File

@ -34,12 +34,16 @@
border: none;
display: flex;
align-items: center;
gap: 6px;
.ant-btn-icon {
margin-inline-end: 0 !important;
}
&--save {
background-color: var(--bg-slate-400);
font-size: 12px;
font-weight: 500;
line-height: 10px; /* 83.333% */
letter-spacing: 0.12px;
border-radius: 2px;
&--sync {
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
color: var(--bg-vanilla-400);
}
&--run {
background-color: var(--bg-robin-500);

View File

@ -1,8 +1,49 @@
import './StepsFooter.styles.scss';
import { Button, Skeleton } from 'antd';
import { Cone, Play } from 'lucide-react';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { Cone, Play, RefreshCcw } from 'lucide-react';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useEffect, useMemo } from 'react';
import { useIsFetching, useQueryClient } 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;
@ -14,10 +55,27 @@ function ValidTracesCount(): JSX.Element {
isValidateStepsLoading,
hasIncompleteStepFields,
validTracesCount,
funnelId,
selectedTime,
} = useFunnelContext();
if (isValidateStepsLoading) {
return <Skeleton.Button size="small" />;
const queryClient = useQueryClient();
const validationQueryKey = useMemo(
() => [REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS, funnelId, selectedTime],
[funnelId, selectedTime],
);
const validationStatus = queryClient.getQueryData(validationQueryKey);
useEffect(() => {
// Show loading state immediately when fields become valid
if (hasIncompleteStepFields && validationStatus !== 'pending') {
queryClient.setQueryData(validationQueryKey, 'pending');
}
}, [
hasIncompleteStepFields,
queryClient,
validationQueryKey,
validationStatus,
]);
if (hasAllEmptyStepFields) {
return (
@ -33,6 +91,10 @@ function ValidTracesCount(): JSX.Element {
);
}
if (isValidateStepsLoading || validationStatus === 'pending') {
return <Skeleton.Button size="small" />;
}
if (validTracesCount === 0) {
return (
<span className="steps-footer__valid-traces steps-footer__valid-traces--none">
@ -45,7 +107,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,6 +124,7 @@ function StepsFooter({ stepsCount }: StepsFooterProps): JSX.Element {
<ValidTracesCount />
</div>
<div className="steps-footer__right">
{!hasFunnelBeenExecuted ? (
<Button
disabled={validTracesCount === 0}
onClick={handleRunFunnel}
@ -65,6 +134,18 @@ function StepsFooter({ stepsCount }: StepsFooterProps): JSX.Element {
>
Run funnel
</Button>
) : (
<Button
type="text"
className="steps-footer__button steps-footer__button--sync"
icon={<RefreshCcw size={16} />}
onClick={handleRunFunnel}
loading={isFunnelResultsLoading}
disabled={validTracesCount === 0}
>
Refresh
</Button>
)}
</div>
</div>
);

View File

@ -18,7 +18,7 @@ function EmptyFunnelResults({
<div className="empty-funnel-results__title">{title}</div>
<div className="empty-funnel-results__description">{description}</div>
<div className="empty-funnel-results__learn-more">
<LearnMore />
<LearnMore url="https://signoz.io/blog/tracing-funnels-observability-distributed-systems/" />
</div>
</div>
</div>

View File

@ -1,7 +1,9 @@
import './FunnelResults.styles.scss';
import Spinner from 'components/Spinner';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useQueryClient } from 'react-query';
import EmptyFunnelResults from './EmptyFunnelResults';
import FunnelGraph from './FunnelGraph';
@ -14,11 +16,17 @@ function FunnelResults(): JSX.Element {
isValidateStepsLoading,
hasIncompleteStepFields,
hasAllEmptyStepFields,
hasFunnelBeenExecuted,
funnelId,
selectedTime,
} = useFunnelContext();
const queryClient = useQueryClient();
if (isValidateStepsLoading) {
return <Spinner size="large" />;
}
const validateQueryData = queryClient.getQueryData([
REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS,
funnelId,
selectedTime,
]);
if (hasAllEmptyStepFields) return <EmptyFunnelResults />;
@ -30,6 +38,10 @@ function FunnelResults(): JSX.Element {
/>
);
if (isValidateStepsLoading || validateQueryData === 'pending') {
return <Spinner size="large" />;
}
if (validTracesCount === 0) {
return (
<EmptyFunnelResults
@ -38,6 +50,14 @@ function FunnelResults(): JSX.Element {
/>
);
}
if (!hasFunnelBeenExecuted) {
return (
<EmptyFunnelResults
title="Funnel has not been run yet."
description="Run the funnel to see the results"
/>
);
}
return (
<div className="funnel-results">

View File

@ -1,4 +1,8 @@
import { ErrorTraceData, SlowTraceData } from 'api/traceFunnels';
import {
ErrorTraceData,
FunnelOverviewPayload,
SlowTraceData,
} from 'api/traceFunnels';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useMemo } from 'react';
import { UseQueryResult } from 'react-query';
@ -15,12 +19,7 @@ interface FunnelTopTracesTableProps {
tooltip: string;
useQueryHook: (
funnelId: string,
payload: {
start_time: number;
end_time: number;
step_a_order: number;
step_b_order: number;
},
payload: FunnelOverviewPayload,
) => UseQueryResult<
SuccessResponse<SlowTraceData | ErrorTraceData> | ErrorResponse,
Error
@ -40,8 +39,8 @@ function FunnelTopTracesTable({
() => ({
start_time: startTime,
end_time: endTime,
step_a_order: stepAOrder,
step_b_order: stepBOrder,
step_start: stepAOrder,
step_end: stepBOrder,
}),
[startTime, endTime, stepAOrder, stepBOrder],
);

View File

@ -1,4 +1,4 @@
import { useFunnelMetrics } from 'hooks/TracesFunnels/useFunnelMetrics';
import { useFunnelStepsMetrics } from 'hooks/TracesFunnels/useFunnelMetrics';
import { useParams } from 'react-router-dom';
import FunnelMetricsTable from './FunnelMetricsTable';
@ -22,7 +22,7 @@ function StepsTransitionMetrics({
(transition) => transition.value === selectedTransition,
);
const { isLoading, metricsData, conversionRate } = useFunnelMetrics({
const { isLoading, metricsData, conversionRate } = useFunnelStepsMetrics({
funnelId: funnelId || '',
stepStart: startStep,
stepEnd: endStep,

View File

@ -13,7 +13,7 @@ export const topTracesTableColumns = [
),
},
{
title: 'DURATION',
title: 'STEP TRANSITION DURATION',
dataIndex: 'duration_ms',
key: 'duration_ms',
render: (value: string): string => getYAxisFormattedValue(value, 'ms'),

View File

@ -12,7 +12,7 @@ export const initialStepsData: FunnelStepData[] = [
op: 'and',
},
latency_pointer: 'start',
latency_type: LatencyOptions.P95,
latency_type: undefined,
has_errors: false,
name: '',
description: '',

View File

@ -1,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,7 @@ export function FunnelProvider({
selectedTime,
startTime,
endTime,
enabled: !!funnelId && !!selectedTime && !!startTime && !!endTime,
});
const validTracesCount = useMemo(
@ -163,6 +181,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 +206,15 @@ export function FunnelProvider({
funnelId,
selectedTime,
]);
}, [funnelId, queryClient, selectedTime, validTracesCount]);
}, [
funnelId,
hasFunnelBeenExecuted,
unexecutedFunnels,
queryClient,
selectedTime,
setUnexecutedFunnels,
validTracesCount,
]);
const value = useMemo<FunnelContextType>(
() => ({
@ -202,11 +233,11 @@ export function FunnelProvider({
validationResponse,
isValidateStepsLoading: isValidationLoading || isValidationFetching,
hasIncompleteStepFields,
setHasIncompleteStepFields,
hasAllEmptyStepFields,
setHasAllEmptyStepFields,
handleReplaceStep,
handleRestoreSteps,
hasFunnelBeenExecuted,
setHasFunnelBeenExecuted,
}),
[
funnelId,
@ -224,11 +255,11 @@ export function FunnelProvider({
isValidationLoading,
isValidationFetching,
hasIncompleteStepFields,
setHasIncompleteStepFields,
hasAllEmptyStepFields,
setHasAllEmptyStepFields,
handleReplaceStep,
handleRestoreSteps,
hasFunnelBeenExecuted,
setHasFunnelBeenExecuted,
],
);

View File

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

View File

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

View File

@ -40,6 +40,10 @@ function RenameFunnel({
message: 'Funnel renamed successfully',
});
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_FUNNELS_LIST]);
queryClient.invalidateQueries([
REACT_QUERY_KEY.GET_FUNNEL_DETAILS,
funnelId,
]);
onClose();
},
onError: () => {

View File

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