Compare commits

..

4 Commits

Author SHA1 Message Date
amlannandy
aaaad8e0a2 chore: fix the route tab regex error 2025-07-03 08:33:14 +07:00
Srikanth Chekuri
5102cf2b7b fix: remove deprecated telemetry::metrics::address from config (#8412) 2025-07-02 11:58:40 +00:00
Vishal Sharma
9ec5594648 fix: telemetry query events (#8388)
* fix: telemetry query events

* chore: reduced cyclomatic complexity

* chore: nit
2025-07-02 08:22:54 +00:00
Shaheer Kochai
b6c2ebd6d7 feat: trace to logs custom empty state UI (#8381)
* feat: display custom empty message if no logs on navigating from trace to logs

* chore: write tests for logs explorer normal and custom empty state

* feat: build the custom empty logs UI based on the updated designs

* feat: clear the filters and run stage query on clicking clear filters in logs custom empty state

* fix: update the failing test to match the logs custom empty state

* chore: remove the unnecessary onClick for documentation links

* refactor: overall improvements

* refactor: move the empty logs list config to util

* chore: update the documentation links + remove the explicit height from resources card

* refactor: reuse the EmptyLogsListConfig type in EmptyLogsSearch

* test: update LogsExplorerList tests to reflect changes in documentation links

---------

Co-authored-by: Vishal Sharma <makeavish786@gmail.com>
2025-07-02 07:30:17 +00:00
11 changed files with 613 additions and 58 deletions

View File

@@ -74,13 +74,10 @@ exporters:
dsn: tcp://clickhouse:9000/signoz_logs
timeout: 10s
use_new_schema: true
# debug: {}
service:
telemetry:
logs:
encoding: json
metrics:
address: 0.0.0.0:8888
extensions:
- health_check
- pprof

View File

@@ -74,13 +74,10 @@ exporters:
dsn: tcp://clickhouse:9000/signoz_logs
timeout: 10s
use_new_schema: true
# debug: {}
service:
telemetry:
logs:
encoding: json
metrics:
address: 0.0.0.0:8888
extensions:
- health_check
- pprof

View File

@@ -29,13 +29,19 @@ function RouteTab({
// Find the matching route for the current pathname
const currentRoute = routesWithParams.find((route) => {
const pathnameOnly = route.route.split('?')[0];
const routePattern = escapeRegExp(pathnameOnly).replace(
/\\:([a-zA-Z0-9_]+)/g,
'([^/]+)',
);
const regex = new RegExp(`^${routePattern}$`);
return regex.test(location.pathname);
try {
const routePattern = route.route.replace(/:(\w+)/g, '([^/]+)');
const regex = new RegExp(`^${routePattern}$`);
return regex.test(location.pathname);
} catch (error) {
const pathnameOnly = route.route.split('?')[0];
const routePattern = escapeRegExp(pathnameOnly).replace(
/\\:([a-zA-Z0-9_]+)/g,
'([^/]+)',
);
const regex = new RegExp(`^${routePattern}$`);
return regex.test(location.pathname);
}
});
const onChange = (activeRoute: string): void => {

View File

@@ -1,30 +1,173 @@
.empty-logs-search-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 240px;
.empty-logs-search-container-content {
.empty-logs-search {
&__container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 240px;
}
&__content {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--text-vanilla-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
line-height: 18px;
letter-spacing: -0.07px;
align-items: flex-start;
.empty-state-svg {
height: 50px;
width: 50px;
}
}
&__sub-text {
font-weight: 600;
}
.sub-text {
font-weight: 600;
&__container {
&--custom-message {
height: 445px;
.empty-state-svg {
height: 32px;
width: 32px;
}
.empty-logs-search {
&__header {
display: flex;
align-items: center;
gap: 4px;
}
&__title {
color: var(--bg-vanilla-100);
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
&__subtitle {
color: var(--bg-vanilla-400);
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
&__description {
font-size: 14px;
color: var(--text-vanilla-400);
line-height: 20px;
}
&__description-list {
margin: 0;
margin-top: 8px;
color: var(--bg-vanilla-400);
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
display: flex;
flex-direction: column;
gap: 6px;
list-style: none;
padding: 0;
font-family: Inter;
}
&__description-list li {
position: relative;
padding-left: 20px;
}
&__description-list li::before {
content: '';
font-family: Inter;
position: absolute;
left: 0;
color: var(--bg-robin-400);
font-weight: bold;
font-size: 16px;
line-height: 20px;
}
&__clear-filters-btn {
display: flex;
width: 468px;
font-family: Inter;
padding: 12px;
justify-content: space-between;
align-items: flex-start;
border-radius: 3px;
border: 1px dashed var(--bg-slate-500);
background: transparent;
color: var(--bg-vanilla-400);
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
cursor: pointer;
margin-top: 12px;
}
&__clear-filters-btn-icon {
display: flex;
align-items: center;
gap: 6px;
}
&__row {
display: flex;
flex-direction: row;
align-items: flex-end;
max-width: 825px;
gap: 25px;
justify-content: center;
margin-left: 21px;
}
&__content {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 260px;
}
&__resources-card {
background: var(--bg-ink-400);
border: 1px solid var(--bg-slate-500);
border-radius: 4px;
width: 332px;
}
&__resources-title {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 11px;
font-weight: 600;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
padding: 16px 16px 12px;
border-bottom: 1px solid var(--bg-slate-500);
height: 46px;
}
&__resources-links {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
.learn-more {
height: 18px;
}
}
}
}
}
}

View File

@@ -2,16 +2,24 @@ import './EmptyLogsSearch.styles.scss';
import { Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import LearnMore from 'components/LearnMore/LearnMore';
import { EmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import { Delete } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { DataSource, PanelTypeKeys } from 'types/common/queryBuilder';
interface EmptyLogsSearchProps {
dataSource: DataSource;
panelType: PanelTypeKeys;
customMessage?: EmptyLogsListConfig;
}
export default function EmptyLogsSearch({
dataSource,
panelType,
}: {
dataSource: DataSource;
panelType: PanelTypeKeys;
}): JSX.Element {
customMessage,
}: EmptyLogsSearchProps): JSX.Element {
const logEventCalledRef = useRef(false);
useEffect(() => {
if (!logEventCalledRef.current) {
@@ -30,18 +38,80 @@ export default function EmptyLogsSearch({
}, []);
return (
<div className="empty-logs-search-container">
<div className="empty-logs-search-container-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Typography.Text>
<span className="sub-text">This query had no results. </span>
Edit your query and try again!
</Typography.Text>
<div
className={cx('empty-logs-search__container', {
'empty-logs-search__container--custom-message': !!customMessage,
})}
>
<div className="empty-logs-search__row">
<div className="empty-logs-search__content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
{customMessage ? (
<>
<div className="empty-logs-search__header">
<Typography.Text className="empty-logs-search__title">
{customMessage.title}
</Typography.Text>
{customMessage.subTitle && (
<Typography.Text className="empty-logs-search__subtitle">
{customMessage.subTitle}
</Typography.Text>
)}
</div>
{Array.isArray(customMessage.description) ? (
<ul className="empty-logs-search__description-list">
{customMessage.description.map((desc) => (
<li key={desc}>{desc}</li>
))}
</ul>
) : (
<Typography.Text className="empty-logs-search__description">
{customMessage.description}
</Typography.Text>
)}
{/* Clear filters button */}
{customMessage.showClearFiltersButton && (
<button
type="button"
className="empty-logs-search__clear-filters-btn"
onClick={customMessage.onClearFilters}
>
{customMessage.clearFiltersButtonText}
<span className="empty-logs-search__clear-filters-btn-icon">
<Delete size={14} />
Clear filters
</span>
</button>
)}
</>
) : (
<Typography.Text>
<span className="empty-logs-search__sub-text">
This query had no results.{' '}
</span>
Edit your query and try again!
</Typography.Text>
)}
</div>
{customMessage?.documentationLinks && (
<div className="empty-logs-search__resources-card">
<div className="empty-logs-search__resources-title">RESOURCES</div>
<div className="empty-logs-search__resources-links">
{customMessage.documentationLinks.map((link) => (
<LearnMore key={link.text} text={link.text} url={link.url} />
))}
</div>
</div>
)}
</div>
</div>
);
}
EmptyLogsSearch.defaultProps = {
customMessage: null,
};

View File

@@ -0,0 +1,221 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import LogsExplorerViews from 'container/LogsExplorerViews';
import { mockQueryBuilderContextValue } from 'container/LogsExplorerViews/tests/mock';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
import { logsQueryRangeEmptyResponse } from 'mocks-server/__mockdata__/logs_query_range';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { SELECTED_VIEWS } from 'pages/LogsExplorer/utils';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import { render, screen } from 'tests/test-utils';
const queryRangeURL = 'http://localhost/api/v3/query_range';
const logsQueryServerRequest = ({
response = logsQueryRangeEmptyResponse,
}: {
response?: any;
}): void =>
server.use(
rest.post(queryRangeURL, (req, res, ctx) =>
res(ctx.status(200), ctx.json(response)),
),
);
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${ROUTES.LOGS_EXPLORER}`,
}),
}));
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
usePreferenceSync: (): any => ({
preferences: {
columns: [],
formatting: {
maxLines: 2,
format: 'table',
fontSize: 'small',
version: 1,
},
},
loading: false,
error: null,
updateColumns: jest.fn(),
updateFormatting: jest.fn(),
}),
}));
jest.mock(
'container/TimeSeriesView/TimeSeriesView',
() =>
// eslint-disable-next-line func-names, @typescript-eslint/explicit-function-return-type, react/display-name
function () {
return <div>Time Series Chart</div>;
},
);
jest.mock(
'container/LogsExplorerChart',
() =>
// eslint-disable-next-line func-names, @typescript-eslint/explicit-function-return-type, react/display-name
function () {
return <div>Histogram Chart</div>;
},
);
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('hooks/queryBuilder/useGetExplorerQueryRange', () => ({
__esModule: true,
useGetExplorerQueryRange: jest.fn(),
}));
describe('LogsExplorerList - empty states', () => {
beforeEach(() => {
(useGetExplorerQueryRange as jest.Mock).mockReturnValue({
data: { payload: logsQueryRangeEmptyResponse },
});
logsQueryServerRequest({});
});
it('should display custom empty state when navigating from trace to logs with no results', async () => {
const mockTraceToLogsContextValue = {
...mockQueryBuilderContextValue,
panelType: PANEL_TYPES.LIST,
stagedQuery: {
...mockQueryBuilderContextValue.stagedQuery,
builder: {
...mockQueryBuilderContextValue.stagedQuery.builder,
queryData: [
{
...mockQueryBuilderContextValue.stagedQuery.builder.queryData[0],
filters: {
items: [
{
id: 'trace-filter',
key: {
key: 'trace_id',
type: '',
dataType: 'string',
isColumn: true,
},
op: '=',
value: 'test-trace-id',
},
],
op: 'AND',
},
},
],
},
},
};
render(
<QueryBuilderContext.Provider value={mockTraceToLogsContextValue as any}>
<PreferenceContextProvider>
<LogsExplorerViews
selectedView={SELECTED_VIEWS.SEARCH}
showFrequencyChart
setIsLoadingQueries={(): void => {}}
listQueryKeyRef={{ current: {} }}
chartQueryKeyRef={{ current: {} }}
/>
</PreferenceContextProvider>
</QueryBuilderContext.Provider>,
);
// Check for custom empty state message
expect(screen.getByText('No logs found for this trace.')).toBeInTheDocument();
expect(screen.getByText('This could be because :')).toBeInTheDocument();
expect(
screen.getByText('Logs are not linked to Traces.'),
).toBeInTheDocument();
expect(
screen.getByText('Logs are not being sent to SigNoz.'),
).toBeInTheDocument();
expect(
screen.getByText('No logs are associated with this particular trace/span.'),
).toBeInTheDocument();
// Check for documentation links
expect(screen.getByText('Sending logs to SigNoz')).toBeInTheDocument();
expect(screen.getByText('Correlate traces and logs')).toBeInTheDocument();
});
it('should display empty state when filters are applied and no results are found', async () => {
const mockTraceToLogsContextValue = {
...mockQueryBuilderContextValue,
panelType: PANEL_TYPES.LIST,
stagedQuery: {
...mockQueryBuilderContextValue.stagedQuery,
builder: {
...mockQueryBuilderContextValue.stagedQuery.builder,
queryData: [
{
...mockQueryBuilderContextValue.stagedQuery.builder.queryData[0],
filters: {
items: [
{
id: 'service-filter',
key: {
key: 'service.name',
type: '',
dataType: 'string',
isColumn: true,
},
op: '=',
value: 'test-service-name',
},
],
op: 'AND',
},
},
],
},
},
};
render(
<QueryBuilderContext.Provider value={mockTraceToLogsContextValue as any}>
<PreferenceContextProvider>
<LogsExplorerViews
selectedView={SELECTED_VIEWS.SEARCH}
showFrequencyChart
setIsLoadingQueries={(): void => {}}
listQueryKeyRef={{ current: {} }}
chartQueryKeyRef={{ current: {} }}
/>
</PreferenceContextProvider>
</QueryBuilderContext.Provider>,
);
// Check for custom empty state message
expect(screen.getByText(/This query had no results./i)).toBeInTheDocument();
expect(
screen.getByText(/Edit your query and try again!/i),
).toBeInTheDocument();
});
});

View File

@@ -29,7 +29,11 @@ import NoLogs from '../NoLogs/NoLogs';
import InfinityTableView from './InfinityTableView';
import { LogsExplorerListProps } from './LogsExplorerList.interfaces';
import { InfinityWrapperStyled } from './styles';
import { convertKeysToColumnFields } from './utils';
import {
convertKeysToColumnFields,
getEmptyLogsListConfig,
isTraceToLogsQuery,
} from './utils';
function Footer(): JSX.Element {
return <Spinner height={20} tip="Getting Logs" />;
@@ -63,6 +67,12 @@ function LogsExplorerList({
currentStagedQueryData?.aggregateOperator || StringOperators.NOOP,
});
const {
currentQuery,
lastUsedQuery,
redirectWithQueryBuilderData,
} = useQueryBuilder();
const activeLogIndex = useMemo(
() => logs.findIndex(({ id }) => id === activeLogId),
[logs, activeLogId],
@@ -186,6 +196,44 @@ function LogsExplorerList({
selectedFields,
]);
const isTraceToLogsNavigation = useMemo(() => {
if (!currentStagedQueryData) return false;
return isTraceToLogsQuery(currentStagedQueryData);
}, [currentStagedQueryData]);
const handleClearFilters = useCallback((): void => {
const queryIndex = lastUsedQuery ?? 0;
const updatedQuery = currentQuery?.builder.queryData?.[queryIndex];
if (!updatedQuery) return;
if (updatedQuery?.filters?.items) {
updatedQuery.filters.items = [];
}
const preparedQuery = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx: number) => ({
...item,
filters: {
...item.filters,
items: idx === queryIndex ? [] : [...(item.filters?.items || [])],
},
})),
},
};
redirectWithQueryBuilderData(preparedQuery);
}, [currentQuery, lastUsedQuery, redirectWithQueryBuilderData]);
const getEmptyStateMessage = useMemo(() => {
if (!isTraceToLogsNavigation) return;
return getEmptyLogsListConfig(handleClearFilters);
}, [isTraceToLogsNavigation, handleClearFilters]);
return (
<div className="logs-list-view-container">
{(isLoading || (isFetching && logs.length === 0)) && <LogsLoading />}
@@ -201,7 +249,11 @@ function LogsExplorerList({
logs.length === 0 &&
!isError &&
isFilterApplied && (
<EmptyLogsSearch dataSource={DataSource.LOGS} panelType="LIST" />
<EmptyLogsSearch
dataSource={DataSource.LOGS}
panelType="LIST"
customMessage={getEmptyStateMessage}
/>
)}
{isError && !isLoading && !isFetching && <LogsError />}

View File

@@ -1,5 +1,9 @@
import { IField } from 'types/api/logs/fields';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
export const convertKeysToColumnFields = (
keys: BaseAutocompleteData[],
@@ -9,3 +13,56 @@ export const convertKeysToColumnFields = (
name: item.key,
type: item.type as string,
}));
/**
* Determines if a query represents a trace-to-logs navigation
* by checking for the presence of a trace_id filter.
*/
export const isTraceToLogsQuery = (queryData: IBuilderQuery): boolean => {
// Check if this is a trace-to-logs query by looking for trace_id filter
if (!queryData?.filters?.items) return false;
const traceIdFilter = queryData.filters.items.find(
(item: TagFilterItem) => item.key?.key === 'trace_id',
);
return !!traceIdFilter;
};
export type EmptyLogsListConfig = {
title: string;
subTitle: string;
description: string | string[];
documentationLinks?: Array<{
text: string;
url: string;
}>;
showClearFiltersButton?: boolean;
onClearFilters?: () => void;
clearFiltersButtonText?: string;
};
export const getEmptyLogsListConfig = (
handleClearFilters: () => void,
): EmptyLogsListConfig => ({
title: 'No logs found for this trace.',
subTitle: 'This could be because :',
description: [
'Logs are not linked to Traces.',
'Logs are not being sent to SigNoz.',
'No logs are associated with this particular trace/span.',
],
documentationLinks: [
{
text: 'Sending logs to SigNoz',
url: 'https://signoz.io/docs/logs-management/send-logs-to-signoz/',
},
{
text: 'Correlate traces and logs',
url:
'https://signoz.io/docs/traces-management/guides/correlate-traces-and-logs/',
},
],
clearFiltersButtonText: 'Clear filters from Trace to view other logs',
showClearFiltersButton: true,
onClearFilters: handleClearFilters,
});

View File

@@ -551,19 +551,19 @@ function LogsExplorerViews({
if (!stagedQuery) return [];
if (panelType === PANEL_TYPES.LIST) {
if (listChartData && listChartData.payload.data.result.length > 0) {
if (listChartData && listChartData.payload.data?.result.length > 0) {
return listChartData.payload.data.result;
}
return [];
}
if (!data || data.payload.data.result.length === 0) return [];
if (!data || data.payload.data?.result.length === 0) return [];
const isGroupByExist = stagedQuery.builder.queryData.some(
(queryData) => queryData.groupBy.length > 0,
);
const firstPayloadQuery = data.payload.data.result.find(
const firstPayloadQuery = data.payload.data?.result.find(
(item) => item.queryName === listQuery?.queryName,
);

View File

@@ -46,6 +46,14 @@ export const logsQueryRangeSuccessResponse = {
],
},
};
export const logsQueryRangeEmptyResponse = {
resultType: '',
result: [
{
queryName: 'A',
},
],
};
export const logsPaginationQueryRangeSuccessResponse = ({
offset = 0,

View File

@@ -4521,24 +4521,27 @@ func (aH *APIHandler) sendQueryResultEvents(r *http.Request, result []*v3.Result
}
queryInfoResult := NewQueryInfoResult(queryRangeParams, version)
if !(len(result) > 0 && (len(result[0].Series) > 0 || len(result[0].List) > 0)) {
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.UserID, "Telemetry Query Returned Empty", queryInfoResult.ToMap())
return
}
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.UserID, "Telemetry Query Returned Results", queryInfoResult.ToMap())
if !(queryInfoResult.LogsUsed || queryInfoResult.MetricsUsed || queryInfoResult.TracesUsed) {
return
}
referrer := r.Header.Get("Referer")
if referrer == "" {
properties := queryInfoResult.ToMap()
if !(len(result) > 0 && (len(result[0].Series) > 0 || len(result[0].List) > 0 || len(result[0].Table.Rows) > 0)) {
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.UserID, "Telemetry Query Returned Empty", properties)
return
}
referrer := r.Header.Get("Referer")
if referrer == "" {
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.UserID, "Telemetry Query Returned Results", properties)
return
}
properties["referrer"] = referrer
if matched, _ := regexp.MatchString(`/dashboard/[a-zA-Z0-9\-]+/(new|edit)(?:\?.*)?$`, referrer); matched {
properties := queryInfoResult.ToMap()
if dashboardIDRegex, err := regexp.Compile(`/dashboard/([a-f0-9\-]+)/`); err == nil {
if matches := dashboardIDRegex.FindStringSubmatch(referrer); len(matches) > 1 {
@@ -4552,13 +4555,12 @@ func (aH *APIHandler) sendQueryResultEvents(r *http.Request, result []*v3.Result
}
}
properties["referrer"] = referrer
properties["module_name"] = "dashboard"
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.UserID, "Telemetry Query Returned Results", properties)
return
}
if matched, _ := regexp.MatchString(`/alerts/(new|edit)(?:\?.*)?$`, referrer); matched {
properties := queryInfoResult.ToMap()
if alertIDRegex, err := regexp.Compile(`ruleId=(\d+)`); err == nil {
if matches := alertIDRegex.FindStringSubmatch(referrer); len(matches) > 1 {
@@ -4566,11 +4568,13 @@ func (aH *APIHandler) sendQueryResultEvents(r *http.Request, result []*v3.Result
}
}
properties["referrer"] = referrer
properties["module_name"] = "rule"
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.UserID, "Telemetry Query Returned Results", properties)
return
}
aH.Signoz.Analytics.TrackUser(r.Context(), claims.OrgID, claims.UserID, "Telemetry Query Returned Results", properties)
}
func (aH *APIHandler) QueryRangeV3(w http.ResponseWriter, r *http.Request) {