Compare commits

..

6 Commits

Author SHA1 Message Date
aniket
70f1cbaf1e Merge branch 'main' of github.com:SigNoz/signoz into feat/7294-services 2025-05-14 02:06:07 +05:30
aniket
ac15bbea72 feat(7294-services): added dot metrics boolean for services tab 2025-05-14 02:05:44 +05:30
Vishal Sharma
9c0134da54 feat: user pilot reload (#7905)
* feat: user pilot reload

* feat: added test cases

---------

Co-authored-by: SagarRajput-7 <sagar@signoz.io>
2025-05-13 23:28:01 +05:30
Ekansh Gupta
175b059268 fix: quick filter dependency injection issue in CE (#7918)
* fix: quick filter dependency injection issue in CE

* fix: quick filter dependency injection issue in CE
2025-05-13 19:45:48 +05:30
Nageshbansal
dfca5b13c0 fix: OSS telemetry for the number of services (#7908)
Fixes the OSS telemetry for sending the `Number of services` events.
2025-05-13 16:18:59 +05:30
aniket
48c01235e9 chore(7837): disable v2 and add signozclickhousemetrics in signozspanmetrics 2025-05-06 12:55:50 +05:30
49 changed files with 566 additions and 4353 deletions

View File

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

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"github.com/SigNoz/signoz/ee/query-service/constants"
"time"
"github.com/jmoiron/sqlx"
@@ -207,6 +208,14 @@ func (r *Repo) GetAllFeatures() ([]basemodel.Feature, error) {
return feature, err
}
env := constants.GetOrDefaultEnv("DOT_METRICS_MIGRATED", "false")
if env == "true" {
feature = append(feature, basemodel.Feature{
Name: "DOT_METRICS_ENABLED",
Active: true,
})
}
return feature, nil
}

View File

@@ -62,6 +62,13 @@ var BasicPlan = basemodel.FeatureSet{
UsageLimit: -1,
Route: "",
},
basemodel.Feature{
Name: basemodel.TraceFunnels,
Active: false,
Usage: 0,
UsageLimit: -1,
Route: "",
},
}
var EnterprisePlan = basemodel.FeatureSet{
@@ -114,4 +121,11 @@ var EnterprisePlan = basemodel.FeatureSet{
UsageLimit: -1,
Route: "",
},
basemodel.Feature{
Name: basemodel.TraceFunnels,
Active: false,
Usage: 0,
UsageLimit: -1,
Route: "",
},
}

View File

@@ -5,6 +5,7 @@ import setLocalStorageApi from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import UserpilotRouteTracker from 'components/UserpilotRouteTracker/UserpilotRouteTracker';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
@@ -354,6 +355,7 @@ function App(): JSX.Element {
<ConfigProvider theme={themeConfig}>
<Router history={history}>
<CompatRouter>
<UserpilotRouteTracker />
<NotificationProvider>
<PrivateRoute>
<ResourceProvider>

View File

@@ -0,0 +1,223 @@
import { render } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { MemoryRouter } from 'react-router-dom';
import { Userpilot } from 'userpilot';
import UserpilotRouteTracker from './UserpilotRouteTracker';
// Mock constants
const INITIAL_PATH = '/initial';
const TIMER_DELAY = 100;
// Mock the userpilot module
jest.mock('userpilot', () => ({
Userpilot: {
reload: jest.fn(),
},
}));
// Mock location state
let mockLocation = {
pathname: INITIAL_PATH,
search: '',
hash: '',
state: null,
};
// Mock react-router-dom
jest.mock('react-router-dom', () => {
const originalModule = jest.requireActual('react-router-dom');
return {
...originalModule,
useLocation: jest.fn(() => mockLocation),
};
});
describe('UserpilotRouteTracker', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset timers
jest.useFakeTimers();
// Reset error mock implementation
(Userpilot.reload as jest.Mock).mockImplementation(() => {});
// Reset location to initial state
mockLocation = {
pathname: INITIAL_PATH,
search: '',
hash: '',
state: null,
};
});
afterEach(() => {
jest.useRealTimers();
});
it('calls Userpilot.reload on initial render', () => {
render(
<MemoryRouter>
<UserpilotRouteTracker />
</MemoryRouter>,
);
// Fast-forward timer to trigger the setTimeout in reloadUserpilot
act(() => {
jest.advanceTimersByTime(TIMER_DELAY);
});
expect(Userpilot.reload).toHaveBeenCalledTimes(1);
});
it('calls Userpilot.reload when pathname changes', () => {
const { rerender } = render(
<MemoryRouter>
<UserpilotRouteTracker />
</MemoryRouter>,
);
// Fast-forward initial render timer
act(() => {
jest.advanceTimersByTime(TIMER_DELAY);
});
jest.clearAllMocks();
// Create a new location object with different pathname
const newLocation = {
...mockLocation,
pathname: '/new-path',
};
// Update the mock location with new path and trigger re-render
act(() => {
mockLocation = newLocation;
// Force a component update with the new location
rerender(
<MemoryRouter>
<UserpilotRouteTracker />
</MemoryRouter>,
);
});
// Fast-forward timer to allow the setTimeout to execute
act(() => {
jest.advanceTimersByTime(TIMER_DELAY);
});
expect(Userpilot.reload).toHaveBeenCalledTimes(1);
});
it('calls Userpilot.reload when search parameters change', () => {
const { rerender } = render(
<MemoryRouter>
<UserpilotRouteTracker />
</MemoryRouter>,
);
// Fast-forward initial render timer
act(() => {
jest.advanceTimersByTime(TIMER_DELAY);
});
jest.clearAllMocks();
// Create a new location object with different search params
const newLocation = {
...mockLocation,
search: '?param=value',
};
// Update the mock location with new search and trigger re-render
// eslint-disable-next-line sonarjs/no-identical-functions
act(() => {
mockLocation = newLocation;
// Force a component update with the new location
rerender(
<MemoryRouter>
<UserpilotRouteTracker />
</MemoryRouter>,
);
});
// Fast-forward timer to allow the setTimeout to execute
act(() => {
jest.advanceTimersByTime(TIMER_DELAY);
});
expect(Userpilot.reload).toHaveBeenCalledTimes(1);
});
it('handles errors in Userpilot.reload gracefully', () => {
// Mock console.error to prevent test output noise and capture calls
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
// Instead of using the component, we test the error handling behavior directly
const errorMsg = 'Error message';
// Set up a function that has the same error handling behavior as in component
const testErrorHandler = (): void => {
try {
if (typeof Userpilot !== 'undefined' && Userpilot.reload) {
Userpilot.reload();
}
} catch (error) {
console.error('[Userpilot] Error reloading on route change:', error);
}
};
// Make Userpilot.reload throw an error
(Userpilot.reload as jest.Mock).mockImplementation(() => {
throw new Error(errorMsg);
});
// Execute the function that should handle errors
testErrorHandler();
// Verify error was logged
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[Userpilot] Error reloading on route change:',
expect.any(Error),
);
// Restore console mock
consoleErrorSpy.mockRestore();
});
it('does not call Userpilot.reload when same route is rendered again', () => {
const { rerender } = render(
<MemoryRouter>
<UserpilotRouteTracker />
</MemoryRouter>,
);
// Fast-forward initial render timer
act(() => {
jest.advanceTimersByTime(TIMER_DELAY);
});
jest.clearAllMocks();
act(() => {
mockLocation = {
pathname: mockLocation.pathname,
search: mockLocation.search,
hash: mockLocation.hash,
state: mockLocation.state,
};
// Force a component update with the same location
rerender(
<MemoryRouter>
<UserpilotRouteTracker />
</MemoryRouter>,
);
});
// Fast-forward timer
act(() => {
jest.advanceTimersByTime(TIMER_DELAY);
});
// Should not call reload since path and search are the same
expect(Userpilot.reload).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,60 @@
import { useCallback, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { Userpilot } from 'userpilot';
/**
* UserpilotRouteTracker - A component that tracks route changes and calls Userpilot.reload
* on actual page changes (pathname changes or significant query parameter changes).
*
* This component renders nothing and is designed to be placed once high in the component tree.
*/
function UserpilotRouteTracker(): null {
const location = useLocation();
const prevPathRef = useRef<string>(location.pathname);
const prevSearchRef = useRef<string>(location.search);
const isFirstRenderRef = useRef<boolean>(true);
// Function to reload Userpilot safely - using useCallback to avoid dependency issues
const reloadUserpilot = useCallback((): void => {
try {
if (typeof Userpilot !== 'undefined' && Userpilot.reload) {
setTimeout(() => {
Userpilot.reload();
}, 100);
}
} catch (error) {
console.error('[Userpilot] Error reloading on route change:', error);
}
}, []);
// Handle first render
useEffect(() => {
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
reloadUserpilot();
}
}, [reloadUserpilot]);
// Handle route/query changes
useEffect(() => {
// Skip first render as it's handled by the effect above
if (isFirstRenderRef.current) {
return;
}
// Check if the path has changed or if significant query params have changed
const pathChanged = location.pathname !== prevPathRef.current;
const searchChanged = location.search !== prevSearchRef.current;
if (pathChanged || searchChanged) {
// Update refs
prevPathRef.current = location.pathname;
prevSearchRef.current = location.search;
reloadUserpilot();
}
}, [location.pathname, location.search, reloadUserpilot]);
return null;
}
export default UserpilotRouteTracker;

View File

@@ -1,7 +1,3 @@
export const ENVIRONMENT = {
baseURL:
process?.env?.FRONTEND_API_ENDPOINT ||
process?.env?.GITPOD_WORKSPACE_URL?.replace('://', '://8080-') ||
'',
wsURL: process?.env?.WEBSOCKET_API_ENDPOINT || '',
baseURL: 'http://localhost:8080',
};

View File

@@ -10,4 +10,5 @@ export enum FeatureKeys {
ONBOARDING_V3 = 'ONBOARDING_V3',
THIRD_PARTY_API = 'THIRD_PARTY_API',
TRACE_FUNNELS = 'TRACE_FUNNELS',
DOT_METRICS_ENABLED = 'DOT_METRICS_ENABLED',
}

View File

@@ -30,6 +30,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { Tags } from 'types/reducer/trace';
import { USER_ROLES } from 'types/roles';
import { FeatureKeys } from '../../../constants/features';
import { DOCS_LINKS } from '../constants';
import { columns, TIME_PICKER_OPTIONS } from './constants';
@@ -210,6 +211,11 @@ function ServiceMetrics({
const topLevelOperations = useMemo(() => Object.entries(data || {}), [data]);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const queryRangeRequestData = useMemo(
() =>
getQueryRangeRequestData({
@@ -217,12 +223,14 @@ function ServiceMetrics({
minTime: timeRange.startTime * 1e6,
maxTime: timeRange.endTime * 1e6,
globalSelectedInterval,
dotMetricsEnabled,
}),
[
globalSelectedInterval,
timeRange.endTime,
timeRange.startTime,
topLevelOperations,
dotMetricsEnabled,
],
);

View File

@@ -21,6 +21,7 @@ export const databaseCallsRPS = ({
servicename,
legend,
tagFilterItems,
dotMetricsEnabled,
}: DatabaseCallsRPSProps): QueryBuilderData => {
const autocompleteData: BaseAutocompleteData[] = [
{
@@ -34,7 +35,7 @@ export const databaseCallsRPS = ({
{
dataType: DataTypes.String,
isColumn: false,
key: 'db_system',
key: dotMetricsEnabled ? WidgetKeys.Db_system : WidgetKeys.Db_system_norm,
type: 'tag',
},
];
@@ -43,7 +44,9 @@ export const databaseCallsRPS = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Resource,
@@ -75,6 +78,7 @@ export const databaseCallsRPS = ({
export const databaseCallsAvgDuration = ({
servicename,
tagFilterItems,
dotMetricsEnabled,
}: DatabaseCallProps): QueryBuilderData => {
const autocompleteDataA: BaseAutocompleteData = {
key: WidgetKeys.SignozDbLatencySum,
@@ -93,7 +97,9 @@ export const databaseCallsAvgDuration = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Resource,

View File

@@ -33,6 +33,7 @@ export const externalCallErrorPercent = ({
servicename,
legend,
tagFilterItems,
dotMetricsEnabled,
}: ExternalCallDurationByAddressProps): QueryBuilderData => {
const autocompleteDataA: BaseAutocompleteData = {
key: WidgetKeys.SignozExternalCallLatencyCount,
@@ -51,7 +52,9 @@ export const externalCallErrorPercent = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Resource,
@@ -62,7 +65,7 @@ export const externalCallErrorPercent = ({
{
id: '',
key: {
key: WidgetKeys.StatusCode,
key: dotMetricsEnabled ? WidgetKeys.StatusCode : WidgetKeys.StatusCodeNorm,
dataType: DataTypes.Int64,
isColumn: false,
type: MetricsType.Tag,
@@ -76,7 +79,9 @@ export const externalCallErrorPercent = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Resource,
@@ -121,6 +126,7 @@ export const externalCallErrorPercent = ({
export const externalCallDuration = ({
servicename,
tagFilterItems,
dotMetricsEnabled,
}: ExternalCallProps): QueryBuilderData => {
const autocompleteDataA: BaseAutocompleteData = {
dataType: DataTypes.Float64,
@@ -144,7 +150,9 @@ export const externalCallDuration = ({
key: {
dataType: DataTypes.String,
isColumn: false,
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
@@ -184,6 +192,7 @@ export const externalCallRpsByAddress = ({
servicename,
legend,
tagFilterItems,
dotMetricsEnabled,
}: ExternalCallDurationByAddressProps): QueryBuilderData => {
const autocompleteData: BaseAutocompleteData[] = [
{
@@ -200,7 +209,9 @@ export const externalCallRpsByAddress = ({
key: {
dataType: DataTypes.String,
isColumn: false,
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
@@ -231,6 +242,7 @@ export const externalCallDurationByAddress = ({
servicename,
legend,
tagFilterItems,
dotMetricsEnabled,
}: ExternalCallDurationByAddressProps): QueryBuilderData => {
const autocompleteDataA: BaseAutocompleteData = {
dataType: DataTypes.Float64,
@@ -253,7 +265,9 @@ export const externalCallDurationByAddress = ({
key: {
dataType: DataTypes.String,
isColumn: false,
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
type: MetricsType.Resource,
},
op: OPERATORS.IN,

View File

@@ -37,10 +37,18 @@ export const latency = ({
tagFilterItems,
isSpanMetricEnable = false,
topLevelOperationsRoute,
dotMetricsEnabled,
}: LatencyProps): QueryBuilderData => {
const signozLatencyBucketMetrics = dotMetricsEnabled
? WidgetKeys.Signoz_latency_bucket
: WidgetKeys.Signoz_latency_bucket_norm;
const signozMetricsServiceName = dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm;
const newAutoCompleteData: BaseAutocompleteData = {
key: isSpanMetricEnable
? WidgetKeys.Signoz_latency_bucket
? signozLatencyBucketMetrics
: WidgetKeys.DurationNano,
dataType: DataTypes.Float64,
isColumn: true,
@@ -53,7 +61,7 @@ export const latency = ({
{
id: '',
key: {
key: isSpanMetricEnable ? WidgetKeys.Service_name : WidgetKeys.ServiceName,
key: isSpanMetricEnable ? signozMetricsServiceName : WidgetKeys.ServiceName,
dataType: DataTypes.String,
type: isSpanMetricEnable ? MetricsType.Resource : MetricsType.Tag,
isColumn: !isSpanMetricEnable,
@@ -295,23 +303,30 @@ export const apDexMetricsQueryBuilderQueries = ({
threashold,
delta,
metricsBuckets,
dotMetricsEnabled,
}: ApDexMetricsQueryBuilderQueriesProps): QueryBuilderData => {
const autoCompleteDataA: BaseAutocompleteData = {
key: WidgetKeys.SignozLatencyCount,
key: dotMetricsEnabled
? WidgetKeys.SignozLatencyCount
: WidgetKeys.SignozLatencyCountNorm,
dataType: DataTypes.Float64,
isColumn: true,
type: '',
};
const autoCompleteDataB: BaseAutocompleteData = {
key: WidgetKeys.Signoz_latency_bucket,
key: dotMetricsEnabled
? WidgetKeys.Signoz_latency_bucket
: WidgetKeys.Signoz_latency_bucket_norm,
dataType: DataTypes.Float64,
isColumn: true,
type: '',
};
const autoCompleteDataC: BaseAutocompleteData = {
key: WidgetKeys.Signoz_latency_bucket,
key: dotMetricsEnabled
? WidgetKeys.Signoz_latency_bucket
: WidgetKeys.Signoz_latency_bucket_norm,
dataType: DataTypes.Float64,
isColumn: true,
type: '',
@@ -321,7 +336,9 @@ export const apDexMetricsQueryBuilderQueries = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Tag,
@@ -347,7 +364,7 @@ export const apDexMetricsQueryBuilderQueries = ({
{
id: '',
key: {
key: WidgetKeys.StatusCode,
key: dotMetricsEnabled ? WidgetKeys.StatusCode : WidgetKeys.StatusCodeNorm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Tag,
@@ -369,7 +386,9 @@ export const apDexMetricsQueryBuilderQueries = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Tag,
@@ -406,7 +425,7 @@ export const apDexMetricsQueryBuilderQueries = ({
{
id: '',
key: {
key: WidgetKeys.StatusCode,
key: dotMetricsEnabled ? WidgetKeys.StatusCode : WidgetKeys.StatusCodeNorm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Tag,
@@ -417,7 +436,9 @@ export const apDexMetricsQueryBuilderQueries = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Tag,
@@ -482,10 +503,13 @@ export const operationPerSec = ({
servicename,
tagFilterItems,
topLevelOperations,
dotMetricsEnabled,
}: OperationPerSecProps): QueryBuilderData => {
const autocompleteData: BaseAutocompleteData[] = [
{
key: WidgetKeys.SignozLatencyCount,
key: dotMetricsEnabled
? WidgetKeys.SignozLatencyCount
: WidgetKeys.SignozLatencyCountNorm,
dataType: DataTypes.Float64,
isColumn: true,
type: '',
@@ -497,7 +521,9 @@ export const operationPerSec = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Resource,
@@ -540,6 +566,7 @@ export const errorPercentage = ({
servicename,
tagFilterItems,
topLevelOperations,
dotMetricsEnabled,
}: OperationPerSecProps): QueryBuilderData => {
const autocompleteDataA: BaseAutocompleteData = {
key: WidgetKeys.SignozCallsTotal,
@@ -560,7 +587,9 @@ export const errorPercentage = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Resource,
@@ -582,7 +611,9 @@ export const errorPercentage = ({
{
id: '',
key: {
key: WidgetKeys.StatusCode,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.StatusCodeNorm,
dataType: DataTypes.Int64,
isColumn: false,
type: MetricsType.Tag,
@@ -597,7 +628,9 @@ export const errorPercentage = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Resource,

View File

@@ -21,9 +21,12 @@ import { getQueryBuilderQuerieswithFormula } from './MetricsPageQueriesFactory';
export const topOperationQueries = ({
servicename,
dotMetricsEnabled,
}: TopOperationQueryFactoryProps): QueryBuilderData => {
const latencyAutoCompleteData: BaseAutocompleteData = {
key: WidgetKeys.Signoz_latency_bucket,
key: dotMetricsEnabled
? WidgetKeys.Signoz_latency_bucket
: WidgetKeys.Signoz_latency_bucket_norm,
dataType: DataTypes.Float64,
isColumn: true,
type: '',
@@ -37,7 +40,9 @@ export const topOperationQueries = ({
};
const numOfCallAutoCompleteData: BaseAutocompleteData = {
key: WidgetKeys.SignozLatencyCount,
key: dotMetricsEnabled
? WidgetKeys.SignozLatencyCount
: WidgetKeys.SignozLatencyCountNorm,
dataType: DataTypes.Float64,
isColumn: true,
type: '',
@@ -47,7 +52,9 @@ export const topOperationQueries = ({
{
id: '',
key: {
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
dataType: DataTypes.String,
isColumn: false,
type: MetricsType.Resource,
@@ -63,7 +70,9 @@ export const topOperationQueries = ({
key: {
dataType: DataTypes.String,
isColumn: false,
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
@@ -74,7 +83,7 @@ export const topOperationQueries = ({
key: {
dataType: DataTypes.Int64,
isColumn: false,
key: WidgetKeys.StatusCode,
key: dotMetricsEnabled ? WidgetKeys.StatusCode : WidgetKeys.StatusCodeNorm,
type: MetricsType.Tag,
},
op: OPERATORS.IN,

View File

@@ -26,6 +26,8 @@ import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App';
import { GraphTitle, MENU_ITEMS, SERVICE_CHART_ID } from '../constant';
import { getWidgetQueryBuilder } from '../MetricsApplication.factory';
import { Card, GraphContainer, Row } from '../styles';
@@ -80,8 +82,11 @@ function DBCall(): JSX.Element {
[queries],
);
const legend = '{{db_system}}';
const legend = '{{db.system}}';
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const databaseCallsRPSWidget = useMemo(
() =>
getWidgetQueryBuilder({
@@ -92,6 +97,7 @@ function DBCall(): JSX.Element {
servicename,
legend,
tagFilterItems,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -102,7 +108,7 @@ function DBCall(): JSX.Element {
id: SERVICE_CHART_ID.dbCallsRPS,
fillSpans: false,
}),
[servicename, tagFilterItems],
[servicename, tagFilterItems, dotMetricsEnabled],
);
const databaseCallsAverageDurationWidget = useMemo(
() =>
@@ -113,6 +119,7 @@ function DBCall(): JSX.Element {
builder: databaseCallsAvgDuration({
servicename,
tagFilterItems,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -123,7 +130,7 @@ function DBCall(): JSX.Element {
id: GraphTitle.DATABASE_CALLS_AVG_DURATION,
fillSpans: true,
}),
[servicename, tagFilterItems],
[servicename, tagFilterItems, dotMetricsEnabled],
);
const stepInterval = useMemo(

View File

@@ -28,6 +28,8 @@ import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { EQueryType } from 'types/common/dashboard';
import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App';
import { GraphTitle, legend, MENU_ITEMS } from '../constant';
import { getWidgetQueryBuilder } from '../MetricsApplication.factory';
import { Card, GraphContainer, Row } from '../styles';
@@ -74,6 +76,10 @@ function External(): JSX.Element {
handleNonInQueryRange(resourceAttributesToTagFilterItems(queries)) || [],
[queries],
);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const externalCallErrorWidget = useMemo(
() =>
@@ -85,6 +91,7 @@ function External(): JSX.Element {
servicename,
legend: legend.address,
tagFilterItems,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -94,7 +101,7 @@ function External(): JSX.Element {
yAxisUnit: '%',
id: GraphTitle.EXTERNAL_CALL_ERROR_PERCENTAGE,
}),
[servicename, tagFilterItems],
[servicename, tagFilterItems, dotMetricsEnabled],
);
const selectedTraceTags = useMemo(
@@ -111,6 +118,7 @@ function External(): JSX.Element {
builder: externalCallDuration({
servicename,
tagFilterItems,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -121,7 +129,7 @@ function External(): JSX.Element {
id: GraphTitle.EXTERNAL_CALL_DURATION,
fillSpans: true,
}),
[servicename, tagFilterItems],
[servicename, tagFilterItems, dotMetricsEnabled],
);
const errorApmToTraceQuery = useGetAPMToTracesQueries({
@@ -180,6 +188,7 @@ function External(): JSX.Element {
servicename,
legend: legend.address,
tagFilterItems,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -190,7 +199,7 @@ function External(): JSX.Element {
id: GraphTitle.EXTERNAL_CALL_RPS_BY_ADDRESS,
fillSpans: true,
}),
[servicename, tagFilterItems],
[servicename, tagFilterItems, dotMetricsEnabled],
);
const externalCallDurationAddressWidget = useMemo(
@@ -203,6 +212,7 @@ function External(): JSX.Element {
servicename,
legend: legend.address,
tagFilterItems,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -213,7 +223,7 @@ function External(): JSX.Element {
id: GraphTitle.EXTERNAL_CALL_DURATION_BY_ADDRESS,
fillSpans: true,
}),
[servicename, tagFilterItems],
[servicename, tagFilterItems, dotMetricsEnabled],
);
const apmToTraceQuery = useGetAPMToTracesQueries({

View File

@@ -145,6 +145,10 @@ function Application(): JSX.Element {
[servicename, topLevelOperations],
);
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const operationPerSecWidget = useMemo(
() =>
getWidgetQueryBuilder({
@@ -155,6 +159,7 @@ function Application(): JSX.Element {
servicename,
tagFilterItems,
topLevelOperations: topLevelOperationsRoute,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -164,7 +169,7 @@ function Application(): JSX.Element {
yAxisUnit: 'ops',
id: SERVICE_CHART_ID.rps,
}),
[servicename, tagFilterItems, topLevelOperationsRoute],
[servicename, tagFilterItems, topLevelOperationsRoute, dotMetricsEnabled],
);
const errorPercentageWidget = useMemo(
@@ -177,6 +182,7 @@ function Application(): JSX.Element {
servicename,
tagFilterItems,
topLevelOperations: topLevelOperationsRoute,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -187,7 +193,7 @@ function Application(): JSX.Element {
id: SERVICE_CHART_ID.errorPercentage,
fillSpans: true,
}),
[servicename, tagFilterItems, topLevelOperationsRoute],
[servicename, tagFilterItems, topLevelOperationsRoute, dotMetricsEnabled],
);
const stepInterval = useMemo(

View File

@@ -20,6 +20,8 @@ import { useParams } from 'react-router-dom';
import { EQueryType } from 'types/common/dashboard';
import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../../../../constants/features';
import { useAppContext } from '../../../../../providers/App/App';
import { IServiceName } from '../../types';
import { ApDexMetricsProps } from './types';
@@ -34,7 +36,10 @@ function ApDexMetrics({
}: ApDexMetricsProps): JSX.Element {
const { servicename: encodedServiceName } = useParams<IServiceName>();
const servicename = decodeURIComponent(encodedServiceName);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const apDexMetricsWidget = useMemo(
() =>
getWidgetQueryBuilder({
@@ -48,6 +53,7 @@ function ApDexMetrics({
threashold: thresholdValue || 0,
delta: delta || false,
metricsBuckets: metricsBuckets || [],
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -73,6 +79,7 @@ function ApDexMetrics({
tagFilterItems,
thresholdValue,
topLevelOperationsRoute,
dotMetricsEnabled,
],
);

View File

@@ -56,6 +56,10 @@ function ServiceOverview({
[isSpanMetricEnable, queries],
);
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_SPAN_METRICS)
?.active || false;
const latencyWidget = useMemo(
() =>
getWidgetQueryBuilder({
@@ -67,6 +71,7 @@ function ServiceOverview({
tagFilterItems,
isSpanMetricEnable,
topLevelOperationsRoute,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
@@ -76,7 +81,13 @@ function ServiceOverview({
yAxisUnit: 'ns',
id: SERVICE_CHART_ID.latency,
}),
[isSpanMetricEnable, servicename, tagFilterItems, topLevelOperationsRoute],
[
isSpanMetricEnable,
servicename,
tagFilterItems,
topLevelOperationsRoute,
dotMetricsEnabled,
],
);
const isQueryEnabled =

View File

@@ -18,6 +18,8 @@ import { EQueryType } from 'types/common/dashboard';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../../../constants/features';
import { useAppContext } from '../../../../providers/App/App';
import { IServiceName } from '../types';
import { title } from './config';
import ColumnWithLink from './TableRenderer/ColumnWithLink';
@@ -40,6 +42,11 @@ function TopOperationMetrics(): JSX.Element {
convertRawQueriesToTraceSelectedTags(queries) || [],
);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const keyOperationWidget = useMemo(
() =>
getWidgetQueryBuilder({
@@ -48,13 +55,14 @@ function TopOperationMetrics(): JSX.Element {
promql: [],
builder: topOperationQueries({
servicename,
dotMetricsEnabled,
}),
clickhouse_sql: [],
id: uuid(),
},
panelTypes: PANEL_TYPES.TABLE,
}),
[servicename],
[servicename, dotMetricsEnabled],
);
const updatedQuery = useStepInterval(keyOperationWidget.query);

View File

@@ -10,6 +10,7 @@ export interface IServiceName {
export interface TopOperationQueryFactoryProps {
servicename: IServiceName['servicename'];
dotMetricsEnabled: boolean;
}
export interface ExternalCallDurationByAddressProps extends ExternalCallProps {
@@ -19,6 +20,7 @@ export interface ExternalCallDurationByAddressProps extends ExternalCallProps {
export interface ExternalCallProps {
servicename: IServiceName['servicename'];
tagFilterItems: TagFilterItem[];
dotMetricsEnabled: boolean;
}
export interface BuilderQueriesProps {
@@ -50,6 +52,7 @@ export interface OperationPerSecProps {
servicename: IServiceName['servicename'];
tagFilterItems: TagFilterItem[];
topLevelOperations: string[];
dotMetricsEnabled: boolean;
}
export interface LatencyProps {
@@ -57,6 +60,7 @@ export interface LatencyProps {
tagFilterItems: TagFilterItem[];
isSpanMetricEnable?: boolean;
topLevelOperationsRoute: string[];
dotMetricsEnabled: boolean;
}
export interface ApDexProps {
@@ -74,4 +78,5 @@ export interface TableRendererProps {
export interface ApDexMetricsQueryBuilderQueriesProps extends ApDexProps {
delta: boolean;
metricsBuckets: number[];
dotMetricsEnabled: boolean;
}

View File

@@ -85,12 +85,15 @@ export enum WidgetKeys {
HasError = 'hasError',
Address = 'address',
DurationNano = 'durationNano',
StatusCode = 'status_code',
StatusCodeNorm = 'status_code',
StatusCode = 'status.code',
Operation = 'operation',
OperationName = 'operationName',
Service_name = 'service_name',
Service_name_norm = 'service_name',
Service_name = 'service.name',
ServiceName = 'serviceName',
SignozLatencyCount = 'signoz_latency_count',
SignozLatencyCountNorm = 'signoz_latency_count',
SignozLatencyCount = 'signoz_latency.count',
SignozDBLatencyCount = 'signoz_db_latency_count',
DatabaseCallCount = 'signoz_database_call_count',
DatabaseCallLatencySum = 'signoz_database_call_latency_sum',
@@ -98,7 +101,10 @@ export enum WidgetKeys {
SignozCallsTotal = 'signoz_calls_total',
SignozExternalCallLatencyCount = 'signoz_external_call_latency_count',
SignozExternalCallLatencySum = 'signoz_external_call_latency_sum',
Signoz_latency_bucket = 'signoz_latency_bucket',
Signoz_latency_bucket_norm = 'signoz_latency_bucket',
Signoz_latency_bucket = 'signoz_latency.bucket',
Db_system = 'db.system',
Db_system_norm = 'db.system',
}
export const topOperationMetricsDownloadOptions: DownloadOptions = {

View File

@@ -25,10 +25,11 @@ export interface NavigateToTraceProps {
}
export interface DatabaseCallsRPSProps extends DatabaseCallProps {
legend: '{{db_system}}';
legend: '{{db.system}}';
}
export interface DatabaseCallProps {
servicename: IServiceName['servicename'];
tagFilterItems: TagFilterItem[];
dotMetricsEnabled: boolean;
}

View File

@@ -3,6 +3,8 @@ import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App';
import { ServiceMetricsProps } from '../types';
import { getQueryRangeRequestData } from '../utils';
import ServiceMetricTable from './ServiceMetricTable';
@@ -15,6 +17,11 @@ function ServiceMetricsApplication({
GlobalReducer
>((state) => state.globalTime);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const queryRangeRequestData = useMemo(
() =>
getQueryRangeRequestData({
@@ -22,8 +29,15 @@ function ServiceMetricsApplication({
minTime,
maxTime,
globalSelectedInterval,
dotMetricsEnabled,
}),
[globalSelectedInterval, maxTime, minTime, topLevelOperations],
[
globalSelectedInterval,
maxTime,
minTime,
topLevelOperations,
dotMetricsEnabled,
],
);
return (
<ServiceMetricTable

View File

@@ -19,11 +19,14 @@ import {
export const serviceMetricsQuery = (
topLevelOperation: [keyof ServiceDataProps, string[]],
dotMetricsEnabled: boolean,
): QueryBuilderData => {
const p99AutoCompleteData: BaseAutocompleteData = {
dataType: DataTypes.Float64,
isColumn: true,
key: WidgetKeys.Signoz_latency_bucket,
key: dotMetricsEnabled
? WidgetKeys.Signoz_latency_bucket
: WidgetKeys.Signoz_latency_bucket_norm,
type: '',
};
@@ -54,7 +57,9 @@ export const serviceMetricsQuery = (
key: {
dataType: DataTypes.String,
isColumn: false,
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
@@ -79,7 +84,9 @@ export const serviceMetricsQuery = (
key: {
dataType: DataTypes.String,
isColumn: false,
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
@@ -90,7 +97,7 @@ export const serviceMetricsQuery = (
key: {
dataType: DataTypes.Int64,
isColumn: false,
key: WidgetKeys.StatusCode,
key: dotMetricsEnabled ? WidgetKeys.StatusCode : WidgetKeys.StatusCodeNorm,
type: MetricsType.Tag,
},
op: OPERATORS.IN,
@@ -115,7 +122,9 @@ export const serviceMetricsQuery = (
key: {
dataType: DataTypes.String,
isColumn: false,
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
@@ -140,7 +149,9 @@ export const serviceMetricsQuery = (
key: {
dataType: DataTypes.String,
isColumn: false,
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
type: MetricsType.Resource,
},
op: OPERATORS.IN,
@@ -195,7 +206,9 @@ export const serviceMetricsQuery = (
{
dataType: DataTypes.String,
isColumn: false,
key: WidgetKeys.Service_name,
key: dotMetricsEnabled
? WidgetKeys.Service_name
: WidgetKeys.Service_name_norm,
type: MetricsType.Tag,
},
];

View File

@@ -29,6 +29,7 @@ export interface GetQueryRangeRequestDataProps {
maxTime: number;
minTime: number;
globalSelectedInterval: Time | TimeV2 | CustomTimeType;
dotMetricsEnabled: boolean;
}
export interface GetServiceListFromQueryProps {

View File

@@ -28,6 +28,7 @@ export const getQueryRangeRequestData = ({
maxTime,
minTime,
globalSelectedInterval,
dotMetricsEnabled,
}: GetQueryRangeRequestDataProps): GetQueryResultsProps[] => {
const requestData: GetQueryResultsProps[] = [];
topLevelOperations.forEach((operation) => {
@@ -35,7 +36,7 @@ export const getQueryRangeRequestData = ({
query: {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: serviceMetricsQuery(operation),
builder: serviceMetricsQuery(operation, dotMetricsEnabled),
clickhouse_sql: [],
id: uuid(),
},

File diff suppressed because it is too large Load Diff

View File

@@ -1,294 +0,0 @@
package impltracefunnel
import (
"encoding/json"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
tf "github.com/SigNoz/signoz/pkg/types/tracefunnel"
"github.com/gorilla/mux"
)
type handler struct {
module tracefunnel.Module
}
func NewHandler(module tracefunnel.Module) tracefunnel.Handler {
return &handler{module: module}
}
func (handler *handler) New(rw http.ResponseWriter, r *http.Request) {
var req tf.FunnelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
claims, err := tracefunnel.GetClaims(r)
if err != nil {
render.Error(rw, err)
return
}
funnel, err := handler.module.Create(r.Context(), req.Timestamp, req.Name, claims.UserID, claims.OrgID)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"failed to create funnel: %v", err))
return
}
response := tracefunnel.ConstructFunnelResponse(funnel, claims)
render.Success(rw, http.StatusOK, response)
}
func (handler *handler) UpdateSteps(rw http.ResponseWriter, r *http.Request) {
var req tf.FunnelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
claims, err := tracefunnel.GetClaims(r)
if err != nil {
render.Error(rw, err)
return
}
updatedAt, err := tracefunnel.ValidateAndConvertTimestamp(req.Timestamp)
if err != nil {
render.Error(rw, err)
return
}
funnel, err := handler.module.Get(r.Context(), req.FunnelID.String())
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"funnel not found: %v", err))
return
}
steps, err := tracefunnel.ProcessFunnelSteps(req.Steps)
if err != nil {
render.Error(rw, err)
return
}
funnel.Steps = steps
funnel.UpdatedAt = updatedAt
funnel.UpdatedBy = claims.UserID
if req.Name != "" {
funnel.Name = req.Name
}
if req.Description != "" {
funnel.Description = req.Description
}
if err := handler.module.Update(r.Context(), funnel, claims.UserID); err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"failed to update funnel in database: %v", err))
return
}
updatedFunnel, err := handler.module.Get(r.Context(), funnel.ID.String())
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"failed to get updated funnel: %v", err))
return
}
response := tracefunnel.ConstructFunnelResponse(updatedFunnel, claims)
render.Success(rw, http.StatusOK, response)
}
func (handler *handler) UpdateFunnel(rw http.ResponseWriter, r *http.Request) {
var req tf.FunnelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
claims, err := tracefunnel.GetClaims(r)
if err != nil {
render.Error(rw, err)
return
}
updatedAt, err := tracefunnel.ValidateAndConvertTimestamp(req.Timestamp)
if err != nil {
render.Error(rw, err)
return
}
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
funnel, err := handler.module.Get(r.Context(), funnelID)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"funnel not found: %v", err))
return
}
funnel.UpdatedAt = updatedAt
funnel.UpdatedBy = claims.UserID
if req.Name != "" {
funnel.Name = req.Name
}
if req.Description != "" {
funnel.Description = req.Description
}
if err := handler.module.Update(r.Context(), funnel, claims.UserID); err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"failed to update funnel in database: %v", err))
return
}
updatedFunnel, err := handler.module.Get(r.Context(), funnel.ID.String())
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"failed to get updated funnel: %v", err))
return
}
response := tracefunnel.ConstructFunnelResponse(updatedFunnel, claims)
render.Success(rw, http.StatusOK, response)
}
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
claims, err := tracefunnel.GetClaims(r)
if err != nil {
render.Error(rw, err)
return
}
funnels, err := handler.module.List(r.Context(), claims.OrgID)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"failed to list funnels: %v", err))
return
}
var response []tf.FunnelResponse
for _, f := range funnels {
response = append(response, tracefunnel.ConstructFunnelResponse(f, claims))
}
render.Success(rw, http.StatusOK, response)
}
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
funnel, err := handler.module.Get(r.Context(), funnelID)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"funnel not found: %v", err))
return
}
claims, _ := tracefunnel.GetClaims(r) // Ignore error as email is optional
response := tracefunnel.ConstructFunnelResponse(funnel, claims)
render.Success(rw, http.StatusOK, response)
}
func (handler *handler) Delete(rw http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
if err := handler.module.Delete(r.Context(), funnelID); err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"failed to delete funnel: %v", err))
return
}
render.Success(rw, http.StatusOK, nil)
}
func (handler *handler) Save(rw http.ResponseWriter, r *http.Request) {
var req tf.FunnelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"invalid request: %v", err))
return
}
claims, err := tracefunnel.GetClaims(r)
if err != nil {
render.Error(rw, err)
return
}
funnel, err := handler.module.Get(r.Context(), req.FunnelID.String())
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"funnel not found: %v", err))
return
}
updateTimestamp := req.Timestamp
if updateTimestamp == 0 {
updateTimestamp = time.Now().UnixMilli()
} else if !tracefunnel.ValidateTimestampIsMilliseconds(updateTimestamp) {
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"timestamp must be in milliseconds format (13 digits)"))
return
}
updatedAt, err := tracefunnel.ValidateAndConvertTimestamp(updateTimestamp)
if err != nil {
render.Error(rw, err)
return
}
funnel.UpdatedAt = updatedAt
funnel.UpdatedBy = claims.UserID
funnel.Description = req.Description
if err := handler.module.Save(r.Context(), funnel, funnel.UpdatedBy, claims.OrgID); err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"failed to save funnel: %v", err))
return
}
createdAtMillis, updatedAtMillis, extraDataFromDB, err := handler.module.GetFunnelMetadata(r.Context(), funnel.ID.String())
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"failed to get funnel metadata: %v", err))
return
}
resp := tf.FunnelResponse{
FunnelName: funnel.Name,
CreatedAt: createdAtMillis,
UpdatedAt: updatedAtMillis,
CreatedBy: funnel.CreatedBy,
UpdatedBy: funnel.UpdatedBy,
OrgID: funnel.OrgID.String(),
Description: extraDataFromDB,
UserEmail: claims.Email,
}
render.Success(rw, http.StatusOK, resp)
}

View File

@@ -1,421 +0,0 @@
package impltracefunnel
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
type MockModule struct {
mock.Mock
}
func (m *MockModule) Create(ctx context.Context, timestamp int64, name string, userID string, orgID string) (*traceFunnels.Funnel, error) {
args := m.Called(ctx, timestamp, name, userID, orgID)
return args.Get(0).(*traceFunnels.Funnel), args.Error(1)
}
func (m *MockModule) Get(ctx context.Context, funnelID string) (*traceFunnels.Funnel, error) {
args := m.Called(ctx, funnelID)
return args.Get(0).(*traceFunnels.Funnel), args.Error(1)
}
func (m *MockModule) Update(ctx context.Context, funnel *traceFunnels.Funnel, userID string) error {
args := m.Called(ctx, funnel, userID)
return args.Error(0)
}
func (m *MockModule) List(ctx context.Context, orgID string) ([]*traceFunnels.Funnel, error) {
args := m.Called(ctx, orgID)
return args.Get(0).([]*traceFunnels.Funnel), args.Error(1)
}
func (m *MockModule) Delete(ctx context.Context, funnelID string) error {
args := m.Called(ctx, funnelID)
return args.Error(0)
}
func (m *MockModule) Save(ctx context.Context, funnel *traceFunnels.Funnel, userID string, orgID string) error {
args := m.Called(ctx, funnel, userID, orgID)
return args.Error(0)
}
func (m *MockModule) GetFunnelMetadata(ctx context.Context, funnelID string) (int64, int64, string, error) {
args := m.Called(ctx, funnelID)
return args.Get(0).(int64), args.Get(1).(int64), args.String(2), args.Error(3)
}
func TestHandler_New(t *testing.T) {
mockModule := new(MockModule)
handler := NewHandler(mockModule)
reqBody := traceFunnels.FunnelRequest{
Name: "test-funnel",
Timestamp: time.Now().UnixMilli(),
}
jsonBody, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/trace-funnels/new", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
orgID := valuer.GenerateUUID().String()
claims := authtypes.Claims{
UserID: "user-123",
OrgID: orgID,
Email: "test@example.com",
}
req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), claims))
rr := httptest.NewRecorder()
funnelID := valuer.GenerateUUID()
expectedFunnel := &traceFunnels.Funnel{
BaseMetadata: traceFunnels.BaseMetadata{
Identifiable: types.Identifiable{
ID: funnelID,
},
Name: reqBody.Name,
OrgID: valuer.MustNewUUID(orgID),
},
}
mockModule.On("List", req.Context(), orgID).Return([]*traceFunnels.Funnel{}, nil)
mockModule.On("Create", req.Context(), reqBody.Timestamp, reqBody.Name, "user-123", orgID).Return(expectedFunnel, nil)
handler.New(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
var response struct {
Status string `json:"status"`
Data traceFunnels.FunnelResponse `json:"data"`
}
err := json.Unmarshal(rr.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, "success", response.Status)
assert.Equal(t, reqBody.Name, response.Data.FunnelName)
assert.Equal(t, orgID, response.Data.OrgID)
assert.Equal(t, "test@example.com", response.Data.UserEmail)
mockModule.AssertExpectations(t)
}
func TestHandler_Update(t *testing.T) {
mockModule := new(MockModule)
handler := NewHandler(mockModule)
// Create a valid UUID for the funnel ID
funnelID := valuer.GenerateUUID()
orgID := valuer.GenerateUUID().String()
reqBody := traceFunnels.FunnelRequest{
FunnelID: funnelID,
Name: "updated-funnel",
Steps: []traceFunnels.FunnelStep{
{
ID: valuer.GenerateUUID(),
Name: "Step 1",
ServiceName: "test-service",
SpanName: "test-span",
Order: 1,
},
{
ID: valuer.GenerateUUID(),
Name: "Step 2",
ServiceName: "test-service",
SpanName: "test-span-2",
Order: 2,
},
},
Timestamp: time.Now().UnixMilli(),
}
body, err := json.Marshal(reqBody)
assert.NoError(t, err)
req, err := http.NewRequest(http.MethodPut, "/api/v1/trace-funnels/steps/update", bytes.NewBuffer(body))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
// Set up context with claims
claims := authtypes.Claims{
UserID: "user-123",
OrgID: orgID,
Email: "test@example.com",
}
ctx := authtypes.NewContextWithClaims(req.Context(), claims)
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
// Set up mock expectations
existingFunnel := &traceFunnels.Funnel{
BaseMetadata: traceFunnels.BaseMetadata{
Identifiable: types.Identifiable{
ID: funnelID,
},
Name: "test-funnel",
OrgID: valuer.MustNewUUID(orgID),
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
UserAuditable: types.UserAuditable{
CreatedBy: "user-123",
UpdatedBy: "user-123",
},
},
CreatedByUser: &types.User{
ID: "user-123",
Email: "test@example.com",
},
}
updatedFunnel := &traceFunnels.Funnel{
BaseMetadata: traceFunnels.BaseMetadata{
Identifiable: types.Identifiable{
ID: funnelID,
},
Name: reqBody.Name,
OrgID: valuer.MustNewUUID(orgID),
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Unix(0, reqBody.Timestamp*1000000),
},
UserAuditable: types.UserAuditable{
CreatedBy: "user-123",
UpdatedBy: "user-123",
},
},
Steps: reqBody.Steps,
CreatedByUser: &types.User{
ID: "user-123",
Email: "test@example.com",
},
}
// First Get call to validate the funnel exists
mockModule.On("Get", req.Context(), funnelID.String()).Return(existingFunnel, nil).Once()
// List call to check for name conflicts
mockModule.On("List", req.Context(), orgID).Return([]*traceFunnels.Funnel{}, nil).Once()
// Update call to save the changes
mockModule.On("Update", req.Context(), mock.MatchedBy(func(f *traceFunnels.Funnel) bool {
return f.Name == reqBody.Name &&
f.ID.String() == funnelID.String() &&
len(f.Steps) == len(reqBody.Steps) &&
f.Steps[0].Name == reqBody.Steps[0].Name &&
f.Steps[0].ServiceName == reqBody.Steps[0].ServiceName &&
f.Steps[0].SpanName == reqBody.Steps[0].SpanName &&
f.Steps[1].Name == reqBody.Steps[1].Name &&
f.Steps[1].ServiceName == reqBody.Steps[1].ServiceName &&
f.Steps[1].SpanName == reqBody.Steps[1].SpanName &&
f.UpdatedAt.UnixNano()/1000000 == reqBody.Timestamp &&
f.UpdatedBy == "user-123"
}), "user-123").Return(nil).Once()
// Second Get call to get the updated funnel for the response
mockModule.On("Get", req.Context(), funnelID.String()).Return(updatedFunnel, nil).Once()
handler.UpdateSteps(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
var response struct {
Status string `json:"status"`
Data traceFunnels.FunnelResponse `json:"data"`
}
err = json.Unmarshal(rr.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, "success", response.Status)
assert.Equal(t, "updated-funnel", response.Data.FunnelName)
assert.Equal(t, funnelID.String(), response.Data.FunnelID)
assert.Equal(t, "test@example.com", response.Data.UserEmail)
mockModule.AssertExpectations(t)
}
func TestHandler_List(t *testing.T) {
mockModule := new(MockModule)
handler := NewHandler(mockModule)
req := httptest.NewRequest(http.MethodGet, "/api/v1/trace-funnels/list", nil)
orgID := valuer.GenerateUUID().String()
claims := authtypes.Claims{
OrgID: orgID,
}
req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), claims))
rr := httptest.NewRecorder()
funnel1ID := valuer.GenerateUUID()
funnel2ID := valuer.GenerateUUID()
expectedFunnels := []*traceFunnels.Funnel{
{
BaseMetadata: traceFunnels.BaseMetadata{
Identifiable: types.Identifiable{
ID: funnel1ID,
},
Name: "funnel-1",
OrgID: valuer.MustNewUUID(orgID),
},
},
{
BaseMetadata: traceFunnels.BaseMetadata{
Identifiable: types.Identifiable{
ID: funnel2ID,
},
Name: "funnel-2",
OrgID: valuer.MustNewUUID(orgID),
},
},
}
mockModule.On("List", req.Context(), orgID).Return(expectedFunnels, nil)
handler.List(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
var response struct {
Status string `json:"status"`
Data []traceFunnels.FunnelResponse `json:"data"`
}
err := json.Unmarshal(rr.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, "success", response.Status)
assert.Len(t, response.Data, 2)
assert.Equal(t, "funnel-1", response.Data[0].FunnelName)
assert.Equal(t, "funnel-2", response.Data[1].FunnelName)
mockModule.AssertExpectations(t)
}
func TestHandler_Get(t *testing.T) {
mockModule := new(MockModule)
handler := NewHandler(mockModule)
funnelID := valuer.GenerateUUID()
req := httptest.NewRequest(http.MethodGet, "/api/v1/trace-funnels/"+funnelID.String(), nil)
req = mux.SetURLVars(req, map[string]string{"funnel_id": funnelID.String()})
rr := httptest.NewRecorder()
expectedFunnel := &traceFunnels.Funnel{
BaseMetadata: traceFunnels.BaseMetadata{
Identifiable: types.Identifiable{
ID: funnelID,
},
Name: "test-funnel",
OrgID: valuer.GenerateUUID(),
},
}
mockModule.On("Get", req.Context(), funnelID.String()).Return(expectedFunnel, nil)
handler.Get(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
var response struct {
Status string `json:"status"`
Data traceFunnels.FunnelResponse `json:"data"`
}
err := json.Unmarshal(rr.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, "success", response.Status)
assert.Equal(t, "test-funnel", response.Data.FunnelName)
assert.Equal(t, expectedFunnel.OrgID.String(), response.Data.OrgID)
mockModule.AssertExpectations(t)
}
func TestHandler_Delete(t *testing.T) {
mockModule := new(MockModule)
handler := NewHandler(mockModule)
funnelID := valuer.GenerateUUID()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/trace-funnels/"+funnelID.String(), nil)
req = mux.SetURLVars(req, map[string]string{"funnel_id": funnelID.String()})
rr := httptest.NewRecorder()
mockModule.On("Delete", req.Context(), funnelID.String()).Return(nil)
handler.Delete(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
mockModule.AssertExpectations(t)
}
func TestHandler_Save(t *testing.T) {
mockModule := new(MockModule)
handler := NewHandler(mockModule)
reqBody := traceFunnels.FunnelRequest{
FunnelID: valuer.GenerateUUID(),
Description: "updated description",
Timestamp: time.Now().UnixMilli(),
UserID: "user-123",
}
jsonBody, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/trace-funnels/save", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
orgID := valuer.GenerateUUID().String()
claims := authtypes.Claims{
UserID: "user-123",
OrgID: orgID,
}
req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), claims))
rr := httptest.NewRecorder()
existingFunnel := &traceFunnels.Funnel{
BaseMetadata: traceFunnels.BaseMetadata{
Identifiable: types.Identifiable{
ID: reqBody.FunnelID,
},
Name: "test-funnel",
OrgID: valuer.MustNewUUID(orgID),
},
}
mockModule.On("Get", req.Context(), reqBody.FunnelID.String()).Return(existingFunnel, nil)
mockModule.On("Save", req.Context(), mock.MatchedBy(func(f *traceFunnels.Funnel) bool {
return f.ID.String() == reqBody.FunnelID.String() &&
f.Name == existingFunnel.Name &&
f.Description == reqBody.Description &&
f.UpdatedBy == "user-123" &&
f.OrgID.String() == orgID
}), "user-123", orgID).Return(nil)
mockModule.On("GetFunnelMetadata", req.Context(), reqBody.FunnelID.String()).Return(int64(0), int64(0), reqBody.Description, nil)
handler.Save(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
var response struct {
Status string `json:"status"`
Data traceFunnels.FunnelResponse `json:"data"`
}
err := json.Unmarshal(rr.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, "success", response.Status)
assert.Equal(t, reqBody.Description, response.Data.Description)
mockModule.AssertExpectations(t)
}

View File

@@ -1,115 +0,0 @@
package impltracefunnel
import (
"context"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
"github.com/SigNoz/signoz/pkg/types"
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
"github.com/SigNoz/signoz/pkg/valuer"
)
type module struct {
store traceFunnels.FunnelStore
}
func NewModule(store traceFunnels.FunnelStore) tracefunnel.Module {
return &module{
store: store,
}
}
func (module *module) Create(ctx context.Context, timestamp int64, name string, userID string, orgID string) (*traceFunnels.Funnel, error) {
orgUUID, err := valuer.NewUUID(orgID)
if err != nil {
return nil, fmt.Errorf("invalid org ID: %v", err)
}
funnel := &traceFunnels.Funnel{
BaseMetadata: traceFunnels.BaseMetadata{
Name: name,
OrgID: orgUUID,
},
}
funnel.CreatedAt = time.Unix(0, timestamp*1000000) // Convert to nanoseconds
funnel.CreatedBy = userID
// Set up the user relationship
funnel.CreatedByUser = &types.User{
ID: userID,
}
if err := module.store.Create(ctx, funnel); err != nil {
return nil, fmt.Errorf("failed to create funnel: %v", err)
}
return funnel, nil
}
// Get gets a funnel by ID
func (module *module) Get(ctx context.Context, funnelID string) (*traceFunnels.Funnel, error) {
uuid, err := valuer.NewUUID(funnelID)
if err != nil {
return nil, fmt.Errorf("invalid funnel ID: %v", err)
}
return module.store.Get(ctx, uuid)
}
// Update updates a funnel
func (module *module) Update(ctx context.Context, funnel *traceFunnels.Funnel, userID string) error {
funnel.UpdatedBy = userID
return module.store.Update(ctx, funnel)
}
// List lists all funnels for an organization
func (module *module) List(ctx context.Context, orgID string) ([]*traceFunnels.Funnel, error) {
orgUUID, err := valuer.NewUUID(orgID)
if err != nil {
return nil, fmt.Errorf("invalid org ID: %v", err)
}
funnels, err := module.store.List(ctx, orgUUID)
if err != nil {
return nil, fmt.Errorf("failed to list funnels: %v", err)
}
return funnels, nil
}
// Delete deletes a funnel
func (module *module) Delete(ctx context.Context, funnelID string) error {
uuid, err := valuer.NewUUID(funnelID)
if err != nil {
return fmt.Errorf("invalid funnel ID: %v", err)
}
return module.store.Delete(ctx, uuid)
}
// Save saves a funnel
func (module *module) Save(ctx context.Context, funnel *traceFunnels.Funnel, userID string, orgID string) error {
orgUUID, err := valuer.NewUUID(orgID)
if err != nil {
return fmt.Errorf("invalid org ID: %v", err)
}
funnel.UpdatedBy = userID
funnel.OrgID = orgUUID
return module.store.Update(ctx, funnel)
}
// GetFunnelMetadata gets metadata for a funnel
func (module *module) GetFunnelMetadata(ctx context.Context, funnelID string) (int64, int64, string, error) {
uuid, err := valuer.NewUUID(funnelID)
if err != nil {
return 0, 0, "", fmt.Errorf("invalid funnel ID: %v", err)
}
funnel, err := module.store.Get(ctx, uuid)
if err != nil {
return 0, 0, "", err
}
return funnel.CreatedAt.UnixNano() / 1000000, funnel.UpdatedAt.UnixNano() / 1000000, funnel.Description, nil
}

View File

@@ -1,213 +0,0 @@
package impltracefunnel
import (
"context"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/types"
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
type MockStore struct {
mock.Mock
}
func (m *MockStore) Create(ctx context.Context, funnel *traceFunnels.Funnel) error {
args := m.Called(ctx, funnel)
return args.Error(0)
}
func (m *MockStore) Get(ctx context.Context, uuid valuer.UUID) (*traceFunnels.Funnel, error) {
args := m.Called(ctx, uuid)
return args.Get(0).(*traceFunnels.Funnel), args.Error(1)
}
func (m *MockStore) List(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.Funnel, error) {
args := m.Called(ctx, orgID)
return args.Get(0).([]*traceFunnels.Funnel), args.Error(1)
}
func (m *MockStore) Update(ctx context.Context, funnel *traceFunnels.Funnel) error {
args := m.Called(ctx, funnel)
return args.Error(0)
}
func (m *MockStore) Delete(ctx context.Context, uuid valuer.UUID) error {
args := m.Called(ctx, uuid)
return args.Error(0)
}
func TestModule_Create(t *testing.T) {
mockStore := new(MockStore)
module := NewModule(mockStore)
ctx := context.Background()
timestamp := time.Now().UnixMilli()
name := "test-funnel"
userID := "user-123"
orgID := valuer.GenerateUUID().String()
mockStore.On("Create", ctx, mock.MatchedBy(func(f *traceFunnels.Funnel) bool {
return f.Name == name &&
f.CreatedBy == userID &&
f.OrgID.String() == orgID &&
f.CreatedByUser != nil &&
f.CreatedByUser.ID == userID &&
f.CreatedAt.UnixNano()/1000000 == timestamp
})).Return(nil)
funnel, err := module.Create(ctx, timestamp, name, userID, orgID)
assert.NoError(t, err)
assert.NotNil(t, funnel)
assert.Equal(t, name, funnel.Name)
assert.Equal(t, userID, funnel.CreatedBy)
assert.Equal(t, orgID, funnel.OrgID.String())
assert.NotNil(t, funnel.CreatedByUser)
assert.Equal(t, userID, funnel.CreatedByUser.ID)
mockStore.AssertExpectations(t)
}
func TestModule_Get(t *testing.T) {
mockStore := new(MockStore)
module := NewModule(mockStore)
ctx := context.Background()
funnelID := valuer.GenerateUUID().String()
expectedFunnel := &traceFunnels.Funnel{
BaseMetadata: traceFunnels.BaseMetadata{
Name: "test-funnel",
},
}
mockStore.On("Get", ctx, mock.AnythingOfType("valuer.UUID")).Return(expectedFunnel, nil)
funnel, err := module.Get(ctx, funnelID)
assert.NoError(t, err)
assert.Equal(t, expectedFunnel, funnel)
mockStore.AssertExpectations(t)
}
func TestModule_Update(t *testing.T) {
mockStore := new(MockStore)
module := NewModule(mockStore)
ctx := context.Background()
userID := "user-123"
funnel := &traceFunnels.Funnel{
BaseMetadata: traceFunnels.BaseMetadata{
Name: "test-funnel",
},
}
mockStore.On("Update", ctx, funnel).Return(nil)
err := module.Update(ctx, funnel, userID)
assert.NoError(t, err)
assert.Equal(t, userID, funnel.UpdatedBy)
mockStore.AssertExpectations(t)
}
func TestModule_List(t *testing.T) {
mockStore := new(MockStore)
module := NewModule(mockStore)
ctx := context.Background()
orgID := valuer.GenerateUUID().String()
orgUUID := valuer.MustNewUUID(orgID)
expectedFunnels := []*traceFunnels.Funnel{
{
BaseMetadata: traceFunnels.BaseMetadata{
Name: "funnel-1",
OrgID: orgUUID,
},
},
{
BaseMetadata: traceFunnels.BaseMetadata{
Name: "funnel-2",
OrgID: orgUUID,
},
},
}
mockStore.On("List", ctx, orgUUID).Return(expectedFunnels, nil)
funnels, err := module.List(ctx, orgID)
assert.NoError(t, err)
assert.Len(t, funnels, 2)
assert.Equal(t, expectedFunnels, funnels)
mockStore.AssertExpectations(t)
}
func TestModule_Delete(t *testing.T) {
mockStore := new(MockStore)
module := NewModule(mockStore)
ctx := context.Background()
funnelID := valuer.GenerateUUID().String()
mockStore.On("Delete", ctx, mock.AnythingOfType("valuer.UUID")).Return(nil)
err := module.Delete(ctx, funnelID)
assert.NoError(t, err)
mockStore.AssertExpectations(t)
}
func TestModule_Save(t *testing.T) {
mockStore := new(MockStore)
module := NewModule(mockStore)
ctx := context.Background()
userID := "user-123"
orgID := valuer.GenerateUUID().String()
funnel := &traceFunnels.Funnel{
BaseMetadata: traceFunnels.BaseMetadata{
Name: "test-funnel",
},
}
mockStore.On("Update", ctx, funnel).Return(nil)
err := module.Save(ctx, funnel, userID, orgID)
assert.NoError(t, err)
assert.Equal(t, userID, funnel.UpdatedBy)
assert.Equal(t, orgID, funnel.OrgID.String())
mockStore.AssertExpectations(t)
}
func TestModule_GetFunnelMetadata(t *testing.T) {
mockStore := new(MockStore)
module := NewModule(mockStore)
ctx := context.Background()
funnelID := valuer.GenerateUUID().String()
now := time.Now()
expectedFunnel := &traceFunnels.Funnel{
BaseMetadata: traceFunnels.BaseMetadata{
Description: "test description",
TimeAuditable: types.TimeAuditable{
CreatedAt: now,
UpdatedAt: now,
},
},
}
mockStore.On("Get", ctx, mock.AnythingOfType("valuer.UUID")).Return(expectedFunnel, nil)
createdAt, updatedAt, description, err := module.GetFunnelMetadata(ctx, funnelID)
assert.NoError(t, err)
assert.Equal(t, now.UnixNano()/1000000, createdAt)
assert.Equal(t, now.UnixNano()/1000000, updatedAt)
assert.Equal(t, "test description", description)
mockStore.AssertExpectations(t)
}

View File

@@ -1,114 +0,0 @@
package impltracefunnel
import (
"context"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/sqlstore"
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
"github.com/SigNoz/signoz/pkg/valuer"
)
type store struct {
sqlstore sqlstore.SQLStore
}
func NewStore(sqlstore sqlstore.SQLStore) traceFunnels.FunnelStore {
return &store{sqlstore: sqlstore}
}
func (store *store) Create(ctx context.Context, funnel *traceFunnels.Funnel) error {
if funnel.ID.IsZero() {
funnel.ID = valuer.GenerateUUID()
}
if funnel.CreatedAt.IsZero() {
funnel.CreatedAt = time.Now()
}
if funnel.UpdatedAt.IsZero() {
funnel.UpdatedAt = time.Now()
}
// Set created_by if CreatedByUser is present
if funnel.CreatedByUser != nil {
funnel.CreatedBy = funnel.CreatedByUser.ID
}
_, err := store.
sqlstore.
BunDB().
NewInsert().
Model(funnel).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, traceFunnels.ErrFunnelAlreadyExists, "a funnel with name '%s' already exists in this organization", funnel.Name)
}
return nil
}
// Get retrieves a funnel by ID
func (store *store) Get(ctx context.Context, uuid valuer.UUID) (*traceFunnels.Funnel, error) {
funnel := &traceFunnels.Funnel{}
err := store.
sqlstore.
BunDB().
NewSelect().
Model(funnel).
Relation("CreatedByUser").
Where("?TableAlias.id = ?", uuid).
Scan(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get funnel: %v", err)
}
return funnel, nil
}
// Update updates an existing funnel
func (store *store) Update(ctx context.Context, funnel *traceFunnels.Funnel) error {
funnel.UpdatedAt = time.Now()
_, err := store.
sqlstore.
BunDB().
NewUpdate().
Model(funnel).
WherePK().
Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, traceFunnels.ErrFunnelAlreadyExists, "a funnel with name '%s' already exists in this organization", funnel.Name)
}
return nil
}
// List retrieves all funnels for a given organization
func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*traceFunnels.Funnel, error) {
var funnels []*traceFunnels.Funnel
err := store.
sqlstore.
BunDB().
NewSelect().
Model(&funnels).
Relation("CreatedByUser").
Where("?TableAlias.org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list funnels: %v", err)
}
return funnels, nil
}
// Delete removes a funnel by ID
func (store *store) Delete(ctx context.Context, uuid valuer.UUID) error {
_, err := store.
sqlstore.
BunDB().
NewDelete().
Model((*traceFunnels.Funnel)(nil)).
Where("id = ?", uuid).Exec(ctx)
if err != nil {
return fmt.Errorf("failed to delete funnel: %v", err)
}
return nil
}

View File

@@ -1,420 +0,0 @@
package tracefunnel
import (
"fmt"
tracev4 "github.com/SigNoz/signoz/pkg/query-service/app/traces/v4"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/tracefunnel"
"strings"
)
func ValidateTraces(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange) (*v3.ClickHouseQuery, error) {
var query string
var err error
funnelSteps := funnel.Steps
containsErrorT1 := 0
containsErrorT2 := 0
containsErrorT3 := 0
if funnelSteps[0].HasErrors {
containsErrorT1 = 1
}
if funnelSteps[1].HasErrors {
containsErrorT2 = 1
}
if len(funnel.Steps) > 2 && funnelSteps[2].HasErrors {
containsErrorT3 = 1
}
// Build filter clauses for each step
clauseStep1, err := tracev4.BuildTracesFilterQuery(funnelSteps[0].Filters)
if err != nil {
return nil, err
}
clauseStep2, err := tracev4.BuildTracesFilterQuery(funnelSteps[1].Filters)
if err != nil {
return nil, err
}
clauseStep3 := ""
if len(funnel.Steps) > 2 {
clauseStep3, err = tracev4.BuildTracesFilterQuery(funnelSteps[2].Filters)
if err != nil {
return nil, err
}
}
if len(funnel.Steps) > 2 {
query = BuildThreeStepFunnelValidationQuery(
containsErrorT1, // containsErrorT1
containsErrorT2, // containsErrorT2
containsErrorT3, // containsErrorT3
timeRange.StartTime, // startTs
timeRange.EndTime, // endTs
funnelSteps[0].ServiceName, // serviceNameT1
funnelSteps[0].SpanName, // spanNameT1
funnelSteps[1].ServiceName, // serviceNameT1
funnelSteps[1].SpanName, // spanNameT2
funnelSteps[2].ServiceName, // serviceNameT1
funnelSteps[2].SpanName, // spanNameT3
clauseStep1,
clauseStep2,
clauseStep3,
)
} else {
query = BuildTwoStepFunnelValidationQuery(
containsErrorT1, // containsErrorT1
containsErrorT2, // containsErrorT2
timeRange.StartTime, // startTs
timeRange.EndTime, // endTs
funnelSteps[0].ServiceName, // serviceNameT1
funnelSteps[0].SpanName, // spanNameT1
funnelSteps[1].ServiceName, // serviceNameT1
funnelSteps[1].SpanName, // spanNameT2
clauseStep1,
clauseStep2,
)
}
return &v3.ClickHouseQuery{
Query: query,
}, nil
}
func GetFunnelAnalytics(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange) (*v3.ClickHouseQuery, error) {
var query string
var err error
funnelSteps := funnel.Steps
containsErrorT1 := 0
containsErrorT2 := 0
containsErrorT3 := 0
latencyPointerT1 := funnelSteps[0].LatencyPointer
latencyPointerT2 := funnelSteps[1].LatencyPointer
latencyPointerT3 := "start"
if len(funnel.Steps) > 2 {
latencyPointerT3 = funnelSteps[2].LatencyPointer
}
if funnelSteps[0].HasErrors {
containsErrorT1 = 1
}
if funnelSteps[1].HasErrors {
containsErrorT2 = 1
}
if len(funnel.Steps) > 2 && funnelSteps[2].HasErrors {
containsErrorT3 = 1
}
// Build filter clauses for each step
clauseStep1, err := tracev4.BuildTracesFilterQuery(funnelSteps[0].Filters)
if err != nil {
return nil, err
}
clauseStep2, err := tracev4.BuildTracesFilterQuery(funnelSteps[1].Filters)
if err != nil {
return nil, err
}
clauseStep3 := ""
if len(funnel.Steps) > 2 {
clauseStep3, err = tracev4.BuildTracesFilterQuery(funnelSteps[2].Filters)
if err != nil {
return nil, err
}
}
if len(funnel.Steps) > 2 {
query = BuildThreeStepFunnelOverviewQuery(
containsErrorT1, // containsErrorT1
containsErrorT2, // containsErrorT2
containsErrorT3, // containsErrorT3
latencyPointerT1,
latencyPointerT2,
latencyPointerT3,
timeRange.StartTime, // startTs
timeRange.EndTime, // endTs
funnelSteps[0].ServiceName, // serviceNameT1
funnelSteps[0].SpanName, // spanNameT1
funnelSteps[1].ServiceName, // serviceNameT1
funnelSteps[1].SpanName, // spanNameT2
funnelSteps[2].ServiceName, // serviceNameT1
funnelSteps[2].SpanName, // spanNameT3
clauseStep1,
clauseStep2,
clauseStep3,
)
} else {
query = BuildTwoStepFunnelOverviewQuery(
containsErrorT1, // containsErrorT1
containsErrorT2, // containsErrorT2
latencyPointerT1,
latencyPointerT2,
timeRange.StartTime, // startTs
timeRange.EndTime, // endTs
funnelSteps[0].ServiceName, // serviceNameT1
funnelSteps[0].SpanName, // spanNameT1
funnelSteps[1].ServiceName, // serviceNameT1
funnelSteps[1].SpanName, // spanNameT2
clauseStep1,
clauseStep2,
)
}
return &v3.ClickHouseQuery{Query: query}, nil
}
func GetFunnelStepAnalytics(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange, stepStart, stepEnd int64) (*v3.ClickHouseQuery, error) {
var query string
var err error
funnelSteps := funnel.Steps
containsErrorT1 := 0
containsErrorT2 := 0
containsErrorT3 := 0
latencyPointerT1 := funnelSteps[0].LatencyPointer
latencyPointerT2 := funnelSteps[1].LatencyPointer
latencyPointerT3 := "start"
if len(funnel.Steps) > 2 {
latencyPointerT3 = funnelSteps[2].LatencyPointer
}
latencyTypeT2 := "p99"
latencyTypeT3 := "p99"
if stepStart == stepEnd {
return nil, fmt.Errorf("step start and end cannot be the same for /step/overview")
}
if funnelSteps[0].HasErrors {
containsErrorT1 = 1
}
if funnelSteps[1].HasErrors {
containsErrorT2 = 1
}
if len(funnel.Steps) > 2 && funnelSteps[2].HasErrors {
containsErrorT3 = 1
}
if funnelSteps[1].LatencyType != "" {
latencyTypeT2 = strings.ToLower(funnelSteps[1].LatencyType)
}
if len(funnel.Steps) > 2 && funnelSteps[2].LatencyType != "" {
latencyTypeT3 = strings.ToLower(funnelSteps[2].LatencyType)
}
// Build filter clauses for each step
clauseStep1, err := tracev4.BuildTracesFilterQuery(funnelSteps[0].Filters)
if err != nil {
return nil, err
}
clauseStep2, err := tracev4.BuildTracesFilterQuery(funnelSteps[1].Filters)
if err != nil {
return nil, err
}
clauseStep3 := ""
if len(funnel.Steps) > 2 {
clauseStep3, err = tracev4.BuildTracesFilterQuery(funnelSteps[2].Filters)
if err != nil {
return nil, err
}
}
if len(funnel.Steps) > 2 {
query = BuildThreeStepFunnelStepOverviewQuery(
containsErrorT1, // containsErrorT1
containsErrorT2, // containsErrorT2
containsErrorT3, // containsErrorT3
latencyPointerT1,
latencyPointerT2,
latencyPointerT3,
timeRange.StartTime, // startTs
timeRange.EndTime, // endTs
funnelSteps[0].ServiceName, // serviceNameT1
funnelSteps[0].SpanName, // spanNameT1
funnelSteps[1].ServiceName, // serviceNameT1
funnelSteps[1].SpanName, // spanNameT2
funnelSteps[2].ServiceName, // serviceNameT1
funnelSteps[2].SpanName, // spanNameT3
clauseStep1,
clauseStep2,
clauseStep3,
stepStart,
stepEnd,
latencyTypeT2,
latencyTypeT3,
)
} else {
query = BuildTwoStepFunnelStepOverviewQuery(
containsErrorT1, // containsErrorT1
containsErrorT2, // containsErrorT2
latencyPointerT1,
latencyPointerT2,
timeRange.StartTime, // startTs
timeRange.EndTime, // endTs
funnelSteps[0].ServiceName, // serviceNameT1
funnelSteps[0].SpanName, // spanNameT1
funnelSteps[1].ServiceName, // serviceNameT1
funnelSteps[1].SpanName, // spanNameT2
clauseStep1,
clauseStep2,
latencyTypeT2,
)
}
return &v3.ClickHouseQuery{Query: query}, nil
}
func GetStepAnalytics(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange) (*v3.ClickHouseQuery, error) {
var query string
funnelSteps := funnel.Steps
containsErrorT1 := 0
containsErrorT2 := 0
containsErrorT3 := 0
if funnelSteps[0].HasErrors {
containsErrorT1 = 1
}
if funnelSteps[1].HasErrors {
containsErrorT2 = 1
}
if len(funnel.Steps) > 2 && funnelSteps[2].HasErrors {
containsErrorT3 = 1
}
// Build filter clauses for each step
clauseStep1, err := tracev4.BuildTracesFilterQuery(funnelSteps[0].Filters)
if err != nil {
return nil, err
}
clauseStep2, err := tracev4.BuildTracesFilterQuery(funnelSteps[1].Filters)
if err != nil {
return nil, err
}
clauseStep3 := ""
if len(funnel.Steps) > 2 {
clauseStep3, err = tracev4.BuildTracesFilterQuery(funnelSteps[2].Filters)
if err != nil {
return nil, err
}
}
if len(funnel.Steps) > 2 {
query = BuildThreeStepFunnelCountQuery(
containsErrorT1, // containsErrorT1
containsErrorT2, // containsErrorT2
containsErrorT3, // containsErrorT3
timeRange.StartTime, // startTs
timeRange.EndTime, // endTs
funnelSteps[0].ServiceName, // serviceNameT1
funnelSteps[0].SpanName, // spanNameT1
funnelSteps[1].ServiceName, // serviceNameT1
funnelSteps[1].SpanName, // spanNameT2
funnelSteps[2].ServiceName, // serviceNameT1
funnelSteps[2].SpanName, // spanNameT3
clauseStep1,
clauseStep2,
clauseStep3,
)
} else {
query = BuildTwoStepFunnelCountQuery(
containsErrorT1, // containsErrorT1
containsErrorT2, // containsErrorT2
timeRange.StartTime, // startTs
timeRange.EndTime, // endTs
funnelSteps[0].ServiceName, // serviceNameT1
funnelSteps[0].SpanName, // spanNameT1
funnelSteps[1].ServiceName, // serviceNameT1
funnelSteps[1].SpanName, // spanNameT2
clauseStep1,
clauseStep2,
)
}
return &v3.ClickHouseQuery{
Query: query,
}, nil
}
func GetSlowestTraces(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange, stepStart, stepEnd int64) (*v3.ClickHouseQuery, error) {
funnelSteps := funnel.Steps
containsErrorT1 := 0
containsErrorT2 := 0
stepStartOrder := 0
stepEndOrder := 1
if stepStart != stepEnd {
stepStartOrder = int(stepStart) - 1
stepEndOrder = int(stepEnd) - 1
if funnelSteps[stepStartOrder].HasErrors {
containsErrorT1 = 1
}
if funnelSteps[stepEndOrder].HasErrors {
containsErrorT2 = 1
}
}
// Build filter clauses for the steps
clauseStep1, err := tracev4.BuildTracesFilterQuery(funnelSteps[stepStartOrder].Filters)
if err != nil {
return nil, err
}
clauseStep2, err := tracev4.BuildTracesFilterQuery(funnelSteps[stepEndOrder].Filters)
if err != nil {
return nil, err
}
query := BuildTwoStepFunnelTopSlowTracesQuery(
containsErrorT1, // containsErrorT1
containsErrorT2, // containsErrorT2
timeRange.StartTime, // startTs
timeRange.EndTime, // endTs
funnelSteps[stepStartOrder].ServiceName, // serviceNameT1
funnelSteps[stepStartOrder].SpanName, // spanNameT1
funnelSteps[stepEndOrder].ServiceName, // serviceNameT1
funnelSteps[stepEndOrder].SpanName, // spanNameT2
clauseStep1,
clauseStep2,
)
return &v3.ClickHouseQuery{Query: query}, nil
}
func GetErroredTraces(funnel *tracefunnel.Funnel, timeRange tracefunnel.TimeRange, stepStart, stepEnd int64) (*v3.ClickHouseQuery, error) {
funnelSteps := funnel.Steps
containsErrorT1 := 0
containsErrorT2 := 0
stepStartOrder := 0
stepEndOrder := 1
if stepStart != stepEnd {
stepStartOrder = int(stepStart) - 1
stepEndOrder = int(stepEnd) - 1
if funnelSteps[stepStartOrder].HasErrors {
containsErrorT1 = 1
}
if funnelSteps[stepEndOrder].HasErrors {
containsErrorT2 = 1
}
}
// Build filter clauses for the steps
clauseStep1, err := tracev4.BuildTracesFilterQuery(funnelSteps[stepStartOrder].Filters)
if err != nil {
return nil, err
}
clauseStep2, err := tracev4.BuildTracesFilterQuery(funnelSteps[stepEndOrder].Filters)
if err != nil {
return nil, err
}
query := BuildTwoStepFunnelTopSlowErrorTracesQuery(
containsErrorT1, // containsErrorT1
containsErrorT2, // containsErrorT2
timeRange.StartTime, // startTs
timeRange.EndTime, // endTs
funnelSteps[stepStartOrder].ServiceName, // serviceNameT1
funnelSteps[stepStartOrder].SpanName, // spanNameT1
funnelSteps[stepEndOrder].ServiceName, // serviceNameT1
funnelSteps[stepEndOrder].SpanName, // spanNameT2
clauseStep1,
clauseStep2,
)
return &v3.ClickHouseQuery{Query: query}, nil
}

View File

@@ -1,41 +0,0 @@
package tracefunnel
import (
"context"
"net/http"
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
)
// Module defines the interface for trace funnel operations
type Module interface {
Create(ctx context.Context, timestamp int64, name string, userID string, orgID string) (*traceFunnels.Funnel, error)
Get(ctx context.Context, funnelID string) (*traceFunnels.Funnel, error)
Update(ctx context.Context, funnel *traceFunnels.Funnel, userID string) error
List(ctx context.Context, orgID string) ([]*traceFunnels.Funnel, error)
Delete(ctx context.Context, funnelID string) error
Save(ctx context.Context, funnel *traceFunnels.Funnel, userID string, orgID string) error
GetFunnelMetadata(ctx context.Context, funnelID string) (int64, int64, string, error)
}
type Handler interface {
New(http.ResponseWriter, *http.Request)
UpdateSteps(http.ResponseWriter, *http.Request)
UpdateFunnel(http.ResponseWriter, *http.Request)
List(http.ResponseWriter, *http.Request)
Get(http.ResponseWriter, *http.Request)
Delete(http.ResponseWriter, *http.Request)
Save(http.ResponseWriter, *http.Request)
}

View File

@@ -1,132 +0,0 @@
package tracefunnel
import (
"fmt"
"net/http"
"sort"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/tracefunnel"
"github.com/SigNoz/signoz/pkg/valuer"
)
// ValidateTimestamp validates a timestamp
func ValidateTimestamp(timestamp int64, fieldName string) error {
if timestamp == 0 {
return fmt.Errorf("%s is required", fieldName)
}
if timestamp < 0 {
return fmt.Errorf("%s must be positive", fieldName)
}
return nil
}
// ValidateTimestampIsMilliseconds validates that a timestamp is in milliseconds
func ValidateTimestampIsMilliseconds(timestamp int64) bool {
return timestamp >= 1000000000000 && timestamp <= 9999999999999
}
func ValidateFunnelSteps(steps []tracefunnel.FunnelStep) error {
if len(steps) < 2 {
return fmt.Errorf("funnel must have at least 2 steps")
}
for i, step := range steps {
if step.ServiceName == "" {
return fmt.Errorf("step %d: service name is required", i+1)
}
if step.SpanName == "" {
return fmt.Errorf("step %d: span name is required", i+1)
}
if step.Order < 0 {
return fmt.Errorf("step %d: order must be non-negative", i+1)
}
}
return nil
}
// NormalizeFunnelSteps normalizes step orders to be sequential starting from 1.
// Returns a new slice with normalized step orders, leaving the input slice unchanged.
func NormalizeFunnelSteps(steps []tracefunnel.FunnelStep) []tracefunnel.FunnelStep {
if len(steps) == 0 {
return []tracefunnel.FunnelStep{}
}
newSteps := make([]tracefunnel.FunnelStep, len(steps))
copy(newSteps, steps)
sort.Slice(newSteps, func(i, j int) bool {
return newSteps[i].Order < newSteps[j].Order
})
for i := range newSteps {
newSteps[i].Order = int64(i + 1)
}
return newSteps
}
func GetClaims(r *http.Request) (*authtypes.Claims, error) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
return nil, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"unauthenticated")
}
return &claims, nil
}
func ValidateAndConvertTimestamp(timestamp int64) (time.Time, error) {
if err := ValidateTimestamp(timestamp, "timestamp"); err != nil {
return time.Time{}, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"timestamp is invalid: %v", err)
}
return time.Unix(0, timestamp*1000000), nil // Convert to nanoseconds
}
func ConstructFunnelResponse(funnel *tracefunnel.Funnel, claims *authtypes.Claims) tracefunnel.FunnelResponse {
resp := tracefunnel.FunnelResponse{
FunnelName: funnel.Name,
FunnelID: funnel.ID.String(),
Steps: funnel.Steps,
CreatedAt: funnel.CreatedAt.UnixNano() / 1000000,
CreatedBy: funnel.CreatedBy,
OrgID: funnel.OrgID.String(),
UpdatedBy: funnel.UpdatedBy,
UpdatedAt: funnel.UpdatedAt.UnixNano() / 1000000,
Description: funnel.Description,
}
if funnel.CreatedByUser != nil {
resp.UserEmail = funnel.CreatedByUser.Email
} else if claims != nil {
resp.UserEmail = claims.Email
}
return resp
}
func ProcessFunnelSteps(steps []tracefunnel.FunnelStep) ([]tracefunnel.FunnelStep, error) {
// First validate the steps
if err := ValidateFunnelSteps(steps); err != nil {
return nil, errors.Newf(errors.TypeInvalidInput,
errors.CodeInvalidInput,
"invalid funnel steps: %v", err)
}
// Then process the steps
for i := range steps {
if steps[i].Order < 1 {
steps[i].Order = int64(i + 1)
}
if steps[i].ID.IsZero() {
steps[i].ID = valuer.GenerateUUID()
}
}
return NormalizeFunnelSteps(steps), nil
}

View File

@@ -1,657 +0,0 @@
package tracefunnel
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/tracefunnel"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
)
func TestValidateTimestamp(t *testing.T) {
tests := []struct {
name string
timestamp int64
fieldName string
expectError bool
}{
{
name: "valid timestamp",
timestamp: time.Now().UnixMilli(),
fieldName: "timestamp",
expectError: false,
},
{
name: "zero timestamp",
timestamp: 0,
fieldName: "timestamp",
expectError: true,
},
{
name: "negative timestamp",
timestamp: -1,
fieldName: "timestamp",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateTimestamp(tt.timestamp, tt.fieldName)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestValidateTimestampIsMilliseconds(t *testing.T) {
tests := []struct {
name string
timestamp int64
expected bool
}{
{
name: "valid millisecond timestamp",
timestamp: 1700000000000, // 2023-11-14 12:00:00 UTC
expected: true,
},
{
name: "too small timestamp",
timestamp: 999999999999,
expected: false,
},
{
name: "too large timestamp",
timestamp: 10000000000000,
expected: false,
},
{
name: "second precision timestamp",
timestamp: 1700000000,
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ValidateTimestampIsMilliseconds(tt.timestamp)
assert.Equal(t, tt.expected, result)
})
}
}
func TestValidateFunnelSteps(t *testing.T) {
tests := []struct {
name string
steps []tracefunnel.FunnelStep
expectError bool
}{
{
name: "valid steps",
steps: []tracefunnel.FunnelStep{
{
ID: valuer.GenerateUUID(),
Name: "Step 1",
ServiceName: "test-service",
SpanName: "test-span",
Order: 1,
},
{
ID: valuer.GenerateUUID(),
Name: "Step 2",
ServiceName: "test-service",
SpanName: "test-span-2",
Order: 2,
},
},
expectError: false,
},
{
name: "too few steps",
steps: []tracefunnel.FunnelStep{
{
ID: valuer.GenerateUUID(),
Name: "Step 1",
ServiceName: "test-service",
SpanName: "test-span",
Order: 1,
},
},
expectError: true,
},
{
name: "missing service name",
steps: []tracefunnel.FunnelStep{
{
ID: valuer.GenerateUUID(),
Name: "Step 1",
SpanName: "test-span",
Order: 1,
},
{
ID: valuer.GenerateUUID(),
Name: "Step 2",
ServiceName: "test-service",
SpanName: "test-span-2",
Order: 2,
},
},
expectError: true,
},
{
name: "missing span name",
steps: []tracefunnel.FunnelStep{
{
ID: valuer.GenerateUUID(),
Name: "Step 1",
ServiceName: "test-service",
Order: 1,
},
{
ID: valuer.GenerateUUID(),
Name: "Step 2",
ServiceName: "test-service",
SpanName: "test-span-2",
Order: 2,
},
},
expectError: true,
},
{
name: "negative order",
steps: []tracefunnel.FunnelStep{
{
ID: valuer.GenerateUUID(),
Name: "Step 1",
ServiceName: "test-service",
SpanName: "test-span",
Order: -1,
},
{
ID: valuer.GenerateUUID(),
Name: "Step 2",
ServiceName: "test-service",
SpanName: "test-span-2",
Order: 2,
},
},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateFunnelSteps(tt.steps)
if tt.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestNormalizeFunnelSteps(t *testing.T) {
tests := []struct {
name string
steps []tracefunnel.FunnelStep
expected []tracefunnel.FunnelStep
}{
{
name: "already normalized steps",
steps: []tracefunnel.FunnelStep{
{
ID: valuer.GenerateUUID(),
Name: "Step 1",
ServiceName: "test-service",
SpanName: "test-span",
Order: 1,
},
{
ID: valuer.GenerateUUID(),
Name: "Step 2",
ServiceName: "test-service",
SpanName: "test-span-2",
Order: 2,
},
},
expected: []tracefunnel.FunnelStep{
{
Name: "Step 1",
ServiceName: "test-service",
SpanName: "test-span",
Order: 1,
},
{
Name: "Step 2",
ServiceName: "test-service",
SpanName: "test-span-2",
Order: 2,
},
},
},
{
name: "unordered steps",
steps: []tracefunnel.FunnelStep{
{
ID: valuer.GenerateUUID(),
Name: "Step 2",
ServiceName: "test-service",
SpanName: "test-span-2",
Order: 2,
},
{
ID: valuer.GenerateUUID(),
Name: "Step 1",
ServiceName: "test-service",
SpanName: "test-span",
Order: 1,
},
},
expected: []tracefunnel.FunnelStep{
{
Name: "Step 1",
ServiceName: "test-service",
SpanName: "test-span",
Order: 1,
},
{
Name: "Step 2",
ServiceName: "test-service",
SpanName: "test-span-2",
Order: 2,
},
},
},
{
name: "steps with gaps in order",
steps: []tracefunnel.FunnelStep{
{
ID: valuer.GenerateUUID(),
Name: "Step 1",
ServiceName: "test-service",
SpanName: "test-span",
Order: 1,
},
{
ID: valuer.GenerateUUID(),
Name: "Step 3",
ServiceName: "test-service",
SpanName: "test-span-3",
Order: 3,
},
{
ID: valuer.GenerateUUID(),
Name: "Step 2",
ServiceName: "test-service",
SpanName: "test-span-2",
Order: 2,
},
},
expected: []tracefunnel.FunnelStep{
{
Name: "Step 1",
ServiceName: "test-service",
SpanName: "test-span",
Order: 1,
},
{
Name: "Step 2",
ServiceName: "test-service",
SpanName: "test-span-2",
Order: 2,
},
{
Name: "Step 3",
ServiceName: "test-service",
SpanName: "test-span-3",
Order: 3,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Make a copy of the steps to avoid modifying the original
steps := make([]tracefunnel.FunnelStep, len(tt.steps))
copy(steps, tt.steps)
result := NormalizeFunnelSteps(steps)
// Compare only the relevant fields
for i := range result {
assert.Equal(t, tt.expected[i].Name, result[i].Name)
assert.Equal(t, tt.expected[i].ServiceName, result[i].ServiceName)
assert.Equal(t, tt.expected[i].SpanName, result[i].SpanName)
assert.Equal(t, tt.expected[i].Order, result[i].Order)
}
})
}
}
func TestGetClaims(t *testing.T) {
tests := []struct {
name string
setup func(*http.Request)
expectError bool
}{
{
name: "valid claims",
setup: func(r *http.Request) {
claims := authtypes.Claims{
UserID: "user-123",
OrgID: "org-123",
Email: "test@example.com",
}
*r = *r.WithContext(authtypes.NewContextWithClaims(r.Context(), claims))
},
expectError: false,
},
{
name: "no claims in context",
setup: func(r *http.Request) {},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
tt.setup(req)
claims, err := GetClaims(req)
if tt.expectError {
assert.Error(t, err)
assert.Nil(t, claims)
} else {
assert.NoError(t, err)
assert.NotNil(t, claims)
assert.Equal(t, "user-123", claims.UserID)
assert.Equal(t, "org-123", claims.OrgID)
assert.Equal(t, "test@example.com", claims.Email)
}
})
}
}
func TestValidateAndConvertTimestamp(t *testing.T) {
tests := []struct {
name string
timestamp int64
expectError bool
}{
{
name: "valid timestamp",
timestamp: time.Now().UnixMilli(),
expectError: false,
},
{
name: "zero timestamp",
timestamp: 0,
expectError: true,
},
{
name: "negative timestamp",
timestamp: -1,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ValidateAndConvertTimestamp(tt.timestamp)
if tt.expectError {
assert.Error(t, err)
assert.True(t, result.IsZero())
} else {
assert.NoError(t, err)
assert.False(t, result.IsZero())
// Verify the conversion from milliseconds to nanoseconds
assert.Equal(t, tt.timestamp*1000000, result.UnixNano())
}
})
}
}
func TestConstructFunnelResponse(t *testing.T) {
now := time.Now()
funnelID := valuer.GenerateUUID()
orgID := valuer.GenerateUUID()
tests := []struct {
name string
funnel *tracefunnel.Funnel
claims *authtypes.Claims
expected tracefunnel.FunnelResponse
}{
{
name: "with user email from funnel",
funnel: &tracefunnel.Funnel{
BaseMetadata: tracefunnel.BaseMetadata{
Identifiable: types.Identifiable{
ID: funnelID,
},
Name: "test-funnel",
OrgID: orgID,
TimeAuditable: types.TimeAuditable{
CreatedAt: now,
UpdatedAt: now,
},
UserAuditable: types.UserAuditable{
CreatedBy: "user-123",
UpdatedBy: "user-123",
},
},
CreatedByUser: &types.User{
ID: "user-123",
Email: "funnel@example.com",
},
Steps: []tracefunnel.FunnelStep{
{
ID: valuer.GenerateUUID(),
Name: "Step 1",
ServiceName: "test-service",
SpanName: "test-span",
Order: 1,
},
},
},
claims: &authtypes.Claims{
UserID: "user-123",
OrgID: orgID.String(),
Email: "claims@example.com",
},
expected: tracefunnel.FunnelResponse{
FunnelName: "test-funnel",
FunnelID: funnelID.String(),
Steps: []tracefunnel.FunnelStep{
{
Name: "Step 1",
ServiceName: "test-service",
SpanName: "test-span",
Order: 1,
},
},
CreatedAt: now.UnixNano() / 1000000,
CreatedBy: "user-123",
UpdatedAt: now.UnixNano() / 1000000,
UpdatedBy: "user-123",
OrgID: orgID.String(),
UserEmail: "funnel@example.com",
},
},
{
name: "with user email from claims",
funnel: &tracefunnel.Funnel{
BaseMetadata: tracefunnel.BaseMetadata{
Identifiable: types.Identifiable{
ID: funnelID,
},
Name: "test-funnel",
OrgID: orgID,
TimeAuditable: types.TimeAuditable{
CreatedAt: now,
UpdatedAt: now,
},
UserAuditable: types.UserAuditable{
CreatedBy: "user-123",
UpdatedBy: "user-123",
},
},
Steps: []tracefunnel.FunnelStep{
{
ID: valuer.GenerateUUID(),
Name: "Step 1",
ServiceName: "test-service",
SpanName: "test-span",
Order: 1,
},
},
},
claims: &authtypes.Claims{
UserID: "user-123",
OrgID: orgID.String(),
Email: "claims@example.com",
},
expected: tracefunnel.FunnelResponse{
FunnelName: "test-funnel",
FunnelID: funnelID.String(),
Steps: []tracefunnel.FunnelStep{
{
Name: "Step 1",
ServiceName: "test-service",
SpanName: "test-span",
Order: 1,
},
},
CreatedAt: now.UnixNano() / 1000000,
CreatedBy: "user-123",
UpdatedAt: now.UnixNano() / 1000000,
UpdatedBy: "user-123",
OrgID: orgID.String(),
UserEmail: "claims@example.com",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ConstructFunnelResponse(tt.funnel, tt.claims)
// Compare top-level fields
assert.Equal(t, tt.expected.FunnelName, result.FunnelName)
assert.Equal(t, tt.expected.FunnelID, result.FunnelID)
assert.Equal(t, tt.expected.CreatedAt, result.CreatedAt)
assert.Equal(t, tt.expected.CreatedBy, result.CreatedBy)
assert.Equal(t, tt.expected.UpdatedAt, result.UpdatedAt)
assert.Equal(t, tt.expected.UpdatedBy, result.UpdatedBy)
assert.Equal(t, tt.expected.OrgID, result.OrgID)
assert.Equal(t, tt.expected.UserEmail, result.UserEmail)
// Compare steps
assert.Len(t, result.Steps, len(tt.expected.Steps))
for i, step := range result.Steps {
expectedStep := tt.expected.Steps[i]
assert.Equal(t, expectedStep.Name, step.Name)
assert.Equal(t, expectedStep.ServiceName, step.ServiceName)
assert.Equal(t, expectedStep.SpanName, step.SpanName)
assert.Equal(t, expectedStep.Order, step.Order)
}
})
}
}
func TestProcessFunnelSteps(t *testing.T) {
tests := []struct {
name string
steps []tracefunnel.FunnelStep
expectError bool
}{
{
name: "valid steps with missing IDs",
steps: []tracefunnel.FunnelStep{
{
Name: "Step 1",
ServiceName: "test-service",
SpanName: "test-span",
Order: 0, // Will be normalized to 1
},
{
Name: "Step 2",
ServiceName: "test-service",
SpanName: "test-span-2",
Order: 0, // Will be normalized to 2
},
},
expectError: false,
},
{
name: "invalid steps - missing service name",
steps: []tracefunnel.FunnelStep{
{
Name: "Step 1",
SpanName: "test-span",
Order: 1,
},
{
Name: "Step 2",
ServiceName: "test-service",
SpanName: "test-span-2",
Order: 2,
},
},
expectError: true,
},
{
name: "invalid steps - negative order",
steps: []tracefunnel.FunnelStep{
{
Name: "Step 1",
ServiceName: "test-service",
SpanName: "test-span",
Order: -1,
},
{
Name: "Step 2",
ServiceName: "test-service",
SpanName: "test-span-2",
Order: 2,
},
},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ProcessFunnelSteps(tt.steps)
if tt.expectError {
assert.Error(t, err)
assert.Nil(t, result)
} else {
assert.NoError(t, err)
assert.NotNil(t, result)
assert.Len(t, result, len(tt.steps))
// Verify IDs are generated
for _, step := range result {
assert.False(t, step.ID.IsZero())
}
// Verify orders are normalized
for i, step := range result {
assert.Equal(t, int64(i+1), step.Order)
}
}
})
}
}

View File

@@ -24,11 +24,9 @@ import (
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
tracefunnels "github.com/SigNoz/signoz/pkg/modules/tracefunnel"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
"github.com/SigNoz/signoz/pkg/query-service/app/metricsexplorer"
"github.com/SigNoz/signoz/pkg/signoz"
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/prometheus/promql"
@@ -1713,7 +1711,7 @@ func (aH *APIHandler) getServices(w http.ResponseWriter, r *http.Request) {
"number": len(*result),
}
claims, errv2 := authtypes.ClaimsFromContext(r.Context())
if errv2 != nil {
if errv2 == nil {
telemetry.GetInstance().SendEvent(telemetry.TELEMETRY_EVENT_NUMBER_OF_SERVICES, data, claims.Email, true, false)
}
@@ -5665,226 +5663,3 @@ func (aH *APIHandler) getDomainInfo(w http.ResponseWriter, r *http.Request) {
}
aH.Respond(w, resp)
}
// RegisterTraceFunnelsRoutes adds trace funnels routes
func (aH *APIHandler) RegisterTraceFunnelsRoutes(router *mux.Router, am *middleware.AuthZ) {
// Main trace funnels router
traceFunnelsRouter := router.PathPrefix("/api/v1/trace-funnels").Subrouter()
// API endpoints
traceFunnelsRouter.HandleFunc("/new",
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.New)).
Methods(http.MethodPost)
traceFunnelsRouter.HandleFunc("/list",
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.List)).
Methods(http.MethodGet)
traceFunnelsRouter.HandleFunc("/steps/update",
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.UpdateSteps)).
Methods(http.MethodPut)
traceFunnelsRouter.HandleFunc("/{funnel_id}",
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.Get)).
Methods(http.MethodGet)
traceFunnelsRouter.HandleFunc("/{funnel_id}",
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.Delete)).
Methods(http.MethodDelete)
traceFunnelsRouter.HandleFunc("/{funnel_id}",
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.UpdateFunnel)).
Methods(http.MethodPut)
traceFunnelsRouter.HandleFunc("/save",
am.ViewAccess(aH.Signoz.Handlers.TraceFunnel.Save)).
Methods(http.MethodPost)
// Analytics endpoints
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/validate", aH.handleValidateTraces).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/overview", aH.handleFunnelAnalytics).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/steps", aH.handleStepAnalytics).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/steps/overview", aH.handleFunnelStepAnalytics).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/slow-traces", aH.handleFunnelSlowTraces).Methods("POST")
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/error-traces", aH.handleFunnelErrorTraces).Methods("POST")
}
func (aH *APIHandler) handleValidateTraces(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
return
}
var timeRange traceFunnels.TimeRange
if err := json.NewDecoder(r.Body).Decode(&timeRange); err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("error decoding time range: %v", err)}, nil)
return
}
if len(funnel.Steps) < 2 {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("funnel must have at least 2 steps")}, nil)
return
}
chq, err := tracefunnels.ValidateTraces(funnel, timeRange)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
return
}
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
return
}
aH.Respond(w, results)
}
func (aH *APIHandler) handleFunnelAnalytics(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
return
}
var stepTransition traceFunnels.StepTransitionRequest
if err := json.NewDecoder(r.Body).Decode(&stepTransition); err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("error decoding time range: %v", err)}, nil)
return
}
chq, err := tracefunnels.GetFunnelAnalytics(funnel, stepTransition.TimeRange)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
return
}
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
return
}
aH.Respond(w, results)
}
func (aH *APIHandler) handleFunnelStepAnalytics(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
return
}
var stepTransition traceFunnels.StepTransitionRequest
if err := json.NewDecoder(r.Body).Decode(&stepTransition); err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("error decoding time range: %v", err)}, nil)
return
}
chq, err := tracefunnels.GetFunnelStepAnalytics(funnel, stepTransition.TimeRange, stepTransition.StepAOrder, stepTransition.StepBOrder)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
return
}
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
return
}
aH.Respond(w, results)
}
func (aH *APIHandler) handleStepAnalytics(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
return
}
var timeRange traceFunnels.TimeRange
if err := json.NewDecoder(r.Body).Decode(&timeRange); err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("error decoding time range: %v", err)}, nil)
return
}
chq, err := tracefunnels.GetStepAnalytics(funnel, timeRange)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
return
}
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
return
}
aH.Respond(w, results)
}
func (aH *APIHandler) handleFunnelSlowTraces(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
return
}
var req traceFunnels.StepTransitionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("invalid request body: %v", err)}, nil)
return
}
chq, err := tracefunnels.GetSlowestTraces(funnel, req.TimeRange, req.StepAOrder, req.StepBOrder)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
return
}
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
return
}
aH.Respond(w, results)
}
func (aH *APIHandler) handleFunnelErrorTraces(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
funnelID := vars["funnel_id"]
funnel, err := aH.Signoz.Modules.TraceFunnel.Get(r.Context(), funnelID)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("funnel not found: %v", err)}, nil)
return
}
var req traceFunnels.StepTransitionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("invalid request body: %v", err)}, nil)
return
}
chq, err := tracefunnels.GetErroredTraces(funnel, req.TimeRange, req.StepAOrder, req.StepBOrder)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error building clickhouse query: %v", err)}, nil)
return
}
results, err := aH.reader.GetListResultV3(r.Context(), chq.Query)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: fmt.Errorf("error converting clickhouse results to list: %v", err)}, nil)
return
}
aH.Respond(w, results)
}

View File

@@ -263,7 +263,11 @@ func PrepareTimeseriesFilterQuery(start, end int64, mq *v3.BuilderQuery) (string
conditions = append(conditions, fmt.Sprintf("metric_name IN %s", utils.ClickHouseFormattedMetricNames(mq.AggregateAttribute.Key)))
conditions = append(conditions, fmt.Sprintf("temporality = '%s'", mq.Temporality))
conditions = append(conditions, "__normalized = true")
if constants.GetOrDefaultEnv("DOT_METRICS_ENABLED", "true") == "true" {
conditions = append(conditions, "__normalized = false")
} else {
conditions = append(conditions, "__normalized = true")
}
start, end, tableName := whichTSTableToUse(start, end, mq)
@@ -351,7 +355,11 @@ func PrepareTimeseriesFilterQueryV3(start, end int64, mq *v3.BuilderQuery) (stri
conditions = append(conditions, fmt.Sprintf("metric_name IN %s", utils.ClickHouseFormattedMetricNames(mq.AggregateAttribute.Key)))
conditions = append(conditions, fmt.Sprintf("temporality = '%s'", mq.Temporality))
conditions = append(conditions, "__normalized = true")
if constants.GetOrDefaultEnv("DOT_METRICS_ENABLED", "true") == "true" {
conditions = append(conditions, "__normalized = false")
} else {
conditions = append(conditions, "__normalized = true")
}
start, end, tableName := whichTSTableToUse(start, end, mq)

View File

@@ -149,7 +149,8 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
}
telemetry.GetInstance().SetReader(reader)
quickFilterModule := quickfilter.NewAPI(quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(serverOptions.SigNoz.SQLStore)))
quickfiltermodule := quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(serverOptions.SigNoz.SQLStore))
quickFilter := quickfilter.NewAPI(quickfiltermodule)
apiHandler, err := NewAPIHandler(APIHandlerOpts{
Reader: reader,
PreferSpanMetrics: serverOptions.PreferSpanMetrics,
@@ -164,7 +165,8 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
AlertmanagerAPI: alertmanager.NewAPI(serverOptions.SigNoz.Alertmanager),
FieldsAPI: fields.NewAPI(serverOptions.SigNoz.TelemetryStore),
Signoz: serverOptions.SigNoz,
QuickFilters: quickFilterModule,
QuickFilters: quickFilter,
QuickFilterModule: quickfiltermodule,
})
if err != nil {
return nil, err
@@ -281,7 +283,6 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
api.RegisterMessagingQueuesRoutes(r, am)
api.RegisterThirdPartyApiRoutes(r, am)
api.MetricExplorerRoutes(r, am)
api.RegisterTraceFunnelsRoutes(r, am)
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},

View File

@@ -87,7 +87,7 @@ func existsSubQueryForFixedColumn(key v3.AttributeKey, op v3.FilterOperator) (st
}
}
func BuildTracesFilterQuery(fs *v3.FilterSet) (string, error) {
func buildTracesFilterQuery(fs *v3.FilterSet) (string, error) {
var conditions []string
if fs != nil && len(fs.Items) != 0 {
@@ -167,7 +167,7 @@ func handleEmptyValuesInGroupBy(groupBy []v3.AttributeKey) (string, error) {
Operator: "AND",
Items: filterItems,
}
return BuildTracesFilterQuery(&filterSet)
return buildTracesFilterQuery(&filterSet)
}
return "", nil
}
@@ -248,7 +248,7 @@ func buildTracesQuery(start, end, step int64, mq *v3.BuilderQuery, panelType v3.
timeFilter := fmt.Sprintf("(timestamp >= '%d' AND timestamp <= '%d') AND (ts_bucket_start >= %d AND ts_bucket_start <= %d)", tracesStart, tracesEnd, bucketStart, bucketEnd)
filterSubQuery, err := BuildTracesFilterQuery(mq.Filters)
filterSubQuery, err := buildTracesFilterQuery(mq.Filters)
if err != nil {
return "", err
}

View File

@@ -211,7 +211,7 @@ func Test_buildTracesFilterQuery(t *testing.T) {
want: "",
},
{
name: "Test BuildTracesFilterQuery in, nin",
name: "Test buildTracesFilterQuery in, nin",
args: args{
fs: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "method", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: []interface{}{"GET", "POST"}, Operator: v3.FilterOperatorIn},
@@ -226,7 +226,7 @@ func Test_buildTracesFilterQuery(t *testing.T) {
wantErr: false,
},
{
name: "Test BuildTracesFilterQuery not eq, neq, gt, lt, gte, lte",
name: "Test buildTracesFilterQuery not eq, neq, gt, lt, gte, lte",
args: args{
fs: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "duration", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag}, Value: 102, Operator: v3.FilterOperatorEqual},
@@ -274,13 +274,13 @@ func Test_buildTracesFilterQuery(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := BuildTracesFilterQuery(tt.args.fs)
got, err := buildTracesFilterQuery(tt.args.fs)
if (err != nil) != tt.wantErr {
t.Errorf("BuildTracesFilterQuery() error = %v, wantErr %v", err, tt.wantErr)
t.Errorf("buildTracesFilterQuery() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("BuildTracesFilterQuery() = %v, want %v", got, tt.want)
t.Errorf("buildTracesFilterQuery() = %v, want %v", got, tt.want)
}
})
}

View File

@@ -33,6 +33,15 @@ func (fm *FeatureManager) CheckFeature(featureKey string) error {
// GetFeatureFlags returns current features
func (fm *FeatureManager) GetFeatureFlags() (model.FeatureSet, error) {
features := constants.DEFAULT_FEATURE_SET
//dot metrics migration
env := constants.GetOrDefaultEnv("DOT_METRICS_ENABLED", "false")
if env == "true" {
features = append(features, model.Feature{
Name: "DOT_METRICS_MIGRATED",
Active: true,
})
}
return features, nil
}

View File

@@ -11,6 +11,7 @@ type Feature struct {
const UseSpanMetrics = "USE_SPAN_METRICS"
const AnomalyDetection = "ANOMALY_DETECTION"
const TraceFunnels = "TRACE_FUNNELS"
var BasicPlan = FeatureSet{
Feature{
@@ -27,4 +28,11 @@ var BasicPlan = FeatureSet{
UsageLimit: -1,
Route: "",
},
Feature{
Name: TraceFunnels,
Active: false,
Usage: 0,
UsageLimit: -1,
Route: "",
},
}

View File

@@ -5,20 +5,16 @@ import (
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/preference"
"github.com/SigNoz/signoz/pkg/modules/preference/implpreference"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
)
type Handlers struct {
Organization organization.Handler
Preference preference.Handler
TraceFunnel tracefunnel.Handler
}
func NewHandlers(modules Modules) Handlers {
return Handlers{
Organization: implorganization.NewHandler(modules.Organization),
Preference: implpreference.NewHandler(modules.Preference),
TraceFunnel: impltracefunnel.NewHandler(modules.TraceFunnel),
}
}

View File

@@ -5,8 +5,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/preference"
"github.com/SigNoz/signoz/pkg/modules/preference/implpreference"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/preferencetypes"
)
@@ -14,13 +12,11 @@ import (
type Modules struct {
Organization organization.Module
Preference preference.Module
TraceFunnel tracefunnel.Module
}
func NewModules(sqlstore sqlstore.SQLStore) Modules {
return Modules{
Organization: implorganization.NewModule(implorganization.NewStore(sqlstore)),
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewDefaultPreferenceMap()),
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
}
}

View File

@@ -76,7 +76,6 @@ func NewSQLMigrationProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedM
sqlmigration.NewDropGroupsFactory(sqlstore),
sqlmigration.NewCreateQuickFiltersFactory(sqlstore),
sqlmigration.NewUpdateQuickFiltersFactory(sqlstore),
sqlmigration.NewAddTraceFunnelsFactory(sqlstore),
)
}

View File

@@ -1,111 +0,0 @@
package sqlmigration
import (
"context"
"fmt"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunnel"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addTraceFunnels struct {
sqlstore sqlstore.SQLStore
}
func NewAddTraceFunnelsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.
NewProviderFactory(factory.
MustNewName("add_trace_funnels"),
func(ctx context.Context, providerSettings factory.ProviderSettings, config Config) (SQLMigration, error) {
return newAddTraceFunnels(ctx, providerSettings, config, sqlstore)
})
}
func newAddTraceFunnels(_ context.Context, _ factory.ProviderSettings, _ Config, sqlstore sqlstore.SQLStore) (SQLMigration, error) {
return &addTraceFunnels{sqlstore: sqlstore}, nil
}
func (migration *addTraceFunnels) Register(migrations *migrate.Migrations) error {
if err := migrations.
Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *addTraceFunnels) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Create trace_funnel table with foreign key constraint inline
_, err = tx.NewCreateTable().
Model((*traceFunnels.Funnel)(nil)).
ForeignKey(`("org_id") REFERENCES "organizations" ("id") ON DELETE CASCADE`).
IfNotExists().
Exec(ctx)
if err != nil {
return fmt.Errorf("failed to create trace_funnel table: %v", err)
}
// Add unique constraint for org_id and name
_, err = tx.NewRaw(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_trace_funnel_org_id_name
ON trace_funnel (org_id, name)
`).Exec(ctx)
if err != nil {
return fmt.Errorf("failed to create unique constraint: %v", err)
}
// Create indexes
_, err = tx.NewCreateIndex().
Model((*traceFunnels.Funnel)(nil)).
Index("idx_trace_funnel_org_id").
Column("org_id").
Exec(ctx)
if err != nil {
return fmt.Errorf("failed to create org_id index: %v", err)
}
_, err = tx.NewCreateIndex().
Model((*traceFunnels.Funnel)(nil)).
Index("idx_trace_funnel_created_at").
Column("created_at").Exec(ctx)
if err != nil {
return fmt.Errorf("failed to create created_at index: %v", err)
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (migration *addTraceFunnels) Down(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Drop trace_funnel table
_, err = tx.NewDropTable().
Model((*traceFunnels.Funnel)(nil)).
IfExists().
Exec(ctx)
if err != nil {
return fmt.Errorf("failed to drop trace_funnel table: %v", err)
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}

View File

@@ -1,15 +0,0 @@
package tracefunnel
import (
"context"
"github.com/SigNoz/signoz/pkg/valuer"
)
type FunnelStore interface {
Create(context.Context, *Funnel) error
Get(context.Context, valuer.UUID) (*Funnel, error)
List(context.Context, valuer.UUID) ([]*Funnel, error)
Update(context.Context, *Funnel) error
Delete(context.Context, valuer.UUID) error
}

View File

@@ -1,103 +0,0 @@
package tracefunnel
import (
"github.com/SigNoz/signoz/pkg/errors"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
var (
ErrFunnelAlreadyExists = errors.MustNewCode("funnel_already_exists")
)
// BaseMetadata metadata for funnels
type BaseMetadata struct {
types.Identifiable // funnel id
types.TimeAuditable
types.UserAuditable
Name string `json:"funnel_name" bun:"name,type:text,notnull"` // funnel name
Description string `json:"description" bun:"description,type:text"` // funnel description
OrgID valuer.UUID `json:"org_id" bun:"org_id,type:varchar,notnull"`
}
// Funnel Core Data Structure (Funnel and FunnelStep)
type Funnel struct {
bun.BaseModel `bun:"table:trace_funnel"`
BaseMetadata
Steps []FunnelStep `json:"steps" bun:"steps,type:text,notnull"`
Tags string `json:"tags" bun:"tags,type:text"`
CreatedByUser *types.User `json:"user" bun:"rel:belongs-to,join:created_by=id"`
}
type FunnelStep struct {
ID valuer.UUID `json:"id,omitempty"`
Name string `json:"name,omitempty"` // step name
Description string `json:"description,omitempty"` // step description
Order int64 `json:"step_order"`
ServiceName string `json:"service_name"`
SpanName string `json:"span_name"`
Filters *v3.FilterSet `json:"filters,omitempty"`
LatencyPointer string `json:"latency_pointer,omitempty"`
LatencyType string `json:"latency_type,omitempty"`
HasErrors bool `json:"has_errors"`
}
// FunnelRequest represents all possible funnel-related requests
type FunnelRequest struct {
FunnelID valuer.UUID `json:"funnel_id,omitempty"`
Name string `json:"funnel_name,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
Description string `json:"description,omitempty"`
Steps []FunnelStep `json:"steps,omitempty"`
UserID string `json:"user_id,omitempty"`
// Analytics specific fields
StartTime int64 `json:"start_time,omitempty"`
EndTime int64 `json:"end_time,omitempty"`
StepAOrder int64 `json:"step_a_order,omitempty"`
StepBOrder int64 `json:"step_b_order,omitempty"`
}
// FunnelResponse represents all possible funnel-related responses
type FunnelResponse struct {
FunnelID string `json:"funnel_id,omitempty"`
FunnelName string `json:"funnel_name,omitempty"`
Description string `json:"description,omitempty"`
CreatedAt int64 `json:"created_at,omitempty"`
CreatedBy string `json:"created_by,omitempty"`
UpdatedAt int64 `json:"updated_at,omitempty"`
UpdatedBy string `json:"updated_by,omitempty"`
OrgID string `json:"org_id,omitempty"`
UserEmail string `json:"user_email,omitempty"`
Funnel *Funnel `json:"funnel,omitempty"`
Steps []FunnelStep `json:"steps,omitempty"`
}
// TimeRange represents a time range for analytics
type TimeRange struct {
StartTime int64 `json:"start_time"`
EndTime int64 `json:"end_time"`
}
// StepTransitionRequest represents a request for step transition analytics
type StepTransitionRequest struct {
TimeRange
StepAOrder int64 `json:"step_start,omitempty"`
StepBOrder int64 `json:"step_end,omitempty"`
}
// UserInfo represents basic user information
type UserInfo struct {
ID string `json:"id"`
Email string `json:"email"`
}
type FunnelStepFilter struct {
StepNumber int
ServiceName string
SpanName string
LatencyPointer string // "start" or "end"
CustomFilters *v3.FilterSet
}