mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-16 18:02:09 +00:00
Compare commits
57 Commits
debug_time
...
chore/tf/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ddf002d46 | ||
|
|
31cd192f24 | ||
|
|
3aacb99adc | ||
|
|
40ca9adbfc | ||
|
|
bfa7a06e90 | ||
|
|
189046865a | ||
|
|
d5ee6ca2c3 | ||
|
|
62fb05ac5a | ||
|
|
5a3ed26f01 | ||
|
|
8e490e4089 | ||
|
|
d3adc319ad | ||
|
|
6b58e859b5 | ||
|
|
3d3a1eaaf2 | ||
|
|
361640fd22 | ||
|
|
89de68f987 | ||
|
|
71b098776b | ||
|
|
6b3e2759a1 | ||
|
|
ea46639f59 | ||
|
|
69ed1b7d02 | ||
|
|
5b07705157 | ||
|
|
2fc8bb4585 | ||
|
|
11d75940e8 | ||
|
|
3caaa51e08 | ||
|
|
cad3bf6883 | ||
|
|
5b7ce41d0d | ||
|
|
ee5120d4ed | ||
|
|
c249620e8f | ||
|
|
1c1811b216 | ||
|
|
86d69f74f3 | ||
|
|
37b26a7116 | ||
|
|
26ad89ed70 | ||
|
|
94c7512a6a | ||
|
|
333ff86a6b | ||
|
|
a22d061ec1 | ||
|
|
22fdeb1381 | ||
|
|
77b330cfe9 | ||
|
|
78a5d7e39e | ||
|
|
ea1f4e8253 | ||
|
|
5e0d6110b5 | ||
|
|
14ce7f80e2 | ||
|
|
f8341e8958 | ||
|
|
7a7428d73e | ||
|
|
77cd490e48 | ||
|
|
9a96817a88 | ||
|
|
da9a2520a4 | ||
|
|
c03bf9905c | ||
|
|
6676832c71 | ||
|
|
03e50d3bc3 | ||
|
|
95bc3987bb | ||
|
|
eb797edc53 | ||
|
|
c2d36480a2 | ||
|
|
ab0d9918b2 | ||
|
|
c364a3e695 | ||
|
|
43337b6697 | ||
|
|
e258d70df5 | ||
|
|
19ee5860cb | ||
|
|
235ea39d73 |
@@ -298,6 +298,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
|||||||
apiHandler.RegisterMessagingQueuesRoutes(r, am)
|
apiHandler.RegisterMessagingQueuesRoutes(r, am)
|
||||||
apiHandler.RegisterThirdPartyApiRoutes(r, am)
|
apiHandler.RegisterThirdPartyApiRoutes(r, am)
|
||||||
apiHandler.MetricExplorerRoutes(r, am)
|
apiHandler.MetricExplorerRoutes(r, am)
|
||||||
|
apiHandler.RegisterTraceFunnelsRoutes(r, am)
|
||||||
|
|
||||||
c := cors.New(cors.Options{
|
c := cors.New(cors.Options{
|
||||||
AllowedOrigins: []string{"*"},
|
AllowedOrigins: []string{"*"},
|
||||||
|
|||||||
@@ -167,8 +167,8 @@ interface UpdateFunnelDescriptionPayload {
|
|||||||
export const saveFunnelDescription = async (
|
export const saveFunnelDescription = async (
|
||||||
payload: UpdateFunnelDescriptionPayload,
|
payload: UpdateFunnelDescriptionPayload,
|
||||||
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
|
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
|
||||||
const response: AxiosResponse = await axios.post(
|
const response: AxiosResponse = await axios.put(
|
||||||
`${FUNNELS_BASE_PATH}/save`,
|
`${FUNNELS_BASE_PATH}/${payload.funnel_id}`,
|
||||||
payload,
|
payload,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -149,30 +149,28 @@ function SpanOverview({
|
|||||||
<Typography.Text className="service-name">
|
<Typography.Text className="service-name">
|
||||||
{span.serviceName}
|
{span.serviceName}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
{!!span.serviceName &&
|
{!!span.serviceName && !!span.name && (
|
||||||
!!span.name &&
|
<div className="add-funnel-button">
|
||||||
process.env.NODE_ENV === 'development' && (
|
<span className="add-funnel-button__separator">·</span>
|
||||||
<div className="add-funnel-button">
|
<Button
|
||||||
<span className="add-funnel-button__separator">·</span>
|
type="text"
|
||||||
<Button
|
size="small"
|
||||||
type="text"
|
className="add-funnel-button__button"
|
||||||
size="small"
|
onClick={(e): void => {
|
||||||
className="add-funnel-button__button"
|
e.preventDefault();
|
||||||
onClick={(e): void => {
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
handleAddSpanToFunnel(span);
|
||||||
e.stopPropagation();
|
}}
|
||||||
handleAddSpanToFunnel(span);
|
icon={
|
||||||
}}
|
<img
|
||||||
icon={
|
className="add-funnel-button__icon"
|
||||||
<img
|
src="/Icons/funnel-add.svg"
|
||||||
className="add-funnel-button__icon"
|
alt="funnel-icon"
|
||||||
src="/Icons/funnel-add.svg"
|
/>
|
||||||
alt="funnel-icon"
|
}
|
||||||
/>
|
/>
|
||||||
}
|
</div>
|
||||||
/>
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -450,7 +448,7 @@ function Success(props: ISuccessProps): JSX.Element {
|
|||||||
virtualiserRef={virtualizerRef}
|
virtualiserRef={virtualizerRef}
|
||||||
setColumnWidths={setTraceFlamegraphStatsWidth}
|
setColumnWidths={setTraceFlamegraphStatsWidth}
|
||||||
/>
|
/>
|
||||||
{selectedSpanToAddToFunnel && process.env.NODE_ENV === 'development' && (
|
{selectedSpanToAddToFunnel && (
|
||||||
<AddSpanToFunnelModal
|
<AddSpanToFunnelModal
|
||||||
span={selectedSpanToAddToFunnel}
|
span={selectedSpanToAddToFunnel}
|
||||||
isOpen={isAddSpanToFunnelModalOpen}
|
isOpen={isAddSpanToFunnelModalOpen}
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ export const useValidateFunnelSteps = ({
|
|||||||
interface SaveFunnelDescriptionPayload {
|
interface SaveFunnelDescriptionPayload {
|
||||||
funnel_id: string;
|
funnel_id: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSaveFunnelDescription = (): UseMutationResult<
|
export const useSaveFunnelDescription = (): UseMutationResult<
|
||||||
@@ -149,7 +150,11 @@ export const useSaveFunnelDescription = (): UseMutationResult<
|
|||||||
Error,
|
Error,
|
||||||
SaveFunnelDescriptionPayload
|
SaveFunnelDescriptionPayload
|
||||||
> =>
|
> =>
|
||||||
useMutation({
|
useMutation<
|
||||||
|
SuccessResponse<FunnelData> | ErrorResponse,
|
||||||
|
Error,
|
||||||
|
SaveFunnelDescriptionPayload
|
||||||
|
>({
|
||||||
mutationFn: saveFunnelDescription,
|
mutationFn: saveFunnelDescription,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -67,19 +67,15 @@ export default function TraceDetailsPage(): JSX.Element {
|
|||||||
key: 'trace-details',
|
key: 'trace-details',
|
||||||
children: <TraceDetailsV2 />,
|
children: <TraceDetailsV2 />,
|
||||||
},
|
},
|
||||||
...(process.env.NODE_ENV === 'development'
|
{
|
||||||
? [
|
label: (
|
||||||
{
|
<div className="tab-item">
|
||||||
label: (
|
<Cone className="funnel-icon" size={16} /> Funnels
|
||||||
<div className="tab-item">
|
</div>
|
||||||
<Cone className="funnel-icon" size={16} /> Funnels
|
),
|
||||||
</div>
|
key: 'funnels',
|
||||||
),
|
children: <div />,
|
||||||
key: 'funnels',
|
},
|
||||||
children: <div />,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
{
|
||||||
label: (
|
label: (
|
||||||
<div className="tab-item">
|
<div className="tab-item">
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ function AddFunnelDescriptionModal({
|
|||||||
{
|
{
|
||||||
funnel_id: funnelId,
|
funnel_id: funnelId,
|
||||||
description,
|
description,
|
||||||
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import './FunnelStep.styles.scss';
|
import './FunnelStep.styles.scss';
|
||||||
|
|
||||||
import { Button, Divider, Dropdown, Form, Space, Switch, Tooltip } from 'antd';
|
import { Button, Divider, Form, Switch, Tooltip } from 'antd';
|
||||||
import { MenuProps } from 'antd/lib';
|
|
||||||
import { FilterSelect } from 'components/CeleryOverview/CeleryOverviewConfigOptions/CeleryOverviewConfigOptions';
|
import { FilterSelect } from 'components/CeleryOverview/CeleryOverviewConfigOptions/CeleryOverviewConfigOptions';
|
||||||
import { QueryParams } from 'constants/query';
|
import { QueryParams } from 'constants/query';
|
||||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||||
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||||
import { ChevronDown, GripVertical, HardHat, PencilLine } from 'lucide-react';
|
import { GripVertical, HardHat, PencilLine } from 'lucide-react';
|
||||||
import { LatencyPointers } from 'pages/TracesFunnelDetails/constants';
|
|
||||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { FunnelStepData } from 'types/api/traceFunnels';
|
import { FunnelStepData } from 'types/api/traceFunnels';
|
||||||
@@ -37,16 +35,17 @@ function FunnelStep({
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
const latencyPointerItems: MenuProps['items'] = LatencyPointers.map(
|
// temporarily hide latency pointer, as it breaks some edge cases (ref: https://signoz-team.slack.com/archives/C089MNX4Y90/p1748600682066499?thread_ts=1748599673.171759&cid=C089MNX4Y90)
|
||||||
(option) => ({
|
// const latencyPointerItems: MenuProps['items'] = LatencyPointers.map(
|
||||||
key: option.value,
|
// (option) => ({
|
||||||
label: option.key,
|
// key: option.value,
|
||||||
style:
|
// label: option.key,
|
||||||
option.value === stepData.latency_pointer
|
// style:
|
||||||
? { backgroundColor: 'var(--bg-slate-100)' }
|
// option.value === stepData.latency_pointer
|
||||||
: {},
|
// ? { backgroundColor: 'var(--bg-slate-100)' }
|
||||||
}),
|
// : {},
|
||||||
);
|
// }),
|
||||||
|
// );
|
||||||
|
|
||||||
const updatedCurrentQuery = useMemo(
|
const updatedCurrentQuery = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -176,7 +175,8 @@ function FunnelStep({
|
|||||||
/>
|
/>
|
||||||
<div className="error__label">Errors</div>
|
<div className="error__label">Errors</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="latency-pointer">
|
{/* temporarily hide latency pointer, as it breaks some edge cases (ref: https://signoz-team.slack.com/archives/C089MNX4Y90/p1748600682066499?thread_ts=1748599673.171759&cid=C089MNX4Y90) */}
|
||||||
|
{/* <div className="latency-pointer">
|
||||||
<div className="latency-pointer__label">Latency pointer</div>
|
<div className="latency-pointer__label">Latency pointer</div>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
menu={{
|
menu={{
|
||||||
@@ -197,7 +197,7 @@ function FunnelStep({
|
|||||||
<ChevronDown size={14} color="var(--bg-vanilla-400)" />
|
<ChevronDown size={14} color="var(--bg-vanilla-400)" />
|
||||||
</Space>
|
</Space>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -170,6 +170,10 @@ export function FunnelProvider({
|
|||||||
handleStepUpdate(index, {
|
handleStepUpdate(index, {
|
||||||
service_name: serviceName,
|
service_name: serviceName,
|
||||||
span_name: spanName,
|
span_name: spanName,
|
||||||
|
filters: {
|
||||||
|
items: [],
|
||||||
|
op: 'AND',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
logEvent('Trace Funnels: span added (replaced) from trace details page', {});
|
logEvent('Trace Funnels: span added (replaced) from trace details page', {});
|
||||||
},
|
},
|
||||||
@@ -191,6 +195,11 @@ export function FunnelProvider({
|
|||||||
funnelId,
|
funnelId,
|
||||||
selectedTime,
|
selectedTime,
|
||||||
]);
|
]);
|
||||||
|
queryClient.refetchQueries([
|
||||||
|
REACT_QUERY_KEY.GET_FUNNEL_STEPS_OVERVIEW,
|
||||||
|
funnelId,
|
||||||
|
selectedTime,
|
||||||
|
]);
|
||||||
queryClient.refetchQueries([
|
queryClient.refetchQueries([
|
||||||
REACT_QUERY_KEY.GET_FUNNEL_STEPS_GRAPH_DATA,
|
REACT_QUERY_KEY.GET_FUNNEL_STEPS_GRAPH_DATA,
|
||||||
funnelId,
|
funnelId,
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ function TracesModulePage(): JSX.Element {
|
|||||||
|
|
||||||
const routes: TabRoutes[] = [
|
const routes: TabRoutes[] = [
|
||||||
tracesExplorer,
|
tracesExplorer,
|
||||||
// TODO(shaheer): remove this check after everything is ready
|
tracesFunnel(pathname),
|
||||||
process.env.NODE_ENV === 'development' ? tracesFunnel(pathname) : null,
|
|
||||||
tracesSaveView,
|
tracesSaveView,
|
||||||
].filter(Boolean) as TabRoutes[];
|
].filter(Boolean) as TabRoutes[];
|
||||||
|
|
||||||
|
|||||||
789
pkg/modules/tracefunnel/clickhouse_queries.go
Normal file
789
pkg/modules/tracefunnel/clickhouse_queries.go
Normal file
@@ -0,0 +1,789 @@
|
|||||||
|
package tracefunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildTwoStepFunnelValidationQuery(
|
||||||
|
containsErrorT1 int,
|
||||||
|
containsErrorT2 int,
|
||||||
|
startTs int64,
|
||||||
|
endTs int64,
|
||||||
|
serviceNameT1 string,
|
||||||
|
spanNameT1 string,
|
||||||
|
serviceNameT2 string,
|
||||||
|
spanNameT2 string,
|
||||||
|
clauseStep1 string,
|
||||||
|
clauseStep2 string,
|
||||||
|
) string {
|
||||||
|
queryTemplate := `
|
||||||
|
WITH
|
||||||
|
%[1]d AS contains_error_t1,
|
||||||
|
%[2]d AS contains_error_t2,
|
||||||
|
toDateTime64(%[3]d/1e9, 9) AS start_ts,
|
||||||
|
toDateTime64(%[4]d/1e9, 9) AS end_ts,
|
||||||
|
|
||||||
|
('%[5]s','%[6]s') AS step1,
|
||||||
|
('%[7]s','%[8]s') AS step2
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
trace_id
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
trace_id,
|
||||||
|
minIf(timestamp, serviceName = step1.1 AND name = step1.2) AS t1_time,
|
||||||
|
minIf(timestamp, serviceName = step2.1 AND name = step2.2) AS t2_time
|
||||||
|
FROM signoz_traces.signoz_index_v3
|
||||||
|
WHERE
|
||||||
|
timestamp BETWEEN start_ts AND end_ts
|
||||||
|
AND (
|
||||||
|
(serviceName = step1.1 AND name = step1.2 AND (contains_error_t1 = 0 OR has_error = true) %[9]s)
|
||||||
|
OR
|
||||||
|
(serviceName = step2.1 AND name = step2.2 AND (contains_error_t2 = 0 OR has_error = true) %[10]s)
|
||||||
|
)
|
||||||
|
GROUP BY trace_id
|
||||||
|
HAVING t1_time > 0 AND t2_time > t1_time
|
||||||
|
)
|
||||||
|
ORDER BY t1_time
|
||||||
|
LIMIT 5;`
|
||||||
|
return fmt.Sprintf(queryTemplate,
|
||||||
|
containsErrorT1,
|
||||||
|
containsErrorT2,
|
||||||
|
startTs,
|
||||||
|
endTs,
|
||||||
|
serviceNameT1,
|
||||||
|
spanNameT1,
|
||||||
|
serviceNameT2,
|
||||||
|
spanNameT2,
|
||||||
|
clauseStep1,
|
||||||
|
clauseStep2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildThreeStepFunnelValidationQuery(
|
||||||
|
containsErrorT1 int,
|
||||||
|
containsErrorT2 int,
|
||||||
|
containsErrorT3 int,
|
||||||
|
startTs int64,
|
||||||
|
endTs int64,
|
||||||
|
serviceNameT1 string,
|
||||||
|
spanNameT1 string,
|
||||||
|
serviceNameT2 string,
|
||||||
|
spanNameT2 string,
|
||||||
|
serviceNameT3 string,
|
||||||
|
spanNameT3 string,
|
||||||
|
clauseStep1 string,
|
||||||
|
clauseStep2 string,
|
||||||
|
clauseStep3 string,
|
||||||
|
) string {
|
||||||
|
queryTemplate := `
|
||||||
|
WITH
|
||||||
|
%[1]d AS contains_error_t1,
|
||||||
|
%[2]d AS contains_error_t2,
|
||||||
|
%[3]d AS contains_error_t3,
|
||||||
|
toDateTime64(%[4]d/1e9, 9) AS start_ts,
|
||||||
|
toDateTime64(%[5]d/1e9, 9) AS end_ts,
|
||||||
|
|
||||||
|
('%[6]s','%[7]s') AS step1,
|
||||||
|
('%[8]s','%[9]s') AS step2,
|
||||||
|
('%[10]s','%[11]s') AS step3
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
trace_id
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
trace_id,
|
||||||
|
minIf(timestamp, serviceName = step1.1 AND name = step1.2) AS t1_time,
|
||||||
|
minIf(timestamp, serviceName = step2.1 AND name = step2.2) AS t2_time,
|
||||||
|
minIf(timestamp, serviceName = step3.1 AND name = step3.2) AS t3_time
|
||||||
|
FROM signoz_traces.signoz_index_v3
|
||||||
|
WHERE
|
||||||
|
timestamp BETWEEN start_ts AND end_ts
|
||||||
|
AND (
|
||||||
|
(serviceName = step1.1 AND name = step1.2 AND (contains_error_t1 = 0 OR has_error = true) %[12]s)
|
||||||
|
OR (serviceName = step2.1 AND name = step2.2 AND (contains_error_t2 = 0 OR has_error = true) %[13]s)
|
||||||
|
OR (serviceName = step3.1 AND name = step3.2 AND (contains_error_t3 = 0 OR has_error = true) %[14]s)
|
||||||
|
)
|
||||||
|
GROUP BY trace_id
|
||||||
|
HAVING t1_time > 0 AND t2_time > t1_time AND t3_time > t2_time
|
||||||
|
)
|
||||||
|
ORDER BY t1_time
|
||||||
|
LIMIT 5;`
|
||||||
|
return fmt.Sprintf(queryTemplate,
|
||||||
|
containsErrorT1,
|
||||||
|
containsErrorT2,
|
||||||
|
containsErrorT3,
|
||||||
|
startTs,
|
||||||
|
endTs,
|
||||||
|
serviceNameT1,
|
||||||
|
spanNameT1,
|
||||||
|
serviceNameT2,
|
||||||
|
spanNameT2,
|
||||||
|
serviceNameT3,
|
||||||
|
spanNameT3,
|
||||||
|
clauseStep1,
|
||||||
|
clauseStep2,
|
||||||
|
clauseStep3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildTwoStepFunnelOverviewQuery(
|
||||||
|
containsErrorT1 int,
|
||||||
|
containsErrorT2 int,
|
||||||
|
latencyPointerT1 string,
|
||||||
|
latencyPointerT2 string,
|
||||||
|
startTs int64,
|
||||||
|
endTs int64,
|
||||||
|
serviceNameT1 string,
|
||||||
|
spanNameT1 string,
|
||||||
|
serviceNameT2 string,
|
||||||
|
spanNameT2 string,
|
||||||
|
clauseStep1 string,
|
||||||
|
clauseStep2 string,
|
||||||
|
) string {
|
||||||
|
queryTemplate := `
|
||||||
|
WITH
|
||||||
|
%[1]d AS contains_error_t1,
|
||||||
|
%[2]d AS contains_error_t2,
|
||||||
|
'%[3]s' AS latency_pointer_t1,
|
||||||
|
'%[4]s' AS latency_pointer_t2,
|
||||||
|
toDateTime64(%[5]d/1e9, 9) AS start_ts,
|
||||||
|
toDateTime64(%[6]d/1e9, 9) AS end_ts,
|
||||||
|
(%[6]d - %[5]d)/1e9 AS time_window_sec,
|
||||||
|
|
||||||
|
('%[7]s','%[8]s') AS step1,
|
||||||
|
('%[9]s','%[10]s') AS step2
|
||||||
|
|
||||||
|
, funnel AS (
|
||||||
|
SELECT
|
||||||
|
trace_id,
|
||||||
|
minIf(timestamp, serviceName = step1.1 AND name = step1.2) AS t1_time,
|
||||||
|
minIf(timestamp, serviceName = step2.1 AND name = step2.2) AS t2_time,
|
||||||
|
toUInt8(anyIf(has_error, serviceName = step1.1 AND name = step1.2)) AS s1_error,
|
||||||
|
toUInt8(anyIf(has_error, serviceName = step2.1 AND name = step2.2)) AS s2_error
|
||||||
|
FROM signoz_traces.signoz_index_v3
|
||||||
|
WHERE
|
||||||
|
timestamp BETWEEN start_ts AND end_ts
|
||||||
|
AND (
|
||||||
|
(serviceName = step1.1 AND name = step1.2 AND (contains_error_t1 = 0 OR has_error = true) %[11]s)
|
||||||
|
OR
|
||||||
|
(serviceName = step2.1 AND name = step2.2 AND (contains_error_t2 = 0 OR has_error = true) %[12]s)
|
||||||
|
)
|
||||||
|
GROUP BY trace_id
|
||||||
|
HAVING t1_time > 0 AND t2_time > t1_time
|
||||||
|
)
|
||||||
|
|
||||||
|
, totals AS (
|
||||||
|
SELECT
|
||||||
|
count() AS total_s1_spans,
|
||||||
|
countIf(t2_time > t1_time) AS total_s2_spans,
|
||||||
|
sum(s1_error) AS sum_s1_error,
|
||||||
|
sum(s2_error) AS sum_s2_error,
|
||||||
|
avg((toUnixTimestamp64Nano(t2_time) - toUnixTimestamp64Nano(t1_time)) / 1e6) AS avg_duration,
|
||||||
|
quantile(0.99)((toUnixTimestamp64Nano(t2_time) - toUnixTimestamp64Nano(t1_time)) / 1e6) AS latency
|
||||||
|
FROM funnel
|
||||||
|
)
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
round(if(total_s1_spans > 0, total_s2_spans * 100.0 / total_s1_spans, 0), 2) AS conversion_rate,
|
||||||
|
total_s2_spans / time_window_sec AS avg_rate,
|
||||||
|
greatest(sum_s1_error, sum_s2_error) AS errors,
|
||||||
|
avg_duration,
|
||||||
|
latency
|
||||||
|
FROM totals;
|
||||||
|
`
|
||||||
|
return fmt.Sprintf(queryTemplate,
|
||||||
|
containsErrorT1,
|
||||||
|
containsErrorT2,
|
||||||
|
latencyPointerT1,
|
||||||
|
latencyPointerT2,
|
||||||
|
startTs,
|
||||||
|
endTs,
|
||||||
|
serviceNameT1,
|
||||||
|
spanNameT1,
|
||||||
|
serviceNameT2,
|
||||||
|
spanNameT2,
|
||||||
|
clauseStep1,
|
||||||
|
clauseStep2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildThreeStepFunnelOverviewQuery(
|
||||||
|
containsErrorT1 int,
|
||||||
|
containsErrorT2 int,
|
||||||
|
containsErrorT3 int,
|
||||||
|
latencyPointerT1 string,
|
||||||
|
latencyPointerT2 string,
|
||||||
|
latencyPointerT3 string,
|
||||||
|
startTs int64,
|
||||||
|
endTs int64,
|
||||||
|
serviceNameT1 string,
|
||||||
|
spanNameT1 string,
|
||||||
|
serviceNameT2 string,
|
||||||
|
spanNameT2 string,
|
||||||
|
serviceNameT3 string,
|
||||||
|
spanNameT3 string,
|
||||||
|
clauseStep1 string,
|
||||||
|
clauseStep2 string,
|
||||||
|
clauseStep3 string,
|
||||||
|
) string {
|
||||||
|
queryTemplate := `
|
||||||
|
WITH
|
||||||
|
%[1]d AS contains_error_t1,
|
||||||
|
%[2]d AS contains_error_t2,
|
||||||
|
%[3]d AS contains_error_t3,
|
||||||
|
'%[4]s' AS latency_pointer_t1,
|
||||||
|
'%[5]s' AS latency_pointer_t2,
|
||||||
|
'%[6]s' AS latency_pointer_t3,
|
||||||
|
toDateTime64(%[7]d/1e9, 9) AS start_ts,
|
||||||
|
toDateTime64(%[8]d/1e9, 9) AS end_ts,
|
||||||
|
(%[8]d - %[7]d)/1e9 AS time_window_sec,
|
||||||
|
|
||||||
|
('%[9]s','%[10]s') AS step1,
|
||||||
|
('%[11]s','%[12]s') AS step2,
|
||||||
|
('%[13]s','%[14]s') AS step3
|
||||||
|
|
||||||
|
, funnel AS (
|
||||||
|
SELECT
|
||||||
|
trace_id,
|
||||||
|
minIf(timestamp, serviceName = step1.1 AND name = step1.2) AS t1_time,
|
||||||
|
minIf(timestamp, serviceName = step2.1 AND name = step2.2) AS t2_time,
|
||||||
|
minIf(timestamp, serviceName = step3.1 AND name = step3.2) AS t3_time,
|
||||||
|
toUInt8(anyIf(has_error, serviceName = step1.1 AND name = step1.2)) AS s1_error,
|
||||||
|
toUInt8(anyIf(has_error, serviceName = step2.1 AND name = step2.2)) AS s2_error,
|
||||||
|
toUInt8(anyIf(has_error, serviceName = step3.1 AND name = step3.2)) AS s3_error
|
||||||
|
FROM signoz_traces.signoz_index_v3
|
||||||
|
WHERE
|
||||||
|
timestamp BETWEEN start_ts AND end_ts
|
||||||
|
AND (
|
||||||
|
(serviceName = step1.1 AND name = step1.2 AND (contains_error_t1 = 0 OR has_error = true) %[15]s)
|
||||||
|
OR (serviceName = step2.1 AND name = step2.2 AND (contains_error_t2 = 0 OR has_error = true) %[16]s)
|
||||||
|
OR (serviceName = step3.1 AND name = step3.2 AND (contains_error_t3 = 0 OR has_error = true) %[17]s)
|
||||||
|
)
|
||||||
|
GROUP BY trace_id
|
||||||
|
)
|
||||||
|
|
||||||
|
, totals AS (
|
||||||
|
SELECT
|
||||||
|
countIf(t1_time > 0) AS total_s1_spans,
|
||||||
|
countIf(t1_time > 0 AND t2_time > t1_time) AS total_s2_spans,
|
||||||
|
countIf(t2_time > 0 AND t3_time > t2_time) AS total_s3_spans,
|
||||||
|
|
||||||
|
sum(s1_error) AS sum_s1_error,
|
||||||
|
sum(s2_error) AS sum_s2_error,
|
||||||
|
sum(s3_error) AS sum_s3_error,
|
||||||
|
|
||||||
|
avgIf((toUnixTimestamp64Nano(t2_time) - toUnixTimestamp64Nano(t1_time))/1e6, t1_time > 0 AND t2_time > t1_time) AS avg_duration_12,
|
||||||
|
quantileIf(0.99)((toUnixTimestamp64Nano(t2_time) - toUnixTimestamp64Nano(t1_time))/1e6, t1_time > 0 AND t2_time > t1_time) AS latency_12,
|
||||||
|
|
||||||
|
avgIf((toUnixTimestamp64Nano(t3_time) - toUnixTimestamp64Nano(t2_time))/1e6, t2_time > 0 AND t3_time > t2_time) AS avg_duration_23,
|
||||||
|
quantileIf(0.99)((toUnixTimestamp64Nano(t3_time) - toUnixTimestamp64Nano(t2_time))/1e6, t2_time > 0 AND t3_time > t2_time) AS latency_23
|
||||||
|
FROM funnel
|
||||||
|
)
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
round(if(total_s1_spans > 0, total_s3_spans * 100.0 / total_s1_spans, 0), 2) AS conversion_rate,
|
||||||
|
total_s3_spans / nullIf(time_window_sec, 0) AS avg_rate,
|
||||||
|
greatest(sum_s1_error, sum_s2_error, sum_s3_error) AS errors,
|
||||||
|
avg_duration_23 AS avg_duration,
|
||||||
|
latency_23 AS latency
|
||||||
|
FROM totals;
|
||||||
|
`
|
||||||
|
return fmt.Sprintf(
|
||||||
|
queryTemplate,
|
||||||
|
containsErrorT1,
|
||||||
|
containsErrorT2,
|
||||||
|
containsErrorT3,
|
||||||
|
latencyPointerT1,
|
||||||
|
latencyPointerT2,
|
||||||
|
latencyPointerT3,
|
||||||
|
startTs,
|
||||||
|
endTs,
|
||||||
|
serviceNameT1,
|
||||||
|
spanNameT1,
|
||||||
|
serviceNameT2,
|
||||||
|
spanNameT2,
|
||||||
|
serviceNameT3,
|
||||||
|
spanNameT3,
|
||||||
|
clauseStep1,
|
||||||
|
clauseStep2,
|
||||||
|
clauseStep3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildTwoStepFunnelCountQuery(
|
||||||
|
containsErrorT1 int,
|
||||||
|
containsErrorT2 int,
|
||||||
|
startTs int64,
|
||||||
|
endTs int64,
|
||||||
|
serviceNameT1 string,
|
||||||
|
spanNameT1 string,
|
||||||
|
serviceNameT2 string,
|
||||||
|
spanNameT2 string,
|
||||||
|
clauseStep1 string,
|
||||||
|
clauseStep2 string,
|
||||||
|
) string {
|
||||||
|
queryTemplate := `
|
||||||
|
WITH
|
||||||
|
%[1]d AS contains_error_t1,
|
||||||
|
%[2]d AS contains_error_t2,
|
||||||
|
toDateTime64(%[3]d/1e9,9) AS start_ts,
|
||||||
|
toDateTime64(%[4]d/1e9,9) AS end_ts,
|
||||||
|
|
||||||
|
('%[5]s','%[6]s') AS step1,
|
||||||
|
('%[7]s','%[8]s') AS step2
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
count() AS total_s1_spans,
|
||||||
|
countIf(t1_error = 1) AS total_s1_errored_spans,
|
||||||
|
countIf(t2_time > t1_time) AS total_s2_spans,
|
||||||
|
countIf(t2_time > t1_time AND t2_error = 1) AS total_s2_errored_spans
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
trace_id,
|
||||||
|
minIf(timestamp, serviceName = step1.1 AND name = step1.2) AS t1_time,
|
||||||
|
minIf(timestamp, serviceName = step2.1 AND name = step2.2) AS t2_time,
|
||||||
|
toUInt8(anyIf(has_error, serviceName = step1.1 AND name = step1.2)) AS t1_error,
|
||||||
|
toUInt8(anyIf(has_error, serviceName = step2.1 AND name = step2.2)) AS t2_error
|
||||||
|
FROM signoz_traces.signoz_index_v3
|
||||||
|
WHERE
|
||||||
|
timestamp BETWEEN start_ts AND end_ts
|
||||||
|
AND (
|
||||||
|
(serviceName = step1.1 AND name = step1.2 AND (contains_error_t1 = 0 OR has_error = true) %[9]s)
|
||||||
|
OR
|
||||||
|
(serviceName = step2.1 AND name = step2.2 AND (contains_error_t2 = 0 OR has_error = true) %[10]s)
|
||||||
|
)
|
||||||
|
GROUP BY trace_id
|
||||||
|
HAVING t1_time > 0 AND t2_time > t1_time
|
||||||
|
) AS funnel;
|
||||||
|
`
|
||||||
|
return fmt.Sprintf(queryTemplate,
|
||||||
|
containsErrorT1,
|
||||||
|
containsErrorT2,
|
||||||
|
startTs,
|
||||||
|
endTs,
|
||||||
|
serviceNameT1,
|
||||||
|
spanNameT1,
|
||||||
|
serviceNameT2,
|
||||||
|
spanNameT2,
|
||||||
|
clauseStep1,
|
||||||
|
clauseStep2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildThreeStepFunnelCountQuery(
|
||||||
|
containsErrorT1 int,
|
||||||
|
containsErrorT2 int,
|
||||||
|
containsErrorT3 int,
|
||||||
|
startTs int64,
|
||||||
|
endTs int64,
|
||||||
|
serviceNameT1 string,
|
||||||
|
spanNameT1 string,
|
||||||
|
serviceNameT2 string,
|
||||||
|
spanNameT2 string,
|
||||||
|
serviceNameT3 string,
|
||||||
|
spanNameT3 string,
|
||||||
|
clauseStep1 string,
|
||||||
|
clauseStep2 string,
|
||||||
|
clauseStep3 string,
|
||||||
|
) string {
|
||||||
|
queryTemplate := `
|
||||||
|
WITH
|
||||||
|
%[1]d AS contains_error_t1,
|
||||||
|
%[2]d AS contains_error_t2,
|
||||||
|
%[3]d AS contains_error_t3,
|
||||||
|
toDateTime64(%[4]d/1e9,9) AS start_ts,
|
||||||
|
toDateTime64(%[5]d/1e9,9) AS end_ts,
|
||||||
|
|
||||||
|
('%[6]s','%[7]s') AS step1,
|
||||||
|
('%[8]s','%[9]s') AS step2,
|
||||||
|
('%[10]s','%[11]s') AS step3
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
count() AS total_s1_spans,
|
||||||
|
countIf(t1_error = 1) AS total_s1_errored_spans,
|
||||||
|
countIf(t2_time > t1_time) AS total_s2_spans,
|
||||||
|
countIf(t2_time > t1_time AND t2_error = 1) AS total_s2_errored_spans,
|
||||||
|
countIf(t2_time > t1_time AND t3_time > t2_time) AS total_s3_spans,
|
||||||
|
countIf(t2_time > t1_time AND t3_time > t2_time AND t3_error = 1) AS total_s3_errored_spans
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
trace_id,
|
||||||
|
minIf(timestamp, serviceName = step1.1 AND name = step1.2) AS t1_time,
|
||||||
|
minIf(timestamp, serviceName = step2.1 AND name = step2.2) AS t2_time,
|
||||||
|
minIf(timestamp, serviceName = step3.1 AND name = step3.2) AS t3_time,
|
||||||
|
toUInt8(anyIf(has_error, serviceName = step1.1 AND name = step1.2)) AS t1_error,
|
||||||
|
toUInt8(anyIf(has_error, serviceName = step2.1 AND name = step2.2)) AS t2_error,
|
||||||
|
toUInt8(anyIf(has_error, serviceName = step3.1 AND name = step3.2)) AS t3_error
|
||||||
|
FROM signoz_traces.signoz_index_v3
|
||||||
|
WHERE
|
||||||
|
timestamp BETWEEN start_ts AND end_ts
|
||||||
|
AND (
|
||||||
|
(serviceName = step1.1 AND name = step1.2 AND (contains_error_t1 = 0 OR has_error = true) %[12]s)
|
||||||
|
OR (serviceName = step2.1 AND name = step2.2 AND (contains_error_t2 = 0 OR has_error = true) %[13]s)
|
||||||
|
OR (serviceName = step3.1 AND name = step3.2 AND (contains_error_t3 = 0 OR has_error = true) %[14]s)
|
||||||
|
)
|
||||||
|
GROUP BY trace_id
|
||||||
|
HAVING t1_time > 0 AND t2_time > t1_time AND t3_time > t2_time
|
||||||
|
) AS funnel;
|
||||||
|
`
|
||||||
|
return fmt.Sprintf(queryTemplate,
|
||||||
|
containsErrorT1,
|
||||||
|
containsErrorT2,
|
||||||
|
containsErrorT3,
|
||||||
|
startTs,
|
||||||
|
endTs,
|
||||||
|
serviceNameT1,
|
||||||
|
spanNameT1,
|
||||||
|
serviceNameT2,
|
||||||
|
spanNameT2,
|
||||||
|
serviceNameT3,
|
||||||
|
spanNameT3,
|
||||||
|
clauseStep1,
|
||||||
|
clauseStep2,
|
||||||
|
clauseStep3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildTwoStepFunnelTopSlowTracesQuery(
|
||||||
|
containsErrorT1 int,
|
||||||
|
containsErrorT2 int,
|
||||||
|
startTs int64,
|
||||||
|
endTs int64,
|
||||||
|
serviceNameT1 string,
|
||||||
|
spanNameT1 string,
|
||||||
|
serviceNameT2 string,
|
||||||
|
spanNameT2 string,
|
||||||
|
clauseStep1 string,
|
||||||
|
clauseStep2 string,
|
||||||
|
) string {
|
||||||
|
queryTemplate := `
|
||||||
|
WITH
|
||||||
|
%[1]d AS contains_error_t1,
|
||||||
|
%[2]d AS contains_error_t2,
|
||||||
|
toDateTime64(%[3]d/1e9, 9) AS start_ts,
|
||||||
|
toDateTime64(%[4]d/1e9, 9) AS end_ts,
|
||||||
|
|
||||||
|
('%[5]s','%[6]s') AS step1,
|
||||||
|
('%[7]s','%[8]s') AS step2
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
trace_id,
|
||||||
|
(toUnixTimestamp64Nano(t2_time) - toUnixTimestamp64Nano(t1_time)) / 1e6 AS duration_ms,
|
||||||
|
span_count
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
trace_id,
|
||||||
|
minIf(timestamp, serviceName = step1.1 AND name = step1.2) AS t1_time,
|
||||||
|
minIf(timestamp, serviceName = step2.1 AND name = step2.2) AS t2_time,
|
||||||
|
count() AS span_count
|
||||||
|
FROM signoz_traces.signoz_index_v3
|
||||||
|
WHERE
|
||||||
|
timestamp BETWEEN start_ts AND end_ts
|
||||||
|
AND (
|
||||||
|
(serviceName = step1.1 AND name = step1.2 AND (contains_error_t1 = 0 OR has_error = true) %[9]s)
|
||||||
|
OR
|
||||||
|
(serviceName = step2.1 AND name = step2.2 AND (contains_error_t2 = 0 OR has_error = true) %[10]s)
|
||||||
|
)
|
||||||
|
GROUP BY trace_id
|
||||||
|
HAVING t1_time > 0 AND t2_time > t1_time
|
||||||
|
) AS funnel
|
||||||
|
ORDER BY duration_ms DESC
|
||||||
|
LIMIT 5;
|
||||||
|
`
|
||||||
|
return fmt.Sprintf(queryTemplate,
|
||||||
|
containsErrorT1,
|
||||||
|
containsErrorT2,
|
||||||
|
startTs,
|
||||||
|
endTs,
|
||||||
|
serviceNameT1,
|
||||||
|
spanNameT1,
|
||||||
|
serviceNameT2,
|
||||||
|
spanNameT2,
|
||||||
|
clauseStep1,
|
||||||
|
clauseStep2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildTwoStepFunnelTopSlowErrorTracesQuery(
|
||||||
|
containsErrorT1 int,
|
||||||
|
containsErrorT2 int,
|
||||||
|
startTs int64,
|
||||||
|
endTs int64,
|
||||||
|
serviceNameT1 string,
|
||||||
|
spanNameT1 string,
|
||||||
|
serviceNameT2 string,
|
||||||
|
spanNameT2 string,
|
||||||
|
clauseStep1 string,
|
||||||
|
clauseStep2 string,
|
||||||
|
) string {
|
||||||
|
queryTemplate := `
|
||||||
|
WITH
|
||||||
|
%[1]d AS contains_error_t1,
|
||||||
|
%[2]d AS contains_error_t2,
|
||||||
|
toDateTime64(%[3]d/1e9, 9) AS start_ts,
|
||||||
|
toDateTime64(%[4]d/1e9, 9) AS end_ts,
|
||||||
|
|
||||||
|
('%[5]s','%[6]s') AS step1,
|
||||||
|
('%[7]s','%[8]s') AS step2
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
trace_id,
|
||||||
|
(toUnixTimestamp64Nano(t2_time) - toUnixTimestamp64Nano(t1_time)) / 1e6 AS duration_ms,
|
||||||
|
span_count
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
trace_id,
|
||||||
|
minIf(timestamp, serviceName = step1.1 AND name = step1.2) AS t1_time,
|
||||||
|
minIf(timestamp, serviceName = step2.1 AND name = step2.2) AS t2_time,
|
||||||
|
toUInt8(anyIf(has_error, serviceName = step1.1 AND name = step1.2)) AS t1_error,
|
||||||
|
toUInt8(anyIf(has_error, serviceName = step2.1 AND name = step2.2)) AS t2_error,
|
||||||
|
count() AS span_count
|
||||||
|
FROM signoz_traces.signoz_index_v3
|
||||||
|
WHERE
|
||||||
|
timestamp BETWEEN start_ts AND end_ts
|
||||||
|
AND (
|
||||||
|
(serviceName = step1.1 AND name = step1.2 AND (contains_error_t1 = 0 OR has_error = true) %[9]s)
|
||||||
|
OR
|
||||||
|
(serviceName = step2.1 AND name = step2.2 AND (contains_error_t2 = 0 OR has_error = true) %[10]s)
|
||||||
|
)
|
||||||
|
GROUP BY trace_id
|
||||||
|
HAVING t1_time > 0 AND t2_time > t1_time
|
||||||
|
) AS funnel
|
||||||
|
WHERE
|
||||||
|
(t1_error = 1 OR t2_error = 1)
|
||||||
|
ORDER BY duration_ms DESC
|
||||||
|
LIMIT 5;
|
||||||
|
`
|
||||||
|
return fmt.Sprintf(queryTemplate,
|
||||||
|
containsErrorT1,
|
||||||
|
containsErrorT2,
|
||||||
|
startTs,
|
||||||
|
endTs,
|
||||||
|
serviceNameT1,
|
||||||
|
spanNameT1,
|
||||||
|
serviceNameT2,
|
||||||
|
spanNameT2,
|
||||||
|
clauseStep1,
|
||||||
|
clauseStep2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildTwoStepFunnelStepOverviewQuery(
|
||||||
|
containsErrorT1 int,
|
||||||
|
containsErrorT2 int,
|
||||||
|
latencyPointerT1 string,
|
||||||
|
latencyPointerT2 string,
|
||||||
|
startTs int64,
|
||||||
|
endTs int64,
|
||||||
|
serviceNameT1 string,
|
||||||
|
spanNameT1 string,
|
||||||
|
serviceNameT2 string,
|
||||||
|
spanNameT2 string,
|
||||||
|
clauseStep1 string,
|
||||||
|
clauseStep2 string,
|
||||||
|
latencyTypeT2 string,
|
||||||
|
) string {
|
||||||
|
const tpl = `
|
||||||
|
WITH
|
||||||
|
toDateTime64(%[5]d / 1e9, 9) AS start_ts,
|
||||||
|
toDateTime64(%[6]d / 1e9, 9) AS end_ts,
|
||||||
|
(%[6]d - %[5]d) / 1e9 AS time_window_sec,
|
||||||
|
|
||||||
|
('%[7]s', '%[8]s') AS step1,
|
||||||
|
('%[9]s', '%[10]s') AS step2,
|
||||||
|
|
||||||
|
%[1]d AS contains_error_t1,
|
||||||
|
%[2]d AS contains_error_t2
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
round(total_s2_spans * 100.0 / total_s1_spans, 2) AS conversion_rate,
|
||||||
|
total_s2_spans / time_window_sec AS avg_rate,
|
||||||
|
greatest(sum_s1_error, sum_s2_error) AS errors,
|
||||||
|
avg_duration,
|
||||||
|
latency
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
countIf(t1_time > 0) AS total_s1_spans,
|
||||||
|
countIf(t1_time > 0 AND t2_time > t1_time) AS total_s2_spans,
|
||||||
|
sum(s1_error) AS sum_s1_error,
|
||||||
|
sum(s2_error) AS sum_s2_error,
|
||||||
|
|
||||||
|
avgIf(
|
||||||
|
(toUnixTimestamp64Nano(t2_time) - toUnixTimestamp64Nano(t1_time)) / 1e6,
|
||||||
|
t1_time > 0 AND t2_time > t1_time
|
||||||
|
) AS avg_duration,
|
||||||
|
|
||||||
|
quantileIf(%[13]s)(
|
||||||
|
(toUnixTimestamp64Nano(t2_time) - toUnixTimestamp64Nano(t1_time)) / 1e6,
|
||||||
|
t1_time > 0 AND t2_time > t1_time
|
||||||
|
) AS latency
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
trace_id,
|
||||||
|
minIf(timestamp, serviceName = step1.1 AND name = step1.2) AS t1_time,
|
||||||
|
minIf(timestamp, serviceName = step2.1 AND name = step2.2) AS t2_time,
|
||||||
|
toUInt8(anyIf(has_error, serviceName = step1.1 AND name = step1.2)) AS s1_error,
|
||||||
|
toUInt8(anyIf(has_error, serviceName = step2.1 AND name = step2.2)) AS s2_error
|
||||||
|
FROM signoz_traces.signoz_index_v3
|
||||||
|
WHERE
|
||||||
|
timestamp BETWEEN start_ts AND end_ts
|
||||||
|
AND serviceName IN (step1.1, step2.1)
|
||||||
|
AND name IN (step1.2, step2.2)
|
||||||
|
AND ((contains_error_t1 = 0) OR (has_error AND serviceName = step1.1 AND name = step1.2)) %[11]s
|
||||||
|
AND ((contains_error_t2 = 0) OR (has_error AND serviceName = step2.1 AND name = step2.2)) %[12]s
|
||||||
|
GROUP BY trace_id
|
||||||
|
) AS funnel
|
||||||
|
) AS totals;
|
||||||
|
`
|
||||||
|
|
||||||
|
return fmt.Sprintf(tpl,
|
||||||
|
containsErrorT1,
|
||||||
|
containsErrorT2,
|
||||||
|
latencyPointerT1,
|
||||||
|
latencyPointerT2,
|
||||||
|
startTs,
|
||||||
|
endTs,
|
||||||
|
serviceNameT1,
|
||||||
|
spanNameT1,
|
||||||
|
serviceNameT2,
|
||||||
|
spanNameT2,
|
||||||
|
clauseStep1,
|
||||||
|
clauseStep2,
|
||||||
|
latencyTypeT2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildThreeStepFunnelStepOverviewQuery(
|
||||||
|
containsErrorT1 int,
|
||||||
|
containsErrorT2 int,
|
||||||
|
containsErrorT3 int,
|
||||||
|
latencyPointerT1 string,
|
||||||
|
latencyPointerT2 string,
|
||||||
|
latencyPointerT3 string,
|
||||||
|
startTs int64,
|
||||||
|
endTs int64,
|
||||||
|
serviceNameT1 string,
|
||||||
|
spanNameT1 string,
|
||||||
|
serviceNameT2 string,
|
||||||
|
spanNameT2 string,
|
||||||
|
serviceNameT3 string,
|
||||||
|
spanNameT3 string,
|
||||||
|
clauseStep1 string,
|
||||||
|
clauseStep2 string,
|
||||||
|
clauseStep3 string,
|
||||||
|
stepStart int64,
|
||||||
|
stepEnd int64,
|
||||||
|
latencyTypeT2 string,
|
||||||
|
latencyTypeT3 string,
|
||||||
|
) string {
|
||||||
|
const baseWithAndFunnel = `
|
||||||
|
WITH
|
||||||
|
toDateTime64(%[7]d/1e9, 9) AS start_ts,
|
||||||
|
toDateTime64(%[8]d/1e9, 9) AS end_ts,
|
||||||
|
(%[8]d - %[7]d) / 1e9 AS time_window_sec,
|
||||||
|
|
||||||
|
('%[9]s','%[10]s') AS step1,
|
||||||
|
('%[11]s','%[12]s') AS step2,
|
||||||
|
('%[13]s','%[14]s') AS step3,
|
||||||
|
|
||||||
|
%[1]d AS contains_error_t1,
|
||||||
|
%[2]d AS contains_error_t2,
|
||||||
|
%[3]d AS contains_error_t3,
|
||||||
|
|
||||||
|
funnel AS (
|
||||||
|
SELECT
|
||||||
|
trace_id,
|
||||||
|
minIf(timestamp, serviceName = step1.1 AND name = step1.2) AS t1_time,
|
||||||
|
minIf(timestamp, serviceName = step2.1 AND name = step2.2) AS t2_time,
|
||||||
|
minIf(timestamp, serviceName = step3.1 AND name = step3.2) AS t3_time,
|
||||||
|
toUInt8(anyIf(has_error, serviceName = step1.1 AND name = step1.2)) AS s1_error,
|
||||||
|
toUInt8(anyIf(has_error, serviceName = step2.1 AND name = step2.2)) AS s2_error,
|
||||||
|
toUInt8(anyIf(has_error, serviceName = step3.1 AND name = step3.2)) AS s3_error
|
||||||
|
FROM signoz_traces.signoz_index_v3
|
||||||
|
WHERE
|
||||||
|
timestamp BETWEEN start_ts AND end_ts
|
||||||
|
AND serviceName IN (step1.1, step2.1, step3.1)
|
||||||
|
AND name IN (step1.2, step2.2, step3.2)
|
||||||
|
AND ((contains_error_t1 = 0) OR (has_error AND serviceName = step1.1 AND name = step1.2)) %[15]s
|
||||||
|
AND ((contains_error_t2 = 0) OR (has_error AND serviceName = step2.1 AND name = step2.2)) %[16]s
|
||||||
|
AND ((contains_error_t3 = 0) OR (has_error AND serviceName = step3.1 AND name = step3.2)) %[17]s
|
||||||
|
GROUP BY trace_id
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
const totals12 = `
|
||||||
|
SELECT
|
||||||
|
round(if(total_s1_spans > 0, total_s2_spans * 100.0 / total_s1_spans, 0), 2) AS conversion_rate,
|
||||||
|
total_s2_spans / time_window_sec AS avg_rate,
|
||||||
|
greatest(sum_s1_error, sum_s2_error) AS errors,
|
||||||
|
avg_duration_12 AS avg_duration,
|
||||||
|
latency_12 AS latency
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
countIf(t1_time > 0 AND t2_time > t1_time) AS total_s2_spans,
|
||||||
|
countIf(t1_time > 0) AS total_s1_spans, -- eligible only
|
||||||
|
sum(s1_error) AS sum_s1_error,
|
||||||
|
sum(s2_error) AS sum_s2_error,
|
||||||
|
avgIf((toUnixTimestamp64Nano(t2_time) - toUnixTimestamp64Nano(t1_time)) / 1e6, t1_time > 0 AND t2_time > t1_time) AS avg_duration_12,
|
||||||
|
quantileIf(%[18]s)((toUnixTimestamp64Nano(t2_time) - toUnixTimestamp64Nano(t1_time)) / 1e6, t1_time > 0 AND t2_time > t1_time) AS latency_12
|
||||||
|
FROM funnel
|
||||||
|
) AS totals;
|
||||||
|
`
|
||||||
|
|
||||||
|
const totals23 = `
|
||||||
|
SELECT
|
||||||
|
round(if(total_s2_spans > 0, total_s3_spans * 100.0 / total_s2_spans, 0), 2) AS conversion_rate,
|
||||||
|
total_s3_spans / time_window_sec AS avg_rate,
|
||||||
|
greatest(sum_s2_error, sum_s3_error) AS errors,
|
||||||
|
avg_duration_23 AS avg_duration,
|
||||||
|
latency_23 AS latency
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
countIf(t2_time > 0 AND t3_time > t2_time) AS total_s3_spans,
|
||||||
|
countIf(t2_time > 0) AS total_s2_spans, -- eligible only
|
||||||
|
sum(s2_error) AS sum_s2_error,
|
||||||
|
sum(s3_error) AS sum_s3_error,
|
||||||
|
avgIf((toUnixTimestamp64Nano(t3_time) - toUnixTimestamp64Nano(t2_time)) / 1e6, t2_time > 0 AND t3_time > t2_time) AS avg_duration_23,
|
||||||
|
quantileIf(%[19]s)((toUnixTimestamp64Nano(t3_time) - toUnixTimestamp64Nano(t2_time)) / 1e6, t2_time > 0 AND t3_time > t2_time) AS latency_23
|
||||||
|
FROM funnel
|
||||||
|
) AS totals;
|
||||||
|
`
|
||||||
|
|
||||||
|
const fallback = `
|
||||||
|
SELECT 0 AS conversion_rate, 0 AS avg_rate, 0 AS errors, 0 AS avg_duration, 0 AS latency;
|
||||||
|
`
|
||||||
|
|
||||||
|
var totalsTpl string
|
||||||
|
switch {
|
||||||
|
case stepStart == 1 && stepEnd == 2:
|
||||||
|
totalsTpl = totals12
|
||||||
|
case stepStart == 2 && stepEnd == 3:
|
||||||
|
totalsTpl = totals23
|
||||||
|
default:
|
||||||
|
totalsTpl = fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
baseWithAndFunnel+totalsTpl,
|
||||||
|
containsErrorT1,
|
||||||
|
containsErrorT2,
|
||||||
|
containsErrorT3,
|
||||||
|
latencyPointerT1,
|
||||||
|
latencyPointerT2,
|
||||||
|
latencyPointerT3,
|
||||||
|
startTs,
|
||||||
|
endTs,
|
||||||
|
serviceNameT1,
|
||||||
|
spanNameT1,
|
||||||
|
serviceNameT2,
|
||||||
|
spanNameT2,
|
||||||
|
serviceNameT3,
|
||||||
|
spanNameT3,
|
||||||
|
clauseStep1,
|
||||||
|
clauseStep2,
|
||||||
|
clauseStep3,
|
||||||
|
latencyTypeT2,
|
||||||
|
latencyTypeT3,
|
||||||
|
)
|
||||||
|
}
|
||||||
235
pkg/modules/tracefunnel/impltracefunnel/handler.go
Normal file
235
pkg/modules/tracefunnel/impltracefunnel/handler.go
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
package impltracefunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/SigNoz/signoz/pkg/errors"
|
||||||
|
"github.com/SigNoz/signoz/pkg/http/render"
|
||||||
|
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||||
|
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||||
|
tf "github.com/SigNoz/signoz/pkg/types/tracefunneltypes"
|
||||||
|
"github.com/SigNoz/signoz/pkg/valuer"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
type handler struct {
|
||||||
|
module tracefunnel.Module
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(module tracefunnel.Module) tracefunnel.Handler {
|
||||||
|
return &handler{module: module}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *handler) New(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
var req tf.PostableFunnel
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
render.Error(rw, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
render.Error(rw, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
funnel, err := handler.module.Create(r.Context(), req.Timestamp, req.Name, valuer.MustNewUUID(claims.UserID), valuer.MustNewUUID(claims.OrgID))
|
||||||
|
if err != nil {
|
||||||
|
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||||
|
errors.CodeInvalidInput,
|
||||||
|
"failed to create funnel: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := tf.ConstructFunnelResponse(funnel, &claims)
|
||||||
|
render.Success(rw, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *handler) UpdateSteps(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
var req tf.PostableFunnel
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
render.Error(rw, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
render.Error(rw, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedAt, err := tf.ValidateAndConvertTimestamp(req.Timestamp)
|
||||||
|
if err != nil {
|
||||||
|
render.Error(rw, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
funnel, err := handler.module.Get(r.Context(), req.FunnelID, valuer.MustNewUUID(claims.OrgID))
|
||||||
|
if err != nil {
|
||||||
|
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||||
|
errors.CodeInvalidInput,
|
||||||
|
"funnel not found: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
steps, err := tf.ProcessFunnelSteps(req.Steps)
|
||||||
|
if err != nil {
|
||||||
|
render.Error(rw, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
funnel.Steps = steps
|
||||||
|
funnel.UpdatedAt = updatedAt
|
||||||
|
funnel.UpdatedBy = claims.UserID
|
||||||
|
|
||||||
|
if req.Name != "" {
|
||||||
|
funnel.Name = req.Name
|
||||||
|
}
|
||||||
|
if req.Description != "" {
|
||||||
|
funnel.Description = req.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := handler.module.Update(r.Context(), funnel, valuer.MustNewUUID(claims.UserID)); err != nil {
|
||||||
|
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||||
|
errors.CodeInvalidInput,
|
||||||
|
"failed to update funnel in database: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedFunnel, err := handler.module.Get(r.Context(), funnel.ID, valuer.MustNewUUID(claims.OrgID))
|
||||||
|
if err != nil {
|
||||||
|
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||||
|
errors.CodeInvalidInput,
|
||||||
|
"failed to get updated funnel: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := tf.ConstructFunnelResponse(updatedFunnel, &claims)
|
||||||
|
render.Success(rw, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *handler) UpdateFunnel(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
var req tf.PostableFunnel
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
render.Error(rw, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
render.Error(rw, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedAt, err := tf.ValidateAndConvertTimestamp(req.Timestamp)
|
||||||
|
if err != nil {
|
||||||
|
render.Error(rw, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
funnelID := vars["funnel_id"]
|
||||||
|
|
||||||
|
funnel, err := handler.module.Get(r.Context(), valuer.MustNewUUID(funnelID), valuer.MustNewUUID(claims.OrgID))
|
||||||
|
if err != nil {
|
||||||
|
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||||
|
errors.CodeInvalidInput,
|
||||||
|
"funnel not found: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
funnel.UpdatedAt = updatedAt
|
||||||
|
funnel.UpdatedBy = claims.UserID
|
||||||
|
|
||||||
|
if req.Name != "" {
|
||||||
|
funnel.Name = req.Name
|
||||||
|
}
|
||||||
|
if req.Description != "" {
|
||||||
|
funnel.Description = req.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := handler.module.Update(r.Context(), funnel, valuer.MustNewUUID(claims.UserID)); err != nil {
|
||||||
|
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||||
|
errors.CodeInvalidInput,
|
||||||
|
"failed to update funnel in database: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedFunnel, err := handler.module.Get(r.Context(), funnel.ID, valuer.MustNewUUID(claims.OrgID))
|
||||||
|
if err != nil {
|
||||||
|
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||||
|
errors.CodeInvalidInput,
|
||||||
|
"failed to get updated funnel: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := tf.ConstructFunnelResponse(updatedFunnel, &claims)
|
||||||
|
render.Success(rw, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
render.Error(rw, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
funnels, err := handler.module.List(r.Context(), valuer.MustNewUUID(claims.OrgID))
|
||||||
|
if err != nil {
|
||||||
|
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||||
|
errors.CodeInvalidInput,
|
||||||
|
"failed to list funnels: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var response []tf.GettableFunnel
|
||||||
|
for _, f := range funnels {
|
||||||
|
response = append(response, tf.ConstructFunnelResponse(f, &claims))
|
||||||
|
}
|
||||||
|
|
||||||
|
render.Success(rw, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
funnelID := vars["funnel_id"]
|
||||||
|
|
||||||
|
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
render.Error(rw, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
funnel, err := handler.module.Get(r.Context(), valuer.MustNewUUID(funnelID), valuer.MustNewUUID(claims.OrgID))
|
||||||
|
if err != nil {
|
||||||
|
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||||
|
errors.CodeInvalidInput,
|
||||||
|
"funnel not found: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response := tf.ConstructFunnelResponse(funnel, &claims)
|
||||||
|
render.Success(rw, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *handler) Delete(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
funnelID := vars["funnel_id"]
|
||||||
|
|
||||||
|
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
render.Error(rw, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := handler.module.Delete(r.Context(), valuer.MustNewUUID(funnelID), valuer.MustNewUUID(claims.OrgID)); err != nil {
|
||||||
|
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
|
||||||
|
errors.CodeInvalidInput,
|
||||||
|
"failed to delete funnel: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
render.Success(rw, http.StatusOK, nil)
|
||||||
|
}
|
||||||
173
pkg/modules/tracefunnel/impltracefunnel/handler_test.go
Normal file
173
pkg/modules/tracefunnel/impltracefunnel/handler_test.go
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
package impltracefunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/SigNoz/signoz/pkg/types"
|
||||||
|
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||||
|
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunneltypes"
|
||||||
|
"github.com/SigNoz/signoz/pkg/valuer"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MockModule struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockModule) Create(ctx context.Context, timestamp int64, name string, userID valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error) {
|
||||||
|
args := m.Called(ctx, timestamp, name, userID, orgID)
|
||||||
|
return args.Get(0).(*traceFunnels.StorableFunnel), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockModule) Get(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error) {
|
||||||
|
args := m.Called(ctx, funnelID, orgID)
|
||||||
|
return args.Get(0).(*traceFunnels.StorableFunnel), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockModule) Update(ctx context.Context, funnel *traceFunnels.StorableFunnel, userID valuer.UUID) error {
|
||||||
|
args := m.Called(ctx, funnel, userID)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockModule) List(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.StorableFunnel, error) {
|
||||||
|
args := m.Called(ctx, orgID)
|
||||||
|
return args.Get(0).([]*traceFunnels.StorableFunnel), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockModule) Delete(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) error {
|
||||||
|
args := m.Called(ctx, funnelID, orgID)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockModule) Save(ctx context.Context, funnel *traceFunnels.StorableFunnel, userID valuer.UUID, orgID valuer.UUID) error {
|
||||||
|
args := m.Called(ctx, funnel, userID, orgID)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockModule) GetFunnelMetadata(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) (int64, int64, string, error) {
|
||||||
|
args := m.Called(ctx, funnelID, orgID)
|
||||||
|
return args.Get(0).(int64), args.Get(1).(int64), args.String(2), args.Error(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_List(t *testing.T) {
|
||||||
|
mockModule := new(MockModule)
|
||||||
|
handler := NewHandler(mockModule)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/trace-funnels/list", nil)
|
||||||
|
|
||||||
|
orgID := valuer.GenerateUUID()
|
||||||
|
claims := authtypes.Claims{
|
||||||
|
OrgID: orgID.String(),
|
||||||
|
}
|
||||||
|
req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), claims))
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
funnel1ID := valuer.GenerateUUID()
|
||||||
|
funnel2ID := valuer.GenerateUUID()
|
||||||
|
expectedFunnels := []*traceFunnels.StorableFunnel{
|
||||||
|
{
|
||||||
|
Identifiable: types.Identifiable{
|
||||||
|
ID: funnel1ID,
|
||||||
|
},
|
||||||
|
Name: "funnel-1",
|
||||||
|
OrgID: orgID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Identifiable: types.Identifiable{
|
||||||
|
ID: funnel2ID,
|
||||||
|
},
|
||||||
|
Name: "funnel-2",
|
||||||
|
OrgID: orgID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mockModule.On("List", req.Context(), orgID).Return(expectedFunnels, nil)
|
||||||
|
|
||||||
|
handler.List(rr, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Data []traceFunnels.GettableFunnel `json:"data"`
|
||||||
|
}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "success", response.Status)
|
||||||
|
assert.Len(t, response.Data, 2)
|
||||||
|
assert.Equal(t, "funnel-1", response.Data[0].FunnelName)
|
||||||
|
assert.Equal(t, "funnel-2", response.Data[1].FunnelName)
|
||||||
|
|
||||||
|
mockModule.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_Get(t *testing.T) {
|
||||||
|
mockModule := new(MockModule)
|
||||||
|
handler := NewHandler(mockModule)
|
||||||
|
|
||||||
|
funnelID := valuer.GenerateUUID()
|
||||||
|
orgID := valuer.GenerateUUID()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/trace-funnels/"+funnelID.String(), nil)
|
||||||
|
req = mux.SetURLVars(req, map[string]string{"funnel_id": funnelID.String()})
|
||||||
|
req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), authtypes.Claims{
|
||||||
|
OrgID: orgID.String(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
expectedFunnel := &traceFunnels.StorableFunnel{
|
||||||
|
Identifiable: types.Identifiable{
|
||||||
|
ID: funnelID,
|
||||||
|
},
|
||||||
|
Name: "test-funnel",
|
||||||
|
OrgID: orgID,
|
||||||
|
}
|
||||||
|
|
||||||
|
mockModule.On("Get", req.Context(), funnelID, orgID).Return(expectedFunnel, nil)
|
||||||
|
|
||||||
|
handler.Get(rr, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Data traceFunnels.GettableFunnel `json:"data"`
|
||||||
|
}
|
||||||
|
err := json.Unmarshal(rr.Body.Bytes(), &response)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "success", response.Status)
|
||||||
|
assert.Equal(t, "test-funnel", response.Data.FunnelName)
|
||||||
|
assert.Equal(t, expectedFunnel.OrgID.String(), response.Data.OrgID)
|
||||||
|
|
||||||
|
mockModule.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_Delete(t *testing.T) {
|
||||||
|
mockModule := new(MockModule)
|
||||||
|
handler := NewHandler(mockModule)
|
||||||
|
|
||||||
|
funnelID := valuer.GenerateUUID()
|
||||||
|
orgID := valuer.GenerateUUID()
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/trace-funnels/"+funnelID.String(), nil)
|
||||||
|
req = mux.SetURLVars(req, map[string]string{"funnel_id": funnelID.String()})
|
||||||
|
req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), authtypes.Claims{
|
||||||
|
OrgID: orgID.String(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
mockModule.On("Delete", req.Context(), funnelID, orgID).Return(nil)
|
||||||
|
|
||||||
|
handler.Delete(rr, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, rr.Code)
|
||||||
|
|
||||||
|
mockModule.AssertExpectations(t)
|
||||||
|
}
|
||||||
96
pkg/modules/tracefunnel/impltracefunnel/module.go
Normal file
96
pkg/modules/tracefunnel/impltracefunnel/module.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package impltracefunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||||
|
"github.com/SigNoz/signoz/pkg/types"
|
||||||
|
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunneltypes"
|
||||||
|
"github.com/SigNoz/signoz/pkg/valuer"
|
||||||
|
)
|
||||||
|
|
||||||
|
type module struct {
|
||||||
|
store traceFunnels.FunnelStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewModule(store traceFunnels.FunnelStore) tracefunnel.Module {
|
||||||
|
return &module{
|
||||||
|
store: store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (module *module) Create(ctx context.Context, timestamp int64, name string, userID valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error) {
|
||||||
|
funnel := &traceFunnels.StorableFunnel{
|
||||||
|
Name: name,
|
||||||
|
OrgID: orgID,
|
||||||
|
}
|
||||||
|
funnel.CreatedAt = time.Unix(0, timestamp*1000000) // Convert to nanoseconds
|
||||||
|
funnel.CreatedBy = userID.String()
|
||||||
|
|
||||||
|
// Set up the user relationship
|
||||||
|
funnel.CreatedByUser = &types.User{
|
||||||
|
Identifiable: types.Identifiable{
|
||||||
|
ID: userID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if funnel.ID.IsZero() {
|
||||||
|
funnel.ID = valuer.GenerateUUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
if funnel.CreatedAt.IsZero() {
|
||||||
|
funnel.CreatedAt = time.Now()
|
||||||
|
}
|
||||||
|
if funnel.UpdatedAt.IsZero() {
|
||||||
|
funnel.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set created_by if CreatedByUser is present
|
||||||
|
if funnel.CreatedByUser != nil {
|
||||||
|
funnel.CreatedBy = funnel.CreatedByUser.Identifiable.ID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := module.store.Create(ctx, funnel); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create funnel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return funnel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get gets a funnel by ID
|
||||||
|
func (module *module) Get(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error) {
|
||||||
|
return module.store.Get(ctx, funnelID, orgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates a funnel
|
||||||
|
func (module *module) Update(ctx context.Context, funnel *traceFunnels.StorableFunnel, userID valuer.UUID) error {
|
||||||
|
funnel.UpdatedBy = userID.String()
|
||||||
|
return module.store.Update(ctx, funnel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List lists all funnels for an organization
|
||||||
|
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.StorableFunnel, error) {
|
||||||
|
funnels, err := module.store.List(ctx, orgID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list funnels: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return funnels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes a funnel
|
||||||
|
func (module *module) Delete(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) error {
|
||||||
|
return module.store.Delete(ctx, funnelID, orgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFunnelMetadata gets metadata for a funnel
|
||||||
|
func (module *module) GetFunnelMetadata(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) (int64, int64, string, error) {
|
||||||
|
funnel, err := module.store.Get(ctx, funnelID, orgID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return funnel.CreatedAt.UnixNano() / 1000000, funnel.UpdatedAt.UnixNano() / 1000000, funnel.Description, nil
|
||||||
|
}
|
||||||
114
pkg/modules/tracefunnel/impltracefunnel/store.go
Normal file
114
pkg/modules/tracefunnel/impltracefunnel/store.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package impltracefunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/SigNoz/signoz/pkg/errors"
|
||||||
|
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||||
|
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunneltypes"
|
||||||
|
"github.com/SigNoz/signoz/pkg/valuer"
|
||||||
|
)
|
||||||
|
|
||||||
|
type store struct {
|
||||||
|
sqlstore sqlstore.SQLStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(sqlstore sqlstore.SQLStore) traceFunnels.FunnelStore {
|
||||||
|
return &store{sqlstore: sqlstore}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *store) Create(ctx context.Context, funnel *traceFunnels.StorableFunnel) error {
|
||||||
|
// Check if a funnel with the same name already exists in the organization
|
||||||
|
exists, err := store.
|
||||||
|
sqlstore.
|
||||||
|
BunDB().
|
||||||
|
NewSelect().
|
||||||
|
Model(new(traceFunnels.StorableFunnel)).
|
||||||
|
Where("name = ? AND org_id = ?", funnel.Name, funnel.OrgID.String()).
|
||||||
|
Exists(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to check for existing funnel")
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return store.sqlstore.WrapAlreadyExistsErrf(nil, traceFunnels.ErrFunnelAlreadyExists, "a funnel with name '%s' already exists in this organization", funnel.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = store.
|
||||||
|
sqlstore.
|
||||||
|
BunDB().
|
||||||
|
NewInsert().
|
||||||
|
Model(funnel).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to create funnels")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a funnel by ID
|
||||||
|
func (store *store) Get(ctx context.Context, uuid valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error) {
|
||||||
|
funnel := &traceFunnels.StorableFunnel{}
|
||||||
|
err := store.
|
||||||
|
sqlstore.
|
||||||
|
BunDB().
|
||||||
|
NewSelect().
|
||||||
|
Model(funnel).
|
||||||
|
Relation("CreatedByUser").
|
||||||
|
Where("?TableAlias.id = ? AND ?TableAlias.org_id = ?", uuid.String(), orgID.String()).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to get funnels")
|
||||||
|
}
|
||||||
|
return funnel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates an existing funnel
|
||||||
|
func (store *store) Update(ctx context.Context, funnel *traceFunnels.StorableFunnel) error {
|
||||||
|
funnel.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
_, err := store.
|
||||||
|
sqlstore.
|
||||||
|
BunDB().
|
||||||
|
NewUpdate().
|
||||||
|
Model(funnel).
|
||||||
|
WherePK().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return store.sqlstore.WrapAlreadyExistsErrf(err, traceFunnels.ErrFunnelAlreadyExists, "a funnel with name '%s' already exists in this organization", funnel.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List retrieves all funnels for a given organization
|
||||||
|
func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.StorableFunnel, error) {
|
||||||
|
var funnels []*traceFunnels.StorableFunnel
|
||||||
|
err := store.
|
||||||
|
sqlstore.
|
||||||
|
BunDB().
|
||||||
|
NewSelect().
|
||||||
|
Model(&funnels).
|
||||||
|
Relation("CreatedByUser").
|
||||||
|
Where("?TableAlias.org_id = ?", orgID.String()).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to list funnels")
|
||||||
|
}
|
||||||
|
return funnels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a funnel by ID
|
||||||
|
func (store *store) Delete(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) error {
|
||||||
|
_, err := store.
|
||||||
|
sqlstore.
|
||||||
|
BunDB().
|
||||||
|
NewDelete().
|
||||||
|
Model(new(traceFunnels.StorableFunnel)).
|
||||||
|
Where("id = ? AND org_id = ?", funnelID.String(), orgID.String()).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete funnel")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
471
pkg/modules/tracefunnel/query.go
Normal file
471
pkg/modules/tracefunnel/query.go
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
package tracefunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tracev4 "github.com/SigNoz/signoz/pkg/query-service/app/traces/v4"
|
||||||
|
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||||
|
"github.com/SigNoz/signoz/pkg/types/tracefunneltypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sanitizeClause adds AND prefix to non-empty clauses
|
||||||
|
func sanitizeClause(clause string) string {
|
||||||
|
if clause == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "AND " + clause
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateTraces(funnel *tracefunneltypes.StorableFunnel, timeRange tracefunneltypes.TimeRange) (*v3.ClickHouseQuery, error) {
|
||||||
|
var query string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
funnelSteps := funnel.Steps
|
||||||
|
containsErrorT1 := 0
|
||||||
|
containsErrorT2 := 0
|
||||||
|
containsErrorT3 := 0
|
||||||
|
|
||||||
|
if funnelSteps[0].HasErrors {
|
||||||
|
containsErrorT1 = 1
|
||||||
|
}
|
||||||
|
if funnelSteps[1].HasErrors {
|
||||||
|
containsErrorT2 = 1
|
||||||
|
}
|
||||||
|
if len(funnel.Steps) > 2 && funnelSteps[2].HasErrors {
|
||||||
|
containsErrorT3 = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build filter clauses for each step
|
||||||
|
clauseStep1, err := tracev4.BuildTracesFilter(funnelSteps[0].Filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
clauseStep2, err := tracev4.BuildTracesFilter(funnelSteps[1].Filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
clauseStep3 := ""
|
||||||
|
if len(funnel.Steps) > 2 {
|
||||||
|
clauseStep3, err = tracev4.BuildTracesFilter(funnelSteps[2].Filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize clauses
|
||||||
|
clauseStep1 = sanitizeClause(clauseStep1)
|
||||||
|
clauseStep2 = sanitizeClause(clauseStep2)
|
||||||
|
clauseStep3 = sanitizeClause(clauseStep3)
|
||||||
|
|
||||||
|
if len(funnel.Steps) > 2 {
|
||||||
|
query = BuildThreeStepFunnelValidationQuery(
|
||||||
|
containsErrorT1,
|
||||||
|
containsErrorT2,
|
||||||
|
containsErrorT3,
|
||||||
|
timeRange.StartTime,
|
||||||
|
timeRange.EndTime,
|
||||||
|
funnelSteps[0].ServiceName,
|
||||||
|
funnelSteps[0].SpanName,
|
||||||
|
funnelSteps[1].ServiceName,
|
||||||
|
funnelSteps[1].SpanName,
|
||||||
|
funnelSteps[2].ServiceName,
|
||||||
|
funnelSteps[2].SpanName,
|
||||||
|
clauseStep1,
|
||||||
|
clauseStep2,
|
||||||
|
clauseStep3,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
query = BuildTwoStepFunnelValidationQuery(
|
||||||
|
containsErrorT1,
|
||||||
|
containsErrorT2,
|
||||||
|
timeRange.StartTime,
|
||||||
|
timeRange.EndTime,
|
||||||
|
funnelSteps[0].ServiceName,
|
||||||
|
funnelSteps[0].SpanName,
|
||||||
|
funnelSteps[1].ServiceName,
|
||||||
|
funnelSteps[1].SpanName,
|
||||||
|
clauseStep1,
|
||||||
|
clauseStep2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &v3.ClickHouseQuery{
|
||||||
|
Query: query,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFunnelAnalytics(funnel *tracefunneltypes.StorableFunnel, timeRange tracefunneltypes.TimeRange) (*v3.ClickHouseQuery, error) {
|
||||||
|
var query string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
funnelSteps := funnel.Steps
|
||||||
|
containsErrorT1 := 0
|
||||||
|
containsErrorT2 := 0
|
||||||
|
containsErrorT3 := 0
|
||||||
|
latencyPointerT1 := funnelSteps[0].LatencyPointer
|
||||||
|
latencyPointerT2 := funnelSteps[1].LatencyPointer
|
||||||
|
latencyPointerT3 := "start"
|
||||||
|
if len(funnel.Steps) > 2 {
|
||||||
|
latencyPointerT3 = funnelSteps[2].LatencyPointer
|
||||||
|
}
|
||||||
|
|
||||||
|
if funnelSteps[0].HasErrors {
|
||||||
|
containsErrorT1 = 1
|
||||||
|
}
|
||||||
|
if funnelSteps[1].HasErrors {
|
||||||
|
containsErrorT2 = 1
|
||||||
|
}
|
||||||
|
if len(funnel.Steps) > 2 && funnelSteps[2].HasErrors {
|
||||||
|
containsErrorT3 = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build filter clauses for each step
|
||||||
|
clauseStep1, err := tracev4.BuildTracesFilter(funnelSteps[0].Filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
clauseStep2, err := tracev4.BuildTracesFilter(funnelSteps[1].Filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
clauseStep3 := ""
|
||||||
|
if len(funnel.Steps) > 2 {
|
||||||
|
clauseStep3, err = tracev4.BuildTracesFilter(funnelSteps[2].Filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize clauses
|
||||||
|
clauseStep1 = sanitizeClause(clauseStep1)
|
||||||
|
clauseStep2 = sanitizeClause(clauseStep2)
|
||||||
|
clauseStep3 = sanitizeClause(clauseStep3)
|
||||||
|
|
||||||
|
if len(funnel.Steps) > 2 {
|
||||||
|
query = BuildThreeStepFunnelOverviewQuery(
|
||||||
|
containsErrorT1,
|
||||||
|
containsErrorT2,
|
||||||
|
containsErrorT3,
|
||||||
|
latencyPointerT1,
|
||||||
|
latencyPointerT2,
|
||||||
|
latencyPointerT3,
|
||||||
|
timeRange.StartTime,
|
||||||
|
timeRange.EndTime,
|
||||||
|
funnelSteps[0].ServiceName,
|
||||||
|
funnelSteps[0].SpanName,
|
||||||
|
funnelSteps[1].ServiceName,
|
||||||
|
funnelSteps[1].SpanName,
|
||||||
|
funnelSteps[2].ServiceName,
|
||||||
|
funnelSteps[2].SpanName,
|
||||||
|
clauseStep1,
|
||||||
|
clauseStep2,
|
||||||
|
clauseStep3,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
query = BuildTwoStepFunnelOverviewQuery(
|
||||||
|
containsErrorT1,
|
||||||
|
containsErrorT2,
|
||||||
|
latencyPointerT1,
|
||||||
|
latencyPointerT2,
|
||||||
|
timeRange.StartTime,
|
||||||
|
timeRange.EndTime,
|
||||||
|
funnelSteps[0].ServiceName,
|
||||||
|
funnelSteps[0].SpanName,
|
||||||
|
funnelSteps[1].ServiceName,
|
||||||
|
funnelSteps[1].SpanName,
|
||||||
|
clauseStep1,
|
||||||
|
clauseStep2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return &v3.ClickHouseQuery{Query: query}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFunnelStepAnalytics(funnel *tracefunneltypes.StorableFunnel, timeRange tracefunneltypes.TimeRange, stepStart, stepEnd int64) (*v3.ClickHouseQuery, error) {
|
||||||
|
var query string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
funnelSteps := funnel.Steps
|
||||||
|
containsErrorT1 := 0
|
||||||
|
containsErrorT2 := 0
|
||||||
|
containsErrorT3 := 0
|
||||||
|
latencyPointerT1 := funnelSteps[0].LatencyPointer
|
||||||
|
latencyPointerT2 := funnelSteps[1].LatencyPointer
|
||||||
|
latencyPointerT3 := "start"
|
||||||
|
if len(funnel.Steps) > 2 {
|
||||||
|
latencyPointerT3 = funnelSteps[2].LatencyPointer
|
||||||
|
}
|
||||||
|
latencyTypeT2 := "0.99"
|
||||||
|
latencyTypeT3 := "0.99"
|
||||||
|
|
||||||
|
if stepStart == stepEnd {
|
||||||
|
return nil, fmt.Errorf("step start and end cannot be the same for /step/overview")
|
||||||
|
}
|
||||||
|
|
||||||
|
if funnelSteps[0].HasErrors {
|
||||||
|
containsErrorT1 = 1
|
||||||
|
}
|
||||||
|
if funnelSteps[1].HasErrors {
|
||||||
|
containsErrorT2 = 1
|
||||||
|
}
|
||||||
|
if len(funnel.Steps) > 2 && funnelSteps[2].HasErrors {
|
||||||
|
containsErrorT3 = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if funnelSteps[1].LatencyType != "" {
|
||||||
|
latency := strings.ToLower(funnelSteps[1].LatencyType)
|
||||||
|
if latency == "p90" {
|
||||||
|
latencyTypeT2 = "0.90"
|
||||||
|
} else if latency == "p95" {
|
||||||
|
latencyTypeT2 = "0.95"
|
||||||
|
} else {
|
||||||
|
latencyTypeT2 = "0.99"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(funnel.Steps) > 2 && funnelSteps[2].LatencyType != "" {
|
||||||
|
latency := strings.ToLower(funnelSteps[2].LatencyType)
|
||||||
|
if latency == "p90" {
|
||||||
|
latencyTypeT3 = "0.90"
|
||||||
|
} else if latency == "p95" {
|
||||||
|
latencyTypeT3 = "0.95"
|
||||||
|
} else {
|
||||||
|
latencyTypeT3 = "0.99"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build filter clauses for each step
|
||||||
|
clauseStep1, err := tracev4.BuildTracesFilter(funnelSteps[0].Filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
clauseStep2, err := tracev4.BuildTracesFilter(funnelSteps[1].Filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
clauseStep3 := ""
|
||||||
|
if len(funnel.Steps) > 2 {
|
||||||
|
clauseStep3, err = tracev4.BuildTracesFilter(funnelSteps[2].Filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize clauses
|
||||||
|
clauseStep1 = sanitizeClause(clauseStep1)
|
||||||
|
clauseStep2 = sanitizeClause(clauseStep2)
|
||||||
|
clauseStep3 = sanitizeClause(clauseStep3)
|
||||||
|
|
||||||
|
if len(funnel.Steps) > 2 {
|
||||||
|
query = BuildThreeStepFunnelStepOverviewQuery(
|
||||||
|
containsErrorT1,
|
||||||
|
containsErrorT2,
|
||||||
|
containsErrorT3,
|
||||||
|
latencyPointerT1,
|
||||||
|
latencyPointerT2,
|
||||||
|
latencyPointerT3,
|
||||||
|
timeRange.StartTime,
|
||||||
|
timeRange.EndTime,
|
||||||
|
funnelSteps[0].ServiceName,
|
||||||
|
funnelSteps[0].SpanName,
|
||||||
|
funnelSteps[1].ServiceName,
|
||||||
|
funnelSteps[1].SpanName,
|
||||||
|
funnelSteps[2].ServiceName,
|
||||||
|
funnelSteps[2].SpanName,
|
||||||
|
clauseStep1,
|
||||||
|
clauseStep2,
|
||||||
|
clauseStep3,
|
||||||
|
stepStart,
|
||||||
|
stepEnd,
|
||||||
|
latencyTypeT2,
|
||||||
|
latencyTypeT3,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
query = BuildTwoStepFunnelStepOverviewQuery(
|
||||||
|
containsErrorT1,
|
||||||
|
containsErrorT2,
|
||||||
|
latencyPointerT1,
|
||||||
|
latencyPointerT2,
|
||||||
|
timeRange.StartTime,
|
||||||
|
timeRange.EndTime,
|
||||||
|
funnelSteps[0].ServiceName,
|
||||||
|
funnelSteps[0].SpanName,
|
||||||
|
funnelSteps[1].ServiceName,
|
||||||
|
funnelSteps[1].SpanName,
|
||||||
|
clauseStep1,
|
||||||
|
clauseStep2,
|
||||||
|
latencyTypeT2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return &v3.ClickHouseQuery{Query: query}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetStepAnalytics(funnel *tracefunneltypes.StorableFunnel, timeRange tracefunneltypes.TimeRange) (*v3.ClickHouseQuery, error) {
|
||||||
|
var query string
|
||||||
|
|
||||||
|
funnelSteps := funnel.Steps
|
||||||
|
containsErrorT1 := 0
|
||||||
|
containsErrorT2 := 0
|
||||||
|
containsErrorT3 := 0
|
||||||
|
|
||||||
|
if funnelSteps[0].HasErrors {
|
||||||
|
containsErrorT1 = 1
|
||||||
|
}
|
||||||
|
if funnelSteps[1].HasErrors {
|
||||||
|
containsErrorT2 = 1
|
||||||
|
}
|
||||||
|
if len(funnel.Steps) > 2 && funnelSteps[2].HasErrors {
|
||||||
|
containsErrorT3 = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build filter clauses for each step
|
||||||
|
clauseStep1, err := tracev4.BuildTracesFilter(funnelSteps[0].Filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
clauseStep2, err := tracev4.BuildTracesFilter(funnelSteps[1].Filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
clauseStep3 := ""
|
||||||
|
if len(funnel.Steps) > 2 {
|
||||||
|
clauseStep3, err = tracev4.BuildTracesFilter(funnelSteps[2].Filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize clauses
|
||||||
|
clauseStep1 = sanitizeClause(clauseStep1)
|
||||||
|
clauseStep2 = sanitizeClause(clauseStep2)
|
||||||
|
clauseStep3 = sanitizeClause(clauseStep3)
|
||||||
|
|
||||||
|
if len(funnel.Steps) > 2 {
|
||||||
|
query = BuildThreeStepFunnelCountQuery(
|
||||||
|
containsErrorT1,
|
||||||
|
containsErrorT2,
|
||||||
|
containsErrorT3,
|
||||||
|
timeRange.StartTime,
|
||||||
|
timeRange.EndTime,
|
||||||
|
funnelSteps[0].ServiceName,
|
||||||
|
funnelSteps[0].SpanName,
|
||||||
|
funnelSteps[1].ServiceName,
|
||||||
|
funnelSteps[1].SpanName,
|
||||||
|
funnelSteps[2].ServiceName,
|
||||||
|
funnelSteps[2].SpanName,
|
||||||
|
clauseStep1,
|
||||||
|
clauseStep2,
|
||||||
|
clauseStep3,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
query = BuildTwoStepFunnelCountQuery(
|
||||||
|
containsErrorT1,
|
||||||
|
containsErrorT2,
|
||||||
|
timeRange.StartTime,
|
||||||
|
timeRange.EndTime,
|
||||||
|
funnelSteps[0].ServiceName,
|
||||||
|
funnelSteps[0].SpanName,
|
||||||
|
funnelSteps[1].ServiceName,
|
||||||
|
funnelSteps[1].SpanName,
|
||||||
|
clauseStep1,
|
||||||
|
clauseStep2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &v3.ClickHouseQuery{
|
||||||
|
Query: query,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSlowestTraces(funnel *tracefunneltypes.StorableFunnel, timeRange tracefunneltypes.TimeRange, stepStart, stepEnd int64) (*v3.ClickHouseQuery, error) {
|
||||||
|
funnelSteps := funnel.Steps
|
||||||
|
containsErrorT1 := 0
|
||||||
|
containsErrorT2 := 0
|
||||||
|
stepStartOrder := 0
|
||||||
|
stepEndOrder := 1
|
||||||
|
|
||||||
|
if stepStart != stepEnd {
|
||||||
|
stepStartOrder = int(stepStart) - 1
|
||||||
|
stepEndOrder = int(stepEnd) - 1
|
||||||
|
if funnelSteps[stepStartOrder].HasErrors {
|
||||||
|
containsErrorT1 = 1
|
||||||
|
}
|
||||||
|
if funnelSteps[stepEndOrder].HasErrors {
|
||||||
|
containsErrorT2 = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build filter clauses for the steps
|
||||||
|
clauseStep1, err := tracev4.BuildTracesFilter(funnelSteps[stepStartOrder].Filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
clauseStep2, err := tracev4.BuildTracesFilter(funnelSteps[stepEndOrder].Filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize clauses
|
||||||
|
clauseStep1 = sanitizeClause(clauseStep1)
|
||||||
|
clauseStep2 = sanitizeClause(clauseStep2)
|
||||||
|
|
||||||
|
query := BuildTwoStepFunnelTopSlowTracesQuery(
|
||||||
|
containsErrorT1,
|
||||||
|
containsErrorT2,
|
||||||
|
timeRange.StartTime,
|
||||||
|
timeRange.EndTime,
|
||||||
|
funnelSteps[stepStartOrder].ServiceName,
|
||||||
|
funnelSteps[stepStartOrder].SpanName,
|
||||||
|
funnelSteps[stepEndOrder].ServiceName,
|
||||||
|
funnelSteps[stepEndOrder].SpanName,
|
||||||
|
clauseStep1,
|
||||||
|
clauseStep2,
|
||||||
|
)
|
||||||
|
return &v3.ClickHouseQuery{Query: query}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetErroredTraces(funnel *tracefunneltypes.StorableFunnel, timeRange tracefunneltypes.TimeRange, stepStart, stepEnd int64) (*v3.ClickHouseQuery, error) {
|
||||||
|
funnelSteps := funnel.Steps
|
||||||
|
containsErrorT1 := 0
|
||||||
|
containsErrorT2 := 0
|
||||||
|
stepStartOrder := 0
|
||||||
|
stepEndOrder := 1
|
||||||
|
|
||||||
|
if stepStart != stepEnd {
|
||||||
|
stepStartOrder = int(stepStart) - 1
|
||||||
|
stepEndOrder = int(stepEnd) - 1
|
||||||
|
if funnelSteps[stepStartOrder].HasErrors {
|
||||||
|
containsErrorT1 = 1
|
||||||
|
}
|
||||||
|
if funnelSteps[stepEndOrder].HasErrors {
|
||||||
|
containsErrorT2 = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build filter clauses for the steps
|
||||||
|
clauseStep1, err := tracev4.BuildTracesFilter(funnelSteps[stepStartOrder].Filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
clauseStep2, err := tracev4.BuildTracesFilter(funnelSteps[stepEndOrder].Filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize clauses
|
||||||
|
clauseStep1 = sanitizeClause(clauseStep1)
|
||||||
|
clauseStep2 = sanitizeClause(clauseStep2)
|
||||||
|
|
||||||
|
query := BuildTwoStepFunnelTopSlowErrorTracesQuery(
|
||||||
|
containsErrorT1,
|
||||||
|
containsErrorT2,
|
||||||
|
timeRange.StartTime,
|
||||||
|
timeRange.EndTime,
|
||||||
|
funnelSteps[stepStartOrder].ServiceName,
|
||||||
|
funnelSteps[stepStartOrder].SpanName,
|
||||||
|
funnelSteps[stepEndOrder].ServiceName,
|
||||||
|
funnelSteps[stepEndOrder].SpanName,
|
||||||
|
clauseStep1,
|
||||||
|
clauseStep2,
|
||||||
|
)
|
||||||
|
return &v3.ClickHouseQuery{Query: query}, nil
|
||||||
|
}
|
||||||
38
pkg/modules/tracefunnel/tracefunnel.go
Normal file
38
pkg/modules/tracefunnel/tracefunnel.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package tracefunnel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/SigNoz/signoz/pkg/valuer"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunneltypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Module defines the interface for trace funnel operations
|
||||||
|
type Module interface {
|
||||||
|
Create(ctx context.Context, timestamp int64, name string, userID valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error)
|
||||||
|
|
||||||
|
Get(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error)
|
||||||
|
|
||||||
|
Update(ctx context.Context, funnel *traceFunnels.StorableFunnel, userID valuer.UUID) error
|
||||||
|
|
||||||
|
List(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.StorableFunnel, error)
|
||||||
|
|
||||||
|
Delete(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) error
|
||||||
|
|
||||||
|
GetFunnelMetadata(ctx context.Context, funnelID valuer.UUID, orgID valuer.UUID) (int64, int64, string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler interface {
|
||||||
|
New(http.ResponseWriter, *http.Request)
|
||||||
|
|
||||||
|
UpdateSteps(http.ResponseWriter, *http.Request)
|
||||||
|
|
||||||
|
UpdateFunnel(http.ResponseWriter, *http.Request)
|
||||||
|
|
||||||
|
List(http.ResponseWriter, *http.Request)
|
||||||
|
|
||||||
|
Get(http.ResponseWriter, *http.Request)
|
||||||
|
|
||||||
|
Delete(http.ResponseWriter, *http.Request)
|
||||||
|
}
|
||||||
183
pkg/modules/tracefunnel/tracefunneltest/module_test.go
Normal file
183
pkg/modules/tracefunnel/tracefunneltest/module_test.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package tracefunneltest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
|
||||||
|
"github.com/SigNoz/signoz/pkg/types"
|
||||||
|
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunneltypes"
|
||||||
|
"github.com/SigNoz/signoz/pkg/valuer"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MockStore struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockStore) Create(ctx context.Context, funnel *traceFunnels.StorableFunnel) error {
|
||||||
|
args := m.Called(ctx, funnel)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockStore) Get(ctx context.Context, uuid valuer.UUID, orgID valuer.UUID) (*traceFunnels.StorableFunnel, error) {
|
||||||
|
args := m.Called(ctx, uuid, orgID)
|
||||||
|
return args.Get(0).(*traceFunnels.StorableFunnel), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockStore) List(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.StorableFunnel, error) {
|
||||||
|
args := m.Called(ctx, orgID)
|
||||||
|
return args.Get(0).([]*traceFunnels.StorableFunnel), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockStore) Update(ctx context.Context, funnel *traceFunnels.StorableFunnel) error {
|
||||||
|
args := m.Called(ctx, funnel)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockStore) Delete(ctx context.Context, uuid valuer.UUID, orgID valuer.UUID) error {
|
||||||
|
args := m.Called(ctx, uuid, orgID)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModule_Create(t *testing.T) {
|
||||||
|
mockStore := new(MockStore)
|
||||||
|
module := impltracefunnel.NewModule(mockStore)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
timestamp := time.Now().UnixMilli()
|
||||||
|
name := "test-funnel"
|
||||||
|
userID := valuer.GenerateUUID()
|
||||||
|
orgID := valuer.GenerateUUID()
|
||||||
|
|
||||||
|
mockStore.On("Create", ctx, mock.MatchedBy(func(f *traceFunnels.StorableFunnel) bool {
|
||||||
|
return f.Name == name &&
|
||||||
|
f.CreatedBy == userID.String() &&
|
||||||
|
f.OrgID == orgID &&
|
||||||
|
f.CreatedByUser != nil &&
|
||||||
|
f.CreatedByUser.ID == userID &&
|
||||||
|
f.CreatedAt.UnixNano()/1000000 == timestamp
|
||||||
|
})).Return(nil)
|
||||||
|
|
||||||
|
funnel, err := module.Create(ctx, timestamp, name, userID, orgID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, funnel)
|
||||||
|
assert.Equal(t, name, funnel.Name)
|
||||||
|
assert.Equal(t, userID.String(), funnel.CreatedBy)
|
||||||
|
assert.Equal(t, orgID, funnel.OrgID)
|
||||||
|
assert.NotNil(t, funnel.CreatedByUser)
|
||||||
|
assert.Equal(t, userID, funnel.CreatedByUser.ID)
|
||||||
|
|
||||||
|
mockStore.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModule_Get(t *testing.T) {
|
||||||
|
mockStore := new(MockStore)
|
||||||
|
module := impltracefunnel.NewModule(mockStore)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
funnelID := valuer.GenerateUUID()
|
||||||
|
orgID := valuer.GenerateUUID()
|
||||||
|
expectedFunnel := &traceFunnels.StorableFunnel{
|
||||||
|
Name: "test-funnel",
|
||||||
|
}
|
||||||
|
|
||||||
|
mockStore.On("Get", ctx, funnelID, orgID).Return(expectedFunnel, nil)
|
||||||
|
|
||||||
|
funnel, err := module.Get(ctx, funnelID, orgID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedFunnel, funnel)
|
||||||
|
|
||||||
|
mockStore.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModule_Update(t *testing.T) {
|
||||||
|
mockStore := new(MockStore)
|
||||||
|
module := impltracefunnel.NewModule(mockStore)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
userID := valuer.GenerateUUID()
|
||||||
|
funnel := &traceFunnels.StorableFunnel{
|
||||||
|
Name: "test-funnel",
|
||||||
|
}
|
||||||
|
|
||||||
|
mockStore.On("Update", ctx, funnel).Return(nil)
|
||||||
|
|
||||||
|
err := module.Update(ctx, funnel, userID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, userID.String(), funnel.UpdatedBy)
|
||||||
|
|
||||||
|
mockStore.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModule_List(t *testing.T) {
|
||||||
|
mockStore := new(MockStore)
|
||||||
|
module := impltracefunnel.NewModule(mockStore)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
orgID := valuer.GenerateUUID()
|
||||||
|
expectedFunnels := []*traceFunnels.StorableFunnel{
|
||||||
|
{
|
||||||
|
Name: "funnel-1",
|
||||||
|
OrgID: orgID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "funnel-2",
|
||||||
|
OrgID: orgID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mockStore.On("List", ctx, orgID).Return(expectedFunnels, nil)
|
||||||
|
|
||||||
|
funnels, err := module.List(ctx, orgID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, funnels, 2)
|
||||||
|
assert.Equal(t, expectedFunnels, funnels)
|
||||||
|
|
||||||
|
mockStore.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModule_Delete(t *testing.T) {
|
||||||
|
mockStore := new(MockStore)
|
||||||
|
module := impltracefunnel.NewModule(mockStore)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
funnelID := valuer.GenerateUUID()
|
||||||
|
orgID := valuer.GenerateUUID()
|
||||||
|
|
||||||
|
mockStore.On("Delete", ctx, funnelID, orgID).Return(nil)
|
||||||
|
|
||||||
|
err := module.Delete(ctx, funnelID, orgID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
mockStore.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModule_GetFunnelMetadata(t *testing.T) {
|
||||||
|
mockStore := new(MockStore)
|
||||||
|
module := impltracefunnel.NewModule(mockStore)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
funnelID := valuer.GenerateUUID()
|
||||||
|
orgID := valuer.GenerateUUID()
|
||||||
|
now := time.Now()
|
||||||
|
expectedFunnel := &traceFunnels.StorableFunnel{
|
||||||
|
Description: "test description",
|
||||||
|
TimeAuditable: types.TimeAuditable{
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mockStore.On("Get", ctx, funnelID, orgID).Return(expectedFunnel, nil)
|
||||||
|
|
||||||
|
createdAt, updatedAt, description, err := module.GetFunnelMetadata(ctx, funnelID, orgID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, now.UnixNano()/1000000, createdAt)
|
||||||
|
assert.Equal(t, now.UnixNano()/1000000, updatedAt)
|
||||||
|
assert.Equal(t, "test description", description)
|
||||||
|
|
||||||
|
mockStore.AssertExpectations(t)
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ import (
|
|||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
|
||||||
"github.com/SigNoz/signoz/pkg/cache"
|
"github.com/SigNoz/signoz/pkg/cache"
|
||||||
|
traceFunnelsModule "github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||||
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
|
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
|
||||||
"github.com/SigNoz/signoz/pkg/query-service/app/inframetrics"
|
"github.com/SigNoz/signoz/pkg/query-service/app/inframetrics"
|
||||||
@@ -63,6 +64,7 @@ import (
|
|||||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||||
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
|
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
|
||||||
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
|
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||||
|
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunneltypes"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
@@ -5229,3 +5231,266 @@ func (aH *APIHandler) getDomainInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
aH.Respond(w, resp)
|
aH.Respond(w, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterTraceFunnelsRoutes adds trace funnels routes
|
||||||
|
func (aH *APIHandler) RegisterTraceFunnelsRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||||
|
// Main trace funnels router
|
||||||
|
traceFunnelsRouter := router.PathPrefix("/api/v1/trace-funnels").Subrouter()
|
||||||
|
|
||||||
|
// API endpoints
|
||||||
|
traceFunnelsRouter.HandleFunc("/new",
|
||||||
|
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.New)).
|
||||||
|
Methods(http.MethodPost)
|
||||||
|
traceFunnelsRouter.HandleFunc("/list",
|
||||||
|
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.List)).
|
||||||
|
Methods(http.MethodGet)
|
||||||
|
traceFunnelsRouter.HandleFunc("/steps/update",
|
||||||
|
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.UpdateSteps)).
|
||||||
|
Methods(http.MethodPut)
|
||||||
|
|
||||||
|
traceFunnelsRouter.HandleFunc("/{funnel_id}",
|
||||||
|
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.Get)).
|
||||||
|
Methods(http.MethodGet)
|
||||||
|
traceFunnelsRouter.HandleFunc("/{funnel_id}",
|
||||||
|
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.Delete)).
|
||||||
|
Methods(http.MethodDelete)
|
||||||
|
traceFunnelsRouter.HandleFunc("/{funnel_id}",
|
||||||
|
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.UpdateFunnel)).
|
||||||
|
Methods(http.MethodPut)
|
||||||
|
|
||||||
|
// Analytics endpoints
|
||||||
|
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/validate", aH.handleValidateTraces).Methods("POST")
|
||||||
|
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/overview", aH.handleFunnelAnalytics).Methods("POST")
|
||||||
|
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/steps", aH.handleStepAnalytics).Methods("POST")
|
||||||
|
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/steps/overview", aH.handleFunnelStepAnalytics).Methods("POST")
|
||||||
|
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/slow-traces", aH.handleFunnelSlowTraces).Methods("POST")
|
||||||
|
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/error-traces", aH.handleFunnelErrorTraces).Methods("POST")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aH *APIHandler) handleValidateTraces(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
funnelID := vars["funnel_id"]
|
||||||
|
|
||||||
|
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
render.Error(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), valuer.MustNewUUID(funnelID), valuer.MustNewUUID(claims.OrgID))
|
||||||
|
if err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeRange traceFunnels.TimeRange
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&timeRange); err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("error decoding time range: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(funnel.Steps) < 2 {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("funnel must have at least 2 steps")}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chq, err := traceFunnelsModule.ValidateTraces(funnel, timeRange)
|
||||||
|
if err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
|
||||||
|
if err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aH.Respond(w, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aH *APIHandler) handleFunnelAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
funnelID := vars["funnel_id"]
|
||||||
|
|
||||||
|
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
render.Error(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), valuer.MustNewUUID(funnelID), valuer.MustNewUUID(claims.OrgID))
|
||||||
|
if err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var stepTransition traceFunnels.StepTransitionRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&stepTransition); err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("error decoding time range: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chq, err := traceFunnelsModule.GetFunnelAnalytics(funnel, stepTransition.TimeRange)
|
||||||
|
if err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
|
||||||
|
if err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aH.Respond(w, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aH *APIHandler) handleFunnelStepAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
funnelID := vars["funnel_id"]
|
||||||
|
|
||||||
|
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
render.Error(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), valuer.MustNewUUID(funnelID), valuer.MustNewUUID(claims.OrgID))
|
||||||
|
if err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var stepTransition traceFunnels.StepTransitionRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&stepTransition); err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("error decoding time range: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chq, err := traceFunnelsModule.GetFunnelStepAnalytics(funnel, stepTransition.TimeRange, stepTransition.StepStart, stepTransition.StepEnd)
|
||||||
|
if err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
|
||||||
|
if err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aH.Respond(w, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aH *APIHandler) handleStepAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
funnelID := vars["funnel_id"]
|
||||||
|
|
||||||
|
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
render.Error(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), valuer.MustNewUUID(funnelID), valuer.MustNewUUID(claims.OrgID))
|
||||||
|
if err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeRange traceFunnels.TimeRange
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&timeRange); err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("error decoding time range: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chq, err := traceFunnelsModule.GetStepAnalytics(funnel, timeRange)
|
||||||
|
if err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
|
||||||
|
if err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aH.Respond(w, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aH *APIHandler) handleFunnelSlowTraces(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
funnelID := vars["funnel_id"]
|
||||||
|
|
||||||
|
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
render.Error(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), valuer.MustNewUUID(funnelID), valuer.MustNewUUID(claims.OrgID))
|
||||||
|
if err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req traceFunnels.StepTransitionRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("invalid request body: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chq, err := traceFunnelsModule.GetSlowestTraces(funnel, req.TimeRange, req.StepStart, req.StepEnd)
|
||||||
|
if err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
|
||||||
|
if err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aH.Respond(w, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aH *APIHandler) handleFunnelErrorTraces(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
funnelID := vars["funnel_id"]
|
||||||
|
|
||||||
|
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
render.Error(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), valuer.MustNewUUID(funnelID), valuer.MustNewUUID(claims.OrgID))
|
||||||
|
if err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req traceFunnels.StepTransitionRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("invalid request body: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chq, err := traceFunnelsModule.GetErroredTraces(funnel, req.TimeRange, req.StepStart, req.StepEnd)
|
||||||
|
if err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
|
||||||
|
if err != nil {
|
||||||
|
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aH.Respond(w, results)
|
||||||
|
}
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
|
|||||||
api.RegisterMessagingQueuesRoutes(r, am)
|
api.RegisterMessagingQueuesRoutes(r, am)
|
||||||
api.RegisterThirdPartyApiRoutes(r, am)
|
api.RegisterThirdPartyApiRoutes(r, am)
|
||||||
api.MetricExplorerRoutes(r, am)
|
api.MetricExplorerRoutes(r, am)
|
||||||
|
api.RegisterTraceFunnelsRoutes(r, am)
|
||||||
|
|
||||||
c := cors.New(cors.Options{
|
c := cors.New(cors.Options{
|
||||||
AllowedOrigins: []string{"*"},
|
AllowedOrigins: []string{"*"},
|
||||||
|
|||||||
@@ -87,7 +87,63 @@ func existsSubQueryForFixedColumn(key v3.AttributeKey, op v3.FilterOperator) (st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildTracesFilterQuery(fs *v3.FilterSet) (string, error) {
|
func BuildTracesFilter(fs *v3.FilterSet) (string, error) {
|
||||||
|
var conditions []string
|
||||||
|
|
||||||
|
if fs != nil && len(fs.Items) != 0 {
|
||||||
|
for _, item := range fs.Items {
|
||||||
|
|
||||||
|
val := item.Value
|
||||||
|
// generate the key
|
||||||
|
columnName := getColumnName(item.Key)
|
||||||
|
var fmtVal string
|
||||||
|
item.Operator = v3.FilterOperator(strings.ToLower(strings.TrimSpace(string(item.Operator))))
|
||||||
|
if item.Operator != v3.FilterOperatorExists && item.Operator != v3.FilterOperatorNotExists {
|
||||||
|
var err error
|
||||||
|
val, err = utils.ValidateAndCastValue(val, item.Key.DataType)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid value for key %s: %v", item.Key.Key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if val != nil {
|
||||||
|
fmtVal = utils.ClickHouseFormattedValue(val)
|
||||||
|
}
|
||||||
|
if operator, ok := tracesOperatorMappingV3[item.Operator]; ok {
|
||||||
|
switch item.Operator {
|
||||||
|
case v3.FilterOperatorContains, v3.FilterOperatorNotContains:
|
||||||
|
// we also want to treat %, _ as literals for contains
|
||||||
|
val := utils.QuoteEscapedStringForContains(fmt.Sprintf("%s", item.Value), false)
|
||||||
|
conditions = append(conditions, fmt.Sprintf("%s %s '%%%s%%'", columnName, operator, val))
|
||||||
|
case v3.FilterOperatorRegex, v3.FilterOperatorNotRegex:
|
||||||
|
conditions = append(conditions, fmt.Sprintf(operator, columnName, fmtVal))
|
||||||
|
case v3.FilterOperatorExists, v3.FilterOperatorNotExists:
|
||||||
|
if item.Key.IsColumn {
|
||||||
|
subQuery, err := existsSubQueryForFixedColumn(item.Key, item.Operator)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
conditions = append(conditions, subQuery)
|
||||||
|
} else {
|
||||||
|
cType := getClickHouseTracesColumnType(item.Key.Type)
|
||||||
|
cDataType := getClickHouseTracesColumnDataType(item.Key.DataType)
|
||||||
|
col := fmt.Sprintf("%s_%s", cType, cDataType)
|
||||||
|
conditions = append(conditions, fmt.Sprintf(operator, col, item.Key.Key))
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
conditions = append(conditions, fmt.Sprintf("%s %s %s", columnName, operator, fmtVal))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return "", fmt.Errorf("unsupported operator %s", item.Operator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queryString := strings.Join(conditions, " AND ")
|
||||||
|
|
||||||
|
return queryString, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildTracesFilterQuery(fs *v3.FilterSet) (string, error) {
|
||||||
var conditions []string
|
var conditions []string
|
||||||
|
|
||||||
if fs != nil && len(fs.Items) != 0 {
|
if fs != nil && len(fs.Items) != 0 {
|
||||||
@@ -167,7 +223,7 @@ func handleEmptyValuesInGroupBy(groupBy []v3.AttributeKey) (string, error) {
|
|||||||
Operator: "AND",
|
Operator: "AND",
|
||||||
Items: filterItems,
|
Items: filterItems,
|
||||||
}
|
}
|
||||||
return buildTracesFilterQuery(&filterSet)
|
return BuildTracesFilterQuery(&filterSet)
|
||||||
}
|
}
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@@ -248,7 +304,7 @@ func buildTracesQuery(start, end, step int64, mq *v3.BuilderQuery, panelType v3.
|
|||||||
|
|
||||||
timeFilter := fmt.Sprintf("(timestamp >= '%d' AND timestamp <= '%d') AND (ts_bucket_start >= %d AND ts_bucket_start <= %d)", tracesStart, tracesEnd, bucketStart, bucketEnd)
|
timeFilter := fmt.Sprintf("(timestamp >= '%d' AND timestamp <= '%d') AND (ts_bucket_start >= %d AND ts_bucket_start <= %d)", tracesStart, tracesEnd, bucketStart, bucketEnd)
|
||||||
|
|
||||||
filterSubQuery, err := buildTracesFilterQuery(mq.Filters)
|
filterSubQuery, err := BuildTracesFilterQuery(mq.Filters)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ func Test_buildTracesFilterQuery(t *testing.T) {
|
|||||||
want: "",
|
want: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Test buildTracesFilterQuery in, nin",
|
name: "Test BuildTracesFilterQuery in, nin",
|
||||||
args: args{
|
args: args{
|
||||||
fs: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
|
fs: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
|
||||||
{Key: v3.AttributeKey{Key: "method", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: []interface{}{"GET", "POST"}, Operator: v3.FilterOperatorIn},
|
{Key: v3.AttributeKey{Key: "method", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: []interface{}{"GET", "POST"}, Operator: v3.FilterOperatorIn},
|
||||||
@@ -226,7 +226,7 @@ func Test_buildTracesFilterQuery(t *testing.T) {
|
|||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Test buildTracesFilterQuery not eq, neq, gt, lt, gte, lte",
|
name: "Test BuildTracesFilterQuery not eq, neq, gt, lt, gte, lte",
|
||||||
args: args{
|
args: args{
|
||||||
fs: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
|
fs: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
|
||||||
{Key: v3.AttributeKey{Key: "duration", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag}, Value: 102, Operator: v3.FilterOperatorEqual},
|
{Key: v3.AttributeKey{Key: "duration", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag}, Value: 102, Operator: v3.FilterOperatorEqual},
|
||||||
@@ -274,13 +274,13 @@ func Test_buildTracesFilterQuery(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := buildTracesFilterQuery(tt.args.fs)
|
got, err := BuildTracesFilterQuery(tt.args.fs)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("buildTracesFilterQuery() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("BuildTracesFilterQuery() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if got != tt.want {
|
if got != tt.want {
|
||||||
t.Errorf("buildTracesFilterQuery() = %v, want %v", got, tt.want)
|
t.Errorf("BuildTracesFilterQuery() = %v, want %v", got, tt.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter/implquickfilter"
|
"github.com/SigNoz/signoz/pkg/modules/quickfilter/implquickfilter"
|
||||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||||
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview"
|
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview"
|
||||||
|
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||||
|
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
|
||||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||||
)
|
)
|
||||||
@@ -25,6 +27,7 @@ type Handlers struct {
|
|||||||
Apdex apdex.Handler
|
Apdex apdex.Handler
|
||||||
Dashboard dashboard.Handler
|
Dashboard dashboard.Handler
|
||||||
QuickFilter quickfilter.Handler
|
QuickFilter quickfilter.Handler
|
||||||
|
TraceFunnel tracefunnel.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandlers(modules Modules) Handlers {
|
func NewHandlers(modules Modules) Handlers {
|
||||||
@@ -36,5 +39,6 @@ func NewHandlers(modules Modules) Handlers {
|
|||||||
Apdex: implapdex.NewHandler(modules.Apdex),
|
Apdex: implapdex.NewHandler(modules.Apdex),
|
||||||
Dashboard: impldashboard.NewHandler(modules.Dashboard),
|
Dashboard: impldashboard.NewHandler(modules.Dashboard),
|
||||||
QuickFilter: implquickfilter.NewHandler(modules.QuickFilter),
|
QuickFilter: implquickfilter.NewHandler(modules.QuickFilter),
|
||||||
|
TraceFunnel: impltracefunnel.NewHandler(modules.TraceFunnel),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter/implquickfilter"
|
"github.com/SigNoz/signoz/pkg/modules/quickfilter/implquickfilter"
|
||||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||||
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview"
|
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview"
|
||||||
|
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||||
|
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
|
||||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||||
@@ -32,6 +34,7 @@ type Modules struct {
|
|||||||
Apdex apdex.Module
|
Apdex apdex.Module
|
||||||
Dashboard dashboard.Module
|
Dashboard dashboard.Module
|
||||||
QuickFilter quickfilter.Module
|
QuickFilter quickfilter.Module
|
||||||
|
TraceFunnel tracefunnel.Module
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModules(
|
func NewModules(
|
||||||
@@ -54,5 +57,6 @@ func NewModules(
|
|||||||
Dashboard: impldashboard.NewModule(sqlstore),
|
Dashboard: impldashboard.NewModule(sqlstore),
|
||||||
User: user,
|
User: user,
|
||||||
QuickFilter: quickfilter,
|
QuickFilter: quickfilter,
|
||||||
|
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ func NewSQLMigrationProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedM
|
|||||||
sqlmigration.NewMigratePATToFactorAPIKey(sqlstore),
|
sqlmigration.NewMigratePATToFactorAPIKey(sqlstore),
|
||||||
sqlmigration.NewUpdateApiMonitoringFiltersFactory(sqlstore),
|
sqlmigration.NewUpdateApiMonitoringFiltersFactory(sqlstore),
|
||||||
sqlmigration.NewAddKeyOrganizationFactory(sqlstore),
|
sqlmigration.NewAddKeyOrganizationFactory(sqlstore),
|
||||||
|
sqlmigration.NewAddTraceFunnelsFactory(sqlstore),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
89
pkg/sqlmigration/037_add_trace_funnels.go
Normal file
89
pkg/sqlmigration/037_add_trace_funnels.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package sqlmigration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/SigNoz/signoz/pkg/factory"
|
||||||
|
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||||
|
"github.com/SigNoz/signoz/pkg/types"
|
||||||
|
"github.com/SigNoz/signoz/pkg/valuer"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
"github.com/uptrace/bun/migrate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// funnel Core Data Structure (funnel and funnelStep)
|
||||||
|
type funnel struct {
|
||||||
|
bun.BaseModel `bun:"table:trace_funnel"`
|
||||||
|
types.Identifiable // funnel id
|
||||||
|
types.TimeAuditable
|
||||||
|
types.UserAuditable
|
||||||
|
Name string `json:"funnel_name" bun:"name,type:text,notnull"` // funnel name
|
||||||
|
Description string `json:"description" bun:"description,type:text"` // funnel description
|
||||||
|
OrgID valuer.UUID `json:"org_id" bun:"org_id,type:varchar,notnull"`
|
||||||
|
Steps []funnelStep `json:"steps" bun:"steps,type:text,notnull"`
|
||||||
|
Tags string `json:"tags" bun:"tags,type:text"`
|
||||||
|
CreatedByUser *types.User `json:"user" bun:"rel:belongs-to,join:created_by=id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type funnelStep struct {
|
||||||
|
types.Identifiable
|
||||||
|
Name string `json:"name,omitempty"` // step name
|
||||||
|
Description string `json:"description,omitempty"` // step description
|
||||||
|
Order int64 `json:"step_order"`
|
||||||
|
ServiceName string `json:"service_name"`
|
||||||
|
SpanName string `json:"span_name"`
|
||||||
|
Filters string `json:"filters,omitempty"`
|
||||||
|
LatencyPointer string `json:"latency_pointer,omitempty"`
|
||||||
|
LatencyType string `json:"latency_type,omitempty"`
|
||||||
|
HasErrors bool `json:"has_errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type addTraceFunnels struct {
|
||||||
|
sqlstore sqlstore.SQLStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAddTraceFunnelsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||||
|
return factory.NewProviderFactory(factory.MustNewName("add_trace_funnels"), func(ctx context.Context, providerSettings factory.ProviderSettings, config Config) (SQLMigration, error) {
|
||||||
|
return newAddTraceFunnels(ctx, providerSettings, config, sqlstore)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAddTraceFunnels(_ context.Context, _ factory.ProviderSettings, _ Config, sqlstore sqlstore.SQLStore) (SQLMigration, error) {
|
||||||
|
return &addTraceFunnels{sqlstore: sqlstore}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (migration *addTraceFunnels) Register(migrations *migrate.Migrations) error {
|
||||||
|
if err := migrations.Register(migration.Up, migration.Down); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (migration *addTraceFunnels) Up(ctx context.Context, db *bun.DB) error {
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, err = tx.NewCreateTable().
|
||||||
|
Model(new(funnel)).
|
||||||
|
ForeignKey(`("org_id") REFERENCES "organizations" ("id") ON DELETE CASCADE`).
|
||||||
|
IfNotExists().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (migration *addTraceFunnels) Down(ctx context.Context, db *bun.DB) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
15
pkg/types/tracefunneltypes/store.go
Normal file
15
pkg/types/tracefunneltypes/store.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package tracefunneltypes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/SigNoz/signoz/pkg/valuer"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FunnelStore interface {
|
||||||
|
Create(context.Context, *StorableFunnel) error
|
||||||
|
Get(context.Context, valuer.UUID, valuer.UUID) (*StorableFunnel, error)
|
||||||
|
List(context.Context, valuer.UUID) ([]*StorableFunnel, error)
|
||||||
|
Update(context.Context, *StorableFunnel) error
|
||||||
|
Delete(context.Context, valuer.UUID, valuer.UUID) error
|
||||||
|
}
|
||||||
98
pkg/types/tracefunneltypes/tracefunnel.go
Normal file
98
pkg/types/tracefunneltypes/tracefunnel.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package tracefunneltypes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/SigNoz/signoz/pkg/errors"
|
||||||
|
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||||
|
"github.com/SigNoz/signoz/pkg/types"
|
||||||
|
"github.com/SigNoz/signoz/pkg/valuer"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrFunnelAlreadyExists = errors.MustNewCode("funnel_already_exists")
|
||||||
|
)
|
||||||
|
|
||||||
|
// StorableFunnel Core Data Structure (StorableFunnel and FunnelStep)
|
||||||
|
type StorableFunnel struct {
|
||||||
|
types.Identifiable
|
||||||
|
types.TimeAuditable
|
||||||
|
types.UserAuditable
|
||||||
|
bun.BaseModel `bun:"table:trace_funnel"`
|
||||||
|
Name string `json:"funnel_name" bun:"name,type:text,notnull"`
|
||||||
|
Description string `json:"description" bun:"description,type:text"`
|
||||||
|
OrgID valuer.UUID `json:"org_id" bun:"org_id,type:varchar,notnull"`
|
||||||
|
Steps []*FunnelStep `json:"steps" bun:"steps,type:text,notnull"`
|
||||||
|
Tags string `json:"tags" bun:"tags,type:text"`
|
||||||
|
CreatedByUser *types.User `json:"user" bun:"rel:belongs-to,join:created_by=id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FunnelStep struct {
|
||||||
|
ID valuer.UUID `json:"id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"` // step name
|
||||||
|
Description string `json:"description,omitempty"` // step description
|
||||||
|
Order int64 `json:"step_order"`
|
||||||
|
ServiceName string `json:"service_name"`
|
||||||
|
SpanName string `json:"span_name"`
|
||||||
|
Filters *v3.FilterSet `json:"filters,omitempty"`
|
||||||
|
LatencyPointer string `json:"latency_pointer,omitempty"`
|
||||||
|
LatencyType string `json:"latency_type,omitempty"`
|
||||||
|
HasErrors bool `json:"has_errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostableFunnel represents all possible funnel-related requests
|
||||||
|
type PostableFunnel struct {
|
||||||
|
FunnelID valuer.UUID `json:"funnel_id,omitempty"`
|
||||||
|
Name string `json:"funnel_name,omitempty"`
|
||||||
|
Timestamp int64 `json:"timestamp,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Steps []*FunnelStep `json:"steps,omitempty"`
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
|
||||||
|
// Analytics specific fields
|
||||||
|
StartTime int64 `json:"start_time,omitempty"`
|
||||||
|
EndTime int64 `json:"end_time,omitempty"`
|
||||||
|
StepAOrder int64 `json:"step_a_order,omitempty"`
|
||||||
|
StepBOrder int64 `json:"step_b_order,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GettableFunnel represents all possible funnel-related responses
|
||||||
|
type GettableFunnel struct {
|
||||||
|
FunnelID string `json:"funnel_id,omitempty"`
|
||||||
|
FunnelName string `json:"funnel_name,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at,omitempty"`
|
||||||
|
CreatedBy string `json:"created_by,omitempty"`
|
||||||
|
UpdatedAt int64 `json:"updated_at,omitempty"`
|
||||||
|
UpdatedBy string `json:"updated_by,omitempty"`
|
||||||
|
OrgID string `json:"org_id,omitempty"`
|
||||||
|
UserEmail string `json:"user_email,omitempty"`
|
||||||
|
Funnel *StorableFunnel `json:"funnel,omitempty"`
|
||||||
|
Steps []*FunnelStep `json:"steps,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeRange represents a time range for analytics
|
||||||
|
type TimeRange struct {
|
||||||
|
StartTime int64 `json:"start_time"`
|
||||||
|
EndTime int64 `json:"end_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepTransitionRequest represents a request for step transition analytics
|
||||||
|
type StepTransitionRequest struct {
|
||||||
|
TimeRange
|
||||||
|
StepStart int64 `json:"step_start,omitempty"`
|
||||||
|
StepEnd int64 `json:"step_end,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserInfo represents basic user information
|
||||||
|
type UserInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FunnelStepFilter struct {
|
||||||
|
StepNumber int
|
||||||
|
ServiceName string
|
||||||
|
SpanName string
|
||||||
|
LatencyPointer string // "start" or "end"
|
||||||
|
CustomFilters *v3.FilterSet
|
||||||
|
}
|
||||||
139
pkg/types/tracefunneltypes/utils.go
Normal file
139
pkg/types/tracefunneltypes/utils.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package tracefunneltypes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/SigNoz/signoz/pkg/errors"
|
||||||
|
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||||
|
"github.com/SigNoz/signoz/pkg/valuer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateTimestamp validates a timestamp
|
||||||
|
func ValidateTimestamp(timestamp int64, fieldName string) error {
|
||||||
|
if timestamp == 0 {
|
||||||
|
return fmt.Errorf("%s is required", fieldName)
|
||||||
|
}
|
||||||
|
if timestamp < 0 {
|
||||||
|
return fmt.Errorf("%s must be positive", fieldName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateTimestampIsMilliseconds validates that a timestamp is in milliseconds
|
||||||
|
func ValidateTimestampIsMilliseconds(timestamp int64) bool {
|
||||||
|
return timestamp >= 1000000000000 && timestamp <= 9999999999999
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateFunnelSteps(steps []*FunnelStep) error {
|
||||||
|
if len(steps) < 2 {
|
||||||
|
return fmt.Errorf("funnel must have at least 2 steps")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, step := range steps {
|
||||||
|
if step.ServiceName == "" {
|
||||||
|
return fmt.Errorf("step %d: service name is required", i+1)
|
||||||
|
}
|
||||||
|
if step.SpanName == "" {
|
||||||
|
return fmt.Errorf("step %d: span name is required", i+1)
|
||||||
|
}
|
||||||
|
if step.Order < 0 {
|
||||||
|
return fmt.Errorf("step %d: order must be non-negative", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeFunnelSteps normalizes step orders to be sequential starting from 1.
|
||||||
|
// The function takes a slice of pointers to FunnelStep and returns a new slice with normalized step orders.
|
||||||
|
// The input slice is left unchanged. If the input slice contains nil pointers, they will be filtered out.
|
||||||
|
// Returns an empty slice if the input is empty or contains only nil pointers.
|
||||||
|
func NormalizeFunnelSteps(steps []*FunnelStep) []*FunnelStep {
|
||||||
|
if len(steps) == 0 {
|
||||||
|
return []*FunnelStep{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out nil pointers and create a new slice
|
||||||
|
validSteps := make([]*FunnelStep, 0, len(steps))
|
||||||
|
for _, step := range steps {
|
||||||
|
if step != nil {
|
||||||
|
validSteps = append(validSteps, step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(validSteps) == 0 {
|
||||||
|
return []*FunnelStep{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a defensive copy of the valid steps
|
||||||
|
newSteps := make([]*FunnelStep, len(validSteps))
|
||||||
|
for i, step := range validSteps {
|
||||||
|
// Create a copy of each step to avoid modifying the original
|
||||||
|
stepCopy := *step
|
||||||
|
newSteps[i] = &stepCopy
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(newSteps, func(i, j int) bool {
|
||||||
|
return newSteps[i].Order < newSteps[j].Order
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := range newSteps {
|
||||||
|
newSteps[i].Order = int64(i + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newSteps
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateAndConvertTimestamp(timestamp int64) (time.Time, error) {
|
||||||
|
if err := ValidateTimestamp(timestamp, "timestamp"); err != nil {
|
||||||
|
return time.Time{}, errors.Newf(errors.TypeInvalidInput,
|
||||||
|
errors.CodeInvalidInput,
|
||||||
|
"timestamp is invalid: %v", err)
|
||||||
|
}
|
||||||
|
return time.Unix(0, timestamp*1000000), nil // Convert to nanoseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConstructFunnelResponse(funnel *StorableFunnel, claims *authtypes.Claims) GettableFunnel {
|
||||||
|
resp := GettableFunnel{
|
||||||
|
FunnelName: funnel.Name,
|
||||||
|
FunnelID: funnel.ID.String(),
|
||||||
|
Steps: funnel.Steps,
|
||||||
|
CreatedAt: funnel.CreatedAt.UnixNano() / 1000000,
|
||||||
|
CreatedBy: funnel.CreatedBy,
|
||||||
|
OrgID: funnel.OrgID.String(),
|
||||||
|
UpdatedBy: funnel.UpdatedBy,
|
||||||
|
UpdatedAt: funnel.UpdatedAt.UnixNano() / 1000000,
|
||||||
|
Description: funnel.Description,
|
||||||
|
}
|
||||||
|
|
||||||
|
if funnel.CreatedByUser != nil {
|
||||||
|
resp.UserEmail = funnel.CreatedByUser.Email
|
||||||
|
} else if claims != nil {
|
||||||
|
resp.UserEmail = claims.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProcessFunnelSteps(steps []*FunnelStep) ([]*FunnelStep, error) {
|
||||||
|
// First validate the steps
|
||||||
|
if err := ValidateFunnelSteps(steps); err != nil {
|
||||||
|
return nil, errors.Newf(errors.TypeInvalidInput,
|
||||||
|
errors.CodeInvalidInput,
|
||||||
|
"invalid funnel steps: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then process the steps
|
||||||
|
for i := range steps {
|
||||||
|
if steps[i].Order < 1 {
|
||||||
|
steps[i].Order = int64(i + 1)
|
||||||
|
}
|
||||||
|
if steps[i].ID.IsZero() {
|
||||||
|
steps[i].ID = valuer.GenerateUUID()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NormalizeFunnelSteps(steps), nil
|
||||||
|
}
|
||||||
698
pkg/types/tracefunneltypes/utils_test.go
Normal file
698
pkg/types/tracefunneltypes/utils_test.go
Normal file
@@ -0,0 +1,698 @@
|
|||||||
|
package tracefunneltypes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/SigNoz/signoz/pkg/types"
|
||||||
|
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||||
|
"github.com/SigNoz/signoz/pkg/valuer"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateTimestamp(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
timestamp int64
|
||||||
|
fieldName string
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid timestamp",
|
||||||
|
timestamp: time.Now().UnixMilli(),
|
||||||
|
fieldName: "timestamp",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero timestamp",
|
||||||
|
timestamp: 0,
|
||||||
|
fieldName: "timestamp",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative timestamp",
|
||||||
|
timestamp: -1,
|
||||||
|
fieldName: "timestamp",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateTimestamp(tt.timestamp, tt.fieldName)
|
||||||
|
if tt.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTimestampIsMilliseconds(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
timestamp int64
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid millisecond timestamp",
|
||||||
|
timestamp: 1700000000000, // 2023-11-14 12:00:00 UTC
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "too small timestamp",
|
||||||
|
timestamp: 999999999999,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "too large timestamp",
|
||||||
|
timestamp: 10000000000000,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "second precision timestamp",
|
||||||
|
timestamp: 1700000000,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := ValidateTimestampIsMilliseconds(tt.timestamp)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFunnelSteps(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
steps []*FunnelStep
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid steps",
|
||||||
|
steps: []*FunnelStep{
|
||||||
|
{
|
||||||
|
ID: valuer.GenerateUUID(),
|
||||||
|
Name: "Step 1",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span",
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: valuer.GenerateUUID(),
|
||||||
|
Name: "Step 2",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span-2",
|
||||||
|
Order: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "too few steps",
|
||||||
|
steps: []*FunnelStep{
|
||||||
|
{
|
||||||
|
ID: valuer.GenerateUUID(),
|
||||||
|
Name: "Step 1",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span",
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing service name",
|
||||||
|
steps: []*FunnelStep{
|
||||||
|
{
|
||||||
|
ID: valuer.GenerateUUID(),
|
||||||
|
Name: "Step 1",
|
||||||
|
SpanName: "test-span",
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: valuer.GenerateUUID(),
|
||||||
|
Name: "Step 2",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span-2",
|
||||||
|
Order: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing span name",
|
||||||
|
steps: []*FunnelStep{
|
||||||
|
{
|
||||||
|
ID: valuer.GenerateUUID(),
|
||||||
|
Name: "Step 1",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: valuer.GenerateUUID(),
|
||||||
|
Name: "Step 2",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span-2",
|
||||||
|
Order: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative order",
|
||||||
|
steps: []*FunnelStep{
|
||||||
|
{
|
||||||
|
ID: valuer.GenerateUUID(),
|
||||||
|
Name: "Step 1",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span",
|
||||||
|
Order: -1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: valuer.GenerateUUID(),
|
||||||
|
Name: "Step 2",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span-2",
|
||||||
|
Order: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateFunnelSteps(tt.steps)
|
||||||
|
if tt.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeFunnelSteps(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
steps []*FunnelStep
|
||||||
|
expected []*FunnelStep
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "already normalized steps",
|
||||||
|
steps: []*FunnelStep{
|
||||||
|
{
|
||||||
|
ID: valuer.GenerateUUID(),
|
||||||
|
Name: "Step 1",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span",
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: valuer.GenerateUUID(),
|
||||||
|
Name: "Step 2",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span-2",
|
||||||
|
Order: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: []*FunnelStep{
|
||||||
|
{
|
||||||
|
Name: "Step 1",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span",
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Step 2",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span-2",
|
||||||
|
Order: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unordered steps",
|
||||||
|
steps: []*FunnelStep{
|
||||||
|
{
|
||||||
|
ID: valuer.GenerateUUID(),
|
||||||
|
Name: "Step 2",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span-2",
|
||||||
|
Order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: valuer.GenerateUUID(),
|
||||||
|
Name: "Step 1",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span",
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: []*FunnelStep{
|
||||||
|
{
|
||||||
|
Name: "Step 1",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span",
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Step 2",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span-2",
|
||||||
|
Order: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "steps with gaps in order",
|
||||||
|
steps: []*FunnelStep{
|
||||||
|
{
|
||||||
|
ID: valuer.GenerateUUID(),
|
||||||
|
Name: "Step 1",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span",
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: valuer.GenerateUUID(),
|
||||||
|
Name: "Step 3",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span-3",
|
||||||
|
Order: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: valuer.GenerateUUID(),
|
||||||
|
Name: "Step 2",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span-2",
|
||||||
|
Order: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: []*FunnelStep{
|
||||||
|
{
|
||||||
|
Name: "Step 1",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span",
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Step 2",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span-2",
|
||||||
|
Order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Step 3",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span-3",
|
||||||
|
Order: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "steps with nil pointers",
|
||||||
|
steps: []*FunnelStep{
|
||||||
|
{
|
||||||
|
ID: valuer.GenerateUUID(),
|
||||||
|
Name: "Step 1",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span",
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
{
|
||||||
|
ID: valuer.GenerateUUID(),
|
||||||
|
Name: "Step 2",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span-2",
|
||||||
|
Order: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: []*FunnelStep{
|
||||||
|
{
|
||||||
|
Name: "Step 1",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span",
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Step 2",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span-2",
|
||||||
|
Order: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty steps",
|
||||||
|
steps: []*FunnelStep{},
|
||||||
|
expected: []*FunnelStep{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all nil steps",
|
||||||
|
steps: []*FunnelStep{nil, nil},
|
||||||
|
expected: []*FunnelStep{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := NormalizeFunnelSteps(tt.steps)
|
||||||
|
|
||||||
|
// Compare only the relevant fields
|
||||||
|
assert.Len(t, result, len(tt.expected))
|
||||||
|
for i := range result {
|
||||||
|
assert.Equal(t, tt.expected[i].Name, result[i].Name)
|
||||||
|
assert.Equal(t, tt.expected[i].ServiceName, result[i].ServiceName)
|
||||||
|
assert.Equal(t, tt.expected[i].SpanName, result[i].SpanName)
|
||||||
|
assert.Equal(t, tt.expected[i].Order, result[i].Order)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetClaims(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup func(*http.Request)
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid claims",
|
||||||
|
setup: func(r *http.Request) {
|
||||||
|
claims := authtypes.Claims{
|
||||||
|
UserID: "user-123",
|
||||||
|
OrgID: "org-123",
|
||||||
|
Email: "test@example.com",
|
||||||
|
}
|
||||||
|
*r = *r.WithContext(authtypes.NewContextWithClaims(r.Context(), claims))
|
||||||
|
},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no claims in context",
|
||||||
|
setup: func(r *http.Request) {
|
||||||
|
claims := authtypes.Claims{}
|
||||||
|
*r = *r.WithContext(authtypes.NewContextWithClaims(r.Context(), claims))
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
|
tt.setup(req)
|
||||||
|
|
||||||
|
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||||
|
if tt.expectError {
|
||||||
|
assert.Equal(t, authtypes.Claims{}, claims)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, claims)
|
||||||
|
assert.Equal(t, "user-123", claims.UserID)
|
||||||
|
assert.Equal(t, "org-123", claims.OrgID)
|
||||||
|
assert.Equal(t, "test@example.com", claims.Email)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAndConvertTimestamp(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
timestamp int64
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid timestamp",
|
||||||
|
timestamp: time.Now().UnixMilli(),
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero timestamp",
|
||||||
|
timestamp: 0,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative timestamp",
|
||||||
|
timestamp: -1,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := ValidateAndConvertTimestamp(tt.timestamp)
|
||||||
|
if tt.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.True(t, result.IsZero())
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, result.IsZero())
|
||||||
|
// Verify the conversion from milliseconds to nanoseconds
|
||||||
|
assert.Equal(t, tt.timestamp*1000000, result.UnixNano())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConstructFunnelResponse(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
funnelID := valuer.GenerateUUID()
|
||||||
|
orgID := valuer.GenerateUUID()
|
||||||
|
userID := valuer.GenerateUUID()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
funnel *StorableFunnel
|
||||||
|
claims *authtypes.Claims
|
||||||
|
expected GettableFunnel
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "with user email from funnel",
|
||||||
|
funnel: &StorableFunnel{
|
||||||
|
Identifiable: types.Identifiable{
|
||||||
|
ID: funnelID,
|
||||||
|
},
|
||||||
|
TimeAuditable: types.TimeAuditable{
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
},
|
||||||
|
UserAuditable: types.UserAuditable{
|
||||||
|
CreatedBy: userID.String(),
|
||||||
|
UpdatedBy: userID.String(),
|
||||||
|
},
|
||||||
|
Name: "test-funnel",
|
||||||
|
OrgID: orgID,
|
||||||
|
CreatedByUser: &types.User{
|
||||||
|
Identifiable: types.Identifiable{
|
||||||
|
ID: userID,
|
||||||
|
},
|
||||||
|
Email: "funnel@example.com",
|
||||||
|
},
|
||||||
|
Steps: []*FunnelStep{
|
||||||
|
{
|
||||||
|
ID: valuer.GenerateUUID(),
|
||||||
|
Name: "Step 1",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span",
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
claims: &authtypes.Claims{
|
||||||
|
UserID: userID.String(),
|
||||||
|
OrgID: orgID.String(),
|
||||||
|
Email: "claims@example.com",
|
||||||
|
},
|
||||||
|
expected: GettableFunnel{
|
||||||
|
FunnelName: "test-funnel",
|
||||||
|
FunnelID: funnelID.String(),
|
||||||
|
Steps: []*FunnelStep{
|
||||||
|
{
|
||||||
|
Name: "Step 1",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span",
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CreatedAt: now.UnixNano() / 1000000,
|
||||||
|
CreatedBy: userID.String(),
|
||||||
|
UpdatedAt: now.UnixNano() / 1000000,
|
||||||
|
UpdatedBy: userID.String(),
|
||||||
|
OrgID: orgID.String(),
|
||||||
|
UserEmail: "funnel@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with user email from claims",
|
||||||
|
funnel: &StorableFunnel{
|
||||||
|
Identifiable: types.Identifiable{
|
||||||
|
ID: funnelID,
|
||||||
|
},
|
||||||
|
TimeAuditable: types.TimeAuditable{
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
},
|
||||||
|
UserAuditable: types.UserAuditable{
|
||||||
|
CreatedBy: userID.String(),
|
||||||
|
UpdatedBy: userID.String(),
|
||||||
|
},
|
||||||
|
Name: "test-funnel",
|
||||||
|
OrgID: orgID,
|
||||||
|
Steps: []*FunnelStep{
|
||||||
|
{
|
||||||
|
ID: valuer.GenerateUUID(),
|
||||||
|
Name: "Step 1",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span",
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
claims: &authtypes.Claims{
|
||||||
|
UserID: userID.String(),
|
||||||
|
OrgID: orgID.String(),
|
||||||
|
Email: "claims@example.com",
|
||||||
|
},
|
||||||
|
expected: GettableFunnel{
|
||||||
|
FunnelName: "test-funnel",
|
||||||
|
FunnelID: funnelID.String(),
|
||||||
|
Steps: []*FunnelStep{
|
||||||
|
{
|
||||||
|
Name: "Step 1",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span",
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CreatedAt: now.UnixNano() / 1000000,
|
||||||
|
CreatedBy: userID.String(),
|
||||||
|
UpdatedAt: now.UnixNano() / 1000000,
|
||||||
|
UpdatedBy: userID.String(),
|
||||||
|
OrgID: orgID.String(),
|
||||||
|
UserEmail: "claims@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := ConstructFunnelResponse(tt.funnel, tt.claims)
|
||||||
|
|
||||||
|
// Compare top-level fields
|
||||||
|
assert.Equal(t, tt.expected.FunnelName, result.FunnelName)
|
||||||
|
assert.Equal(t, tt.expected.FunnelID, result.FunnelID)
|
||||||
|
assert.Equal(t, tt.expected.CreatedAt, result.CreatedAt)
|
||||||
|
assert.Equal(t, tt.expected.CreatedBy, result.CreatedBy)
|
||||||
|
assert.Equal(t, tt.expected.UpdatedAt, result.UpdatedAt)
|
||||||
|
assert.Equal(t, tt.expected.UpdatedBy, result.UpdatedBy)
|
||||||
|
assert.Equal(t, tt.expected.OrgID, result.OrgID)
|
||||||
|
assert.Equal(t, tt.expected.UserEmail, result.UserEmail)
|
||||||
|
|
||||||
|
// Compare steps
|
||||||
|
assert.Len(t, result.Steps, len(tt.expected.Steps))
|
||||||
|
for i, step := range result.Steps {
|
||||||
|
expectedStep := tt.expected.Steps[i]
|
||||||
|
assert.Equal(t, expectedStep.Name, step.Name)
|
||||||
|
assert.Equal(t, expectedStep.ServiceName, step.ServiceName)
|
||||||
|
assert.Equal(t, expectedStep.SpanName, step.SpanName)
|
||||||
|
assert.Equal(t, expectedStep.Order, step.Order)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessFunnelSteps(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
steps []*FunnelStep
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid steps with missing IDs",
|
||||||
|
steps: []*FunnelStep{
|
||||||
|
{
|
||||||
|
Name: "Step 1",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span",
|
||||||
|
Order: 0, // Will be normalized to 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Step 2",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span-2",
|
||||||
|
Order: 0, // Will be normalized to 2
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid steps - missing service name",
|
||||||
|
steps: []*FunnelStep{
|
||||||
|
{
|
||||||
|
Name: "Step 1",
|
||||||
|
SpanName: "test-span",
|
||||||
|
Order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Step 2",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span-2",
|
||||||
|
Order: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid steps - negative order",
|
||||||
|
steps: []*FunnelStep{
|
||||||
|
{
|
||||||
|
Name: "Step 1",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span",
|
||||||
|
Order: -1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Step 2",
|
||||||
|
ServiceName: "test-service",
|
||||||
|
SpanName: "test-span-2",
|
||||||
|
Order: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := ProcessFunnelSteps(tt.steps)
|
||||||
|
if tt.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.Len(t, result, len(tt.steps))
|
||||||
|
|
||||||
|
// Verify IDs are generated
|
||||||
|
for _, step := range result {
|
||||||
|
assert.False(t, step.ID.IsZero())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify orders are normalized
|
||||||
|
for i, step := range result {
|
||||||
|
assert.Equal(t, int64(i+1), step.Order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user