Compare commits

..

28 Commits

Author SHA1 Message Date
Srikanth Chekuri
aea42ba5ca Merge branch 'main' into enhancement/cmd-click-stack 2025-10-30 00:33:25 +05:30
Shaheer Kochai
01e0b36d62 fix: overall improvements to span logs drawer empty state (i.e. trace logs empty state vs. span logs empty state + UI improvements) (#9252)
* chore: remove the applied filters in related signals drawer

* chore: make the span logs highlight color more prominent

* fix: add label to open trace logs in logs explorer button

* feat: improve the span logs empty state i.e. add support for no logs for trace_id

* refactor: refactor the span logs content and make it readable

* test: add tests for span logs

* chore: improve tests

* refactor: simplify condition

* chore: remove redundant test

* fix: make trace_id logs request only if drawer is open

* chore: fix failing tests + overall improvements

* Update frontend/src/container/SpanDetailsDrawer/__tests__/SpanDetailsDrawer.test.tsx

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* chore: fix the failing test

* fix: fix the light mode styles for empty logs component

* chore: update the empty state copy

* chore: fix the failing tests by updating the assertions with correct empty state copy

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-10-29 16:20:52 +00:00
Ekansh Gupta
e90bb016f7 feat: add span percentile for traces (#8955)
* feat: add span percentile for traces

* feat: fixed merge conflicts

* feat: fixed merge conflicts

* feat: fixed merge conflicts

* feat: added span percentile

* feat: added span percentile

* feat: added test for span percentiles

* feat: added test for span percentiles

* feat: added test for span percentiles

* feat: added test for span percentiles

* feat: removed comments

* feat: moved everything to module

* feat: refactored span percentile

* feat: refactored span percentile

* feat: refactored module package

* feat: fixed tests for span percentile

* feat: refactored span percentile and changed query

* feat: refactored span percentile and changed query

* feat: refactored span percentile and changed query

* feat: refactored span percentile and changed query

* feat: added better error handling

* feat: added better error handling

* feat: addressed pr comments

* feat: addressed pr comments

* feat: renamed translator.go

* feat: added query settings

* feat: added full query test

* feat: added fingerprinting

* feat: refactored tests

* feat: refactored to use fingerprinting and changed tests

* feat: refactored to use fingerprinting and changed tests

* feat: refactored to use fingerprinting and changed tests

* feat: changed errors

* feat: removed redundant tests

* feat: removed redundant tests

* feat: moved everything to trace aggregation and updated tests

* feat: addressed comments regarding metadatastore

* feat: addressed comments regarding metadatastore

* feat: addressed comments regarding metadatastore

* feat: addressed comments for float64

* feat: cleaned up code

* feat: cleaned up code
2025-10-29 21:35:59 +05:30
Amlan Kumar Nandy
bdecbfb7f5 chore: add missing unit tests for getLegend (#9374) 2025-10-29 16:27:20 +05:30
Nageshbansal
3dced2b082 chore(costmeter): enable costmeter by default in docker installations (#9432)
* chore(costmeter): enable costmeter by default in docker installations

* chore(costmeter): enable costmeter by default in docker installations
2025-10-29 15:24:54 +05:30
Manika Malhotra
455ba0549f Merge branch 'main' into enhancement/cmd-click-stack 2025-10-22 11:32:29 +05:30
manika-signoz
5f2c302551 chore: extract isEventObject utility to separate file 2025-10-13 23:56:49 +05:30
manika-signoz
15c2dc700a chore: use requireActual 2025-10-13 23:41:21 +05:30
manika-signoz
02fa0dbc32 Merge branch 'main' into enhancement/cmd-click-stack 2025-10-13 23:25:35 +05:30
manika-signoz
e0948033c8 chore: re-export isEventObject utility from mocks 2025-10-13 23:24:28 +05:30
manika-signoz
a1115ac65b Merge branch 'main' into enhancement/cmd-click-stack 2025-10-13 10:30:04 +05:30
manika-signoz
9bcb88c747 Merge branch 'main' into enhancement/cmd-click-stack 2025-10-09 17:03:12 +05:30
manika-signoz
367bf7b4b5 Merge branch 'main' into enhancement/cmd-click-stack 2025-10-09 00:57:31 +05:30
manika-signoz
59b68057b8 Merge branch 'main' into enhancement/cmd-click-stack 2025-10-08 15:38:11 +05:30
manika-signoz
fa1b2ddf7c Merge branch 'main' into enhancement/cmd-click-stack 2025-10-07 23:07:15 +05:30
manika-signoz
642a0e5656 fix: dashboardandalertspopover.test to use usesafenav mock 2025-09-30 19:31:34 +05:30
manika-signoz
cb99ee1ac1 fix: failing test cases due to isEventObject, add to mock 2025-09-30 19:16:54 +05:30
manika-signoz
7616cb89e4 Merge branch 'enhancement/cmd-click-stack' of github.com:SigNoz/signoz into enhancement/cmd-click-stack 2025-09-30 17:41:07 +05:30
manika-signoz
bf780c7445 chore: resolve comments, improve type safety in usesafenav 2025-09-30 17:40:17 +05:30
manika-signoz
61062dfd8d Merge branch 'main' into enhancement/cmd-click-stack 2025-09-30 17:08:38 +05:30
manika-signoz
5b7af9651c Merge branch 'main' into enhancement/cmd-click-stack 2025-09-29 10:40:22 +05:30
manika-signoz
b9012f6150 Merge branch 'main' into enhancement/cmd-click-stack 2025-09-24 13:12:36 +05:30
manika-signoz
7ab81780b3 Merge branch 'main' into enhancement/cmd-click-stack 2025-09-24 10:06:51 +05:30
manika-signoz
a16f51457f Merge branch 'main' into enhancement/cmd-click-stack 2025-09-23 16:32:51 +05:30
manika-signoz
38a38b5645 test: add tests for history.push 2025-09-23 16:30:16 +05:30
manika-signoz
bb04bc5044 Merge branch 'main' into enhancement/cmd-click-stack 2025-09-23 16:16:16 +05:30
manika-signoz
58736f40dc feat: add support for location object in history.push override 2025-09-22 19:05:42 +05:30
manika-signoz
91154249d6 feat: add history.push and safeNavigate method overrides 2025-09-22 18:55:52 +05:30
36 changed files with 2236 additions and 237 deletions

1
.gitignore vendored
View File

@@ -106,6 +106,7 @@ downloads/
eggs/
.eggs/
lib/
!frontend/src/lib/
lib64/
parts/
sdist/

View File

@@ -1,3 +1,10 @@
connectors:
signozmeter:
metrics_flush_interval: 1h
dimensions:
- name: service.name
- name: deployment.environment
- name: host.name
receivers:
otlp:
protocols:
@@ -21,6 +28,10 @@ processors:
send_batch_size: 10000
send_batch_max_size: 11000
timeout: 10s
batch/meter:
send_batch_max_size: 25000
send_batch_size: 20000
timeout: 1s
resourcedetection:
# Using OTEL_RESOURCE_ATTRIBUTES envvar, env detector adds custom labels.
detectors: [env, system]
@@ -66,6 +77,11 @@ exporters:
dsn: tcp://clickhouse:9000/signoz_logs
timeout: 10s
use_new_schema: true
signozclickhousemeter:
dsn: tcp://clickhouse:9000/signoz_meter
timeout: 45s
sending_queue:
enabled: false
service:
telemetry:
logs:
@@ -77,16 +93,20 @@ service:
traces:
receivers: [otlp]
processors: [signozspanmetrics/delta, batch]
exporters: [clickhousetraces]
exporters: [clickhousetraces, signozmeter]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [signozclickhousemetrics]
exporters: [signozclickhousemetrics, signozmeter]
metrics/prometheus:
receivers: [prometheus]
processors: [batch]
exporters: [signozclickhousemetrics]
exporters: [signozclickhousemetrics, signozmeter]
logs:
receivers: [otlp]
processors: [batch]
exporters: [clickhouselogsexporter]
exporters: [clickhouselogsexporter, signozmeter]
metrics/meter:
receivers: [signozmeter]
processors: [batch/meter]
exporters: [signozclickhousemeter]

View File

@@ -1,3 +1,10 @@
connectors:
signozmeter:
metrics_flush_interval: 1h
dimensions:
- name: service.name
- name: deployment.environment
- name: host.name
receivers:
otlp:
protocols:
@@ -21,6 +28,10 @@ processors:
send_batch_size: 10000
send_batch_max_size: 11000
timeout: 10s
batch/meter:
send_batch_max_size: 25000
send_batch_size: 20000
timeout: 1s
resourcedetection:
# Using OTEL_RESOURCE_ATTRIBUTES envvar, env detector adds custom labels.
detectors: [env, system]
@@ -66,6 +77,11 @@ exporters:
dsn: tcp://clickhouse:9000/signoz_logs
timeout: 10s
use_new_schema: true
signozclickhousemeter:
dsn: tcp://clickhouse:9000/signoz_meter
timeout: 45s
sending_queue:
enabled: false
service:
telemetry:
logs:
@@ -77,16 +93,20 @@ service:
traces:
receivers: [otlp]
processors: [signozspanmetrics/delta, batch]
exporters: [clickhousetraces]
exporters: [clickhousetraces, signozmeter]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [signozclickhousemetrics]
exporters: [signozclickhousemetrics, signozmeter]
metrics/prometheus:
receivers: [prometheus]
processors: [batch]
exporters: [signozclickhousemetrics]
exporters: [signozclickhousemetrics, signozmeter]
logs:
receivers: [otlp]
processors: [batch]
exporters: [clickhouselogsexporter]
exporters: [clickhouselogsexporter, signozmeter]
metrics/meter:
receivers: [signozmeter]
processors: [batch/meter]
exporters: [signozclickhousemeter]

View File

@@ -1,4 +1,6 @@
// Mock for useSafeNavigate hook to avoid React Router version conflicts in tests
export { isEventObject } from '../src/utils/isEventObject';
interface SafeNavigateOptions {
replace?: boolean;
state?: unknown;

View File

@@ -57,8 +57,8 @@ export const RawLogViewContainer = styled(Row)<{
transition: background-color 2s ease-in;`
: ''}
${({ $isCustomHighlighted, $isDarkMode, $logType }): string =>
getCustomHighlightBackground($isCustomHighlighted, $isDarkMode, $logType)}
${({ $isCustomHighlighted }): string =>
getCustomHighlightBackground($isCustomHighlighted)}
`;
export const InfoIconWrapper = styled(Info)`

View File

@@ -86,6 +86,7 @@ export const REACT_QUERY_KEY = {
SPAN_LOGS: 'SPAN_LOGS',
SPAN_BEFORE_LOGS: 'SPAN_BEFORE_LOGS',
SPAN_AFTER_LOGS: 'SPAN_AFTER_LOGS',
TRACE_ONLY_LOGS: 'TRACE_ONLY_LOGS',
// Routing Policies Query Keys
GET_ROUTING_POLICIES: 'GET_ROUTING_POLICIES',

View File

@@ -171,3 +171,30 @@
}
}
}
.lightMode {
.empty-logs-search {
&__resources-card {
background: var(--bg-vanilla-100);
border: 1px solid var(--bg-vanilla-300);
}
&__resources-title {
color: var(--bg-ink-400);
}
&__resources-description,
&__description-list,
&__subtitle {
color: var(--bg-ink-300);
}
&__title {
color: var(--bg-ink-500);
}
&__clear-filters-btn {
border: 1px dashed var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
}
}

View File

@@ -23,11 +23,15 @@ const mockAlerts = [mockAlert1, mockAlert2];
const mockDashboards = [mockDashboard1, mockDashboard2];
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('hooks/useSafeNavigate', () => {
const actual = jest.requireActual('hooks/useSafeNavigate');
return {
...actual,
useSafeNavigate: (): any => ({
safeNavigate: mockSafeNavigate,
}),
};
});
const mockSetQuery = jest.fn();
const mockUrlQuery = {

View File

@@ -0,0 +1,289 @@
import { getLegend } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { QueryData } from 'types/api/widgets/getQuery';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { getMockQuery, getMockQueryData } from './testUtils';
const mockQueryData = getMockQueryData();
const mockQuery = getMockQuery();
const MOCK_LABEL_NAME = 'mock-label-name';
describe('getLegend', () => {
it('should directly return the label name for clickhouse query', () => {
const legendsData = getLegend(
mockQueryData,
getMockQuery({
queryType: EQueryType.CLICKHOUSE,
}),
MOCK_LABEL_NAME,
);
expect(legendsData).toBeDefined();
expect(legendsData).toBe(MOCK_LABEL_NAME);
});
it('should directly return the label name for promql query', () => {
const legendsData = getLegend(
mockQueryData,
getMockQuery({
queryType: EQueryType.PROM,
}),
MOCK_LABEL_NAME,
);
expect(legendsData).toBeDefined();
expect(legendsData).toBe(MOCK_LABEL_NAME);
});
it('should return alias when single builder query with single aggregation and alias (logs)', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [{ expression: "sum(bytes) as 'alias_sum'" }],
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe('alias_sum');
});
it('should return legend when single builder query with no alias but legend set (builder)', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [{ expression: 'count()' }],
legend: 'custom-legend',
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe('custom-legend');
});
it('should return label when grouped by with single aggregation (builder)', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [{ expression: 'count()' }],
groupBy: [
{ key: 'serviceName', dataType: DataTypes.String, type: 'resource' },
],
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe(MOCK_LABEL_NAME);
});
it("should return '<alias>-<label>' when grouped by with multiple aggregations (builder)", () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [
{ expression: "sum(bytes) as 'sum_b'" },
{ expression: 'count()' },
],
groupBy: [
{ key: 'serviceName', dataType: DataTypes.String, type: 'resource' },
],
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe(`sum_b-${MOCK_LABEL_NAME}`);
});
it('should fallback to label or query name when no alias/expression', () => {
const legendsData = getLegend(mockQueryData, mockQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe(MOCK_LABEL_NAME);
});
it('should return alias when single query with multiple aggregations and no group by', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [
{ expression: "sum(bytes) as 'total'" },
{ expression: 'count()' },
],
groupBy: [],
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe('total');
});
it("should return '<alias>-<label>' when multiple queries with group by", () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [
{ expression: "sum(bytes) as 'sum_b'" },
{ expression: 'count()' },
],
groupBy: [
{ key: 'serviceName', dataType: DataTypes.String, type: 'resource' },
],
},
{
...mockQuery.builder.queryData[0],
queryName: 'B',
dataSource: DataSource.LOGS,
aggregations: [{ expression: 'count()' }],
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe(`sum_b-${MOCK_LABEL_NAME}`);
});
it('should return label according to the index of the query', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [
{ expression: "sum(bytes) as 'sum_a'" },
{ expression: 'count()' },
],
groupBy: [
{ key: 'serviceName', dataType: DataTypes.String, type: 'resource' },
],
},
{
...mockQuery.builder.queryData[0],
queryName: 'B',
dataSource: DataSource.LOGS,
aggregations: [{ expression: 'count()' }],
},
],
},
});
const legendsData = getLegend(
{
...mockQueryData,
metaData: {
...mockQueryData.metaData,
index: 1,
},
} as QueryData,
payloadQuery,
MOCK_LABEL_NAME,
);
expect(legendsData).toBe(`count()-${MOCK_LABEL_NAME}`);
});
it('should handle trace operator with multiple queries and group by', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: 'A',
dataSource: DataSource.TRACES,
aggregations: [{ expression: 'count()' }],
},
],
queryTraceOperator: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.TRACES,
aggregations: [
{ expression: "count() as 'total_count' avg(duration_nano)" },
],
groupBy: [
{ key: 'service.name', dataType: DataTypes.String, type: 'resource' },
],
expression: 'A',
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe(`total_count-${MOCK_LABEL_NAME}`);
});
it('should handle single trace operator query with group by', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [],
queryTraceOperator: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.TRACES,
aggregations: [{ expression: "count() as 'total' avg(duration_nano)" }],
groupBy: [
{ key: 'service.name', dataType: DataTypes.String, type: 'resource' },
],
expression: 'A && B',
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe(`total-${MOCK_LABEL_NAME}`);
});
});

View File

@@ -0,0 +1,36 @@
import { initialQueryState } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryData } from 'types/api/widgets/getQuery';
import { EQueryType } from 'types/common/dashboard';
export function getMockQueryData(): QueryData {
return {
lowerBoundSeries: [],
upperBoundSeries: [],
predictedSeries: [],
anomalyScores: [],
metric: {},
queryName: 'test-query-name',
legend: 'test-legend',
values: [],
quantity: [],
unit: 'test-unit',
table: {
rows: [],
columns: [],
},
metaData: {
alias: 'test-alias',
index: 0,
queryName: 'test-query-name',
},
};
}
export function getMockQuery(overrides?: Partial<Query>): Query {
return {
...initialQueryState,
queryType: EQueryType.QUERY_BUILDER,
...overrides,
};
}

View File

@@ -12,7 +12,9 @@ import {
PANEL_TYPES,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import LogsError from 'container/LogsError/LogsError';
import { EmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
@@ -30,8 +32,6 @@ import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
import { useSpanContextLogs } from './useSpanContextLogs';
interface SpanLogsProps {
traceId: string;
spanId: string;
@@ -39,29 +39,29 @@ interface SpanLogsProps {
startTime: number;
endTime: number;
};
logs: ILog[];
isLoading: boolean;
isError: boolean;
isFetching: boolean;
isLogSpanRelated: (logId: string) => boolean;
handleExplorerPageRedirect: () => void;
emptyStateConfig?: EmptyLogsListConfig;
}
function SpanLogs({
traceId,
spanId,
timeRange,
logs,
isLoading,
isError,
isFetching,
isLogSpanRelated,
handleExplorerPageRedirect,
emptyStateConfig,
}: SpanLogsProps): JSX.Element {
const { updateAllQueriesOperators } = useQueryBuilder();
const {
logs,
isLoading,
isError,
isFetching,
isLogSpanRelated,
} = useSpanContextLogs({
traceId,
spanId,
timeRange,
});
// Create trace_id and span_id filters for logs explorer navigation
const createLogsFilter = useCallback(
(targetSpanId: string): TagFilter => {
@@ -236,9 +236,7 @@ function SpanLogs({
<img src="/Icons/no-data.svg" alt="no-data" className="no-data-img" />
<Typography.Text className="no-data-text-1">
No logs found for selected span.
<span className="no-data-text-2">
Try viewing logs for the current trace.
</span>
<span className="no-data-text-2">View logs for the current trace.</span>
</Typography.Text>
</section>
<section className="action-section">
@@ -249,24 +247,45 @@ function SpanLogs({
onClick={handleExplorerPageRedirect}
size="md"
>
Log Explorer
View Logs
</Button>
</section>
</div>
);
const renderSpanLogsContent = (): JSX.Element | null => {
if (isLoading || isFetching) {
return <LogsLoading />;
}
if (isError) {
return <LogsError />;
}
if (logs.length === 0) {
if (emptyStateConfig) {
return (
<EmptyLogsSearch
dataSource={DataSource.LOGS}
panelType="LIST"
customMessage={emptyStateConfig}
/>
);
}
return renderNoLogsFound();
}
return renderContent;
};
return (
<div className={cx('span-logs', { 'span-logs-empty': logs.length === 0 })}>
{(isLoading || isFetching) && <LogsLoading />}
{!isLoading &&
!isFetching &&
!isError &&
logs.length === 0 &&
renderNoLogsFound()}
{isError && !isLoading && !isFetching && <LogsError />}
{!isLoading && !isFetching && !isError && logs.length > 0 && renderContent}
{renderSpanLogsContent()}
</div>
);
}
SpanLogs.defaultProps = {
emptyStateConfig: undefined,
};
export default SpanLogs;

View File

@@ -0,0 +1,214 @@
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import { server } from 'mocks-server/server';
import { render, screen, userEvent } from 'tests/test-utils';
import SpanLogs from '../SpanLogs';
// Mock external dependencies
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => ({
updateAllQueriesOperators: jest.fn().mockReturnValue({
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
aggregateOperator: 'noop',
filter: { expression: "trace_id = 'test-trace-id'" },
expression: 'A',
disabled: false,
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
groupBy: [],
limit: null,
having: [],
},
],
queryFormulas: [],
},
queryType: 'builder',
}),
}),
}));
// Mock window.open
const mockWindowOpen = jest.fn();
Object.defineProperty(window, 'open', {
writable: true,
value: mockWindowOpen,
});
// Mock Virtuoso to avoid complex virtualization
jest.mock('react-virtuoso', () => ({
Virtuoso: jest.fn(({ data, itemContent }: any) => (
<div data-testid="virtuoso">
{data?.map((item: any, index: number) => (
<div key={item.id || index} data-testid={`log-item-${item.id}`}>
{itemContent(index, item)}
</div>
))}
</div>
)),
}));
// Mock RawLogView component
jest.mock(
'components/Logs/RawLogView',
() =>
function MockRawLogView({
data,
onLogClick,
isHighlighted,
helpTooltip,
}: any): JSX.Element {
return (
<button
type="button"
data-testid={`raw-log-${data.id}`}
className={isHighlighted ? 'log-highlighted' : 'log-context'}
title={helpTooltip}
onClick={(e): void => onLogClick?.(data, e)}
>
<div>{data.body}</div>
<div>{data.timestamp}</div>
</button>
);
},
);
// Mock PreferenceContextProvider
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
PreferenceContextProvider: ({ children }: any): JSX.Element => (
<div>{children}</div>
),
}));
// Mock OverlayScrollbar
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
default: ({ children }: any): JSX.Element => (
<div data-testid="overlay-scrollbar">{children}</div>
),
}));
// Mock LogsLoading component
jest.mock('container/LogsLoading/LogsLoading', () => ({
LogsLoading: function MockLogsLoading(): JSX.Element {
return <div data-testid="logs-loading">Loading logs...</div>;
},
}));
// Mock LogsError component
jest.mock(
'container/LogsError/LogsError',
() =>
function MockLogsError(): JSX.Element {
return <div data-testid="logs-error">Error loading logs</div>;
},
);
// Don't mock EmptyLogsSearch - test the actual component behavior
const TEST_TRACE_ID = 'test-trace-id';
const TEST_SPAN_ID = 'test-span-id';
const defaultProps = {
traceId: TEST_TRACE_ID,
spanId: TEST_SPAN_ID,
timeRange: {
startTime: 1640995200000,
endTime: 1640995260000,
},
logs: [],
isLoading: false,
isError: false,
isFetching: false,
isLogSpanRelated: jest.fn().mockReturnValue(false),
handleExplorerPageRedirect: jest.fn(),
};
describe('SpanLogs', () => {
beforeEach(() => {
jest.clearAllMocks();
mockWindowOpen.mockClear();
});
afterEach(() => {
server.resetHandlers();
});
it('should show simple empty state when emptyStateConfig is not provided', () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<SpanLogs {...defaultProps} />);
// Should show simple empty state (no emptyStateConfig provided)
expect(
screen.getByText('No logs found for selected span.'),
).toBeInTheDocument();
expect(
screen.getByText('View logs for the current trace.'),
).toBeInTheDocument();
expect(
screen.getByRole('button', {
name: /view logs/i,
}),
).toBeInTheDocument();
// Should NOT show enhanced empty state
expect(screen.queryByTestId('empty-logs-search')).not.toBeInTheDocument();
expect(screen.queryByTestId('documentation-links')).not.toBeInTheDocument();
});
it('should show enhanced empty state when entire trace has no logs', () => {
render(
<SpanLogs
// eslint-disable-next-line react/jsx-props-no-spreading
{...defaultProps}
emptyStateConfig={getEmptyLogsListConfig(jest.fn())}
/>,
);
// Should show enhanced empty state with custom message
expect(screen.getByText('No logs found for this trace.')).toBeInTheDocument();
expect(screen.getByText('This could be because :')).toBeInTheDocument();
// Should show description list
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();
// Should show documentation links
expect(screen.getByText('RESOURCES')).toBeInTheDocument();
expect(screen.getByText('Sending logs to SigNoz')).toBeInTheDocument();
expect(screen.getByText('Correlate traces and logs')).toBeInTheDocument();
// Should NOT show simple empty state
expect(
screen.queryByText('No logs found for selected span.'),
).not.toBeInTheDocument();
});
it('should call handleExplorerPageRedirect when Log Explorer button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockHandleExplorerPageRedirect = jest.fn();
render(
<SpanLogs
// eslint-disable-next-line react/jsx-props-no-spreading
{...defaultProps}
handleExplorerPageRedirect={mockHandleExplorerPageRedirect}
/>,
);
const logExplorerButton = screen.getByRole('button', {
name: /view logs/i,
});
await user.click(logExplorerButton);
expect(mockHandleExplorerPageRedirect).toHaveBeenCalledTimes(1);
});
});

View File

@@ -85,7 +85,7 @@ export const getTraceOnlyFilters = (traceId: string): TagFilter => ({
type: '',
key: 'trace_id',
},
op: 'in',
op: '=',
value: traceId,
},
],

View File

@@ -11,7 +11,7 @@ import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Filter } from 'types/api/v5/queryRange';
import { v4 as uuid } from 'uuid';
import { getSpanLogsQueryPayload } from './constants';
import { getSpanLogsQueryPayload, getTraceOnlyFilters } from './constants';
interface UseSpanContextLogsProps {
traceId: string;
@@ -20,6 +20,7 @@ interface UseSpanContextLogsProps {
startTime: number;
endTime: number;
};
isDrawerOpen?: boolean;
}
interface UseSpanContextLogsReturn {
@@ -29,6 +30,7 @@ interface UseSpanContextLogsReturn {
isFetching: boolean;
spanLogIds: Set<string>;
isLogSpanRelated: (logId: string) => boolean;
hasTraceIdLogs: boolean;
}
const traceIdKey = {
@@ -110,6 +112,7 @@ export const useSpanContextLogs = ({
traceId,
spanId,
timeRange,
isDrawerOpen = true,
}: UseSpanContextLogsProps): UseSpanContextLogsReturn => {
const [allLogs, setAllLogs] = useState<ILog[]>([]);
const [spanLogIds, setSpanLogIds] = useState<Set<string>>(new Set());
@@ -264,6 +267,43 @@ export const useSpanContextLogs = ({
setAllLogs(combined);
}, [beforeLogs, spanLogs, afterLogs]);
// Phase 4: Check for trace_id-only logs when span has no logs
// This helps differentiate between "no logs for span" vs "no logs for trace"
const traceOnlyFilter = useMemo(() => {
if (spanLogs.length > 0) return null;
const filters = getTraceOnlyFilters(traceId);
return convertFiltersToExpression(filters);
}, [traceId, spanLogs.length]);
const traceOnlyQueryPayload = useMemo(() => {
if (!traceOnlyFilter) return null;
return getSpanLogsQueryPayload(
timeRange.startTime,
timeRange.endTime,
traceOnlyFilter,
);
}, [timeRange.startTime, timeRange.endTime, traceOnlyFilter]);
const { data: traceOnlyData } = useQuery({
queryKey: [
REACT_QUERY_KEY.TRACE_ONLY_LOGS,
traceId,
timeRange.startTime,
timeRange.endTime,
],
queryFn: () =>
GetMetricQueryRange(traceOnlyQueryPayload as any, ENTITY_VERSION_V5),
enabled: isDrawerOpen && !!traceOnlyQueryPayload && spanLogs.length === 0,
staleTime: FIVE_MINUTES_IN_MS,
});
const hasTraceIdLogs = useMemo(() => {
if (spanLogs.length > 0) return true;
return !!(
traceOnlyData?.payload?.data?.newResult?.data?.result?.[0]?.list?.length || 0
);
}, [spanLogs.length, traceOnlyData]);
// Helper function to check if a log belongs to the span
const isLogSpanRelated = useCallback(
(logId: string): boolean => spanLogIds.has(logId),
@@ -277,5 +317,6 @@ export const useSpanContextLogs = ({
isFetching: isSpanFetching || isBeforeFetching || isAfterFetching,
spanLogIds,
isLogSpanRelated,
hasTraceIdLogs,
};
};

View File

@@ -37,7 +37,8 @@
align-items: center;
justify-content: space-between;
.open-in-explorer {
width: 30px;
display: flex;
align-items: center;
height: 30px;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);

View File

@@ -11,39 +11,20 @@ import {
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Compass, X } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { Span } from 'types/api/trace/getTraceV2';
import { LogsAggregatorOperator } from 'types/common/queryBuilder';
import { RelatedSignalsViews } from '../constants';
import SpanLogs from '../SpanLogs/SpanLogs';
import { useSpanContextLogs } from '../SpanLogs/useSpanContextLogs';
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
interface AppliedFiltersProps {
filters: TagFilterItem[];
}
function AppliedFilters({ filters }: AppliedFiltersProps): JSX.Element {
return (
<div className="span-related-signals-drawer__applied-filters">
<div className="span-related-signals-drawer__filters-list">
{filters.map((filter) => (
<div key={filter.id} className="span-related-signals-drawer__filter-tag">
<Typography.Text>
{filter.key?.key}={filter.value}
</Typography.Text>
</div>
))}
</div>
</div>
);
}
interface SpanRelatedSignalsProps {
selectedSpan: Span;
traceStartTime: number;
@@ -66,6 +47,23 @@ function SpanRelatedSignals({
);
const isDarkMode = useIsDarkMode();
const {
logs,
isLoading,
isError,
isFetching,
isLogSpanRelated,
hasTraceIdLogs,
} = useSpanContextLogs({
traceId: selectedSpan.traceId,
spanId: selectedSpan.spanId,
timeRange: {
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
},
isDrawerOpen: isOpen,
});
const handleTabChange = useCallback((e: RadioChangeEvent): void => {
setSelectedView(e.target.value);
}, []);
@@ -75,25 +73,6 @@ function SpanRelatedSignals({
onClose();
}, [onClose]);
const appliedFilters = useMemo(
(): TagFilterItem[] => [
{
id: 'trace-id-filter',
key: {
key: 'trace_id',
id: 'trace-id-key',
dataType: 'string' as const,
isColumn: true,
type: '',
isJSON: false,
} as BaseAutocompleteData,
op: '=',
value: selectedSpan.traceId,
},
],
[selectedSpan.traceId],
);
const handleExplorerPageRedirect = useCallback((): void => {
const startTimeMs = traceStartTime - FIVE_MINUTES_IN_MS;
const endTimeMs = traceEndTime + FIVE_MINUTES_IN_MS;
@@ -146,6 +125,14 @@ function SpanRelatedSignals({
);
}, [selectedSpan.traceId, traceStartTime, traceEndTime]);
const emptyStateConfig = useMemo(
() => ({
...getEmptyLogsListConfig(() => {}),
showClearFiltersButton: false,
}),
[],
);
return (
<Drawer
width="50%"
@@ -210,23 +197,28 @@ function SpanRelatedSignals({
icon={<Compass size={18} />}
className="open-in-explorer"
onClick={handleExplorerPageRedirect}
/>
>
Open in Logs Explorer
</Button>
)}
</div>
{selectedView === RelatedSignalsViews.LOGS && (
<>
<AppliedFilters filters={appliedFilters} />
<SpanLogs
traceId={selectedSpan.traceId}
spanId={selectedSpan.spanId}
timeRange={{
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
}}
handleExplorerPageRedirect={handleExplorerPageRedirect}
/>
</>
<SpanLogs
traceId={selectedSpan.traceId}
spanId={selectedSpan.spanId}
timeRange={{
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
}}
logs={logs}
isLoading={isLoading}
isError={isError}
isFetching={isFetching}
isLogSpanRelated={isLogSpanRelated}
handleExplorerPageRedirect={handleExplorerPageRedirect}
emptyStateConfig={!hasTraceIdLogs ? emptyStateConfig : undefined}
/>
)}
</div>
)}

View File

@@ -16,6 +16,7 @@ import {
expectedAfterFilterExpression,
expectedBeforeFilterExpression,
expectedSpanFilterExpression,
expectedTraceOnlyFilterExpression,
mockAfterLogsResponse,
mockBeforeLogsResponse,
mockEmptyLogsResponse,
@@ -217,19 +218,22 @@ const renderSpanDetailsDrawer = (props = {}): void => {
};
describe('SpanDetailsDrawer', () => {
let apiCallHistory: any[] = [];
let apiCallHistory: any = {};
beforeEach(() => {
jest.clearAllMocks();
apiCallHistory = [];
apiCallHistory = {
span_logs: null,
before_logs: null,
after_logs: null,
trace_only_logs: null,
};
mockSafeNavigate.mockClear();
mockWindowOpen.mockClear();
mockUpdateAllQueriesOperators.mockClear();
// Setup API call tracking
(GetMetricQueryRange as jest.Mock).mockImplementation((query) => {
apiCallHistory.push(query);
// Determine response based on v5 filter expressions
const filterExpression =
query.query?.builder?.queryData?.[0]?.filter?.expression;
@@ -238,14 +242,23 @@ describe('SpanDetailsDrawer', () => {
// Check for span logs query (contains both trace_id and span_id)
if (filterExpression.includes('span_id')) {
apiCallHistory.span_logs = query;
return Promise.resolve(mockSpanLogsResponse);
}
// Check for before logs query (contains trace_id and id <)
if (filterExpression.includes('id <')) {
apiCallHistory.before_logs = query;
return Promise.resolve(mockBeforeLogsResponse);
}
// Check for after logs query (contains trace_id and id >)
if (filterExpression.includes('id >')) {
apiCallHistory.after_logs = query;
return Promise.resolve(mockAfterLogsResponse);
}
// Check for trace only logs query (contains trace_id)
if (filterExpression.includes('trace_id =')) {
apiCallHistory.trace_only_logs = query;
return Promise.resolve(mockAfterLogsResponse);
}
@@ -287,7 +300,7 @@ describe('SpanDetailsDrawer', () => {
});
});
it('should make three API queries when logs tab is opened', async () => {
it('should make 4 API queries when logs tab is opened', async () => {
renderSpanDetailsDrawer();
// Click on logs tab to trigger API calls
@@ -296,11 +309,16 @@ describe('SpanDetailsDrawer', () => {
// Wait for all API calls to complete
await waitFor(() => {
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
expect(GetMetricQueryRange).toHaveBeenCalledTimes(4);
});
// Verify the three distinct queries were made
const [spanQuery, beforeQuery, afterQuery] = apiCallHistory;
// Verify the four distinct queries were made
const {
span_logs: spanQuery,
before_logs: beforeQuery,
after_logs: afterQuery,
trace_only_logs: traceOnlyQuery,
} = apiCallHistory;
// 1. Span logs query (trace_id + span_id)
expect(spanQuery.query.builder.queryData[0].filter.expression).toBe(
@@ -316,6 +334,11 @@ describe('SpanDetailsDrawer', () => {
expect(afterQuery.query.builder.queryData[0].filter.expression).toBe(
expectedAfterFilterExpression,
);
// 4. Trace only logs query (trace_id)
expect(traceOnlyQuery.query.builder.queryData[0].filter.expression).toBe(
expectedTraceOnlyFilterExpression,
);
});
it('should use correct timestamp ordering for different query types', async () => {
@@ -327,10 +350,14 @@ describe('SpanDetailsDrawer', () => {
// Wait for all API calls to complete
await waitFor(() => {
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
expect(GetMetricQueryRange).toHaveBeenCalledTimes(4);
});
const [spanQuery, beforeQuery, afterQuery] = apiCallHistory;
const {
span_logs: spanQuery,
before_logs: beforeQuery,
after_logs: afterQuery,
} = apiCallHistory;
// Verify ordering: span query should use 'desc' (default)
expect(spanQuery.query.builder.queryData[0].orderBy[0].order).toBe('desc');
@@ -463,24 +490,6 @@ describe('SpanDetailsDrawer', () => {
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
it('should handle empty logs state', async () => {
// Mock empty response for all queries
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockEmptyLogsResponse);
renderSpanDetailsDrawer();
// Open logs view
const logsButton = screen.getByRole('radio', { name: /logs/i });
fireEvent.click(logsButton);
// Wait and verify empty state is shown
await waitFor(() => {
expect(
screen.getByText(/No logs found for selected span/),
).toBeInTheDocument();
});
});
it('should display span logs as highlighted and context logs as regular', async () => {
renderSpanDetailsDrawer();
@@ -490,7 +499,7 @@ describe('SpanDetailsDrawer', () => {
// Wait for all API calls to complete first
await waitFor(() => {
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
expect(GetMetricQueryRange).toHaveBeenCalledTimes(4);
});
// Wait for all logs to be rendered - both span logs and context logs

View File

@@ -12,7 +12,7 @@ export const mockSpan: Span = {
traceId: TEST_TRACE_ID,
name: TEST_SERVICE,
serviceName: TEST_SERVICE,
timestamp: 1640995200000000, // 2022-01-01 00:00:00 in microseconds
timestamp: 1640995200000, // 2022-01-01 00:00:00 in milliseconds
durationNano: 1000000000, // 1 second in nanoseconds
spanKind: 'server',
statusCodeString: 'STATUS_CODE_OK',
@@ -207,3 +207,4 @@ export const mockEmptyLogsResponse = {
export const expectedSpanFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND span_id = '${TEST_SPAN_ID}'`;
export const expectedBeforeFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND id < 'span-log-1'`;
export const expectedAfterFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND id > 'span-log-2'`;
export const expectedTraceOnlyFilterExpression = `trace_id = '${TEST_TRACE_ID}'`;

View File

@@ -1,7 +1,14 @@
import { cloneDeep, isEqual } from 'lodash-es';
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
import {
Location,
NavigateFunction,
useLocation,
useNavigate,
} from 'react-router-dom-v5-compat';
import { isEventObject } from 'utils/isEventObject';
// state uses 'any' because react-router's NavigateOptions interface uses it
interface NavigateOptions {
replace?: boolean;
state?: any;
@@ -83,6 +90,74 @@ const isDefaultNavigation = (currentUrl: URL, targetUrl: URL): boolean => {
return newKeys.length > 0;
};
// Helper function to extract options from arguments
const extractOptions = (
optionsOrEvent?:
| NavigateOptions
| React.MouseEvent
| MouseEvent
| KeyboardEvent,
options?: NavigateOptions,
): NavigateOptions => {
const isEvent = isEventObject(optionsOrEvent);
const actualOptions = isEvent ? options : (optionsOrEvent as NavigateOptions);
const shouldOpenInNewTab =
isEvent && (optionsOrEvent.metaKey || optionsOrEvent.ctrlKey);
return {
...actualOptions,
newTab: shouldOpenInNewTab || actualOptions?.newTab,
};
};
// Helper function to create target URL
const createTargetUrl = (
to: string | SafeNavigateParams,
location: Location,
): URL => {
if (typeof to === 'string') {
return new URL(to, window.location.origin);
}
return new URL(
`${to.pathname || location.pathname}${to.search || ''}`,
window.location.origin,
);
};
// Helper function to handle new tab navigation
const handleNewTabNavigation = (
to: string | SafeNavigateParams,
location: Location,
): void => {
const targetPath =
typeof to === 'string'
? to
: `${to.pathname || location.pathname}${to.search || ''}`;
window.open(targetPath, '_blank');
};
// Helper function to perform navigation
const performNavigation = (
to: string | SafeNavigateParams,
navigationOptions: NavigateOptions,
navigate: NavigateFunction,
location: Location,
): void => {
if (typeof to === 'string') {
navigate(to, navigationOptions);
} else {
navigate(
{
pathname: to.pathname || location.pathname,
search: to.search,
},
navigationOptions,
);
}
};
export const useSafeNavigate = (
{ preventSameUrlNavigation }: UseSafeNavigateProps = {
preventSameUrlNavigation: true,
@@ -90,6 +165,11 @@ export const useSafeNavigate = (
): {
safeNavigate: (
to: string | SafeNavigateParams,
optionsOrEvent?:
| NavigateOptions
| React.MouseEvent
| MouseEvent
| KeyboardEvent,
options?: NavigateOptions,
) => void;
} => {
@@ -97,30 +177,25 @@ export const useSafeNavigate = (
const location = useLocation();
const safeNavigate = useCallback(
(to: string | SafeNavigateParams, options?: NavigateOptions) => {
(
to: string | SafeNavigateParams,
optionsOrEvent?:
| NavigateOptions
| React.MouseEvent
| MouseEvent
| KeyboardEvent,
options?: NavigateOptions,
) => {
const finalOptions = extractOptions(optionsOrEvent, options);
const currentUrl = new URL(
`${location.pathname}${location.search}`,
window.location.origin,
);
const targetUrl = createTargetUrl(to, location);
let targetUrl: URL;
if (typeof to === 'string') {
targetUrl = new URL(to, window.location.origin);
} else {
targetUrl = new URL(
`${to.pathname || location.pathname}${to.search || ''}`,
window.location.origin,
);
}
// If newTab is true, open in new tab and return early
if (options?.newTab) {
const targetPath =
typeof to === 'string'
? to
: `${to.pathname || location.pathname}${to.search || ''}`;
window.open(targetPath, '_blank');
// Handle new tab navigation
if (finalOptions?.newTab) {
handleNewTabNavigation(to, location);
return;
}
@@ -132,23 +207,13 @@ export const useSafeNavigate = (
}
const navigationOptions = {
...options,
replace: isDefaultParamsNavigation || options?.replace,
...finalOptions,
replace: isDefaultParamsNavigation || finalOptions?.replace,
};
if (typeof to === 'string') {
navigate(to, navigationOptions);
} else {
navigate(
{
pathname: to.pathname || location.pathname,
search: to.search,
},
navigationOptions,
);
}
performNavigation(to, navigationOptions, navigate, location);
},
[navigate, location.pathname, location.search, preventSameUrlNavigation],
[navigate, location, preventSameUrlNavigation],
);
return { safeNavigate };

View File

@@ -0,0 +1,634 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { LocationDescriptorObject } from 'history';
import history from '../history';
jest.mock('history', () => {
const actualHistory = jest.requireActual('history');
const mockPush = jest.fn();
const mockReplace = jest.fn();
const mockGo = jest.fn();
const mockGoBack = jest.fn();
const mockGoForward = jest.fn();
const mockBlock = jest.fn(() => jest.fn());
const mockListen = jest.fn(() => jest.fn());
const mockCreateHref = jest.fn((location) => {
if (typeof location === 'string') return location;
return actualHistory.createPath(location);
});
const baseHistory = {
length: 2,
action: 'PUSH' as const,
location: {
pathname: '/current-path',
search: '?existing=param',
hash: '#section',
state: { existing: 'state' },
key: 'test-key',
},
push: mockPush,
replace: mockReplace,
go: mockGo,
goBack: mockGoBack,
goForward: mockGoForward,
block: mockBlock,
listen: mockListen,
createHref: mockCreateHref,
};
return {
...actualHistory,
createBrowserHistory: jest.fn(() => baseHistory),
};
});
interface TestUser {
id: number;
name: string;
email: string;
}
interface TestState {
from?: string;
user?: TestUser;
timestamp?: number;
}
describe('Enhanced History Methods', () => {
let mockWindowOpen: jest.SpyInstance;
let originalPush: jest.MockedFunction<typeof history.push>;
beforeEach(() => {
jest.clearAllMocks();
mockWindowOpen = jest.spyOn(window, 'open').mockImplementation(() => null);
originalPush = history.originalPush as jest.MockedFunction<
typeof history.push
>;
});
afterEach(() => {
mockWindowOpen.mockRestore();
});
describe('history.push() - String Path Navigation', () => {
it('should handle simple string path navigation', () => {
history.push('/dashboard');
expect(originalPush).toHaveBeenCalledTimes(1);
expect(originalPush).toHaveBeenCalledWith('/dashboard', undefined);
expect(mockWindowOpen).not.toHaveBeenCalled();
});
it('should handle string path with state', () => {
const testState: TestState = { from: 'home', timestamp: Date.now() };
history.push('/dashboard', testState);
expect(originalPush).toHaveBeenCalledWith('/dashboard', testState);
expect(mockWindowOpen).not.toHaveBeenCalled();
});
it('should handle string path with query parameters', () => {
history.push('/logs?filter=error&timeRange=24h');
expect(originalPush).toHaveBeenCalledWith(
'/logs?filter=error&timeRange=24h',
undefined,
);
});
it('should handle string path with hash', () => {
history.push('/docs#installation');
expect(originalPush).toHaveBeenCalledWith('/docs#installation', undefined);
});
it('should handle complex URL with all components', () => {
const complexUrl = '/api/traces?service=backend&status=error#span-details';
const state: TestState = {
user: { id: 1, name: 'John', email: 'john@test.com' },
};
history.push(complexUrl, state);
expect(originalPush).toHaveBeenCalledWith(complexUrl, state);
});
});
describe('history.push() - Location Object Navigation', () => {
it('should handle location object with only pathname', () => {
const location: LocationDescriptorObject = {
pathname: '/metrics',
};
history.push(location);
expect(originalPush).toHaveBeenCalledWith(location, undefined);
expect(mockWindowOpen).not.toHaveBeenCalled();
});
it('should handle location object with pathname and search', () => {
const location: LocationDescriptorObject = {
pathname: '/logs',
search: '?filter=error&severity=high',
};
history.push(location);
expect(originalPush).toHaveBeenCalledWith(location, undefined);
});
it('should handle location object with all properties', () => {
const location: LocationDescriptorObject<TestState> = {
pathname: '/traces',
search: '?service=api-server&duration=slow',
hash: '#span-123',
state: { from: 'dashboard', timestamp: Date.now() },
key: 'unique-key',
};
history.push(location);
expect(originalPush).toHaveBeenCalledWith(location, undefined);
});
it('should handle location object with state passed separately', () => {
const location: LocationDescriptorObject = {
pathname: '/alerts',
search: '?type=critical',
};
const separateState: TestState = { from: 'monitoring' };
history.push(location, separateState);
expect(originalPush).toHaveBeenCalledWith(location, separateState);
});
it('should handle empty location object', () => {
const location: LocationDescriptorObject = {};
history.push(location);
expect(originalPush).toHaveBeenCalledWith(location, undefined);
});
it('should preserve current pathname when updating search', () => {
const location: LocationDescriptorObject = {
pathname: history.location.pathname,
search: '?newParam=value',
};
history.push(location);
expect(originalPush).toHaveBeenCalledWith(location, undefined);
expect(originalPush.mock.calls[0][0]).toHaveProperty(
'pathname',
'/current-path',
);
});
});
describe('history.push() - Event Handling (Cmd/Ctrl+Click)', () => {
describe('MouseEvent handling', () => {
it('should open in new tab when metaKey is pressed with string path', () => {
const event = new MouseEvent('click', { metaKey: true });
history.push('/dashboard', event);
expect(mockWindowOpen).toHaveBeenCalledWith('/dashboard', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should open in new tab when ctrlKey is pressed with string path', () => {
const event = new MouseEvent('click', { ctrlKey: true });
history.push('/metrics', event);
expect(mockWindowOpen).toHaveBeenCalledWith('/metrics', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should open in new tab when both metaKey and ctrlKey are pressed', () => {
const event = new MouseEvent('click', { metaKey: true, ctrlKey: true });
history.push('/logs', event);
expect(mockWindowOpen).toHaveBeenCalledWith('/logs', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should handle normal click without meta/ctrl keys', () => {
const event = new MouseEvent('click', { metaKey: false, ctrlKey: false });
const state: TestState = { from: 'nav' };
history.push('/alerts', event, state);
expect(mockWindowOpen).not.toHaveBeenCalled();
expect(originalPush).toHaveBeenCalledWith('/alerts', state);
});
});
describe('KeyboardEvent handling', () => {
it('should open in new tab when metaKey is pressed with keyboard event', () => {
const event = new KeyboardEvent('keydown', { metaKey: true });
history.push('/traces', event);
expect(mockWindowOpen).toHaveBeenCalledWith('/traces', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should open in new tab when ctrlKey is pressed with keyboard event', () => {
const event = new KeyboardEvent('keydown', { ctrlKey: true });
history.push('/pipelines', event);
expect(mockWindowOpen).toHaveBeenCalledWith('/pipelines', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
});
describe('React SyntheticEvent handling', () => {
it('should handle React MouseEvent with metaKey', () => {
const nativeEvent = new MouseEvent('click', { metaKey: true });
const reactEvent = {
nativeEvent,
metaKey: true,
ctrlKey: false,
} as React.MouseEvent;
history.push('/dashboard', reactEvent);
expect(mockWindowOpen).toHaveBeenCalledWith('/dashboard', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should handle React MouseEvent with ctrlKey', () => {
const nativeEvent = new MouseEvent('click', { ctrlKey: true });
const reactEvent = {
nativeEvent,
metaKey: false,
ctrlKey: true,
} as React.MouseEvent;
history.push('/logs', reactEvent);
expect(mockWindowOpen).toHaveBeenCalledWith('/logs', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should handle React MouseEvent without modifier keys', () => {
const nativeEvent = new MouseEvent('click');
const reactEvent = {
nativeEvent,
metaKey: false,
ctrlKey: false,
} as React.MouseEvent;
history.push('/metrics', reactEvent);
expect(mockWindowOpen).not.toHaveBeenCalled();
expect(originalPush).toHaveBeenCalledWith('/metrics', undefined);
});
});
describe('Location Object with Event handling', () => {
it('should open location object URL in new tab with metaKey', () => {
const location: LocationDescriptorObject = {
pathname: '/traces',
search: '?service=backend',
hash: '#span-details',
};
const event = new MouseEvent('click', { metaKey: true });
history.push(location, event);
expect(mockWindowOpen).toHaveBeenCalledWith(
'/traces?service=backend#span-details',
'_blank',
);
expect(originalPush).not.toHaveBeenCalled();
});
it('should open location object URL in new tab with ctrlKey', () => {
const location: LocationDescriptorObject = {
pathname: '/alerts',
search: '?status=firing',
};
const event = new MouseEvent('click', { ctrlKey: true });
history.push(location, event);
expect(mockWindowOpen).toHaveBeenCalledWith(
'/alerts?status=firing',
'_blank',
);
expect(originalPush).not.toHaveBeenCalled();
});
it('should handle location object with normal navigation', () => {
const location: LocationDescriptorObject = {
pathname: '/dashboard',
search: '?tab=overview',
};
const event = new MouseEvent('click', { metaKey: false, ctrlKey: false });
const state: TestState = { from: 'home' };
history.push(location, event, state);
expect(mockWindowOpen).not.toHaveBeenCalled();
expect(originalPush).toHaveBeenCalledWith(location, state);
});
it('should handle complex location object with all properties in new tab', () => {
const location: LocationDescriptorObject<TestState> = {
pathname: '/api/v1/traces',
search: '?limit=100&offset=0&service=auth',
hash: '#result-section',
state: { from: 'explorer' }, // State is ignored in new tab
};
const event = new MouseEvent('click', { metaKey: true });
history.push(location, event);
expect(mockWindowOpen).toHaveBeenCalledWith(
'/api/v1/traces?limit=100&offset=0&service=auth#result-section',
'_blank',
);
});
});
});
describe('history.push() - Edge Cases and Error Scenarios', () => {
it('should handle undefined as second parameter', () => {
history.push('/dashboard', undefined);
expect(originalPush).toHaveBeenCalledWith('/dashboard', undefined);
});
it('should handle null as second parameter', () => {
history.push('/logs', null);
expect(originalPush).toHaveBeenCalledWith('/logs', null);
});
it('should handle empty string path', () => {
history.push('');
expect(originalPush).toHaveBeenCalledWith('', undefined);
});
it('should handle root path', () => {
history.push('/');
expect(originalPush).toHaveBeenCalledWith('/', undefined);
});
it('should handle relative paths', () => {
history.push('../parent');
expect(originalPush).toHaveBeenCalledWith('../parent', undefined);
});
it('should handle special characters in path', () => {
const specialPath = '/path/with spaces/and#special?chars=@$%';
history.push(specialPath);
expect(originalPush).toHaveBeenCalledWith(specialPath, undefined);
});
it('should handle location object with undefined values', () => {
const location: LocationDescriptorObject = {
pathname: undefined,
search: undefined,
hash: undefined,
state: undefined,
};
history.push(location);
expect(originalPush).toHaveBeenCalledWith(location, undefined);
});
it('should handle very long URLs', () => {
const longParam = 'x'.repeat(1000);
const longUrl = `/path?param=${longParam}`;
history.push(longUrl);
expect(originalPush).toHaveBeenCalledWith(longUrl, undefined);
});
it('should handle object that looks like an event but isnt', () => {
const fakeEvent = {
metaKey: 'not-a-boolean', // Invalid type but still truthy values
ctrlKey: 'not-a-boolean',
};
history.push('/dashboard', fakeEvent as any);
// The implementation checks if metaKey/ctrlKey exist and are truthy values
// Since these are truthy strings, it will be treated as an event
expect(mockWindowOpen).toHaveBeenCalledWith('/dashboard', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should handle event-like object with falsy values', () => {
const fakeEventFalsy = {
metaKey: false,
ctrlKey: false,
};
history.push('/dashboard', fakeEventFalsy as any);
// The object is detected as an event (has metaKey/ctrlKey properties)
// but since both are false, it doesn't open in new tab
// When treated as event, third param (state) is undefined
expect(mockWindowOpen).not.toHaveBeenCalled();
expect(originalPush).toHaveBeenCalledWith('/dashboard', undefined);
});
it('should handle partial event-like objects', () => {
const partialEvent = { metaKey: true }; // Has metaKey but not instanceof MouseEvent
history.push('/logs', partialEvent as any);
expect(mockWindowOpen).toHaveBeenCalledWith('/logs', '_blank');
expect(originalPush).not.toHaveBeenCalled();
});
it('should handle object without event properties as state', () => {
const regularObject = {
someData: 'value',
anotherProp: 123,
// No metaKey or ctrlKey properties
};
history.push('/page', regularObject);
// Object without metaKey/ctrlKey is treated as state, not event
expect(mockWindowOpen).not.toHaveBeenCalled();
expect(originalPush).toHaveBeenCalledWith('/page', regularObject);
});
});
describe('history.push() - State Handling', () => {
it('should pass state with string path', () => {
const complexState: TestState = {
from: 'dashboard',
user: { id: 123, name: 'Test User', email: 'test@example.com' },
timestamp: Date.now(),
};
history.push('/profile', complexState);
expect(originalPush).toHaveBeenCalledWith('/profile', complexState);
});
it('should handle state with location object', () => {
const location: LocationDescriptorObject<TestState> = {
pathname: '/settings',
state: { from: 'profile' },
};
const additionalState: TestState = { timestamp: Date.now() };
history.push(location, additionalState);
expect(originalPush).toHaveBeenCalledWith(location, additionalState);
});
it('should handle state with event and string path', () => {
const event = new MouseEvent('click', { metaKey: false });
const state: TestState = { from: 'nav' };
history.push('/dashboard', event, state);
expect(originalPush).toHaveBeenCalledWith('/dashboard', state);
});
it('should handle state with event and location object', () => {
const location: LocationDescriptorObject = {
pathname: '/logs',
};
const event = new MouseEvent('click', { metaKey: false });
const state: TestState = { from: 'sidebar' };
history.push(location, event, state);
expect(originalPush).toHaveBeenCalledWith(location, state);
});
});
describe('Other History Methods', () => {
it('should have working replace method', () => {
// replace should exist and be callable
expect(history.replace).toBeDefined();
expect(typeof history.replace).toBe('function');
history.replace('/new-path');
const mockReplace = (history as any).replace as jest.MockedFunction<
typeof history.replace
>;
expect(mockReplace).toHaveBeenCalledWith('/new-path');
});
it('should have working go method', () => {
expect(history.go).toBeDefined();
expect(typeof history.go).toBe('function');
history.go(-2);
const mockGo = (history as any).go as jest.MockedFunction<typeof history.go>;
expect(mockGo).toHaveBeenCalledWith(-2);
});
it('should have working goBack method', () => {
expect(history.goBack).toBeDefined();
expect(typeof history.goBack).toBe('function');
history.goBack();
const mockGoBack = (history as any).goBack as jest.MockedFunction<
typeof history.goBack
>;
expect(mockGoBack).toHaveBeenCalled();
});
it('should have working goForward method', () => {
expect(history.goForward).toBeDefined();
expect(typeof history.goForward).toBe('function');
history.goForward();
const mockGoForward = (history as any).goForward as jest.MockedFunction<
typeof history.goForward
>;
expect(mockGoForward).toHaveBeenCalled();
});
it('should have working block method', () => {
expect(history.block).toBeDefined();
expect(typeof history.block).toBe('function');
const unblock = history.block('Are you sure?');
expect(typeof unblock).toBe('function');
const mockBlock = (history as any).block as jest.MockedFunction<
typeof history.block
>;
expect(mockBlock).toHaveBeenCalledWith('Are you sure?');
});
it('should have working listen method', () => {
expect(history.listen).toBeDefined();
expect(typeof history.listen).toBe('function');
const listener = jest.fn();
const unlisten = history.listen(listener);
expect(typeof unlisten).toBe('function');
const mockListen = (history as any).listen as jest.MockedFunction<
typeof history.listen
>;
expect(mockListen).toHaveBeenCalledWith(listener);
});
it('should have working createHref method', () => {
expect(history.createHref).toBeDefined();
expect(typeof history.createHref).toBe('function');
const location: LocationDescriptorObject = {
pathname: '/test',
search: '?query=value',
};
const href = history.createHref(location);
expect(href).toBe('/test?query=value');
});
it('should have accessible location property', () => {
expect(history.location).toBeDefined();
expect(history.location.pathname).toBe('/current-path');
expect(history.location.search).toBe('?existing=param');
expect(history.location.hash).toBe('#section');
expect(history.location.state).toEqual({ existing: 'state' });
});
it('should have accessible length property', () => {
expect(history.length).toBeDefined();
expect(history.length).toBe(2);
});
it('should have accessible action property', () => {
expect(history.action).toBeDefined();
expect(history.action).toBe('PUSH');
});
});
});

View File

@@ -21,7 +21,7 @@ import { convertNewDataToOld } from 'lib/newQueryBuilder/convertNewDataToOld';
import { isEmpty } from 'lodash-es';
import { SuccessResponse, SuccessResponseV2, Warning } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { prepareQueryRangePayload } from './prepareQueryRangePayload';
@@ -76,14 +76,13 @@ const getQueryDataSource = (
const getLegendForSingleAggregation = (
queryData: QueryData,
payloadQuery: Query,
allQueries: IBuilderQuery[],
aggregationAlias: string,
aggregationExpression: string,
labelName: string,
singleAggregation: boolean,
) => {
// Find the corresponding query in payloadQuery
const queryItem = payloadQuery.builder?.queryData.find(
const queryItem = allQueries.find(
(query) => query.queryName === queryData.queryName,
);
@@ -108,14 +107,13 @@ const getLegendForSingleAggregation = (
const getLegendForMultipleAggregations = (
queryData: QueryData,
payloadQuery: Query,
allQueries: IBuilderQuery[],
aggregationAlias: string,
aggregationExpression: string,
labelName: string,
singleAggregation: boolean,
) => {
// Find the corresponding query in payloadQuery
const queryItem = payloadQuery.builder?.queryData.find(
const queryItem = allQueries.find(
(query) => query.queryName === queryData.queryName,
);
@@ -148,15 +146,18 @@ export const getLegend = (
return labelName;
}
const aggregationPerQuery = payloadQuery?.builder?.queryData.reduce(
(acc, query) => {
if (query.queryName === queryData.queryName) {
acc[query.queryName] = createAggregation(query);
}
return acc;
},
{},
);
// Combine queryData and queryTraceOperator
const allQueries = [
...(payloadQuery?.builder?.queryData || []),
...(payloadQuery?.builder?.queryTraceOperator || []),
];
const aggregationPerQuery = allQueries.reduce((acc, query) => {
if (query.queryName === queryData.queryName) {
acc[query.queryName] = createAggregation(query);
}
return acc;
}, {});
const metaData = queryData?.metaData;
const aggregation =
@@ -165,8 +166,8 @@ export const getLegend = (
const aggregationAlias = aggregation?.alias || '';
const aggregationExpression = aggregation?.expression || '';
// Check if there's only one total query (queryData)
const singleQuery = payloadQuery?.builder?.queryData?.length === 1;
// Check if there's only one total query
const singleQuery = allQueries.length === 1;
const singleAggregation =
aggregationPerQuery?.[metaData?.queryName]?.length === 1;
@@ -174,7 +175,7 @@ export const getLegend = (
return singleQuery
? getLegendForSingleAggregation(
queryData,
payloadQuery,
allQueries,
aggregationAlias,
aggregationExpression,
labelName,
@@ -182,7 +183,7 @@ export const getLegend = (
)
: getLegendForMultipleAggregations(
queryData,
payloadQuery,
allQueries,
aggregationAlias,
aggregationExpression,
labelName,

View File

@@ -1,3 +1,58 @@
import { createBrowserHistory } from 'history';
import {
createBrowserHistory,
createPath,
History,
LocationDescriptorObject,
LocationState,
} from 'history';
import { isEventObject } from 'utils/isEventObject';
export default createBrowserHistory();
// Create the base history instance
const baseHistory = createBrowserHistory();
type PathOrLocation = string | LocationDescriptorObject<LocationState>;
// Extend the History interface to include enhanced push method
interface EnhancedHistory extends History {
push: {
(path: PathOrLocation, state?: any): void;
(
path: PathOrLocation,
event?: React.MouseEvent | MouseEvent | KeyboardEvent,
state?: any,
): void;
};
originalPush: History['push'];
}
// Create enhanced history with overridden push method
const history = baseHistory as EnhancedHistory;
// Store the original push method
history.originalPush = baseHistory.push;
// Override push to handle meta/ctrl key events and location objects
history.push = function (
path: PathOrLocation,
eventOrState?: React.MouseEvent | MouseEvent | KeyboardEvent | any,
state?: any,
): void {
// Check if second argument is an event object
const isEvent = isEventObject(eventOrState);
// If it's an event and meta/ctrl key is pressed, open in new tab
if (isEvent && (eventOrState.metaKey || eventOrState.ctrlKey)) {
// Convert location object to URL string using createPath from history
const url = typeof path === 'string' ? path : createPath(path);
window.open(url, '_blank');
return;
}
// Otherwise, use normal navigation
// The original push method already handles both strings and location objects
// If eventOrState is not an event, treat it as state
const actualState = isEvent ? state : eventOrState;
history.originalPush(path, actualState);
};
export default history;

View File

@@ -0,0 +1,21 @@
// Event types that have metaKey/ctrlKey properties
type EventWithModifiers =
| MouseEvent
| KeyboardEvent
| React.MouseEvent<any, MouseEvent>
| React.KeyboardEvent<any>;
// Helper function to determine if an argument is an event - Also used in utils/history.ts
export const isEventObject = (arg: unknown): arg is EventWithModifiers => {
if (!arg || typeof arg !== 'object') return false;
return (
arg instanceof MouseEvent ||
arg instanceof KeyboardEvent ||
('nativeEvent' in arg &&
(arg.nativeEvent instanceof MouseEvent ||
arg.nativeEvent instanceof KeyboardEvent)) ||
'metaKey' in arg ||
'ctrlKey' in arg
);
};

View File

@@ -49,12 +49,8 @@ export const getHightLightedLogBackground = (
return `background-color: ${orange[3]};`;
};
export const getCustomHighlightBackground = (
isHighlighted = false,
isDarkMode = true,
$logType: string,
): string => {
export const getCustomHighlightBackground = (isHighlighted = false): string => {
if (!isHighlighted) return '';
return getActiveLogBackground(true, isDarkMode, $logType);
return `background-color: ${Color.BG_ROBIN_500}20;`;
};

View File

@@ -0,0 +1,58 @@
package implspanpercentile
import (
"encoding/json"
"net/http"
errorsV2 "github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/spanpercentiletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type handler struct {
module spanpercentile.Module
}
func NewHandler(module spanpercentile.Module) spanpercentile.Handler {
return &handler{
module: module,
}
}
func (h *handler) GetSpanPercentileDetails(w http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
spanPercentileRequest, err := parseSpanPercentileRequestBody(r)
if err != nil {
render.Error(w, err)
return
}
result, err := h.module.GetSpanPercentile(r.Context(), valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.UserID), spanPercentileRequest)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, result)
}
func parseSpanPercentileRequestBody(r *http.Request) (*spanpercentiletypes.SpanPercentileRequest, error) {
req := new(spanpercentiletypes.SpanPercentileRequest)
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
return nil, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, "cannot parse the request body: %v", err)
}
if err := req.Validate(); err != nil {
return nil, err
}
return req, nil
}

View File

@@ -0,0 +1,126 @@
package implspanpercentile
import (
"context"
"fmt"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/querier"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/spanpercentiletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type module struct {
querier querier.Querier
}
func NewModule(
querier querier.Querier,
_ factory.ProviderSettings,
) spanpercentile.Module {
return &module{
querier: querier,
}
}
func (m *module) GetSpanPercentile(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error) {
queryRangeRequest, err := buildSpanPercentileQuery(ctx, req)
if err != nil {
return nil, err
}
if err := queryRangeRequest.Validate(); err != nil {
return nil, err
}
result, err := m.querier.QueryRange(ctx, orgID, queryRangeRequest)
if err != nil {
return nil, err
}
return transformToSpanPercentileResponse(result)
}
func transformToSpanPercentileResponse(queryResult *qbtypes.QueryRangeResponse) (*spanpercentiletypes.SpanPercentileResponse, error) {
if len(queryResult.Data.Results) == 0 {
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "no data returned from query")
}
scalarData, ok := queryResult.Data.Results[0].(*qbtypes.ScalarData)
if !ok {
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "unexpected result type")
}
if len(scalarData.Data) == 0 {
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "no rows returned from query")
}
row := scalarData.Data[0]
columnMap := make(map[string]int)
for i, col := range scalarData.Columns {
columnMap[col.Name] = i
}
p50Idx, ok := columnMap["__result_0"]
if !ok {
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "missing __result_0 column")
}
p90Idx, ok := columnMap["__result_1"]
if !ok {
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "missing __result_1 column")
}
p99Idx, ok := columnMap["__result_2"]
if !ok {
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "missing __result_2 column")
}
positionIdx, ok := columnMap["__result_3"]
if !ok {
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "missing __result_3 column")
}
p50, err := toFloat64(row[p50Idx])
if err != nil {
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "no spans found matching the specified criteria")
}
p90, err := toFloat64(row[p90Idx])
if err != nil {
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "no spans found matching the specified criteria")
}
p99, err := toFloat64(row[p99Idx])
if err != nil {
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "no spans found matching the specified criteria")
}
position, err := toFloat64(row[positionIdx])
if err != nil {
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "no spans found matching the specified criteria")
}
description := fmt.Sprintf("faster than %.1f%% of spans", position)
if position < 50 {
description = fmt.Sprintf("slower than %.1f%% of spans", 100-position)
}
return &spanpercentiletypes.SpanPercentileResponse{
Percentiles: spanpercentiletypes.PercentileStats{
P50: p50,
P90: p90,
P99: p99,
},
Position: spanpercentiletypes.PercentilePosition{
Percentile: position,
Description: description,
},
}, nil
}
func toFloat64(val any) (float64, error) {
result, ok := val.(float64)
if !ok {
return 0, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot convert %T to float64", val)
}
return result, nil
}

View File

@@ -0,0 +1,118 @@
package implspanpercentile
import (
"context"
"fmt"
"sort"
"strings"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/spanpercentiletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
func buildSpanPercentileQuery(
_ context.Context,
req *spanpercentiletypes.SpanPercentileRequest,
) (*qbtypes.QueryRangeRequest, error) {
if err := req.Validate(); err != nil {
return nil, err
}
var attrKeys []string
for key := range req.ResourceAttributes {
attrKeys = append(attrKeys, key)
}
sort.Strings(attrKeys)
filterConditions := []string{
fmt.Sprintf("service.name = '%s'", strings.ReplaceAll(req.ServiceName, "'", `\'`)),
fmt.Sprintf("name = '%s'", strings.ReplaceAll(req.Name, "'", `\'`)),
}
for _, key := range attrKeys {
value := req.ResourceAttributes[key]
filterConditions = append(filterConditions,
fmt.Sprintf("%s = '%s'", key, strings.ReplaceAll(value, "'", `\'`)))
}
filterExpr := strings.Join(filterConditions, " AND ")
groupByKeys := []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
}
for _, key := range attrKeys {
groupByKeys = append(groupByKeys, qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: key,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
})
}
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "span_percentile",
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{
{
Expression: "p50(duration_nano)",
Alias: "p50_duration_nano",
},
{
Expression: "p90(duration_nano)",
Alias: "p90_duration_nano",
},
{
Expression: "p99(duration_nano)",
Alias: "p99_duration_nano",
},
{
Expression: fmt.Sprintf(
"(100.0 * countIf(duration_nano <= %d)) / count()",
req.DurationNano,
),
Alias: "percentile_position",
},
},
GroupBy: groupByKeys,
Filter: &qbtypes.Filter{
Expression: filterExpr,
},
}
queryEnvelope := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: query,
}
return &qbtypes.QueryRangeRequest{
SchemaVersion: "v5",
Start: req.Start,
End: req.End,
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{queryEnvelope},
},
FormatOptions: &qbtypes.FormatOptions{
FormatTableResultForUI: true,
},
}, nil
}

View File

@@ -0,0 +1,149 @@
package implspanpercentile
import (
"context"
"fmt"
"sort"
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/spanpercentiletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/require"
)
func TestBuildSpanPercentileQuery(t *testing.T) {
req := &spanpercentiletypes.SpanPercentileRequest{
DurationNano: 100000,
Name: "test",
ServiceName: "test-service",
ResourceAttributes: map[string]string{},
Start: 1640995200000,
End: 1640995800000,
}
ctx := context.Background()
result, err := buildSpanPercentileQuery(ctx, req)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, 1, len(result.CompositeQuery.Queries))
require.Equal(t, qbtypes.QueryTypeBuilder, result.CompositeQuery.Queries[0].Type)
query, ok := result.CompositeQuery.Queries[0].Spec.(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
require.True(t, ok, "Spec should be QueryBuilderQuery type")
require.Equal(t, "span_percentile", query.Name)
require.Equal(t, telemetrytypes.SignalTraces, query.Signal)
require.Equal(t, 4, len(query.Aggregations))
require.Equal(t, "p50(duration_nano)", query.Aggregations[0].Expression)
require.Equal(t, "p50_duration_nano", query.Aggregations[0].Alias)
require.Equal(t, "p90(duration_nano)", query.Aggregations[1].Expression)
require.Equal(t, "p90_duration_nano", query.Aggregations[1].Alias)
require.Equal(t, "p99(duration_nano)", query.Aggregations[2].Expression)
require.Equal(t, "p99_duration_nano", query.Aggregations[2].Alias)
require.Equal(t, "(100.0 * countIf(duration_nano <= 100000)) / count()", query.Aggregations[3].Expression)
require.Equal(t, "percentile_position", query.Aggregations[3].Alias)
require.NotNil(t, query.Filter)
require.Equal(t, "service.name = 'test-service' AND name = 'test'", query.Filter.Expression)
require.Equal(t, 2, len(query.GroupBy))
require.Equal(t, "service.name", query.GroupBy[0].TelemetryFieldKey.Name)
require.Equal(t, telemetrytypes.FieldContextResource, query.GroupBy[0].TelemetryFieldKey.FieldContext)
require.Equal(t, "name", query.GroupBy[1].TelemetryFieldKey.Name)
require.Equal(t, telemetrytypes.FieldContextSpan, query.GroupBy[1].TelemetryFieldKey.FieldContext)
require.Equal(t, qbtypes.RequestTypeScalar, result.RequestType)
}
func TestBuildSpanPercentileQueryWithResourceAttributes(t *testing.T) {
testCases := []struct {
name string
request *spanpercentiletypes.SpanPercentileRequest
expectedFilterExpr string
}{
{
name: "query with service.name only (no additional resource attributes)",
request: &spanpercentiletypes.SpanPercentileRequest{
DurationNano: 100000,
Name: "GET /api/users",
ServiceName: "user-service",
ResourceAttributes: map[string]string{},
Start: 1640995200000,
End: 1640995800000,
},
expectedFilterExpr: "service.name = 'user-service' AND name = 'GET /api/users'",
},
{
name: "query with service.name and deployment.environment",
request: &spanpercentiletypes.SpanPercentileRequest{
DurationNano: 250000,
Name: "POST /api/orders",
ServiceName: "order-service",
ResourceAttributes: map[string]string{
"deployment.environment": "production",
},
Start: 1640995200000,
End: 1640995800000,
},
expectedFilterExpr: "service.name = 'order-service' AND name = 'POST /api/orders' AND deployment.environment = 'production'",
},
{
name: "query with multiple resource attributes",
request: &spanpercentiletypes.SpanPercentileRequest{
DurationNano: 500000,
Name: "DELETE /api/items",
ServiceName: "inventory-service",
ResourceAttributes: map[string]string{
"cloud.platform": "aws",
"deployment.environment": "staging",
"k8s.cluster.name": "staging-cluster",
},
Start: 1640995200000,
End: 1640995800000,
},
expectedFilterExpr: "service.name = 'inventory-service' AND name = 'DELETE /api/items' AND cloud.platform = 'aws' AND deployment.environment = 'staging' AND k8s.cluster.name = 'staging-cluster'",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
result, err := buildSpanPercentileQuery(ctx, tc.request)
require.NoError(t, err)
require.NotNil(t, result)
query, ok := result.CompositeQuery.Queries[0].Spec.(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
require.True(t, ok, "Spec should be QueryBuilderQuery type")
require.Equal(t, tc.expectedFilterExpr, query.Filter.Expression)
require.Equal(t, 4, len(query.Aggregations))
require.Equal(t, "p50(duration_nano)", query.Aggregations[0].Expression)
require.Equal(t, "p90(duration_nano)", query.Aggregations[1].Expression)
require.Equal(t, "p99(duration_nano)", query.Aggregations[2].Expression)
require.Contains(t, query.Aggregations[3].Expression, fmt.Sprintf("countIf(duration_nano <= %d)", tc.request.DurationNano))
expectedGroupByCount := 2 + len(tc.request.ResourceAttributes)
require.Equal(t, expectedGroupByCount, len(query.GroupBy))
require.Equal(t, "service.name", query.GroupBy[0].TelemetryFieldKey.Name)
require.Equal(t, "name", query.GroupBy[1].TelemetryFieldKey.Name)
for i, key := range getSortedKeys(tc.request.ResourceAttributes) {
require.Equal(t, key, query.GroupBy[2+i].TelemetryFieldKey.Name)
require.Equal(t, telemetrytypes.FieldContextResource, query.GroupBy[2+i].TelemetryFieldKey.FieldContext)
}
})
}
}
func getSortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

View File

@@ -0,0 +1,17 @@
package spanpercentile
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/types/spanpercentiletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Module interface {
GetSpanPercentile(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error)
}
type Handler interface {
GetSpanPercentileDetails(http.ResponseWriter, *http.Request)
}

View File

@@ -625,6 +625,8 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
// Export
router.HandleFunc("/api/v1/export_raw_data", am.ViewAccess(aH.Signoz.Handlers.RawDataExport.ExportRawData)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/span_percentile", am.ViewAccess(aH.Signoz.Handlers.SpanPercentile.GetSpanPercentileDetails)).Methods(http.MethodPost)
}
func (ah *APIHandler) MetricExplorerRoutes(router *mux.Router, am *middleware.AuthZ) {

View File

@@ -20,6 +20,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/session/implsession"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
"github.com/SigNoz/signoz/pkg/modules/user"
@@ -27,31 +29,33 @@ import (
)
type Handlers struct {
Organization organization.Handler
Preference preference.Handler
User user.Handler
SavedView savedview.Handler
Apdex apdex.Handler
Dashboard dashboard.Handler
QuickFilter quickfilter.Handler
TraceFunnel tracefunnel.Handler
RawDataExport rawdataexport.Handler
AuthDomain authdomain.Handler
Session session.Handler
Organization organization.Handler
Preference preference.Handler
User user.Handler
SavedView savedview.Handler
Apdex apdex.Handler
Dashboard dashboard.Handler
QuickFilter quickfilter.Handler
TraceFunnel tracefunnel.Handler
RawDataExport rawdataexport.Handler
AuthDomain authdomain.Handler
Session session.Handler
SpanPercentile spanpercentile.Handler
}
func NewHandlers(modules Modules, providerSettings factory.ProviderSettings) Handlers {
return Handlers{
Organization: implorganization.NewHandler(modules.OrgGetter, modules.OrgSetter),
Preference: implpreference.NewHandler(modules.Preference),
User: impluser.NewHandler(modules.User, modules.UserGetter),
SavedView: implsavedview.NewHandler(modules.SavedView),
Apdex: implapdex.NewHandler(modules.Apdex),
Dashboard: impldashboard.NewHandler(modules.Dashboard, providerSettings),
QuickFilter: implquickfilter.NewHandler(modules.QuickFilter),
TraceFunnel: impltracefunnel.NewHandler(modules.TraceFunnel),
RawDataExport: implrawdataexport.NewHandler(modules.RawDataExport),
AuthDomain: implauthdomain.NewHandler(modules.AuthDomain),
Session: implsession.NewHandler(modules.Session),
Organization: implorganization.NewHandler(modules.OrgGetter, modules.OrgSetter),
Preference: implpreference.NewHandler(modules.Preference),
User: impluser.NewHandler(modules.User, modules.UserGetter),
SavedView: implsavedview.NewHandler(modules.SavedView),
Apdex: implapdex.NewHandler(modules.Apdex),
Dashboard: impldashboard.NewHandler(modules.Dashboard, providerSettings),
QuickFilter: implquickfilter.NewHandler(modules.QuickFilter),
TraceFunnel: impltracefunnel.NewHandler(modules.TraceFunnel),
RawDataExport: implrawdataexport.NewHandler(modules.RawDataExport),
AuthDomain: implauthdomain.NewHandler(modules.AuthDomain),
Session: implsession.NewHandler(modules.Session),
SpanPercentile: implspanpercentile.NewHandler(modules.SpanPercentile),
}
}

View File

@@ -24,6 +24,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/session/implsession"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
"github.com/SigNoz/signoz/pkg/modules/user"
@@ -36,19 +38,20 @@ import (
)
type Modules struct {
OrgGetter organization.Getter
OrgSetter organization.Setter
Preference preference.Module
User user.Module
UserGetter user.Getter
SavedView savedview.Module
Apdex apdex.Module
Dashboard dashboard.Module
QuickFilter quickfilter.Module
TraceFunnel tracefunnel.Module
RawDataExport rawdataexport.Module
AuthDomain authdomain.Module
Session session.Module
OrgGetter organization.Getter
OrgSetter organization.Setter
Preference preference.Module
User user.Module
UserGetter user.Getter
SavedView savedview.Module
Apdex apdex.Module
Dashboard dashboard.Module
QuickFilter quickfilter.Module
TraceFunnel tracefunnel.Module
RawDataExport rawdataexport.Module
AuthDomain authdomain.Module
Session session.Module
SpanPercentile spanpercentile.Module
}
func NewModules(
@@ -66,19 +69,21 @@ func NewModules(
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
user := impluser.NewModule(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, analytics)
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings))
return Modules{
OrgGetter: orgGetter,
OrgSetter: orgSetter,
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
SavedView: implsavedview.NewModule(sqlstore),
Apdex: implapdex.NewModule(sqlstore),
Dashboard: impldashboard.NewModule(sqlstore, providerSettings, analytics),
User: user,
UserGetter: userGetter,
QuickFilter: quickfilter,
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier),
AuthDomain: implauthdomain.NewModule(implauthdomain.NewStore(sqlstore)),
Session: implsession.NewModule(providerSettings, authNs, user, userGetter, implauthdomain.NewModule(implauthdomain.NewStore(sqlstore)), tokenizer, orgGetter),
OrgGetter: orgGetter,
OrgSetter: orgSetter,
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
SavedView: implsavedview.NewModule(sqlstore),
Apdex: implapdex.NewModule(sqlstore),
Dashboard: impldashboard.NewModule(sqlstore, providerSettings, analytics),
User: user,
UserGetter: userGetter,
QuickFilter: quickfilter,
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier),
AuthDomain: implauthdomain.NewModule(implauthdomain.NewStore(sqlstore)),
Session: implsession.NewModule(providerSettings, authNs, user, userGetter, implauthdomain.NewModule(implauthdomain.NewStore(sqlstore)), tokenizer, orgGetter),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
}
}

View File

@@ -20,6 +20,7 @@ var (
NameNavShortcuts = Name{valuer.NewString("nav_shortcuts")}
NameLastSeenChangelogVersion = Name{valuer.NewString("last_seen_changelog_version")}
NameSpanDetailsPinnedAttributes = Name{valuer.NewString("span_details_pinned_attributes")}
NameSpanPercentileResourceAttributes = Name{valuer.NewString("span_percentile_resource_attributes")}
)
type Name struct{ valuer.String }
@@ -39,6 +40,7 @@ func NewName(name string) (Name, error) {
NameNavShortcuts.StringValue(),
NameLastSeenChangelogVersion.StringValue(),
NameSpanDetailsPinnedAttributes.StringValue(),
NameSpanPercentileResourceAttributes.StringValue(),
},
name,
)

View File

@@ -163,6 +163,15 @@ func NewAvailablePreference() map[Name]Preference {
AllowedValues: []string{},
Value: MustNewValue([]any{}, ValueTypeArray),
},
NameSpanPercentileResourceAttributes: {
Name: NameSpanPercentileResourceAttributes,
Description: "Additional resource attributes for span percentile filtering (beyond mandatory name and service.name).",
ValueType: ValueTypeArray,
DefaultValue: MustNewValue([]any{"deployment.environment"}, ValueTypeArray),
AllowedScopes: []Scope{ScopeUser},
AllowedValues: []string{},
Value: MustNewValue([]any{"deployment.environment"}, ValueTypeArray),
},
}
}

View File

@@ -0,0 +1,17 @@
package spanpercentiletypes
type SpanPercentileResponse struct {
Percentiles PercentileStats `json:"percentiles"`
Position PercentilePosition `json:"position"`
}
type PercentileStats struct {
P50 float64 `json:"p50"`
P90 float64 `json:"p90"`
P99 float64 `json:"p99"`
}
type PercentilePosition struct {
Percentile float64 `json:"percentile"`
Description string `json:"description"`
}

View File

@@ -0,0 +1,43 @@
package spanpercentiletypes
import (
"github.com/SigNoz/signoz/pkg/errors"
)
type SpanPercentileRequest struct {
DurationNano int64 `json:"spanDuration"`
Name string `json:"name"`
ServiceName string `json:"serviceName"`
ResourceAttributes map[string]string `json:"resourceAttributes"`
Start uint64 `json:"start"`
End uint64 `json:"end"`
}
func (req *SpanPercentileRequest) Validate() error {
if req.Name == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "name is required")
}
if req.ServiceName == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "service_name is required")
}
if req.DurationNano <= 0 {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "duration_nano must be greater than 0")
}
if req.Start >= req.End {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "start time must be before end time")
}
for key, val := range req.ResourceAttributes {
if key == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "resource attribute key cannot be empty")
}
if val == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "resource attribute value cannot be empty")
}
}
return nil
}