mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-22 00:00:27 +01:00
Compare commits
28 Commits
v0.99.0
...
enhancemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aea42ba5ca | ||
|
|
01e0b36d62 | ||
|
|
e90bb016f7 | ||
|
|
bdecbfb7f5 | ||
|
|
3dced2b082 | ||
|
|
455ba0549f | ||
|
|
5f2c302551 | ||
|
|
15c2dc700a | ||
|
|
02fa0dbc32 | ||
|
|
e0948033c8 | ||
|
|
a1115ac65b | ||
|
|
9bcb88c747 | ||
|
|
367bf7b4b5 | ||
|
|
59b68057b8 | ||
|
|
fa1b2ddf7c | ||
|
|
642a0e5656 | ||
|
|
cb99ee1ac1 | ||
|
|
7616cb89e4 | ||
|
|
bf780c7445 | ||
|
|
61062dfd8d | ||
|
|
5b7af9651c | ||
|
|
b9012f6150 | ||
|
|
7ab81780b3 | ||
|
|
a16f51457f | ||
|
|
38a38b5645 | ||
|
|
bb04bc5044 | ||
|
|
58736f40dc | ||
|
|
91154249d6 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -106,6 +106,7 @@ downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
!frontend/src/lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
36
frontend/src/container/PanelWrapper/__tests__/testUtils.ts
Normal file
36
frontend/src/container/PanelWrapper/__tests__/testUtils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -85,7 +85,7 @@ export const getTraceOnlyFilters = (traceId: string): TagFilter => ({
|
||||
type: '',
|
||||
key: 'trace_id',
|
||||
},
|
||||
op: 'in',
|
||||
op: '=',
|
||||
value: traceId,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}'`;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
634
frontend/src/lib/__tests__/history.test.ts
Normal file
634
frontend/src/lib/__tests__/history.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
21
frontend/src/utils/isEventObject.ts
Normal file
21
frontend/src/utils/isEventObject.ts
Normal 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
|
||||
);
|
||||
};
|
||||
@@ -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;`;
|
||||
};
|
||||
|
||||
58
pkg/modules/spanpercentile/implspanpercentile/handler.go
Normal file
58
pkg/modules/spanpercentile/implspanpercentile/handler.go
Normal 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
|
||||
}
|
||||
126
pkg/modules/spanpercentile/implspanpercentile/module.go
Normal file
126
pkg/modules/spanpercentile/implspanpercentile/module.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
17
pkg/modules/spanpercentile/spanpercentile.go
Normal file
17
pkg/modules/spanpercentile/spanpercentile.go
Normal 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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
17
pkg/types/spanpercentiletypes/response.go
Normal file
17
pkg/types/spanpercentiletypes/response.go
Normal 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"`
|
||||
}
|
||||
43
pkg/types/spanpercentiletypes/spanpercentile.go
Normal file
43
pkg/types/spanpercentiletypes/spanpercentile.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user