Compare commits

...

2 Commits

Author SHA1 Message Date
Shivanshu Raj Shrivastava
5cf417f43c chore: tf dev testing wip
Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-03-31 13:17:11 +01:00
ahmadshaheer
13651b25a8 chore: kubecon demo frontend 2025-03-31 12:48:24 +01:00
91 changed files with 6934 additions and 64 deletions

View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -0,0 +1 @@
<svg width="14" height="14" fill="none" xmlns="http://www.w3.org/2000/svg"><g stroke="#C0C1C3" stroke-width="1.167" stroke-linecap="round" stroke-linejoin="round"><path d="m12.192 3.18-1.167 2.33-.583 1.165M7.31 12.74a.583.583 0 0 1-.835-.24L1.808 3.179"/><path d="M7 1.167c2.9 0 5.25.783 5.25 1.75 0 .966-2.35 1.75-5.25 1.75s-5.25-.784-5.25-1.75c0-.967 2.35-1.75 5.25-1.75ZM8.75 10.5h3.5M10.5 12.25v-3.5"/></g></svg>

After

Width:  |  Height:  |  Size: 418 B

View File

@@ -0,0 +1 @@
<svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)" stroke-linecap="round" stroke-linejoin="round"><path d="M8 14.666A6.667 6.667 0 1 0 8 1.333a6.667 6.667 0 0 0 0 13.333Z" fill="#C0C1C3" stroke="#C0C1C3" stroke-width="2"/><path d="M8 11.333v-4H6.333M8 4.667h.007" stroke="#121317" stroke-width="1.333"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 439 B

View File

@@ -47,9 +47,10 @@ export const TracesFunnels = Loadable(
import(/* webpackChunkName: "Traces Funnels" */ 'pages/TracesModulePage'),
);
export const TracesFunnelDetails = Loadable(
// eslint-disable-next-line sonarjs/no-identical-functions
() =>
import(
/* webpackChunkName: "Traces Funnel Details" */ 'pages/TracesFunnelDetails'
/* webpackChunkName: "Traces Funnel Details" */ 'pages/TracesModulePage'
),
);

View File

@@ -5,6 +5,7 @@ import {
CreateFunnelPayload,
CreateFunnelResponse,
FunnelData,
FunnelStepData,
} from 'types/api/traceFunnels';
const FUNNELS_BASE_PATH = '/trace-funnels';
@@ -54,7 +55,7 @@ export const getFunnelsList = async ({
};
export const getFunnelById = async (
funnelId: string,
funnelId?: string,
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
const response: AxiosResponse = await axios.get(
`${FUNNELS_BASE_PATH}/get/${funnelId}`,
@@ -107,3 +108,267 @@ export const deleteFunnel = async (
payload: response.data,
};
};
export interface UpdateFunnelStepsPayload {
funnel_id: string;
steps: FunnelStepData[];
updated_timestamp: number;
}
export const updateFunnelSteps = async (
payload: UpdateFunnelStepsPayload,
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
const response: AxiosResponse = await axios.put(
`${FUNNELS_BASE_PATH}/steps/update`,
payload,
);
return {
statusCode: 200,
error: null,
message: 'Funnel steps updated successfully',
payload: response.data,
};
};
export interface ValidateFunnelPayload {
start_time: number;
end_time: number;
}
export interface ValidateFunnelResponse {
status: string;
data: Array<{
timestamp: string;
data: {
trace_id: string;
};
}> | null;
}
export const validateFunnelSteps = async (
funnelId: string,
payload: ValidateFunnelPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<ValidateFunnelResponse> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/validate`,
payload,
{ signal },
);
return {
statusCode: 200,
error: null,
message: '',
payload: response.data,
};
};
export interface UpdateFunnelStepDetailsPayload {
funnel_id: string;
steps: Array<{
step_name: string;
description: string;
}>;
updated_timestamp: number;
}
export const updateFunnelStepDetails = async ({
stepOrder,
payload,
}: {
stepOrder: number;
payload: UpdateFunnelStepDetailsPayload;
}): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
const response: AxiosResponse = await axios.put(
`${FUNNELS_BASE_PATH}/steps/${stepOrder}/update`,
payload,
);
return {
statusCode: 200,
error: null,
message: 'Funnel step details updated successfully',
payload: response.data,
};
};
interface UpdateFunnelDescriptionPayload {
funnel_id: string;
description: string;
}
export const saveFunnelDescription = async (
payload: UpdateFunnelDescriptionPayload,
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
const response: AxiosResponse = await axios.post(
`${FUNNELS_BASE_PATH}/save`,
payload,
);
return {
statusCode: 200,
error: null,
message: 'Funnel description updated successfully',
payload: response.data,
};
};
export interface FunnelOverviewPayload {
start_time: number;
end_time: number;
step_start?: number;
step_end?: number;
}
export interface FunnelOverviewResponse {
status: string;
data: Array<{
timestamp: string;
data: {
avg_duration: number;
avg_rate: number;
conversion_rate: number | null;
errors: number;
p99_latency: number;
};
}>;
}
export const getFunnelOverview = async (
funnelId: string,
payload: FunnelOverviewPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<FunnelOverviewResponse> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/overview`,
payload,
{
signal,
},
);
return {
statusCode: 200,
error: null,
message: '',
payload: response.data,
};
};
export interface SlowTracesPayload {
start_time: number;
end_time: number;
step_a_order: number;
step_b_order: number;
}
export interface SlowTraceData {
status: string;
data: Array<{
timestamp: string;
data: {
duration_ms: string;
span_count: number;
trace_id: string;
};
}>;
}
export const getFunnelSlowTraces = async (
funnelId: string,
payload: SlowTracesPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<SlowTraceData> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/slow-traces`,
payload,
{
signal,
},
);
return {
statusCode: 200,
error: null,
message: '',
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;
data: Array<{
timestamp: string;
data: {
duration_ms: string;
span_count: number;
trace_id: string;
};
}>;
}
export const getFunnelErrorTraces = async (
funnelId: string,
payload: ErrorTracesPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<ErrorTraceData> | ErrorResponse> => {
const response: AxiosResponse = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/error-traces`,
payload,
{
signal,
},
);
return {
statusCode: 200,
error: null,
message: '',
payload: response.data,
};
};
export interface FunnelStepsPayload {
start_time: number;
end_time: number;
}
export interface FunnelStepGraphMetrics {
[key: `total_s${number}_spans`]: number;
[key: `total_s${number}_errored_spans`]: number;
}
export interface FunnelStepsResponse {
status: string;
data: Array<{
timestamp: string;
data: FunnelStepGraphMetrics;
}>;
}
export const getFunnelSteps = async (
funnelId: string,
payload: FunnelStepsPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<FunnelStepsResponse> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/steps`,
payload,
{ signal },
);
return {
statusCode: 200,
error: null,
message: '',
payload: response.data,
};
};

View File

@@ -11,16 +11,24 @@ import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { useHistory, useLocation } from 'react-router-dom';
interface SelectOptionConfig {
export interface SelectOptionConfig {
placeholder: string;
queryParam: QueryParams;
filterType: string | string[];
shouldSetQueryParams?: boolean;
onChange?: (value: string | string[]) => void;
values?: string | string[];
isMultiple?: boolean;
}
function FilterSelect({
export function FilterSelect({
placeholder,
queryParam,
filterType,
values,
shouldSetQueryParams,
onChange,
isMultiple,
}: SelectOptionConfig): JSX.Element {
const { handleSearch, isFetching, options } = useCeleryFilterOptions(
filterType,
@@ -35,7 +43,8 @@ function FilterSelect({
key={filterType.toString()}
placeholder={placeholder}
showSearch
mode="multiple"
// eslint-disable-next-line react/jsx-props-no-spreading
{...(isMultiple ? { mode: 'multiple' } : {})}
options={options}
loading={isFetching}
className="config-select-option"
@@ -43,7 +52,11 @@ function FilterSelect({
maxTagCount={4}
allowClear
maxTagPlaceholder={SelectMaxTagPlaceholder}
value={getValuesFromQueryParams(queryParam, urlQuery) || []}
value={
!shouldSetQueryParams && !!values?.length
? values
: getValuesFromQueryParams(queryParam, urlQuery) || []
}
notFoundContent={
isFetching ? (
<span>
@@ -55,12 +68,28 @@ function FilterSelect({
}
onChange={(value): void => {
handleSearch('');
setQueryParamsFromOptions(value, urlQuery, history, location, queryParam);
if (shouldSetQueryParams) {
setQueryParamsFromOptions(
value as string[],
urlQuery,
history,
location,
queryParam,
);
}
onChange?.(value);
}}
/>
);
}
FilterSelect.defaultProps = {
shouldSetQueryParams: true,
onChange: (): void => {},
values: [],
isMultiple: true,
};
function CeleryOverviewConfigOptions(): JSX.Element {
const selectConfigs: SelectOptionConfig[] = [
{

View File

@@ -0,0 +1,40 @@
.change-percentage-pill {
display: flex;
align-items: center;
gap: 2px;
padding: 1px 4px;
border-radius: 50px;
&__icon {
display: flex;
align-items: center;
justify-content: center;
}
&__label {
font-family: 'Geist Mono';
font-size: 12px;
line-height: normal;
}
&--positive {
.change-percentage-pill {
&__icon {
color: var(--bg-forest-500);
}
&__label {
color: var(--bg-forest-500);
}
}
}
&--negative {
background: rgba(229, 72, 77, 0.1);
.change-percentage-pill {
&__icon {
color: var(--bg-cherry-500);
}
&__label {
color: var(--bg-cherry-500);
}
}
}
}

View File

@@ -0,0 +1,38 @@
import './ChangePercentagePill.styles.scss';
import { Color } from '@signozhq/design-tokens';
import cx from 'classnames';
import { ArrowDown, ArrowUp } from 'lucide-react';
interface ChangePercentagePillProps {
percentage: number;
direction: number;
}
function ChangePercentagePill({
percentage,
direction,
}: ChangePercentagePillProps): JSX.Element | null {
if (direction === 0 || percentage === 0) {
return null;
}
const isPositive = direction > 0;
return (
<div
className={cx('change-percentage-pill', {
'change-percentage-pill--positive': isPositive,
'change-percentage-pill--negative': !isPositive,
})}
>
<div className="change-percentage-pill__icon">
{isPositive ? (
<ArrowUp size={12} color={Color.BG_FOREST_500} />
) : (
<ArrowDown size={12} color={Color.BG_CHERRY_500} />
)}
</div>
<div className="change-percentage-pill__label">{percentage}%</div>
</div>
);
}
export default ChangePercentagePill;

View File

@@ -0,0 +1,55 @@
.signoz-radio-group.ant-radio-group {
color: var(--text-vanilla-400);
.view-title {
display: flex;
gap: var(--margin-2);
align-items: center;
justify-content: center;
font-size: var(--font-size-xs);
font-style: normal;
font-weight: var(--font-weight-normal);
}
.tab {
border: 1px solid var(--bg-slate-400);
&:hover {
color: var(--text-vanilla-100);
}
&::before {
background: var(--bg-slate-400);
}
}
.selected_view {
&,
&:hover {
background: var(--bg-slate-300);
color: var(--text-vanilla-100);
border: 1px solid var(--bg-slate-400);
}
&::before {
background: var(--bg-slate-400);
}
}
}
// Light mode styles
.lightMode {
.signoz-radio-group {
.tab {
background: var(--bg-vanilla-100);
}
.selected_view {
background: var(--bg-vanilla-300);
border: 1px solid var(--bg-slate-300);
color: var(--text-ink-400);
}
.selected_view::before {
background: var(--bg-vanilla-300);
border-left: 1px solid var(--bg-slate-300);
}
}
}

View File

@@ -0,0 +1,48 @@
import './SignozRadioGroup.styles.scss';
import { Radio } from 'antd';
import { RadioChangeEvent } from 'antd/es/radio';
interface Option {
value: string;
label: string;
}
interface SignozRadioGroupProps {
value: string;
options: Option[];
onChange: (e: RadioChangeEvent) => void;
className?: string;
}
function SignozRadioGroup({
value,
options,
onChange,
className = '',
}: SignozRadioGroupProps): JSX.Element {
return (
<Radio.Group
value={value}
buttonStyle="solid"
className={`signoz-radio-group ${className}`}
onChange={onChange}
>
{options.map((option) => (
<Radio.Button
key={option.value}
value={option.value}
className={value === option.value ? 'selected_view tab' : 'tab'}
>
{option.label}
</Radio.Button>
))}
</Radio.Group>
);
}
SignozRadioGroup.defaultProps = {
className: '',
};
export default SignozRadioGroup;

View File

@@ -52,7 +52,7 @@ export const REACT_QUERY_KEY = {
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',
GET_RELATED_METRICS: 'GET_RELATED_METRICS',
// API Monitoring Query Keys
// Traces Funnels Query Keys
GET_DOMAINS_LIST: 'GET_DOMAINS_LIST',
GET_ENDPOINTS_LIST_BY_DOMAIN: 'GET_ENDPOINTS_LIST_BY_DOMAIN',
GET_NESTED_ENDPOINTS_LIST: 'GET_NESTED_ENDPOINTS_LIST',
@@ -68,4 +68,11 @@ export const REACT_QUERY_KEY = {
'GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA',
GET_FUNNELS_LIST: 'GET_FUNNELS_LIST',
GET_FUNNEL_DETAILS: 'GET_FUNNEL_DETAILS',
UPDATE_FUNNEL_STEPS: 'UPDATE_FUNNEL_STEPS',
VALIDATE_FUNNEL_STEPS: 'VALIDATE_FUNNEL_STEPS',
UPDATE_FUNNEL_STEP_DETAILS: 'UPDATE_FUNNEL_STEP_DETAILS',
GET_FUNNEL_OVERVIEW: 'GET_FUNNEL_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',
} as const;

View File

@@ -42,7 +42,7 @@ import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueries } from 'react-query';
import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { matchPath, useLocation } from 'react-router-dom';
import { Dispatch } from 'redux';
import AppActions from 'types/actions';
import {
@@ -360,6 +360,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
routeKey === 'INFRASTRUCTURE_MONITORING_HOSTS' ||
routeKey === 'INFRASTRUCTURE_MONITORING_KUBERNETES';
const isTracesFunnels = (): boolean => routeKey === 'TRACES_FUNNELS';
const isTracesFunnelDetails = (): boolean =>
!!matchPath(pathname, ROUTES.TRACES_FUNNELS_DETAIL);
const isPathMatch = (regex: RegExp): boolean => regex.test(pathname);
const isDashboardView = (): boolean =>
@@ -665,7 +668,11 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
? 0
: '0 1rem',
...(isTraceDetailsView() || isTracesFunnels() ? { margin: 0 } : {}),
...(isTraceDetailsView() ||
isTracesFunnels() ||
isTracesFunnelDetails()
? { margin: 0 }
: {}),
}}
>
{isToDisplayLayout && !renderFullScreen && <TopNav />}

View File

@@ -1,3 +1,19 @@
.query-builder-search {
.content {
.suggested-filters {
color: var(--bg-slate-50);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
padding: 12px 0px 8px 14px;
}
}
}
.query-builder-search-v2 {
display: flex;
gap: 4px;

View File

@@ -88,6 +88,9 @@ interface QueryBuilderSearchV2Props {
className?: string;
suffixIcon?: React.ReactNode;
hardcodedAttributeKeys?: BaseAutocompleteData[];
hasPopupContainer?: boolean;
rootClassName?: string;
maxTagCount?: number | 'responsive';
}
export interface Option {
@@ -121,6 +124,9 @@ function QueryBuilderSearchV2(
suffixIcon,
whereClauseConfig,
hardcodedAttributeKeys,
hasPopupContainer,
rootClassName,
maxTagCount,
} = props;
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
@@ -928,7 +934,10 @@ function QueryBuilderSearchV2(
<div className="query-builder-search-v2">
<Select
ref={selectRef}
getPopupContainer={popupContainer}
// eslint-disable-next-line react/jsx-props-no-spreading
{...(hasPopupContainer ? { getPopupContainer: popupContainer } : {})}
// eslint-disable-next-line react/jsx-props-no-spreading
{...(maxTagCount ? { maxTagCount } : {})}
key={queryTags.join('.')}
virtual={false}
showSearch
@@ -960,7 +969,7 @@ function QueryBuilderSearchV2(
: '',
className,
)}
rootClassName="query-builder-search"
rootClassName={cx('query-builder-search', rootClassName)}
disabled={isMetricsDataSource && !query.aggregateAttribute.key}
style={selectStyle}
onSearch={handleSearch}
@@ -1017,7 +1026,10 @@ QueryBuilderSearchV2.defaultProps = {
className: '',
suffixIcon: null,
whereClauseConfig: {},
hasPopupContainer: true,
rootClassName: '',
hardcodedAttributeKeys: undefined,
maxTagCount: undefined,
};
export default QueryBuilderSearchV2;

View File

@@ -0,0 +1,240 @@
// Modal base styles
.add-span-to-funnel-modal-container {
.ant-modal {
&-content,
&-header {
background: var(--bg-ink-500);
}
&-header {
border-bottom: none;
.ant-modal-title {
color: var(--bg-vanilla-100);
}
}
&-body {
padding: 14px 16px !important;
}
}
&--details {
.ant-modal-content {
height: 710px;
}
}
}
// Main modal styles
.add-span-to-funnel-modal {
// Common button styles
%button-base {
display: flex;
align-items: center;
font-family: Inter;
}
// Details view styles
&--details {
.traces-funnel-details {
height: unset;
&__steps-config {
width: unset;
border: none;
}
.funnel-step-wrapper {
gap: 15px;
}
.steps-content {
height: 500px;
}
}
}
// Search section
&__search {
display: flex;
gap: 12px;
margin-bottom: 14px;
align-items: center;
&-input {
flex: 1;
padding: 6px 8px;
background: var(--bg-ink-300);
.ant-input-prefix {
height: 18px;
margin-inline-end: 6px;
svg {
opacity: 0.4;
}
}
&,
input {
font-family: Inter;
font-size: 14px;
line-height: 18px;
letter-spacing: -0.07px;
font-weight: 400;
background: var(--bg-ink-300);
}
input::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.4;
}
}
}
// Create button
&__create-button {
@extend %button-base;
width: 153px;
padding: 4px 8px;
justify-content: center;
gap: 4px;
flex-shrink: 0;
border-radius: 2px;
background: var(--bg-slate-500);
border: none;
box-shadow: none;
color: var(--bg-vanilla-400);
font-size: 12px;
font-weight: 500;
line-height: 24px;
}
.funnel-item {
padding: 8px 16px 12px;
&,
&:first-child {
border-radius: 6px;
}
&__header {
line-height: 20px;
}
&__details {
line-height: 18px;
}
}
// List section
&__list {
max-height: 400px;
overflow-y: scroll;
.funnels-empty {
&__content {
padding: 0;
}
}
.funnels-list {
gap: 8px;
.funnel-item {
padding: 8px 16px 12px;
&__details {
margin-top: 8px;
}
}
}
}
&__spinner {
height: 400px;
}
// Back button
&__back-button {
@extend %button-base;
gap: 6px;
color: var(--bg-vanilla-400);
font-size: 14px;
line-height: 20px;
margin-bottom: 14px;
}
// Details section
&__details {
display: flex;
flex-direction: column;
gap: 24px;
.funnel-configuration__steps {
padding: 0;
.funnel-step {
&__content .filters__service-and-span .ant-select {
width: 170px;
}
&__footer .error {
width: 25%;
}
}
.inter-step-config {
width: calc(100% - 104px);
}
}
.funnel-item__actions-popover {
display: none;
}
}
}
// Light mode styles
.lightMode {
.add-span-to-funnel-modal-container {
.ant-modal {
&-content,
&-header {
background: var(--bg-vanilla-100);
}
&-title {
color: var(--bg-ink-500);
}
}
}
.add-span-to-funnel-modal {
&__search-input {
background: var(--bg-vanilla-100);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-500);
input {
color: var(--bg-ink-500);
background: var(--bg-vanilla-100);
&::placeholder {
color: var(--bg-ink-400);
}
}
}
&__create-button {
background: var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
&__back-button {
color: var(--bg-ink-500);
&:hover {
color: var(--bg-ink-400);
}
}
&__details h3 {
color: var(--bg-ink-500);
}
}
}

View File

@@ -0,0 +1,204 @@
import './AddSpanToFunnelModal.styles.scss';
import { LoadingOutlined } from '@ant-design/icons';
import { Button, Input, Spin } from 'antd';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import SignozModal from 'components/SignozModal/SignozModal';
import {
useFunnelDetails,
useFunnelsList,
} from 'hooks/TracesFunnels/useFunnels';
import { ArrowLeft, Plus, Search } from 'lucide-react';
import FunnelConfiguration from 'pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelConfiguration';
import { TracesFunnelsContentRenderer } from 'pages/TracesFunnels';
import CreateFunnel from 'pages/TracesFunnels/components/CreateFunnel/CreateFunnel';
import { FunnelListItem } from 'pages/TracesFunnels/components/FunnelsList/FunnelsList';
import { FunnelProvider } from 'pages/TracesFunnels/FunnelContext';
import { ChangeEvent, useMemo, useState } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import { FunnelData } from 'types/api/traceFunnels';
enum ModalView {
LIST = 'list',
DETAILS = 'details',
}
function FunnelDetailsView({
funnel,
span,
}: {
funnel: FunnelData;
span: Span;
}): JSX.Element {
return (
<div className="add-span-to-funnel-modal__details">
<FunnelListItem
funnel={funnel}
shouldRedirectToTracesListOnDeleteSuccess={false}
/>
<FunnelConfiguration funnel={funnel} isTraceDetailsPage span={span} />
</div>
);
}
interface AddSpanToFunnelModalProps {
isOpen: boolean;
onClose: () => void;
span: Span;
}
function AddSpanToFunnelModal({
isOpen,
onClose,
span,
}: AddSpanToFunnelModalProps): JSX.Element {
const [activeView, setActiveView] = useState<ModalView>(ModalView.LIST);
const [searchQuery, setSearchQuery] = useState<string>('');
const [selectedFunnelId, setSelectedFunnelId] = useState<string | undefined>(
undefined,
);
const [isCreateModalOpen, setIsCreateModalOpen] = useState<boolean>(false);
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
setSearchQuery(e.target.value);
};
const { data, isLoading, isError, isFetching } = useFunnelsList({
searchQuery: '',
});
const filteredData = useMemo(
() =>
data?.payload
?.filter((funnel) =>
funnel.funnel_name.toLowerCase().includes(searchQuery.toLowerCase()),
)
.sort(
(a, b) =>
new Date(b.creation_timestamp).getTime() -
new Date(a.creation_timestamp).getTime(),
),
[data?.payload, searchQuery],
);
const {
data: funnelDetails,
isLoading: isFunnelDetailsLoading,
isFetching: isFunnelDetailsFetching,
} = useFunnelDetails({
funnelId: selectedFunnelId,
});
const handleFunnelClick = (funnel: FunnelData): void => {
setSelectedFunnelId(funnel.id);
setActiveView(ModalView.DETAILS);
};
const handleBack = (): void => {
setActiveView(ModalView.LIST);
setSelectedFunnelId(undefined);
};
const handleCreateNewClick = (): void => {
setIsCreateModalOpen(true);
};
const renderListView = (): JSX.Element => (
<div className="add-span-to-funnel-modal">
{!!filteredData?.length && (
<div className="add-span-to-funnel-modal__search">
<Input
className="add-span-to-funnel-modal__search-input"
placeholder="Search by name, description, or tags..."
prefix={<Search size={12} />}
value={searchQuery}
onChange={handleSearch}
/>
</div>
)}
<div className="add-span-to-funnel-modal__list">
<OverlayScrollbar>
<TracesFunnelsContentRenderer
isError={isError}
isLoading={isLoading || isFetching}
data={filteredData || []}
onCreateFunnel={handleCreateNewClick}
onFunnelClick={(funnel: FunnelData): void => handleFunnelClick(funnel)}
shouldRedirectToTracesListOnDeleteSuccess={false}
/>
</OverlayScrollbar>
</div>
<CreateFunnel
isOpen={isCreateModalOpen}
onClose={(funnelId): void => {
if (funnelId) {
setSelectedFunnelId(funnelId);
setActiveView(ModalView.DETAILS);
}
setIsCreateModalOpen(false);
}}
redirectToDetails={false}
/>
</div>
);
const renderDetailsView = ({ span }: { span: Span }): JSX.Element => (
<div className="add-span-to-funnel-modal add-span-to-funnel-modal--details">
<Button
type="text"
className="add-span-to-funnel-modal__back-button"
onClick={handleBack}
>
<ArrowLeft size={14} />
All funnels
</Button>
<Spin
style={{ height: 400 }}
spinning={isFunnelDetailsLoading || isFunnelDetailsFetching}
indicator={<LoadingOutlined spin />}
>
<div className="traces-funnel-details">
<div className="traces-funnel-details__steps-config">
{selectedFunnelId && funnelDetails?.payload && (
<FunnelProvider funnelId={selectedFunnelId}>
<FunnelDetailsView funnel={funnelDetails.payload} span={span} />
</FunnelProvider>
)}
</div>
</div>
</Spin>
</div>
);
return (
<SignozModal
open={isOpen}
onCancel={onClose}
width={570}
title="Add span to funnel"
className={cx('add-span-to-funnel-modal-container', {
'add-span-to-funnel-modal-container--details':
activeView === ModalView.DETAILS,
})}
okText="Save Funnel"
footer={
activeView === ModalView.LIST && !!filteredData?.length ? (
<Button
type="default"
className="add-span-to-funnel-modal__create-button"
onClick={handleCreateNewClick}
icon={<Plus size={14} />}
>
Create new funnel
</Button>
) : null
}
>
{activeView === ModalView.LIST
? renderListView()
: renderDetailsView({ span })}
</SignozModal>
);
}
export default AddSpanToFunnelModal;

View File

@@ -95,6 +95,10 @@
border-radius: 4px;
background: rgba(171, 189, 255, 0.06) !important;
.div-td .span-overview .second-row .add-funnel-button {
opacity: 1;
}
.span-overview {
background: unset !important;
@@ -231,6 +235,24 @@
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
.add-funnel-button {
position: relative;
z-index: 1;
opacity: 0;
display: flex;
align-items: center;
gap: 6px;
transition: opacity 0.1s ease-in-out;
&__separator {
color: var(--bg-vanilla-400);
}
&__button {
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
}

View File

@@ -9,6 +9,7 @@ import cx from 'classnames';
import { TableV3 } from 'components/TableV3/TableV3';
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import AddSpanToFunnelModal from 'container/TraceWaterfall/AddSpanToFunnelModal/AddSpanToFunnelModal';
import { IInterestedSpan } from 'container/TraceWaterfall/TraceWaterfall';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import {
@@ -25,6 +26,7 @@ import {
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
@@ -57,6 +59,7 @@ function SpanOverview({
isSpanCollapsed,
handleCollapseUncollapse,
setSelectedSpan,
handleAddSpanToFunnel,
selectedSpan,
}: {
span: Span;
@@ -64,6 +67,7 @@ function SpanOverview({
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
selectedSpan: Span | undefined;
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
handleAddSpanToFunnel: (span: Span) => void;
}): JSX.Element {
const isRootSpan = span.level === 0;
@@ -141,6 +145,28 @@ function SpanOverview({
<Typography.Text className="service-name">
{span.serviceName}
</Typography.Text>
{!!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>
@@ -210,12 +236,14 @@ function getWaterfallColumns({
traceMetadata,
selectedSpan,
setSelectedSpan,
handleAddSpanToFunnel,
}: {
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
uncollapsedNodes: string[];
traceMetadata: ITraceMetadata;
selectedSpan: Span | undefined;
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
handleAddSpanToFunnel: (span: Span) => void;
}): ColumnDef<Span, any>[] {
const waterfallColumns: ColumnDef<Span, any>[] = [
columnDefHelper.display({
@@ -228,6 +256,7 @@ function getWaterfallColumns({
isSpanCollapsed={!uncollapsedNodes.includes(props.row.original.spanId)}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
handleAddSpanToFunnel={handleAddSpanToFunnel}
/>
),
size: 450,
@@ -294,6 +323,17 @@ function Success(props: ISuccessProps): JSX.Element {
}
};
const [isAddSpanToFunnelModalOpen, setIsAddSpanToFunnelModalOpen] = useState(
false,
);
const [selectedSpanToAddToFunnel, setSelectedSpanToAddToFunnel] = useState<
Span | undefined
>(undefined);
const handleAddSpanToFunnel = useCallback((span: Span): void => {
setIsAddSpanToFunnelModalOpen(true);
setSelectedSpanToAddToFunnel(span);
}, []);
const columns = useMemo(
() =>
getWaterfallColumns({
@@ -302,6 +342,7 @@ function Success(props: ISuccessProps): JSX.Element {
traceMetadata,
selectedSpan,
setSelectedSpan,
handleAddSpanToFunnel,
}),
[
handleCollapseUncollapse,
@@ -309,6 +350,7 @@ function Success(props: ISuccessProps): JSX.Element {
traceMetadata,
selectedSpan,
setSelectedSpan,
handleAddSpanToFunnel,
],
);
@@ -380,6 +422,13 @@ function Success(props: ISuccessProps): JSX.Element {
virtualiserRef={virtualizerRef}
setColumnWidths={setTraceFlamegraphStatsWidth}
/>
{selectedSpanToAddToFunnel && (
<AddSpanToFunnelModal
span={selectedSpanToAddToFunnel}
isOpen={isAddSpanToFunnelModalOpen}
onClose={(): void => setIsAddSpanToFunnelModalOpen(false)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,163 @@
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import useDebounce from 'hooks/useDebounce';
import { useNotifications } from 'hooks/useNotifications';
import { isEqual } from 'lodash-es';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { FunnelData, FunnelStepData } from 'types/api/traceFunnels';
import { useUpdateFunnelSteps } from './useFunnels';
interface UseFunnelConfiguration {
isPopoverOpen: boolean;
setIsPopoverOpen: (isPopoverOpen: boolean) => void;
steps: FunnelStepData[];
}
// Add this helper function
const normalizeSteps = (steps: FunnelStepData[]): FunnelStepData[] => {
if (steps.some((step) => !step.filters)) return steps;
return steps.map((step) => ({
...step,
filters: {
...step.filters,
items: step.filters.items.map((item) => ({
id: '',
key: item.key,
value: item.value,
op: item.op,
})),
},
}));
};
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function useFunnelConfiguration({
funnel,
}: {
funnel: FunnelData;
}): UseFunnelConfiguration {
const { notifications } = useNotifications();
const {
steps,
initialSteps,
setHasIncompleteStepFields,
setHasAllEmptyStepFields,
} = useFunnelContext();
// State management
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const debouncedSteps = useDebounce(steps, 200);
const [lastValidatedSteps, setLastValidatedSteps] = useState<FunnelStepData[]>(
initialSteps,
);
// Mutation hooks
const updateStepsMutation = useUpdateFunnelSteps(funnel.id, notifications);
// Derived state
const lastSavedStepsStateRef = useRef<FunnelStepData[]>(steps);
const hasStepsChanged = useCallback(() => {
const normalizedLastSavedSteps = normalizeSteps(
lastSavedStepsStateRef.current,
);
const normalizedDebouncedSteps = normalizeSteps(debouncedSteps);
return !isEqual(normalizedDebouncedSteps, normalizedLastSavedSteps);
}, [debouncedSteps]);
const hasStepServiceOrSpanNameChanged = 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
);
});
},
[],
);
// Mutation payload preparation
const getUpdatePayload = useCallback(
() => ({
funnel_id: funnel.id,
steps: debouncedSteps,
updated_timestamp: Date.now(),
}),
[funnel.id, debouncedSteps],
);
const queryClient = useQueryClient();
const { selectedTime } = useFunnelContext();
const validateStepsQueryKey = useMemo(
() => [REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS, funnel.id, selectedTime],
[funnel.id, selectedTime],
);
useEffect(() => {
if (hasStepsChanged()) {
updateStepsMutation.mutate(getUpdatePayload(), {
onSuccess: (data) => {
const updatedFunnelSteps = data?.payload?.steps;
if (!updatedFunnelSteps) return;
queryClient.setQueryData(
[REACT_QUERY_KEY.GET_FUNNEL_DETAILS, funnel.id],
(oldData: any) => ({
...oldData,
payload: {
...oldData.payload,
steps: updatedFunnelSteps,
},
}),
);
lastSavedStepsStateRef.current = updatedFunnelSteps;
const hasIncompleteStepFields = updatedFunnelSteps.some(
(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)
) {
queryClient.refetchQueries(validateStepsQueryKey);
setLastValidatedSteps(debouncedSteps);
}
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
debouncedSteps,
getUpdatePayload,
hasStepServiceOrSpanNameChanged,
hasStepsChanged,
lastValidatedSteps,
queryClient,
validateStepsQueryKey,
]);
return {
isPopoverOpen,
setIsPopoverOpen,
steps,
};
}

View File

@@ -0,0 +1,207 @@
import { Color } from '@signozhq/design-tokens';
import { FunnelStepGraphMetrics } from 'api/traceFunnels';
import { Chart, ChartConfiguration } from 'chart.js';
import ChangePercentagePill from 'components/ChangePercentagePill/ChangePercentagePill';
import { useCallback, useEffect, useRef } from 'react';
const CHART_CONFIG: Partial<ChartConfiguration> = {
type: 'bar',
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
grid: {
display: false,
},
ticks: {
font: {
family: "'Geist Mono', monospace",
},
},
},
y: {
stacked: true,
beginAtZero: true,
grid: {
color: 'rgba(192, 193, 195, 0.04)',
},
ticks: {
font: {
family: "'Geist Mono', monospace",
},
},
},
},
plugins: {
legend: {
display: false,
},
tooltip: {
enabled: false,
},
},
},
};
interface UseFunnelGraphProps {
data: FunnelStepGraphMetrics | undefined;
}
interface UseFunnelGraph {
successSteps: number[];
errorSteps: number[];
totalSteps: number;
canvasRef: React.RefObject<HTMLCanvasElement>;
renderLegendItem: (
step: number,
successSpans: number,
errorSpans: number,
prevTotalSpans: number,
) => JSX.Element;
}
function useFunnelGraph({ data }: UseFunnelGraphProps): UseFunnelGraph {
const canvasRef = useRef<HTMLCanvasElement>(null);
const chartRef = useRef<Chart | null>(null);
const getPercentageChange = useCallback(
(current: number, previous: number): number => {
if (previous === 0) return 0;
return Math.abs(Math.round(((current - previous) / previous) * 100));
},
[],
);
interface StepGraphData {
successSteps: number[];
errorSteps: number[];
totalSteps: number;
}
const getStepGraphData = useCallback((): StepGraphData => {
const successSteps: number[] = [];
const errorSteps: number[] = [];
let stepCount = 1;
if (!data) return { successSteps, errorSteps, totalSteps: 0 };
while (
data[`total_s${stepCount}_spans`] !== undefined &&
data[`total_s${stepCount}_errored_spans`] !== undefined
) {
const totalSpans = data[`total_s${stepCount}_spans`];
const erroredSpans = data[`total_s${stepCount}_errored_spans`];
const successSpans = totalSpans - erroredSpans;
successSteps.push(successSpans);
errorSteps.push(erroredSpans);
stepCount += 1;
}
return {
successSteps,
errorSteps,
totalSteps: stepCount - 1,
};
}, [data]);
useEffect(() => {
if (!canvasRef.current) return;
if (chartRef.current) {
chartRef.current.destroy();
}
const ctx = canvasRef.current.getContext('2d');
if (!ctx) return;
const { successSteps, errorSteps, totalSteps } = getStepGraphData();
chartRef.current = new Chart(ctx, {
...CHART_CONFIG,
data: {
labels: Array.from({ length: totalSteps }, (_, i) => String(i + 1)),
datasets: [
{
label: 'Success spans',
data: successSteps,
backgroundColor: Color.BG_ROBIN_500,
stack: 'Stack 0',
borderRadius: 2,
borderSkipped: false,
},
{
label: 'Error spans',
data: errorSteps,
backgroundColor: Color.BG_CHERRY_500,
stack: 'Stack 0',
borderRadius: 2,
borderSkipped: false,
borderWidth: {
top: 2,
bottom: 2,
},
borderColor: 'rgba(0, 0, 0, 0)',
},
],
},
options: CHART_CONFIG.options,
} as ChartConfiguration);
}, [data, getStepGraphData]);
// Log the widths when they change
const renderLegendItem = useCallback(
(
step: number,
successSpans: number,
errorSpans: number,
prevTotalSpans: number,
): JSX.Element => {
const totalSpans = successSpans + errorSpans;
return (
<div key={step} className="funnel-graph__legend-column">
<div className="legend-item">
<div className="legend-item__left">
<span className="legend-item__dot legend-item--total" />
<span className="legend-item__label">Total spans</span>
</div>
<div className="legend-item__right">
<span className="legend-item__value">{totalSpans}</span>
{step > 1 && (
<ChangePercentagePill
direction={totalSpans < prevTotalSpans ? -1 : 1}
percentage={getPercentageChange(totalSpans, prevTotalSpans)}
/>
)}
</div>
</div>
<div className="legend-item">
<div className="legend-item__left">
<span className="legend-item__dot legend-item--error" />
<span className="legend-item__label">Error spans</span>
</div>
<div className="legend-item__right">
<span className="legend-item__value">{errorSpans}</span>
</div>
</div>
</div>
);
},
[getPercentageChange],
);
const { successSteps, errorSteps, totalSteps } = getStepGraphData();
return {
successSteps,
errorSteps,
totalSteps,
canvasRef,
renderLegendItem,
};
}
export default useFunnelGraph;

View File

@@ -0,0 +1,69 @@
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 { useFunnelOverview } from './useFunnels';
interface FunnelMetricsParams {
funnelId: string;
stepStart?: number;
stepEnd?: number;
}
export function useFunnelMetrics({
funnelId,
stepStart,
stepEnd,
}: FunnelMetricsParams): {
isLoading: boolean;
isError: boolean;
metricsData: MetricItem[];
conversionRate: number;
} {
const { startTime, endTime } = useFunnelContext();
const payload = {
start_time: startTime,
end_time: endTime,
...(stepStart !== undefined && { step_start: stepStart }),
...(stepEnd !== undefined && { step_end: stepEnd }),
};
const {
data: overviewData,
isLoading,
isFetching,
isError,
} = useFunnelOverview(funnelId, payload);
const metricsData = useMemo(() => {
const sourceData = overviewData?.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.toString(), 'ms'),
},
{
title: 'P99 Latency',
value: getYAxisFormattedValue(sourceData.p99_latency.toString(), 'ms'),
},
];
}, [overviewData]);
const conversionRate =
overviewData?.payload?.data?.[0]?.data?.conversion_rate ?? 0;
return {
isLoading: isLoading || isFetching,
isError,
metricsData,
conversionRate,
};
}

View File

@@ -1,11 +1,31 @@
import { NotificationInstance } from 'antd/es/notification/interface';
import {
createFunnel,
deleteFunnel,
ErrorTraceData,
ErrorTracesPayload,
FunnelOverviewPayload,
FunnelOverviewResponse,
FunnelStepsResponse,
getFunnelById,
getFunnelErrorTraces,
getFunnelOverview,
getFunnelsList,
getFunnelSlowTraces,
getFunnelSteps,
renameFunnel,
saveFunnelDescription,
SlowTraceData,
SlowTracesPayload,
updateFunnelStepDetails,
UpdateFunnelStepDetailsPayload,
updateFunnelSteps,
UpdateFunnelStepsPayload,
ValidateFunnelResponse,
validateFunnelSteps,
} from 'api/traceFunnels';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import {
useMutation,
UseMutationResult,
@@ -20,19 +40,20 @@ import {
} from 'types/api/traceFunnels';
export const useFunnelsList = ({
searchQuery,
searchQuery = '',
}: {
searchQuery: string;
searchQuery?: string;
}): UseQueryResult<SuccessResponse<FunnelData[]> | ErrorResponse, unknown> =>
useQuery({
queryKey: [REACT_QUERY_KEY.GET_FUNNELS_LIST, searchQuery],
queryFn: () => getFunnelsList({ search: searchQuery }),
refetchOnWindowFocus: true,
});
export const useFunnelDetails = ({
funnelId,
}: {
funnelId: string;
funnelId?: string;
}): UseQueryResult<SuccessResponse<FunnelData> | ErrorResponse, unknown> =>
useQuery({
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_DETAILS, funnelId],
@@ -75,3 +96,148 @@ export const useDeleteFunnel = (): UseMutationResult<
useMutation({
mutationFn: deleteFunnel,
});
export const useUpdateFunnelSteps = (
funnelId: string,
notification: NotificationInstance,
): UseMutationResult<
SuccessResponse<FunnelData> | ErrorResponse,
Error,
UpdateFunnelStepsPayload
> =>
useMutation({
mutationFn: updateFunnelSteps,
mutationKey: [REACT_QUERY_KEY.UPDATE_FUNNEL_STEPS, funnelId],
onError: (error) => {
notification.error({
message: 'Failed to update funnel steps',
description: error.message,
});
},
});
export const useValidateFunnelSteps = ({
funnelId,
selectedTime,
startTime,
endTime,
}: {
funnelId: string;
selectedTime: string;
startTime: number;
endTime: number;
}): UseQueryResult<
SuccessResponse<ValidateFunnelResponse> | ErrorResponse,
Error
> =>
useQuery({
queryFn: ({ signal }) =>
validateFunnelSteps(
funnelId,
{ start_time: startTime, end_time: endTime },
signal,
),
queryKey: [REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS, funnelId, selectedTime],
enabled: !!funnelId && !!selectedTime && !!startTime && !!endTime,
staleTime: 1000 * 60 * 5,
});
export const useUpdateFunnelStepDetails = ({
stepOrder,
}: {
stepOrder: number;
}): UseMutationResult<
SuccessResponse<FunnelData> | ErrorResponse,
Error,
UpdateFunnelStepDetailsPayload
> =>
useMutation({
mutationFn: (payload) => updateFunnelStepDetails({ payload, stepOrder }),
mutationKey: [REACT_QUERY_KEY.UPDATE_FUNNEL_STEP_DETAILS, stepOrder],
});
interface SaveFunnelDescriptionPayload {
funnel_id: string;
description: string;
}
export const useSaveFunnelDescription = (): UseMutationResult<
SuccessResponse<FunnelData> | ErrorResponse,
Error,
SaveFunnelDescriptionPayload
> =>
useMutation({
mutationFn: saveFunnelDescription,
});
export const useFunnelOverview = (
funnelId: string,
payload: FunnelOverviewPayload,
): UseQueryResult<
SuccessResponse<FunnelOverviewResponse> | ErrorResponse,
Error
> => {
const { selectedTime, validTracesCount } = useFunnelContext();
return useQuery({
queryFn: ({ signal }) => getFunnelOverview(funnelId, payload, signal),
queryKey: [
REACT_QUERY_KEY.GET_FUNNEL_OVERVIEW,
funnelId,
selectedTime,
payload.step_start ?? '',
payload.step_end ?? '',
],
enabled: !!funnelId && validTracesCount > 0,
});
};
export const useFunnelSlowTraces = (
funnelId: string,
payload: SlowTracesPayload,
): UseQueryResult<SuccessResponse<SlowTraceData> | ErrorResponse, Error> => {
const { selectedTime, validTracesCount } = 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,
});
};
export const useFunnelErrorTraces = (
funnelId: string,
payload: ErrorTracesPayload,
): UseQueryResult<SuccessResponse<ErrorTraceData> | ErrorResponse, Error> => {
const { selectedTime, validTracesCount } = useFunnelContext();
return useQuery({
queryFn: ({ signal }) => getFunnelErrorTraces(funnelId, payload, signal),
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_ERROR_TRACES, funnelId, selectedTime],
enabled: !!funnelId && validTracesCount > 0,
});
};
export function useFunnelStepsGraphData(
funnelId: string,
): UseQueryResult<SuccessResponse<FunnelStepsResponse> | ErrorResponse, Error> {
const {
startTime,
endTime,
selectedTime,
validTracesCount,
} = useFunnelContext();
return useQuery({
queryFn: ({ signal }) =>
getFunnelSteps(
funnelId,
{ start_time: startTime, end_time: endTime },
signal,
),
queryKey: [
REACT_QUERY_KEY.GET_FUNNEL_STEPS_GRAPH_DATA,
funnelId,
selectedTime,
],
enabled: !!funnelId && validTracesCount > 0,
});
}

View File

@@ -1,31 +1,38 @@
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { ChangeEvent, useState } from 'react';
import { debounce } from 'lodash-es';
import { ChangeEvent, useCallback, useState } from 'react';
const useHandleTraceFunnelsSearch = (): {
searchQuery: string;
handleSearch: (e: ChangeEvent<HTMLInputElement>) => void;
} => {
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const [searchQuery, setSearchQuery] = useState<string>(
urlQuery.get('search') || '',
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedUpdateUrl = useCallback(
debounce((value: string) => {
const trimmedValue = value.trim();
if (trimmedValue) {
urlQuery.set('search', trimmedValue);
} else {
urlQuery.delete('search');
}
safeNavigate({ search: urlQuery.toString() });
}, 300),
[],
);
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
const { value } = e.target;
setSearchQuery(value);
const trimmedValue = value.trim();
if (trimmedValue) {
urlQuery.set('search', trimmedValue);
} else {
urlQuery.delete('search');
}
safeNavigate({ search: urlQuery.toString() });
debouncedUpdateUrl(value);
};
return {

View File

@@ -1,4 +1,7 @@
.traces-module-container {
.funnel-icon {
transform: rotate(180deg);
}
.trace-module {
.ant-tabs-tab {
.tab-item {

View File

@@ -3,7 +3,7 @@ import './TraceDetailV2.styles.scss';
import { Button, Tabs } from 'antd';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { Compass, TowerControl, Undo } from 'lucide-react';
import { Compass, Cone, TowerControl, Undo } from 'lucide-react';
import TraceDetail from 'pages/TraceDetail';
import { useCallback, useState } from 'react';
@@ -33,6 +33,9 @@ function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
if (activeKey === 'trace-details') {
history.push(ROUTES.TRACES_EXPLORER);
}
if (activeKey === 'funnels') {
history.push(ROUTES.TRACES_FUNNELS);
}
}}
tabBarExtraContent={
<Button
@@ -61,6 +64,15 @@ export default function TraceDetailsPage(): JSX.Element {
key: 'trace-details',
children: <TraceDetailsV2 />,
},
{
label: (
<div className="tab-item">
<Cone className="funnel-icon" size={16} /> Funnels
</div>
),
key: 'funnels',
children: <div />,
},
{
label: (
<div className="tab-item">

View File

@@ -0,0 +1,28 @@
.traces-funnel-details {
display: flex;
// 45px -> height of the tab bar
height: calc(100vh - 45px);
&__steps-config {
flex-shrink: 0;
width: 600px;
border-right: 1px solid var(--bg-slate-400);
position: relative;
}
&__steps-results {
width: 100%;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.1rem;
}
}
}
.lightMode {
.traces-funnel-details {
&__steps-config {
border-color: var(--bg-vanilla-300);
}
}
}

View File

@@ -1,13 +1,42 @@
import './TracesFunnelDetails.styles.scss';
import { Typography } from 'antd';
import Spinner from 'components/Spinner';
import { NotFoundContainer } from 'container/GridCardLayout/GridCard/FullView/styles';
import { useFunnelDetails } from 'hooks/TracesFunnels/useFunnels';
import { FunnelProvider } from 'pages/TracesFunnels/FunnelContext';
import { useParams } from 'react-router-dom';
import FunnelConfiguration from './components/FunnelConfiguration/FunnelConfiguration';
import FunnelResults from './components/FunnelResults/FunnelResults';
function TracesFunnelDetails(): JSX.Element {
const { funnelId } = useParams<{ funnelId: string }>();
const { data } = useFunnelDetails({ funnelId });
const { data, isLoading, isError } = useFunnelDetails({ funnelId });
if (isLoading || !data?.payload) {
return <Spinner size="large" tip="Loading..." />;
}
if (isError) {
return (
<NotFoundContainer>
<Typography>Error loading funnel details</Typography>
</NotFoundContainer>
);
}
return (
<div style={{ color: 'var(--bg-vanilla-400)' }}>
TracesFunnelDetails, {JSON.stringify(data)}
</div>
<FunnelProvider funnelId={funnelId}>
<div className="traces-funnel-details">
<div className="traces-funnel-details__steps-config">
<FunnelConfiguration funnel={data.payload} />
</div>
<div className="traces-funnel-details__steps-results">
<FunnelResults />
</div>
</div>
</FunnelProvider>
);
}

View File

@@ -0,0 +1,135 @@
.funnel-step-modal {
.ant-modal-content {
background: var(--bg-ink-400);
.ant-modal-header {
background: var(--bg-ink-400);
.ant-modal-title {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
line-height: 20px;
}
}
.ant-modal-body {
padding-bottom: 20px;
}
}
&__ok-btn {
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-robin-500);
border: none;
&[disabled] {
background: var(--bg-slate-400);
opacity: 1;
}
.ant-btn-icon {
margin-inline-end: 0 !important;
}
}
&__cancel-btn {
display: flex;
align-items: center;
gap: 6px;
color: var(--bg-vanilla-400);
.ant-btn-icon {
margin-inline-end: 0 !important;
}
}
}
.funnel-step-modal-content {
display: flex;
flex-direction: column;
gap: 28px;
&__field {
display: flex;
flex-direction: column;
gap: 8px;
}
&__label {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
&__input {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
color: var(--bg-vanilla-400);
&::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.6;
}
&.ant-input-textarea {
.ant-input {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
color: var(--bg-vanilla-400);
&::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.6;
}
}
}
}
}
// Light mode styles
.lightMode {
.funnel-step-modal {
.ant-modal-content {
.ant-modal-header {
background: var(--bg-vanilla-100);
.ant-modal-title {
color: var(--bg-ink-400);
}
}
}
&__cancel-btn {
color: var(--bg-ink-400);
}
}
.funnel-step-modal-content {
&__label {
color: var(--bg-ink-400);
}
&__input {
background: var(--bg-vanilla-400);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&::placeholder {
color: var(--bg-ink-100);
}
&.ant-input-textarea {
.ant-input {
background: var(--bg-vanilla-400);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&::placeholder {
color: var(--bg-ink-100);
}
}
}
}
}
}

View File

@@ -0,0 +1,107 @@
import './AddFunnelDescriptionModal.styles.scss';
import { Input } from 'antd';
import SignozModal from 'components/SignozModal/SignozModal';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useSaveFunnelDescription } from 'hooks/TracesFunnels/useFunnels';
import { useNotifications } from 'hooks/useNotifications';
import { Check, X } from 'lucide-react';
import { useState } from 'react';
import { useQueryClient } from 'react-query';
interface AddFunnelDescriptionProps {
isOpen: boolean;
onClose: () => void;
funnelId: string;
}
function AddFunnelDescriptionModal({
isOpen,
onClose,
funnelId,
}: AddFunnelDescriptionProps): JSX.Element {
const [description, setDescription] = useState<string>('');
const { notifications } = useNotifications();
const queryClient = useQueryClient();
const {
mutate: saveFunnelDescription,
isLoading,
} = useSaveFunnelDescription();
const handleCancel = (): void => {
setDescription('');
onClose();
};
const handleSave = (): void => {
saveFunnelDescription(
{
funnel_id: funnelId,
description,
},
{
onSuccess: () => {
queryClient.invalidateQueries([
REACT_QUERY_KEY.GET_FUNNEL_DETAILS,
funnelId,
]);
notifications.success({
message: 'Success',
description: 'Funnel description saved successfully',
});
handleCancel();
},
onError: (error) => {
notifications.error({
message: 'Failed to save funnel description',
description: error.message,
});
},
},
);
};
return (
<SignozModal
open={isOpen}
title="Add funnel description"
width={384}
onCancel={handleCancel}
rootClassName="funnel-step-modal funnel-modal signoz-modal"
cancelText="Cancel"
okText="Save changes"
okButtonProps={{
icon: <Check size={14} />,
type: 'primary',
className: 'funnel-step-modal__ok-btn',
onClick: handleSave,
loading: isLoading,
}}
cancelButtonProps={{
icon: <X size={14} />,
type: 'text',
className: 'funnel-step-modal__cancel-btn',
onClick: handleCancel,
disabled: isLoading,
}}
destroyOnClose
>
<div className="funnel-step-modal-content">
<div className="funnel-step-modal-content__field">
<span className="funnel-step-modal-content__label">Description</span>
<Input.TextArea
className="funnel-step-modal-content__input"
placeholder="(Optional) Eg. checkout dropoff funnel"
value={description}
onChange={(e): void => setDescription(e.target.value)}
autoSize={{ minRows: 3, maxRows: 5 }}
disabled={isLoading}
/>
</div>
</div>
</SignozModal>
);
}
export default AddFunnelDescriptionModal;

View File

@@ -0,0 +1,138 @@
.funnel-step-modal {
.ant-modal-content {
background: var(--bg-ink-400);
.ant-modal-header {
background: var(--bg-ink-400);
.ant-modal-title {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
line-height: 20px;
}
}
.ant-modal-body {
padding-bottom: 20px;
}
}
&__ok-btn {
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-robin-500);
border: none;
&[disabled] {
background: var(--bg-slate-400);
opacity: 1;
}
.ant-btn-icon {
margin-inline-end: 0 !important;
}
}
&__cancel-btn {
display: flex;
align-items: center;
gap: 6px;
color: var(--bg-vanilla-400);
.ant-btn-icon {
margin-inline-end: 0 !important;
}
}
}
.funnel-step-modal-content {
display: flex;
flex-direction: column;
gap: 28px;
&__field {
display: flex;
flex-direction: column;
gap: 8px;
}
&__label {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
&__input {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
color: var(--bg-vanilla-400);
&::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.6;
}
&.ant-input-textarea {
.ant-input {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
color: var(--bg-vanilla-400);
&::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.6;
}
}
}
}
}
// Light mode styles
.lightMode {
.funnel-step-modal {
.ant-modal-content {
.ant-modal-header {
.ant-modal-title {
color: var(--bg-ink-400);
}
}
}
&__ok-btn {
background: var(--bg-robin-500) !important;
}
&__cancel-btn {
color: var(--bg-ink-400);
}
}
.funnel-step-modal-content {
&__label {
color: var(--bg-ink-400);
}
&__input {
background: var(--bg-vanilla-400);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&::placeholder {
color: var(--bg-ink-100);
}
&.ant-input-textarea {
.ant-input {
background: var(--bg-vanilla-400);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&::placeholder {
color: var(--bg-ink-100);
}
}
}
}
}
}

View File

@@ -0,0 +1,130 @@
import './AddFunnelStepDetailsModal.styles.scss';
import { Input } from 'antd';
import SignozModal from 'components/SignozModal/SignozModal';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useUpdateFunnelStepDetails } from 'hooks/TracesFunnels/useFunnels';
import { useNotifications } from 'hooks/useNotifications';
import { Check, X } from 'lucide-react';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useState } from 'react';
import { useQueryClient } from 'react-query';
interface AddFunnelStepDetailsModalProps {
isOpen: boolean;
onClose: () => void;
stepOrder: number;
}
function AddFunnelStepDetailsModal({
isOpen,
onClose,
stepOrder,
}: AddFunnelStepDetailsModalProps): JSX.Element {
const { funnelId } = useFunnelContext();
const [stepName, setStepName] = useState<string>('');
const [description, setDescription] = useState<string>('');
const { notifications } = useNotifications();
const queryClient = useQueryClient();
const {
mutate: updateFunnelStepDetails,
isLoading,
} = useUpdateFunnelStepDetails({ stepOrder });
const handleCancel = (): void => {
setStepName('');
setDescription('');
onClose();
};
const handleSave = (): void => {
updateFunnelStepDetails(
{
funnel_id: funnelId,
steps: [
{
step_name: stepName,
description,
},
],
updated_timestamp: Date.now(),
},
{
onSuccess: () => {
queryClient.invalidateQueries([
REACT_QUERY_KEY.GET_FUNNEL_DETAILS,
funnelId,
]);
console.log('funnelId', funnelId);
notifications.success({
message: 'Success',
description: 'Funnel step details updated successfully',
});
handleCancel();
},
onError: (error) => {
notifications.error({
message: 'Failed to update funnel step details',
description: error.message,
});
},
},
);
};
return (
<SignozModal
open={isOpen}
title="Add funnel step details"
width={384}
onCancel={handleCancel}
rootClassName="funnel-step-modal funnel-modal signoz-modal"
cancelText="Cancel"
okText="Save changes"
okButtonProps={{
icon: <Check size={14} />,
type: 'primary',
className: 'funnel-step-modal__ok-btn',
onClick: handleSave,
disabled: !stepName.trim(),
loading: isLoading,
}}
cancelButtonProps={{
icon: <X size={14} />,
type: 'text',
className: 'funnel-step-modal__cancel-btn',
onClick: handleCancel,
disabled: isLoading,
}}
destroyOnClose
>
<div className="funnel-step-modal-content">
<div className="funnel-step-modal-content__field">
<span className="funnel-step-modal-content__label">Step name</span>
<Input
className="funnel-step-modal-content__input"
placeholder="Eg. checkout-dropoff-funnel-step1"
value={stepName}
onChange={(e): void => setStepName(e.target.value)}
autoFocus
disabled={isLoading}
/>
</div>
<div className="funnel-step-modal-content__field">
<span className="funnel-step-modal-content__label">Description</span>
<Input.TextArea
className="funnel-step-modal-content__input"
placeholder="Eg. checkout dropoff funnel"
value={description}
onChange={(e): void => setDescription(e.target.value)}
autoSize={{ minRows: 3, maxRows: 5 }}
disabled={isLoading}
/>
</div>
</div>
</SignozModal>
);
}
export default AddFunnelStepDetailsModal;

View File

@@ -0,0 +1,134 @@
.funnel-step-modal {
.ant-modal-content {
background: var(--bg-ink-400);
.ant-modal-header {
background: var(--bg-ink-400);
.ant-modal-title {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
line-height: 20px;
}
}
.ant-modal-body {
padding-bottom: 20px;
}
}
&__ok-btn {
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-robin-500);
border: none;
&[disabled] {
background: var(--bg-slate-400);
opacity: 1;
}
.ant-btn-icon {
margin-inline-end: 0 !important;
}
}
&__cancel-btn {
display: flex;
align-items: center;
gap: 6px;
color: var(--bg-vanilla-400);
.ant-btn-icon {
margin-inline-end: 0 !important;
}
}
}
.funnel-step-modal-content {
display: flex;
flex-direction: column;
gap: 28px;
&__field {
display: flex;
flex-direction: column;
gap: 8px;
}
&__label {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
&__input {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
color: var(--bg-vanilla-400);
&::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.6;
}
&.ant-input-textarea {
.ant-input {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
color: var(--bg-vanilla-400);
&::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.6;
}
}
}
}
}
// Light mode styles
.lightMode {
.funnel-step-modal {
.ant-modal-content {
.ant-modal-header {
.ant-modal-title {
color: var(--bg-ink-400);
}
}
}
&__cancel-btn {
color: var(--bg-ink-400);
}
}
.funnel-step-modal-content {
&__label {
color: var(--bg-ink-400);
}
&__input {
background: var(--bg-vanilla-400);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&::placeholder {
color: var(--bg-ink-100);
}
&.ant-input-textarea {
.ant-input {
background: var(--bg-vanilla-400);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&::placeholder {
color: var(--bg-ink-100);
}
}
}
}
}
}

View File

@@ -0,0 +1,53 @@
import './DeleteFunnelStep.styles.scss';
import SignozModal from 'components/SignozModal/SignozModal';
import { Trash2, X } from 'lucide-react';
interface DeleteFunnelStepProps {
isOpen: boolean;
onClose: () => void;
onStepRemove: () => void;
}
function DeleteFunnelStep({
isOpen,
onClose,
onStepRemove,
}: DeleteFunnelStepProps): JSX.Element {
const handleStepRemoval = (): void => {
onStepRemove();
onClose();
};
return (
<SignozModal
open={isOpen}
title="Delete this step"
width={390}
onCancel={onClose}
rootClassName="funnel-modal delete-funnel-modal"
cancelText="Cancel"
okText="Delete Funnel"
okButtonProps={{
icon: <Trash2 size={14} />,
type: 'primary',
className: 'funnel-modal__ok-btn',
onClick: handleStepRemoval,
}}
cancelButtonProps={{
icon: <X size={14} />,
type: 'text',
className: 'funnel-modal__cancel-btn',
onClick: onClose,
}}
destroyOnClose
>
<div className="delete-funnel-modal-content">
Deleting this step would stop further analytics using this step of the
funnel.
</div>
</SignozModal>
);
}
export default DeleteFunnelStep;

View File

@@ -0,0 +1,38 @@
.funnel-breadcrumb {
height: 20px;
&__link {
display: flex;
align-items: center;
}
li:first-of-type {
.funnel-breadcrumb__title {
color: var(--bg-vanilla-400);
}
}
.ant-breadcrumb-separator {
color: var(--bg-vanilla-100);
}
& > ol {
gap: 6px;
}
&__title {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
}
}
.lightMode {
.funnel-breadcrumb__title,
.ant-breadcrumb-separator {
color: var(--bg-ink-400);
}
li:first-of-type {
.funnel-breadcrumb__title {
color: var(--bg-ink-400);
}
}
}

View File

@@ -0,0 +1,35 @@
import './FunnelBreadcrumb.styles.scss';
import { Breadcrumb } from 'antd';
import ROUTES from 'constants/routes';
import { Link } from 'react-router-dom';
interface FunnelBreadcrumbProps {
funnelName: string;
}
function FunnelBreadcrumb({ funnelName }: FunnelBreadcrumbProps): JSX.Element {
return (
<div>
<Breadcrumb
className="funnel-breadcrumb"
items={[
{
title: (
<Link to={ROUTES.TRACES_FUNNELS}>
<span className="funnel-breadcrumb__link">
<span className="funnel-breadcrumb__title">All funnels</span>
</span>
</Link>
),
},
{
title: <div className="funnel-breadcrumb__title">{funnelName}</div>,
},
]}
/>
</div>
);
}
export default FunnelBreadcrumb;

View File

@@ -0,0 +1,42 @@
.funnel-configuration {
&__steps-wrapper {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--bg-slate-400);
}
&__description {
padding: 16px 16px 0 16px;
color: var(--bg-vanilla-400);
font-size: 12px;
line-height: 18px; /* 150% */
letter-spacing: -0.06px;
}
.funnel-item__action-icon {
opacity: 1;
}
&__steps {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
}
.lightMode {
.funnel-configuration {
&__header {
border-color: var(--bg-vanilla-300);
}
}
}

View File

@@ -0,0 +1,68 @@
import './FunnelConfiguration.styles.scss';
import useFunnelConfiguration from 'hooks/TracesFunnels/useFunnelConfiguration';
import FunnelItemPopover from 'pages/TracesFunnels/components/FunnelsList/FunnelItemPopover';
import { memo } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import { FunnelData } from 'types/api/traceFunnels';
import FunnelBreadcrumb from './FunnelBreadcrumb';
import StepsContent from './StepsContent';
import StepsFooter from './StepsFooter';
import StepsHeader from './StepsHeader';
interface FunnelConfigurationProps {
funnel: FunnelData;
isTraceDetailsPage?: boolean;
span?: Span;
}
function FunnelConfiguration({
funnel,
isTraceDetailsPage,
span,
}: FunnelConfigurationProps): JSX.Element {
const { isPopoverOpen, setIsPopoverOpen, steps } = useFunnelConfiguration({
funnel,
});
return (
<div className="funnel-configuration">
{!isTraceDetailsPage && (
<>
<div className="funnel-configuration__header">
<FunnelBreadcrumb funnelName={funnel.funnel_name} />
<FunnelItemPopover
isPopoverOpen={isPopoverOpen}
setIsPopoverOpen={setIsPopoverOpen}
funnel={funnel}
/>
</div>
<div className="funnel-configuration__description">
{funnel?.description}
</div>
</>
)}
<div className="funnel-configuration__steps-wrapper">
<div className="funnel-configuration__steps">
{!isTraceDetailsPage && <StepsHeader />}
<StepsContent isTraceDetailsPage={isTraceDetailsPage} span={span} />
</div>
{!isTraceDetailsPage && (
<StepsFooter
funnelId={funnel.id}
stepsCount={steps.length}
funnelDescription={funnel?.description || ''}
/>
)}
</div>
</div>
);
}
FunnelConfiguration.defaultProps = {
isTraceDetailsPage: false,
span: undefined,
};
export default memo(FunnelConfiguration);

View File

@@ -0,0 +1,208 @@
.traces-funnel-where-filter {
.keyboard-shortcuts {
display: none !important;
}
}
.funnel-step {
background: var(--bg-ink-400);
color: var(--bg-vanilla-400);
border: 1px solid var(--bg-slate-500);
border-radius: 6px;
.step-popover {
opacity: 0;
width: 22px;
height: 22px;
padding: 4px;
background: var(--bg-ink-100);
border-radius: 2px;
position: absolute;
right: -11px;
top: -11px;
}
&:hover .step-popover {
opacity: 1;
}
&__header {
display: flex;
justify-content: space-between;
align-items: start;
padding: 8px 12px;
border-bottom: 1px solid var(--bg-slate-500);
.funnel-step-details {
display: flex;
flex-direction: column;
gap: 4px;
&__title {
color: var(--bg-vanilla-400);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
&__description {
color: var(--bg-vanilla-400);
font-size: 12px;
line-height: 18px;
letter-spacing: -0.06px;
}
}
}
&__content {
display: flex;
align-items: baseline;
gap: 6px;
padding: 16px;
padding-left: 6px;
.ant-form-item {
margin: 0;
width: 100%;
}
.drag-icon {
cursor: grab;
}
.filters {
display: flex;
flex-direction: column;
gap: 10px;
.ant-select-selector {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
.ant-select-selection-placeholder {
font-size: 12px;
line-height: 16px;
}
}
&__service-and-span {
display: flex;
align-items: center;
gap: 12px;
.ant-select-selection-placeholder {
color: var(--bg-vanilla-400);
}
.ant-select {
width: 239px;
}
}
&__where-filter {
display: flex;
align-items: center;
gap: 8px;
.label {
color: var(--bg-vanilla-400);
font-size: 14px;
line-height: 20px;
font-weight: 400;
}
.query-builder-search-v2 {
width: 100%;
}
}
}
.ant-steps.ant-steps-vertical > .ant-steps-item .ant-steps-item-description {
padding-bottom: 16px;
}
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--bg-slate-500);
.error {
display: flex;
align-items: center;
padding: 10.5px 12px 10.5px 16px;
gap: 20px;
border-right: 1px solid var(--bg-slate-500);
width: 50%;
}
.error__label,
.latency-pointer__label {
color: var(--bg-vanilla-400);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
.latency-pointer {
padding: 10.5px 16px 10.5px 12px;
width: 55%;
display: flex;
align-items: center;
justify-content: space-between;
.ant-space {
display: flex;
align-items: center;
cursor: pointer;
&-item {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
&:last-child {
height: 14px;
}
}
}
}
}
}
.lightMode {
.funnel-step {
background: var(--bg-vanilla-100);
color: var(--bg-ink-400);
border-color: var(--bg-vanilla-300);
.step-popover {
background: var(--bg-vanilla-100);
}
&__header {
border-color: var(--bg-vanilla-300);
.funnel-step-details {
&__title {
color: var(--bg-ink-400);
}
&__description {
color: var(--bg-ink-400);
}
}
}
&__content {
.filters {
.ant-select-selector {
background: var(--bg-vanilla-100);
border-color: var(--bg-vanilla-300);
}
&__service-and-span {
.ant-select-selection-placeholder {
color: var(--bg-ink-400);
}
}
&__where-filter {
.label {
color: var(--bg-ink-400);
}
}
}
}
&__footer {
&,
.error {
border-color: var(--bg-vanilla-300);
}
.error__label,
.latency-pointer__label {
color: var(--bg-ink-400);
}
.latency-pointer {
.ant-space-item {
color: var(--bg-ink-400);
}
}
}
}
}

View File

@@ -0,0 +1,188 @@
import './FunnelStep.styles.scss';
import { Dropdown, Form, Space, Switch } from 'antd';
import { MenuProps } from 'antd/lib';
import { FilterSelect } from 'components/CeleryOverview/CeleryOverviewConfigOptions/CeleryOverviewConfigOptions';
import { QueryParams } from 'constants/query';
import { initialQueriesMap } from 'constants/queryBuilder';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { ChevronDown, GripVertical, HardHat } from 'lucide-react';
import { LatencyPointers } from 'pages/TracesFunnelDetails/constants';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useMemo, useState } from 'react';
import { FunnelStepData } from 'types/api/traceFunnels';
import { DataSource } from 'types/common/queryBuilder';
import FunnelStepPopover from './FunnelStepPopover';
interface FunnelStepProps {
stepData: FunnelStepData;
index: number;
stepsCount: number;
}
function FunnelStep({
stepData,
index,
stepsCount,
}: FunnelStepProps): JSX.Element {
const {
handleStepChange: onStepChange,
handleStepRemoval: onStepRemove,
} = useFunnelContext();
const [form] = Form.useForm();
const currentQuery = initialQueriesMap[DataSource.TRACES];
const latencyPointerItems: MenuProps['items'] = LatencyPointers.map(
(option) => ({
key: option.value,
label: option.key,
style:
option.value === stepData.latency_pointer
? { backgroundColor: 'var(--bg-slate-100)' }
: {},
}),
);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.TRACES,
filters: stepData.filters ?? {
op: 'AND',
items: [],
},
},
],
},
}),
[stepData.filters, currentQuery],
);
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
return (
<div className="funnel-step">
<Form form={form}>
<div className="funnel-step__header">
<div className="funnel-step-details">
{!!stepData.title && (
<div className="funnel-step-details__title">{stepData.title}</div>
)}
{!!stepData.description && (
<div className="funnel-step-details__description">
{stepData.description}
</div>
)}
</div>
<div className="funnel-step-actions">
<FunnelStepPopover
isPopoverOpen={isPopoverOpen}
setIsPopoverOpen={setIsPopoverOpen}
stepOrder={stepData.step_order}
onStepRemove={(): void => onStepRemove(index)}
stepsCount={stepsCount}
/>
</div>
</div>
<div className="funnel-step__content">
<div className="drag-icon">
<GripVertical size={14} color="var(--bg-slate-200)" />
</div>
<div className="filters">
<div className="filters__service-and-span">
<div className="service">
<Form.Item name={['steps', stepData.id, 'service_name']}>
<FilterSelect
placeholder="Select Service"
queryParam={QueryParams.service}
filterType="serviceName"
shouldSetQueryParams={false}
values={stepData.service_name}
isMultiple={false}
onChange={(v): void => {
onStepChange(index, { service_name: (v ?? '') as string });
}}
/>
</Form.Item>
</div>
<div className="span">
<Form.Item name={['steps', stepData.id, 'span_name']}>
<FilterSelect
placeholder="Select Span name"
queryParam={QueryParams.spanName}
filterType="name"
shouldSetQueryParams={false}
values={stepData.span_name}
isMultiple={false}
onChange={(v): void =>
onStepChange(index, { span_name: (v ?? '') as string })
}
/>
</Form.Item>
</div>
</div>
<div className="filters__where-filter">
<div className="label">Where</div>
<Form.Item name={['steps', stepData.id, 'filters']}>
<QueryBuilderSearchV2
query={query}
onChange={(query): void => onStepChange(index, { filters: query })}
hasPopupContainer={false}
placeholder="Search for filters..."
suffixIcon={<HardHat size={12} color="var(--bg-vanilla-400)" />}
rootClassName="traces-funnel-where-filter"
maxTagCount="responsive"
/>
</Form.Item>
</div>
</div>
</div>
<div className="funnel-step__footer">
<div className="error">
<Switch
className="error__switch"
size="small"
checked={stepData.has_errors}
onChange={(): void =>
onStepChange(index, { has_errors: !stepData.has_errors })
}
/>
<div className="error__label">Errors</div>
</div>
<div className="latency-pointer">
<div className="latency-pointer__label">Latency pointer</div>
<Dropdown
menu={{
items: latencyPointerItems,
onClick: ({ key }): void =>
onStepChange(index, {
latency_pointer: key as FunnelStepData['latency_pointer'],
}),
}}
trigger={['click']}
>
<Space>
{
LatencyPointers.find(
(option) => option.value === stepData.latency_pointer,
)?.key
}
<ChevronDown size={14} color="var(--bg-vanilla-400)" />
</Space>
</Dropdown>
</div>
</div>
</Form>
</div>
);
}
export default FunnelStep;

View File

@@ -0,0 +1,129 @@
import { Button, Popover, Tooltip } from 'antd';
import cx from 'classnames';
import { Ellipsis, PencilLine, Trash2 } from 'lucide-react';
import { useState } from 'react';
import AddFunnelStepDetailsModal from './AddFunnelStepDetailsModal';
import DeleteFunnelStep from './DeleteFunnelStep';
interface FunnelStepPopoverProps {
isPopoverOpen: boolean;
setIsPopoverOpen: (isOpen: boolean) => void;
className?: string;
stepOrder: number;
stepsCount: number;
onStepRemove: () => void;
}
interface FunnelStepActionsProps {
setIsPopoverOpen: (isOpen: boolean) => void;
setIsAddDetailsModalOpen: (isOpen: boolean) => void;
setIsDeleteModalOpen: (isOpen: boolean) => void;
stepsCount: number;
}
function FunnelStepActions({
setIsPopoverOpen,
setIsAddDetailsModalOpen,
setIsDeleteModalOpen,
stepsCount,
}: FunnelStepActionsProps): JSX.Element {
return (
<div className="funnel-item__actions">
<Button
type="text"
className="funnel-item__action-btn"
icon={<PencilLine size={14} />}
onClick={(): void => {
setIsPopoverOpen(false);
setIsAddDetailsModalOpen(true);
}}
>
Add details
</Button>
<Tooltip title={stepsCount <= 2 ? 'Minimum 2 steps required' : 'Delete'}>
<Button
type="text"
className="funnel-item__action-btn funnel-item__action-btn--delete"
icon={<Trash2 size={14} />}
disabled={stepsCount <= 2}
onClick={(): void => {
if (stepsCount > 2) {
setIsPopoverOpen(false);
setIsDeleteModalOpen(true);
}
}}
>
Delete
</Button>
</Tooltip>
</div>
);
}
function FunnelStepPopover({
isPopoverOpen,
setIsPopoverOpen,
stepOrder,
className,
onStepRemove,
stepsCount,
}: FunnelStepPopoverProps): JSX.Element {
const [isAddDetailsModalOpen, setIsAddDetailsModalOpen] = useState<boolean>(
false,
);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false);
const preventDefault = (e: React.MouseEvent | React.KeyboardEvent): void => {
e.preventDefault();
e.stopPropagation();
};
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div onClick={preventDefault} role="button" tabIndex={0}>
<Popover
trigger="click"
rootClassName="funnel-item__actions"
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
content={
<FunnelStepActions
setIsDeleteModalOpen={setIsDeleteModalOpen}
setIsPopoverOpen={setIsPopoverOpen}
setIsAddDetailsModalOpen={setIsAddDetailsModalOpen}
stepsCount={stepsCount}
/>
}
placement="bottomRight"
arrow={false}
>
<Ellipsis
className={cx('funnel-item__action-icon', className, {
'funnel-item__action-icon--active': isPopoverOpen,
})}
size={14}
/>
</Popover>
<DeleteFunnelStep
isOpen={isDeleteModalOpen}
onClose={(): void => setIsDeleteModalOpen(false)}
onStepRemove={onStepRemove}
/>
<AddFunnelStepDetailsModal
isOpen={isAddDetailsModalOpen}
onClose={(): void => setIsAddDetailsModalOpen(false)}
stepOrder={stepOrder}
/>
</div>
);
}
FunnelStepPopover.defaultProps = {
className: '',
};
export default FunnelStepPopover;

View File

@@ -0,0 +1,57 @@
.inter-step-config {
display: flex;
align-items: center;
gap: 6px;
.ant-form-item {
margin-bottom: 0;
}
&::before {
content: '';
position: absolute;
left: 4px;
bottom: 16px;
transform: translateY(-50%);
width: 12px;
height: 12px;
background-color: var(--bg-slate-400);
border-radius: 50%;
z-index: 1;
}
&__label {
color: var(--Vanilla-400, #c0c1c3);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
flex-shrink: 0;
}
&__divider {
width: 100%;
.ant-divider {
margin: 0;
border-color: var(--bg-slate-400);
}
}
&__latency-options {
flex-shrink: 0;
}
}
.lightMode {
.inter-step-config {
background-color: var(--bg-vanilla-200);
color: var(--bg-ink-400);
&::before {
background-color: var(--bg-vanilla-400);
}
&__label {
color: var(--bg-ink-300);
}
&__divider {
.ant-divider {
border-color: var(--bg-vanilla-400);
}
}
}
}

View File

@@ -0,0 +1,43 @@
import './InterStepConfig.styles.scss';
import { Divider } from 'antd';
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { FunnelStepData, LatencyOptions } from 'types/api/traceFunnels';
function InterStepConfig({
index,
step,
}: {
index: number;
step: FunnelStepData;
}): JSX.Element {
const { handleStepChange: onStepChange } = useFunnelContext();
const options = Object.entries(LatencyOptions).map(([key, value]) => ({
label: key,
value,
}));
return (
<div className="inter-step-config">
<div className="inter-step-config__label">Latency type</div>
<div className="inter-step-config__divider">
<Divider dashed />
</div>
<div className="inter-step-config__latency-options">
<SignozRadioGroup
value={step.latency_type}
options={options}
onChange={(e): void =>
onStepChange(index, {
...step,
latency_type: e.target.value,
})
}
/>
</div>
</div>
);
}
export default InterStepConfig;

View File

@@ -0,0 +1,156 @@
.steps-content {
height: calc(
100vh - 253px
); // 64px (footer) + 12 (steps gap) + 32 (steps header) + 16 (steps padding) + 50 (breadcrumb) + 34 (description) + 45 (steps footer) = 219px
overflow-y: auto;
.ant-btn {
box-shadow: none;
&-icon {
margin-inline-end: 0 !important;
}
}
&__description {
display: flex;
flex-direction: column;
gap: 16px;
.funnel-step-wrapper {
display: flex;
gap: 16px;
&__replace-button {
display: flex;
height: 28px;
padding: 5px 12px;
justify-content: center;
align-items: center;
gap: 6px;
border-radius: 3px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px;
&:disabled {
background-color: rgba(209, 209, 209, 0.074);
color: #5f5f5f;
}
}
}
}
&__add-btn {
border-radius: 2px;
border: 1px solid var(--bg-ink-200);
background: var(--bg-ink-200);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
padding: 6px 12px;
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.ant-steps-item.steps-content__add-step {
.ant-steps-item-icon {
margin-left: 4px;
margin-right: 20px;
width: 12px;
height: 12px;
}
.ant-steps-icon {
display: none;
}
}
.ant-steps-item-process .ant-steps-item-icon,
.ant-steps-item-icon {
// margin-left: 6px;
height: 20px;
width: 20px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background-color: var(--bg-slate-400) !important;
& > .ant-steps-icon {
font-size: 13px;
font-weight: 400;
line-height: normal;
letter-spacing: -0.065px;
color: var(--bg-vanilla-400);
}
}
.ant-steps.ant-steps-vertical
> .ant-steps-item
> .ant-steps-item-container
> .ant-steps-item-tail {
inset-inline-start: 9px;
}
.ant-steps-item-tail {
padding: 20px 0 0 !important;
&::after {
background-color: var(--bg-slate-400) !important;
}
}
.latency-step-marker {
&::before {
content: '';
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
width: 12px;
height: 12px;
background-color: var(--bg-ink-400);
border-radius: 50%;
z-index: 1;
}
}
}
// Light mode styles
.lightMode {
.funnel-step-wrapper__replace-button {
background: var(--bg-vanilla-300);
color: var(--bg-ink-400);
border: none;
}
.steps-content {
&__add-btn {
background: var(--bg-vanilla-300);
border: none;
color: var(--bg-ink-400);
&:hover {
background: var(--bg-vanilla-400);
}
}
.ant-steps-item-icon {
background-color: var(--bg-vanilla-400) !important;
.ant-steps-icon {
color: var(--bg-ink-400);
}
}
.ant-steps-item-tail::after {
background-color: var(--bg-vanilla-400) !important;
}
.inter-step-config::before {
background-color: var(--bg-vanilla-400);
}
.latency-step-marker::before {
background-color: var(--bg-vanilla-400);
}
}
}

View File

@@ -0,0 +1,106 @@
import './StepsContent.styles.scss';
import { Button, Steps } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { PlusIcon, Undo2 } from 'lucide-react';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { memo, useCallback } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import FunnelStep from './FunnelStep';
import InterStepConfig from './InterStepConfig';
const { Step } = Steps;
function StepsContent({
isTraceDetailsPage,
span,
}: {
isTraceDetailsPage?: boolean;
span?: Span;
}): JSX.Element {
const { steps, handleAddStep, handleReplaceStep } = useFunnelContext();
const handleAddForNewStep = useCallback(() => {
if (!span) return;
const stepWasAdded = handleAddStep();
if (stepWasAdded) {
handleReplaceStep(steps.length, span.serviceName, span.name);
}
}, [span, handleAddStep, handleReplaceStep, steps.length]);
return (
<div className="steps-content">
<OverlayScrollbar>
<Steps direction="vertical">
{steps.map((step, index) => (
<Step
key={`step-${index + 1}`}
description={
<div className="steps-content__description">
<div className="funnel-step-wrapper">
<FunnelStep stepData={step} index={index} stepsCount={steps.length} />
{isTraceDetailsPage && span && (
<Button
type="default"
className="funnel-step-wrapper__replace-button"
icon={<Undo2 size={12} />}
disabled={
step.service_name === span.serviceName &&
step.span_name === span.name
}
onClick={(): void =>
handleReplaceStep(index, span.serviceName, span.name)
}
>
Replace
</Button>
)}
</div>
{/* Display InterStepConfig only between steps */}
{index < steps.length - 1 && (
<InterStepConfig index={index} step={step} />
)}
</div>
}
/>
))}
{steps.length < 3 && (
<Step
className="steps-content__add-step"
description={
!isTraceDetailsPage ? (
<Button
type="default"
className="steps-content__add-btn"
onClick={handleAddStep}
icon={<PlusIcon size={14} />}
>
Add Funnel Step
</Button>
) : (
<Button
type="default"
className="steps-content__add-btn"
onClick={handleAddForNewStep}
icon={<PlusIcon size={14} />}
>
Add for new Step
</Button>
)
}
/>
)}
</Steps>
</OverlayScrollbar>
</div>
);
}
StepsContent.defaultProps = {
isTraceDetailsPage: false,
span: undefined,
};
export default memo(StepsContent);

View File

@@ -0,0 +1,74 @@
.steps-footer {
border-top: 1px solid var(--bg-slate-500);
background: var(--bg-ink-500);
padding: 16px;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
&__left {
display: flex;
gap: 6px;
align-items: center;
font-size: 14px;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
&__valid-traces {
&--none {
color: var(--text-amber-500);
}
}
&__right {
display: flex;
align-items: center;
gap: 8px;
}
&__button {
border: none;
display: flex;
align-items: center;
gap: 6px;
.ant-btn-icon {
margin-inline-end: 0 !important;
}
&--save {
background-color: var(--bg-slate-400);
}
&--run {
background-color: var(--bg-robin-500);
}
}
}
.lightMode {
.steps-footer {
border-top: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-200);
&__left {
color: var(--bg-ink-400);
}
&__valid-traces {
&--none {
color: var(--text-amber-600);
}
}
&__button {
&--save {
background: var(--bg-vanilla-300);
}
&--run {
background-color: var(--bg-robin-400);
}
}
}
}

View File

@@ -0,0 +1,109 @@
import './StepsFooter.styles.scss';
import { SyncOutlined } from '@ant-design/icons';
import { Button, Skeleton } from 'antd';
import cx from 'classnames';
import { Check, Cone, Play } from 'lucide-react';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useState } from 'react';
import AddFunnelDescriptionModal from './AddFunnelDescriptionModal';
interface StepsFooterProps {
stepsCount: number;
funnelId: string;
funnelDescription: string;
}
function ValidTracesCount(): JSX.Element {
const {
hasAllEmptyStepFields,
isValidateStepsLoading,
hasIncompleteStepFields,
validTracesCount,
} = useFunnelContext();
if (isValidateStepsLoading) {
return <Skeleton.Button size="small" />;
}
if (hasAllEmptyStepFields) {
return (
<span className="steps-footer__valid-traces">No service / span names</span>
);
}
if (hasIncompleteStepFields) {
return (
<span className="steps-footer__valid-traces">
Missing service / span names
</span>
);
}
return (
<span
className={cx('steps-footer__valid-traces', {
'steps-footer__valid-traces--none': validTracesCount === 0,
})}
>
{validTracesCount} valid traces
</span>
);
}
function StepsFooter({
stepsCount,
funnelId,
funnelDescription,
}: StepsFooterProps): JSX.Element {
const { validTracesCount, handleRunFunnel } = useFunnelContext();
const [isDescriptionModalOpen, setIsDescriptionModalOpen] = useState(false);
return (
<div className="steps-footer">
<div className="steps-footer__left">
<Cone className="funnel-icon" size={14} />
<span>{stepsCount} steps</span>
<span>·</span>
<ValidTracesCount />
</div>
<div className="steps-footer__right">
{funnelDescription ? (
<Button
type="primary"
disabled={validTracesCount === 0}
onClick={handleRunFunnel}
icon={<SyncOutlined />}
/>
) : (
<>
<Button
type="default"
className="steps-footer__button steps-footer__button--save"
icon={<Check size={16} />}
onClick={(): void => setIsDescriptionModalOpen(true)}
>
Save funnel
</Button>
<Button
disabled={validTracesCount === 0}
onClick={handleRunFunnel}
type="primary"
className="steps-footer__button steps-footer__button--run"
icon={<Play size={16} />}
>
Run funnel
</Button>
</>
)}
</div>
<AddFunnelDescriptionModal
isOpen={isDescriptionModalOpen}
onClose={(): void => setIsDescriptionModalOpen(false)}
funnelId={funnelId}
/>
</div>
);
}
export default StepsFooter;

View File

@@ -0,0 +1,51 @@
.steps-header {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
&__label {
color: var(--bg-slate-50);
font-size: 12px;
font-weight: 600;
line-height: 18px;
letter-spacing: 0.48px;
text-transform: uppercase;
flex-shrink: 0;
}
&__divider {
width: 100%;
.ant-divider {
margin: 0;
border-color: var(--bg-slate-400);
}
}
&__time-range {
min-width: 192px;
height: 32px;
flex-shrink: 0;
.timeSelection-input {
.ant-input-prefix > svg {
height: 12px;
}
&,
input {
background: var(--bg-ink-300);
font-size: 12px;
}
}
}
}
.lightMode {
.steps-header {
&__label {
color: var(--bg-ink-400);
}
.timeSelection-input {
&,
input {
background: unset;
}
}
}
}

View File

@@ -0,0 +1,24 @@
import './StepsHeader.styles.scss';
import { Divider } from 'antd';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
function StepsHeader(): JSX.Element {
return (
<div className="steps-header">
<div className="steps-header__label">FUNNEL STEPS</div>
<div className="steps-header__divider">
<Divider dashed />
</div>
<div className="steps-header__time-range">
<DateTimeSelectionV2
showAutoRefresh={false}
showRefreshText={false}
hideShareModal
/>
</div>
</div>
);
}
export default StepsHeader;

View File

@@ -0,0 +1,42 @@
.funnel-results--empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.empty-funnel-results {
display: flex;
flex-direction: column;
gap: 4px;
&__title {
color: var(--bg-vanilla-100);
font-size: 14px;
font-weight: 500;
line-height: 18px;
letter-spacing: -0.07px;
}
&__description {
color: var(--bg-vanilla-400);
font-size: 14px;
line-height: 18px;
letter-spacing: -0.07px;
}
&__learn-more {
margin-top: 8px;
}
}
.lightMode {
.empty-funnel-results {
&__title {
color: var(--bg-ink-400);
}
&__description {
color: var(--bg-ink-300);
}
}
}

View File

@@ -0,0 +1,33 @@
import './EmptyFunnelResults.styles.scss';
import LearnMore from 'components/LearnMore/LearnMore';
function EmptyFunnelResults({
title,
description,
}: {
title?: string;
description?: string;
}): JSX.Element {
return (
<div className="funnel-results funnel-results--empty">
<div className="empty-funnel-results">
<div className="empty-funnel-results__icon">
<img src="/Icons/empty-funnel-icon.svg" alt="Empty funnel results" />
</div>
<div className="empty-funnel-results__title">{title}</div>
<div className="empty-funnel-results__description">{description}</div>
<div className="empty-funnel-results__learn-more">
<LearnMore />
</div>
</div>
</div>
);
}
EmptyFunnelResults.defaultProps = {
title: 'No spans selected yet.',
description: 'Add spans to the funnel steps to start seeing analytics here.',
};
export default EmptyFunnelResults;

View File

@@ -0,0 +1,117 @@
.funnel-graph {
width: 100%;
padding: 16px;
height: 459px;
background: var(--bg-ink-500);
border-radius: 6px;
box-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.1);
border: 1px solid var(--bg-slate-500);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 13px;
&--2-columns {
.funnel-graph {
&__legend-column {
width: 45%;
}
&__legends {
padding-left: 10%;
padding-right: 5%;
}
}
}
&--3-columns {
.funnel-graph {
&__legend-column {
width: 30%;
}
&__legends {
padding-left: 6%;
padding-right: 2%;
}
}
}
&__chart-container {
width: 100%;
height: 370px;
}
&__legends {
display: flex;
justify-content: space-between;
padding-left: 7%;
padding-right: 2%;
width: 100%;
}
&__legend-column {
display: flex;
flex-direction: column;
gap: 8px;
.legend-item {
display: flex;
align-items: center;
justify-content: space-between;
font-family: 'Geist Mono', monospace;
font-size: 12px;
&__left {
display: flex;
align-items: center;
gap: 8px;
}
&__right {
display: flex;
align-items: center;
gap: 8px;
}
&__dot {
width: 8px;
height: 8px;
border-radius: 1px;
flex-shrink: 0;
}
&--total {
background-color: var(--bg-robin-500);
}
&--error {
background-color: var(--bg-cherry-500);
}
&__label {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 12px;
}
&__value {
color: var(--bg-vanilla-100);
font-family: 'Geist Mono';
font-size: 12px;
}
}
}
}
.lightMode {
.funnel-graph {
background: var(--bg-vanilla-100);
border: 1px solid var(--bg-vanilla-300);
&__legend-column {
.legend-item {
&__label,
&__value {
color: var(--bg-ink-500);
}
}
}
}
}

View File

@@ -0,0 +1,101 @@
import './FunnelGraph.styles.scss';
import { LoadingOutlined } from '@ant-design/icons';
import { Empty, Spin } from 'antd';
import {
BarController,
BarElement,
CategoryScale,
Chart,
Legend,
LinearScale,
Title,
} from 'chart.js';
import cx from 'classnames';
import Spinner from 'components/Spinner';
import useFunnelGraph from 'hooks/TracesFunnels/useFunnelGraph';
import { useFunnelStepsGraphData } from 'hooks/TracesFunnels/useFunnels';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useMemo } from 'react';
// Register required components
Chart.register(
BarController,
BarElement,
CategoryScale,
LinearScale,
Legend,
Title,
);
function FunnelGraph(): JSX.Element {
const { funnelId } = useFunnelContext();
const {
data: stepsData,
isLoading,
isFetching,
isError,
} = useFunnelStepsGraphData(funnelId);
const data = useMemo(() => stepsData?.payload?.data?.[0]?.data, [
stepsData?.payload?.data,
]);
const {
successSteps,
errorSteps,
totalSteps,
canvasRef,
renderLegendItem,
} = useFunnelGraph({ data });
if (isLoading) {
return (
<div className="funnel-graph">
<Spinner size="default" />
</div>
);
}
if (!data) {
return (
<div className="funnel-graph">
<Empty description="No data" />
</div>
);
}
if (isError) {
return (
<div className="funnel-graph">
<Empty description="Error fetching data. If the problem persists, please contact support." />
</div>
);
}
return (
<Spin spinning={isFetching} indicator={<LoadingOutlined spin />}>
<div className={cx('funnel-graph', `funnel-graph--${totalSteps}-columns`)}>
<div className="funnel-graph__chart-container">
<canvas ref={canvasRef} />
</div>
<div className="funnel-graph__legends">
{Array.from({ length: totalSteps }, (_, index) => {
const prevTotalSpans =
index > 0
? successSteps[index - 1] + errorSteps[index - 1]
: successSteps[0] + errorSteps[0];
return renderLegendItem(
index + 1,
successSteps[index],
errorSteps[index],
prevTotalSpans,
);
})}
</div>
</div>
</Spin>
);
}
export default FunnelGraph;

View File

@@ -0,0 +1,122 @@
.funnel-metrics {
background: var(--bg-ink-500);
border-radius: 6px;
box-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.1);
border: 1px solid var(--bg-slate-500);
&--loading-state,
&--empty-state {
padding: 16px;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
border-bottom: 1px solid var(--bg-slate-500);
}
&__title {
color: var(--bg-vanilla-400);
font-size: 12px;
font-weight: 500;
line-height: 22px;
letter-spacing: 0.48px;
text-transform: uppercase;
}
&__subtitle {
display: flex;
align-items: center;
gap: 8px;
&-label {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
}
&-value {
color: var(--bg-vanilla-100);
font-family: 'Geist Mono';
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
}
}
&__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
&__item {
display: flex;
flex-direction: column;
gap: 26px;
padding: 14px 16px;
&:not(:last-child) {
border-right: 1px solid var(--bg-slate-500);
}
&-title {
color: var(--bg-vanilla-100);
font-size: 14px;
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
}
&-value,
&-unit {
color: var(--bg-vanilla-400);
font-family: 'Geist Mono';
font-size: 14px;
line-height: 22px; /* 157.143% */
letter-spacing: -0.07px;
}
}
}
.lightMode {
.funnel-metrics {
background: var(--bg-vanilla-100);
border: 1px solid var(--bg-vanilla-300);
box-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.05);
&__header {
border-bottom: 1px solid var(--bg-vanilla-300);
}
&__title {
color: var(--text-ink-300);
}
&__subtitle {
&-label {
color: var(--text-ink-300);
}
&-value {
color: var(--text-ink-500);
}
}
&__item {
&:not(:last-child) {
border-right: 1px solid var(--bg-vanilla-300);
}
&-title {
color: var(--text-ink-500);
}
&-value,
&-unit {
color: var(--text-ink-300);
}
}
}
}

View File

@@ -0,0 +1,104 @@
import './FunnelMetricsTable.styles.scss';
import { Empty } from 'antd';
import Spinner from 'components/Spinner';
export interface MetricItem {
title: string;
value: string | number;
}
interface FunnelMetricsTableProps {
title: string;
subtitle?: {
label: string;
value: string | number;
};
data: MetricItem[];
isLoading?: boolean;
isError?: boolean;
emptyState?: JSX.Element;
}
function FunnelMetricsContentRenderer({
data,
isLoading,
isError,
emptyState,
}: {
data: MetricItem[];
isLoading?: boolean;
isError?: boolean;
emptyState?: JSX.Element;
}): JSX.Element {
if (isLoading)
return (
<div className="funnel-metrics--loading-state">
<Spinner size="small" height="100%" />
</div>
);
if (data.length === 0 && emptyState) {
return emptyState;
}
if (isError) {
return (
<Empty description="Error fetching data. If the problem persists, please contact support." />
);
}
return (
<div className="funnel-metrics__grid">
{data.map((metric) => (
<div key={metric.title} className="funnel-metrics__item">
<div className="funnel-metrics__item-title">{metric.title}</div>
<div className="funnel-metrics__item-value">{metric.value}</div>
</div>
))}
</div>
);
}
FunnelMetricsContentRenderer.defaultProps = {
isLoading: false,
isError: false,
emptyState: <Empty className="funnel-metrics--empty-state" />,
};
function FunnelMetricsTable({
title,
subtitle,
data,
isLoading,
isError,
emptyState,
}: FunnelMetricsTableProps): JSX.Element {
return (
<div className="funnel-metrics">
<div className="funnel-metrics__header">
<div className="funnel-metrics__title">{title}</div>
{subtitle && (
<div className="funnel-metrics__subtitle">
<span className="funnel-metrics__subtitle-label">{subtitle.label}</span>
<span className="funnel-metrics__subtitle-separator"></span>
<span className="funnel-metrics__subtitle-value">{subtitle.value}</span>
</div>
)}
</div>
<FunnelMetricsContentRenderer
data={data}
isLoading={isLoading}
emptyState={emptyState}
isError={isError}
/>
</div>
);
}
FunnelMetricsTable.defaultProps = {
subtitle: undefined,
isLoading: false,
emptyState: <Empty className="funnel-metrics--empty-state" />,
isError: false,
};
export default FunnelMetricsTable;

View File

@@ -0,0 +1,6 @@
.funnel-results {
padding: 16px;
display: flex;
flex-direction: column;
gap: 20px;
}

View File

@@ -0,0 +1,51 @@
import './FunnelResults.styles.scss';
import Spinner from 'components/Spinner';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import EmptyFunnelResults from './EmptyFunnelResults';
import FunnelGraph from './FunnelGraph';
import OverallMetrics from './OverallMetrics';
import StepsTransitionResults from './StepsTransitionResults';
function FunnelResults(): JSX.Element {
const {
validTracesCount,
isValidateStepsLoading,
hasIncompleteStepFields,
hasAllEmptyStepFields,
} = useFunnelContext();
if (isValidateStepsLoading) {
return <Spinner size="large" />;
}
if (hasAllEmptyStepFields) return <EmptyFunnelResults />;
if (hasIncompleteStepFields)
return (
<EmptyFunnelResults
title="Missing service / span names"
description="Fill in the service and span names for all the steps"
/>
);
if (validTracesCount === 0) {
return (
<EmptyFunnelResults
title="There are no traces that match the funnel steps."
description="Check the service / span names in the funnel steps and try again to start seeing analytics here"
/>
);
}
return (
<div className="funnel-results">
<OverallMetrics />
<FunnelGraph />
<StepsTransitionResults />
</div>
);
}
export default FunnelResults;

View File

@@ -0,0 +1,148 @@
.funnel-table {
border-radius: 3px;
border: 1px solid var(--bg-slate-500);
background: linear-gradient(
0deg,
rgba(171, 189, 255, 0.01) 0%,
rgba(171, 189, 255, 0.01) 100%
),
#0b0c0e;
&__header {
padding: 12px 14px 12px;
padding-bottom: 24px;
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px;
background: var(--bg-ink-400);
display: flex;
justify-content: space-between;
align-items: center;
}
.ant-table {
.ant-table-thead > tr > th {
padding: 2px 12px;
border-bottom: none;
color: var(--text-vanilla-400);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px;
letter-spacing: 0.44px;
text-transform: uppercase;
background: none;
.ant-table-cell:first-child {
border-radius: 0px 4px 0px 0px !important;
}
&::before {
background-color: transparent;
}
}
.ant-table-cell {
padding: 12px;
font-size: 13px;
line-height: 20px;
color: var(--bg-vanilla-100);
border-bottom: none;
}
.ant-table-tbody > tr:hover > td {
background: rgba(255, 255, 255, 0.04);
}
.ant-table-cell:first-child {
text-align: justify;
background: rgba(171, 189, 255, 0.04);
}
.ant-table-cell:nth-child(2) {
padding-left: 16px;
padding-right: 16px;
}
.ant-table-cell:nth-child(n + 3) {
padding-right: 24px;
}
.column-header {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.ant-table-tbody > tr > td {
border-bottom: none;
}
.ant-table-thead
> tr
> th:not(:last-child):not(.ant-table-selection-column):not(.ant-table-row-expand-icon-cell):not([colspan])::before {
background-color: transparent;
}
.ant-empty-normal {
visibility: hidden;
}
.table-row-light {
background: none;
}
.table-row-dark {
background: var(--bg-ink-300);
}
.trace-id-cell {
color: var(--bg-robin-400);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
}
.lightMode {
.funnel-table {
border-color: var(--bg-vanilla-300);
.ant-table {
.ant-table-thead > tr > th {
background: var(--bg-vanilla-100);
color: var(--text-ink-300);
}
.ant-table-cell {
background: var(--bg-vanilla-100);
color: var(--bg-ink-500);
}
.ant-table-tbody > tr:hover > td {
background: rgba(0, 0, 0, 0.04);
}
.table-row-light {
background: none;
color: var(--bg-ink-500);
}
.table-row-dark {
background: none;
color: var(--bg-ink-500);
}
}
&__header {
background: var(--bg-vanilla-100);
color: var(--text-ink-300);
}
}
}

View File

@@ -0,0 +1,55 @@
import './FunnelTable.styles.scss';
import { Empty, Table, Tooltip } from 'antd';
import { ColumnProps } from 'antd/es/table';
interface FunnelTableProps {
loading?: boolean;
data?: any[];
columns: Array<ColumnProps<any>>;
title: string;
tooltip?: string;
}
function FunnelTable({
loading = false,
data = [],
columns = [],
title,
tooltip,
}: FunnelTableProps): JSX.Element {
return (
<div className="funnel-table">
<div className="funnel-table__header">
<div className="funnel-table__title">{title}</div>
<div className="funnel-table__actions">
<Tooltip title={tooltip ?? null}>
<img src="/Icons/solid-info-circle.svg" alt="info" />
</Tooltip>
</div>
</div>
<Table
columns={columns}
dataSource={data}
loading={loading}
pagination={false}
locale={{
emptyText: loading ? null : <Empty />,
}}
scroll={{ x: true }}
tableLayout="fixed"
rowClassName={(_, index): string =>
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
}
/>
</div>
);
}
FunnelTable.defaultProps = {
loading: false,
data: [],
tooltip: '',
};
export default FunnelTable;

View File

@@ -0,0 +1,103 @@
import { ErrorTraceData, SlowTraceData } from 'api/traceFunnels';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { Link } from 'react-router-dom';
import { ErrorResponse, SuccessResponse } from 'types/api';
import FunnelTable from './FunnelTable';
interface FunnelTopTracesTableProps {
funnelId: string;
stepAOrder: number;
stepBOrder: number;
title: string;
tooltip: string;
useQueryHook: (
funnelId: string,
payload: {
start_time: number;
end_time: number;
step_a_order: number;
step_b_order: number;
},
) => UseQueryResult<
SuccessResponse<SlowTraceData | ErrorTraceData> | ErrorResponse,
Error
>;
}
function FunnelTopTracesTable({
funnelId,
stepAOrder,
stepBOrder,
title,
tooltip,
useQueryHook,
}: FunnelTopTracesTableProps): JSX.Element {
const { startTime, endTime } = useFunnelContext();
const payload = useMemo(
() => ({
start_time: startTime,
end_time: endTime,
step_a_order: stepAOrder,
step_b_order: stepBOrder,
}),
[startTime, endTime, stepAOrder, stepBOrder],
);
const { data: response, isLoading, isFetching } = useQueryHook(
funnelId,
payload,
);
const data = useMemo(() => {
if (!response?.payload?.data) return [];
return response.payload.data.map((item) => ({
trace_id: item.data.trace_id,
duration_ms: item.data.duration_ms,
span_count: item.data.span_count,
}));
}, [response]);
const columns = useMemo(
() => [
{
title: 'TRACE ID',
dataIndex: 'trace_id',
key: 'trace_id',
render: (traceId: string): JSX.Element => (
<Link to={`/trace/${traceId}`} className="trace-id-cell">
{traceId}
</Link>
),
},
{
title: 'DURATION',
dataIndex: 'duration_ms',
key: 'duration_ms',
render: (value: string): string => getYAxisFormattedValue(value, 'ms'),
},
{
title: 'SPAN COUNT',
dataIndex: 'span_count',
key: 'span_count',
render: (value: number): string => value.toString(),
},
],
[],
);
return (
<FunnelTable
title={title}
tooltip={tooltip}
columns={columns}
data={data}
loading={isLoading || isFetching}
/>
);
}
export default FunnelTopTracesTable;

View File

@@ -0,0 +1,26 @@
import { useFunnelMetrics } from 'hooks/TracesFunnels/useFunnelMetrics';
import { useParams } from 'react-router-dom';
import FunnelMetricsTable from './FunnelMetricsTable';
function OverallMetrics(): JSX.Element {
const { funnelId } = useParams<{ funnelId: string }>();
const { isLoading, metricsData, conversionRate, isError } = useFunnelMetrics({
funnelId: funnelId || '',
});
return (
<FunnelMetricsTable
title="Overall Funnel Metrics"
subtitle={{
label: 'Conversion rate',
value: `${conversionRate.toFixed(2)}%`,
}}
isLoading={isLoading}
isError={isError}
data={metricsData}
/>
);
}
export default OverallMetrics;

View File

@@ -0,0 +1,53 @@
import { useFunnelMetrics } from 'hooks/TracesFunnels/useFunnelMetrics';
import { useParams } from 'react-router-dom';
import FunnelMetricsTable from './FunnelMetricsTable';
import { StepTransition } from './StepsTransitionResults';
interface StepsTransitionMetricsProps {
selectedTransition: string;
transitions: StepTransition[];
startStep?: number;
endStep?: number;
}
function StepsTransitionMetrics({
selectedTransition,
transitions,
startStep,
endStep,
}: StepsTransitionMetricsProps): JSX.Element {
const { funnelId } = useParams<{ funnelId: string }>();
const currentTransition = transitions.find(
(transition) => transition.value === selectedTransition,
);
const { isLoading, metricsData, conversionRate } = useFunnelMetrics({
funnelId: funnelId || '',
stepStart: startStep,
stepEnd: endStep,
});
if (!currentTransition) {
return <div>No transition selected</div>;
}
return (
<FunnelMetricsTable
title={currentTransition.label}
subtitle={{
label: 'Conversion rate',
value: `${conversionRate.toFixed(2)}%`,
}}
isLoading={isLoading}
data={metricsData}
/>
);
}
StepsTransitionMetrics.defaultProps = {
startStep: undefined,
endStep: undefined,
};
export default StepsTransitionMetrics;

View File

@@ -0,0 +1,38 @@
.steps-transition-results {
display: flex;
flex-direction: column;
gap: 20px;
&__steps-selector {
display: flex;
justify-content: center;
}
&__results {
display: flex;
flex-direction: column;
gap: 16px;
}
}
.lightMode {
.steps-transition-results {
&__steps-selector {
.views-tabs {
.tab {
background: var(--bg-vanilla-100);
}
.selected_view {
background: var(--bg-vanilla-300);
border: 1px solid var(--bg-slate-300);
color: var(--text-ink-400);
}
.selected_view::before {
background: var(--bg-vanilla-300);
border-left: 1px solid var(--bg-slate-300);
}
}
}
}
}

View File

@@ -0,0 +1,66 @@
import './StepsTransitionResults.styles.scss';
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useMemo, useState } from 'react';
import StepsTransitionMetrics from './StepsTransitionMetrics';
import TopSlowestTraces from './TopSlowestTraces';
import TopTracesWithErrors from './TopTracesWithErrors';
export interface StepTransition {
value: string;
label: string;
}
function generateStepTransitions(stepsCount: number): StepTransition[] {
return Array.from({ length: stepsCount - 1 }, (_, index) => ({
value: `${index + 1}_to_${index + 2}`,
label: `Step ${index + 1} -> Step ${index + 2}`,
}));
}
function StepsTransitionResults(): JSX.Element {
const { steps, funnelId } = useFunnelContext();
const stepTransitions = generateStepTransitions(steps.length);
const [selectedTransition, setSelectedTransition] = useState<string>(
stepTransitions[0]?.value || '',
);
const [stepAOrder, stepBOrder] = useMemo(() => {
const [a, b] = selectedTransition.split('_to_');
return [parseInt(a, 10), parseInt(b, 10)];
}, [selectedTransition]);
return (
<div className="steps-transition-results">
<div className="steps-transition-results__steps-selector">
<SignozRadioGroup
value={selectedTransition}
options={stepTransitions}
onChange={(e): void => setSelectedTransition(e.target.value)}
/>
</div>
<div className="steps-transition-results__results">
<StepsTransitionMetrics
selectedTransition={selectedTransition}
transitions={stepTransitions}
startStep={stepAOrder}
endStep={stepBOrder}
/>
<TopSlowestTraces
funnelId={funnelId}
stepAOrder={stepAOrder}
stepBOrder={stepBOrder}
/>
<TopTracesWithErrors
funnelId={funnelId}
stepAOrder={stepAOrder}
stepBOrder={stepBOrder}
/>
</div>
</div>
);
}
export default StepsTransitionResults;

View File

@@ -0,0 +1,23 @@
import { useFunnelSlowTraces } from 'hooks/TracesFunnels/useFunnels';
import FunnelTopTracesTable from './FunnelTopTracesTable';
interface TopSlowestTracesProps {
funnelId: string;
stepAOrder: number;
stepBOrder: number;
}
function TopSlowestTraces(props: TopSlowestTracesProps): JSX.Element {
return (
<FunnelTopTracesTable
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
title="Slowest 5 traces"
tooltip="A list of the slowest traces in the funnel"
useQueryHook={useFunnelSlowTraces}
/>
);
}
export default TopSlowestTraces;

View File

@@ -0,0 +1,23 @@
import { useFunnelErrorTraces } from 'hooks/TracesFunnels/useFunnels';
import FunnelTopTracesTable from './FunnelTopTracesTable';
interface TopTracesWithErrorsProps {
funnelId: string;
stepAOrder: number;
stepBOrder: number;
}
function TopTracesWithErrors(props: TopTracesWithErrorsProps): JSX.Element {
return (
<FunnelTopTracesTable
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
title="Traces with errors"
tooltip="A list of the traces with errors in the funnel"
useQueryHook={useFunnelErrorTraces}
/>
);
}
export default TopTracesWithErrors;

View File

@@ -0,0 +1,49 @@
import { FunnelStepData, LatencyOptions } from 'types/api/traceFunnels';
import { v4 } from 'uuid';
export const initialStepsData: FunnelStepData[] = [
{
id: v4(),
step_order: 1,
service_name: '',
span_name: '',
filters: {
items: [],
op: 'and',
},
latency_pointer: 'start',
latency_type: LatencyOptions.P95,
has_errors: false,
title: '',
description: '',
},
{
id: v4(),
step_order: 2,
service_name: '',
span_name: '',
filters: {
items: [],
op: 'and',
},
latency_pointer: 'start',
latency_type: LatencyOptions.P95,
has_errors: false,
title: '',
description: '',
},
];
export const LatencyPointers: {
value: FunnelStepData['latency_pointer'];
key: string;
}[] = [
{
value: 'start',
key: 'Start of span',
},
{
value: 'end',
key: 'End of span',
},
];

View File

@@ -0,0 +1,237 @@
import { ValidateFunnelResponse } from 'api/traceFunnels';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { Time } from 'container/TopNav/DateTimeSelection/config';
import {
CustomTimeType,
Time as TimeV2,
} from 'container/TopNav/DateTimeSelectionV2/config';
import { useValidateFunnelSteps } from 'hooks/TracesFunnels/useFunnels';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import { initialStepsData } from 'pages/TracesFunnelDetails/constants';
import {
createContext,
Dispatch,
SetStateAction,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import { useQueryClient } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { FunnelData, FunnelStepData } from 'types/api/traceFunnels';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 } from 'uuid';
interface FunnelContextType {
startTime: number;
endTime: number;
selectedTime: CustomTimeType | Time | TimeV2;
validTracesCount: number;
funnelId: string;
steps: FunnelStepData[];
setSteps: Dispatch<SetStateAction<FunnelStepData[]>>;
initialSteps: FunnelStepData[];
handleAddStep: () => boolean;
handleStepChange: (index: number, newStep: Partial<FunnelStepData>) => void;
handleStepRemoval: (index: number) => void;
handleRunFunnel: () => void;
validationResponse:
| SuccessResponse<ValidateFunnelResponse>
| ErrorResponse
| undefined;
isValidateStepsLoading: boolean;
hasIncompleteStepFields: boolean;
setHasIncompleteStepFields: Dispatch<SetStateAction<boolean>>;
hasAllEmptyStepFields: boolean;
setHasAllEmptyStepFields: Dispatch<SetStateAction<boolean>>;
handleReplaceStep: (
index: number,
serviceName: string,
spanName: string,
) => void;
}
const FunnelContext = createContext<FunnelContextType | undefined>(undefined);
export function FunnelProvider({
children,
funnelId,
}: {
children: React.ReactNode;
funnelId: string;
}): JSX.Element {
const { selectedTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { start, end } = getStartEndRangeTime({
type: 'GLOBAL_TIME',
interval: selectedTime,
});
const startTime = Math.floor(Number(start) * 1e9);
const endTime = Math.floor(Number(end) * 1e9);
const queryClient = useQueryClient();
const data = queryClient.getQueryData<{ payload: FunnelData }>([
REACT_QUERY_KEY.GET_FUNNEL_DETAILS,
funnelId,
]);
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 [hasAllEmptyStepFields, setHasAllEmptyStepFields] = useState(
steps.every((step) => step.service_name === '' && step.span_name === ''),
);
const {
data: validationResponse,
isLoading: isValidationLoading,
isFetching: isValidationFetching,
} = useValidateFunnelSteps({
funnelId,
selectedTime,
startTime,
endTime,
});
const validTracesCount = useMemo(
() => validationResponse?.payload?.data?.length || 0,
[validationResponse],
);
// Step modifications
const handleStepUpdate = useCallback(
(index: number, newStep: Partial<FunnelStepData>) => {
setSteps((prev) =>
prev.map((step, i) => (i === index ? { ...step, ...newStep } : step)),
);
},
[],
);
const addNewStep = useCallback(() => {
if (steps.length >= 3) return false;
setSteps((prev) => [
...prev,
{
...initialStepsData[0],
id: v4(),
step_order: prev.length + 1,
},
]);
return true;
}, [steps.length]);
const handleStepRemoval = useCallback((index: number) => {
setSteps((prev) =>
prev
// remove the step in the index
.filter((_, i) => i !== index)
// reset the step_order for the remaining steps
.map((step, newIndex) => ({
...step,
step_order: newIndex + 1,
})),
);
}, []);
const handleReplaceStep = useCallback(
(index: number, serviceName: string, spanName: string) => {
handleStepUpdate(index, {
service_name: serviceName,
span_name: spanName,
});
},
[handleStepUpdate],
);
if (!funnelId) {
throw new Error('Funnel ID is required');
}
const handleRunFunnel = useCallback(async (): Promise<void> => {
if (validTracesCount === 0) return;
queryClient.refetchQueries([
REACT_QUERY_KEY.GET_FUNNEL_OVERVIEW,
funnelId,
selectedTime,
]);
queryClient.refetchQueries([
REACT_QUERY_KEY.GET_FUNNEL_STEPS_GRAPH_DATA,
funnelId,
selectedTime,
]);
queryClient.refetchQueries([
REACT_QUERY_KEY.GET_FUNNEL_ERROR_TRACES,
funnelId,
selectedTime,
]);
queryClient.refetchQueries([
REACT_QUERY_KEY.GET_FUNNEL_SLOW_TRACES,
funnelId,
selectedTime,
]);
}, [funnelId, queryClient, selectedTime, validTracesCount]);
const value = useMemo<FunnelContextType>(
() => ({
funnelId,
startTime,
endTime,
validTracesCount,
selectedTime,
steps,
setSteps,
initialSteps,
handleStepChange: handleStepUpdate,
handleAddStep: addNewStep,
handleStepRemoval,
handleRunFunnel,
validationResponse,
isValidateStepsLoading: isValidationLoading || isValidationFetching,
hasIncompleteStepFields,
setHasIncompleteStepFields,
hasAllEmptyStepFields,
setHasAllEmptyStepFields,
handleReplaceStep,
}),
[
funnelId,
startTime,
endTime,
validTracesCount,
selectedTime,
steps,
initialSteps,
handleStepUpdate,
addNewStep,
handleStepRemoval,
handleRunFunnel,
validationResponse,
isValidationLoading,
isValidationFetching,
hasIncompleteStepFields,
setHasIncompleteStepFields,
hasAllEmptyStepFields,
setHasAllEmptyStepFields,
handleReplaceStep,
],
);
return (
<FunnelContext.Provider value={value}>{children}</FunnelContext.Provider>
);
}
export function useFunnelContext(): FunnelContextType {
const context = useContext(FunnelContext);
if (context === undefined) {
throw new Error('useFunnelContext must be used within a FunnelProvider');
}
return context;
}

View File

@@ -1,6 +1,7 @@
import '../RenameFunnel/RenameFunnel.styles.scss';
import { Input } from 'antd';
import { AxiosError } from 'axios';
import SignozModal from 'components/SignozModal/SignozModal';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
@@ -14,10 +15,15 @@ import { generatePath } from 'react-router-dom';
interface CreateFunnelProps {
isOpen: boolean;
onClose: () => void;
onClose: (funnelId?: string) => void;
redirectToDetails?: boolean;
}
function CreateFunnel({ isOpen, onClose }: CreateFunnelProps): JSX.Element {
function CreateFunnel({
isOpen,
onClose,
redirectToDetails,
}: CreateFunnelProps): JSX.Element {
const [funnelName, setFunnelName] = useState<string>('');
const createFunnelMutation = useCreateFunnel();
const { notifications } = useNotifications();
@@ -37,8 +43,8 @@ function CreateFunnel({ isOpen, onClose }: CreateFunnelProps): JSX.Element {
});
setFunnelName('');
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_FUNNELS_LIST]);
onClose();
if (data?.payload?.funnel_id) {
onClose(data?.payload?.funnel_id);
if (data?.payload?.funnel_id && redirectToDetails) {
safeNavigate(
generatePath(ROUTES.TRACES_FUNNELS_DETAIL, {
funnelId: data.payload.funnel_id,
@@ -46,9 +52,11 @@ function CreateFunnel({ isOpen, onClose }: CreateFunnelProps): JSX.Element {
);
}
},
onError: () => {
onError: (error) => {
notifications.error({
message: 'Failed to create funnel',
message:
((error as AxiosError)?.response?.data as string) ||
'Failed to create funnel',
});
},
},
@@ -100,4 +108,7 @@ function CreateFunnel({ isOpen, onClose }: CreateFunnelProps): JSX.Element {
);
}
CreateFunnel.defaultProps = {
redirectToDetails: true,
};
export default CreateFunnel;

View File

@@ -3,26 +3,32 @@ import './DeleteFunnel.styles.scss';
import SignozModal from 'components/SignozModal/SignozModal';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import { useDeleteFunnel } from 'hooks/TracesFunnels/useFunnels';
import { useNotifications } from 'hooks/useNotifications';
import { Trash2, X } from 'lucide-react';
import { useQueryClient } from 'react-query';
import { useHistory } from 'react-router-dom';
interface DeleteFunnelProps {
isOpen: boolean;
onClose: () => void;
funnelId: string;
shouldRedirectToTracesListOnDeleteSuccess?: boolean;
}
function DeleteFunnel({
isOpen,
onClose,
funnelId,
shouldRedirectToTracesListOnDeleteSuccess,
}: DeleteFunnelProps): JSX.Element {
const deleteFunnelMutation = useDeleteFunnel();
const { notifications } = useNotifications();
const queryClient = useQueryClient();
const history = useHistory();
const { pathname } = history.location;
const handleDelete = (): void => {
deleteFunnelMutation.mutate(
{
@@ -34,6 +40,14 @@ function DeleteFunnel({
message: 'Funnel deleted successfully',
});
onClose();
if (
pathname !== ROUTES.TRACES_FUNNELS &&
shouldRedirectToTracesListOnDeleteSuccess
) {
history.push(ROUTES.TRACES_FUNNELS);
return;
}
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_FUNNELS_LIST]);
},
onError: () => {
@@ -81,4 +95,8 @@ function DeleteFunnel({
);
}
DeleteFunnel.defaultProps = {
shouldRedirectToTracesListOnDeleteSuccess: true,
};
export default DeleteFunnel;

View File

@@ -5,7 +5,7 @@ import LearnMore from 'components/LearnMore/LearnMore';
import { Plus } from 'lucide-react';
interface FunnelsEmptyStateProps {
onCreateFunnel: () => void;
onCreateFunnel?: () => void;
}
function FunnelsEmptyState({
@@ -44,4 +44,8 @@ function FunnelsEmptyState({
);
}
FunnelsEmptyState.defaultProps = {
onCreateFunnel: undefined,
};
export default FunnelsEmptyState;

View File

@@ -11,6 +11,7 @@ interface FunnelItemPopoverProps {
isPopoverOpen: boolean;
setIsPopoverOpen: (isOpen: boolean) => void;
funnel: FunnelData;
shouldRedirectToTracesListOnDeleteSuccess?: boolean;
}
interface FunnelItemActionsProps {
@@ -56,6 +57,7 @@ function FunnelItemPopover({
isPopoverOpen,
setIsPopoverOpen,
funnel,
shouldRedirectToTracesListOnDeleteSuccess,
}: FunnelItemPopoverProps): JSX.Element {
const [isRenameModalOpen, setIsRenameModalOpen] = useState<boolean>(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false);
@@ -71,7 +73,12 @@ function FunnelItemPopover({
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div onClick={preventDefault} role="button" tabIndex={0}>
<div
onClick={preventDefault}
role="button"
tabIndex={0}
className="funnel-item__actions-popover"
>
<Popover
trigger="click"
rootClassName="funnel-item__actions"
@@ -96,6 +103,9 @@ function FunnelItemPopover({
</Popover>
<DeleteFunnel
shouldRedirectToTracesListOnDeleteSuccess={
shouldRedirectToTracesListOnDeleteSuccess
}
isOpen={isDeleteModalOpen}
onClose={(): void => setIsDeleteModalOpen(false)}
funnelId={funnel.id}
@@ -111,4 +121,7 @@ function FunnelItemPopover({
);
}
FunnelItemPopover.defaultProps = {
shouldRedirectToTracesListOnDeleteSuccess: true,
};
export default FunnelItemPopover;

View File

@@ -12,16 +12,22 @@ import FunnelItemPopover from './FunnelItemPopover';
interface FunnelListItemProps {
funnel: FunnelData;
onFunnelClick?: (funnel: FunnelData) => void;
shouldRedirectToTracesListOnDeleteSuccess?: boolean;
}
function FunnelListItem({ funnel }: FunnelListItemProps): JSX.Element {
export function FunnelListItem({
funnel,
onFunnelClick,
shouldRedirectToTracesListOnDeleteSuccess,
}: FunnelListItemProps): JSX.Element {
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const funnelDetailsLink = generatePath(ROUTES.TRACES_FUNNELS_DETAIL, {
funnelId: funnel.id,
});
return (
<Link to={funnelDetailsLink} className="funnel-item">
const content = (
<>
<div className="funnel-item__header">
<div className="funnel-item__title">
<div>{funnel.funnel_name}</div>
@@ -30,6 +36,9 @@ function FunnelListItem({ funnel }: FunnelListItemProps): JSX.Element {
isPopoverOpen={isPopoverOpen}
setIsPopoverOpen={setIsPopoverOpen}
funnel={funnel}
shouldRedirectToTracesListOnDeleteSuccess={
shouldRedirectToTracesListOnDeleteSuccess
}
/>
</div>
@@ -52,22 +61,59 @@ function FunnelListItem({ funnel }: FunnelListItemProps): JSX.Element {
<div>{funnel.user}</div>
</div>
</div>
</>
);
return onFunnelClick ? (
<button
type="button"
className="funnel-item"
onClick={(): void => onFunnelClick(funnel)}
>
{content}
</button>
) : (
<Link to={funnelDetailsLink} className="funnel-item">
{content}
</Link>
);
}
FunnelListItem.defaultProps = {
onFunnelClick: undefined,
shouldRedirectToTracesListOnDeleteSuccess: true,
};
interface FunnelsListProps {
data: FunnelData[];
onFunnelClick?: (funnel: FunnelData) => void;
shouldRedirectToTracesListOnDeleteSuccess?: boolean;
}
function FunnelsList({ data }: FunnelsListProps): JSX.Element {
function FunnelsList({
data,
onFunnelClick,
shouldRedirectToTracesListOnDeleteSuccess,
}: FunnelsListProps): JSX.Element {
return (
<div className="funnels-list">
{data.map((funnel) => (
<FunnelListItem key={funnel.id} funnel={funnel} />
{data?.map((funnel) => (
<FunnelListItem
key={funnel.id}
funnel={funnel}
onFunnelClick={onFunnelClick}
shouldRedirectToTracesListOnDeleteSuccess={
shouldRedirectToTracesListOnDeleteSuccess
}
/>
))}
</div>
);
}
FunnelsList.defaultProps = {
onFunnelClick: undefined,
shouldRedirectToTracesListOnDeleteSuccess: true,
};
export default FunnelsList;

View File

@@ -17,18 +17,22 @@ interface TracesFunnelsContentRendererProps {
isLoading: boolean;
isError: boolean;
data: FunnelData[];
onCreateFunnel: () => void;
onCreateFunnel?: () => void;
onFunnelClick?: (funnel: FunnelData) => void;
shouldRedirectToTracesListOnDeleteSuccess?: boolean;
}
function TracesFunnelsContentRenderer({
export function TracesFunnelsContentRenderer({
isLoading,
isError,
data,
onCreateFunnel,
onFunnelClick,
shouldRedirectToTracesListOnDeleteSuccess,
}: TracesFunnelsContentRendererProps): JSX.Element {
if (isLoading) {
return (
<div className="traces-funnels__loading">
{Array(6)
{Array(2)
.fill(0)
.map((item, index) => (
<Skeleton.Button
@@ -49,13 +53,27 @@ function TracesFunnelsContentRenderer({
return <div>Something went wrong</div>;
}
if (data.length === 0) {
if (data.length === 0 && onCreateFunnel) {
return <FunnelsEmptyState onCreateFunnel={onCreateFunnel} />;
}
return <FunnelsList data={data} />;
return (
<FunnelsList
data={data}
onFunnelClick={onFunnelClick}
shouldRedirectToTracesListOnDeleteSuccess={
shouldRedirectToTracesListOnDeleteSuccess
}
/>
);
}
TracesFunnelsContentRenderer.defaultProps = {
onCreateFunnel: undefined,
onFunnelClick: undefined,
shouldRedirectToTracesListOnDeleteSuccess: true,
};
function TracesFunnels(): JSX.Element {
const { searchQuery, handleSearch } = useHandleTraceFunnelsSearch();
const [isCreateModalOpen, setIsCreateModalOpen] = useState<boolean>(false);

View File

@@ -3,6 +3,7 @@ import './TracesModulePage.styles.scss';
import RouteTab from 'components/RouteTab';
import { TabRoutes } from 'components/RouteTab/types';
import { FeatureKeys } from 'constants/features';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import { useLocation } from 'react-router-dom';
@@ -19,13 +20,19 @@ function TracesModulePage(): JSX.Element {
const routes: TabRoutes[] = [
tracesExplorer,
isTraceFunnelsEnabled ? tracesFunnel : null,
isTraceFunnelsEnabled ? tracesFunnel(pathname) : null,
tracesSaveView,
].filter(Boolean) as TabRoutes[];
return (
<div className="traces-module-container">
<RouteTab routes={routes} activeKey={pathname} history={history} />
<RouteTab
routes={routes}
activeKey={
pathname.includes(ROUTES.TRACES_FUNNELS) ? ROUTES.TRACES_FUNNELS : pathname
}
history={history}
/>
</div>
);
}

View File

@@ -3,7 +3,9 @@ import ROUTES from 'constants/routes';
import { Compass, Cone, TowerControl } from 'lucide-react';
import SaveView from 'pages/SaveView';
import TracesExplorer from 'pages/TracesExplorer';
import TracesFunnelDetails from 'pages/TracesFunnelDetails';
import TracesFunnels from 'pages/TracesFunnels';
import { matchPath } from 'react-router-dom';
export const tracesExplorer: TabRoutes = {
Component: TracesExplorer,
@@ -16,8 +18,12 @@ export const tracesExplorer: TabRoutes = {
key: ROUTES.TRACES_EXPLORER,
};
export const tracesFunnel: TabRoutes = {
Component: TracesFunnels,
export const tracesFunnel = (pathname: string): TabRoutes => ({
Component: (): JSX.Element => {
const isFunnelDetails = matchPath(pathname, ROUTES.TRACES_FUNNELS_DETAIL);
return isFunnelDetails ? <TracesFunnelDetails /> : <TracesFunnels />;
},
name: (
<div className="tab-item">
<Cone className="funnel-icon" size={16} /> Funnels
@@ -25,7 +31,7 @@ export const tracesFunnel: TabRoutes = {
),
route: ROUTES.TRACES_FUNNELS,
key: ROUTES.TRACES_FUNNELS,
};
});
export const tracesSaveView: TabRoutes = {
Component: SaveView,

View File

@@ -1,14 +1,23 @@
import { TagFilter } from '../queryBuilder/queryBuilderData';
export interface FunnelStep {
export enum LatencyOptions {
P99 = 'p99',
P95 = 'p95',
P90 = 'p90',
}
export type LatencyOptionsType = 'p99' | 'p95' | 'p90';
export interface FunnelStepData {
id: string;
funnel_order: number;
step_order: number;
service_name: string;
span_name: string;
filters: TagFilter;
latency_pointer: 'start' | 'end';
latency_type: 'p95' | 'p99' | 'p90';
latency_type: LatencyOptionsType;
has_errors: boolean;
title?: string;
description?: string;
}
export interface FunnelData {
@@ -17,7 +26,8 @@ export interface FunnelData {
creation_timestamp: number;
updated_timestamp: number;
user: string;
steps?: FunnelStep[];
description?: string;
steps?: FunnelStepData[];
}
export interface CreateFunnelPayload {

View File

@@ -66,7 +66,7 @@ type Server struct {
func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registerer, srvConfig Config, orgID string, stateStore alertmanagertypes.StateStore) (*Server, error) {
server := &Server{
logger: logger.With("pkg", "go.signoz.io/pkg/alertmanager/alertmanagerserver"),
logger: logger.With("pkg", "github.com/SigNoz/pkg/alertmanager/alertmanagerserver"),
registry: registry,
srvConfig: srvConfig,
orgID: orgID,

View File

@@ -33,7 +33,7 @@ func NewRegistry(logger *slog.Logger, services ...NamedService) (*Registry, erro
}
return &Registry{
logger: logger.With("pkg", "go.signoz.io/pkg/factory"),
logger: logger.With("pkg", "github.com/SigNoz/pkg/factory"),
services: m,
startCh: make(chan error, 1),
stopCh: make(chan error, len(services)),

View File

@@ -3,7 +3,7 @@ package middleware
import "net/http"
const (
pkgname string = "go.signoz.io/pkg/http/middleware"
pkgname string = "github.com/SigNoz/pkg/http/middleware"
)
// Wrapper is an interface implemented by all middlewares

View File

@@ -38,7 +38,7 @@ func New(logger *zap.Logger, cfg Config, handler http.Handler) (*Server, error)
return &Server{
srv: srv,
logger: logger.Named("go.signoz.io/pkg/http/server"),
logger: logger.Named("github.com/SigNoz/pkg/http/server"),
handler: handler,
cfg: cfg,
}, nil

View File

@@ -18,6 +18,9 @@ import (
"text/template"
"time"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations/traceFunnels"
"github.com/google/uuid"
"github.com/SigNoz/signoz/pkg/alertmanager"
errorsV2 "github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
@@ -142,6 +145,8 @@ type APIHandler struct {
AlertmanagerAPI *alertmanager.API
Signoz *signoz.SigNoz
TraceFunnels *traceFunnels.SQLClient
}
type APIHandlerOpts struct {
@@ -227,6 +232,11 @@ func NewAPIHandler(opts APIHandlerOpts) (*APIHandler, error) {
pvcsRepo := inframetrics.NewPvcsRepo(opts.Reader, querierv2)
summaryService := metricsexplorer.NewSummaryService(opts.Reader, opts.RuleManager)
traceFunnelsClient, err := traceFunnels.NewSQLClient(opts.Signoz.SQLStore)
if err != nil {
return nil, fmt.Errorf("failed to create trace funnels client: %v", err)
}
aH := &APIHandler{
reader: opts.Reader,
appDao: opts.AppDao,
@@ -257,6 +267,7 @@ func NewAPIHandler(opts APIHandlerOpts) (*APIHandler, error) {
SummaryService: summaryService,
AlertmanagerAPI: opts.AlertmanagerAPI,
Signoz: opts.Signoz,
TraceFunnels: traceFunnelsClient,
}
logsQueryBuilder := logsv3.PrepareLogsQuery
@@ -1331,6 +1342,7 @@ func (aH *APIHandler) testRule(w http.ResponseWriter, r *http.Request) {
return
}
// ToDo: (shivanshu26) should be a struct
response := map[string]interface{}{
"alertCount": alertCount,
"message": "notification sent",
@@ -5590,3 +5602,531 @@ 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 *AuthMiddleware) {
// Main messaging queues router
traceFunnelsRouter := router.PathPrefix("/api/v1/trace-funnels").Subrouter()
// API endpoints
traceFunnelsRouter.HandleFunc("/new-funnel", aH.handleNewFunnel).Methods("POST")
traceFunnelsRouter.HandleFunc("/steps/update", aH.handleUpdateFunnelStep).Methods("PUT")
traceFunnelsRouter.HandleFunc("/list", aH.handleListFunnels).Methods("GET")
// Standard RESTful endpoints for funnel resource
traceFunnelsRouter.HandleFunc("/get/{funnel_id}", aH.handleGetFunnel).Methods("GET")
traceFunnelsRouter.HandleFunc("/delete/{funnel_id}", aH.handleDeleteFunnel).Methods("DELETE")
traceFunnelsRouter.HandleFunc("/save", aH.handleSaveFunnel).Methods("POST")
//// 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/slow-traces", func(w http.ResponseWriter, r *http.Request) {
aH.handleSlowTraces(w, r, false)
}).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/error-traces", func(w http.ResponseWriter, r *http.Request) {
aH.handleSlowTraces(w, r, true)
}).Methods("POST")
}
// handleNewFunnel creates a new funnel without steps
// Steps should be added separately using the update endpoint
func (aH *APIHandler) handleNewFunnel(w http.ResponseWriter, r *http.Request) {
var req traceFunnels.NewFunnelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
claims, ok := authtypes.ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
userID := claims.UserID
orgID := claims.OrgID
// Validate timestamp is provided and in milliseconds format
if err := traceFunnels.ValidateTimestamp(req.Timestamp, "creation_timestamp"); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Check for name collision in the SQLite database
exists, err := aH.TraceFunnels.CheckFunnelNameCollision(req.Name, userID)
if err != nil {
zap.L().Error("Error checking for funnel name collision: %v", zap.Error(err))
http.Error(w, fmt.Sprintf("failed to check funnel name collision: %v", err), http.StatusInternalServerError)
return
} else if exists {
http.Error(w, fmt.Sprintf("funnel with name '%s' already exists for user '%s' in the database", req.Name, userID), http.StatusBadRequest)
return
}
funnel := &traceFunnels.Funnel{
ID: uuid.New().String(),
Name: req.Name,
CreatedAt: req.Timestamp * 1000000, // Convert milliseconds to nanoseconds for internal storage
CreatedBy: userID,
OrgID: orgID,
Steps: make([]traceFunnels.FunnelStep, 0),
}
// Create new funnel in database
err = aH.TraceFunnels.CreateFunnel(funnel, userID, orgID)
if err != nil {
http.Error(w, fmt.Sprintf("failed to save funnel to database: %v", err), http.StatusInternalServerError)
return
}
response := traceFunnels.NewFunnelResponse{
ID: funnel.ID,
Name: funnel.Name,
CreatedAt: funnel.CreatedAt / 1000000,
CreatedBy: funnel.CreatedBy,
OrgID: orgID,
}
json.NewEncoder(w).Encode(response)
}
// handleUpdateFunnelStep adds or updates steps for an existing funnel
// Steps are identified by their step_order, which must be unique within a funnel
func (aH *APIHandler) handleUpdateFunnelStep(w http.ResponseWriter, r *http.Request) {
var req traceFunnels.FunnelStepRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
claims, ok := authtypes.ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
userID := claims.UserID
if err := traceFunnels.ValidateTimestamp(req.Timestamp, "updated_timestamp"); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
aH.Signoz.SQLStore.SQLxDB()
dbClient, _ := traceFunnels.NewSQLClient(aH.Signoz.SQLStore)
funnel, err := dbClient.GetFunnelFromDB(req.FunnelID)
if err != nil {
http.Error(w, fmt.Sprintf("funnel not found: %v", err), http.StatusNotFound)
return
}
// Process each step in the request
for i := range req.Steps {
if req.Steps[i].StepOrder < 1 {
req.Steps[i].StepOrder = int64(i + 1) // Default to sequential ordering if not specified
}
}
if err := traceFunnels.ValidateFunnelSteps(req.Steps); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Normalize step orders
req.Steps = traceFunnels.NormalizeFunnelSteps(req.Steps)
// Update the funnel with new steps
funnel.Steps = req.Steps
funnel.UpdatedAt = req.Timestamp * 1000000
funnel.UpdatedBy = userID
// Update funnel in database
err = aH.TraceFunnels.UpdateFunnel(funnel, userID)
if err != nil {
http.Error(w, fmt.Sprintf("failed to update funnel in database: %v", err), http.StatusInternalServerError)
return
}
// ToDo: (shivanshu26) should be a struct
response := map[string]interface{}{
"id": funnel.ID,
"funnel_name": funnel.Name,
"creation_timestamp": funnel.CreatedAt / 1000000,
"user_id": funnel.CreatedBy,
"org_id": funnel.OrgID,
"updated_timestamp": req.Timestamp,
"updated_by": userID,
"steps": funnel.Steps,
}
json.NewEncoder(w).Encode(response)
}
func (aH *APIHandler) handleListFunnels(w http.ResponseWriter, r *http.Request) {
orgID := r.URL.Query().Get("org_id")
var dbFunnels []*traceFunnels.Funnel
var err error
dbClient, _ := traceFunnels.NewSQLClient(aH.Signoz.SQLStore)
if orgID != "" {
dbFunnels, err = dbClient.ListFunnelsFromDB(orgID)
} else {
dbFunnels, err = dbClient.ListAllFunnelsFromDB()
}
if err != nil {
http.Error(w, fmt.Sprintf("error fetching funnels from database: %v", err), http.StatusInternalServerError)
return
}
// Convert to response format with additional metadata
response := make([]map[string]interface{}, 0, len(dbFunnels))
for _, f := range dbFunnels {
// ToDo: (shivanshu26) should be a struct for all request and responses
funnelInfo := map[string]interface{}{
"id": f.ID,
"funnel_name": f.Name,
"creation_timestamp": f.CreatedAt / 1000000,
"user_id": f.CreatedBy,
"org_id": f.OrgID,
}
if f.UpdatedAt > 0 {
funnelInfo["updated_timestamp"] = f.UpdatedAt / 1000000
}
if f.UpdatedBy != "" {
funnelInfo["updated_by"] = f.UpdatedBy
}
// Get funnel extra data and tags
extraData, tags, err := aH.TraceFunnels.GetFunnelExtraDataAndTags(f.ID)
if err == nil && tags != "" {
funnelInfo["tags"] = tags
}
if err == nil && extraData != "" {
var extraDataMap map[string]interface{}
if err := json.Unmarshal([]byte(extraData), &extraDataMap); err == nil {
if description, ok := extraDataMap["description"].(string); ok {
funnelInfo["description"] = description
}
}
}
response = append(response, funnelInfo)
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}
func (aH *APIHandler) handleGetFunnel(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
dbClient, _ := traceFunnels.NewSQLClient(aH.Signoz.SQLStore)
funnel, err := dbClient.GetFunnelFromDB(funnelID)
if err != nil {
http.Error(w, fmt.Sprintf("funnel not found: %v", err), http.StatusNotFound)
return
}
// Get funnel extra data and tags
extraData, tags, err := aH.TraceFunnels.GetFunnelExtraData(funnelID)
if err != nil {
// Log error but continue with response since we already have the main funnel data
zap.L().Error("Failed to get funnel extra data", zap.Error(err))
}
// ToDo: (shivanshu26) should be a struct
response := map[string]interface{}{
"id": funnel.ID,
"funnel_name": funnel.Name,
"creation_timestamp": funnel.CreatedAt / 1000000,
"user_id": funnel.CreatedBy,
"org_id": funnel.OrgID,
"steps": funnel.Steps,
}
if funnel.UpdatedAt > 0 {
response["updated_timestamp"] = funnel.UpdatedAt / 1000000
}
if funnel.UpdatedBy != "" {
response["updated_by"] = funnel.UpdatedBy
}
if err == nil && tags != "" {
response["tags"] = tags
}
if err == nil && extraData != "" {
var extraDataMap map[string]interface{}
if err := json.Unmarshal([]byte(extraData), &extraDataMap); err == nil {
if description, ok := extraDataMap["description"].(string); ok {
response["description"] = description
}
}
}
json.NewEncoder(w).Encode(response)
}
func (aH *APIHandler) handleDeleteFunnel(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
dbClient, _ := traceFunnels.NewSQLClient(aH.Signoz.SQLStore)
err := dbClient.DeleteFunnelFromDB(funnelID)
if err != nil {
http.Error(w, fmt.Sprintf("failed to delete funnel: %v", err), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
// handleSaveFunnel saves a funnel to the SQLite database
// Only requires funnel_id and optional description
func (aH *APIHandler) handleSaveFunnel(w http.ResponseWriter, r *http.Request) {
var req traceFunnels.SaveFunnelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
dbClient, _ := traceFunnels.NewSQLClient(aH.Signoz.SQLStore)
funnel, err := dbClient.GetFunnelFromDB(req.FunnelID)
if err != nil {
http.Error(w, fmt.Sprintf("funnel not found: %v", err), http.StatusNotFound)
return
}
updateTimestamp := req.Timestamp
if updateTimestamp == 0 {
updateTimestamp = time.Now().UnixMilli()
} else {
if !traceFunnels.ValidateTimestampIsMilliseconds(updateTimestamp) {
http.Error(w, "timestamp must be in milliseconds format (13 digits)", http.StatusBadRequest)
return
}
}
funnel.UpdatedAt = updateTimestamp * 1000000 // Convert ms to ns
if req.UserID != "" {
funnel.UpdatedBy = req.UserID
}
extraData := ""
if req.Description != "" {
descriptionJSON, err := json.Marshal(map[string]string{"description": req.Description})
if err != nil {
http.Error(w, "failed to marshal description: "+err.Error(), http.StatusInternalServerError)
return
}
extraData = string(descriptionJSON)
}
orgID := req.OrgID
if orgID == "" {
orgID = funnel.OrgID
}
if err := dbClient.SaveFunnel(funnel, funnel.UpdatedBy, orgID, req.Tags, extraData); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Get funnel metadata from database
createdAt, updatedAt, tags, extraDataFromDB, err := aH.TraceFunnels.GetFunnelMetadata(funnel.ID)
if err != nil {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "success",
"id": funnel.ID,
"name": funnel.Name,
})
return
}
response := map[string]string{
"status": "success",
"id": funnel.ID,
"name": funnel.Name,
"created_at": createdAt,
"updated_at": updatedAt,
"created_by": funnel.CreatedBy,
"updated_by": funnel.UpdatedBy,
"org_id": funnel.OrgID,
}
if tags != "" {
response["tags"] = tags
}
if extraDataFromDB != "" {
var extraDataMap map[string]interface{}
if err := json.Unmarshal([]byte(extraDataFromDB), &extraDataMap); err == nil {
if description, ok := extraDataMap["description"].(string); ok {
response["description"] = description
}
}
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}
func (aH *APIHandler) handleValidateTraces(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
dbClient, _ := traceFunnels.NewSQLClient(aH.Signoz.SQLStore)
funnel, err := dbClient.GetFunnelFromDB(funnelID)
if err != nil {
http.Error(w, fmt.Sprintf("funnel not found: %v", err), http.StatusNotFound)
return
}
var timeRange traceFunnels.TimeRange
if err := json.NewDecoder(r.Body).Decode(&timeRange); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(funnel.Steps) < 2 {
http.Error(w, "funnel must have at least 2 steps", http.StatusBadRequest)
return
}
chq, err := traceFunnels.ValidateTraces(funnel, timeRange)
if err != nil {
zap.L().Error(err.Error())
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 {
zap.L().Error(err.Error())
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
return
}
aH.Respond(w, results)
}
// Analytics handlers
func (aH *APIHandler) handleFunnelAnalytics(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
dbClient, _ := traceFunnels.NewSQLClient(aH.Signoz.SQLStore)
funnel, err := dbClient.GetFunnelFromDB(funnelID)
if err != nil {
http.Error(w, fmt.Sprintf("funnel not found: %v", err), http.StatusNotFound)
return
}
var timeRange traceFunnels.TimeRange
if err := json.NewDecoder(r.Body).Decode(&timeRange); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
chq, err := traceFunnels.ValidateTracesWithLatency(funnel, timeRange)
if err != nil {
zap.L().Error(err.Error())
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 {
zap.L().Error(err.Error())
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"]
// Get funnel directly from SQLite database
dbClient, _ := traceFunnels.NewSQLClient(aH.Signoz.SQLStore)
funnel, err := dbClient.GetFunnelFromDB(funnelID)
if err != nil {
http.Error(w, fmt.Sprintf("funnel not found: %v", err), http.StatusNotFound)
return
}
var timeRange traceFunnels.TimeRange
if err := json.NewDecoder(r.Body).Decode(&timeRange); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
chq, err := traceFunnels.GetStepAnalytics(funnel, timeRange)
if err != nil {
zap.L().Error(err.Error())
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 {
zap.L().Error(err.Error())
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) handleSlowTraces(w http.ResponseWriter, r *http.Request, withErrors bool) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
dbClient, _ := traceFunnels.NewSQLClient(aH.Signoz.SQLStore)
funnel, err := dbClient.GetFunnelFromDB(funnelID)
if err != nil {
http.Error(w, fmt.Sprintf("funnel not found: %v", err), http.StatusNotFound)
return
}
var req traceFunnels.StepTransitionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
stepAExists, stepBExists := false, false
for _, step := range funnel.Steps {
if step.StepOrder == req.StepAOrder {
stepAExists = true
}
if step.StepOrder == req.StepBOrder {
stepBExists = true
}
}
if !stepAExists || !stepBExists {
http.Error(w, fmt.Sprintf("One or both steps not found. Step A Order: %d, Step B Order: %d", req.StepAOrder, req.StepBOrder), http.StatusBadRequest)
return
}
chq, err := traceFunnels.GetSlowestTraces(funnel, req.StepAOrder, req.StepBOrder, req.TimeRange, withErrors)
if err != nil {
zap.L().Error(err.Error())
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)
aH.Respond(w, results)
}

View File

@@ -0,0 +1,57 @@
package traceFunnels
import (
"fmt"
"sort"
)
// ValidateFunnelSteps validates funnel steps and ensures they have unique and correct order
// Rules: At least 2 steps, max 3 steps, orders must be unique and include 1 and 2
func ValidateFunnelSteps(steps []FunnelStep) error {
if len(steps) < 2 {
return fmt.Errorf("at least 2 funnel steps are required")
}
if len(steps) > 3 {
return fmt.Errorf("maximum 3 funnel steps are allowed")
}
orderMap := make(map[int64]bool)
for _, step := range steps {
if orderMap[step.StepOrder] {
return fmt.Errorf("duplicate step order: %d", step.StepOrder)
}
orderMap[step.StepOrder] = true
if step.StepOrder < 1 || step.StepOrder > 3 {
return fmt.Errorf("step order must be between 1 and 3, got: %d", step.StepOrder)
}
}
if !orderMap[1] || !orderMap[2] {
return fmt.Errorf("funnel steps with orders 1 and 2 are mandatory")
}
return nil
}
// NormalizeFunnelSteps ensures steps have sequential orders starting from 1
// This sorts steps by order and then reassigns orders to be sequential
func NormalizeFunnelSteps(steps []FunnelStep) []FunnelStep {
// Create a copy of the input slice
sortedSteps := make([]FunnelStep, len(steps))
copy(sortedSteps, steps)
// Sort using Go's built-in sort.Slice function
sort.Slice(sortedSteps, func(i, j int) bool {
return sortedSteps[i].StepOrder < sortedSteps[j].StepOrder
})
// Normalize orders to be sequential starting from 1
for i := 0; i < len(sortedSteps); i++ {
sortedSteps[i].StepOrder = int64(i + 1)
}
return sortedSteps
}

View File

@@ -0,0 +1,81 @@
package traceFunnels
import v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
type Funnel struct {
ID string `json:"id"`
Name string `json:"funnel_name"`
CreatedAt int64 `json:"creation_timestamp"`
CreatedBy string `json:"user_id"`
OrgID string `json:"org_id"`
UpdatedAt int64 `json:"updated_timestamp,omitempty"`
UpdatedBy string `json:"updated_by,omitempty"`
Steps []FunnelStep `json:"steps"`
}
// FunnelStep Models
type FunnelStep struct {
StepOrder int64 `json:"step_order"` // Order of the step in the funnel (1-based)
ServiceName string `json:"service_name"` // Service name for the span
SpanName string `json:"span_name"` // Span name to match
Filters *v3.FilterSet `json:"filters"` // Additional SQL filters
LatencyPointer string `json:"latency_pointer"` // "start" or "end"
LatencyType string `json:"latency_type"` // "p99", "p95", "p90"
HasErrors bool `json:"has_errors"` // Whether to include error spans
}
// NewFunnelRequest Request/Response structures
// NewFunnelRequest is used to create a new funnel without steps
// Steps should be added separately using the update endpoint
type NewFunnelRequest struct {
Name string `json:"funnel_name"`
Timestamp int64 `json:"creation_timestamp"` // Unix milliseconds timestamp
}
type NewFunnelResponse struct {
ID string `json:"funnel_id"`
Name string `json:"funnel_name"`
CreatedAt int64 `json:"creation_timestamp"`
CreatedBy string `json:"user_id"`
OrgID string `json:"org_id"`
}
type FunnelListResponse struct {
ID string `json:"id"`
Name string `json:"funnel_name"`
CreatedAt int64 `json:"creation_timestamp"` // Unix nano timestamp
CreatedBy string `json:"user_id"`
OrgID string `json:"org_id,omitempty"`
}
// FunnelStepRequest is used to add or update steps for an existing funnel
type FunnelStepRequest struct {
FunnelID string `json:"funnel_id"`
Steps []FunnelStep `json:"steps"`
Timestamp int64 `json:"updated_timestamp"` // Unix milliseconds timestamp for update time
}
// TimeRange Analytics request/response types
type TimeRange struct {
StartTime int64 `json:"start_time"` // Unix nano
EndTime int64 `json:"end_time"` // Unix nano
}
type StepTransitionRequest struct {
TimeRange
StepAOrder int64 `json:"step_a_order"` // First step in transition
StepBOrder int64 `json:"step_b_order"` // Second step in transition
}
type ValidTracesResponse struct {
TraceIDs []string `json:"trace_ids"`
}
type FunnelAnalytics struct {
TotalStart int64 `json:"total_start"`
TotalComplete int64 `json:"total_complete"`
ErrorCount int64 `json:"error_count"`
AvgDurationMs float64 `json:"avg_duration_ms"`
P99LatencyMs float64 `json:"p99_latency_ms"`
ConversionRate float64 `json:"conversion_rate"`
}

View File

@@ -0,0 +1,474 @@
package traceFunnels
import (
"fmt"
"strings"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
)
// TracesTable is the ClickHouse table name for traces
const TracesTable = "signoz_traces.signoz_index_v3"
// StepAnalytics represents analytics data for a single step in a funnel
type StepAnalytics struct {
StepOrder int64 `json:"stepOrder"`
TotalSpans int64 `json:"totalSpans"`
ErroredSpans int64 `json:"erroredSpans"`
AvgDurationMs string `json:"avgDurationMs"`
}
// FunnelStepFilter represents filters for a single step in the funnel
type FunnelStepFilter struct {
StepNumber int
ServiceName string
SpanName string
LatencyPointer string // "start" or "end"
CustomFilters *v3.FilterSet
}
// SlowTrace represents a trace with its duration and span count
type SlowTrace struct {
TraceID string `json:"traceId"`
DurationMs string `json:"durationMs"`
SpanCount int64 `json:"spanCount"`
}
// ValidateTraces parses the Funnel and builds a query to validate traces
func ValidateTraces(funnel *Funnel, timeRange TimeRange) (*v3.ClickHouseQuery, error) {
filters, err := buildFunnelFilters(funnel)
if err != nil {
return nil, fmt.Errorf("error building funnel filters: %w", err)
}
query := generateFunnelSQL(timeRange.StartTime, timeRange.EndTime, filters)
return &v3.ClickHouseQuery{
Query: query,
}, nil
}
// ValidateTracesWithLatency builds a query that considers the latency pointer for trace calculations
func ValidateTracesWithLatency(funnel *Funnel, timeRange TimeRange) (*v3.ClickHouseQuery, error) {
filters, err := buildFunnelFiltersWithLatency(funnel)
if err != nil {
return nil, fmt.Errorf("error building funnel filters with latency: %w", err)
}
query := generateFunnelSQLWithLatency(timeRange.StartTime, timeRange.EndTime, filters)
return &v3.ClickHouseQuery{
Query: query,
}, nil
}
// buildFunnelFilters extracts filters from funnel steps (without latency pointer)
func buildFunnelFilters(funnel *Funnel) ([]FunnelStepFilter, error) {
if funnel == nil {
return nil, fmt.Errorf("funnel cannot be nil")
}
if len(funnel.Steps) == 0 {
return nil, fmt.Errorf("funnel must have at least one step")
}
filters := make([]FunnelStepFilter, len(funnel.Steps))
for i, step := range funnel.Steps {
filters[i] = FunnelStepFilter{
StepNumber: i + 1,
ServiceName: step.ServiceName,
SpanName: step.SpanName,
CustomFilters: step.Filters,
}
}
return filters, nil
}
// buildFunnelFiltersWithLatency extracts filters including the latency pointer
func buildFunnelFiltersWithLatency(funnel *Funnel) ([]FunnelStepFilter, error) {
if funnel == nil {
return nil, fmt.Errorf("funnel cannot be nil")
}
if len(funnel.Steps) == 0 {
return nil, fmt.Errorf("funnel must have at least one step")
}
filters := make([]FunnelStepFilter, len(funnel.Steps))
for i, step := range funnel.Steps {
latencyPointer := "start" // Default value
if step.LatencyPointer != "" {
latencyPointer = step.LatencyPointer
}
filters[i] = FunnelStepFilter{
StepNumber: i + 1,
ServiceName: step.ServiceName,
SpanName: step.SpanName,
LatencyPointer: latencyPointer,
CustomFilters: step.Filters,
}
}
return filters, nil
}
// escapeString escapes a string for safe use in SQL queries
func escapeString(s string) string {
// Replace single quotes with double single quotes to escape them in SQL
return strings.ReplaceAll(s, "'", "''")
}
// generateFunnelSQL builds the ClickHouse SQL query for funnel validation
func generateFunnelSQL(start, end int64, filters []FunnelStepFilter) string {
var expressions []string
// Basic time expressions.
expressions = append(expressions, fmt.Sprintf("toUInt64(%d) AS start_time", start))
expressions = append(expressions, fmt.Sprintf("toUInt64(%d) AS end_time", end))
expressions = append(expressions, "toString(intDiv(start_time, 1000000000) - 1800) AS tsBucketStart")
expressions = append(expressions, "toString(intDiv(end_time, 1000000000)) AS tsBucketEnd")
// Add service and span alias definitions from each filter.
for _, f := range filters {
expressions = append(expressions, fmt.Sprintf("'%s' AS service_%d", escapeString(f.ServiceName), f.StepNumber))
expressions = append(expressions, fmt.Sprintf("'%s' AS span_%d", escapeString(f.SpanName), f.StepNumber))
}
// Add the CTE for each step.
for _, f := range filters {
cte := fmt.Sprintf(`step%d_traces AS (
SELECT DISTINCT trace_id
FROM %s
WHERE serviceName = service_%d
AND name = span_%d
AND timestamp BETWEEN toString(start_time) AND toString(end_time)
AND ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
)`, f.StepNumber, TracesTable, f.StepNumber, f.StepNumber)
expressions = append(expressions, cte)
}
// Join all expressions with commas and newlines
withClause := "WITH \n" + strings.Join(expressions, ",\n") + "\n"
// Build the intersect clause for each step.
var intersectQueries []string
for _, f := range filters {
intersectQueries = append(intersectQueries, fmt.Sprintf("SELECT trace_id FROM step%d_traces", f.StepNumber))
}
intersectClause := strings.Join(intersectQueries, "\nINTERSECT\n")
query := withClause + `
SELECT trace_id
FROM ` + TracesTable + `
WHERE trace_id IN (
` + intersectClause + `
)
AND timestamp BETWEEN toString(start_time) AND toString(end_time)
AND ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
GROUP BY trace_id
LIMIT 5
`
return query
}
// generateFunnelSQLWithLatency correctly applies latency pointer logic for trace duration
func generateFunnelSQLWithLatency(start, end int64, filters []FunnelStepFilter) string {
var expressions []string
// Define the base time variables
expressions = append(expressions, fmt.Sprintf("toUInt64(%d) AS start_time", start))
expressions = append(expressions, fmt.Sprintf("toUInt64(%d) AS end_time", end))
expressions = append(expressions, "toString(intDiv(start_time, 1000000000) - 1800) AS tsBucketStart")
expressions = append(expressions, "toString(intDiv(end_time, 1000000000)) AS tsBucketEnd")
expressions = append(expressions, "(end_time - start_time) / 1e9 AS total_time_seconds")
// Define service, span, and latency pointer mappings
for _, f := range filters {
expressions = append(expressions, fmt.Sprintf("('%s', '%s', '%s') AS s%d_config",
escapeString(f.ServiceName),
escapeString(f.SpanName),
escapeString(f.LatencyPointer),
f.StepNumber))
}
// Construct the WITH clause
withClause := "WITH \n" + strings.Join(expressions, ",\n") + "\n"
// Latency calculation logic
var latencyCases []string
for _, f := range filters {
if f.LatencyPointer == "end" {
latencyCases = append(latencyCases, fmt.Sprintf(`
WHEN (resource_string_service$$name, name) = (s%d_config.1, s%d_config.2)
THEN toUnixTimestamp64Nano(timestamp) + duration_nano`, f.StepNumber, f.StepNumber))
} else {
latencyCases = append(latencyCases, fmt.Sprintf(`
WHEN (resource_string_service$$name, name) = (s%d_config.1, s%d_config.2)
THEN toUnixTimestamp64Nano(timestamp)`, f.StepNumber, f.StepNumber))
}
}
latencyComputation := fmt.Sprintf(`
MAX(
CASE %s
ELSE toUnixTimestamp64Nano(timestamp)
END
) -
MIN(
CASE %s
ELSE toUnixTimestamp64Nano(timestamp)
END
) AS trace_duration`, strings.Join(latencyCases, ""), strings.Join(latencyCases, ""))
query := withClause + `
SELECT
COUNT(DISTINCT CASE WHEN in_funnel_s1 = 1 THEN trace_id END) AS total_s1,
COUNT(DISTINCT CASE WHEN in_funnel_s3 = 1 THEN trace_id END) AS total_s3,
COUNT(DISTINCT CASE WHEN in_funnel_s3 = 1 THEN trace_id END) / total_time_seconds AS avg_rate,
COUNT(DISTINCT CASE WHEN in_funnel_s3 = 1 AND has_error = true THEN trace_id END) AS errors,
avg(trace_duration) AS avg_duration,
quantile(0.99)(trace_duration) AS p99_latency,
100 - (
(COUNT(DISTINCT CASE WHEN in_funnel_s1 = 1 THEN trace_id END) -
COUNT(DISTINCT CASE WHEN in_funnel_s3 = 1 THEN trace_id END))
/ NULLIF(COUNT(DISTINCT CASE WHEN in_funnel_s1 = 1 THEN trace_id END), 0) * 100
) AS conversion_rate
FROM (
SELECT
trace_id,
` + latencyComputation + `,
MAX(has_error) AS has_error,
MAX(CASE WHEN (resource_string_service$$name, name) = (s1_config.1, s1_config.2) THEN 1 ELSE 0 END) AS in_funnel_s1,
MAX(CASE WHEN (resource_string_service$$name, name) = (s3_config.1, s3_config.2) THEN 1 ELSE 0 END) AS in_funnel_s3
FROM ` + TracesTable + `
WHERE timestamp BETWEEN toString(start_time) AND toString(end_time)
AND ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
AND (resource_string_service$$name, name) IN (` + generateFilterConditions(filters) + `)
GROUP BY trace_id
) AS trace_metrics;
`
return query
}
// generateFilterConditions creates the filtering conditions dynamically
func generateFilterConditions(filters []FunnelStepFilter) string {
var conditions []string
for _, f := range filters {
conditions = append(conditions, fmt.Sprintf("(s%d_config.1, s%d_config.2)", f.StepNumber, f.StepNumber))
}
return strings.Join(conditions, ", ")
}
// GetStepAnalytics builds a query to get analytics for each step in a funnel
func GetStepAnalytics(funnel *Funnel, timeRange TimeRange) (*v3.ClickHouseQuery, error) {
if len(funnel.Steps) == 0 {
return nil, fmt.Errorf("funnel has no steps")
}
// Build funnel steps array
var steps []string
for _, step := range funnel.Steps {
steps = append(steps, fmt.Sprintf("('%s', '%s')",
escapeString(step.ServiceName), escapeString(step.SpanName)))
}
stepsArray := fmt.Sprintf("array(%s)", strings.Join(steps, ","))
// Build step CTEs
var stepCTEs []string
for i, step := range funnel.Steps {
filterStr := ""
if step.Filters != nil && len(step.Filters.Items) > 0 {
// This is a placeholder - in a real implementation, you would convert
// the filter set to a SQL WHERE clause string
filterStr = "/* Custom filters would be applied here */"
}
cte := fmt.Sprintf(`
step%d_traces AS (
SELECT DISTINCT trace_id
FROM %s
WHERE resource_string_service$$name = '%s'
AND name = '%s'
AND timestamp BETWEEN toString(start_time) AND toString(end_time)
AND ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
%s
)`,
i+1,
TracesTable,
escapeString(step.ServiceName),
escapeString(step.SpanName),
filterStr,
)
stepCTEs = append(stepCTEs, cte)
}
// Build intersecting traces CTE
var intersections []string
for i := 1; i <= len(funnel.Steps); i++ {
intersections = append(intersections, fmt.Sprintf("SELECT trace_id FROM step%d_traces", i))
}
intersectingTracesCTE := fmt.Sprintf(`
intersecting_traces AS (
%s
)`,
strings.Join(intersections, "\nINTERSECT\n"),
)
// Build CASE expressions for each step
var caseExpressions []string
for i, step := range funnel.Steps {
totalSpansExpr := fmt.Sprintf(`
COUNT(CASE WHEN resource_string_service$$name = '%s'
AND name = '%s'
THEN trace_id END) AS total_s%d_spans`,
escapeString(step.ServiceName), escapeString(step.SpanName), i+1)
erroredSpansExpr := fmt.Sprintf(`
COUNT(CASE WHEN resource_string_service$$name = '%s'
AND name = '%s'
AND has_error = true
THEN trace_id END) AS total_s%d_errored_spans`,
escapeString(step.ServiceName), escapeString(step.SpanName), i+1)
caseExpressions = append(caseExpressions, totalSpansExpr, erroredSpansExpr)
}
query := fmt.Sprintf(`
WITH
toUInt64(%d) AS start_time,
toUInt64(%d) AS end_time,
toString(intDiv(start_time, 1000000000) - 1800) AS tsBucketStart,
toString(intDiv(end_time, 1000000000)) AS tsBucketEnd,
%s AS funnel_steps,
%s,
%s
SELECT
%s
FROM %s
WHERE trace_id IN (SELECT trace_id FROM intersecting_traces)
AND timestamp BETWEEN toString(start_time) AND toString(end_time)
AND ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd`,
timeRange.StartTime,
timeRange.EndTime,
stepsArray,
strings.Join(stepCTEs, ",\n"),
intersectingTracesCTE,
strings.Join(caseExpressions, ",\n "),
TracesTable,
)
return &v3.ClickHouseQuery{
Query: query,
}, nil
}
// buildFilters converts a step's filters to a SQL WHERE clause string
func buildFilters(step FunnelStep) string {
if step.Filters == nil || len(step.Filters.Items) == 0 {
return ""
}
// This is a placeholder - in a real implementation, you would convert
// the filter set to a SQL WHERE clause string
return "/* Custom filters would be applied here */"
}
// GetSlowestTraces builds a query to get the slowest traces for a transition between two steps
func GetSlowestTraces(funnel *Funnel, stepAOrder int64, stepBOrder int64, timeRange TimeRange, withErrors bool) (*v3.ClickHouseQuery, error) {
// Find steps by order
var stepA, stepB *FunnelStep
for i := range funnel.Steps {
if funnel.Steps[i].StepOrder == stepAOrder {
stepA = &funnel.Steps[i]
}
if funnel.Steps[i].StepOrder == stepBOrder {
stepB = &funnel.Steps[i]
}
}
if stepA == nil || stepB == nil {
return nil, fmt.Errorf("step not found")
}
// Build having clause based on withErrors flag
havingClause := ""
if withErrors {
havingClause = "HAVING has_error = 1"
}
// Build filter strings for each step
stepAFilters := ""
if stepA.Filters != nil && len(stepA.Filters.Items) > 0 {
// This is a placeholder - in a real implementation, you would convert
// the filter set to a SQL WHERE clause string
stepAFilters = "/* Custom filters for step A would be applied here */"
}
stepBFilters := ""
if stepB.Filters != nil && len(stepB.Filters.Items) > 0 {
// This is a placeholder - in a real implementation, you would convert
// the filter set to a SQL WHERE clause string
stepBFilters = "/* Custom filters for step B would be applied here */"
}
query := fmt.Sprintf(`
WITH
toUInt64(%d) AS start_time,
toUInt64(%d) AS end_time,
toString(intDiv(start_time, 1000000000) - 1800) AS tsBucketStart,
toString(intDiv(end_time, 1000000000)) AS tsBucketEnd
SELECT
trace_id,
concat(toString((max_end_time_ns - min_start_time_ns) / 1e6), ' ms') AS duration_ms,
COUNT(*) AS span_count
FROM (
SELECT
s1.trace_id,
MIN(toUnixTimestamp64Nano(s1.timestamp)) AS min_start_time_ns,
MAX(toUnixTimestamp64Nano(s2.timestamp) + s2.duration_nano) AS max_end_time_ns,
MAX(s1.has_error OR s2.has_error) AS has_error
FROM %s AS s1
JOIN %s AS s2
ON s1.trace_id = s2.trace_id
WHERE s1.resource_string_service$$name = '%s'
AND s1.name = '%s'
AND s2.resource_string_service$$name = '%s'
AND s2.name = '%s'
AND s1.timestamp BETWEEN toString(start_time) AND toString(end_time)
AND s1.ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
AND s2.timestamp BETWEEN toString(start_time) AND toString(end_time)
AND s2.ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
%s
%s
GROUP BY s1.trace_id
%s
) AS trace_durations
JOIN %s AS spans
ON spans.trace_id = trace_durations.trace_id
WHERE spans.timestamp BETWEEN toString(start_time) AND toString(end_time)
AND spans.ts_bucket_start BETWEEN tsBucketStart AND tsBucketEnd
GROUP BY trace_id, duration_ms
ORDER BY CAST(replaceRegexpAll(duration_ms, ' ms$', '') AS Float64) DESC
LIMIT 5`,
timeRange.StartTime,
timeRange.EndTime,
TracesTable,
TracesTable,
escapeString(stepA.ServiceName),
escapeString(stepA.SpanName),
escapeString(stepB.ServiceName),
escapeString(stepB.SpanName),
stepAFilters,
stepBFilters,
havingClause,
TracesTable,
)
return &v3.ClickHouseQuery{
Query: query,
}, nil
}

View File

@@ -0,0 +1,331 @@
package traceFunnels
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
)
// SQLClient handles persistence of funnels to the database
type SQLClient struct {
funnels sqlstore.SQLStore
}
// NewSQLClient creates a new SQL client
func NewSQLClient(store sqlstore.SQLStore) (*SQLClient, error) {
return &SQLClient{funnels: store}, nil
}
// SaveFunnelRequest is used to save a funnel to the database
type SaveFunnelRequest struct {
FunnelID string `json:"funnel_id"` // Required: ID of the funnel to save
UserID string `json:"user_id,omitempty"` // Optional: will use existing user ID if not provided
OrgID string `json:"org_id,omitempty"` // Optional: will use existing org ID if not provided
Tags string `json:"tags,omitempty"` // Optional: comma-separated tags
Description string `json:"description,omitempty"` // Optional: human-readable description
Timestamp int64 `json:"timestamp,omitempty"` // Optional: timestamp for update in milliseconds (uses current time if not provided)
}
// SaveFunnel saves a funnel to the database in the trace_funnels table
// Handles both creating new funnels and updating existing ones
func (c *SQLClient) SaveFunnel(funnel *Funnel, userID, orgID string, tags, extraData string) error {
ctx := context.Background()
db := c.funnels.BunDB()
// Convert funnel to JSON for storage
funnelData, err := json.Marshal(funnel)
if err != nil {
return fmt.Errorf("failed to marshal funnel data: %v", err)
}
// Format timestamps as RFC3339
// Convert nanoseconds to milliseconds for display, then to time.Time for formatting
createdAt := time.Unix(0, funnel.CreatedAt).UTC().Format(time.RFC3339)
updatedAt := createdAt
updatedBy := userID
// If funnel has update metadata, use it
if funnel.UpdatedAt > 0 {
updatedAt = time.Unix(0, funnel.UpdatedAt).UTC().Format(time.RFC3339)
}
if funnel.UpdatedBy != "" {
updatedBy = funnel.UpdatedBy
}
// Check if the funnel already exists
var count int
var existingCreatedBy string
var existingCreatedAt string
err = db.NewRaw("SELECT COUNT(*), IFNULL(\"created_by\", ''), IFNULL(\"created_at\", '') FROM trace_funnels WHERE \"uuid\" = ? AND \"category\" = 'funnel'", funnel.ID).
Scan(ctx, &count, &existingCreatedBy, &existingCreatedAt)
if err != nil {
return fmt.Errorf("failed to check if funnel exists: %v", err)
}
if count > 0 {
// Update existing funnel - preserve created_by and created_at
_, err = db.NewRaw(
"UPDATE trace_funnels SET \"name\" = ?, \"data\" = ?, \"updated_by\" = ?, \"updated_at\" = ?, \"tags\" = ?, \"extra_data\" = ? WHERE \"uuid\" = ? AND \"category\" = 'funnel'",
funnel.Name, string(funnelData), updatedBy, updatedAt, tags, extraData, funnel.ID,
).Exec(ctx)
if err != nil {
return fmt.Errorf("failed to update funnel: %v", err)
}
} else {
// Insert new funnel - set both created and updated fields
savedView := &types.TraceFunnels{
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
UserAuditable: types.UserAuditable{
CreatedBy: userID,
UpdatedBy: updatedBy,
},
UUID: funnel.ID,
Name: funnel.Name,
Category: "funnel",
SourcePage: "trace-funnels",
OrgID: orgID,
Tags: tags,
Data: string(funnelData),
ExtraData: extraData,
}
_, err = db.NewInsert().Model(savedView).Exec(ctx)
if err != nil {
return fmt.Errorf("failed to insert funnel: %v", err)
}
}
return nil
}
// ToDo: (shivanshu26) use this everywhere in http handler
// GetFunnelFromDB retrieves a funnel from the database
func (c *SQLClient) GetFunnelFromDB(funnelID string) (*Funnel, error) {
ctx := context.Background()
db := c.funnels.BunDB()
var savedView types.TraceFunnels
err := db.NewSelect().
Model(&savedView).
Where("\"uuid\" = ? AND \"category\" = 'funnel'", funnelID).
Scan(ctx)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("funnel not found")
}
return nil, fmt.Errorf("failed to get funnel: %v", err)
}
var funnel Funnel
if err := json.Unmarshal([]byte(savedView.Data), &funnel); err != nil {
return nil, fmt.Errorf("failed to unmarshal funnel data: %v", err)
}
return &funnel, nil
}
// ListFunnelsFromDB lists all funnels from the database
func (c *SQLClient) ListFunnelsFromDB(orgID string) ([]*Funnel, error) {
ctx := context.Background()
db := c.funnels.BunDB()
var savedViews []types.TraceFunnels
err := db.NewSelect().
Model(&savedViews).
Where("\"category\" = 'funnel' AND \"org_id\" = ?", orgID).
Scan(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list funnels: %v", err)
}
var funnels []*Funnel
for _, view := range savedViews {
var funnel Funnel
if err := json.Unmarshal([]byte(view.Data), &funnel); err != nil {
return nil, fmt.Errorf("failed to unmarshal funnel data: %v", err)
}
funnels = append(funnels, &funnel)
}
return funnels, nil
}
// ListAllFunnelsFromDB lists all funnels from the database without org_id filter
func (c *SQLClient) ListAllFunnelsFromDB() ([]*Funnel, error) {
ctx := context.Background()
db := c.funnels.BunDB()
var savedViews []types.TraceFunnels
err := db.NewSelect().
Model(&savedViews).
Where("\"category\" = 'funnel'").
Scan(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list all funnels: %v", err)
}
var funnels []*Funnel
for _, view := range savedViews {
var funnel Funnel
if err := json.Unmarshal([]byte(view.Data), &funnel); err != nil {
return nil, fmt.Errorf("failed to unmarshal funnel data: %v", err)
}
funnels = append(funnels, &funnel)
}
return funnels, nil
}
// DeleteFunnelFromDB deletes a funnel from the database
func (c *SQLClient) DeleteFunnelFromDB(funnelID string) error {
ctx := context.Background()
db := c.funnels.BunDB()
_, err := db.NewDelete().
Model(&types.TraceFunnels{}).
Where("\"uuid\" = ? AND \"category\" = 'funnel'", funnelID).
Exec(ctx)
if err != nil {
return fmt.Errorf("failed to delete funnel: %v", err)
}
return nil
}
// CheckFunnelNameCollision checks if a funnel with the given name exists for a user
func (c *SQLClient) CheckFunnelNameCollision(name, userID string) (bool, error) {
ctx := context.Background()
db := c.funnels.BunDB()
var count int
err := db.NewRaw("SELECT COUNT(*) FROM trace_funnels WHERE \"name\" = ? AND \"created_by\" = ? AND \"category\" = 'funnel'", name, userID).
Scan(ctx, &count)
if err != nil {
return false, fmt.Errorf("failed to check funnel name collision: %v", err)
}
return count > 0, nil
}
// CreateFunnel creates a new funnel in the database
func (c *SQLClient) CreateFunnel(funnel *Funnel, userID, orgID string) error {
ctx := context.Background()
db := c.funnels.BunDB()
// Convert funnel to JSON for storage
funnelData, err := json.Marshal(funnel)
if err != nil {
return fmt.Errorf("failed to marshal funnel data: %v", err)
}
// Format timestamps as RFC3339
createdAt := time.Unix(0, funnel.CreatedAt).UTC().Format(time.RFC3339)
// Insert new funnel
_, err = db.NewRaw(
"INSERT INTO trace_funnels (\"uuid\", \"name\", \"category\", \"created_by\", \"updated_by\", \"source_page\", \"data\", \"created_at\", \"updated_at\", \"org_id\") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
funnel.ID, funnel.Name, "funnel", userID, userID, "trace-funnels", string(funnelData), createdAt, createdAt, orgID,
).Exec(ctx)
if err != nil {
return fmt.Errorf("failed to save funnel to database: %v", err)
}
return nil
}
// UpdateFunnel updates an existing funnel in the database
func (c *SQLClient) UpdateFunnel(funnel *Funnel, userID string) error {
ctx := context.Background()
db := c.funnels.BunDB()
// Convert funnel to JSON for storage
funnelData, err := json.Marshal(funnel)
if err != nil {
return fmt.Errorf("failed to marshal funnel data: %v", err)
}
// Format timestamp as RFC3339
updatedAt := time.Unix(0, funnel.UpdatedAt).UTC().Format(time.RFC3339)
// Update funnel in database
_, err = db.NewRaw(
"UPDATE trace_funnels SET \"data\" = ?, \"updated_by\" = ?, \"updated_at\" = ? WHERE \"uuid\" = ? AND \"category\" = 'funnel'",
string(funnelData), userID, updatedAt, funnel.ID,
).Exec(ctx)
if err != nil {
return fmt.Errorf("failed to update funnel in database: %v", err)
}
return nil
}
// GetFunnelMetadata retrieves metadata (created_at, updated_at, tags, extra_data) for a funnel
func (c *SQLClient) GetFunnelMetadata(funnelID string) (string, string, string, string, error) {
ctx := context.Background()
db := c.funnels.BunDB()
var createdAt, updatedAt, tags, extraData string
err := db.NewRaw(
"SELECT \"created_at\", \"updated_at\", IFNULL(\"tags\", ''), IFNULL(\"extra_data\", '') FROM trace_funnels WHERE \"uuid\" = ? AND \"category\" = 'funnel'",
funnelID,
).Scan(ctx, &createdAt, &updatedAt, &tags, &extraData)
if err != nil {
return "", "", "", "", fmt.Errorf("failed to get funnel metadata: %v", err)
}
return createdAt, updatedAt, tags, extraData, nil
}
// GetFunnelExtraData retrieves extra_data and tags for a funnel
func (c *SQLClient) GetFunnelExtraData(funnelID string) (string, string, error) {
ctx := context.Background()
db := c.funnels.BunDB()
var extraData, tags string
err := db.NewRaw(
"SELECT IFNULL(\"extra_data\", ''), IFNULL(\"tags\", '') FROM trace_funnels WHERE \"uuid\" = ? AND \"category\" = 'funnel'",
funnelID,
).Scan(ctx, &extraData, &tags)
if err != nil {
return "", "", fmt.Errorf("failed to get funnel extra data: %v", err)
}
return extraData, tags, nil
}
// GetFunnelExtraDataAndTags retrieves extra_data and tags for a funnel
func (c *SQLClient) GetFunnelExtraDataAndTags(funnelID string) (string, string, error) {
ctx := context.Background()
db := c.funnels.BunDB()
var extraData, tags string
err := db.NewRaw(
"SELECT IFNULL(\"extra_data\", ''), IFNULL(\"tags\", '') FROM trace_funnels WHERE \"uuid\" = ? AND \"category\" = 'funnel'",
funnelID,
).Scan(ctx, &extraData, &tags)
if err != nil {
return "", "", fmt.Errorf("failed to get funnel extra data and tags: %v", err)
}
return extraData, tags, nil
}

View File

@@ -0,0 +1,32 @@
package traceFunnels
import (
"fmt"
"strconv"
)
// ValidateTimestampIsMilliseconds checks if a timestamp is likely in milliseconds format
func ValidateTimestampIsMilliseconds(timestamp int64) bool {
// If timestamp is 0, it's not valid
if timestamp == 0 {
return false
}
timestampStr := strconv.FormatInt(timestamp, 10)
return len(timestampStr) >= 12 && len(timestampStr) <= 14
}
// ValidateTimestamp checks if a timestamp is provided and in milliseconds format
// Returns an error if validation fails
func ValidateTimestamp(timestamp int64, fieldName string) error {
if timestamp == 0 {
return fmt.Errorf("%s is required", fieldName)
}
if !ValidateTimestampIsMilliseconds(timestamp) {
return fmt.Errorf("%s must be in milliseconds format (13 digits)", fieldName)
}
return nil
}

View File

@@ -332,6 +332,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
api.RegisterMessagingQueuesRoutes(r, am)
api.RegisterThirdPartyApiRoutes(r, am)
api.MetricExplorerRoutes(r, am)
api.RegisterTraceFunnelsRoutes(r, am)
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},

View File

@@ -55,6 +55,7 @@ func NewSQLMigrationProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedM
sqlmigration.NewAddPatsFactory(),
sqlmigration.NewModifyDatetimeFactory(),
sqlmigration.NewModifyOrgDomainFactory(),
sqlmigration.NewAddTraceFunnelsFactory(),
sqlmigration.NewUpdateOrganizationFactory(sqlstore),
sqlmigration.NewAddAlertmanagerFactory(sqlstore),
sqlmigration.NewUpdateDashboardAndSavedViewsFactory(sqlstore),

View File

@@ -0,0 +1,58 @@
package sqlmigration
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addTraceFunnels struct{}
func NewAddTraceFunnelsFactory() factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_trace_funnels"), newAddTraceFunnels)
}
func newAddTraceFunnels(_ context.Context, _ factory.ProviderSettings, _ Config) (SQLMigration, error) {
return &addTraceFunnels{}, 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 {
// table:trace_funnels op:create
if _, err := db.NewCreateTable().
Model(&struct {
bun.BaseModel `bun:"table:trace_funnels"`
UUID string `bun:"uuid,pk,type:text"`
OrgID string `json:"orgId" bun:"org_id,notnull"`
Name string `bun:"name,type:text,notnull"`
Category string `bun:"category,type:text,notnull"`
CreatedAt time.Time `bun:"created_at,notnull"`
CreatedBy string `bun:"created_by,type:text"`
UpdatedAt time.Time `bun:"updated_at,notnull"`
UpdatedBy string `bun:"updated_by,type:text"`
SourcePage string `bun:"source_page,type:text,notnull"`
Tags string `bun:"tags,type:text"`
Data string `bun:"data,type:text,notnull"`
ExtraData string `bun:"extra_data,type:text"`
}{}).
IfNotExists().
Exec(ctx); err != nil {
return err
}
return nil
}
func (migration *addTraceFunnels) Down(ctx context.Context, db *bun.DB) error {
return nil
}

18
pkg/types/tracefunnels.go Normal file
View File

@@ -0,0 +1,18 @@
package types
import "github.com/uptrace/bun"
type TraceFunnels struct {
bun.BaseModel `bun:"table:trace_funnels"`
TimeAuditable
UserAuditable
OrgID string `json:"orgId" bun:"org_id,notnull"`
UUID string `json:"uuid" bun:"uuid,pk,type:text"`
Name string `json:"name" bun:"name,type:text,notnull"`
Category string `json:"category" bun:"category,type:text,notnull"`
SourcePage string `json:"sourcePage" bun:"source_page,type:text,notnull"`
Tags string `json:"tags" bun:"tags,type:text"`
Data string `json:"data" bun:"data,type:text,notnull"`
ExtraData string `json:"extraData" bun:"extra_data,type:text"`
}