mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-02 15:10:34 +01:00
Compare commits
1 Commits
issue_5131
...
inline-log
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ffbb1ddb8 |
19
.github/CODEOWNERS
vendored
19
.github/CODEOWNERS
vendored
@@ -169,22 +169,3 @@ go.mod @therealpandey
|
||||
## Dashboard V2
|
||||
/frontend/src/pages/DashboardPageV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/DashboardsListPageV2/ @SigNoz/pulse-frontend
|
||||
|
||||
## Infrastructure Monitoring
|
||||
/frontend/src/pages/InfrastructureMonitoring/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/InfraMonitoringHosts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/InfraMonitoringK8s/ @SigNoz/pulse-frontend
|
||||
|
||||
## Alerts
|
||||
/frontend/src/pages/AlertList/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/AlertDetails/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/CreateAlert/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/EditRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AlertHistory/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlertRule/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlertV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/EditAlertV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/FormAlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
|
||||
|
||||
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -52,7 +52,6 @@ jobs:
|
||||
- rootuser
|
||||
- serviceaccount
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
|
||||
@@ -64,16 +64,16 @@ web:
|
||||
settings:
|
||||
posthog:
|
||||
# Whether to enable PostHog in web.
|
||||
enabled: false
|
||||
enabled: true
|
||||
appcues:
|
||||
# Whether to enable Appcues in web.
|
||||
enabled: false
|
||||
enabled: true
|
||||
sentry:
|
||||
# Whether to enable Sentry in web.
|
||||
enabled: false
|
||||
enabled: true
|
||||
pylon:
|
||||
# Whether to enable Pylon in web.
|
||||
enabled: false
|
||||
enabled: true
|
||||
|
||||
##################### Cache #####################
|
||||
cache:
|
||||
|
||||
@@ -6572,15 +6572,6 @@ components:
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
SpantypesOtelSpanRef:
|
||||
properties:
|
||||
refType:
|
||||
type: string
|
||||
spanId:
|
||||
type: string
|
||||
traceId:
|
||||
type: string
|
||||
type: object
|
||||
SpantypesPostableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -6844,10 +6835,6 @@ components:
|
||||
type: string
|
||||
parent_span_id:
|
||||
type: string
|
||||
references:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesOtelSpanRef'
|
||||
type: array
|
||||
resource:
|
||||
additionalProperties:
|
||||
type: string
|
||||
@@ -6873,8 +6860,6 @@ components:
|
||||
type: string
|
||||
trace_state:
|
||||
type: string
|
||||
required:
|
||||
- references
|
||||
type: object
|
||||
TagtypesPostableTag:
|
||||
properties:
|
||||
|
||||
@@ -7768,21 +7768,6 @@ export interface SpantypesGettableTraceAggregationsDTO {
|
||||
aggregations: SpantypesSpanAggregationResultDTO[];
|
||||
}
|
||||
|
||||
export interface SpantypesOtelSpanRefDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
refType?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
spanId?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
traceId?: string;
|
||||
}
|
||||
|
||||
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -7877,10 +7862,6 @@ export interface SpantypesWaterfallSpanDTO {
|
||||
* @type string
|
||||
*/
|
||||
parent_span_id?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
references: SpantypesOtelSpanRefDTO[];
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
|
||||
1
frontend/src/assets/Icons/solid-x-circle.svg
Normal file
1
frontend/src/assets/Icons/solid-x-circle.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="14" height="14" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#prefix__clip0_4062_7291)" stroke-width="1.167" stroke-linecap="round" stroke-linejoin="round"><path d="M7 12.833A5.833 5.833 0 107 1.167a5.833 5.833 0 000 11.666z" fill="#E5484D" stroke="#E5484D"/><path d="M8.75 5.25l-3.5 3.5M5.25 5.25l3.5 3.5" stroke="#121317"/></g><defs><clipPath id="prefix__clip0_4062_7291"><path fill="#fff" d="M0 0h14v14H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 467 B |
@@ -9,6 +9,8 @@ import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import QueryStatus from './QueryStatus';
|
||||
|
||||
function LogsActionsContainer({
|
||||
listQuery,
|
||||
selectedPanelType,
|
||||
@@ -16,6 +18,10 @@ function LogsActionsContainer({
|
||||
handleToggleFrequencyChart,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
isFetching,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
}: {
|
||||
listQuery: any;
|
||||
selectedPanelType: PANEL_TYPES;
|
||||
@@ -23,6 +29,10 @@ function LogsActionsContainer({
|
||||
handleToggleFrequencyChart: () => void;
|
||||
orderBy: string;
|
||||
setOrderBy: (value: string) => void;
|
||||
isFetching: boolean;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isSuccess: boolean;
|
||||
}): JSX.Element {
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
@@ -96,6 +106,17 @@ function LogsActionsContainer({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(selectedPanelType === PANEL_TYPES.TIME_SERIES ||
|
||||
selectedPanelType === PANEL_TYPES.TABLE) && (
|
||||
<div className="query-stats">
|
||||
<QueryStatus
|
||||
loading={isLoading || isFetching}
|
||||
error={isError}
|
||||
success={isSuccess}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -155,6 +155,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
.query-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
align-self: flex-end;
|
||||
|
||||
.rows {
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: 0.36px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.time {
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: 0.36px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
border: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.query-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
49
frontend/src/container/LogsExplorerViews/QueryStatus.tsx
Normal file
49
frontend/src/container/LogsExplorerViews/QueryStatus.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { LoaderCircle, CircleCheck } from '@signozhq/icons';
|
||||
import { Spin } from 'antd';
|
||||
|
||||
import solidXCircleUrl from '@/assets/Icons/solid-x-circle.svg';
|
||||
|
||||
import './QueryStatus.styles.scss';
|
||||
|
||||
interface IQueryStatusProps {
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export default function QueryStatus(
|
||||
props: IQueryStatusProps,
|
||||
): React.ReactElement {
|
||||
const { loading, error, success } = props;
|
||||
|
||||
const content = useMemo((): React.ReactElement => {
|
||||
if (loading) {
|
||||
return (
|
||||
<Spin
|
||||
spinning
|
||||
size="small"
|
||||
indicator={<LoaderCircle className="animate-spin" size="md" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<img
|
||||
src={solidXCircleUrl}
|
||||
alt="header"
|
||||
className="error"
|
||||
style={{ height: '14px', width: '14px' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (success) {
|
||||
return (
|
||||
<CircleCheck className="success" size={14} fill={Color.BG_ROBIN_500} />
|
||||
);
|
||||
}
|
||||
return <div />;
|
||||
}, [error, loading, success]);
|
||||
return <div className="query-status">{content}</div>;
|
||||
}
|
||||
@@ -160,7 +160,7 @@ function LogsExplorerViewsContainer({
|
||||
'custom',
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } =
|
||||
const { data, isLoading, isFetching, isError, isSuccess, error } =
|
||||
useGetExplorerQueryRange(
|
||||
requestData,
|
||||
selectedPanelType,
|
||||
@@ -437,6 +437,10 @@ function LogsExplorerViewsContainer({
|
||||
handleToggleFrequencyChart={handleToggleFrequencyChart}
|
||||
orderBy={orderBy}
|
||||
setOrderBy={setOrderBy}
|
||||
isFetching={isFetching}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
isSuccess={isSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import styles from './AnalyticsPanel.module.scss';
|
||||
interface AnalyticsPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onTabChange: (tab: string) => void;
|
||||
}
|
||||
|
||||
const PANEL_WIDTH = 350;
|
||||
@@ -33,7 +32,6 @@ const PANEL_MARGIN_BOTTOM = 50;
|
||||
function AnalyticsPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
onTabChange,
|
||||
}: AnalyticsPanelProps): JSX.Element | null {
|
||||
const aggregations = useTraceStore((s) => s.aggregations);
|
||||
const colorByFieldName = useTraceStore((s) => s.colorByField.name);
|
||||
@@ -120,7 +118,7 @@ function AnalyticsPanel({
|
||||
/>
|
||||
|
||||
<div className={styles.body}>
|
||||
<TabsRoot defaultValue="exec-time" onValueChange={onTabChange}>
|
||||
<TabsRoot defaultValue="exec-time">
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="exec-time" variant="secondary">
|
||||
% exec time
|
||||
|
||||
@@ -31,12 +31,7 @@ import Events from 'container/SpanDetailsDrawer/Events/Events';
|
||||
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
TraceDetailEventKeys,
|
||||
TraceDetailEvents,
|
||||
} from 'pages/TraceDetailsV3/events';
|
||||
import { useMigratePinnedAttributes } from 'pages/TraceDetailsV3/hooks/useMigratePinnedAttributes';
|
||||
import { useTraceDetailLogEvent } from 'pages/TraceDetailsV3/hooks/useTraceDetailLogEvent';
|
||||
import {
|
||||
getSpanAttribute,
|
||||
getSpanDisplayData,
|
||||
@@ -91,16 +86,6 @@ function SpanDetailsContent({
|
||||
}): JSX.Element {
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
const spanAttributeActions = useSpanAttributeActions();
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', selectedSpan.trace_id);
|
||||
const handleTabChange = useCallback(
|
||||
(tab: string): void => {
|
||||
logTraceEvent(TraceDetailEvents.SpanPanelTabChanged, {
|
||||
[TraceDetailEventKeys.Tab]: tab,
|
||||
[TraceDetailEventKeys.SpanId]: selectedSpan.span_id,
|
||||
});
|
||||
},
|
||||
[logTraceEvent, selectedSpan.span_id],
|
||||
);
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
|
||||
@@ -391,7 +376,7 @@ function SpanDetailsContent({
|
||||
|
||||
<div className={styles.tabsSection}>
|
||||
{/* Step 9: ContentTabs */}
|
||||
<TabsRoot defaultValue="overview" onValueChange={handleTabChange}>
|
||||
<TabsRoot defaultValue="overview">
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="overview" variant="secondary">
|
||||
<Bookmark size={14} /> Overview
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
@@ -29,8 +29,6 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
@@ -92,35 +90,11 @@ function TraceDetailsHeader({
|
||||
const previewFields = useTraceStore((s) => s.previewFields);
|
||||
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceID || '');
|
||||
const pageLoadedAtRef = useRef(Date.now());
|
||||
|
||||
const handleSwitchToOldView = useCallback((): void => {
|
||||
logTraceEvent(TraceDetailEvents.ViewSwitched, {
|
||||
[TraceDetailEventKeys.From]: 'v3',
|
||||
[TraceDetailEventKeys.To]: 'v2',
|
||||
[TraceDetailEventKeys.DwellMs]: Date.now() - pageLoadedAtRef.current,
|
||||
});
|
||||
setLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW, 'true');
|
||||
const oldUrl = `/trace-old/${traceID}${window.location.search}`;
|
||||
history.replace(oldUrl);
|
||||
}, [traceID, logTraceEvent]);
|
||||
|
||||
const handleToggleAnalytics = useCallback((): void => {
|
||||
logTraceEvent(TraceDetailEvents.AnalyticsPanelToggled, {
|
||||
[TraceDetailEventKeys.Open]: !isAnalyticsOpen,
|
||||
});
|
||||
setIsAnalyticsOpen((prev) => !prev);
|
||||
}, [logTraceEvent, isAnalyticsOpen]);
|
||||
|
||||
const handleAnalyticsTabChange = useCallback(
|
||||
(tab: string): void => {
|
||||
logTraceEvent(TraceDetailEvents.AnalyticsTabChanged, {
|
||||
[TraceDetailEventKeys.Tab]: tab,
|
||||
});
|
||||
},
|
||||
[logTraceEvent],
|
||||
);
|
||||
}, [traceID]);
|
||||
|
||||
const handlePreviousBtnClick = useCallback((): void => {
|
||||
if (hasInAppHistory()) {
|
||||
@@ -193,7 +167,7 @@ function TraceDetailsHeader({
|
||||
size="icon"
|
||||
color="secondary"
|
||||
aria-label="Analytics"
|
||||
onClick={handleToggleAnalytics}
|
||||
onClick={(): void => setIsAnalyticsOpen((prev) => !prev)}
|
||||
>
|
||||
<ChartPie size={14} />
|
||||
</Button>
|
||||
@@ -271,7 +245,6 @@ function TraceDetailsHeader({
|
||||
<AnalyticsPanel
|
||||
isOpen={isAnalyticsOpen}
|
||||
onClose={(): void => setIsAnalyticsOpen(false)}
|
||||
onTabChange={handleAnalyticsTabChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
export enum TraceDetailEvents {
|
||||
DataLoaded = 'Trace Detail: Data loaded',
|
||||
ViewSwitched = 'Trace Detail: View switched',
|
||||
FlameGraphToggled = 'Trace Detail: Flame graph toggled',
|
||||
WaterfallToggled = 'Trace Detail: Waterfall toggled',
|
||||
AnalyticsPanelToggled = 'Trace Detail: Analytics panel toggled',
|
||||
AnalyticsTabChanged = 'Trace Detail: Analytics tab changed',
|
||||
SpanPanelTabChanged = 'Trace Detail: Span panel tab changed',
|
||||
}
|
||||
|
||||
export enum TraceDetailEventKeys {
|
||||
// Injected on every event by useTraceDetailLogEvent
|
||||
View = 'view',
|
||||
TraceId = 'traceId',
|
||||
// Data loaded — trace shape
|
||||
TotalSpansCount = 'totalSpansCount',
|
||||
NumServices = 'numServices',
|
||||
TraceDurationMs = 'traceDurationMs',
|
||||
HadErrors = 'hadErrors',
|
||||
FlamegraphSampled = 'flamegraphSampled',
|
||||
// Data loaded — persisted settings
|
||||
SpanPanelVariant = 'spanPanelVariant',
|
||||
ColorByField = 'colorByField',
|
||||
PreviewFieldsCount = 'previewFieldsCount',
|
||||
EntryPreferOldView = 'entryPreferOldView',
|
||||
// View switched
|
||||
From = 'from',
|
||||
To = 'to',
|
||||
DwellMs = 'dwellMs',
|
||||
// Toggles / tabs
|
||||
Expanded = 'expanded',
|
||||
Open = 'open',
|
||||
Tab = 'tab',
|
||||
// Span panel tab changed
|
||||
SpanId = 'spanId',
|
||||
}
|
||||
|
||||
export type TraceDetailView = 'v2' | 'v3';
|
||||
@@ -1,88 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { TraceDetailEvents } from '../../events';
|
||||
import { useTraceDetailLogEvent } from '../useTraceDetailLogEvent';
|
||||
|
||||
const logEventMock = jest.fn();
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]): void => logEventMock(...args),
|
||||
}));
|
||||
|
||||
describe('useTraceDetailLogEvent', () => {
|
||||
beforeEach(() => {
|
||||
logEventMock.mockClear();
|
||||
});
|
||||
|
||||
it('injects view and traceId on every event', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTraceDetailLogEvent('v3', 'trace-123'),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(TraceDetailEvents.DataLoaded, { totalSpansCount: 42 });
|
||||
});
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(logEventMock).toHaveBeenCalledWith(TraceDetailEvents.DataLoaded, {
|
||||
view: 'v3',
|
||||
traceId: 'trace-123',
|
||||
totalSpansCount: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('injects view and traceId even when no attributes are passed', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTraceDetailLogEvent('v2', 'trace-456'),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current(TraceDetailEvents.ViewSwitched);
|
||||
});
|
||||
|
||||
expect(logEventMock).toHaveBeenCalledWith(TraceDetailEvents.ViewSwitched, {
|
||||
view: 'v2',
|
||||
traceId: 'trace-456',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a stable callback identity and emits the latest traceId', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ traceId }) => useTraceDetailLogEvent('v3', traceId),
|
||||
{ initialProps: { traceId: 'trace-1' } },
|
||||
);
|
||||
|
||||
const firstIdentity = result.current;
|
||||
rerender({ traceId: 'trace-2' });
|
||||
|
||||
expect(result.current).toBe(firstIdentity);
|
||||
|
||||
act(() => {
|
||||
result.current(TraceDetailEvents.SpanPanelTabChanged, { spanId: 's1' });
|
||||
});
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
TraceDetailEvents.SpanPanelTabChanged,
|
||||
{
|
||||
view: 'v3',
|
||||
traceId: 'trace-2',
|
||||
spanId: 's1',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('never throws if logEvent throws (analytics must not break the UI)', () => {
|
||||
logEventMock.mockImplementationOnce(() => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useTraceDetailLogEvent('v3', 'trace-123'),
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current(TraceDetailEvents.DataLoaded);
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
import {
|
||||
TraceDetailEventKeys,
|
||||
TraceDetailEvents,
|
||||
TraceDetailView,
|
||||
} from '../events';
|
||||
|
||||
export type TraceDetailLogEvent = (
|
||||
event: TraceDetailEvents,
|
||||
attributes?: Record<string, unknown>,
|
||||
) => void;
|
||||
|
||||
export function useTraceDetailLogEvent(
|
||||
view: TraceDetailView,
|
||||
traceId: string,
|
||||
): TraceDetailLogEvent {
|
||||
const contextRef = useRef({ view, traceId });
|
||||
contextRef.current = { view, traceId };
|
||||
|
||||
return useCallback(
|
||||
(
|
||||
event: TraceDetailEvents,
|
||||
attributes: Record<string, unknown> = {},
|
||||
): void => {
|
||||
try {
|
||||
void logEvent(event, {
|
||||
[TraceDetailEventKeys.View]: contextRef.current.view,
|
||||
[TraceDetailEventKeys.TraceId]: contextRef.current.traceId,
|
||||
...attributes,
|
||||
});
|
||||
} catch {
|
||||
// No-op. Logging must never throw into the UI.
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -20,10 +20,7 @@ import {
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { COLOR_BY_FIELDS } from './constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
|
||||
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
|
||||
import TraceStoreSync from './stores/TraceStoreSync';
|
||||
import { useTraceStore } from './stores/traceStore';
|
||||
import { AGGREGATIONS } from './utils/aggregations';
|
||||
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
|
||||
@@ -59,14 +56,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
const selectedSpanId = urlQuery.get('spanId') || undefined;
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
|
||||
// Tracks which traceId the load event already fired for, so navigating
|
||||
// between traces (the route component stays mounted) re-fires it once each.
|
||||
const dataLoadedFiredForRef = useRef('');
|
||||
const colorByField = useTraceStore((s) => s.colorByField);
|
||||
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
|
||||
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
|
||||
|
||||
const handleSpanDetailsClose = useCallback((): void => {
|
||||
urlQuery.delete('spanId');
|
||||
safeNavigate({ search: urlQuery.toString() });
|
||||
@@ -165,46 +154,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
allSpansRef.current = allSpans;
|
||||
}, [allSpans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!traceId ||
|
||||
dataLoadedFiredForRef.current === traceId ||
|
||||
!userPrefsReady
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const payload = traceData?.payload;
|
||||
if (!payload?.spans?.length) {
|
||||
return;
|
||||
}
|
||||
dataLoadedFiredForRef.current = traceId;
|
||||
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||
[TraceDetailEventKeys.NumServices]: numServices,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
payload.endTimestampMillis - payload.startTimestampMillis,
|
||||
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
|
||||
[TraceDetailEventKeys.FlamegraphSampled]:
|
||||
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
|
||||
[TraceDetailEventKeys.SpanPanelVariant]:
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
|
||||
SpanDetailVariant.DOCKED_RIGHT,
|
||||
[TraceDetailEventKeys.ColorByField]: colorByField.name,
|
||||
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
|
||||
[TraceDetailEventKeys.EntryPreferOldView]:
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW) === 'true',
|
||||
});
|
||||
}, [
|
||||
traceId,
|
||||
userPrefsReady,
|
||||
traceData,
|
||||
totalSpansCount,
|
||||
colorByField,
|
||||
previewFieldsCount,
|
||||
logTraceEvent,
|
||||
]);
|
||||
|
||||
// Frontend mode: expand all parents by default when full data arrives
|
||||
useEffect(() => {
|
||||
if (isFullDataLoaded && allSpans.length > 0) {
|
||||
@@ -284,12 +233,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
|
||||
|
||||
const handleCollapseChange = (key: string): void => {
|
||||
logTraceEvent(
|
||||
key === 'flame'
|
||||
? TraceDetailEvents.FlameGraphToggled
|
||||
: TraceDetailEvents.WaterfallToggled,
|
||||
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
|
||||
);
|
||||
setActiveKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ChevronDown, Copy } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { JsonView } from 'periscope/components/JsonView';
|
||||
import { PrettyView } from 'periscope/components/PrettyView';
|
||||
import { PrettyViewProps } from 'periscope/components/PrettyView';
|
||||
@@ -13,8 +12,6 @@ import './DataViewer.styles.scss';
|
||||
|
||||
type ViewMode = 'pretty' | 'json';
|
||||
|
||||
const VIEW_MODE_CHANGED_EVENT = 'Data Viewer: View mode changed';
|
||||
|
||||
const VIEW_MODE_OPTIONS: { label: string; value: ViewMode }[] = [
|
||||
{ label: 'Pretty', value: 'pretty' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
@@ -37,20 +34,6 @@ function DataViewer({
|
||||
|
||||
const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]);
|
||||
|
||||
const handleViewModeChange = (value: string): void => {
|
||||
const next = value as ViewMode;
|
||||
setViewMode(next);
|
||||
try {
|
||||
logEvent(VIEW_MODE_CHANGED_EVENT, {
|
||||
viewMode: next,
|
||||
path: window.location.pathname,
|
||||
drawerKey,
|
||||
});
|
||||
} catch {
|
||||
// No op
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (): void => {
|
||||
const text = JSON.stringify(data, null, 2);
|
||||
setCopy(text);
|
||||
@@ -73,7 +56,7 @@ function DataViewer({
|
||||
{
|
||||
type: 'radio-group',
|
||||
value: viewMode,
|
||||
onChange: handleViewModeChange,
|
||||
onChange: (value): void => setViewMode(value as ViewMode),
|
||||
children: VIEW_MODE_OPTIONS.map((opt) => ({
|
||||
type: 'radio',
|
||||
key: opt.value,
|
||||
|
||||
@@ -74,7 +74,7 @@ func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary
|
||||
events, status_message, status_code_string, kind_string, parent_span_id,
|
||||
flags, is_remote, trace_state, status_code,
|
||||
db_name, db_operation, http_method, http_url, http_host,
|
||||
external_http_method, external_http_url, response_status_code, links as references
|
||||
external_http_method, external_http_url, response_status_code
|
||||
FROM %s.%s
|
||||
WHERE trace_id=? AND ts_bucket_start>=? AND ts_bucket_start<=?
|
||||
ORDER BY timestamp ASC, name ASC`,
|
||||
@@ -130,7 +130,7 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
|
||||
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
|
||||
"flags", "is_remote", "trace_state", "status_code",
|
||||
"db_name", "db_operation", "http_method", "http_url", "http_host",
|
||||
"external_http_method", "external_http_url", "response_status_code", "links as references",
|
||||
"external_http_method", "external_http_url", "response_status_code",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
ids := make([]any, len(spanIDs))
|
||||
|
||||
@@ -7,12 +7,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
)
|
||||
|
||||
type SkipResourceFingerprint struct {
|
||||
Enabled bool `yaml:"enabled" mapstructure:"enabled"`
|
||||
// If count of fingerprint is above threshold, skip the fingerprint subquery and filter on main table instead.
|
||||
Threshold uint64 `yaml:"threshold" mapstructure:"threshold"`
|
||||
}
|
||||
|
||||
// Config represents the configuration for the querier.
|
||||
type Config struct {
|
||||
// CacheTTL is the TTL for cached query results
|
||||
@@ -21,8 +15,6 @@ type Config struct {
|
||||
FluxInterval time.Duration `yaml:"flux_interval" mapstructure:"flux_interval"`
|
||||
// MaxConcurrentQueries is the maximum number of concurrent queries for missing ranges
|
||||
MaxConcurrentQueries int `yaml:"max_concurrent_queries" mapstructure:"max_concurrent_queries"`
|
||||
// SkipResourceFingerprint configures when the resource fingerprint subquery is skipped in favor of main-table filtering.
|
||||
SkipResourceFingerprint SkipResourceFingerprint `yaml:"skip_resource_fingerprint" mapstructure:"skip_resource_fingerprint"`
|
||||
}
|
||||
|
||||
// NewConfigFactory creates a new config factory for querier.
|
||||
@@ -36,10 +28,6 @@ func newConfig() factory.Config {
|
||||
CacheTTL: 168 * time.Hour,
|
||||
FluxInterval: 5 * time.Minute,
|
||||
MaxConcurrentQueries: 4,
|
||||
SkipResourceFingerprint: SkipResourceFingerprint{
|
||||
Enabled: false,
|
||||
Threshold: 100000,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,9 +42,6 @@ func (c Config) Validate() error {
|
||||
if c.MaxConcurrentQueries <= 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "max_concurrent_queries must be positive, got %v", c.MaxConcurrentQueries)
|
||||
}
|
||||
if c.SkipResourceFingerprint.Enabled && c.SkipResourceFingerprint.Threshold == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "skip_resource_fingerprint.threshold must be > 0 when enabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -88,8 +88,6 @@ func newProvider(
|
||||
traceAggExprRewriter,
|
||||
telemetryStore,
|
||||
flagger,
|
||||
cfg.SkipResourceFingerprint.Enabled,
|
||||
cfg.SkipResourceFingerprint.Threshold,
|
||||
)
|
||||
|
||||
// Create trace operator statement builder
|
||||
@@ -123,9 +121,6 @@ func newProvider(
|
||||
telemetrylogs.DefaultFullTextColumn,
|
||||
telemetrylogs.GetBodyJSONKey,
|
||||
flagger,
|
||||
telemetryStore,
|
||||
cfg.SkipResourceFingerprint.Enabled,
|
||||
cfg.SkipResourceFingerprint.Threshold,
|
||||
)
|
||||
|
||||
// Create audit statement builder
|
||||
|
||||
@@ -89,9 +89,6 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
|
||||
telemetrylogs.DefaultFullTextColumn,
|
||||
telemetrylogs.GetBodyJSONKey,
|
||||
fl,
|
||||
nil,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
return querier.New(
|
||||
@@ -137,8 +134,6 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
|
||||
traceAggExprRewriter,
|
||||
telemetryStore,
|
||||
fl,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
return querier.New(
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/contextlinks"
|
||||
@@ -85,19 +86,35 @@ func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*q
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
|
||||
// logSamples* bound the recent log lines we attach to a firing log-based alert.
|
||||
// They are kept small to bound the notification payload size.
|
||||
const (
|
||||
// logSamplesMaxCount is the number of most-recent matching log records sampled.
|
||||
logSamplesMaxCount = 5
|
||||
// logSampleBodyMaxLen truncates each sampled body (in runes) so a single large
|
||||
// record cannot blow up the annotation/notification.
|
||||
logSampleBodyMaxLen = 512
|
||||
)
|
||||
|
||||
// logsQueryParams extracts, for a log-based alert, the evaluation window and the
|
||||
// per-group where clause: the rule's filter combined with the breaching group's
|
||||
// label values (lbls). The same where clause backs both the related-logs link and
|
||||
// the sample-logs query, so they always refer to the same set of logs. ok is false
|
||||
// when the rule is not a single log builder query (e.g. a formula or non-logs
|
||||
// signal), in which case there is nothing to link to or sample.
|
||||
func (r *ThresholdRule) logsQueryParams(ctx context.Context, ts time.Time, lbls ruletypes.Labels) (start, end time.Time, whereClause string, ok bool) {
|
||||
selectedQuery := r.SelectedQuery(ctx)
|
||||
|
||||
qr, err := r.prepareQueryRange(ctx, ts)
|
||||
if err != nil {
|
||||
return nil
|
||||
return time.Time{}, time.Time{}, "", false
|
||||
}
|
||||
start := time.UnixMilli(int64(qr.Start))
|
||||
end := time.UnixMilli(int64(qr.End))
|
||||
start = time.UnixMilli(int64(qr.Start))
|
||||
end = time.UnixMilli(int64(qr.End))
|
||||
|
||||
// TODO(srikanthccv): handle formula queries
|
||||
if selectedQuery < "A" || selectedQuery > "Z" {
|
||||
return nil
|
||||
return time.Time{}, time.Time{}, "", false
|
||||
}
|
||||
|
||||
var q qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
@@ -112,7 +129,7 @@ func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time,
|
||||
}
|
||||
|
||||
if q.Signal != telemetrytypes.SignalLogs {
|
||||
return nil
|
||||
return time.Time{}, time.Time{}, "", false
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
@@ -120,11 +137,153 @@ func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time,
|
||||
filterExpr = q.Filter.Expression
|
||||
}
|
||||
|
||||
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, q.GroupBy)
|
||||
whereClause = contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, q.GroupBy)
|
||||
return start, end, whereClause, true
|
||||
}
|
||||
|
||||
func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
|
||||
start, end, whereClause, ok := r.logsQueryParams(ctx, ts, lbls)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return contextlinks.PrepareParamsForLogsV5(start, end, whereClause)
|
||||
}
|
||||
|
||||
// fetchLogSamples returns up to logSamplesMaxCount of the most-recent log records
|
||||
// matching the alert's filter for the breaching group (lbls), newest first. It
|
||||
// reuses the same where clause as the related-logs link, so the samples are exactly
|
||||
// the logs that link points to.
|
||||
//
|
||||
// Sampling is best-effort enrichment: any failure is logged and yields no samples
|
||||
// rather than failing the rule evaluation.
|
||||
func (r *ThresholdRule) fetchLogSamples(ctx context.Context, ts time.Time, lbls ruletypes.Labels) []*qbtypes.RawRow {
|
||||
start, end, whereClause, ok := r.logsQueryParams(ctx, ts, lbls)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(start.UnixMilli()),
|
||||
End: uint64(end.UnixMilli()),
|
||||
RequestType: qbtypes.RequestTypeRaw,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Name: "log_samples",
|
||||
Filter: &qbtypes.Filter{Expression: whereClause},
|
||||
Limit: logSamplesMaxCount,
|
||||
// timestamp,id DESC => most recent first. Both keys with an
|
||||
// identical direction are also what enables the window-list
|
||||
// fast path for raw log queries.
|
||||
Order: []qbtypes.OrderBy{
|
||||
{
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "timestamp", Materialized: true}},
|
||||
},
|
||||
{
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "id", Materialized: true}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
NoCache: true,
|
||||
}
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "rules",
|
||||
instrumentationtypes.CodeFunctionName: "fetchLogSamples",
|
||||
})
|
||||
|
||||
resp, err := r.querier.QueryRange(ctx, r.orgID, req)
|
||||
if err != nil {
|
||||
r.logger.WarnContext(ctx, "failed to fetch log samples for alert annotation", errors.Attr(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, item := range resp.Data.Results {
|
||||
if raw, ok := item.(*qbtypes.RawData); ok {
|
||||
return raw.Rows
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatLogSamples renders sampled log records as a compact, monospaced markdown
|
||||
// block: one line per record as "[RFC3339] SEVERITY body". Records without a body
|
||||
// are skipped (mirroring Datadog), each body is collapsed to a single line and
|
||||
// truncated to logSampleBodyMaxLen. Returns "" when there is nothing to show.
|
||||
func formatLogSamples(rows []*qbtypes.RawRow) string {
|
||||
lines := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
body := strings.TrimSpace(rawRowStringField(row, "body"))
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
body = truncateRunes(logSampleSingleLine(body), logSampleBodyMaxLen)
|
||||
|
||||
var sb strings.Builder
|
||||
if !row.Timestamp.IsZero() {
|
||||
sb.WriteString("[")
|
||||
sb.WriteString(row.Timestamp.UTC().Format(time.RFC3339))
|
||||
sb.WriteString("] ")
|
||||
}
|
||||
if sev := strings.TrimSpace(rawRowStringField(row, "severity_text")); sev != "" {
|
||||
sb.WriteString(sev)
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
sb.WriteString(body)
|
||||
lines = append(lines, sb.String())
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "```\n" + strings.Join(lines, "\n") + "\n```"
|
||||
}
|
||||
|
||||
// rawRowStringField returns the named field from a raw row as a string, or "" if it
|
||||
// is absent or not a string.
|
||||
func rawRowStringField(row *qbtypes.RawRow, key string) string {
|
||||
if row == nil || row.Data == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := row.Data[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// logSampleSingleLine collapses newlines so a multi-line log body renders as one
|
||||
// line within the samples block.
|
||||
func logSampleSingleLine(s string) string {
|
||||
replacer := strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ")
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
|
||||
// truncateRunes shortens s to at most max runes, appending an ellipsis when trimmed.
|
||||
func truncateRunes(s string, max int) string {
|
||||
if max <= 0 {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
return string(runes[:max]) + "…"
|
||||
}
|
||||
|
||||
func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
|
||||
selectedQuery := r.SelectedQuery(ctx)
|
||||
|
||||
@@ -352,6 +511,14 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
r.logger.InfoContext(ctx, "adding logs link to annotations", slog.String("annotation.link", link))
|
||||
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedLogs, Value: link})
|
||||
}
|
||||
// Attach a few recent matching log lines so responders see what fired
|
||||
// the alert without leaving the notification. Skipped for no-data
|
||||
// alerts, which by definition have no matching logs.
|
||||
if !smpl.IsMissing {
|
||||
if samples := formatLogSamples(r.fetchLogSamples(ctx, ts, smpl.Metric)); samples != "" {
|
||||
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedLogsSamples, Value: samples})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lbs := lb.Labels()
|
||||
|
||||
72
pkg/query-service/rules/threshold_rule_log_samples_test.go
Normal file
72
pkg/query-service/rules/threshold_rule_log_samples_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFormatLogSamples(t *testing.T) {
|
||||
ts := time.Date(2026, time.June, 1, 12, 0, 3, 0, time.UTC)
|
||||
|
||||
t.Run("nil and empty yield empty string", func(t *testing.T) {
|
||||
assert.Equal(t, "", formatLogSamples(nil))
|
||||
assert.Equal(t, "", formatLogSamples([]*qbtypes.RawRow{}))
|
||||
})
|
||||
|
||||
t.Run("skips nil rows and rows without a usable body", func(t *testing.T) {
|
||||
rows := []*qbtypes.RawRow{
|
||||
nil,
|
||||
{Timestamp: ts, Data: map[string]any{"body": ""}},
|
||||
{Timestamp: ts, Data: map[string]any{"body": " "}},
|
||||
{Timestamp: ts, Data: map[string]any{"severity_text": "ERROR"}}, // no body key
|
||||
{Timestamp: ts, Data: map[string]any{"body": 42}}, // body not a string
|
||||
}
|
||||
assert.Equal(t, "", formatLogSamples(rows))
|
||||
})
|
||||
|
||||
t.Run("renders timestamp, severity and body inside a code block", func(t *testing.T) {
|
||||
rows := []*qbtypes.RawRow{
|
||||
{Timestamp: ts, Data: map[string]any{"severity_text": "ERROR", "body": "payment failed"}},
|
||||
}
|
||||
want := "```\n[2026-06-01T12:00:03Z] ERROR payment failed\n```"
|
||||
assert.Equal(t, want, formatLogSamples(rows))
|
||||
})
|
||||
|
||||
t.Run("omits severity when absent and collapses a multi-line body", func(t *testing.T) {
|
||||
rows := []*qbtypes.RawRow{
|
||||
{Timestamp: ts, Data: map[string]any{"body": "line1\nline2\r\nline3"}},
|
||||
}
|
||||
want := "```\n[2026-06-01T12:00:03Z] line1 line2 line3\n```"
|
||||
assert.Equal(t, want, formatLogSamples(rows))
|
||||
})
|
||||
|
||||
t.Run("omits the timestamp prefix when zero", func(t *testing.T) {
|
||||
rows := []*qbtypes.RawRow{
|
||||
{Data: map[string]any{"body": "no ts"}},
|
||||
}
|
||||
assert.Equal(t, "```\nno ts\n```", formatLogSamples(rows))
|
||||
})
|
||||
|
||||
t.Run("renders one line per record and preserves input order", func(t *testing.T) {
|
||||
rows := []*qbtypes.RawRow{
|
||||
{Timestamp: ts, Data: map[string]any{"body": "first"}},
|
||||
{Timestamp: ts.Add(-time.Second), Data: map[string]any{"body": "second"}},
|
||||
}
|
||||
want := "```\n[2026-06-01T12:00:03Z] first\n[2026-06-01T12:00:02Z] second\n```"
|
||||
assert.Equal(t, want, formatLogSamples(rows))
|
||||
})
|
||||
|
||||
t.Run("truncates a long body to logSampleBodyMaxLen runes plus ellipsis", func(t *testing.T) {
|
||||
long := strings.Repeat("a", logSampleBodyMaxLen+50)
|
||||
rows := []*qbtypes.RawRow{
|
||||
{Timestamp: ts, Data: map[string]any{"body": long}},
|
||||
}
|
||||
out := formatLogSamples(rows)
|
||||
assert.Contains(t, out, strings.Repeat("a", logSampleBodyMaxLen)+"…")
|
||||
assert.NotContains(t, out, strings.Repeat("a", logSampleBodyMaxLen+1))
|
||||
})
|
||||
}
|
||||
@@ -1205,9 +1205,6 @@ func buildJSONTestStatementBuilder(t *testing.T, addIndexes bool) (*logQueryStat
|
||||
DefaultFullTextColumn,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
nil,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
return statementBuilder, mockMetadataStore
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryresourcefilter"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
@@ -20,14 +19,13 @@ import (
|
||||
)
|
||||
|
||||
type logQueryStatementBuilder struct {
|
||||
logger *slog.Logger
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
fm qbtypes.FieldMapper
|
||||
cb qbtypes.ConditionBuilder
|
||||
resourceFilterResolver *telemetryresourcefilter.ResourceFingerprintResolver[qbtypes.LogAggregation]
|
||||
aggExprRewriter qbtypes.AggExprRewriter
|
||||
fl flagger.Flagger
|
||||
skipResourceFingerprintEnabled bool
|
||||
logger *slog.Logger
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
fm qbtypes.FieldMapper
|
||||
cb qbtypes.ConditionBuilder
|
||||
resourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
aggExprRewriter qbtypes.AggExprRewriter
|
||||
fl flagger.Flagger
|
||||
|
||||
fullTextColumn *telemetrytypes.TelemetryFieldKey
|
||||
jsonKeyToKey qbtypes.JsonKeyToFieldFunc
|
||||
@@ -44,13 +42,10 @@ func NewLogQueryStatementBuilder(
|
||||
fullTextColumn *telemetrytypes.TelemetryFieldKey,
|
||||
jsonKeyToKey qbtypes.JsonKeyToFieldFunc,
|
||||
fl flagger.Flagger,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
skipResourceFingerprintEnable bool,
|
||||
skipResourceFingerprintThreshold uint64,
|
||||
) *logQueryStatementBuilder {
|
||||
logsSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/telemetrylogs")
|
||||
|
||||
resourceFilterResolver := telemetryresourcefilter.NewResolver[qbtypes.LogAggregation](
|
||||
resourceFilterStmtBuilder := telemetryresourcefilter.New[qbtypes.LogAggregation](
|
||||
settings,
|
||||
DBName,
|
||||
LogsResourceV2TableName,
|
||||
@@ -60,21 +55,18 @@ func NewLogQueryStatementBuilder(
|
||||
fullTextColumn,
|
||||
jsonKeyToKey,
|
||||
fl,
|
||||
telemetryStore,
|
||||
skipResourceFingerprintThreshold,
|
||||
)
|
||||
|
||||
return &logQueryStatementBuilder{
|
||||
logger: logsSettings.Logger(),
|
||||
metadataStore: metadataStore,
|
||||
fm: fieldMapper,
|
||||
cb: conditionBuilder,
|
||||
resourceFilterResolver: resourceFilterResolver,
|
||||
aggExprRewriter: aggExprRewriter,
|
||||
fl: fl,
|
||||
skipResourceFingerprintEnabled: skipResourceFingerprintEnable,
|
||||
fullTextColumn: fullTextColumn,
|
||||
jsonKeyToKey: jsonKeyToKey,
|
||||
logger: logsSettings.Logger(),
|
||||
metadataStore: metadataStore,
|
||||
fm: fieldMapper,
|
||||
cb: conditionBuilder,
|
||||
resourceFilterStmtBuilder: resourceFilterStmtBuilder,
|
||||
aggExprRewriter: aggExprRewriter,
|
||||
fl: fl,
|
||||
fullTextColumn: fullTextColumn,
|
||||
jsonKeyToKey: jsonKeyToKey,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,11 +271,9 @@ func (b *logQueryStatementBuilder) buildListQuery(
|
||||
bodyJSONEnabled = b.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
|
||||
)
|
||||
|
||||
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
|
||||
if err != nil {
|
||||
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if frag != "" {
|
||||
} else if frag != "" {
|
||||
cteFragments = append(cteFragments, frag)
|
||||
cteArgs = append(cteArgs, args)
|
||||
}
|
||||
@@ -325,7 +315,7 @@ func (b *logQueryStatementBuilder) buildListQuery(
|
||||
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName))
|
||||
// Add filter conditions
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -383,11 +373,9 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
bodyJSONEnabled = b.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
|
||||
)
|
||||
|
||||
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
|
||||
if err != nil {
|
||||
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if frag != "" {
|
||||
} else if frag != "" {
|
||||
cteFragments = append(cteFragments, frag)
|
||||
cteArgs = append(cteArgs, args)
|
||||
}
|
||||
@@ -431,7 +419,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
// Add FROM clause
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName))
|
||||
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -543,11 +531,9 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
|
||||
bodyJSONEnabled = b.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
|
||||
)
|
||||
|
||||
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
|
||||
if err != nil {
|
||||
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if frag != "" && !skipResourceCTE {
|
||||
} else if frag != "" && !skipResourceCTE {
|
||||
cteFragments = append(cteFragments, frag)
|
||||
cteArgs = append(cteArgs, args)
|
||||
}
|
||||
@@ -590,7 +576,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName))
|
||||
|
||||
// Add filter conditions
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -654,7 +640,6 @@ func (b *logQueryStatementBuilder) addFilterCondition(
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
skipResourceFilter bool,
|
||||
) (querybuilder.PreparedWhereClause, error) {
|
||||
|
||||
var preparedWhereClause querybuilder.PreparedWhereClause
|
||||
@@ -671,7 +656,7 @@ func (b *logQueryStatementBuilder) addFilterCondition(
|
||||
ConditionBuilder: b.cb,
|
||||
FieldKeys: keys,
|
||||
BodyJSONEnabled: bodyJSONEnabled,
|
||||
SkipResourceFilter: skipResourceFilter,
|
||||
SkipResourceFilter: true,
|
||||
FullTextColumn: b.fullTextColumn,
|
||||
JsonKeyToKey: b.jsonKeyToKey,
|
||||
Variables: variables,
|
||||
@@ -722,30 +707,33 @@ func (b *logQueryStatementBuilder) maybeAttachResourceFilter(
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||
start, end uint64,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (cteSQL string, cteArgs []any, skipResourceFilter bool, err error) {
|
||||
) (cteSQL string, cteArgs []any, err error) {
|
||||
|
||||
if b.skipResourceFingerprintEnabled {
|
||||
decision, err := b.resourceFilterResolver.Resolve(ctx, query, start, end, variables)
|
||||
if err != nil {
|
||||
return "", nil, true, err
|
||||
}
|
||||
switch decision {
|
||||
case qbtypes.ResourceFilterResolveKindNoOp:
|
||||
return "", nil, true, nil
|
||||
case qbtypes.ResourceFilterResolveKindFallback:
|
||||
return "", nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
stmt, err := b.resourceFilterResolver.StatementBuilder().Build(
|
||||
ctx, start, end, qbtypes.RequestTypeRaw, query, variables,
|
||||
)
|
||||
stmt, err := b.buildResourceFilterCTE(ctx, query, start, end, variables)
|
||||
if err != nil {
|
||||
return "", nil, true, err
|
||||
return "", nil, err
|
||||
}
|
||||
if stmt == nil {
|
||||
return "", nil, true, nil
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
sb.Where("resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
|
||||
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, true, nil
|
||||
|
||||
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, nil
|
||||
}
|
||||
|
||||
func (b *logQueryStatementBuilder) buildResourceFilterCTE(
|
||||
ctx context.Context,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||
start, end uint64,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
return b.resourceFilterStmtBuilder.Build(
|
||||
ctx,
|
||||
start,
|
||||
end,
|
||||
qbtypes.RequestTypeRaw,
|
||||
query,
|
||||
variables,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,36 +2,19 @@ package telemetrylogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type regexQueryMatcher struct{}
|
||||
|
||||
func (m *regexQueryMatcher) Match(expectedSQL, actualSQL string) error {
|
||||
re, err := regexp.Compile(expectedSQL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !re.MatchString(actualSQL) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "expected query to match %s, got %s", expectedSQL, actualSQL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestStatementBuilderTimeSeries(t *testing.T) {
|
||||
// Create a test release time
|
||||
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
|
||||
@@ -229,9 +212,6 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
|
||||
DefaultFullTextColumn,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
nil,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -373,9 +353,6 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
DefaultFullTextColumn,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
nil,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -522,9 +499,6 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
|
||||
DefaultFullTextColumn,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
nil,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -601,9 +575,6 @@ func TestStatementBuilderTimeSeriesBodyGroupBy(t *testing.T) {
|
||||
DefaultFullTextColumn,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
nil,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -699,9 +670,6 @@ func TestStatementBuilderListQueryServiceCollision(t *testing.T) {
|
||||
DefaultFullTextColumn,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
nil,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -926,9 +894,6 @@ func TestAdjustKey(t *testing.T) {
|
||||
DefaultFullTextColumn,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
nil,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -1074,9 +1039,6 @@ func TestStmtBuilderBodyField(t *testing.T) {
|
||||
DefaultFullTextColumn,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
nil,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
|
||||
@@ -1176,9 +1138,6 @@ func TestStmtBuilderBodyFullTextSearch(t *testing.T) {
|
||||
DefaultFullTextColumn,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
nil,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
|
||||
@@ -1198,110 +1157,3 @@ func TestStmtBuilderBodyFullTextSearch(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkipResourceFingerprintLogs exercises the three resolver outcomes for
|
||||
// logs: use-CTE (count < threshold), fallback (count >= threshold), and the
|
||||
// legacy path (feature disabled).
|
||||
func TestSkipResourceFingerprintLogs(t *testing.T) {
|
||||
const (
|
||||
startMs = uint64(1747947419000)
|
||||
endMs = uint64(1747983448000)
|
||||
threshold = uint64(10)
|
||||
)
|
||||
|
||||
query := qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "service.name = 'redis-manual'",
|
||||
},
|
||||
Limit: 5,
|
||||
}
|
||||
|
||||
t.Run("disabled uses the legacy CTE", func(t *testing.T) {
|
||||
sb := newSkipResourceFingerprintLogsBuilder(t, nil, false, threshold)
|
||||
|
||||
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, stmt.Query, "__resource_filter AS (SELECT fingerprint")
|
||||
require.Contains(t, stmt.Query, "resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
|
||||
})
|
||||
|
||||
t.Run("CTE attached when count below threshold", func(t *testing.T) {
|
||||
mockStore := telemetrystoretest.New(telemetrystore.Config{}, ®exQueryMatcher{})
|
||||
mock := mockStore.Mock()
|
||||
|
||||
mock.ExpectQueryRow(`SELECT count\(\) FROM \(SELECT fingerprint FROM signoz_logs\.distributed_logs_v2_resource`).
|
||||
WillReturnRow(cmock.NewRow([]cmock.ColumnType{
|
||||
{Name: "count", Type: "UInt64"},
|
||||
}, []any{uint64(2)}))
|
||||
|
||||
sb := newSkipResourceFingerprintLogsBuilder(t, mockStore, true, threshold)
|
||||
|
||||
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Contains(t, stmt.Query, "__resource_filter AS (SELECT fingerprint")
|
||||
require.Contains(t, stmt.Query, "resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
|
||||
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
})
|
||||
|
||||
t.Run("fallback when count at or above threshold", func(t *testing.T) {
|
||||
mockStore := telemetrystoretest.New(telemetrystore.Config{}, ®exQueryMatcher{})
|
||||
mock := mockStore.Mock()
|
||||
|
||||
mock.ExpectQueryRow(`SELECT count\(\) FROM \(SELECT fingerprint FROM signoz_logs\.distributed_logs_v2_resource`).
|
||||
WillReturnRow(cmock.NewRow([]cmock.ColumnType{
|
||||
{Name: "count", Type: "UInt64"},
|
||||
}, []any{threshold}))
|
||||
|
||||
sb := newSkipResourceFingerprintLogsBuilder(t, mockStore, true, threshold)
|
||||
|
||||
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotContains(t, stmt.Query, "__resource_filter AS")
|
||||
require.NotContains(t, stmt.Query, "resource_fingerprint")
|
||||
require.Contains(t, stmt.Query, "service.name")
|
||||
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
})
|
||||
}
|
||||
|
||||
func newSkipResourceFingerprintLogsBuilder(
|
||||
t *testing.T,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
skipEnable bool,
|
||||
threshold uint64,
|
||||
) *logQueryStatementBuilder {
|
||||
t.Helper()
|
||||
|
||||
fl := flaggertest.New(t)
|
||||
fm := NewFieldMapper(fl)
|
||||
cb := NewConditionBuilder(fm, fl)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC))
|
||||
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
DefaultFullTextColumn,
|
||||
fm,
|
||||
cb,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
)
|
||||
|
||||
return NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
aggExprRewriter,
|
||||
DefaultFullTextColumn,
|
||||
GetBodyJSONKey,
|
||||
fl,
|
||||
telemetryStore,
|
||||
skipEnable,
|
||||
threshold,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
package telemetryresourcefilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
type ResourceFingerprintResolver[T any] struct {
|
||||
stmtBuilder *resourceFilterStatementBuilder[T]
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
threshold uint64
|
||||
}
|
||||
|
||||
func NewResolver[T any](
|
||||
settings factory.ProviderSettings,
|
||||
dbName string,
|
||||
tableName string,
|
||||
signal telemetrytypes.Signal,
|
||||
source telemetrytypes.Source,
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
fullTextColumn *telemetrytypes.TelemetryFieldKey,
|
||||
jsonKeyToKey qbtypes.JsonKeyToFieldFunc,
|
||||
fl flagger.Flagger,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
threshold uint64,
|
||||
) *ResourceFingerprintResolver[T] {
|
||||
return &ResourceFingerprintResolver[T]{
|
||||
stmtBuilder: New[T](
|
||||
settings,
|
||||
dbName,
|
||||
tableName,
|
||||
signal,
|
||||
source,
|
||||
metadataStore,
|
||||
fullTextColumn,
|
||||
jsonKeyToKey,
|
||||
fl,
|
||||
),
|
||||
telemetryStore: telemetryStore,
|
||||
threshold: threshold,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ResourceFingerprintResolver[T]) StatementBuilder() qbtypes.StatementBuilder[T] {
|
||||
return r.stmtBuilder
|
||||
}
|
||||
|
||||
func (r *ResourceFingerprintResolver[T]) Resolve(
|
||||
ctx context.Context,
|
||||
query qbtypes.QueryBuilderQuery[T],
|
||||
start, end uint64,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (qbtypes.ResourceFilterResolveKind, error) {
|
||||
countStmt, err := r.stmtBuilder.BuildCount(ctx, start, end, query, variables)
|
||||
if err != nil {
|
||||
return qbtypes.ResourceFilterResolveKindNoOp, err
|
||||
}
|
||||
if countStmt == nil {
|
||||
return qbtypes.ResourceFilterResolveKindNoOp, nil
|
||||
}
|
||||
|
||||
var count uint64
|
||||
row := r.telemetryStore.ClickhouseDB().QueryRow(ctx, countStmt.Query, countStmt.Args...)
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return qbtypes.ResourceFilterResolveKindNoOp, err
|
||||
}
|
||||
|
||||
if count >= r.threshold {
|
||||
return qbtypes.ResourceFilterResolveKindFallback, nil
|
||||
}
|
||||
return qbtypes.ResourceFilterResolveKindUseCTE, nil
|
||||
}
|
||||
@@ -127,25 +127,6 @@ func (b *resourceFilterStatementBuilder[T]) Build(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BuildCount returns a statement that counts the distinct fingerprints matching
|
||||
// the resource filter. Returns (nil, nil) when the filter is a no-op.
|
||||
func (b *resourceFilterStatementBuilder[T]) BuildCount(
|
||||
ctx context.Context,
|
||||
start uint64,
|
||||
end uint64,
|
||||
query qbtypes.QueryBuilderQuery[T],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
inner, err := b.Build(ctx, start, end, qbtypes.RequestTypeRaw, query, variables)
|
||||
if err != nil || inner == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &qbtypes.Statement{
|
||||
Query: fmt.Sprintf("SELECT count() FROM (%s)", inner.Query),
|
||||
Args: inner.Args,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// addConditions adds both filter and time conditions to the query.
|
||||
// Returns true (isNoOp) when the filter expression evaluated to no resource conditions,
|
||||
// meaning the CTE would select all fingerprints and should be skipped entirely.
|
||||
|
||||
@@ -24,13 +24,13 @@ var (
|
||||
)
|
||||
|
||||
type traceQueryStatementBuilder struct {
|
||||
logger *slog.Logger
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
fm qbtypes.FieldMapper
|
||||
cb qbtypes.ConditionBuilder
|
||||
resourceFilterResolver *telemetryresourcefilter.ResourceFingerprintResolver[qbtypes.TraceAggregation]
|
||||
aggExprRewriter qbtypes.AggExprRewriter
|
||||
skipResourceFingerprintEnabled bool
|
||||
logger *slog.Logger
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
fm qbtypes.FieldMapper
|
||||
cb qbtypes.ConditionBuilder
|
||||
resourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
aggExprRewriter qbtypes.AggExprRewriter
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
}
|
||||
|
||||
var _ qbtypes.StatementBuilder[qbtypes.TraceAggregation] = (*traceQueryStatementBuilder)(nil)
|
||||
@@ -43,12 +43,10 @@ func NewTraceQueryStatementBuilder(
|
||||
aggExprRewriter qbtypes.AggExprRewriter,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
flagger flagger.Flagger,
|
||||
skipResourceFingerprintEnable bool,
|
||||
skipResourceFingerprintThreshold uint64,
|
||||
) *traceQueryStatementBuilder {
|
||||
tracesSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/telemetrytraces")
|
||||
|
||||
resourceFilterResolver := telemetryresourcefilter.NewResolver[qbtypes.TraceAggregation](
|
||||
resourceFilterStmtBuilder := telemetryresourcefilter.New[qbtypes.TraceAggregation](
|
||||
settings,
|
||||
DBName,
|
||||
TracesResourceV3TableName,
|
||||
@@ -58,18 +56,16 @@ func NewTraceQueryStatementBuilder(
|
||||
nil,
|
||||
nil,
|
||||
flagger,
|
||||
telemetryStore,
|
||||
skipResourceFingerprintThreshold,
|
||||
)
|
||||
|
||||
return &traceQueryStatementBuilder{
|
||||
logger: tracesSettings.Logger(),
|
||||
metadataStore: metadataStore,
|
||||
fm: fieldMapper,
|
||||
cb: conditionBuilder,
|
||||
resourceFilterResolver: resourceFilterResolver,
|
||||
aggExprRewriter: aggExprRewriter,
|
||||
skipResourceFingerprintEnabled: skipResourceFingerprintEnable,
|
||||
logger: tracesSettings.Logger(),
|
||||
metadataStore: metadataStore,
|
||||
fm: fieldMapper,
|
||||
cb: conditionBuilder,
|
||||
resourceFilterStmtBuilder: resourceFilterStmtBuilder,
|
||||
aggExprRewriter: aggExprRewriter,
|
||||
telemetryStore: telemetryStore,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,10 +124,8 @@ func (b *traceQueryStatementBuilder) Build(
|
||||
-------------------------------- End of tech debt ----------------------------
|
||||
*/
|
||||
|
||||
for _, action := range adjustTraceKeys(keys, &query, requestType) {
|
||||
// TODO: change to debug level once we are confident about the behavior
|
||||
b.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
}
|
||||
query = b.adjustKeys(ctx, keys, query, requestType)
|
||||
|
||||
// Create SQL builder
|
||||
q := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
@@ -199,30 +193,24 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
|
||||
return keySelectors
|
||||
}
|
||||
|
||||
// mergeDeprecatedTraceKeys prepends deprecated intrinsic/calculated trace field
|
||||
// definitions to the keys map. We do this during statement building, not at
|
||||
// metadata fetch time, because:
|
||||
// 1. Filter expressions that reference deprecated columns must continue to
|
||||
// resolve — otherwise they fail with "key not found".
|
||||
// 2. Doing it at metadata fetch time would also surface deprecated keys in
|
||||
// autocomplete suggestions, which we don't want.
|
||||
// 3. We prepend (not append) so the intrinsic/calculated entry wins ordering
|
||||
// in the multi_if SQL expression.
|
||||
func mergeDeprecatedTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey) {
|
||||
func (b *traceQueryStatementBuilder) adjustKeys(ctx context.Context, keys map[string][]*telemetrytypes.TelemetryFieldKey, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], requestType qbtypes.RequestType) qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation] {
|
||||
|
||||
// add deprecated fields only during statement building
|
||||
// why?
|
||||
// 1. to not fail filter expression that use deprecated cols
|
||||
// 2. this could have been moved to metadata fetching itself, however, that
|
||||
// would mean, they also show up in suggestions we we don't want to do
|
||||
// 3. reason for not doing a simple append is to keep intrinsic/calculated field first so that it gets
|
||||
// priority in multi_if sql expression
|
||||
for fieldKeyName, fieldKey := range IntrinsicFieldsDeprecated {
|
||||
keys[fieldKeyName] = append([]*telemetrytypes.TelemetryFieldKey{&fieldKey}, keys[fieldKeyName]...)
|
||||
}
|
||||
for fieldKeyName, fieldKey := range CalculatedFieldsDeprecated {
|
||||
keys[fieldKeyName] = append([]*telemetrytypes.TelemetryFieldKey{&fieldKey}, keys[fieldKeyName]...)
|
||||
}
|
||||
}
|
||||
|
||||
func adjustTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, query *qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], requestType qbtypes.RequestType) []string {
|
||||
|
||||
mergeDeprecatedTraceKeys(keys)
|
||||
|
||||
// Adjust keys for alias expressions in aggregations
|
||||
actions := querybuilder.AdjustKeysForAliasExpressions(query, requestType)
|
||||
actions := querybuilder.AdjustKeysForAliasExpressions(&query, requestType)
|
||||
|
||||
/*
|
||||
Check if user is using multiple contexts or data types for same field name
|
||||
@@ -240,7 +228,7 @@ func adjustTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, query
|
||||
and make it just http.status_code and remove the duplicate entry.
|
||||
*/
|
||||
|
||||
actions = append(actions, querybuilder.AdjustDuplicateKeys(query)...)
|
||||
actions = append(actions, querybuilder.AdjustDuplicateKeys(&query)...)
|
||||
|
||||
/*
|
||||
Now adjust each key to have correct context and data type
|
||||
@@ -248,20 +236,24 @@ func adjustTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, query
|
||||
Reason for doing this is to not create an unexpected behavior for users
|
||||
*/
|
||||
for idx := range query.SelectFields {
|
||||
actions = append(actions, adjustTraceKey(&query.SelectFields[idx], keys)...)
|
||||
actions = append(actions, b.adjustKey(&query.SelectFields[idx], keys)...)
|
||||
}
|
||||
for idx := range query.GroupBy {
|
||||
actions = append(actions, adjustTraceKey(&query.GroupBy[idx].TelemetryFieldKey, keys)...)
|
||||
actions = append(actions, b.adjustKey(&query.GroupBy[idx].TelemetryFieldKey, keys)...)
|
||||
}
|
||||
for idx := range query.Order {
|
||||
actions = append(actions, adjustTraceKey(&query.Order[idx].Key.TelemetryFieldKey, keys)...)
|
||||
actions = append(actions, b.adjustKey(&query.Order[idx].Key.TelemetryFieldKey, keys)...)
|
||||
}
|
||||
|
||||
return actions
|
||||
for _, action := range actions {
|
||||
// TODO: change to debug level once we are confident about the behavior
|
||||
b.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
// adjustTraceKey resolves a single TelemetryFieldKey against the keys map.
|
||||
func adjustTraceKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []string {
|
||||
func (b *traceQueryStatementBuilder) adjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []string {
|
||||
|
||||
// for recording actions taken
|
||||
actions := []string{}
|
||||
@@ -310,11 +302,9 @@ func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
cteArgs [][]any
|
||||
)
|
||||
|
||||
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
|
||||
if err != nil {
|
||||
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if frag != "" {
|
||||
} else if frag != "" {
|
||||
cteFragments = append(cteFragments, frag)
|
||||
cteArgs = append(cteArgs, args)
|
||||
}
|
||||
@@ -332,7 +322,7 @@ func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName))
|
||||
|
||||
// Add filter conditions
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -393,17 +383,15 @@ func (b *traceQueryStatementBuilder) buildTraceQuery(
|
||||
cteArgs [][]any
|
||||
)
|
||||
|
||||
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, distSB, query, start, end, variables)
|
||||
if err != nil {
|
||||
if frag, args, err := b.maybeAttachResourceFilter(ctx, distSB, query, start, end, variables); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if frag != "" {
|
||||
} else if frag != "" {
|
||||
cteFragments = append(cteFragments, frag)
|
||||
cteArgs = append(cteArgs, args)
|
||||
}
|
||||
|
||||
// Add filter conditions
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, distSB, start, end, query, keys, variables, skipResourceFilter)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, distSB, start, end, query, keys, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -504,11 +492,9 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
cteArgs [][]any
|
||||
)
|
||||
|
||||
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
|
||||
if err != nil {
|
||||
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if frag != "" {
|
||||
} else if frag != "" {
|
||||
cteFragments = append(cteFragments, frag)
|
||||
cteArgs = append(cteArgs, args)
|
||||
}
|
||||
@@ -549,7 +535,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
}
|
||||
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName))
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -658,11 +644,9 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
cteArgs [][]any
|
||||
)
|
||||
|
||||
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
|
||||
if err != nil {
|
||||
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if frag != "" && !skipResourceCTE {
|
||||
} else if frag != "" && !skipResourceCTE {
|
||||
cteFragments = append(cteFragments, frag)
|
||||
cteArgs = append(cteArgs, args)
|
||||
}
|
||||
@@ -704,7 +688,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName))
|
||||
|
||||
// Add filter conditions
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -767,7 +751,6 @@ func (b *traceQueryStatementBuilder) addFilterCondition(
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
skipResourceFilter bool,
|
||||
) (querybuilder.PreparedWhereClause, error) {
|
||||
|
||||
var preparedWhereClause querybuilder.PreparedWhereClause
|
||||
@@ -781,7 +764,7 @@ func (b *traceQueryStatementBuilder) addFilterCondition(
|
||||
FieldMapper: b.fm,
|
||||
ConditionBuilder: b.cb,
|
||||
FieldKeys: keys,
|
||||
SkipResourceFilter: skipResourceFilter,
|
||||
SkipResourceFilter: true,
|
||||
Variables: variables,
|
||||
StartNs: start,
|
||||
EndNs: end,
|
||||
@@ -822,30 +805,34 @@ func (b *traceQueryStatementBuilder) maybeAttachResourceFilter(
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
start, end uint64,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (cteSQL string, cteArgs []any, skipResourceFilter bool, err error) {
|
||||
) (cteSQL string, cteArgs []any, err error) {
|
||||
|
||||
if b.skipResourceFingerprintEnabled {
|
||||
decision, err := b.resourceFilterResolver.Resolve(ctx, query, start, end, variables)
|
||||
if err != nil {
|
||||
return "", nil, true, err
|
||||
}
|
||||
switch decision {
|
||||
case qbtypes.ResourceFilterResolveKindNoOp:
|
||||
return "", nil, true, nil
|
||||
case qbtypes.ResourceFilterResolveKindFallback:
|
||||
return "", nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
stmt, err := b.resourceFilterResolver.StatementBuilder().Build(
|
||||
ctx, start, end, qbtypes.RequestTypeRaw, query, variables,
|
||||
)
|
||||
stmt, err := b.buildResourceFilterCTE(ctx, query, start, end, variables)
|
||||
if err != nil {
|
||||
return "", nil, true, err
|
||||
return "", nil, err
|
||||
}
|
||||
if stmt == nil {
|
||||
return "", nil, true, nil
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
sb.Where("resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
|
||||
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, true, nil
|
||||
|
||||
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, nil
|
||||
}
|
||||
|
||||
func (b *traceQueryStatementBuilder) buildResourceFilterCTE(
|
||||
ctx context.Context,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
start, end uint64,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
|
||||
return b.resourceFilterStmtBuilder.Build(
|
||||
ctx,
|
||||
start,
|
||||
end,
|
||||
qbtypes.RequestTypeRaw,
|
||||
query,
|
||||
variables,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,36 +2,19 @@ package telemetrytraces
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type regexQueryMatcher struct{}
|
||||
|
||||
func (m *regexQueryMatcher) Match(expectedSQL, actualSQL string) error {
|
||||
re, err := regexp.Compile(expectedSQL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !re.MatchString(actualSQL) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "expected query to match %s, got %s", expectedSQL, actualSQL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestStatementBuilder(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -384,8 +367,6 @@ func TestStatementBuilder(t *testing.T) {
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
vars := map[string]qbtypes.VariableItem{
|
||||
@@ -681,8 +662,6 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -792,8 +771,6 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
|
||||
@@ -946,8 +923,6 @@ func TestStatementBuilderTraceQuery(t *testing.T) {
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -1150,13 +1125,28 @@ func TestAdjustKey(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
statementBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
// Create a copy of the input key to avoid modifying the original
|
||||
key := c.inputKey
|
||||
|
||||
// Call adjustKey
|
||||
adjustTraceKey(&key, c.keysMap)
|
||||
statementBuilder.adjustKey(&key, c.keysMap)
|
||||
|
||||
// Verify the key was adjusted as expected
|
||||
require.Equal(t, c.expectedKey.Name, key.Name, "key name should match")
|
||||
@@ -1409,6 +1399,21 @@ func TestAdjustKeys(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
statementBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
// Create a deep copy of the keys map to avoid modifying the original
|
||||
@@ -1419,7 +1424,7 @@ func TestAdjustKeys(t *testing.T) {
|
||||
}
|
||||
|
||||
// Call adjustKeys
|
||||
adjustTraceKeys(keysMapCopy, &c.query, qbtypes.RequestTypeScalar)
|
||||
c.query = statementBuilder.adjustKeys(context.Background(), keysMapCopy, c.query, qbtypes.RequestTypeScalar)
|
||||
|
||||
// Verify select fields were adjusted
|
||||
if c.expectedSelectFields != nil {
|
||||
@@ -1478,114 +1483,3 @@ func TestAdjustKeys(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkipResourceFingerprint exercises the three resolver outcomes when
|
||||
// skip_resource_fingerprint is enabled: use-CTE (count < threshold),
|
||||
// fallback (count >= threshold), and the legacy path (feature disabled).
|
||||
func TestSkipResourceFingerprint(t *testing.T) {
|
||||
const (
|
||||
startMs = uint64(1747947419000)
|
||||
endMs = uint64(1747983448000)
|
||||
threshold = uint64(10)
|
||||
)
|
||||
|
||||
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "service.name = 'redis-manual'",
|
||||
},
|
||||
SelectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{
|
||||
Name: "name",
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
Limit: 5,
|
||||
}
|
||||
|
||||
t.Run("disabled uses the legacy CTE", func(t *testing.T) {
|
||||
sb := newSkipResourceFingerprintBuilder(t, nil, false, threshold)
|
||||
|
||||
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, stmt.Query, "__resource_filter AS (SELECT fingerprint")
|
||||
require.Contains(t, stmt.Query, "resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
|
||||
})
|
||||
|
||||
t.Run("CTE attached when count below threshold", func(t *testing.T) {
|
||||
mockStore := telemetrystoretest.New(telemetrystore.Config{}, ®exQueryMatcher{})
|
||||
mock := mockStore.Mock()
|
||||
|
||||
// Only the count query runs against the telemetry store; the CTE
|
||||
// itself is embedded as SQL in the main query (no extra round trip).
|
||||
mock.ExpectQueryRow(`SELECT count\(\) FROM \(SELECT fingerprint FROM signoz_traces\.distributed_traces_v3_resource`).
|
||||
WillReturnRow(cmock.NewRow([]cmock.ColumnType{
|
||||
{Name: "count", Type: "UInt64"},
|
||||
}, []any{uint64(2)}))
|
||||
|
||||
sb := newSkipResourceFingerprintBuilder(t, mockStore, true, threshold)
|
||||
|
||||
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Contains(t, stmt.Query, "__resource_filter AS (SELECT fingerprint")
|
||||
require.Contains(t, stmt.Query, "resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
|
||||
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
})
|
||||
|
||||
t.Run("fallback when count at or above threshold", func(t *testing.T) {
|
||||
mockStore := telemetrystoretest.New(telemetrystore.Config{}, ®exQueryMatcher{})
|
||||
mock := mockStore.Mock()
|
||||
|
||||
mock.ExpectQueryRow(`SELECT count\(\) FROM \(SELECT fingerprint FROM signoz_traces\.distributed_traces_v3_resource`).
|
||||
WillReturnRow(cmock.NewRow([]cmock.ColumnType{
|
||||
{Name: "count", Type: "UInt64"},
|
||||
}, []any{threshold}))
|
||||
|
||||
sb := newSkipResourceFingerprintBuilder(t, mockStore, true, threshold)
|
||||
|
||||
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotContains(t, stmt.Query, "__resource_filter AS")
|
||||
require.NotContains(t, stmt.Query, "resource_fingerprint")
|
||||
// resource conditions are pushed onto the main table via the
|
||||
// resource.`service.name` / resources_string lookup
|
||||
require.Contains(t, stmt.Query, "service.name")
|
||||
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
})
|
||||
}
|
||||
|
||||
func newSkipResourceFingerprintBuilder(
|
||||
t *testing.T,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
skipEnable bool,
|
||||
threshold uint64,
|
||||
) *traceQueryStatementBuilder {
|
||||
t.Helper()
|
||||
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
|
||||
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(
|
||||
instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl,
|
||||
)
|
||||
|
||||
return NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
aggExprRewriter,
|
||||
telemetryStore,
|
||||
fl,
|
||||
skipEnable,
|
||||
threshold,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -216,13 +216,6 @@ func (b *traceOperatorCTEBuilder) buildQueryCTE(ctx context.Context, queryName s
|
||||
}
|
||||
b.stmtBuilder.logger.DebugContext(ctx, "Retrieved keys for query", slog.String("query_name", queryName), slog.Int("keys_count", len(keys)))
|
||||
|
||||
// The CTE only selects spans matching the filter. Aggregations, group by
|
||||
// and order by run later in buildFinalQuery, so RequestTypeRaw is fine here.
|
||||
for _, action := range adjustTraceKeys(keys, query, qbtypes.RequestTypeRaw) {
|
||||
// TODO: change to debug level once we are confident about the behavior
|
||||
b.stmtBuilder.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
}
|
||||
|
||||
// Build resource filter CTE for this specific query
|
||||
resourceFilterCTEName := fmt.Sprintf("__resource_filter_%s", cteName)
|
||||
resourceStmt, err := b.buildResourceFilterCTE(ctx, *query)
|
||||
@@ -424,28 +417,21 @@ func (b *traceOperatorCTEBuilder) buildNotCTE(leftCTE, rightCTE string) (string,
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildFinalQuery(ctx context.Context, selectFromCTE string, requestType qbtypes.RequestType) (*qbtypes.Statement, error) {
|
||||
keySelectors := b.getKeySelectors()
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.adjustOperatorKeys(ctx, keys, requestType)
|
||||
|
||||
switch requestType {
|
||||
case qbtypes.RequestTypeRaw:
|
||||
return b.buildListQuery(ctx, selectFromCTE, keys)
|
||||
return b.buildListQuery(ctx, selectFromCTE)
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
return b.buildTimeSeriesQuery(ctx, selectFromCTE, keys)
|
||||
return b.buildTimeSeriesQuery(ctx, selectFromCTE)
|
||||
case qbtypes.RequestTypeTrace:
|
||||
return b.buildTraceQuery(ctx, selectFromCTE, keys)
|
||||
return b.buildTraceQuery(ctx, selectFromCTE)
|
||||
case qbtypes.RequestTypeScalar:
|
||||
return b.buildScalarQuery(ctx, selectFromCTE, keys)
|
||||
return b.buildScalarQuery(ctx, selectFromCTE)
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported request type: %s", requestType)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
// Select core fields
|
||||
@@ -467,6 +453,22 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
|
||||
"parent_span_id": true,
|
||||
}
|
||||
|
||||
// Get keys for selectFields
|
||||
keySelectors := b.getKeySelectors()
|
||||
for _, field := range b.operator.SelectFields {
|
||||
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: field.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: field.FieldContext,
|
||||
FieldDataType: field.FieldDataType,
|
||||
})
|
||||
}
|
||||
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add selectFields using ColumnExpressionFor since we now have all base table columns
|
||||
for _, field := range b.operator.SelectFields {
|
||||
if selectedFields[field.Name] {
|
||||
@@ -516,44 +518,6 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
|
||||
}, nil
|
||||
}
|
||||
|
||||
// adjustOperatorKeys runs the same key adjustments as adjustTraceKeys, but on
|
||||
// the operator's own fields. The operator has a different struct shape than
|
||||
// QueryBuilderQuery, so we copy the relevant fields into a temp query, run
|
||||
// the shared helpers, and copy the results back.
|
||||
func (b *traceOperatorCTEBuilder) adjustOperatorKeys(ctx context.Context, keys map[string][]*telemetrytypes.TelemetryFieldKey, requestType qbtypes.RequestType) {
|
||||
mergeDeprecatedTraceKeys(keys)
|
||||
|
||||
tmp := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Aggregations: b.operator.Aggregations,
|
||||
SelectFields: b.operator.SelectFields,
|
||||
GroupBy: b.operator.GroupBy,
|
||||
Order: b.operator.Order,
|
||||
}
|
||||
|
||||
actions := querybuilder.AdjustKeysForAliasExpressions(&tmp, requestType)
|
||||
actions = append(actions, querybuilder.AdjustDuplicateKeys(&tmp)...)
|
||||
|
||||
for idx := range tmp.SelectFields {
|
||||
actions = append(actions, adjustTraceKey(&tmp.SelectFields[idx], keys)...)
|
||||
}
|
||||
for idx := range tmp.GroupBy {
|
||||
actions = append(actions, adjustTraceKey(&tmp.GroupBy[idx].TelemetryFieldKey, keys)...)
|
||||
}
|
||||
for idx := range tmp.Order {
|
||||
actions = append(actions, adjustTraceKey(&tmp.Order[idx].Key.TelemetryFieldKey, keys)...)
|
||||
}
|
||||
|
||||
// Copy back the slices the helpers can rewrite.
|
||||
b.operator.Aggregations = tmp.Aggregations
|
||||
b.operator.SelectFields = tmp.SelectFields
|
||||
b.operator.GroupBy = tmp.GroupBy
|
||||
b.operator.Order = tmp.Order
|
||||
|
||||
for _, action := range actions {
|
||||
b.stmtBuilder.logger.InfoContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
}
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySelector {
|
||||
var keySelectors []*telemetrytypes.FieldKeySelector
|
||||
|
||||
@@ -581,15 +545,6 @@ func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySe
|
||||
})
|
||||
}
|
||||
|
||||
for _, sf := range b.operator.SelectFields {
|
||||
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: sf.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: sf.FieldContext,
|
||||
FieldDataType: sf.FieldDataType,
|
||||
})
|
||||
}
|
||||
|
||||
for i := range keySelectors {
|
||||
keySelectors[i].Signal = telemetrytypes.SignalTraces
|
||||
}
|
||||
@@ -597,7 +552,7 @@ func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySe
|
||||
return keySelectors
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
sb.Select(fmt.Sprintf(
|
||||
@@ -605,6 +560,12 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
|
||||
int64(b.operator.StepInterval.Seconds()),
|
||||
))
|
||||
|
||||
keySelectors := b.getKeySelectors()
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allGroupByArgs []any
|
||||
|
||||
for _, gb := range b.operator.GroupBy {
|
||||
@@ -683,7 +644,8 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
|
||||
combinedArgs := append(allGroupByArgs, allAggChArgs...)
|
||||
|
||||
// Add HAVING clause if specified
|
||||
if err := b.addHavingClause(sb); err != nil {
|
||||
err = b.addHavingClause(sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -710,11 +672,17 @@ func (b *traceOperatorCTEBuilder) buildTraceSummaryCTE(selectFromCTE string) {
|
||||
b.addCTE("trace_summary", sql, args, []string{"all_spans", selectFromCTE})
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
|
||||
b.buildTraceSummaryCTE(selectFromCTE)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
keySelectors := b.getKeySelectors()
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allGroupByArgs []any
|
||||
|
||||
for _, gb := range b.operator.GroupBy {
|
||||
@@ -796,7 +764,8 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
|
||||
sb.GroupBy(groupByKeys...)
|
||||
}
|
||||
|
||||
if err := b.addHavingClause(sb); err != nil {
|
||||
err = b.addHavingClause(sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -852,9 +821,15 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFromCTE string) (*qbtypes.Statement, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
keySelectors := b.getKeySelectors()
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allGroupByArgs []any
|
||||
|
||||
for _, gb := range b.operator.GroupBy {
|
||||
@@ -936,7 +911,8 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
|
||||
combinedArgs := append(allGroupByArgs, allAggChArgs...)
|
||||
|
||||
// Add HAVING clause if specified
|
||||
if err := b.addHavingClause(sb); err != nil {
|
||||
err = b.addHavingClause(sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -14,24 +14,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestTraceOperatorStatementBuilder(t *testing.T) *traceOperatorStatementBuilder {
|
||||
t.Helper()
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
traceStmtBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore, fm, cb, aggExprRewriter, nil, fl, false, 100000,
|
||||
)
|
||||
return NewTraceOperatorStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore, fm, cb, traceStmtBuilder, aggExprRewriter, fl,
|
||||
)
|
||||
}
|
||||
|
||||
func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -481,7 +463,32 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
statementBuilder := newTestTraceOperatorStatementBuilder(t)
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
|
||||
traceStmtBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
)
|
||||
|
||||
statementBuilder := NewTraceOperatorStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
traceStmtBuilder,
|
||||
aggExprRewriter,
|
||||
fl,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -572,7 +579,32 @@ func TestTraceOperatorStatementBuilderErrors(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
statementBuilder := newTestTraceOperatorStatementBuilder(t)
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
|
||||
fl := flaggertest.New(t)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
|
||||
|
||||
traceStmtBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
aggExprRewriter,
|
||||
nil,
|
||||
fl,
|
||||
)
|
||||
|
||||
statementBuilder := NewTraceOperatorStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
traceStmtBuilder,
|
||||
aggExprRewriter,
|
||||
fl,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -594,142 +626,3 @@ func TestTraceOperatorStatementBuilderErrors(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceOperatorStatementBuilderAdjustsKeys(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
operator qbtypes.QueryBuilderTraceOperator
|
||||
builderFilter string
|
||||
wantSQL string
|
||||
wantArgs []any
|
||||
}{
|
||||
{
|
||||
name: "deprecated duration filter in referenced builder query",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
operator: qbtypes.QueryBuilderTraceOperator{
|
||||
Expression: "A",
|
||||
Limit: 10,
|
||||
},
|
||||
builderFilter: "durationNano = '3s'",
|
||||
wantSQL: "duration_nano = ?",
|
||||
wantArgs: []any{int64(3000000000)},
|
||||
},
|
||||
{
|
||||
name: "context-prefixed aggregation alias in order by",
|
||||
requestType: qbtypes.RequestTypeScalar,
|
||||
operator: qbtypes.QueryBuilderTraceOperator{
|
||||
Expression: "A",
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{
|
||||
Expression: "count()",
|
||||
Alias: "span.count_",
|
||||
},
|
||||
},
|
||||
Order: []qbtypes.OrderBy{
|
||||
{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "count_",
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantSQL: "ORDER BY __result_0 desc",
|
||||
},
|
||||
}
|
||||
|
||||
statementBuilder := newTestTraceOperatorStatementBuilder(t)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := c.operator.ParseExpression()
|
||||
require.NoError(t, err)
|
||||
|
||||
filter := c.builderFilter
|
||||
if filter == "" {
|
||||
filter = "service.name = 'frontend'"
|
||||
}
|
||||
|
||||
q, err := statementBuilder.Build(
|
||||
context.Background(),
|
||||
1747947419000,
|
||||
1747983448000,
|
||||
c.requestType,
|
||||
c.operator,
|
||||
&qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: filter},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, q.Query, c.wantSQL)
|
||||
for _, arg := range c.wantArgs {
|
||||
require.Contains(t, q.Args, arg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTraceOperatorStatementBuilderDeduplicatesKeys checks that a trace
|
||||
// operator with the same field name listed twice in GroupBy (once with a
|
||||
// context, once without) ends up with a single column in the outer SELECT
|
||||
// and a single entry in GROUP BY.
|
||||
func TestTraceOperatorStatementBuilderDeduplicatesKeys(t *testing.T) {
|
||||
statementBuilder := newTestTraceOperatorStatementBuilder(t)
|
||||
|
||||
operator := qbtypes.QueryBuilderTraceOperator{
|
||||
Expression: "A",
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{Expression: "count()"},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "http.method",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
}},
|
||||
// Same name, no context — should be merged with the entry above.
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "http.method",
|
||||
}},
|
||||
},
|
||||
}
|
||||
require.NoError(t, operator.ParseExpression())
|
||||
|
||||
q, err := statementBuilder.Build(
|
||||
context.Background(),
|
||||
1747947419000,
|
||||
1747983448000,
|
||||
qbtypes.RequestTypeScalar,
|
||||
operator,
|
||||
&qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'frontend'"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Contains(t, q.Query,
|
||||
"SELECT toString(multiIf(mapContains(attributes_string, 'http.method') = ?, attributes_string['http.method'], NULL)) AS `http.method`, count() AS __result_0 FROM A GROUP BY `http.method` ORDER BY __result_0 DESC")
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -44,10 +44,8 @@ func TestTraceTimeRangeOptimization(t *testing.T) {
|
||||
fm,
|
||||
cb,
|
||||
aggExprRewriter,
|
||||
nil, // telemetryStore is nil - adaptive path is disabled
|
||||
nil, // telemetryStore is nil - optimization won't happen but code path is tested
|
||||
fl,
|
||||
false,
|
||||
100000,
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
type ResourceFilterResolveKind int
|
||||
|
||||
const (
|
||||
ResourceFilterResolveKindNoOp ResourceFilterResolveKind = iota
|
||||
ResourceFilterResolveKindUseCTE
|
||||
ResourceFilterResolveKindFallback
|
||||
)
|
||||
@@ -30,12 +30,13 @@ const (
|
||||
// {{ .Annotations.value }}, {{ .Annotations.threshold.value }}, etc. in
|
||||
// their channel templates.
|
||||
const (
|
||||
AnnotationTitleTemplate = "_title_template"
|
||||
AnnotationBodyTemplate = "_body_template"
|
||||
AnnotationRelatedLogs = "related_logs"
|
||||
AnnotationRelatedTraces = "related_traces"
|
||||
AnnotationValue = "value"
|
||||
AnnotationThresholdValue = "threshold.value"
|
||||
AnnotationCompareOp = "compare_op"
|
||||
AnnotationMatchType = "match_type"
|
||||
AnnotationTitleTemplate = "_title_template"
|
||||
AnnotationBodyTemplate = "_body_template"
|
||||
AnnotationRelatedLogs = "related_logs"
|
||||
AnnotationRelatedLogsSamples = "related_logs_samples"
|
||||
AnnotationRelatedTraces = "related_traces"
|
||||
AnnotationValue = "value"
|
||||
AnnotationThresholdValue = "threshold.value"
|
||||
AnnotationCompareOp = "compare_op"
|
||||
AnnotationMatchType = "match_type"
|
||||
)
|
||||
|
||||
@@ -54,12 +54,6 @@ type Event struct {
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
type OtelSpanRef struct {
|
||||
TraceId string `json:"traceId,omitempty"`
|
||||
SpanId string `json:"spanId,omitempty"`
|
||||
RefType string `json:"refType,omitempty"`
|
||||
}
|
||||
|
||||
// WaterfallSpan represents the span in waterfall response,
|
||||
// this uses snake_case keys for response as a special case since these
|
||||
// keys can be directly used to query spans and client need to know the actual fields.
|
||||
@@ -80,7 +74,6 @@ type WaterfallSpan struct {
|
||||
TimeUnix uint64 `json:"time_unix"`
|
||||
TraceID string `json:"trace_id"`
|
||||
TraceState string `json:"trace_state"`
|
||||
References []OtelSpanRef `json:"references" required:"true" nullable:"false"`
|
||||
|
||||
// Calculated fields https://signoz.io/docs/traces-management/guides/derived-fields-spans
|
||||
DBName string `json:"db_name,omitempty"`
|
||||
@@ -135,7 +128,6 @@ type StorableSpan struct {
|
||||
ExternalHTTPMethod string `ch:"external_http_method"`
|
||||
ExternalHTTPURL string `ch:"external_http_url"`
|
||||
ResponseStatusCode string `ch:"response_status_code"`
|
||||
References string `ch:"references"`
|
||||
}
|
||||
|
||||
// MinimalSpan with only the fields needed to build the parent-child tree.
|
||||
@@ -293,14 +285,6 @@ func (item *StorableSpan) UnmarshalledEvents() []Event {
|
||||
return events
|
||||
}
|
||||
|
||||
func (item *StorableSpan) UnmarshalledRefs() []OtelSpanRef {
|
||||
refs := []OtelSpanRef{}
|
||||
if err := json.Unmarshal([]byte(item.References), &refs); err != nil {
|
||||
return nil // skip malformed values
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func (item *StorableSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
|
||||
resources := make(map[string]string)
|
||||
maps.Copy(resources, item.ResourcesString)
|
||||
@@ -334,7 +318,6 @@ func (item *StorableSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
|
||||
Children: make([]*WaterfallSpan, 0),
|
||||
TimeUnix: uint64(item.StartTime.UnixNano()),
|
||||
ServiceName: item.ServiceName,
|
||||
References: item.UnmarshalledRefs(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,16 +54,16 @@ func newConfig() factory.Config {
|
||||
Directory: "/etc/signoz/web",
|
||||
Settings: SettingsConfig{
|
||||
Posthog: PosthogConfig{
|
||||
Enabled: false,
|
||||
Enabled: true,
|
||||
},
|
||||
Appcues: AppcuesConfig{
|
||||
Enabled: false,
|
||||
Enabled: true,
|
||||
},
|
||||
Sentry: SentryConfig{
|
||||
Enabled: false,
|
||||
Enabled: true,
|
||||
},
|
||||
Pylon: PylonConfig{
|
||||
Enabled: false,
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
51
tests/fixtures/querier.py
vendored
51
tests/fixtures/querier.py
vendored
@@ -459,57 +459,6 @@ def find_named_result(
|
||||
)
|
||||
|
||||
|
||||
def assert_scalar_value(
|
||||
response: requests.Response,
|
||||
name: str,
|
||||
expected: Any,
|
||||
*,
|
||||
row: int = 0,
|
||||
col: int = 0,
|
||||
) -> None:
|
||||
"""Assert that the named scalar result has `expected` at data[row][col]."""
|
||||
result = find_named_result(response.json()["data"]["data"]["results"], name)
|
||||
assert result is not None, f"no result for query {name}"
|
||||
assert result["data"][row][col] == expected, f"expected {expected} at [{row}][{col}], got {result['data'][row][col]}"
|
||||
|
||||
|
||||
def assert_grouped_scalar(
|
||||
response: requests.Response,
|
||||
name: str,
|
||||
*,
|
||||
expected_groups: int,
|
||||
expected_columns: int,
|
||||
last_col_value: Any | None = None,
|
||||
) -> None:
|
||||
"""Assert grouped scalar result has the expected column count and group count.
|
||||
If `last_col_value` is set and there is exactly one group, also assert the
|
||||
last column of that single row equals it (a common aggregation-value check)."""
|
||||
result = find_named_result(response.json()["data"]["data"]["results"], name)
|
||||
assert result is not None, f"no result for query {name}"
|
||||
columns = result["columns"]
|
||||
rows = result["data"]
|
||||
assert len(columns) == expected_columns, f"expected {expected_columns} columns, got {len(columns)}: {columns}"
|
||||
assert len(rows) == expected_groups, f"expected {expected_groups} groups, got {len(rows)}: {rows}"
|
||||
if last_col_value is not None and expected_groups == 1:
|
||||
assert rows[0][-1] == last_col_value, f"expected last col {last_col_value}, got row {rows[0]}"
|
||||
|
||||
|
||||
def assert_raw_row_subset(
|
||||
response: requests.Response,
|
||||
name: str,
|
||||
expected: dict[str, Any],
|
||||
*,
|
||||
row: int = 0,
|
||||
) -> None:
|
||||
"""Assert that the named raw result's rows[row]['data'] is a superset of `expected`."""
|
||||
result = find_named_result(response.json()["data"]["data"]["results"], name)
|
||||
assert result is not None, f"no result for query {name}"
|
||||
rows = result["rows"]
|
||||
assert rows is not None, f"no rows for query {name}"
|
||||
data = rows[row]["data"]
|
||||
assert expected.items() <= data.items(), f"expected subset {expected}, got data {data}"
|
||||
|
||||
|
||||
def build_scalar_query(
|
||||
name: str,
|
||||
signal: str,
|
||||
|
||||
@@ -25,22 +25,13 @@ returnSpansFrom="A"
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.querier import (
|
||||
assert_grouped_scalar,
|
||||
assert_raw_row_subset,
|
||||
assert_scalar_value,
|
||||
format_timestamp,
|
||||
generate_traces_with_corrupt_metadata,
|
||||
get_rows,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.querier import get_rows
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
|
||||
@@ -443,173 +434,3 @@ def test_trace_operator(
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, f"HTTP {response.status_code}: {response.text}"
|
||||
assert case["validate"](response), f"validation failed: {response.json()}"
|
||||
|
||||
|
||||
def _expected_trace_subset(trace: Traces) -> dict[str, Any]:
|
||||
return {
|
||||
"duration_nano": trace.duration_nano,
|
||||
"name": trace.name,
|
||||
"parent_span_id": trace.parent_span_id,
|
||||
"span_id": trace.span_id,
|
||||
"timestamp": format_timestamp(trace.timestamp),
|
||||
"trace_id": trace.trace_id,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload_factory,request_type,assert_result",
|
||||
[
|
||||
# Case 1: CTE filter uses the deprecated intrinsic field `durationNano`.
|
||||
pytest.param(
|
||||
lambda traces: [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"filter": {"expression": 'durationNano = "3s"'},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"signal": "traces",
|
||||
"filter": {"expression": 'durationNano = "5s"'},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "builder_trace_operator",
|
||||
"spec": {
|
||||
"name": "C",
|
||||
"expression": "A => B",
|
||||
"limit": 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
"raw",
|
||||
lambda response, traces: assert_raw_row_subset(response, "C", _expected_trace_subset(traces[0])),
|
||||
id="deprecated-intrinsic-filter",
|
||||
),
|
||||
# Case 2: CTE filter uses the deprecated calculated field `responseStatusCode`.
|
||||
pytest.param(
|
||||
lambda traces: [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"filter": {"expression": 'responseStatusCode = "200"'},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"signal": "traces",
|
||||
"filter": {"expression": 'durationNano = "5s"'},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "builder_trace_operator",
|
||||
"spec": {
|
||||
"name": "C",
|
||||
"expression": "A => B",
|
||||
"limit": 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
"raw",
|
||||
lambda response, traces: assert_raw_row_subset(response, "C", _expected_trace_subset(traces[0])),
|
||||
id="deprecated-calculated-filter",
|
||||
),
|
||||
# Case 3: order by uses `count_` with fieldContext `span`, which has
|
||||
# to be rewritten to the aggregation alias `span.count_`.
|
||||
pytest.param(
|
||||
lambda traces: [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "builder_trace_operator",
|
||||
"spec": {
|
||||
"name": "C",
|
||||
"expression": "A",
|
||||
"aggregations": [{"expression": "count()", "alias": "span.count_"}],
|
||||
"order": [{"key": {"name": "count_", "fieldContext": "span"}, "direction": "desc"}],
|
||||
},
|
||||
},
|
||||
],
|
||||
"scalar",
|
||||
lambda response, traces: assert_scalar_value(response, "C", len(traces)),
|
||||
id="context-prefixed-aggregation-alias-order",
|
||||
),
|
||||
# Case 4: group by lists `cloud.provider` twice (once with a resource
|
||||
# context, once without).
|
||||
pytest.param(
|
||||
lambda traces: [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"disabled": True,
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "builder_trace_operator",
|
||||
"spec": {
|
||||
"name": "C",
|
||||
"expression": "A",
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
"groupBy": [
|
||||
{"name": "cloud.provider", "fieldContext": "resource"},
|
||||
{"name": "cloud.provider"},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
"scalar",
|
||||
lambda response, traces: assert_grouped_scalar(response, "C", expected_groups=1, expected_columns=2, last_col_value=len(traces)),
|
||||
id="duplicate-group-by-deduplicated",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_trace_operator_with_adjusted_keys(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
payload_factory: Callable[[list[Traces]], list[dict[str, Any]]],
|
||||
request_type: str,
|
||||
assert_result: Callable[[requests.Response, list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Trace operators build a CTE per referenced builder query and an outer
|
||||
query on top. Both layers need the same key adjustment as regular trace
|
||||
queries, otherwise deprecated keys and context-prefixed aliases don't
|
||||
resolve.
|
||||
"""
|
||||
traces = generate_traces_with_corrupt_metadata()
|
||||
insert_traces(traces)
|
||||
payload = payload_factory(traces)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
|
||||
request_type=request_type,
|
||||
queries=payload,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert_result(response, traces)
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
"""
|
||||
End-to-end coverage for the skip_resource_fingerprint querier optimization.
|
||||
|
||||
The conftest in this package boots SigNoz with:
|
||||
- skip_resource_fingerprint.enabled = true
|
||||
- skip_resource_fingerprint.threshold = 2
|
||||
|
||||
With that configuration the two non-trivial resolver branches are reachable
|
||||
from a single SigNoz instance:
|
||||
|
||||
- count < 2 -> the resolver attaches the fingerprint CTE (same shape as the
|
||||
legacy path; cheap because the fingerprint set is small).
|
||||
- count >= 2 -> fallback path: no fingerprint subquery, resource conditions
|
||||
are evaluated directly on the main spans table.
|
||||
|
||||
These tests assert end-to-end correctness — the optimization must be
|
||||
semantically transparent.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.querier import (
|
||||
Aggregation,
|
||||
BuilderQuery,
|
||||
OrderBy,
|
||||
TelemetryFieldKey,
|
||||
get_rows,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
|
||||
def _span(
|
||||
*,
|
||||
timestamp: datetime,
|
||||
service_name: str,
|
||||
name: str = "span",
|
||||
duration_seconds: float = 1.0,
|
||||
extra_resources: dict | None = None,
|
||||
attributes: dict | None = None,
|
||||
) -> Traces:
|
||||
resources = {"service.name": service_name}
|
||||
if extra_resources:
|
||||
resources.update(extra_resources)
|
||||
return Traces(
|
||||
timestamp=timestamp,
|
||||
duration=timedelta(seconds=duration_seconds),
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id="",
|
||||
name=name,
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
status_message="",
|
||||
resources=resources,
|
||||
attributes=attributes or {},
|
||||
)
|
||||
|
||||
|
||||
def test_skip_resource_fingerprint_use_cte_path(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
A filter that matches a single unique resource fingerprint (count = 1 < 2)
|
||||
keeps the legacy CTE attached. The query should still return only the rows
|
||||
belonging to that resource.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
_span(timestamp=now - timedelta(seconds=10), service_name="skip-cte-svc", name="span-1"),
|
||||
_span(timestamp=now - timedelta(seconds=8), service_name="skip-cte-svc", name="span-2"),
|
||||
# Noise from a different resource — must be filtered out.
|
||||
_span(timestamp=now - timedelta(seconds=6), service_name="skip-cte-noise", name="span-noise"),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
|
||||
request_type="raw",
|
||||
queries=[
|
||||
BuilderQuery(
|
||||
signal="traces",
|
||||
limit=50,
|
||||
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
|
||||
filter_expression="service.name = 'skip-cte-svc'",
|
||||
select_fields=[
|
||||
TelemetryFieldKey("service.name", "string", "resource"),
|
||||
TelemetryFieldKey("name", field_context="span"),
|
||||
],
|
||||
aggregations=[Aggregation("count()")],
|
||||
).to_dict()
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
rows = get_rows(response)
|
||||
assert len(rows) == 2, f"expected only the 2 'skip-cte-svc' spans, got {len(rows)}"
|
||||
|
||||
names = [row["data"]["name"] for row in rows]
|
||||
assert names == ["span-1", "span-2"]
|
||||
|
||||
services = {row["data"]["service.name"] for row in rows}
|
||||
assert services == {"skip-cte-svc"}
|
||||
|
||||
|
||||
def test_skip_resource_fingerprint_fallback_path(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
A filter that matches multiple unique resource fingerprints (count >= 2)
|
||||
drives the resolver down the fallback path: no fingerprint subquery, and
|
||||
the resource condition is evaluated directly on the main spans table.
|
||||
The result must still be correct (no over- or under-matching).
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
|
||||
# 3 services share the same deployment.environment, so the resource filter
|
||||
# selects 3 fingerprints, exceeding our threshold of 2.
|
||||
fallback_env = {"deployment.environment": "skip-fallback"}
|
||||
insert_traces(
|
||||
[
|
||||
_span(timestamp=now - timedelta(seconds=10), service_name="skip-fb-svc-a", extra_resources=fallback_env),
|
||||
_span(timestamp=now - timedelta(seconds=9), service_name="skip-fb-svc-b", extra_resources=fallback_env),
|
||||
_span(timestamp=now - timedelta(seconds=8), service_name="skip-fb-svc-c", extra_resources=fallback_env),
|
||||
# Noise without the fallback env — must be filtered out.
|
||||
_span(
|
||||
timestamp=now - timedelta(seconds=7),
|
||||
service_name="skip-fb-other",
|
||||
extra_resources={"deployment.environment": "skip-other"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
|
||||
request_type="raw",
|
||||
queries=[
|
||||
BuilderQuery(
|
||||
signal="traces",
|
||||
limit=50,
|
||||
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
|
||||
filter_expression="deployment.environment = 'skip-fallback'",
|
||||
select_fields=[
|
||||
TelemetryFieldKey("service.name", "string", "resource"),
|
||||
],
|
||||
aggregations=[Aggregation("count()")],
|
||||
).to_dict()
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
rows = get_rows(response)
|
||||
assert len(rows) == 3, f"expected 3 spans tagged with skip-fallback, got {len(rows)}"
|
||||
|
||||
services = sorted(row["data"]["service.name"] for row in rows)
|
||||
assert services == ["skip-fb-svc-a", "skip-fb-svc-b", "skip-fb-svc-c"]
|
||||
@@ -1,164 +0,0 @@
|
||||
"""
|
||||
End-to-end coverage for the skip_resource_fingerprint optimization on logs.
|
||||
|
||||
The conftest boots SigNoz with threshold=2, so:
|
||||
- count < 2 -> resolver attaches the fingerprint CTE (same shape as legacy).
|
||||
- count >= 2 -> fallback: resource conditions evaluated on the main logs table.
|
||||
|
||||
Both branches must return the same correct rows.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.querier import (
|
||||
Aggregation,
|
||||
BuilderQuery,
|
||||
OrderBy,
|
||||
TelemetryFieldKey,
|
||||
get_rows,
|
||||
make_query_request,
|
||||
)
|
||||
|
||||
|
||||
def _log(
|
||||
*,
|
||||
timestamp: datetime,
|
||||
service_name: str,
|
||||
body: str,
|
||||
extra_resources: dict | None = None,
|
||||
) -> Logs:
|
||||
resources = {"service.name": service_name}
|
||||
if extra_resources:
|
||||
resources.update(extra_resources)
|
||||
return Logs(
|
||||
timestamp=timestamp,
|
||||
resources=resources,
|
||||
attributes={},
|
||||
body=body,
|
||||
severity_text="INFO",
|
||||
)
|
||||
|
||||
|
||||
def test_skip_resource_fingerprint_logs_use_cte_path(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
"""
|
||||
A filter matching a single resource fingerprint (count = 1 < 2) keeps the
|
||||
legacy CTE attached. The result must only include rows for that resource.
|
||||
"""
|
||||
now = datetime.now(tz=UTC)
|
||||
|
||||
insert_logs(
|
||||
[
|
||||
_log(timestamp=now - timedelta(seconds=10), service_name="skip-logs-cte-svc", body="log-1"),
|
||||
_log(timestamp=now - timedelta(seconds=8), service_name="skip-logs-cte-svc", body="log-2"),
|
||||
# Noise from a different resource — must not appear.
|
||||
_log(timestamp=now - timedelta(seconds=6), service_name="skip-logs-cte-noise", body="noise"),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
|
||||
request_type="raw",
|
||||
queries=[
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
limit=50,
|
||||
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
|
||||
filter_expression="service.name = 'skip-logs-cte-svc'",
|
||||
select_fields=[
|
||||
TelemetryFieldKey("service.name", "string", "resource"),
|
||||
TelemetryFieldKey("body"),
|
||||
],
|
||||
aggregations=[Aggregation("count()")],
|
||||
).to_dict()
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
rows = get_rows(response)
|
||||
assert len(rows) == 2, f"expected 2 'skip-logs-cte-svc' rows, got {len(rows)}"
|
||||
|
||||
bodies = [row["data"]["body"] for row in rows]
|
||||
assert bodies == ["log-1", "log-2"]
|
||||
|
||||
services = {row["data"]["service.name"] for row in rows}
|
||||
assert services == {"skip-logs-cte-svc"}
|
||||
|
||||
|
||||
def test_skip_resource_fingerprint_logs_fallback_path(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
"""
|
||||
A filter matching multiple resource fingerprints (count >= 2) drives the
|
||||
fallback path: no CTE, resource conditions evaluated on the main logs
|
||||
table. Result must still be correct (no over- or under-matching).
|
||||
"""
|
||||
now = datetime.now(tz=UTC)
|
||||
|
||||
fallback_env = {"deployment.environment": "skip-logs-fallback"}
|
||||
insert_logs(
|
||||
[
|
||||
_log(timestamp=now - timedelta(seconds=10), service_name="skip-logs-fb-svc-a", body="a", extra_resources=fallback_env),
|
||||
_log(timestamp=now - timedelta(seconds=9), service_name="skip-logs-fb-svc-b", body="b", extra_resources=fallback_env),
|
||||
_log(timestamp=now - timedelta(seconds=8), service_name="skip-logs-fb-svc-c", body="c", extra_resources=fallback_env),
|
||||
# Noise without the fallback env — must be filtered out.
|
||||
_log(
|
||||
timestamp=now - timedelta(seconds=7),
|
||||
service_name="skip-logs-fb-other",
|
||||
body="noise",
|
||||
extra_resources={"deployment.environment": "skip-logs-other"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
|
||||
request_type="raw",
|
||||
queries=[
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
limit=50,
|
||||
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
|
||||
filter_expression="deployment.environment = 'skip-logs-fallback'",
|
||||
select_fields=[
|
||||
TelemetryFieldKey("service.name", "string", "resource"),
|
||||
TelemetryFieldKey("body"),
|
||||
],
|
||||
aggregations=[Aggregation("count()")],
|
||||
).to_dict()
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
rows = get_rows(response)
|
||||
assert len(rows) == 3, f"expected 3 fallback rows, got {len(rows)}"
|
||||
|
||||
services = sorted(row["data"]["service.name"] for row in rows)
|
||||
assert services == ["skip-logs-fb-svc-a", "skip-logs-fb-svc-b", "skip-logs-fb-svc-c"]
|
||||
|
||||
bodies = sorted(row["data"]["body"] for row in rows)
|
||||
assert bodies == ["a", "b", "c"]
|
||||
@@ -1,37 +0,0 @@
|
||||
import pytest
|
||||
from testcontainers.core.container import Network
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.signoz import create_signoz
|
||||
|
||||
|
||||
@pytest.fixture(name="signoz", scope="package")
|
||||
def signoz_skip_resource_fingerprint(
|
||||
network: Network,
|
||||
migrator: types.Operation, # pylint: disable=unused-argument
|
||||
zeus: types.TestContainerDocker,
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.SigNoz:
|
||||
"""
|
||||
Package-scoped SigNoz instance with the skip_resource_fingerprint
|
||||
optimization enabled and a low threshold so both the materialized and
|
||||
fallback resolver paths are exercised by sibling tests.
|
||||
"""
|
||||
return create_signoz(
|
||||
network=network,
|
||||
zeus=zeus,
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz-skip-resource-fingerprint",
|
||||
env_overrides={
|
||||
"SIGNOZ_QUERIER_SKIP__RESOURCE__FINGERPRINT_ENABLED": True,
|
||||
"SIGNOZ_QUERIER_SKIP__RESOURCE__FINGERPRINT_THRESHOLD": 2,
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user