Compare commits
7 Commits
feat/chart
...
test/semco
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df4a9ce295 | ||
|
|
5b94d79e46 | ||
|
|
60a6a5b38c | ||
|
|
b47245d46a | ||
|
|
52ec7bf128 | ||
|
|
afe62d77b0 | ||
|
|
1aa6346a4c |
3
frontend/src/assets/Logos/azure.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#0078D4" d="M22.379 23.343a1.62 1.62 0 0 0 1.536-2.14v.002L17.35 1.76A1.62 1.62 0 0 0 15.816.657H8.184A1.62 1.62 0 0 0 6.65 1.76L.086 21.204a1.62 1.62 0 0 0 1.536 2.139h4.741a1.62 1.62 0 0 0 1.535-1.103l.977-2.892 4.947 3.675c.28.208.618.32.966.32m-3.084-12.531 3.624 10.739a.54.54 0 0 1-.51.713v-.001h-.03a.54.54 0 0 1-.322-.106l-9.287-6.9h4.853m6.313 7.006c.116-.326.13-.694.007-1.058L9.79 1.76a1.722 1.722 0 0 0-.007-.02h6.034a.54.54 0 0 1 .512.366l6.562 19.445a.54.54 0 0 1-.338.684"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 583 B |
1
frontend/src/assets/Logos/cloudnative-pg.svg
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
5
frontend/src/assets/Logos/dify.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Dify</title>
|
||||
<path d="M7.043 6.487c1.635 0 2.241-1.003 2.241-2.243S8.681 2 7.044 2C5.405 2 4.801 3.003 4.801 4.244c0 1.24.604 2.243 2.241 2.243z" fill="#03F" />
|
||||
<path d="M14.883 6.97v1.443h-3.679v3.203h3.68v8.012H8.801V8.41h-8v3.203h4.48v8.012H0v3.203h24v-3.203h-5.6v-8.012H24V8.41h-5.6V5.206H24V2.003h-4.161a4.97 4.97 0 00-4.961 4.967h.005z" fill="#03F" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 447 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 115.28 122.88"><path d="M25.38 57h64.88V37.34H69.59c-2.17 0-5.19-1.17-6.62-2.6s-2.3-4.01-2.3-6.17V7.64H8.15c-.18 0-.32.09-.41.18-.15.1-.19.23-.19.42v106.45c0 .14.09.32.18.41.09.14.28.18.41.18h81.51c.18 0 .17-.09.27-.18.14-.09.33-.28.33-.41v-11.16H25.38c-4.14 0-7.56-3.4-7.56-7.56V64.55c0-4.15 3.4-7.55 7.56-7.55m4.11 11.38h7.43v18.15h11.63v5.92H29.49zm20.4 12.05c0-3.93 1.09-6.99 3.28-9.17 2.19-2.19 5.24-3.28 9.15-3.28 4.01 0 7.09 1.08 9.26 3.22 2.17 2.15 3.25 5.16 3.25 9.04 0 2.81-.47 5.11-1.42 6.91q-1.425 2.7-4.11 4.2t-6.69 1.5c-2.71 0-4.96-.43-6.74-1.29-1.78-.87-3.22-2.23-4.32-4.11-1.11-1.87-1.66-4.21-1.66-7.02m7.42.01c0 2.43.45 4.17 1.36 5.23s2.14 1.59 3.7 1.59c1.6 0 2.84-.52 3.71-1.56.88-1.04 1.32-2.9 1.32-5.6 0-2.26-.46-3.92-1.37-4.96-.92-1.05-2.16-1.57-3.73-1.57-1.5 0-2.71.53-3.62 1.59-.91 1.08-1.37 2.83-1.37 5.28m33.11 3.3v-5.01h11.49v10.23c-2.2 1.5-4.15 2.53-5.83 3.07-1.69.54-3.7.81-6.02.81-2.86 0-5.19-.49-6.99-1.46s-3.19-2.42-4.18-4.35c-.99-1.92-1.48-4.13-1.48-6.63 0-2.63.54-4.91 1.62-6.85s2.67-3.41 4.76-4.42c1.63-.78 3.83-1.17 6.58-1.17 2.66 0 4.64.24 5.96.72s2.41 1.23 3.28 2.24 1.52 2.3 1.96 3.85l-7.16 1.29c-.3-.91-.8-1.61-1.5-2.09-.71-.49-1.6-.73-2.7-.73-1.62 0-2.92.57-3.89 1.7s-1.45 2.92-1.45 5.37c0 2.6.49 4.46 1.47 5.57.97 1.11 2.34 1.68 4.09 1.68q1.245 0 2.37-.36c.75-.24 1.61-.65 2.59-1.22v-2.25h-4.97zM97.79 57h9.93c4.16 0 7.56 3.41 7.56 7.56v31.42c0 4.15-3.41 7.56-7.56 7.56h-9.93v13.55c0 1.61-.65 3.04-1.7 4.1a5.74 5.74 0 0 1-4.1 1.7H5.81a5.74 5.74 0 0 1-4.1-1.7 5.74 5.74 0 0 1-1.7-4.1V5.85c0-1.61.65-3.04 1.7-4.1a5.8 5.8 0 0 1 4.1-1.7h58.72c.13-.05.27-.05.41-.05.64 0 1.29.28 1.75.69h.09c.09.05.14.09.23.18L97 31.23c.51.51.88 1.2.88 1.98 0 .23-.05.41-.09.65zM67.52 27.97V8.94l21.43 21.7H70.19c-.74 0-1.38-.32-1.89-.78-.46-.46-.78-1.15-.78-1.89" style="fill-rule:evenodd;clip-rule:evenodd"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 115.28 122.88"><path d="M25.38 57h64.88V37.34H69.59c-2.17 0-5.19-1.17-6.62-2.6s-2.3-4.01-2.3-6.17V7.64H8.15c-.18 0-.32.09-.41.18-.15.1-.19.23-.19.42v106.45c0 .14.09.32.18.41.09.14.28.18.41.18h81.51c.18 0 .17-.09.27-.18.14-.09.33-.28.33-.41v-11.16H25.38c-4.14 0-7.56-3.4-7.56-7.56V64.55c0-4.15 3.4-7.55 7.56-7.55m4.11 11.38h7.43v18.15h11.63v5.92H29.49zm20.4 12.05c0-3.93 1.09-6.99 3.28-9.17 2.19-2.19 5.24-3.28 9.15-3.28 4.01 0 7.09 1.08 9.26 3.22 2.17 2.15 3.25 5.16 3.25 9.04 0 2.81-.47 5.11-1.42 6.91q-1.425 2.7-4.11 4.2t-6.69 1.5c-2.71 0-4.96-.43-6.74-1.29-1.78-.87-3.22-2.23-4.32-4.11-1.11-1.87-1.66-4.21-1.66-7.02m7.42.01c0 2.43.45 4.17 1.36 5.23s2.14 1.59 3.7 1.59c1.6 0 2.84-.52 3.71-1.56.88-1.04 1.32-2.9 1.32-5.6 0-2.26-.46-3.92-1.37-4.96-.92-1.05-2.16-1.57-3.73-1.57-1.5 0-2.71.53-3.62 1.59-.91 1.08-1.37 2.83-1.37 5.28m33.11 3.3v-5.01h11.49v10.23c-2.2 1.5-4.15 2.53-5.83 3.07-1.69.54-3.7.81-6.02.81-2.86 0-5.19-.49-6.99-1.46s-3.19-2.42-4.18-4.35c-.99-1.92-1.48-4.13-1.48-6.63 0-2.63.54-4.91 1.62-6.85s2.67-3.41 4.76-4.42c1.63-.78 3.83-1.17 6.58-1.17 2.66 0 4.64.24 5.96.72s2.41 1.23 3.28 2.24 1.52 2.3 1.96 3.85l-7.16 1.29c-.3-.91-.8-1.61-1.5-2.09-.71-.49-1.6-.73-2.7-.73-1.62 0-2.92.57-3.89 1.7s-1.45 2.92-1.45 5.37c0 2.6.49 4.46 1.47 5.57.97 1.11 2.34 1.68 4.09 1.68q1.245 0 2.37-.36c.75-.24 1.61-.65 2.59-1.22v-2.25h-4.97zM97.79 57h9.93c4.16 0 7.56 3.41 7.56 7.56v31.42c0 4.15-3.41 7.56-7.56 7.56h-9.93v13.55c0 1.61-.65 3.04-1.7 4.1a5.74 5.74 0 0 1-4.1 1.7H5.81a5.74 5.74 0 0 1-4.1-1.7 5.74 5.74 0 0 1-1.7-4.1V5.85c0-1.61.65-3.04 1.7-4.1a5.8 5.8 0 0 1 4.1-1.7h58.72c.13-.05.27-.05.41-.05.64 0 1.29.28 1.75.69h.09c.09.05.14.09.23.18L97 31.23c.51.51.88 1.2.88 1.98 0 .23-.05.41-.09.65zM67.52 27.97V8.94l21.43 21.7H70.19c-.74 0-1.38-.32-1.89-.78-.46-.46-.78-1.15-.78-1.89" style="fill:#D1D5DB;fill-rule:evenodd;clip-rule:evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
3
frontend/src/assets/Logos/github-copilot.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#9CA3AF" d="M23.922 16.997C23.061 18.492 18.063 22.02 12 22.02 5.937 22.02.939 18.492.078 16.997A.641.641 0 0 1 0 16.741v-2.869a.883.883 0 0 1 .053-.22c.372-.935 1.347-2.292 2.605-2.656.167-.429.414-1.055.644-1.517a10.098 10.098 0 0 1-.052-1.086c0-1.331.282-2.499 1.132-3.368.397-.406.89-.717 1.474-.952C7.255 2.937 9.248 1.98 11.978 1.98c2.731 0 4.767.957 6.166 2.093.584.235 1.077.546 1.474.952.85.869 1.132 2.037 1.132 3.368 0 .368-.014.733-.052 1.086.23.462.477 1.088.644 1.517 1.258.364 2.233 1.721 2.605 2.656a.841.841 0 0 1 .053.22v2.869a.641.641 0 0 1-.078.256Zm-11.75-5.992h-.344a4.359 4.359 0 0 1-.355.508c-.77.947-1.918 1.492-3.508 1.492-1.725 0-2.989-.359-3.782-1.259a2.137 2.137 0 0 1-.085-.104L4 11.746v6.585c1.435.779 4.514 2.179 8 2.179 3.486 0 6.565-1.4 8-2.179v-6.585l-.098-.104s-.033.045-.085.104c-.793.9-2.057 1.259-3.782 1.259-1.59 0-2.738-.545-3.508-1.492a4.359 4.359 0 0 1-.355-.508Zm2.328 3.25c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm-5 0c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm3.313-6.185c.136 1.057.403 1.913.878 2.497.442.544 1.134.938 2.344.938 1.573 0 2.292-.337 2.657-.751.384-.435.558-1.15.558-2.361 0-1.14-.243-1.847-.705-2.319-.477-.488-1.319-.862-2.824-1.025-1.487-.161-2.192.138-2.533.529-.269.307-.437.808-.438 1.578v.021c0 .265.021.562.063.893Zm-1.626 0c.042-.331.063-.628.063-.894v-.02c-.001-.77-.169-1.271-.438-1.578-.341-.391-1.046-.69-2.533-.529-1.505.163-2.347.537-2.824 1.025-.462.472-.705 1.179-.705 2.319 0 1.211.175 1.926.558 2.361.365.414 1.084.751 2.657.751 1.21 0 1.902-.394 2.344-.938.475-.584.742-1.44.878-2.497Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
frontend/src/assets/Logos/keda.svg
Normal file
|
After Width: | Height: | Size: 25 KiB |
24
frontend/src/assets/Logos/kong.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg viewBox="0 0 44.8 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="kong-a" x1="38.204" x2="8.732" y1="18.417" y2="48.543" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#11A06B" />
|
||||
<stop offset="1" stop-color="#286FEB" />
|
||||
</linearGradient>
|
||||
<linearGradient id="kong-b" x1="38.107" x2="8.635" y1="18.322" y2="48.448" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#11A06B" />
|
||||
<stop offset="1" stop-color="#286FEB" />
|
||||
</linearGradient>
|
||||
<linearGradient id="kong-c" x1="29.439" x2="-0.033" y1="9.842" y2="39.968" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#11A06B" />
|
||||
<stop offset="1" stop-color="#286FEB" />
|
||||
</linearGradient>
|
||||
<linearGradient id="kong-d" x1="30.291" x2="0.819" y1="10.676" y2="40.801" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#11A06B" />
|
||||
<stop offset="1" stop-color="#286FEB" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="m14.7 32.9-1.1 1.4 2.5 3.9-.3 1.8h10.6l.7-1.8-4.2-5.3z" fill="url(#kong-a)" />
|
||||
<path d="M20.5 9.4 16.7 16l18.6 22-.5 2h8.5l1.5-7.1L24.9 9.4z" fill="url(#kong-b)" />
|
||||
<path d="m23 4.4-1.8 3.3h4.5l7.7 9.2 4.6-3.8v-2.4l-1.6-2.2 1.2-1.2L28.4 0z" fill="url(#kong-c)" />
|
||||
<path d="M9.1 22.9H6.6L0 31.3V40h7.1l1.3-1.6 5.5-7.1h7.9l2.4-3.7-8.6-10.2z" fill="url(#kong-d)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
3
frontend/src/assets/Logos/neon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg fill="none" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M63 0.018v63.535L38.418 42.25v21.303H0V0l63 .018ZM7.723 55.839h22.972V25.323l24.583 21.725V7.729L7.723 7.716v48.123Z" fill="#37C38F" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 226 B |
@@ -19,6 +19,7 @@ export type LogDetailProps = {
|
||||
onScrollToLog?: (logId: string) => void;
|
||||
handleOpenInExplorer?: MouseEventHandler;
|
||||
getContainer?: DrawerProps['getContainer'];
|
||||
onApplyLogFilter?: (expression: string) => void;
|
||||
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &
|
||||
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
|
||||
Pick<DrawerProps, 'onClose'>;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Link,
|
||||
} from '@signozhq/icons';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { normalizeTimeToMs } from 'utils/timeUtils';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { MouseEvent, MouseEventHandler } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
@@ -67,6 +68,11 @@ function LogDetailsHeader({
|
||||
},
|
||||
];
|
||||
|
||||
const rawTimestamp = log.date ?? log.timestamp;
|
||||
const displayTimestamp = Number.isNaN(Number(rawTimestamp))
|
||||
? rawTimestamp
|
||||
: normalizeTimeToMs(rawTimestamp);
|
||||
|
||||
return (
|
||||
<div className={styles.header} data-log-detail-ignore="true">
|
||||
<div className={styles.leftSection}>
|
||||
@@ -76,7 +82,7 @@ function LogDetailsHeader({
|
||||
data-testid="log-details-header-timestamp"
|
||||
>
|
||||
{formatTimezoneAdjustedTimestamp(
|
||||
log.date ?? log.timestamp,
|
||||
displayTimestamp,
|
||||
DATE_TIME_FORMATS.DASH_DATETIME,
|
||||
)}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -18,10 +18,9 @@ jest.mock('periscope/components/DataViewer', () => ({
|
||||
DataViewer: (): JSX.Element => <div data-testid="overview-data-viewer" />,
|
||||
}));
|
||||
|
||||
// The flag to be removed later
|
||||
jest.mock('../constants', () => ({
|
||||
...jest.requireActual('../constants'),
|
||||
isLogDetailsV2: true,
|
||||
// Force v2 for these tests regardless of route.
|
||||
jest.mock('../useIsLogDetailsV2', () => ({
|
||||
useIsLogDetailsV2: (): boolean => true,
|
||||
}));
|
||||
|
||||
const mockLog: ILog = {
|
||||
@@ -92,6 +91,24 @@ describe('LogDetail drawer — header (isLogDetailsV2)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes a nanosecond-epoch timestamp in the header', () => {
|
||||
localStorage.setItem(LOCALSTORAGE.PREFERRED_TIMEZONE, 'UTC');
|
||||
|
||||
// Same instant as mockLog but as epoch nanoseconds (e.g. dashboard list panel).
|
||||
// Must scale to ms, not render a wildly wrong date.
|
||||
renderDrawer({
|
||||
log: {
|
||||
...mockLog,
|
||||
date: '1705311930000000000',
|
||||
timestamp: 1705311930000000000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('log-details-header-timestamp')).toHaveTextContent(
|
||||
'Jan 15, 2024 ⎯ 09:45:30',
|
||||
);
|
||||
});
|
||||
|
||||
it('copies the log link from the ⋯ menu', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// temporary flag to be removed with old log details code.
|
||||
export const isLogDetailsV2 = true;
|
||||
|
||||
export const VIEW_TYPES = {
|
||||
OVERVIEW: 'OVERVIEW',
|
||||
JSON: 'JSON',
|
||||
|
||||
@@ -51,11 +51,12 @@ import { ILogBody } from 'types/api/logs/log';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import { isLogDetailsV2, RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
|
||||
import { RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
|
||||
import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
|
||||
import LogDetailsHeader from './LogDetailsHeader/LogDetailsHeader';
|
||||
import { useLogNavigation } from './LogDetailsHeader/useLogNavigation';
|
||||
import LogHighlights from './LogHighlights/LogHighlights';
|
||||
import { useIsLogDetailsV2 } from './useIsLogDetailsV2';
|
||||
|
||||
import './LogDetails.styles.scss';
|
||||
|
||||
@@ -74,6 +75,7 @@ function LogDetailInner({
|
||||
onScrollToLog,
|
||||
handleOpenInExplorer,
|
||||
getContainer,
|
||||
onApplyLogFilter,
|
||||
}: LogDetailInnerProps): JSX.Element {
|
||||
const initialContextQuery = useInitialQuery(log);
|
||||
const [contextQuery, setContextQuery] = useState<Query | undefined>(
|
||||
@@ -92,6 +94,8 @@ function LogDetailInner({
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
|
||||
const isLogDetailsV2 = useIsLogDetailsV2();
|
||||
|
||||
// Handle clicks outside to close drawer, except on explicitly ignored regions
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent): void => {
|
||||
@@ -516,6 +520,7 @@ function LogDetailInner({
|
||||
selectedOptions={options}
|
||||
listViewPanelSelectedFields={listViewPanelSelectedFields}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
onApplyLogFilter={onApplyLogFilter}
|
||||
/>
|
||||
)}
|
||||
{!isLogDetailsV2 && selectedView === VIEW_TYPES.JSON && (
|
||||
|
||||
11
frontend/src/components/LogDetail/useIsLogDetailsV2.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
export function useIsLogDetailsV2(): boolean {
|
||||
const { pathname } = useLocation();
|
||||
return (
|
||||
pathname === ROUTES.LOGS_EXPLORER ||
|
||||
pathname.startsWith(ROUTES.INFRASTRUCTURE_MONITORING_BASE) ||
|
||||
pathname.startsWith(`${ROUTES.ALL_DASHBOARD}/`)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
useRecoverFromEmptyPage,
|
||||
UseRecoverFromEmptyPageParams,
|
||||
} from '../useRecoverFromEmptyPage';
|
||||
|
||||
const REPLACE = { history: 'replace' };
|
||||
|
||||
function renderRecovery(
|
||||
overrides: Partial<UseRecoverFromEmptyPageParams> = {},
|
||||
): { setPage: jest.Mock; rerender: (next?: unknown) => void } {
|
||||
const setPage = jest.fn();
|
||||
const props: UseRecoverFromEmptyPageParams = {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
rowCount: 10,
|
||||
total: 100,
|
||||
isFetching: false,
|
||||
setPage,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(next: UseRecoverFromEmptyPageParams) => useRecoverFromEmptyPage(next),
|
||||
{ initialProps: props },
|
||||
);
|
||||
|
||||
return {
|
||||
setPage,
|
||||
rerender: (next?: unknown): void =>
|
||||
rerender({ ...props, ...(next as Partial<UseRecoverFromEmptyPageParams>) }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('useRecoverFromEmptyPage', () => {
|
||||
it('leaves the page alone while it still holds rows', () => {
|
||||
const { setPage } = renderRecovery({ page: 3, rowCount: 10 });
|
||||
|
||||
expect(setPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves the page alone on page 1 with no rows at all', () => {
|
||||
const { setPage } = renderRecovery({ page: 1, rowCount: 0, total: 0 });
|
||||
|
||||
expect(setPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('jumps to the last page that holds data when the page is out of range', () => {
|
||||
const { setPage } = renderRecovery({
|
||||
page: 7,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 25,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(3, REPLACE);
|
||||
});
|
||||
|
||||
it('replaces the history entry so the back button does not return to the empty page', () => {
|
||||
const { setPage } = renderRecovery({ page: 4, rowCount: 0, total: 10 });
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('falls back to page 1 when the total is unknown', () => {
|
||||
const { setPage } = renderRecovery({ page: 5, rowCount: 0, total: 0 });
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('steps back one page when the total claims the page should have data', () => {
|
||||
// total says 100 rows exist, yet page 5 came back empty — step back rather
|
||||
// than stall on a page the query cannot actually serve.
|
||||
const { setPage } = renderRecovery({
|
||||
page: 5,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 100,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(4, REPLACE);
|
||||
});
|
||||
|
||||
it('clamps a page below the first one', () => {
|
||||
const { setPage } = renderRecovery({ page: 0, rowCount: 10 });
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('falls back to page 1 when pageSize is zero', () => {
|
||||
const { setPage } = renderRecovery({
|
||||
page: 5,
|
||||
pageSize: 0,
|
||||
rowCount: 0,
|
||||
total: 100,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('waits for the request to settle before moving the user', () => {
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 3,
|
||||
rowCount: 0,
|
||||
total: 10,
|
||||
isFetching: true,
|
||||
});
|
||||
|
||||
expect(setPage).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ page: 3, rowCount: 0, total: 10, isFetching: false });
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('clamps a page below the first one even when the query failed', () => {
|
||||
// A negative offset is what made the request fail (400 "offset cannot be
|
||||
// negative"), so retrying the same page loops forever — clamp regardless.
|
||||
const { setPage } = renderRecovery({
|
||||
page: 0,
|
||||
rowCount: 0,
|
||||
total: 0,
|
||||
isDisabled: true,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('clamps a page below the first one while the query is still in flight', () => {
|
||||
const { setPage } = renderRecovery({
|
||||
page: -2,
|
||||
rowCount: 0,
|
||||
isFetching: true,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('keeps the page when the query failed so a retry lands where the user was', () => {
|
||||
const { setPage } = renderRecovery({
|
||||
page: 3,
|
||||
rowCount: 0,
|
||||
total: 0,
|
||||
isDisabled: true,
|
||||
});
|
||||
|
||||
expect(setPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clamps a page below the first one exactly once while the request settles', () => {
|
||||
// The clamp runs ahead of both gates, so a request settling underneath an
|
||||
// uncorrected page must not re-issue the same history rewrite.
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 0,
|
||||
rowCount: 0,
|
||||
total: 0,
|
||||
isFetching: true,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ page: 0, rowCount: 0, total: 0, isFetching: false });
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops correcting once the corrected page comes back with rows', () => {
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 7,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 25,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(3, REPLACE);
|
||||
|
||||
// The correction lands: the query refetches, then resolves with the rows page 3 holds.
|
||||
rerender({ page: 3, pageSize: 10, rowCount: 0, total: 25, isFetching: true });
|
||||
rerender({
|
||||
page: 3,
|
||||
pageSize: 10,
|
||||
rowCount: 5,
|
||||
total: 25,
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not correct again while the same page is still being observed', () => {
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 5,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 100,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A refetch cycle that leaves the page untouched — the correction is already in flight.
|
||||
rerender({
|
||||
page: 5,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 100,
|
||||
isFetching: true,
|
||||
});
|
||||
rerender({
|
||||
page: 5,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 100,
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('gives up on step-backs and jumps to page 1 when the total keeps lying', () => {
|
||||
// `total` claims 400 rows exist, but every page comes back empty. Walking back one
|
||||
// page at a time would cost a request per hop, so bail out to page 1 instead.
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 40,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 400,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenNthCalledWith(1, 39, REPLACE);
|
||||
|
||||
rerender({ page: 39, pageSize: 10, rowCount: 0, total: 400 });
|
||||
|
||||
expect(setPage).toHaveBeenNthCalledWith(2, 38, REPLACE);
|
||||
|
||||
rerender({ page: 38, pageSize: 10, rowCount: 0, total: 400 });
|
||||
|
||||
expect(setPage).toHaveBeenNthCalledWith(3, 1, REPLACE);
|
||||
expect(setPage).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('corrects again when the user returns to a page that is still empty', () => {
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 3,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 10,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenNthCalledWith(1, 1, REPLACE);
|
||||
|
||||
rerender({ page: 1, pageSize: 10, rowCount: 10, total: 10 });
|
||||
rerender({ page: 3, pageSize: 10, rowCount: 0, total: 10 });
|
||||
|
||||
expect(setPage).toHaveBeenNthCalledWith(2, 1, REPLACE);
|
||||
});
|
||||
|
||||
it('does not re-run the correction when setPage is a fresh function each render', () => {
|
||||
// The hook reads setPage through a ref, so an inline arrow must not turn the
|
||||
// ungated `page < 1` clamp into a per-render history rewrite.
|
||||
const setPage = jest.fn();
|
||||
const { rerender } = renderHook(
|
||||
() =>
|
||||
useRecoverFromEmptyPage({
|
||||
page: 0,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 0,
|
||||
isFetching: false,
|
||||
setPage: (nextPage, options): void => setPage(nextPage, options),
|
||||
}),
|
||||
{ initialProps: undefined },
|
||||
);
|
||||
|
||||
rerender(undefined);
|
||||
rerender(undefined);
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export * from './useCalculatedPageSize';
|
||||
export * from './useColumnState';
|
||||
export * from './useColumnStore';
|
||||
export * from './usePreferredPageSize.store';
|
||||
export * from './useRecoverFromEmptyPage';
|
||||
export * from './useTableParams';
|
||||
|
||||
/**
|
||||
@@ -285,6 +286,51 @@ export * from './useTableParams';
|
||||
*
|
||||
* **Pagination shows "Auto" option** when `calculatedPageSize` is passed, allowing users
|
||||
* to reset to auto-calculated size.
|
||||
*
|
||||
* **`setPage` accepts history options**: `setPage(page, { history: 'replace' })` rewrites the
|
||||
* current history entry instead of pushing a new one. Use `replace` for corrections the user
|
||||
* did not ask for — otherwise the back button walks straight back into the state that was just
|
||||
* corrected. Only applies when the page is synced to the URL; local (non-URL) pages ignore it.
|
||||
*
|
||||
* @example useRecoverFromEmptyPage — send the user back to a page that has data
|
||||
*
|
||||
* When rows disappear underneath the current page (filters narrowed, time range moved, items
|
||||
* deleted), the user is stranded on an empty page they cannot leave by scrolling. This hook
|
||||
* watches the fetched result and corrects the page with `history: 'replace'`, so the back
|
||||
* button does not return to the empty page.
|
||||
*
|
||||
* Correction rules:
|
||||
* - `page < 1` → jump to page 1, even while fetching or disabled. Such a page usually maps to a
|
||||
* negative offset the API rejects (400 `offset cannot be negative`), so the response can never
|
||||
* confirm the page is empty — deferring to it would strand the user on a permanent error.
|
||||
* - Page is empty and not page 1 → go to `min(ceil(total / pageSize), page - 1)`. When `total`
|
||||
* is trustworthy that lands on the last page holding data; when `total` is unknown or zero it
|
||||
* lands on page 1; and when `total` claims this page should have had rows it steps back a
|
||||
* single page. Repeated step-backs give up and jump to page 1 after the second one, so a
|
||||
* badly inflated `total` cannot walk the user down one request at a time.
|
||||
* - Page has rows, or the user is already on page 1 → do nothing (an empty page 1 means there
|
||||
* is genuinely nothing to show).
|
||||
*
|
||||
* Pass `isFetching` so the hook waits for the request to settle, and `isDisabled` so a failed
|
||||
* request is not mistaken for an empty page. Neither gate suppresses the `page < 1` clamp.
|
||||
*
|
||||
* ```tsx
|
||||
* import { useRecoverFromEmptyPage, useTableParams } from 'components/TanStackTableView';
|
||||
*
|
||||
* const { page, limit, setPage } = useTableParams(QUERY_PARAMS, { page: 1, limit: 20 });
|
||||
* const { data, isLoading, isFetching, isError } = useListQuery({ page, limit });
|
||||
*
|
||||
* useRecoverFromEmptyPage({
|
||||
* page,
|
||||
* pageSize: limit,
|
||||
* rowCount: data?.rows.length ?? 0,
|
||||
* total: data?.total ?? 0,
|
||||
* isFetching: isLoading || isFetching,
|
||||
* // Skip correction on errors — no rows there means "request failed", not "page is empty".
|
||||
* isDisabled: isError,
|
||||
* setPage,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
const TanStackTable = Object.assign(TanStackTableBase, {
|
||||
Text: TanStackTableText,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { SetPageOptions } from './useTableParams';
|
||||
|
||||
const FIRST_PAGE = 1;
|
||||
const REPLACE_HISTORY: SetPageOptions = { history: 'replace' };
|
||||
|
||||
/**
|
||||
* How many single-page step-backs to attempt before giving up and going to page 1.
|
||||
*
|
||||
* A step-back only happens when `total` claims the current page should hold data but the
|
||||
* response came back empty. Each hop costs a request, so an inflated `total` on a high page
|
||||
* number would otherwise walk the user down one page at a time behind a spinner.
|
||||
*/
|
||||
const MAX_STEP_BACKS = 2;
|
||||
|
||||
type Correction = {
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
|
||||
export type UseRecoverFromEmptyPageParams = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
rowCount: number;
|
||||
total: number;
|
||||
isFetching: boolean;
|
||||
isDisabled?: boolean;
|
||||
setPage: (page: number, options?: SetPageOptions) => void;
|
||||
};
|
||||
|
||||
export function useRecoverFromEmptyPage({
|
||||
page,
|
||||
pageSize,
|
||||
rowCount,
|
||||
total,
|
||||
isFetching,
|
||||
isDisabled = false,
|
||||
setPage,
|
||||
}: UseRecoverFromEmptyPageParams): void {
|
||||
const setPageRef = useRef(setPage);
|
||||
const lastCorrectionRef = useRef<Correction | null>(null);
|
||||
const stepBacksRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
setPageRef.current = setPage;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (lastCorrectionRef.current && lastCorrectionRef.current.from !== page) {
|
||||
lastCorrectionRef.current = null;
|
||||
}
|
||||
|
||||
const correctTo = (nextPage: number): boolean => {
|
||||
if (lastCorrectionRef.current?.to === nextPage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
lastCorrectionRef.current = { from: page, to: nextPage };
|
||||
setPageRef.current(nextPage, REPLACE_HISTORY);
|
||||
return true;
|
||||
};
|
||||
|
||||
// A page below the first one is invalid on its own terms — it usually maps to a
|
||||
// negative offset the API rejects outright, so waiting for a response that will
|
||||
// never arrive (or trusting a failed one) would strand the user for good.
|
||||
if (page < FIRST_PAGE) {
|
||||
stepBacksRef.current = 0;
|
||||
void correctTo(FIRST_PAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFetching || isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The page has data, or there is genuinely nothing to show anywhere.
|
||||
if (rowCount > 0 || page === FIRST_PAGE) {
|
||||
stepBacksRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPage = Math.floor(page);
|
||||
const lastPageWithData =
|
||||
pageSize > 0 && total > 0 ? Math.ceil(total / pageSize) : FIRST_PAGE;
|
||||
const nextPage = Math.max(
|
||||
FIRST_PAGE,
|
||||
Math.min(lastPageWithData, currentPage - 1),
|
||||
);
|
||||
|
||||
// `total` disagrees with the response: it says this page should have rows, so the
|
||||
// only safe move is one page back. Cap how often that repeats — every hop is a
|
||||
// request, and a badly inflated `total` would otherwise crawl down from page 40.
|
||||
const isStepBack = nextPage === currentPage - 1;
|
||||
|
||||
if (isStepBack && stepBacksRef.current >= MAX_STEP_BACKS) {
|
||||
if (correctTo(FIRST_PAGE)) {
|
||||
stepBacksRef.current = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (correctTo(nextPage) && isStepBack) {
|
||||
stepBacksRef.current += 1;
|
||||
}
|
||||
}, [isFetching, isDisabled, page, pageSize, rowCount, total]);
|
||||
}
|
||||
@@ -29,12 +29,16 @@ type Defaults = {
|
||||
cleanupOnUnmount?: boolean;
|
||||
};
|
||||
|
||||
export type SetPageOptions = {
|
||||
history?: 'push' | 'replace';
|
||||
};
|
||||
|
||||
export type TableParamsResult = {
|
||||
page: number;
|
||||
limit: number;
|
||||
orderBy: SortState | null;
|
||||
expanded: ExpandedState;
|
||||
setPage: (p: number) => void;
|
||||
setPage: (p: number, options?: SetPageOptions) => void;
|
||||
setLimit: (l: number) => void;
|
||||
setOrderBy: (s: SortState | null) => void;
|
||||
setExpanded: (updaterOrValue: Updater<ExpandedState>) => void;
|
||||
@@ -249,6 +253,17 @@ export function useTableParams(
|
||||
[],
|
||||
);
|
||||
|
||||
const setUrlPageWithOptions = useCallback(
|
||||
(page: number, options?: SetPageOptions): void => {
|
||||
void setUrlPage(page, options);
|
||||
},
|
||||
[setUrlPage],
|
||||
);
|
||||
|
||||
const setLocalPageValue = useCallback((page: number): void => {
|
||||
setLocalPage(page);
|
||||
}, []);
|
||||
|
||||
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
|
||||
const prevOrderByRef = useRef<string | null>(null);
|
||||
|
||||
@@ -303,7 +318,7 @@ export function useTableParams(
|
||||
limit: useUrlForLimit ? urlLimit : localLimit,
|
||||
orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null,
|
||||
expanded: useUrlForExpanded ? urlExpanded : localExpanded,
|
||||
setPage: useUrlForPage ? setUrlPage : setLocalPage,
|
||||
setPage: useUrlForPage ? setUrlPageWithOptions : setLocalPageValue,
|
||||
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimitWithPersist,
|
||||
setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy,
|
||||
setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded,
|
||||
|
||||
@@ -7,6 +7,7 @@ import TanStackTable, {
|
||||
TableColumnDef,
|
||||
useCalculatedPageSize,
|
||||
useHiddenColumnIds,
|
||||
useRecoverFromEmptyPage,
|
||||
useTableParams,
|
||||
} from 'components/TanStackTableView';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
@@ -136,6 +137,7 @@ export function K8sBaseList<
|
||||
page: currentPage,
|
||||
limit: currentPageSize,
|
||||
setLimit,
|
||||
setPage,
|
||||
} = useTableParams(
|
||||
{
|
||||
page: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
|
||||
@@ -243,6 +245,16 @@ export function K8sBaseList<
|
||||
const totalCount = data?.total || 0;
|
||||
const hasFilters = !!expression?.trim();
|
||||
|
||||
useRecoverFromEmptyPage({
|
||||
page: currentPage,
|
||||
pageSize: currentPageSize,
|
||||
rowCount: pageData.length,
|
||||
total: totalCount,
|
||||
isFetching: isLoading || isFetching,
|
||||
isDisabled: isError || Boolean(data?.error),
|
||||
setPage,
|
||||
});
|
||||
|
||||
const getGroupKeyFn = useCallback(
|
||||
(item: T) => getGroupedByMeta(item, groupBy),
|
||||
[groupBy],
|
||||
|
||||
@@ -591,12 +591,14 @@ describe('K8sBaseList', () => {
|
||||
});
|
||||
|
||||
describe('with empty data', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
|
||||
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
fetchListDataMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [],
|
||||
@@ -605,6 +607,7 @@ describe('K8sBaseList', () => {
|
||||
});
|
||||
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
@@ -625,6 +628,177 @@ describe('K8sBaseList', () => {
|
||||
expect(fetchListDataMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not rewrite the page when already on the first page', async () => {
|
||||
await waitFor(() => {
|
||||
expect(fetchListDataMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const pageUpdates = onUrlUpdateMock.mock.calls
|
||||
.map((call) => call[0].searchParams.get('page'))
|
||||
.filter(Boolean);
|
||||
|
||||
expect(pageUpdates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a page beyond the end of the list', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
|
||||
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
|
||||
>();
|
||||
|
||||
// 25 rows exist, so pages 1-3 serve data and page 7 of 10 comes back empty.
|
||||
const rows: TestItem[] = Array.from({ length: 25 }, (_, index) => ({
|
||||
id: `pod-${index + 1}`,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
fetchListDataMock.mockClear();
|
||||
// Offset-aware on purpose: a mock that answers empty for every offset would let
|
||||
// the assertions pass against a page the recovery has already moved on from.
|
||||
fetchListDataMock.mockImplementation(async ({ offset = 0, limit = 10 }) => ({
|
||||
data: rows.slice(offset, offset + limit),
|
||||
total: rows.length,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: { page: '7', pageSize: '10' },
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should send the user back to the last page holding data', async () => {
|
||||
// The rows of page 3 on screen are what proves the recovery settled there,
|
||||
// rather than passing through on its way somewhere else.
|
||||
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
|
||||
|
||||
const pageUpdates = onUrlUpdateMock.mock.calls
|
||||
.map((call) => call[0].searchParams.get('page'))
|
||||
.filter(Boolean);
|
||||
|
||||
expect(pageUpdates).toStrictEqual(['3']);
|
||||
});
|
||||
|
||||
it('should correct the page in a single hop', async () => {
|
||||
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
|
||||
|
||||
// Only the original out-of-range page and the corrected one are requested.
|
||||
expect(
|
||||
fetchListDataMock.mock.calls.map((call) => call[0].offset),
|
||||
).toStrictEqual([60, 20]);
|
||||
});
|
||||
|
||||
it('should replace the history entry instead of pushing the correction', async () => {
|
||||
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
|
||||
|
||||
const pageCorrection = onUrlUpdateMock.mock.calls.find(
|
||||
(call) => call[0].searchParams.get('page') === '3',
|
||||
);
|
||||
|
||||
expect(pageCorrection?.[0].options.history).toBe('replace');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a page below the first one', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
|
||||
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
fetchListDataMock.mockClear();
|
||||
// page=0 turns into offset=-10, which the API rejects outright — the list
|
||||
// can only recover by clamping the page, never by reading the response.
|
||||
fetchListDataMock.mockImplementation(async ({ offset = 0 }) => {
|
||||
if (offset < 0) {
|
||||
throw new APIError({
|
||||
httpStatusCode: 400,
|
||||
error: {
|
||||
code: 'invalid_input',
|
||||
message: 'offset cannot be negative',
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { data: [{ id: 'pod-1' }], total: 1, error: null };
|
||||
});
|
||||
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: { page: '0', pageSize: '10' },
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject the request that carried the negative offset', async () => {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchListDataMock.mock.calls.some((call) => call[0].offset === -10),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchListDataMock.mock.results[0].value as Promise<unknown>,
|
||||
).rejects.toThrow('offset cannot be negative');
|
||||
});
|
||||
|
||||
it('should clamp the page to the first one even though the request failed', async () => {
|
||||
await waitFor(() => {
|
||||
expect(onUrlUpdateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Page 1 is the default, so the correction drops the param rather than
|
||||
// writing `page=1`.
|
||||
const pageCorrection = onUrlUpdateMock.mock.calls.find(
|
||||
(call) => call[0].searchParams.get('page') === null,
|
||||
);
|
||||
|
||||
expect(pageCorrection).toBeDefined();
|
||||
expect(pageCorrection?.[0].queryString).toBe('?pageSize=10');
|
||||
});
|
||||
|
||||
it('should replace the history entry instead of pushing the correction', async () => {
|
||||
await waitFor(() => {
|
||||
expect(onUrlUpdateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const pageCorrection = onUrlUpdateMock.mock.calls.find(
|
||||
(call) => call[0].searchParams.get('page') === null,
|
||||
);
|
||||
|
||||
expect(pageCorrection?.[0].options.history).toBe('replace');
|
||||
});
|
||||
|
||||
it('should refetch with a non-negative offset after clamping', async () => {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchListDataMock.mock.calls.some((call) => call[0].offset === 0),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('pod-1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with error response', () => {
|
||||
|
||||
@@ -82,6 +82,7 @@ function EntityLogsContent({
|
||||
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
|
||||
useLogDetailHandlers();
|
||||
|
||||
// TODO: Move away from using onAddToQuery after old drawer cleanup
|
||||
const onAddToQuery = useCallback(
|
||||
(fieldKey: string, fieldValue: string, operator: string): void => {
|
||||
handleCloseLogDetail();
|
||||
@@ -104,6 +105,21 @@ function EntityLogsContent({
|
||||
[userExpression, querySearchOnRun, handleCloseLogDetail, category],
|
||||
);
|
||||
|
||||
const onApplyLogFilter = useCallback(
|
||||
(expression: string): void => {
|
||||
handleCloseLogDetail();
|
||||
|
||||
const newUser = userExpression.trim()
|
||||
? `${userExpression} AND ${expression}`
|
||||
: expression;
|
||||
|
||||
querySearchOnRun(newUser);
|
||||
|
||||
logInfraDrawerFilterCustomizedEvent(category, 'logs', newUser, 'logs');
|
||||
},
|
||||
[userExpression, querySearchOnRun, handleCloseLogDetail, category],
|
||||
);
|
||||
|
||||
const {
|
||||
logs,
|
||||
loadMoreLogs,
|
||||
@@ -328,6 +344,7 @@ function EntityLogsContent({
|
||||
selectedTab={selectedTab}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
onApplyLogFilter={onApplyLogFilter}
|
||||
onScrollToLog={handleScrollToLog}
|
||||
handleOpenInExplorer={(e) => handleOpenInExplorer(e, activeLog)}
|
||||
getContainer={(): HTMLElement =>
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
useInfraMonitoringCategory,
|
||||
useInfraMonitoringGroupBy,
|
||||
useInfraMonitoringOrderBy,
|
||||
useInfraMonitoringPageListing,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
} from './hooks';
|
||||
|
||||
@@ -67,6 +68,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
const [, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
const [, setOrderBy] = useInfraMonitoringOrderBy();
|
||||
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
@@ -218,6 +220,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
void setSelectedCategory(key as string);
|
||||
void setOrderBy(null);
|
||||
void setGroupBy(null);
|
||||
void setCurrentPage(null);
|
||||
setSelectedItemParams(null);
|
||||
redirectWithQueryBuilderData({
|
||||
...currentQuery,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { MemoryRouter as MemoryRouterV5 } from 'react-router-dom-v5-compat';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { NuqsTestingAdapter, UrlUpdateEvent } from 'nuqs/adapters/testing';
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import store from 'store';
|
||||
|
||||
import { K8sCategories } from '../constants';
|
||||
import InfraMonitoringK8s from '../InfraMonitoringK8s';
|
||||
|
||||
// Quick filters fire their own field APIs and are irrelevant to pagination.
|
||||
jest.mock('components/QuickFilters/QuickFilters', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="quick-filters" />,
|
||||
}));
|
||||
|
||||
// The list owns its own page recovery; stubbing it keeps the page param under the
|
||||
// sole control of the category handler being tested here.
|
||||
jest.mock('../Base/K8sDynamicList', () => ({
|
||||
__esModule: true,
|
||||
K8sDynamicList: (): JSX.Element => <div data-testid="k8s-dynamic-list" />,
|
||||
default: (): JSX.Element => <div data-testid="k8s-dynamic-list" />,
|
||||
}));
|
||||
|
||||
// Analytics only; jsdom lacks the Performance navigation entries it reads.
|
||||
jest.mock('lib/navigation', () => ({
|
||||
getNavigationReferrer: (): string => 'direct',
|
||||
}));
|
||||
|
||||
function renderPage(
|
||||
queryParams: Record<string, string>,
|
||||
onUrlUpdate: jest.Mock<void, [UrlUpdateEvent]>,
|
||||
): void {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MemoryRouterV5>
|
||||
<TimezoneProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppProvider>
|
||||
<Provider store={store}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={queryParams}
|
||||
onUrlUpdate={onUrlUpdate}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<QueryBuilderProvider>
|
||||
<InfraMonitoringK8s />
|
||||
</QueryBuilderProvider>
|
||||
</TooltipProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</Provider>
|
||||
</AppProvider>
|
||||
</QueryClientProvider>
|
||||
</TimezoneProvider>
|
||||
</MemoryRouterV5>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('InfraMonitoringK8s', () => {
|
||||
describe('when the category changes from a page other than the first', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
beforeEach(async () => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
|
||||
renderPage(
|
||||
{ category: K8sCategories.PODS, page: '3', pageSize: '10' },
|
||||
onUrlUpdateMock,
|
||||
);
|
||||
|
||||
await screen.findByTestId(`category-${K8sCategories.NODES}`);
|
||||
});
|
||||
|
||||
it('should drop the page so the new category starts at the first one', async () => {
|
||||
fireEvent.click(screen.getByTestId(`category-${K8sCategories.NODES}`));
|
||||
|
||||
// Page 3 of pods says nothing about nodes — keeping it asks the new entity
|
||||
// for an offset it may not have. The param is cleared rather than set to 1,
|
||||
// since an absent page already means the first one.
|
||||
await waitFor(() => {
|
||||
const categorySwitch = onUrlUpdateMock.mock.calls.find(
|
||||
(call) => call[0].searchParams.get('category') === K8sCategories.NODES,
|
||||
);
|
||||
|
||||
expect(categorySwitch).toBeDefined();
|
||||
expect(categorySwitch?.[0].searchParams.get('page')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep the page size, which is not category specific', async () => {
|
||||
fireEvent.click(screen.getByTestId(`category-${K8sCategories.NODES}`));
|
||||
|
||||
await waitFor(() => {
|
||||
const categorySwitch = onUrlUpdateMock.mock.calls.find(
|
||||
(call) => call[0].searchParams.get('category') === K8sCategories.NODES,
|
||||
);
|
||||
|
||||
expect(categorySwitch?.[0].searchParams.get('pageSize')).toBe('10');
|
||||
});
|
||||
});
|
||||
|
||||
it('should leave the page alone when the same category is clicked again', async () => {
|
||||
fireEvent.click(screen.getByTestId(`category-${K8sCategories.PODS}`));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('k8s-dynamic-list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const droppedPage = onUrlUpdateMock.mock.calls.some(
|
||||
(call) => !call[0].searchParams.has('page'),
|
||||
);
|
||||
|
||||
expect(droppedPage).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,9 @@ export const useInfraMonitoringPageListing = (): UseQueryStateReturn<
|
||||
> =>
|
||||
useQueryState(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
|
||||
// do not use .withDefault here, this can cause bugs when
|
||||
// two hooks of nuqs define default twice, this is also
|
||||
// defined at useTableParams
|
||||
parseAsInteger.withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { ChevronDown, ChevronRight, Search } from '@signozhq/icons';
|
||||
import { isLogDetailsV2 } from 'components/LogDetail/constants';
|
||||
import { useIsLogDetailsV2 } from 'components/LogDetail/useIsLogDetailsV2';
|
||||
import { DataViewer } from 'periscope/components/DataViewer';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
@@ -41,6 +41,7 @@ interface OverviewProps {
|
||||
selectedOptions: OptionsQuery;
|
||||
listViewPanelSelectedFields?: IField[] | null;
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
onApplyLogFilter?: (expression: string) => void;
|
||||
}
|
||||
|
||||
type Props = OverviewProps &
|
||||
@@ -55,6 +56,7 @@ function Overview({
|
||||
selectedOptions,
|
||||
listViewPanelSelectedFields,
|
||||
handleChangeSelectedView,
|
||||
onApplyLogFilter,
|
||||
}: Props): JSX.Element {
|
||||
const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(true);
|
||||
@@ -67,8 +69,11 @@ function Overview({
|
||||
const { actions, visibleActions } = useLogAttributeActions({
|
||||
handleChangeSelectedView,
|
||||
isListViewPanel,
|
||||
onApplyLogFilter,
|
||||
});
|
||||
|
||||
const isLogDetailsV2 = useIsLogDetailsV2();
|
||||
|
||||
if (isLogDetailsV2) {
|
||||
const raw = aggregateAttributesResourcesToObject(logData);
|
||||
const prettyData = buildPrettyViewData(raw);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { CircleMinus, CirclePlus, Layers, RefreshCw } from '@signozhq/icons';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
VisibleActionsConfig,
|
||||
} from 'periscope/components/PrettyView/PrettyView';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { LogDetailsAction } from '../constants';
|
||||
import {
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
interface UseLogAttributeActionsParams {
|
||||
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||
isListViewPanel?: boolean;
|
||||
onApplyLogFilter?: (expression: string) => void;
|
||||
}
|
||||
|
||||
interface UseLogAttributeActionsResult {
|
||||
@@ -50,6 +53,7 @@ const ALL_LEAF_ACTIONS = [
|
||||
export function useLogAttributeActions({
|
||||
handleChangeSelectedView,
|
||||
isListViewPanel = false,
|
||||
onApplyLogFilter,
|
||||
}: UseLogAttributeActionsParams): UseLogAttributeActionsResult {
|
||||
const { pathname } = useLocation();
|
||||
const { stagedQuery, updateQueriesData } = useQueryBuilder();
|
||||
@@ -65,9 +69,6 @@ export function useLogAttributeActions({
|
||||
|
||||
const filterFor = useCallback(
|
||||
(context: FieldContext, isFilterIn: boolean): void => {
|
||||
if (!stagedQuery) {
|
||||
return;
|
||||
}
|
||||
const target = buildLogFilterTarget(
|
||||
context.fieldKeyPath,
|
||||
context.fieldValue,
|
||||
@@ -77,6 +78,29 @@ export function useLogAttributeActions({
|
||||
? target.filterInOperator
|
||||
: target.filterOutOperator;
|
||||
|
||||
// Non-explorer surfaces (infra monitoring, etc.) apply a ready v5
|
||||
// expression fragment to their own query.
|
||||
if (onApplyLogFilter) {
|
||||
const base = {
|
||||
filters: { items: [], op: 'AND' },
|
||||
} as unknown as IBuilderQuery;
|
||||
const nextFilters = getFilterQueryData(
|
||||
base,
|
||||
target,
|
||||
context.fieldValue,
|
||||
operator,
|
||||
).filters ?? { items: [], op: 'AND' };
|
||||
const { expression } = convertFiltersToExpression(nextFilters);
|
||||
if (expression) {
|
||||
onApplyLogFilter(expression);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stagedQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedQuery = updateQueriesData(
|
||||
stagedQuery,
|
||||
'queryData',
|
||||
@@ -99,6 +123,7 @@ export function useLogAttributeActions({
|
||||
updateQueriesData,
|
||||
viewName,
|
||||
handleChangeSelectedView,
|
||||
onApplyLogFilter,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -179,20 +204,25 @@ export function useLogAttributeActions({
|
||||
buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
|
||||
.isRestricted;
|
||||
|
||||
// The using surface must provide an apply path.
|
||||
const canApplyFilter = !!handleChangeSelectedView || !!onApplyLogFilter;
|
||||
|
||||
return [
|
||||
{
|
||||
key: LogDetailsAction.FILTER_IN,
|
||||
label: 'Filter for value',
|
||||
icon: <CirclePlus size={12} />,
|
||||
onClick: (context): void => filterFor(context, true),
|
||||
shouldHide: (_key, fieldKeyPath): boolean => isRestricted(fieldKeyPath),
|
||||
shouldHide: (_key, fieldKeyPath): boolean =>
|
||||
!canApplyFilter || isRestricted(fieldKeyPath),
|
||||
},
|
||||
{
|
||||
key: LogDetailsAction.FILTER_OUT,
|
||||
label: 'Filter out value',
|
||||
icon: <CircleMinus size={12} />,
|
||||
onClick: (context): void => filterFor(context, false),
|
||||
shouldHide: (_key, fieldKeyPath): boolean => isRestricted(fieldKeyPath),
|
||||
shouldHide: (_key, fieldKeyPath): boolean =>
|
||||
!canApplyFilter || isRestricted(fieldKeyPath),
|
||||
},
|
||||
{
|
||||
key: LogDetailsAction.GROUP_BY,
|
||||
@@ -200,8 +230,10 @@ export function useLogAttributeActions({
|
||||
icon: <Layers size={12} />,
|
||||
onClick: groupBy,
|
||||
shouldHide: (_key, fieldKeyPath): boolean =>
|
||||
!handleChangeSelectedView ||
|
||||
!buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
|
||||
.groupBySupported || isOldExplorerOrLive,
|
||||
.groupBySupported ||
|
||||
isOldExplorerOrLive,
|
||||
},
|
||||
{
|
||||
key: LogDetailsAction.REPLACE_FILTER,
|
||||
@@ -209,7 +241,9 @@ export function useLogAttributeActions({
|
||||
icon: <RefreshCw size={12} />,
|
||||
onClick: replaceFilter,
|
||||
shouldHide: (_key, fieldKeyPath): boolean =>
|
||||
isRestricted(fieldKeyPath) || isOldExplorerOrLive,
|
||||
!handleChangeSelectedView ||
|
||||
isRestricted(fieldKeyPath) ||
|
||||
isOldExplorerOrLive,
|
||||
},
|
||||
];
|
||||
}, [
|
||||
@@ -218,6 +252,8 @@ export function useLogAttributeActions({
|
||||
replaceFilter,
|
||||
isBodyJsonQueryEnabled,
|
||||
isOldExplorerOrLive,
|
||||
handleChangeSelectedView,
|
||||
onApplyLogFilter,
|
||||
]);
|
||||
|
||||
const visibleActions = useMemo<VisibleActionsConfig>(
|
||||
|
||||
@@ -21,6 +21,7 @@ import azureMysqlUrl from '@/assets/Logos/azure-mysql.svg';
|
||||
import azureOpenaiUrl from '@/assets/Logos/azure-openai.svg';
|
||||
import azureSqlDatabaseMetricsUrl from '@/assets/Logos/azure-sql-database-metrics.svg';
|
||||
import azureVmUrl from '@/assets/Logos/azure-vm.svg';
|
||||
import azureUrl from '@/assets/Logos/azure.svg';
|
||||
import basetenUrl from '@/assets/Logos/baseten.svg';
|
||||
import cassandraUrl from '@/assets/Logos/cassandra.svg';
|
||||
import celeryUrl from '@/assets/Logos/celery.svg';
|
||||
@@ -28,6 +29,7 @@ import certManagerUrl from '@/assets/Logos/cert-manager.svg';
|
||||
import claudeCodeUrl from '@/assets/Logos/claude-code.svg';
|
||||
import clickhouseUrl from '@/assets/Logos/clickhouse.svg';
|
||||
import cloudflareUrl from '@/assets/Logos/cloudflare.svg';
|
||||
import cloudnativePgUrl from '@/assets/Logos/cloudnative-pg.svg';
|
||||
import cloudwatchLogsUrl from '@/assets/Logos/cloudwatch-logs.svg';
|
||||
import cohereUrl from '@/assets/Logos/cohere.svg';
|
||||
import confluentKafkaUrl from '@/assets/Logos/confluent-kafka.svg';
|
||||
@@ -39,6 +41,7 @@ import datadogUrl from '@/assets/Logos/datadog.svg';
|
||||
import dbosUrl from '@/assets/Logos/dbos.svg';
|
||||
import deepseekUrl from '@/assets/Logos/deepseek.svg';
|
||||
import denoUrl from '@/assets/Logos/deno.svg';
|
||||
import difyUrl from '@/assets/Logos/dify.svg';
|
||||
import dockerUrl from '@/assets/Logos/docker.svg';
|
||||
import documentLoadUrl from '@/assets/Logos/document-load.svg';
|
||||
import dotnetUrl from '@/assets/Logos/dotnet.svg';
|
||||
@@ -70,6 +73,8 @@ import gcpCloudStorageUrl from '@/assets/Logos/gcp-cloud-storage.svg';
|
||||
import gcpComputeEngineUrl from '@/assets/Logos/gcp-compute-engine.svg';
|
||||
import gcpGkeUrl from '@/assets/Logos/gcp-gke.svg';
|
||||
import gcpVpcUrl from '@/assets/Logos/gcp-vpc.svg';
|
||||
import gcpUrl from '@/assets/Logos/gcp.svg';
|
||||
import githubCopilotUrl from '@/assets/Logos/github-copilot.svg';
|
||||
import githubUrl from '@/assets/Logos/github.svg';
|
||||
import goUrl from '@/assets/Logos/go.svg';
|
||||
import googleAdkUrl from '@/assets/Logos/google-adk.svg';
|
||||
@@ -96,6 +101,8 @@ import javascriptUrl from '@/assets/Logos/javascript.svg';
|
||||
import jbossUrl from '@/assets/Logos/jboss.svg';
|
||||
import jenkinsUrl from '@/assets/Logos/jenkins.svg';
|
||||
import kafkaUrl from '@/assets/Logos/kafka.svg';
|
||||
import kedaUrl from '@/assets/Logos/keda.svg';
|
||||
import kongUrl from '@/assets/Logos/kong.svg';
|
||||
import kubernetesUrl from '@/assets/Logos/kubernetes.svg';
|
||||
import lambdaUrl from '@/assets/Logos/lambda.svg';
|
||||
import langchainUrl from '@/assets/Logos/langchain.svg';
|
||||
@@ -114,6 +121,7 @@ import microsoftSqlServerUrl from '@/assets/Logos/microsoft-sql-server.svg';
|
||||
import mistralUrl from '@/assets/Logos/mistral.svg';
|
||||
import mongoUrl from '@/assets/Logos/mongo.svg';
|
||||
import n8nUrl from '@/assets/Logos/n8n.svg';
|
||||
import neonUrl from '@/assets/Logos/neon.svg';
|
||||
import newrelicUrl from '@/assets/Logos/newrelic.svg';
|
||||
import nextjsUrl from '@/assets/Logos/nextjs.svg';
|
||||
import nginxUrl from '@/assets/Logos/nginx.svg';
|
||||
@@ -2859,6 +2867,25 @@ const onboardingConfigWithLinks = [
|
||||
label: 'Traces',
|
||||
imgUrl: lambdaUrl,
|
||||
link: '/docs/aws-monitoring/lambda/lambda-traces/',
|
||||
question: {
|
||||
desc: 'Which runtime does your Lambda function use?',
|
||||
helpText:
|
||||
'Python, Node.js, Java, and Ruby use the OpenTelemetry auto-instrumentation layer. Go has no layer, so you add the SDK to your code.',
|
||||
options: [
|
||||
{
|
||||
key: 'aws-lambda-traces-auto',
|
||||
label: 'Python, Node.js, Java, Ruby',
|
||||
imgUrl: lambdaUrl,
|
||||
link: '/docs/aws-monitoring/lambda/lambda-traces/',
|
||||
},
|
||||
{
|
||||
key: 'aws-lambda-traces-golang',
|
||||
label: 'Go',
|
||||
imgUrl: goUrl,
|
||||
link: '/docs/aws-monitoring/lambda/lambda-traces-golang/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'aws-lambda-metrics',
|
||||
@@ -5510,8 +5537,10 @@ const onboardingConfigWithLinks = [
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'integrations',
|
||||
'logs',
|
||||
'metrics',
|
||||
'supabase',
|
||||
'supabase logs',
|
||||
'supabase metrics',
|
||||
'supabase monitoring',
|
||||
'supabase observability',
|
||||
@@ -5545,43 +5574,23 @@ const onboardingConfigWithLinks = [
|
||||
label: 'Traefik',
|
||||
imgUrl: opentelemetryUrl,
|
||||
tags: ['infrastructure monitoring'],
|
||||
module: 'infrastructure',
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'infrastructure',
|
||||
'traefik',
|
||||
'traefik access logs',
|
||||
'traefik logs',
|
||||
'traefik metrics',
|
||||
'traefik monitoring',
|
||||
'traefik observability',
|
||||
'traefik tracing',
|
||||
],
|
||||
link: '/docs/tutorial/traefik-observability/',
|
||||
question: {
|
||||
desc: 'Which Traefik signals do you want to send to SigNoz?',
|
||||
type: 'select',
|
||||
options: [
|
||||
{
|
||||
key: 'traefik-metrics-traces',
|
||||
label: 'Metrics & Traces',
|
||||
imgUrl: opentelemetryUrl,
|
||||
link: '/docs/tutorial/traefik-observability/',
|
||||
},
|
||||
{
|
||||
key: 'traefik-logs',
|
||||
label: 'Access Logs',
|
||||
imgUrl: opentelemetryUrl,
|
||||
link: '/docs/integrations/opentelemetry-traefik/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataSource: 'mongodb-atlas',
|
||||
label: 'MongoDB (Atlas)',
|
||||
imgUrl: mongoUrl,
|
||||
tags: ['database'],
|
||||
module: 'database',
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'atlas',
|
||||
'atlas metrics',
|
||||
@@ -5600,36 +5609,15 @@ const onboardingConfigWithLinks = [
|
||||
label: 'MySQL',
|
||||
imgUrl: opentelemetryUrl,
|
||||
tags: ['database'],
|
||||
module: 'database',
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'database',
|
||||
'mysql',
|
||||
'mysql error log',
|
||||
'mysql logs',
|
||||
'mysql metrics',
|
||||
'mysql monitoring',
|
||||
'mysql observability',
|
||||
'mysql slow query log',
|
||||
],
|
||||
link: '/docs/metrics-management/mysql-metrics/',
|
||||
question: {
|
||||
desc: 'Which MySQL signals do you want to send to SigNoz?',
|
||||
type: 'select',
|
||||
options: [
|
||||
{
|
||||
key: 'mysql-metrics',
|
||||
label: 'Metrics',
|
||||
imgUrl: opentelemetryUrl,
|
||||
link: '/docs/metrics-management/mysql-metrics/',
|
||||
},
|
||||
{
|
||||
key: 'mysql-logs',
|
||||
label: 'Logs',
|
||||
imgUrl: opentelemetryUrl,
|
||||
link: '/docs/integrations/opentelemetry-mysql/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataSource: 'jmx',
|
||||
@@ -6992,5 +6980,514 @@ const onboardingConfigWithLinks = [
|
||||
id: 'dspy',
|
||||
link: '/docs/dspy-observability/',
|
||||
},
|
||||
{
|
||||
dataSource: 'grok-build',
|
||||
label: 'Grok Build',
|
||||
imgUrl: grokUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'coding agent',
|
||||
'grok build',
|
||||
'grok build events',
|
||||
'grok build logs',
|
||||
'grok build metrics',
|
||||
'grok build monitoring',
|
||||
'grok build observability',
|
||||
'llm',
|
||||
'llm monitoring',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'otel grok build integration',
|
||||
'terminal coding agent',
|
||||
'token usage',
|
||||
'xai',
|
||||
],
|
||||
id: 'grok-build',
|
||||
link: '/docs/grok-build-observability/',
|
||||
},
|
||||
{
|
||||
dataSource: 'neon',
|
||||
label: 'Neon',
|
||||
imgUrl: neonUrl,
|
||||
tags: ['database'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'database',
|
||||
'neon',
|
||||
'neon database',
|
||||
'neon db',
|
||||
'neon logs',
|
||||
'neon metrics',
|
||||
'neon monitoring',
|
||||
'neon observability',
|
||||
'neondb',
|
||||
'opentelemetry neon',
|
||||
'postgres',
|
||||
'postgresql',
|
||||
'serverless postgres',
|
||||
],
|
||||
id: 'neon',
|
||||
link: '/docs/integrations/opentelemetry-neondb/',
|
||||
},
|
||||
{
|
||||
dataSource: 'dify',
|
||||
label: 'Dify',
|
||||
imgUrl: difyUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'ai agent',
|
||||
'dify',
|
||||
'dify monitoring',
|
||||
'dify observability',
|
||||
'dify traces',
|
||||
'llm',
|
||||
'llm monitoring',
|
||||
'metrics',
|
||||
'no code ai',
|
||||
'observability',
|
||||
'opentelemetry dify',
|
||||
'traces',
|
||||
],
|
||||
id: 'dify',
|
||||
link: '/docs/dify-observability/',
|
||||
},
|
||||
{
|
||||
dataSource: 'firecrawl',
|
||||
label: 'Firecrawl',
|
||||
imgUrl: llmMonitoringUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'crawl',
|
||||
'firecrawl',
|
||||
'firecrawl metrics',
|
||||
'firecrawl monitoring',
|
||||
'firecrawl observability',
|
||||
'firecrawl traces',
|
||||
'llm',
|
||||
'llm monitoring',
|
||||
'opentelemetry firecrawl',
|
||||
'scrape',
|
||||
'traces',
|
||||
'web scraping',
|
||||
],
|
||||
id: 'firecrawl',
|
||||
link: '/docs/firecrawl-monitoring/',
|
||||
},
|
||||
{
|
||||
dataSource: 'keda',
|
||||
label: 'KEDA',
|
||||
imgUrl: kedaUrl,
|
||||
tags: ['infrastructure monitoring', 'metrics'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'autoscaling',
|
||||
'event driven autoscaling',
|
||||
'keda',
|
||||
'keda metrics',
|
||||
'keda monitoring',
|
||||
'kubernetes',
|
||||
'kubernetes autoscaling',
|
||||
'metrics',
|
||||
'opentelemetry keda',
|
||||
'scaled jobs',
|
||||
'scaled objects',
|
||||
],
|
||||
id: 'keda',
|
||||
link: '/docs/metrics-management/keda-metrics/',
|
||||
},
|
||||
{
|
||||
dataSource: 'opentelemetry-collector-metrics',
|
||||
label: 'OpenTelemetry Collector Metrics',
|
||||
imgUrl: opentelemetryUrl,
|
||||
tags: ['infrastructure monitoring', 'metrics'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'collector health',
|
||||
'collector internal metrics',
|
||||
'collector metrics',
|
||||
'metrics',
|
||||
'opentelemetry',
|
||||
'opentelemetry collector',
|
||||
'otel collector',
|
||||
'otelcol metrics',
|
||||
'pipeline health',
|
||||
],
|
||||
id: 'opentelemetry-collector-metrics',
|
||||
link: '/docs/metrics-management/opentelemetry-collector-metrics/',
|
||||
},
|
||||
{
|
||||
dataSource: 'cloudnative-pg',
|
||||
label: 'CloudNativePG',
|
||||
imgUrl: cloudnativePgUrl,
|
||||
tags: ['database'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'cloud native postgres',
|
||||
'cloudnativepg',
|
||||
'cnpg',
|
||||
'cnpg metrics',
|
||||
'database',
|
||||
'kubernetes postgres',
|
||||
'metrics',
|
||||
'opentelemetry cloudnativepg',
|
||||
'postgres',
|
||||
'postgresql',
|
||||
],
|
||||
id: 'cloudnative-pg',
|
||||
link: '/docs/metrics-management/opentelemetry-cloudnative-pg/',
|
||||
},
|
||||
{
|
||||
dataSource: 'kong-gateway',
|
||||
label: 'Kong Gateway',
|
||||
imgUrl: kongUrl,
|
||||
tags: ['infrastructure monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'api gateway',
|
||||
'kong',
|
||||
'kong gateway',
|
||||
'kong logs',
|
||||
'kong metrics',
|
||||
'kong monitoring',
|
||||
'kong observability',
|
||||
'kong traces',
|
||||
'opentelemetry kong',
|
||||
'proxy',
|
||||
'traces',
|
||||
],
|
||||
id: 'kong-gateway',
|
||||
link: '/docs/integrations/kong-gateway/',
|
||||
},
|
||||
{
|
||||
dataSource: 'github-copilot',
|
||||
label: 'GitHub Copilot',
|
||||
imgUrl: githubCopilotUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'coding agent',
|
||||
'copilot',
|
||||
'copilot chat',
|
||||
'github copilot',
|
||||
'github copilot metrics',
|
||||
'github copilot monitoring',
|
||||
'github copilot observability',
|
||||
'github copilot traces',
|
||||
'llm',
|
||||
'llm monitoring',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'otel github copilot integration',
|
||||
'traces',
|
||||
'tracing',
|
||||
'vs code',
|
||||
],
|
||||
id: 'github-copilot',
|
||||
link: '/docs/github-copilot-monitoring/',
|
||||
},
|
||||
{
|
||||
dataSource: 'serilog',
|
||||
label: 'Serilog',
|
||||
imgUrl: dotnetUrl,
|
||||
tags: ['logs'],
|
||||
module: 'logs',
|
||||
relatedSearchKeywords: [
|
||||
'.net',
|
||||
'asp.net',
|
||||
'c#',
|
||||
'csharp',
|
||||
'dotnet',
|
||||
'dotnet logs',
|
||||
'logging',
|
||||
'logs',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'otel serilog integration',
|
||||
'serilog',
|
||||
'serilog logs',
|
||||
'serilog sink',
|
||||
'structured logging',
|
||||
],
|
||||
id: 'serilog',
|
||||
link: '/docs/logs-management/send-logs/serilog-to-signoz/',
|
||||
},
|
||||
{
|
||||
dataSource: 'gcp-integration',
|
||||
label: 'GCP Integration',
|
||||
imgUrl: gcpUrl,
|
||||
tags: ['GCP'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'cloud monitoring metrics',
|
||||
'connect gcp account',
|
||||
'gcp',
|
||||
'gcp integration',
|
||||
'gcp manual setup',
|
||||
'gcp metrics',
|
||||
'gcp monitoring',
|
||||
'gcp observability',
|
||||
'gcp service account',
|
||||
'google cloud',
|
||||
'google cloud integration',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'opentelemetry collector gcp',
|
||||
],
|
||||
id: 'gcp-integration',
|
||||
link: '/docs/integrations/gcp/gcp-integration/',
|
||||
},
|
||||
{
|
||||
dataSource: 'azure-cosmos-db',
|
||||
label: 'Azure Cosmos DB',
|
||||
imgUrl: azureUrl,
|
||||
tags: ['Azure'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'azure',
|
||||
'azure cosmos db',
|
||||
'azure integration',
|
||||
'cosmos db',
|
||||
'cosmosdb',
|
||||
'database',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'nosql',
|
||||
'observability',
|
||||
'one click azure',
|
||||
'request units',
|
||||
],
|
||||
id: 'azure-cosmos-db',
|
||||
link: '/integrations/azure?service=cosmosdb',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'azure-mongodb',
|
||||
label: 'Azure MongoDB vCore',
|
||||
imgUrl: mongoUrl,
|
||||
tags: ['Azure'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'azure',
|
||||
'azure integration',
|
||||
'azure mongodb',
|
||||
'azure mongodb vcore',
|
||||
'database',
|
||||
'metrics',
|
||||
'mongodb',
|
||||
'mongodb vcore',
|
||||
'monitoring',
|
||||
'nosql',
|
||||
'observability',
|
||||
'one click azure',
|
||||
],
|
||||
id: 'azure-mongodb',
|
||||
link: '/integrations/azure?service=mongodb',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'azure-postgresql-flexible-server',
|
||||
label: 'Azure PostgreSQL Flexible Server',
|
||||
imgUrl: postgresqlUrl,
|
||||
tags: ['Azure'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'azure',
|
||||
'azure integration',
|
||||
'azure postgresql',
|
||||
'azure postgresql flexible server',
|
||||
'database',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'one click azure',
|
||||
'postgres',
|
||||
'postgresql',
|
||||
'postgresql flexible server',
|
||||
],
|
||||
id: 'azure-postgresql-flexible-server',
|
||||
link: '/integrations/azure?service=postgresqlflexibleserver',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'azure-cache-redis',
|
||||
label: 'Azure Cache for Redis',
|
||||
imgUrl: redisUrl,
|
||||
tags: ['Azure'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'azure',
|
||||
'azure cache for redis',
|
||||
'azure integration',
|
||||
'azure redis',
|
||||
'cache',
|
||||
'database',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'one click azure',
|
||||
'redis',
|
||||
],
|
||||
id: 'azure-cache-redis',
|
||||
link: '/integrations/azure?service=redis',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'azure-sql-managed-instance',
|
||||
label: 'Azure SQL Managed Instance',
|
||||
imgUrl: azureSqlDatabaseMetricsUrl,
|
||||
tags: ['Azure'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'azure',
|
||||
'azure integration',
|
||||
'azure sql',
|
||||
'azure sql managed instance',
|
||||
'database',
|
||||
'managed instance',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'one click azure',
|
||||
'sql server',
|
||||
],
|
||||
id: 'azure-sql-managed-instance',
|
||||
link: '/integrations/azure?service=sqldatabasemi',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'azure-cassandra-db',
|
||||
label: 'Azure Managed Instance for Apache Cassandra',
|
||||
imgUrl: cassandraUrl,
|
||||
tags: ['Azure'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'apache cassandra',
|
||||
'azure',
|
||||
'azure cassandra',
|
||||
'azure integration',
|
||||
'azure managed instance for apache cassandra',
|
||||
'cassandra',
|
||||
'database',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'one click azure',
|
||||
],
|
||||
id: 'azure-cassandra-db',
|
||||
link: '/integrations/azure?service=cassandradb',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'gcp-cloud-sql-postgresql',
|
||||
label: 'GCP Cloud SQL for PostgreSQL',
|
||||
imgUrl: gcpCloudSqlUrl,
|
||||
tags: ['GCP'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'cloud sql',
|
||||
'cloud sql for postgresql',
|
||||
'database',
|
||||
'gcp',
|
||||
'gcp integration',
|
||||
'google cloud',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'postgres',
|
||||
'postgresql',
|
||||
],
|
||||
id: 'gcp-cloud-sql-postgresql',
|
||||
link: '/integrations/gcp?service=cloudsql_postgres',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'gcp-memorystore-redis',
|
||||
label: 'GCP Memorystore for Redis',
|
||||
imgUrl: redisUrl,
|
||||
tags: ['GCP'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'cache',
|
||||
'database',
|
||||
'gcp',
|
||||
'gcp integration',
|
||||
'google cloud',
|
||||
'memorystore',
|
||||
'memorystore for redis',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'redis',
|
||||
],
|
||||
id: 'gcp-memorystore-redis',
|
||||
link: '/integrations/gcp?service=memorystore_redis',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'supabase-logs',
|
||||
label: 'Supabase Logs',
|
||||
imgUrl: supabaseUrl,
|
||||
tags: ['logs'],
|
||||
module: 'logs',
|
||||
relatedSearchKeywords: [
|
||||
'database',
|
||||
'logging',
|
||||
'logs',
|
||||
'postgres',
|
||||
'postgresql',
|
||||
'send supabase logs',
|
||||
'supabase',
|
||||
'supabase log drains',
|
||||
'supabase logs',
|
||||
'supabase observability',
|
||||
],
|
||||
id: 'supabase-logs',
|
||||
link: '/docs/logs-management/send-logs/supabase-logs/',
|
||||
},
|
||||
{
|
||||
dataSource: 'traefik-logs',
|
||||
label: 'Traefik Access Logs',
|
||||
imgUrl: opentelemetryUrl,
|
||||
tags: ['logs'],
|
||||
module: 'logs',
|
||||
relatedSearchKeywords: [
|
||||
'access logs',
|
||||
'logging',
|
||||
'logs',
|
||||
'opentelemetry traefik',
|
||||
'proxy',
|
||||
'reverse proxy',
|
||||
'traefik',
|
||||
'traefik access logs',
|
||||
'traefik logs',
|
||||
],
|
||||
id: 'traefik-logs',
|
||||
link: '/docs/integrations/opentelemetry-traefik/',
|
||||
},
|
||||
{
|
||||
dataSource: 'mysql-logs',
|
||||
label: 'MySQL Logs',
|
||||
imgUrl: opentelemetryUrl,
|
||||
tags: ['logs'],
|
||||
module: 'logs',
|
||||
relatedSearchKeywords: [
|
||||
'database',
|
||||
'logging',
|
||||
'logs',
|
||||
'mysql',
|
||||
'mysql error log',
|
||||
'mysql general query log',
|
||||
'mysql logs',
|
||||
'mysql slow query log',
|
||||
'opentelemetry mysql',
|
||||
],
|
||||
id: 'mysql-logs',
|
||||
link: '/docs/integrations/opentelemetry-mysql/',
|
||||
},
|
||||
];
|
||||
export default onboardingConfigWithLinks;
|
||||
|
||||
@@ -336,7 +336,7 @@ func (r *ClickHouseReader) GetTopLevelOperations(ctx context.Context, start, end
|
||||
return &operations, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) buildResourceSubQuery(tags []model.TagQueryParam, svc string, start, end time.Time) (string, error) {
|
||||
func (r *ClickHouseReader) buildResourceSubQuery(ctx context.Context, orgID valuer.UUID, tags []model.TagQueryParam, svc string, start, end time.Time) (string, error) {
|
||||
// assuming all will be resource attributes.
|
||||
// and resource attributes are string for traces
|
||||
filterSet := v3.FilterSet{}
|
||||
@@ -387,7 +387,8 @@ func (r *ClickHouseReader) buildResourceSubQuery(tags []model.TagQueryParam, svc
|
||||
&filterSet,
|
||||
[]v3.AttributeKey{},
|
||||
v3.AttributeKey{},
|
||||
false)
|
||||
false,
|
||||
r.fl.BooleanOrEmpty(ctx, flagger.FeatureResolveSemconvFamilies, featuretypes.NewFlaggerEvaluationContext(orgID)))
|
||||
if err != nil {
|
||||
r.logger.Error("Error in processing sql query", errorsV2.Attr(err))
|
||||
return "", err
|
||||
@@ -395,7 +396,7 @@ func (r *ClickHouseReader) buildResourceSubQuery(tags []model.TagQueryParam, svc
|
||||
return resourceSubQuery, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetServices(ctx context.Context, queryParams *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError) {
|
||||
func (r *ClickHouseReader) GetServices(ctx context.Context, orgID valuer.UUID, queryParams *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
@@ -467,7 +468,7 @@ func (r *ClickHouseReader) GetServices(ctx context.Context, queryParams *model.G
|
||||
clickhouse.Named("names", ops),
|
||||
)
|
||||
|
||||
resourceSubQuery, err := r.buildResourceSubQuery(queryParams.Tags, svc, *queryParams.Start, *queryParams.End)
|
||||
resourceSubQuery, err := r.buildResourceSubQuery(ctx, orgID, queryParams.Tags, svc, *queryParams.Start, *queryParams.End)
|
||||
if err != nil {
|
||||
r.logger.Error("Error in processing sql query", errorsV2.Attr(err))
|
||||
return
|
||||
@@ -703,9 +704,9 @@ func addExistsOperator(item model.TagQuery, tagMapType string, not bool) (string
|
||||
return fmt.Sprintf(" AND %s (%s)", notStr, strings.Join(tagOperatorPair, " OR ")), args
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetEntryPointOperations(ctx context.Context, queryParams *model.GetTopOperationsParams) (*[]model.TopOperationsItem, error) {
|
||||
func (r *ClickHouseReader) GetEntryPointOperations(ctx context.Context, orgID valuer.UUID, queryParams *model.GetTopOperationsParams) (*[]model.TopOperationsItem, error) {
|
||||
// Step 1: Get top operations for the given service
|
||||
topOps, err := r.GetTopOperations(ctx, queryParams)
|
||||
topOps, err := r.GetTopOperations(ctx, orgID, queryParams)
|
||||
if err != nil {
|
||||
return nil, errorsV2.Wrapf(err, errorsV2.TypeInternal, errorsV2.CodeInternal, "Error in getting Top Operations")
|
||||
}
|
||||
@@ -757,7 +758,7 @@ func (r *ClickHouseReader) GetEntryPointOperations(ctx context.Context, queryPar
|
||||
return &filtered, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetTopOperations(ctx context.Context, queryParams *model.GetTopOperationsParams) (*[]model.TopOperationsItem, *model.ApiError) {
|
||||
func (r *ClickHouseReader) GetTopOperations(ctx context.Context, orgID valuer.UUID, queryParams *model.GetTopOperationsParams) (*[]model.TopOperationsItem, *model.ApiError) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
@@ -787,7 +788,7 @@ func (r *ClickHouseReader) GetTopOperations(ctx context.Context, queryParams *mo
|
||||
r.TraceDB, r.traceTableName,
|
||||
)
|
||||
|
||||
resourceSubQuery, err := r.buildResourceSubQuery(queryParams.Tags, queryParams.ServiceName, *queryParams.Start, *queryParams.End)
|
||||
resourceSubQuery, err := r.buildResourceSubQuery(ctx, orgID, queryParams.Tags, queryParams.ServiceName, *queryParams.Start, *queryParams.End)
|
||||
if err != nil {
|
||||
r.logger.Error("Error in processing sql query", errorsV2.Attr(err))
|
||||
return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("error in processing sql query")}
|
||||
@@ -858,7 +859,7 @@ func (r *ClickHouseReader) GetUsage(ctx context.Context, queryParams *model.GetU
|
||||
return &usageItems, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetDependencyGraph(ctx context.Context, queryParams *model.GetServicesParams) (*[]model.ServiceMapDependencyResponseItem, error) {
|
||||
func (r *ClickHouseReader) GetDependencyGraph(ctx context.Context, orgID valuer.UUID, queryParams *model.GetServicesParams) (*[]model.ServiceMapDependencyResponseItem, error) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
@@ -895,7 +896,7 @@ func (r *ClickHouseReader) GetDependencyGraph(ctx context.Context, queryParams *
|
||||
)
|
||||
|
||||
tags := createTagQueryFromTagQueryParams(queryParams.Tags)
|
||||
filterQuery, filterArgs := services.BuildServiceMapQuery(tags)
|
||||
filterQuery, filterArgs := services.BuildServiceMapQuery(tags, r.fl.BooleanOrEmpty(ctx, flagger.FeatureResolveSemconvFamilies, featuretypes.NewFlaggerEvaluationContext(orgID)))
|
||||
query += filterQuery + " GROUP BY src, dest;"
|
||||
args = append(args, filterArgs...)
|
||||
|
||||
|
||||
@@ -1128,13 +1128,19 @@ func (aH *APIHandler) registerEvent(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (aH *APIHandler) getTopOperations(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
query, err := parseGetTopOperationsRequest(r)
|
||||
if aH.HandleError(w, err, http.StatusBadRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
result, apiErr := aH.reader.GetTopOperations(r.Context(), query)
|
||||
result, apiErr := aH.reader.GetTopOperations(r.Context(), orgID, query)
|
||||
|
||||
if apiErr != nil && aH.HandleError(w, apiErr.Err, http.StatusInternalServerError) {
|
||||
return
|
||||
@@ -1145,13 +1151,20 @@ func (aH *APIHandler) getTopOperations(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (aH *APIHandler) getEntryPointOps(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
query, err := parseGetTopOperationsRequest(r)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, apiErr := aH.reader.GetEntryPointOperations(r.Context(), query)
|
||||
result, apiErr := aH.reader.GetEntryPointOperations(r.Context(), orgID, query)
|
||||
if apiErr != nil {
|
||||
render.Error(w, apiErr)
|
||||
return
|
||||
@@ -1226,12 +1239,19 @@ func (aH *APIHandler) getServicesTopLevelOps(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
func (aH *APIHandler) getServices(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
query, err := parseGetServicesRequest(r)
|
||||
if aH.HandleError(w, err, http.StatusBadRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
result, apiErr := aH.reader.GetServices(r.Context(), query)
|
||||
result, apiErr := aH.reader.GetServices(r.Context(), orgID, query)
|
||||
if apiErr != nil && aH.HandleError(w, apiErr.Err, http.StatusInternalServerError) {
|
||||
return
|
||||
}
|
||||
@@ -1240,13 +1260,19 @@ func (aH *APIHandler) getServices(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (aH *APIHandler) dependencyGraph(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
query, err := parseGetServicesRequest(r)
|
||||
if aH.HandleError(w, err, http.StatusBadRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := aH.reader.GetDependencyGraph(r.Context(), query)
|
||||
result, err := aH.reader.GetDependencyGraph(r.Context(), orgID, query)
|
||||
if aH.HandleError(w, err, http.StatusBadRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -383,7 +383,7 @@ func buildLogsQuery(panelType v3.PanelType, start, end, step int64, mq *v3.Build
|
||||
}
|
||||
|
||||
// build the where clause for resource table
|
||||
resourceSubQuery, err := resource.BuildResourceSubQuery(DB_NAME, DISTRIBUTED_LOGS_V2_RESOURCE, bucketStart, bucketEnd, mq.Filters, mq.GroupBy, mq.AggregateAttribute, false)
|
||||
resourceSubQuery, err := resource.BuildResourceSubQuery(DB_NAME, DISTRIBUTED_LOGS_V2_RESOURCE, bucketStart, bucketEnd, mq.Filters, mq.GroupBy, mq.AggregateAttribute, false, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -475,7 +475,7 @@ func buildLogsLiveTailQuery(mq *v3.BuilderQuery) (string, error) {
|
||||
}
|
||||
|
||||
// no values for bucket start and end
|
||||
resourceSubQuery, err := resource.BuildResourceSubQuery(DB_NAME, DISTRIBUTED_LOGS_V2_RESOURCE, 0, 0, mq.Filters, mq.GroupBy, mq.AggregateAttribute, true)
|
||||
resourceSubQuery, err := resource.BuildResourceSubQuery(DB_NAME, DISTRIBUTED_LOGS_V2_RESOURCE, 0, 0, mq.Filters, mq.GroupBy, mq.AggregateAttribute, true, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/semconv"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
var resourceLogOperators = map[v3.FilterOperator]string{
|
||||
@@ -30,22 +33,49 @@ var resourceLogOperators = map[v3.FilterOperator]string{
|
||||
}
|
||||
|
||||
// buildResourceFilter builds a clickhouse filter string for resource labels
|
||||
func buildResourceFilter(logsOp string, key string, op v3.FilterOperator, value interface{}) string {
|
||||
func buildResourceFilter(logsOp string, key string, op v3.FilterOperator, value interface{}, members []string) string {
|
||||
// for all operators except contains and like
|
||||
searchKey := fmt.Sprintf("simpleJSONExtractString(labels, '%s')", key)
|
||||
searchKey := fmt.Sprintf("simpleJSONExtractString(labels, %s)", querybuilder.ClickHouseStringLiteral(key))
|
||||
if len(members) > 1 {
|
||||
values := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
values = append(values, fmt.Sprintf("NULLIF(simpleJSONExtractString(labels, %s), '')", querybuilder.ClickHouseStringLiteral(member)))
|
||||
}
|
||||
searchKey = "COALESCE(" + strings.Join(values, ", ") + ", '')"
|
||||
}
|
||||
|
||||
// for contains and like it will be case insensitive
|
||||
lowerSearchKey := fmt.Sprintf("simpleJSONExtractString(lower(labels), '%s')", key)
|
||||
lowerSearchKey := fmt.Sprintf("simpleJSONExtractString(lower(labels), %s)", querybuilder.ClickHouseStringLiteral(key))
|
||||
if len(members) > 1 {
|
||||
lowerSearchKey = "lower(" + searchKey + ")"
|
||||
}
|
||||
|
||||
chFmtVal := utils.ClickHouseFormattedValue(value)
|
||||
|
||||
lowerValue := strings.ToLower(fmt.Sprintf("%s", value))
|
||||
|
||||
switch op {
|
||||
case v3.FilterOperatorExists:
|
||||
return fmt.Sprintf("simpleJSONHas(labels, '%s')", key)
|
||||
case v3.FilterOperatorNotExists:
|
||||
return fmt.Sprintf("not simpleJSONHas(labels, '%s')", key)
|
||||
case v3.FilterOperatorExists, v3.FilterOperatorNotExists:
|
||||
exists := op == v3.FilterOperatorExists
|
||||
if len(members) == 1 {
|
||||
if exists {
|
||||
return fmt.Sprintf("simpleJSONHas(labels, %s)", querybuilder.ClickHouseStringLiteral(key))
|
||||
}
|
||||
return fmt.Sprintf("not simpleJSONHas(labels, %s)", querybuilder.ClickHouseStringLiteral(key))
|
||||
}
|
||||
presence := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
if exists {
|
||||
presence = append(presence, fmt.Sprintf("simpleJSONHas(labels, %s)", querybuilder.ClickHouseStringLiteral(member)))
|
||||
} else {
|
||||
presence = append(presence, fmt.Sprintf("not simpleJSONHas(labels, %s)", querybuilder.ClickHouseStringLiteral(member)))
|
||||
}
|
||||
}
|
||||
separator := " OR "
|
||||
if !exists {
|
||||
separator = " AND "
|
||||
}
|
||||
return "(" + strings.Join(presence, separator) + ")"
|
||||
case v3.FilterOperatorRegex, v3.FilterOperatorNotRegex:
|
||||
return fmt.Sprintf(logsOp, searchKey, chFmtVal)
|
||||
case v3.FilterOperatorContains, v3.FilterOperatorNotContains:
|
||||
@@ -93,9 +123,10 @@ func buildIndexFilterForInOperator(key string, op v3.FilterOperator, value inter
|
||||
|
||||
// if there are no values to filter on, return an empty string
|
||||
if len(values) > 0 {
|
||||
escapedKey := utils.QuoteEscapedStringForContains(key, true)
|
||||
for _, v := range values {
|
||||
value := utils.QuoteEscapedStringForContains(v, true)
|
||||
conditions = append(conditions, fmt.Sprintf("labels %s '%%\"%s\":\"%s\"%%'", sqlOp, key, value))
|
||||
conditions = append(conditions, fmt.Sprintf("labels %s '%%\"%s\":\"%s\"%%'", sqlOp, escapedKey, value))
|
||||
}
|
||||
return "(" + strings.Join(conditions, separator) + ")"
|
||||
}
|
||||
@@ -109,8 +140,34 @@ func buildIndexFilterForInOperator(key string, op v3.FilterOperator, value inter
|
||||
// for like/contains we will use lower index
|
||||
// we can use lower index for =, in etc but it's difficult to do it for !=, NIN etc
|
||||
// if as x != "ABC" we cannot predict something like "not lower(labels) like '%%x%%abc%%'". It has it be "not lower(labels) like '%%x%%ABC%%'"
|
||||
func buildResourceIndexFilter(key string, op v3.FilterOperator, value interface{}) string {
|
||||
func buildResourceIndexFilter(key string, op v3.FilterOperator, value interface{}, members []string) string {
|
||||
if len(members) > 1 {
|
||||
// A negated hint would drop rows where another member holds the value.
|
||||
switch op {
|
||||
case v3.FilterOperatorNotEqual,
|
||||
v3.FilterOperatorNotLike,
|
||||
v3.FilterOperatorNotILike,
|
||||
v3.FilterOperatorNotContains,
|
||||
v3.FilterOperatorNotExists,
|
||||
v3.FilterOperatorNotRegex,
|
||||
v3.FilterOperatorNotIn:
|
||||
return ""
|
||||
}
|
||||
|
||||
conditions := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
if condition := buildResourceIndexFilter(member, op, value, []string{member}); condition != "" {
|
||||
conditions = append(conditions, condition)
|
||||
}
|
||||
}
|
||||
if len(conditions) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "(" + strings.Join(conditions, " OR ") + ")"
|
||||
}
|
||||
|
||||
// not using clickhouseFormattedValue as we don't wan't the quotes
|
||||
escapedKey := utils.QuoteEscapedStringForContains(key, true)
|
||||
strVal := fmt.Sprintf("%s", value)
|
||||
fmtValEscapedForContains := utils.QuoteEscapedStringForContains(strVal, true)
|
||||
fmtValEscapedForContainsLower := strings.ToLower(fmtValEscapedForContains)
|
||||
@@ -119,36 +176,36 @@ func buildResourceIndexFilter(key string, op v3.FilterOperator, value interface{
|
||||
// add index filters
|
||||
switch op {
|
||||
case v3.FilterOperatorEqual:
|
||||
return fmt.Sprintf("labels like '%%%s\":\"%s%%'", key, fmtValEscapedForContains)
|
||||
return fmt.Sprintf("labels like '%%%s\":\"%s%%'", escapedKey, fmtValEscapedForContains)
|
||||
case v3.FilterOperatorNotEqual:
|
||||
return fmt.Sprintf("labels not like '%%%s\":\"%s%%'", key, fmtValEscapedForContains)
|
||||
return fmt.Sprintf("labels not like '%%%s\":\"%s%%'", escapedKey, fmtValEscapedForContains)
|
||||
case v3.FilterOperatorLike, v3.FilterOperatorILike:
|
||||
return fmt.Sprintf("lower(labels) like '%%%s%%%s%%'", key, fmtValEscapedLower)
|
||||
return fmt.Sprintf("lower(labels) like '%%%s%%%s%%'", escapedKey, fmtValEscapedLower)
|
||||
case v3.FilterOperatorNotLike, v3.FilterOperatorNotILike:
|
||||
// cannot apply not contains x%y as y can be somewhere else
|
||||
return ""
|
||||
case v3.FilterOperatorContains:
|
||||
return fmt.Sprintf("lower(labels) like '%%%s%%%s%%'", key, fmtValEscapedForContainsLower)
|
||||
return fmt.Sprintf("lower(labels) like '%%%s%%%s%%'", escapedKey, fmtValEscapedForContainsLower)
|
||||
case v3.FilterOperatorNotContains:
|
||||
// cannot apply not contains x%y as y can be somewhere else
|
||||
return ""
|
||||
case v3.FilterOperatorExists:
|
||||
return fmt.Sprintf("lower(labels) like '%%%s%%'", key)
|
||||
return fmt.Sprintf("lower(labels) like '%%%s%%'", escapedKey)
|
||||
case v3.FilterOperatorNotExists:
|
||||
return fmt.Sprintf("lower(labels) not like '%%%s%%'", key)
|
||||
return fmt.Sprintf("lower(labels) not like '%%%s%%'", escapedKey)
|
||||
case v3.FilterOperatorRegex, v3.FilterOperatorNotRegex:
|
||||
// don't try to do anything for regex.
|
||||
return ""
|
||||
case v3.FilterOperatorIn, v3.FilterOperatorNotIn:
|
||||
return buildIndexFilterForInOperator(key, op, value)
|
||||
default:
|
||||
return fmt.Sprintf("labels like '%%%s%%'", key)
|
||||
return fmt.Sprintf("labels like '%%%s%%'", escapedKey)
|
||||
}
|
||||
}
|
||||
|
||||
// buildResourceFiltersFromFilterItems builds a list of clickhouse filter strings for resource labels from a FilterSet.
|
||||
// It skips any filter items that are not resource attributes and checks that the operator is supported and the data type is correct.
|
||||
func buildResourceFiltersFromFilterItems(fs *v3.FilterSet) ([]string, error) {
|
||||
func buildResourceFiltersFromFilterItems(fs *v3.FilterSet, resolveSemconvFamilies bool) ([]string, error) {
|
||||
var conditions []string
|
||||
if fs == nil || len(fs.Items) == 0 {
|
||||
return nil, nil
|
||||
@@ -182,12 +239,20 @@ func buildResourceFiltersFromFilterItems(fs *v3.FilterSet) ([]string, error) {
|
||||
}
|
||||
|
||||
if logsOp, ok := resourceLogOperators[op]; ok {
|
||||
members := []string{keyName}
|
||||
if resolveSemconvFamilies {
|
||||
members = semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: keyName,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
})
|
||||
}
|
||||
// the filter
|
||||
if resourceFilter := buildResourceFilter(logsOp, keyName, op, value); resourceFilter != "" {
|
||||
if resourceFilter := buildResourceFilter(logsOp, keyName, op, value, members); resourceFilter != "" {
|
||||
conditions = append(conditions, resourceFilter)
|
||||
}
|
||||
// the additional filter for better usage of the index
|
||||
if resourceIndexFilter := buildResourceIndexFilter(keyName, op, value); resourceIndexFilter != "" {
|
||||
if resourceIndexFilter := buildResourceIndexFilter(keyName, op, value, members); resourceIndexFilter != "" {
|
||||
conditions = append(conditions, resourceIndexFilter)
|
||||
}
|
||||
} else {
|
||||
@@ -219,12 +284,12 @@ func buildResourceFiltersFromAggregateAttribute(aggregateAttribute v3.AttributeK
|
||||
return ""
|
||||
}
|
||||
|
||||
func BuildResourceSubQuery(dbName, tableName string, bucketStart, bucketEnd int64, fs *v3.FilterSet, groupBy []v3.AttributeKey, aggregateAttribute v3.AttributeKey, isLiveTail bool) (string, error) {
|
||||
func BuildResourceSubQuery(dbName, tableName string, bucketStart, bucketEnd int64, fs *v3.FilterSet, groupBy []v3.AttributeKey, aggregateAttribute v3.AttributeKey, isLiveTail bool, resolveSemconvFamilies bool) (string, error) {
|
||||
|
||||
// BUILD THE WHERE CLAUSE
|
||||
var conditions []string
|
||||
// only add the resource attributes to the filters here
|
||||
rs, err := buildResourceFiltersFromFilterItems(fs)
|
||||
rs, err := buildResourceFiltersFromFilterItems(fs, resolveSemconvFamilies)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_buildResourceFilter(t *testing.T) {
|
||||
@@ -88,7 +89,7 @@ func Test_buildResourceFilter(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := buildResourceFilter(tt.args.logsOp, tt.args.key, tt.args.op, tt.args.value); got != tt.want {
|
||||
if got := buildResourceFilter(tt.args.logsOp, tt.args.key, tt.args.op, tt.args.value, []string{tt.args.key}); got != tt.want {
|
||||
t.Errorf("buildResourceFilter() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
@@ -282,7 +283,7 @@ func Test_buildResourceIndexFilter(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := buildResourceIndexFilter(tt.args.key, tt.args.op, tt.args.value); got != tt.want {
|
||||
if got := buildResourceIndexFilter(tt.args.key, tt.args.op, tt.args.value, []string{tt.args.key}); got != tt.want {
|
||||
t.Errorf("buildResourceIndexFilter() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
@@ -379,7 +380,7 @@ func Test_buildResourceFiltersFromFilterItems(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := buildResourceFiltersFromFilterItems(tt.args.fs)
|
||||
got, err := buildResourceFiltersFromFilterItems(tt.args.fs, false)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("buildResourceFiltersFromFilterItems() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
@@ -541,7 +542,7 @@ func Test_buildResourceSubQuery(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := BuildResourceSubQuery("signoz_logs", "distributed_logs_v2_resource", tt.args.bucketStart, tt.args.bucketEnd, tt.args.fs, tt.args.groupBy, tt.args.aggregateAttribute, false)
|
||||
got, err := BuildResourceSubQuery("signoz_logs", "distributed_logs_v2_resource", tt.args.bucketStart, tt.args.bucketEnd, tt.args.fs, tt.args.groupBy, tt.args.aggregateAttribute, false, false)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("buildResourceSubQuery() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
@@ -552,3 +553,58 @@ func Test_buildResourceSubQuery(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_buildResourceFilterFamily(t *testing.T) {
|
||||
members := []string{"deployment.environment.name", "deployment.environment"}
|
||||
|
||||
require.Equal(t,
|
||||
"COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '') = 'production'",
|
||||
buildResourceFilter("=", "deployment.environment.name", v3.FilterOperatorEqual, "production", members))
|
||||
|
||||
require.Equal(t,
|
||||
"COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '') != 'production'",
|
||||
buildResourceFilter("!=", "deployment.environment.name", v3.FilterOperatorNotEqual, "production", members))
|
||||
|
||||
require.Equal(t,
|
||||
"(simpleJSONHas(labels, 'deployment.environment.name') OR simpleJSONHas(labels, 'deployment.environment'))",
|
||||
buildResourceFilter("", "deployment.environment.name", v3.FilterOperatorExists, nil, members))
|
||||
|
||||
require.Equal(t,
|
||||
"(not simpleJSONHas(labels, 'deployment.environment.name') AND not simpleJSONHas(labels, 'deployment.environment'))",
|
||||
buildResourceFilter("", "deployment.environment.name", v3.FilterOperatorNotExists, nil, members))
|
||||
}
|
||||
|
||||
func Test_buildResourceIndexFilterFamily(t *testing.T) {
|
||||
members := []string{"deployment.environment.name", "deployment.environment"}
|
||||
|
||||
require.Equal(t,
|
||||
`(labels like '%deployment.environment.name":"production%' OR labels like '%deployment.environment":"production%')`,
|
||||
buildResourceIndexFilter("deployment.environment.name", v3.FilterOperatorEqual, "production", members))
|
||||
|
||||
require.Equal(t, "",
|
||||
buildResourceIndexFilter("deployment.environment.name", v3.FilterOperatorNotEqual, "production", members))
|
||||
require.Equal(t, "",
|
||||
buildResourceIndexFilter("deployment.environment.name", v3.FilterOperatorNotIn, []interface{}{"production"}, members))
|
||||
}
|
||||
|
||||
func TestBuildResourceSubQueryFamily(t *testing.T) {
|
||||
fs := &v3.FilterSet{Items: []v3.FilterItem{{
|
||||
Key: v3.AttributeKey{
|
||||
Key: "deployment.environment.name",
|
||||
DataType: v3.AttributeKeyDataTypeString,
|
||||
Type: v3.AttributeKeyTypeResource,
|
||||
},
|
||||
Operator: v3.FilterOperatorEqual,
|
||||
Value: "production",
|
||||
}}}
|
||||
|
||||
familyOn, err := BuildResourceSubQuery("signoz_traces", "distributed_traces_v3_resource", 1, 2, fs, nil, v3.AttributeKey{}, false, true)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, familyOn, "COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '') = 'production'")
|
||||
require.Contains(t, familyOn, `(labels like '%deployment.environment.name":"production%' OR labels like '%deployment.environment":"production%')`)
|
||||
|
||||
familyOff, err := BuildResourceSubQuery("signoz_traces", "distributed_traces_v3_resource", 1, 2, fs, nil, v3.AttributeKey{}, false, false)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, familyOff, "simpleJSONExtractString(labels, 'deployment.environment.name') = 'production'")
|
||||
require.NotContains(t, familyOff, "COALESCE")
|
||||
}
|
||||
|
||||
@@ -6,17 +6,25 @@ import (
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/semconv"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
var (
|
||||
columns = map[string]struct{}{
|
||||
"deployment_environment": {},
|
||||
"k8s_cluster_name": {},
|
||||
"k8s_namespace_name": {},
|
||||
func BuildServiceMapQuery(tags []model.TagQuery, resolveSemconvFamilies bool) (string, []interface{}) {
|
||||
columns := map[string]string{
|
||||
"deployment_environment": "deployment_environment",
|
||||
"k8s_cluster_name": "k8s_cluster_name",
|
||||
"k8s_namespace_name": "k8s_namespace_name",
|
||||
}
|
||||
if resolveSemconvFamilies {
|
||||
for _, member := range semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: "deployment.environment.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
}) {
|
||||
columns[strings.ReplaceAll(member, ".", "_")] = "deployment_environment"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
func BuildServiceMapQuery(tags []model.TagQuery) (string, []interface{}) {
|
||||
var filterQuery string
|
||||
var namedArgs []interface{}
|
||||
for _, tag := range tags {
|
||||
@@ -24,39 +32,40 @@ func BuildServiceMapQuery(tags []model.TagQuery) (string, []interface{}) {
|
||||
operator := tag.GetOperator()
|
||||
value := tag.GetValues()
|
||||
|
||||
if _, ok := columns[key]; !ok {
|
||||
column, ok := columns[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch operator {
|
||||
case model.InOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s IN @%s", key, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s IN @%s", column, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, value))
|
||||
case model.NotInOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s NOT IN @%s", key, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s NOT IN @%s", column, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, value))
|
||||
case model.EqualOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s = @%s", key, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s = @%s", column, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, value))
|
||||
case model.NotEqualOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s != @%s", key, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s != @%s", column, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, value))
|
||||
case model.ContainsOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s LIKE @%s", key, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s LIKE @%s", column, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, fmt.Sprintf("%%%s%%", value)))
|
||||
case model.NotContainsOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s NOT LIKE @%s", key, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s NOT LIKE @%s", column, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, fmt.Sprintf("%%%s%%", value)))
|
||||
case model.StartsWithOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s LIKE @%s", key, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s LIKE @%s", column, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, fmt.Sprintf("%s%%", value)))
|
||||
case model.NotStartsWithOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s NOT LIKE @%s", key, key)
|
||||
filterQuery += fmt.Sprintf(" AND %s NOT LIKE @%s", column, key)
|
||||
namedArgs = append(namedArgs, clickhouse.Named(key, fmt.Sprintf("%s%%", value)))
|
||||
case model.ExistsOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s IS NOT NULL", key)
|
||||
filterQuery += fmt.Sprintf(" AND %s IS NOT NULL", column)
|
||||
case model.NotExistsOperator:
|
||||
filterQuery += fmt.Sprintf(" AND %s IS NULL", key)
|
||||
filterQuery += fmt.Sprintf(" AND %s IS NULL", column)
|
||||
}
|
||||
}
|
||||
return filterQuery, namedArgs
|
||||
|
||||
37
pkg/query-service/app/services/map_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildServiceMapQueryFamily(t *testing.T) {
|
||||
newSpelling := []model.TagQuery{model.NewTagQueryString(model.TagQueryParam{
|
||||
Key: "deployment.environment.name",
|
||||
StringValues: []string{"production"},
|
||||
Operator: model.EqualOperator,
|
||||
})}
|
||||
oldSpelling := []model.TagQuery{model.NewTagQueryString(model.TagQueryParam{
|
||||
Key: "deployment.environment",
|
||||
StringValues: []string{"production"},
|
||||
Operator: model.EqualOperator,
|
||||
})}
|
||||
|
||||
query, args := BuildServiceMapQuery(newSpelling, true)
|
||||
require.Equal(t, " AND deployment_environment = @deployment_environment_name", query)
|
||||
require.Len(t, args, 1)
|
||||
|
||||
query, args = BuildServiceMapQuery(oldSpelling, true)
|
||||
require.Equal(t, " AND deployment_environment = @deployment_environment", query)
|
||||
require.Len(t, args, 1)
|
||||
|
||||
query, args = BuildServiceMapQuery(newSpelling, false)
|
||||
require.Equal(t, "", query)
|
||||
require.Empty(t, args)
|
||||
|
||||
query, args = BuildServiceMapQuery(oldSpelling, false)
|
||||
require.Equal(t, " AND deployment_environment = @deployment_environment", query)
|
||||
require.Len(t, args, 1)
|
||||
}
|
||||
@@ -282,7 +282,7 @@ func buildTracesQuery(start, end, step int64, mq *v3.BuilderQuery, panelType v3.
|
||||
filterSubQuery = filterSubQuery + " AND " + emptyValuesInGroupByFilter
|
||||
}
|
||||
|
||||
resourceSubQuery, err := resource.BuildResourceSubQuery("signoz_traces", "distributed_traces_v3_resource", bucketStart, bucketEnd, mq.Filters, mq.GroupBy, mq.AggregateAttribute, false)
|
||||
resourceSubQuery, err := resource.BuildResourceSubQuery("signoz_traces", "distributed_traces_v3_resource", bucketStart, bucketEnd, mq.Filters, mq.GroupBy, mq.AggregateAttribute, false, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -17,12 +17,12 @@ type Reader interface {
|
||||
GetInstantQueryMetricsResult(ctx context.Context, query *model.InstantQueryMetricsParams) (*promql.Result, *stats.QueryStats, *model.ApiError)
|
||||
GetQueryRangeResult(ctx context.Context, query *model.QueryRangeParams) (*promql.Result, *stats.QueryStats, *model.ApiError)
|
||||
GetTopLevelOperations(ctx context.Context, start, end time.Time, services []string) (*map[string][]string, *model.ApiError)
|
||||
GetEntryPointOperations(ctx context.Context, query *model.GetTopOperationsParams) (*[]model.TopOperationsItem, error)
|
||||
GetServices(ctx context.Context, query *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError)
|
||||
GetTopOperations(ctx context.Context, query *model.GetTopOperationsParams) (*[]model.TopOperationsItem, *model.ApiError)
|
||||
GetEntryPointOperations(ctx context.Context, orgID valuer.UUID, query *model.GetTopOperationsParams) (*[]model.TopOperationsItem, error)
|
||||
GetServices(ctx context.Context, orgID valuer.UUID, query *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError)
|
||||
GetTopOperations(ctx context.Context, orgID valuer.UUID, query *model.GetTopOperationsParams) (*[]model.TopOperationsItem, *model.ApiError)
|
||||
GetUsage(ctx context.Context, query *model.GetUsageParams) (*[]model.UsageItem, error)
|
||||
GetServicesList(ctx context.Context) (*[]string, error)
|
||||
GetDependencyGraph(ctx context.Context, query *model.GetServicesParams) (*[]model.ServiceMapDependencyResponseItem, error)
|
||||
GetDependencyGraph(ctx context.Context, orgID valuer.UUID, query *model.GetServicesParams) (*[]model.ServiceMapDependencyResponseItem, error)
|
||||
|
||||
GetTTL(ctx context.Context, orgID string, ttlParams *retentiontypes.GetTTLParams) (*retentiontypes.GetTTLResponseItem, *model.ApiError)
|
||||
GetCustomRetentionTTL(ctx context.Context, orgID string) (*retentiontypes.GetCustomRetentionTTLResponse, error)
|
||||
|
||||
@@ -19,6 +19,7 @@ pytest_plugins = [
|
||||
"fixtures.traces",
|
||||
"fixtures.metrics",
|
||||
"fixtures.queriercommon",
|
||||
"fixtures.semconvfamilies",
|
||||
"fixtures.metadata",
|
||||
"fixtures.meter",
|
||||
"fixtures.browser",
|
||||
|
||||
103
tests/fixtures/semconvfamilies.py
vendored
Normal file
@@ -0,0 +1,103 @@
|
||||
from collections.abc import Callable, Generator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.querier import (
|
||||
RequestType,
|
||||
build_order_by,
|
||||
build_raw_query,
|
||||
get_column_data_from_response,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
PREFIX = "semconv-fam"
|
||||
CURRENT_KEY = "deployment.environment.name"
|
||||
OLD_KEY = "deployment.environment"
|
||||
|
||||
# Row identities. The span name, the log body, and service.name are the identity.
|
||||
OLD = f"{PREFIX}-old" # only the old spelling, value "production"
|
||||
NEW = f"{PREFIX}-new" # only the current spelling, value "production"
|
||||
BOTH = f"{PREFIX}-both" # current "staging" and old "production" - the conflict row
|
||||
NEITHER = f"{PREFIX}-neither" # no member at all
|
||||
|
||||
_ROWS = [
|
||||
(OLD, {OLD_KEY: "production"}, timedelta(seconds=4)),
|
||||
(NEW, {CURRENT_KEY: "production"}, timedelta(seconds=3)),
|
||||
(BOTH, {CURRENT_KEY: "staging", OLD_KEY: "production"}, timedelta(seconds=2)),
|
||||
(NEITHER, {}, timedelta(seconds=1)),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(name="family_fleet", scope="function")
|
||||
def family_fleet(
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> Generator[datetime]:
|
||||
"""Yields the base timestamp of the inserted rows."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0) - timedelta(minutes=1)
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - offset,
|
||||
duration=timedelta(milliseconds=10),
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name=identity,
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": identity, **family},
|
||||
attributes=dict(family),
|
||||
)
|
||||
for identity, family, offset in _ROWS
|
||||
]
|
||||
)
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=now - offset,
|
||||
body=identity,
|
||||
resources={"service.name": identity, **family},
|
||||
attributes=dict(family),
|
||||
)
|
||||
for identity, family, offset in _ROWS
|
||||
]
|
||||
)
|
||||
yield now
|
||||
|
||||
|
||||
def fleet_identities(
|
||||
signoz: types.SigNoz,
|
||||
token: str,
|
||||
base: datetime,
|
||||
expression: str,
|
||||
signal: str = "traces",
|
||||
) -> set[str]:
|
||||
identity_field = "span.name" if signal == "traces" else "body"
|
||||
identity_column = "name" if signal == "traces" else "body"
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((base - timedelta(minutes=2)).timestamp() * 1000),
|
||||
end_ms=int((base + timedelta(minutes=1)).timestamp() * 1000),
|
||||
request_type=RequestType.RAW,
|
||||
queries=[
|
||||
build_raw_query(
|
||||
"A",
|
||||
signal,
|
||||
limit=100,
|
||||
filter_expression=expression,
|
||||
order=[build_order_by("timestamp", "asc")],
|
||||
select_fields=[{"name": identity_field}],
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
# Sets keep the assertion stable when the shared stack is reused and older
|
||||
# rows with the same identities remain.
|
||||
return {name for name in get_column_data_from_response(response.json(), identity_column) if name.startswith(PREFIX)}
|
||||
162
tests/integration/tests/semconvfamilies/01_family_matrix.py
Normal file
@@ -0,0 +1,162 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.querier import (
|
||||
RequestType,
|
||||
build_aggregation,
|
||||
build_group_by_field,
|
||||
build_order_by,
|
||||
build_raw_query,
|
||||
build_traces_scalar_query,
|
||||
get_column_data_from_response,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.semconvfamilies import (
|
||||
BOTH,
|
||||
CURRENT_KEY,
|
||||
NEITHER,
|
||||
NEW,
|
||||
OLD,
|
||||
OLD_KEY,
|
||||
PREFIX,
|
||||
fleet_identities,
|
||||
)
|
||||
|
||||
FILTER_MATRIX = [
|
||||
pytest.param("{key} = 'production'", {OLD, NEW}, id="eq_matches_either_spelling"),
|
||||
pytest.param("{key} = 'staging'", {BOTH}, id="eq_current_wins_on_conflict"),
|
||||
pytest.param("{key} != 'production'", {BOTH, NEITHER}, id="neq_keeps_keyless_and_conflict"),
|
||||
pytest.param("{key} IN ['production', 'staging']", {OLD, NEW, BOTH}, id="in_matches_merged_value"),
|
||||
pytest.param("{key} NOT IN ['production']", {BOTH, NEITHER}, id="not_in_keeps_keyless"),
|
||||
pytest.param("{key} LIKE '%prod%'", {OLD, NEW}, id="like_matches_merged_value"),
|
||||
pytest.param("{key} EXISTS", {OLD, NEW, BOTH}, id="exists_is_any_member"),
|
||||
pytest.param("{key} NOT EXISTS", {NEITHER}, id="not_exists_is_no_member"),
|
||||
pytest.param("{key} != 'production' AND {key} EXISTS", {BOTH}, id="neq_composed_with_exists"),
|
||||
]
|
||||
|
||||
LITERAL_MATRIX = [
|
||||
pytest.param("{key} = 'production'", {NEW}, id="literal_eq_reads_one_spelling"),
|
||||
pytest.param("{key} != 'production'", {OLD, BOTH, NEITHER}, id="literal_neq_reads_one_spelling"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expression_template,expected", FILTER_MATRIX)
|
||||
@pytest.mark.parametrize("requested_key", [CURRENT_KEY, OLD_KEY], ids=["current", "old"])
|
||||
@pytest.mark.parametrize("context", ["resource", "attribute"])
|
||||
def test_family_filters(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
family_fleet: datetime,
|
||||
context: str,
|
||||
requested_key: str,
|
||||
expression_template: str,
|
||||
expected: set[str],
|
||||
) -> None:
|
||||
"""The result set is a property of the family, not of the requested spelling."""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
expression = expression_template.format(key=f"{context}.{requested_key}")
|
||||
assert fleet_identities(signoz, token, family_fleet, expression) == expected, expression
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expression_template,expected", LITERAL_MATRIX)
|
||||
def test_flag_off_stays_literal(
|
||||
signoz_families_off: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
family_fleet: datetime,
|
||||
expression_template: str,
|
||||
expected: set[str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
expression = expression_template.format(key=f"resource.{CURRENT_KEY}")
|
||||
assert fleet_identities(signoz_families_off, token, family_fleet, expression) == expected, expression
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expression_template,expected", LITERAL_MATRIX)
|
||||
def test_logs_stay_literal_with_flag_on(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
family_fleet: datetime,
|
||||
expression_template: str,
|
||||
expected: set[str],
|
||||
) -> None:
|
||||
"""Only traces have family support today."""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
expression = expression_template.format(key=f"resource.{CURRENT_KEY}")
|
||||
assert fleet_identities(signoz, token, family_fleet, expression, signal="logs") == expected, expression
|
||||
|
||||
|
||||
def test_group_by_merges_and_echoes_requested_spelling(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
family_fleet: datetime,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((family_fleet - timedelta(minutes=2)).timestamp() * 1000),
|
||||
end_ms=int((family_fleet + timedelta(minutes=1)).timestamp() * 1000),
|
||||
request_type=RequestType.SCALAR,
|
||||
queries=[
|
||||
build_traces_scalar_query(
|
||||
[build_aggregation("count()")],
|
||||
filter_expression=f"service.name LIKE '{PREFIX}%'",
|
||||
group_by=[build_group_by_field(CURRENT_KEY, "string", "resource")],
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
result = response.json()["data"]["data"]["results"][0]
|
||||
group_column = result["columns"][0]
|
||||
assert group_column["name"] == CURRENT_KEY, group_column
|
||||
assert group_column["columnType"] == "group", group_column
|
||||
|
||||
groups = {row[0] for row in result["data"]}
|
||||
assert {"production", "staging"}.issubset(groups), groups
|
||||
assert None in groups, groups
|
||||
|
||||
|
||||
def test_bare_name_prefers_resource_and_warns(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
family_fleet: datetime,
|
||||
) -> None:
|
||||
"""Both contexts carry the family, so a bare name is ambiguous: resolution
|
||||
warns and keeps the resource side."""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((family_fleet - timedelta(minutes=2)).timestamp() * 1000),
|
||||
end_ms=int((family_fleet + timedelta(minutes=1)).timestamp() * 1000),
|
||||
request_type=RequestType.RAW,
|
||||
queries=[
|
||||
build_raw_query(
|
||||
"A",
|
||||
"traces",
|
||||
limit=100,
|
||||
filter_expression=f"{CURRENT_KEY} = 'production'",
|
||||
order=[build_order_by("timestamp", "asc")],
|
||||
select_fields=[{"name": "span.name"}],
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
matched = {name for name in get_column_data_from_response(response.json(), "name") if name.startswith(PREFIX)}
|
||||
assert matched == {OLD, NEW}
|
||||
|
||||
warning = response.json()["data"].get("warning") or {}
|
||||
messages = " ".join(entry.get("message", "") for entry in warning.get("warnings", []))
|
||||
assert "ambiguous" in messages.lower(), messages
|
||||
0
tests/integration/tests/semconvfamilies/__init__.py
Normal file
55
tests/integration/tests/semconvfamilies/conftest.py
Normal file
@@ -0,0 +1,55 @@
|
||||
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_semconv_families(
|
||||
network: Network,
|
||||
zeus: types.TestContainerDocker,
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.SigNoz:
|
||||
return create_signoz(
|
||||
network=network,
|
||||
zeus=zeus,
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz-semconv-families",
|
||||
env_overrides={
|
||||
"SIGNOZ_FLAGGER_CONFIG_BOOLEAN_RESOLVE__SEMCONV__FAMILIES": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="signoz_families_off", scope="package")
|
||||
def signoz_families_off(
|
||||
network: Network,
|
||||
zeus: types.TestContainerDocker,
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.SigNoz:
|
||||
"""Shares the sqlstore and clickhouse with the flag-on instance, so the
|
||||
same admin token and seeded rows work."""
|
||||
return create_signoz(
|
||||
network=network,
|
||||
zeus=zeus,
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz-semconv-families-off",
|
||||
env_overrides={},
|
||||
)
|
||||