Compare commits

..

15 Commits

Author SHA1 Message Date
Shubham Dubey
730986b719 chore(onboardingv2/config): python traces changes
Signed-off-by: Shubham Dubey <shubham@signoz.io>
2025-12-16 19:08:06 +05:30
Nikhil Mantri
497972f23c chore(metrics-explorer): address follow-up comments (#9730) 2025-12-15 14:59:30 +05:30
swapnil-signoz
a9e30919d1 Refactor/aws api gateway dashboard (#9763)
* refactor: updating api gateway dashboard to support multiple types of APIs i.e. REST, HTTP and Websocket.
2025-12-15 14:34:39 +05:30
Ishan
925c4c4a3d fix: UI/UX fixes on Global Actions (CMD / CTRL + K) (#9739)
* feat: command K palette , removed kbar

* chore: updated cmdk for login checks and icons

* feat: updated icons and test cases

* adding more llm monitoring sources to onbaording(frontend) (#9623)

* feat: code update, PR comment fix, package.json update

* feat: code update, removed expand icon, moved keyboard func

* feat: css variable update

* feat: removed kbar from applayout

* feat: updated cursor bot comments

* feat: updated cursor bot and test case file

* feat: scss formatted

* feat: deleted unwanted merge change

---------

Co-authored-by: gkarthi-signoz <goutham@signoz.io>
Co-authored-by: Aditya Singh <adityasinghssj1@gmail.com>
2025-12-15 08:41:00 +05:30
Piyush Singariya
e66bfe5961 feat(JSON): JSON Body Metadata (#9593)
* feat: json Body Keys

* feat: telemetry types

* feat: change ExtractBodyPaths

* chore: minor comment change

* chore: func rename, file rename

* chore: change table names

* chore: reflect changes from the overhaul

* test: fixing test 1

* fix: test TestQueryToKeys

* fix: test TestPrepareLogsQuery

* chore: remove db

* chore: go mod

* chore: changes based on review

* chore: changes based on review

* fix: in LIKE operation

* chore: addressed few changes

* revert: test file

* fix: comparison fix

* test: add TestBuildListLogsJSONIndexesQuery

* fix: in test TestBuildListLogsJSONIndexesQuery

* fix: pull promoted paths in single db call

* fix: reducing db calls

* test: fix TestBuildListLogsJSONIndexesQuery

* fix: test TestConditionForJSONBodySearch

* fix: lint try 1

* chore: review changes based on cursor

* fix: use enums only

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2025-12-09 20:47:26 +07:00
Nikhil Mantri
42943f72b7 chore(metric-metadata): do not delete rows, keep inserting, pick latest 2025-12-09 05:46:48 +00:00
Nikhil Mantri
7a72a209e5 chore: metric highlights API for detailed view (#9679) 2025-12-09 00:09:23 +05:30
Karan Balani
44f00943a8 fix: tokenizer cache ttls and guardrails for config (#9776) 2025-12-05 11:01:05 +05:30
Nikhil Mantri
8867e1ef38 chore: metric Metadata Seperate Attributes API (#9622) 2025-12-04 19:38:37 +00:00
Abhi kumar
c08e520941 chore: removed alertRuleProvider from global state (#9648) 2025-12-04 10:27:50 +00:00
Ishan
139cc4452d style: metrics custom function css overflow issues (#9660)
* style: metrics custom function css overflow issues

* feat: add support for recovery threshold (#9428)

* feat: created common component for overflowing input tooltip

* feat: updated function.tsx to have useMemo for debounce

* feat: removed unwanted useEffect and moved inline css to separate file

* feat: re-applied useEffect due to css issues

* feat: removed inline styling

* feat: updated mirror ref to be in common component along with css updates

* feat: reverted prom_rule.go

* feat: code cleanup - input ref/forwardref cleanup

* feat: code cleanup - updated test file

* feat: extracted mirror-ref outside of tooltip

* feat: removed unwanted css

* feat: code optmized

* feat: test file updated

* feat: snapshot update

---------

Co-authored-by: Ishan Uniyal <ishan@Ishans-MacBook-Pro.local>
Co-authored-by: Abhishek Kumar Singh <hritik6058@gmail.com>
2025-12-04 15:04:39 +05:30
Nityananda Gohain
2f3baeb302 fix: fix third party api filtering (#9770) 2025-12-03 23:36:05 +05:30
Abhishek Kumar Singh
3d42b0058e chore: Query filter extraction API (#9617) 2025-12-03 13:13:32 +00:00
Srikanth Chekuri
ed70e3c5f5 chore: update .github/CODEOWNERS (#9759) 2025-12-03 11:06:30 +00:00
Ishan
7d6918f8b6 style: updated subdomain already exists UI error on 409 (#9661)
* style: updated subdomain already exists UI error on 409

* style: updated subdomain default message

---------

Co-authored-by: Ishan Uniyal <ishan@Ishans-MacBook-Pro.local>
2025-12-03 15:12:44 +05:30
91 changed files with 6167 additions and 2548 deletions

40
.github/CODEOWNERS vendored
View File

@@ -3,51 +3,11 @@
# that they own.
/frontend/ @YounixM @aks07
/frontend/src/container/MetricsApplication @srikanthccv
/frontend/src/container/NewWidget/RightContainer/types.ts @srikanthccv
# Onboarding
/frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.json @makeavish
/frontend/src/container/OnboardingV2Container/AddDataSource/AddDataSource.tsx @makeavish
# Dashboard, Alert, Metrics, Service Map, Services
/frontend/src/container/ListOfDashboard/ @srikanthccv
/frontend/src/container/NewDashboard/ @srikanthccv
/frontend/src/pages/DashboardsListPage/ @srikanthccv
/frontend/src/pages/DashboardWidget/ @srikanthccv
/frontend/src/pages/NewDashboard/ @srikanthccv
/frontend/src/providers/Dashboard/ @srikanthccv
# Alerts
/frontend/src/container/AlertHistory/ @srikanthccv
/frontend/src/container/AllAlertChannels/ @srikanthccv
/frontend/src/container/AnomalyAlertEvaluationView/ @srikanthccv
/frontend/src/container/CreateAlertChannels/ @srikanthccv
/frontend/src/container/CreateAlertRule/ @srikanthccv
/frontend/src/container/EditAlertChannels/ @srikanthccv
/frontend/src/container/FormAlertChannels/ @srikanthccv
/frontend/src/container/FormAlertRules/ @srikanthccv
/frontend/src/container/ListAlertRules/ @srikanthccv
/frontend/src/container/TriggeredAlerts/ @srikanthccv
/frontend/src/pages/AlertChannelCreate/ @srikanthccv
/frontend/src/pages/AlertDetails/ @srikanthccv
/frontend/src/pages/AlertHistory/ @srikanthccv
/frontend/src/pages/AlertList/ @srikanthccv
/frontend/src/pages/CreateAlert/ @srikanthccv
/frontend/src/providers/Alert.tsx @srikanthccv
# Metrics
/frontend/src/container/MetricsExplorer/ @srikanthccv
/frontend/src/pages/MetricsApplication/ @srikanthccv
/frontend/src/pages/MetricsExplorer/ @srikanthccv
# Services and Service Map
/frontend/src/container/ServiceApplication/ @srikanthccv
/frontend/src/container/ServiceTable/ @srikanthccv
/frontend/src/pages/Services/ @srikanthccv
/frontend/src/pages/ServiceTopLevelOperations/ @srikanthccv
/frontend/src/container/Home/Services/ @srikanthccv
/deploy/ @SigNoz/devops
.github @SigNoz/devops

View File

@@ -19,6 +19,7 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
@@ -60,6 +61,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
FieldsAPI: fields.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.TelemetryStore),
Signoz: signoz,
QuerierAPI: querierAPI.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.Querier, signoz.Analytics),
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
})
if err != nil {

View File

@@ -48,6 +48,7 @@
"@signozhq/calendar": "0.0.0",
"@signozhq/callout": "0.0.2",
"@signozhq/checkbox": "0.0.2",
"@signozhq/command": "0.0.0",
"@signozhq/design-tokens": "1.1.4",
"@signozhq/input": "0.0.2",
"@signozhq/popover": "0.0.0",
@@ -104,7 +105,6 @@
"i18next-http-backend": "^1.3.2",
"jest": "^27.5.1",
"js-base64": "^3.7.2",
"kbar": "0.1.0-beta.48",
"less": "^4.1.2",
"less-loader": "^10.2.0",
"lodash-es": "^4.17.21",

View File

@@ -4,7 +4,7 @@ import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import AppLoading from 'components/AppLoading/AppLoading';
import KBarCommandPalette from 'components/KBarCommandPalette/KBarCommandPalette';
import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import { FeatureKeys } from 'constants/features';
@@ -22,12 +22,11 @@ import { StatusCodes } from 'http-status-codes';
import history from 'lib/history';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import posthog from 'posthog-js';
import AlertRuleProvider from 'providers/Alert';
import { useAppContext } from 'providers/App/App';
import { IUser } from 'providers/App/types';
import { CmdKProvider } from 'providers/cmdKProvider';
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { KBarCommandPaletteProvider } from 'providers/KBarCommandPaletteProvider';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import { Suspense, useCallback, useEffect, useState } from 'react';
@@ -365,16 +364,15 @@ function App(): JSX.Element {
<ConfigProvider theme={themeConfig}>
<Router history={history}>
<CompatRouter>
<KBarCommandPaletteProvider>
<KBarCommandPalette />
<CmdKProvider>
<NotificationProvider>
<ErrorModalProvider>
{isLoggedInState && <CmdKPalette userRole={user.role} />}
<PrivateRoute>
<ResourceProvider>
<QueryBuilderProvider>
<DashboardProvider>
<KeyboardHotkeysProvider>
<AlertRuleProvider>
<AppLayout>
<PreferenceContextProvider>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
@@ -393,7 +391,6 @@ function App(): JSX.Element {
</Suspense>
</PreferenceContextProvider>
</AppLayout>
</AlertRuleProvider>
</KeyboardHotkeysProvider>
</DashboardProvider>
</QueryBuilderProvider>
@@ -401,7 +398,7 @@ function App(): JSX.Element {
</PrivateRoute>
</ErrorModalProvider>
</NotificationProvider>
</KBarCommandPaletteProvider>
</CmdKProvider>
</CompatRouter>
</Router>
</ConfigProvider>

View File

@@ -14,6 +14,8 @@ import '@signozhq/badge';
import '@signozhq/button';
import '@signozhq/calendar';
import '@signozhq/callout';
import '@signozhq/checkbox';
import '@signozhq/command';
import '@signozhq/design-tokens';
import '@signozhq/input';
import '@signozhq/popover';

View File

@@ -1,152 +0,0 @@
.kbar-command-palette__positioner {
position: fixed;
inset: 0;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(6px);
z-index: 50;
}
.kbar-command-palette__animator {
width: 100%;
max-width: 600px;
}
.kbar-command-palette__card {
background: var(--bg-ink-500);
color: var(--text-vanilla-100);
border-radius: 3px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
overflow: hidden;
display: flex;
flex-direction: column;
}
.kbar-command-palette__search {
padding: 12px 16px;
font-size: 13px;
border: none;
border-bottom: 1px solid var(--border-ink-200);
color: var(--text-vanilla-100);
outline: none;
background-color: var(--bg-ink-500);
}
.kbar-command-palette__section {
padding: 8px 16px 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-robin-500);
font-family: 'Inter', sans-serif;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.kbar-command-palette__item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
font-size: 13px;
cursor: pointer;
transition: background 0.15s ease;
}
.kbar-command-palette__item:hover,
.kbar-command-palette__item--active {
background: var(--bg-ink-400);
}
.kbar-command-palette__icon {
flex-shrink: 0;
width: 18px;
height: 18px;
color: #444;
}
.kbar-command-palette__shortcut {
margin-left: auto;
display: flex;
gap: 4px;
}
.kbar-command-palette__key {
padding: 2px 6px;
font-size: 12px;
border-radius: 4px;
background: var(--bg-ink-300);
color: var(--text-vanilla-300);
text-transform: uppercase;
font-family: 'Space Mono', monospace;
}
.kbar-command-palette__results-container {
div {
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-200);
}
}
}
.lightMode {
.kbar-command-palette__positioner {
background: rgba(0, 0, 0, 0.5);
}
.kbar-command-palette__card {
background: #fff;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}
.kbar-command-palette__search {
border-bottom: 1px solid #e5e5e5;
color: var(--text-ink-500);
background-color: var(--bg-vanilla-100);
}
.kbar-command-palette__item {
color: var(--text-ink-500);
}
.kbar-command-palette__item:hover,
.kbar-command-palette__item--active {
background: #f5f5f5;
}
.kbar-command-palette__icon {
color: #444;
}
.kbar-command-palette__key {
background: #eee;
color: #555;
}
.kbar-command-palette__results-container {
div {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-vanilla-300);
}
}
}
}

View File

@@ -1,69 +0,0 @@
import './KBarCommandPalette.scss';
import {
KBarAnimator,
KBarPortal,
KBarPositioner,
KBarResults,
KBarSearch,
useMatches,
} from 'kbar';
function Results(): JSX.Element {
const { results } = useMatches();
const renderResults = ({
item,
active,
}: {
item: any;
active: boolean;
}): JSX.Element =>
typeof item === 'string' ? (
<div className="kbar-command-palette__section">{item}</div>
) : (
<div
className={`kbar-command-palette__item ${
active ? 'kbar-command-palette__item--active' : ''
}`}
>
{item.icon}
<span>{item.name}</span>
{item.shortcut?.length ? (
<span className="kbar-command-palette__shortcut">
{item.shortcut.map((sc: string) => (
<kbd key={sc} className="kbar-command-palette__key">
{sc}
</kbd>
))}
</span>
) : null}
</div>
);
return (
<div className="kbar-command-palette__results-container">
<KBarResults items={results} onRender={renderResults} />
</div>
);
}
function KBarCommandPalette(): JSX.Element {
return (
<KBarPortal>
<KBarPositioner className="kbar-command-palette__positioner">
<KBarAnimator className="kbar-command-palette__animator">
<div className="kbar-command-palette__card">
<KBarSearch
className="kbar-command-palette__search"
placeholder="Search or type a command..."
/>
<Results />
</div>
</KBarAnimator>
</KBarPositioner>
</KBarPortal>
);
}
export default KBarCommandPalette;

View File

@@ -0,0 +1,16 @@
.overflow-input {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.overflow-input-mirror {
position: absolute;
visibility: hidden;
white-space: pre;
pointer-events: none;
font: inherit;
letter-spacing: inherit;
height: 0;
overflow: hidden;
}

View File

@@ -0,0 +1,119 @@
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import OverflowInputToolTip from './OverflowInputToolTip';
const TOOLTIP_INNER_SELECTOR = '.ant-tooltip-inner';
// Utility to mock overflow behaviour on inputs / elements.
// Stubs HTMLElement.prototype.clientWidth, scrollWidth and offsetWidth used by component.
function mockOverflow(clientWidth: number, scrollWidth: number): void {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
value: clientWidth,
});
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
configurable: true,
value: scrollWidth,
});
// mirror.offsetWidth is used to compute mirrorWidth = offsetWidth + 24.
// Use clientWidth so the mirror measurement aligns with the mocked client width in tests.
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true,
value: clientWidth,
});
}
function queryTooltipInner(): HTMLElement | null {
// find element that has role="tooltip" (could be the inner itself)
const tooltip = document.querySelector<HTMLElement>('[role="tooltip"]');
if (!tooltip) return document.querySelector(TOOLTIP_INNER_SELECTOR);
// if the role element is already the inner, return it; otherwise return its descendant
if (tooltip.classList.contains('ant-tooltip-inner')) return tooltip;
return (
(tooltip.querySelector(TOOLTIP_INNER_SELECTOR) as HTMLElement) ??
document.querySelector(TOOLTIP_INNER_SELECTOR)
);
}
describe('OverflowInputToolTip', () => {
beforeEach(() => {
jest.restoreAllMocks();
});
test('shows tooltip when content overflows and input is clamped at maxAutoWidth', async () => {
mockOverflow(150, 250); // clientWidth >= maxAutoWidth (150), scrollWidth > clientWidth
render(<OverflowInputToolTip value="Very long overflowing text" />);
await userEvent.hover(screen.getByRole('textbox'));
await waitFor(() => {
expect(queryTooltipInner()).not.toBeNull();
});
const tooltipInner = queryTooltipInner();
if (!tooltipInner) throw new Error('Tooltip inner not found');
expect(
within(tooltipInner).getByText('Very long overflowing text'),
).toBeInTheDocument();
});
test('does NOT show tooltip when content does not overflow', async () => {
mockOverflow(150, 100); // content fits (scrollWidth <= clientWidth)
render(<OverflowInputToolTip value="Short text" />);
await userEvent.hover(screen.getByRole('textbox'));
await waitFor(() => {
expect(queryTooltipInner()).toBeNull();
});
});
test('does NOT show tooltip when content overflows but input is NOT at maxAutoWidth', async () => {
mockOverflow(100, 250); // clientWidth < maxAutoWidth (150), scrollWidth > clientWidth
render(<OverflowInputToolTip value="Long but input not clamped" />);
await userEvent.hover(screen.getByRole('textbox'));
await waitFor(() => {
expect(queryTooltipInner()).toBeNull();
});
});
test('uncontrolled input allows typing', async () => {
render(<OverflowInputToolTip defaultValue="Init" />);
const input = screen.getByRole('textbox') as HTMLInputElement;
await userEvent.type(input, 'ABC');
expect(input).toHaveValue('InitABC');
});
test('disabled input never shows tooltip even if overflowing', async () => {
mockOverflow(150, 300);
render(<OverflowInputToolTip value="Overflowing!" disabled />);
await userEvent.hover(screen.getByRole('textbox'));
await waitFor(() => {
expect(queryTooltipInner()).toBeNull();
});
});
test('renders mirror span and input correctly (structural assertions instead of snapshot)', () => {
const { container } = render(<OverflowInputToolTip value="Snapshot" />);
const mirror = container.querySelector('.overflow-input-mirror');
const input = container.querySelector('input') as HTMLInputElement | null;
expect(mirror).toBeTruthy();
expect(mirror?.textContent).toBe('Snapshot');
expect(input).toBeTruthy();
expect(input?.value).toBe('Snapshot');
// width should be set inline (component calculates width on mount)
expect(input?.getAttribute('style')).toContain('width:');
});
});

View File

@@ -0,0 +1,73 @@
/* eslint-disable react/require-default-props */
/* eslint-disable react/jsx-props-no-spreading */
import './OverflowInputToolTip.scss';
import { Input, InputProps, InputRef, Tooltip } from 'antd';
import cx from 'classnames';
import { useEffect, useRef, useState } from 'react';
export interface OverflowTooltipInputProps extends InputProps {
tooltipPlacement?: 'top' | 'bottom' | 'left' | 'right';
minAutoWidth?: number;
maxAutoWidth?: number;
}
function OverflowInputToolTip({
value,
defaultValue,
onChange,
disabled = false,
tooltipPlacement = 'top',
className,
minAutoWidth = 70,
maxAutoWidth = 150,
...rest
}: OverflowTooltipInputProps): JSX.Element {
const inputRef = useRef<InputRef>(null);
const mirrorRef = useRef<HTMLSpanElement | null>(null);
const [isOverflowing, setIsOverflowing] = useState<boolean>(false);
useEffect(() => {
const input = inputRef.current?.input;
const mirror = mirrorRef.current;
if (!input || !mirror) {
setIsOverflowing(false);
return;
}
mirror.textContent = String(value ?? '') || ' ';
const mirrorWidth = mirror.offsetWidth + 24;
const newWidth = Math.min(maxAutoWidth, Math.max(minAutoWidth, mirrorWidth));
input.style.width = `${newWidth}px`;
// consider clamped when mirrorWidth reaches maxAutoWidth (allow -5px tolerance)
const isClamped = mirrorWidth >= maxAutoWidth - 5;
const overflow = input.scrollWidth > input.clientWidth && isClamped;
setIsOverflowing(overflow);
}, [value, disabled, minAutoWidth, maxAutoWidth]);
const tooltipTitle = !disabled && isOverflowing ? String(value ?? '') : '';
return (
<>
<span ref={mirrorRef} aria-hidden className="overflow-input-mirror" />
<Tooltip title={tooltipTitle} placement={tooltipPlacement}>
<Input
{...rest}
value={value}
defaultValue={defaultValue}
onChange={onChange}
disabled={disabled}
ref={inputRef}
className={cx('overflow-input', className)}
/>
</Tooltip>
</>
);
}
OverflowInputToolTip.displayName = 'OverflowInputToolTip';
export default OverflowInputToolTip;

View File

@@ -0,0 +1,3 @@
import OverflowInputToolTip from './OverflowInputToolTip';
export default OverflowInputToolTip;

View File

@@ -0,0 +1,208 @@
/**
* src/components/cmdKPalette/__test__/cmdkPalette.test.tsx
*/
import '@testing-library/jest-dom/extend-expect';
// ---- Mocks (must run BEFORE importing the component) ----
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { render, screen, userEvent } from 'tests/test-utils';
import { CmdKPalette } from '../cmdKPalette';
const HOME_LABEL = 'Go to Home';
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: jest.fn(),
});
});
afterAll(() => {
// restore
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete (HTMLElement.prototype as any).scrollIntoView;
});
// mock history.push / replace / go / location
jest.mock('lib/history', () => {
const location = { pathname: '/', search: '', hash: '' };
const stack: { pathname: string; search: string }[] = [
{ pathname: '/', search: '' },
];
const push = jest.fn((path: string) => {
const [rawPath, rawQuery] = path.split('?');
const pathname = rawPath || '/';
const search = path.includes('?') ? `?${rawQuery || ''}` : '';
location.pathname = pathname;
location.search = search;
stack.push({ pathname, search });
return undefined;
});
const replace = jest.fn((path: string) => {
const [rawPath, rawQuery] = path.split('?');
const pathname = rawPath || '/';
const search = path.includes('?') ? `?${rawQuery || ''}` : '';
location.pathname = pathname;
location.search = search;
if (stack.length > 0) {
stack[stack.length - 1] = { pathname, search };
} else {
stack.push({ pathname, search });
}
return undefined;
});
const listen = jest.fn();
const go = jest.fn((n: number) => {
if (n < 0 && stack.length > 1) {
stack.pop();
}
const top = stack[stack.length - 1] || { pathname: '/', search: '' };
location.pathname = top.pathname;
location.search = top.search;
});
return {
push,
replace,
listen,
go,
location,
__stack: stack,
};
});
// Mock ResizeObserver for Jest/jsdom
class ResizeObserver {
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
observe() {}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
unobserve() {}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
disconnect() {}
}
(global as any).ResizeObserver = ResizeObserver;
// mock cmdK provider hook (open state + setter)
const mockSetOpen = jest.fn();
jest.mock('providers/cmdKProvider', (): unknown => ({
useCmdK: (): {
open: boolean;
setOpen: jest.Mock;
openCmdK: jest.Mock;
closeCmdK: jest.Mock;
} => ({
open: true,
setOpen: mockSetOpen,
openCmdK: jest.fn(),
closeCmdK: jest.fn(),
}),
}));
// mock notifications hook
jest.mock('hooks/useNotifications', (): unknown => ({
useNotifications: (): { notifications: [] } => ({ notifications: [] }),
}));
// mock theme hook
jest.mock('hooks/useDarkMode', (): unknown => ({
useThemeMode: (): {
setAutoSwitch: jest.Mock;
setTheme: jest.Mock;
theme: string;
} => ({
setAutoSwitch: jest.fn(),
setTheme: jest.fn(),
theme: 'dark',
}),
}));
// mock updateUserPreference API and react-query mutation
jest.mock('api/v1/user/preferences/name/update', (): jest.Mock => jest.fn());
jest.mock('react-query', (): unknown => {
const actual = jest.requireActual('react-query');
return {
...actual,
useMutation: (): { mutate: jest.Mock } => ({ mutate: jest.fn() }),
};
});
// mock other side-effecty modules
jest.mock('api/common/logEvent', () => jest.fn());
jest.mock('api/browser/localstorage/set', () => jest.fn());
jest.mock('utils/error', () => ({ showErrorNotification: jest.fn() }));
// ---- Tests ----
describe('CmdKPalette', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('renders navigation and settings groups and items', () => {
render(<CmdKPalette userRole="ADMIN" />);
expect(screen.getByText('Navigation')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
expect(screen.getByText('Go to Dashboards')).toBeInTheDocument();
expect(screen.getByText('Open Sidebar')).toBeInTheDocument();
expect(screen.getByText('Switch to Dark Mode')).toBeInTheDocument();
});
test('clicking a navigation item calls history.push with correct route', async () => {
render(<CmdKPalette userRole="ADMIN" />);
const homeItem = screen.getByText(HOME_LABEL);
await userEvent.click(homeItem);
expect(history.push).toHaveBeenCalledWith(ROUTES.HOME);
});
test('role-based filtering (basic smoke)', () => {
render(<CmdKPalette userRole="VIEWER" />);
// VIEWER still sees basic navigation items
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
});
test('keyboard shortcut opens palette via setOpen', () => {
render(<CmdKPalette userRole="ADMIN" />);
const event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true });
window.dispatchEvent(event);
expect(mockSetOpen).toHaveBeenCalledWith(true);
});
test('items render with icons when provided', () => {
render(<CmdKPalette userRole="ADMIN" />);
const iconHolders = document.querySelectorAll('.cmd-item-icon');
expect(iconHolders.length).toBeGreaterThan(0);
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
});
test('closing the palette via handleInvoke sets open to false', async () => {
render(<CmdKPalette userRole="ADMIN" />);
const dashItem = screen.getByText('Go to Dashboards');
await userEvent.click(dashItem);
// last call from handleInvoke should set open to false
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});

View File

@@ -0,0 +1,55 @@
/* Overlay stays below content */
[data-slot='dialog-overlay'] {
z-index: 50;
}
/* Dialog content always above overlay */
[data-slot='dialog-content'] {
position: fixed;
z-index: 60;
}
.cmdk-section-heading [cmdk-group-heading] {
text-transform: uppercase;
color: var(--bg-slate-100);
}
/* Hide scrollbar but keep scroll */
.cmdk-list-scroll {
scrollbar-width: none; /* Firefox */
}
.cmdk-list-scroll::-webkit-scrollbar {
display: none; /* Chrome, Safari, Edge */
}
.cmdk-list-scroll {
-webkit-overflow-scrolling: touch;
}
.cmdk-input-wrapper {
margin-left: 8px;
}
.cmdk-item-light:hover {
cursor: pointer;
background-color: var(--bg-vanilla-200) !important;
}
.cmdk-item-light[data-selected='true'] {
background-color: var(--bg-vanilla-300) !important;
color: var(--bg-ink-500);
}
.cmdk-item {
cursor: pointer;
}
[cmdk-item] svg {
width: auto;
height: auto;
}
.cmd-item-icon {
margin-right: 8px;
}

View File

@@ -0,0 +1,336 @@
import './cmdKPalette.scss';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from '@signozhq/command';
import setLocalStorageApi from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import { AxiosError } from 'axios';
import ROUTES from 'constants/routes';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { useThemeMode } from 'hooks/useDarkMode';
import { THEME_MODE } from 'hooks/useDarkMode/constant';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import {
BellDot,
BugIcon,
DraftingCompass,
Expand,
HardDrive,
Home,
LayoutGrid,
ListMinus,
ScrollText,
Settings,
} from 'lucide-react';
import React, { useEffect } from 'react';
import { useMutation } from 'react-query';
import { UserPreference } from 'types/api/preferences/preference';
import { showErrorNotification } from 'utils/error';
import { useAppContext } from '../../providers/App/App';
import { useCmdK } from '../../providers/cmdKProvider';
type CmdAction = {
id: string;
name: string;
shortcut?: string[];
keywords?: string;
section?: string;
icon?: React.ReactNode;
roles?: UserRole[];
perform: () => void;
};
type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
export function CmdKPalette({
userRole,
}: {
userRole: UserRole;
}): JSX.Element | null {
const { open, setOpen } = useCmdK();
const { updateUserPreferenceInContext } = useAppContext();
const { notifications } = useNotifications();
const { setAutoSwitch, setTheme, theme } = useThemeMode();
const { mutate: updateUserPreferenceMutation } = useMutation(
updateUserPreference,
{
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
// toggle palette with ⌘/Ctrl+K
function handleGlobalCmdK(
e: KeyboardEvent,
setOpen: React.Dispatch<React.SetStateAction<boolean>>,
): void {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setOpen(true);
}
}
const cmdKEffect = (): void | (() => void) => {
const listener = (e: KeyboardEvent): void => {
handleGlobalCmdK(e, setOpen);
};
window.addEventListener('keydown', listener);
return (): void => {
window.removeEventListener('keydown', listener);
setOpen(false);
};
};
useEffect(cmdKEffect, [setOpen]);
function handleThemeChange(value: string): void {
logEvent('Account Settings: Theme Changed', { theme: value });
if (value === 'auto') {
setAutoSwitch(true);
} else {
setAutoSwitch(false);
setTheme(value);
}
}
function onClickHandler(key: string): void {
history.push(key);
}
function handleOpenSidebar(): void {
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'true');
const save = { name: USER_PREFERENCES.SIDENAV_PINNED, value: true };
updateUserPreferenceInContext(save as UserPreference);
updateUserPreferenceMutation({
name: USER_PREFERENCES.SIDENAV_PINNED,
value: true,
});
}
function handleCloseSidebar(): void {
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'false');
const save = { name: USER_PREFERENCES.SIDENAV_PINNED, value: false };
updateUserPreferenceInContext(save as UserPreference);
updateUserPreferenceMutation({
name: USER_PREFERENCES.SIDENAV_PINNED,
value: false,
});
}
const actions: CmdAction[] = [
{
id: 'home',
name: 'Go to Home',
shortcut: ['shift + h'],
keywords: 'home',
section: 'Navigation',
icon: <Home size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.HOME),
},
{
id: 'dashboards',
name: 'Go to Dashboards',
shortcut: ['shift + d'],
keywords: 'dashboards',
section: 'Navigation',
icon: <LayoutGrid size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.ALL_DASHBOARD),
},
{
id: 'services',
name: 'Go to Services',
shortcut: ['shift + s'],
keywords: 'services monitoring',
section: 'Navigation',
icon: <HardDrive size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.APPLICATION),
},
{
id: 'traces',
name: 'Go to Traces',
shortcut: ['shift + t'],
keywords: 'traces',
section: 'Navigation',
icon: <DraftingCompass size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.TRACES_EXPLORER),
},
{
id: 'logs',
name: 'Go to Logs',
shortcut: ['shift + l'],
keywords: 'logs',
section: 'Navigation',
icon: <ScrollText size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.LOGS),
},
{
id: 'alerts',
name: 'Go to Alerts',
shortcut: ['shift + a'],
keywords: 'alerts',
section: 'Navigation',
icon: <BellDot size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.LIST_ALL_ALERT),
},
{
id: 'exceptions',
name: 'Go to Exceptions',
shortcut: ['shift + e'],
keywords: 'exceptions errors',
section: 'Navigation',
icon: <BugIcon size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.ALL_ERROR),
},
{
id: 'messaging-queues',
name: 'Go to Messaging Queues',
shortcut: ['shift + m'],
keywords: 'messaging queues mq',
section: 'Navigation',
icon: <ListMinus size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.MESSAGING_QUEUES_OVERVIEW),
},
{
id: 'my-settings',
name: 'Go to Account Settings',
keywords: 'account settings',
section: 'Navigation',
icon: <Settings size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.MY_SETTINGS),
},
// Settings
{
id: 'open-sidebar',
name: 'Open Sidebar',
keywords: 'sidebar navigation menu expand',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleOpenSidebar(),
},
{
id: 'collapse-sidebar',
name: 'Collapse Sidebar',
keywords: 'sidebar navigation menu collapse',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleCloseSidebar(),
},
{
id: 'dark-mode',
name: 'Switch to Dark Mode',
keywords: 'theme dark mode appearance',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleThemeChange(THEME_MODE.DARK),
},
{
id: 'light-mode',
name: 'Switch to Light Mode [Beta]',
keywords: 'theme light mode appearance',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleThemeChange(THEME_MODE.LIGHT),
},
{
id: 'system-theme',
name: 'Switch to System Theme',
keywords: 'system theme appearance',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleThemeChange(THEME_MODE.SYSTEM),
},
];
// RBAC filter: show action if no roles set OR current user role is included
const permitted = actions.filter(
(a) => !a.roles || a.roles.includes(userRole),
);
// group permitted actions by section
const grouped: [string, CmdAction[]][] = ((): [string, CmdAction[]][] => {
const map = new Map<string, CmdAction[]>();
permitted.forEach((a) => {
const section = a.section ?? 'Other';
const existing = map.get(section);
if (existing) {
existing.push(a);
} else {
map.set(section, [a]);
}
});
return Array.from(map.entries());
})();
const handleInvoke = (action: CmdAction): void => {
try {
action.perform();
} catch (e) {
console.error('Error invoking action', e);
} finally {
setOpen(false);
}
};
return (
<CommandDialog open={open} onOpenChange={setOpen} position="top" offset={110}>
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
<CommandList className="cmdk-list-scroll">
<CommandEmpty>No results</CommandEmpty>
{grouped.map(([section, items]) => (
<CommandGroup
key={section}
heading={section}
className="cmdk-section-heading"
>
{items.map((it) => (
<CommandItem
key={it.id}
onSelect={(): void => handleInvoke(it)}
value={it.name}
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
>
<span className="cmd-item-icon">{it.icon}</span>
{it.name}
{it.shortcut && it.shortcut.length > 0 && (
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
)}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
);
}

View File

@@ -35,7 +35,6 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import useTabVisibility from 'hooks/useTabFocus';
import { useKBar } from 'kbar';
import history from 'lib/history';
import { isNull } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
@@ -186,19 +185,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const { query, disabled } = useKBar((state) => ({
disabled: state.disabled,
}));
// disable the kbar command palette when not logged in
useEffect(() => {
if (isLoggedIn) {
query.disable(false);
} else {
query.disable(true);
}
}, [isLoggedIn, query, disabled]);
const changelogForTenant = isCloudUserVal
? DeploymentType.CLOUD_ONLY
: DeploymentType.OSS_ONLY;

View File

@@ -266,7 +266,10 @@ export default function CustomDomainSettings(): JSX.Element {
<div className="custom-domain-settings-modal-error">
{updateDomainError.status === 409 ? (
<Alert
message="Youve already updated the custom domain once today. To make further changes, please contact our support team for assistance."
message={
(updateDomainError?.response?.data as { error?: string })?.error ||
'Youve already updated the custom domain once today. To make further changes, please contact our support team for assistance.'
}
type="warning"
className="update-limit-reached-error"
/>

View File

@@ -516,6 +516,8 @@
"falcon",
"fastapi",
"flask",
"celery",
"gunicorn",
"monitor python application",
"monitor python backend",
"opentelemetry python",
@@ -539,116 +541,6 @@
"tracing"
],
"id": "python",
"question": {
"desc": "Which Python framework do you use?",
"type": "select",
"entityID": "framework",
"options": [
{
"key": "flask",
"label": "Flask",
"imgUrl": "/Logos/flask.svg",
"link": "https://signoz.io/docs/instrumentation/opentelemetry-flask/",
"question": {
"desc": "What is your Environment?",
"type": "select",
"entityID": "environment",
"options": [
{
"key": "vm",
"label": "VM",
"imgUrl": "/Logos/vm.svg",
"link": "https://signoz.io/docs/instrumentation/opentelemetry-flask/"
},
{
"key": "k8s",
"label": "Kubernetes",
"imgUrl": "/Logos/kubernetes.svg",
"link": "https://signoz.io/docs/instrumentation/opentelemetry-flask/"
}
]
}
},
{
"key": "django",
"label": "Django",
"imgUrl": "/Logos/django.svg",
"link": "https://signoz.io/docs/instrumentation/opentelemetry-django/",
"question": {
"desc": "What is your Environment?",
"type": "select",
"entityID": "environment",
"options": [
{
"key": "vm",
"label": "VM",
"imgUrl": "/Logos/vm.svg",
"link": "https://signoz.io/docs/instrumentation/opentelemetry-django/"
},
{
"key": "k8s",
"label": "Kubernetes",
"imgUrl": "/Logos/kubernetes.svg",
"link": "https://signoz.io/docs/instrumentation/opentelemetry-django/"
}
]
}
},
{
"key": "fastapi",
"label": "FastAPI",
"imgUrl": "/Logos/fastapi.svg",
"link": "https://signoz.io/docs/instrumentation/opentelemetry-fastapi/",
"question": {
"desc": "What is your Environment?",
"type": "select",
"entityID": "environment",
"options": [
{
"key": "vm",
"label": "VM",
"imgUrl": "/Logos/vm.svg",
"link": "https://signoz.io/docs/instrumentation/opentelemetry-fastapi/"
},
{
"key": "k8s",
"label": "Kubernetes",
"imgUrl": "/Logos/kubernetes.svg",
"link": "https://signoz.io/docs/instrumentation/opentelemetry-fastapi/"
}
]
}
},
{
"key": "falcon",
"label": "Falcon",
"imgUrl": "/Logos/falcon.svg",
"link": "https://signoz.io/docs/instrumentation/opentelemetry-falcon/",
"question": {
"desc": "What is your Environment?",
"type": "select",
"entityID": "environment",
"options": [
{
"key": "vm",
"label": "VM",
"imgUrl": "/Logos/vm.svg",
"link": "https://signoz.io/docs/instrumentation/opentelemetry-falcon/"
},
{
"key": "k8s",
"label": "Kubernetes",
"imgUrl": "/Logos/kubernetes.svg",
"link": "https://signoz.io/docs/instrumentation/opentelemetry-falcon/"
}
]
}
},
{
"key": "others",
"label": "Others",
"imgUrl": "/Logos/python-others.svg",
"link": "https://signoz.io/docs/instrumentation/opentelemetry-python/",
"question": {
"desc": "What is your Environment?",
"type": "select",
@@ -665,9 +557,18 @@
"label": "Kubernetes",
"imgUrl": "/Logos/kubernetes.svg",
"link": "https://signoz.io/docs/instrumentation/opentelemetry-python/"
}
]
}
},
{
"key": "windows",
"label": "Windows",
"imgUrl": "/Logos/windows.svg",
"link": "https://signoz.io/docs/instrumentation/opentelemetry-python/"
},
{
"key": "docker",
"label": "Docker",
"imgUrl": "/Logos/docker.svg",
"link": "https://signoz.io/docs/instrumentation/opentelemetry-python/"
}
]
}

View File

@@ -1,6 +1,8 @@
/* eslint-disable react/jsx-props-no-spreading */
import { Button, Flex, Input, Select } from 'antd';
import { Button, Flex, Select } from 'antd';
import cx from 'classnames';
import OverflowInputToolTip from 'components/OverflowInputToolTip';
import {
logsQueryFunctionOptions,
metricQueryFunctionOptions,
@@ -9,6 +11,7 @@ import {
import { useIsDarkMode } from 'hooks/useDarkMode';
import { debounce, isNil } from 'lodash-es';
import { X } from 'lucide-react';
import { useMemo, useState } from 'react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { QueryFunction } from 'types/api/v5/queryRange';
import { DataSource, QueryFunctionsTypes } from 'types/common/queryBuilder';
@@ -47,9 +50,13 @@ export default function Function({
functionValue = funcData.args?.[0]?.value;
}
const debouncedhandleUpdateFunctionArgs = debounce(
handleUpdateFunctionArgs,
500,
const [value, setValue] = useState<string>(
functionValue !== undefined ? String(functionValue) : '',
);
const debouncedhandleUpdateFunctionArgs = useMemo(
() => debounce(handleUpdateFunctionArgs, 500),
[handleUpdateFunctionArgs],
);
// update the logic when we start supporting functions for traces
@@ -89,13 +96,18 @@ export default function Function({
/>
{showInput && (
<Input
className="query-function-value"
<OverflowInputToolTip
autoFocus
defaultValue={functionValue}
value={value}
onChange={(event): void => {
const newVal = event.target.value;
setValue(newVal);
debouncedhandleUpdateFunctionArgs(funcData, index, event.target.value);
}}
tooltipPlacement="top"
minAutoWidth={70}
maxAutoWidth={150}
className="query-function-value"
/>
)}

View File

@@ -99,7 +99,7 @@
}
.query-function-value {
width: 55px;
width: 70px;
border-left: 0;
background: var(--bg-ink-200);
border-radius: 0;

View File

@@ -68,6 +68,7 @@ import { USER_ROLES } from 'types/roles';
import { checkVersionState } from 'utils/app';
import { showErrorNotification } from 'utils/error';
import { useCmdK } from '../../providers/cmdKProvider';
import { routeConfig } from './config';
import { getQueryString } from './helper';
import {
@@ -120,6 +121,7 @@ function SortableFilter({ item }: { item: SidebarItem }): JSX.Element {
// eslint-disable-next-line sonarjs/cognitive-complexity
function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
const { openCmdK } = useCmdK();
const { pathname, search } = useLocation();
const { currentVersion, latestVersion, isCurrentVersionError } = useSelector<
AppState,
@@ -637,6 +639,8 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
} else {
history.push(settingsRoute);
}
} else if (item.key === 'quick-search') {
openCmdK();
} else if (item) {
onClickHandler(item?.key as string, event);
}

View File

@@ -26,6 +26,7 @@ import {
Receipt,
Route,
ScrollText,
Search,
Settings,
Slack,
Unplug,
@@ -188,6 +189,12 @@ export const primaryMenuItems: SidebarItem[] = [
icon: <Home size={16} />,
itemKey: 'home',
},
{
key: 'quick-search',
label: 'Search',
icon: <Search size={16} />,
itemKey: 'quick-search',
},
{
key: ROUTES.LIST_ALL_ALERT,
label: 'Alerts',

View File

@@ -1,3 +1,13 @@
import AlertRuleProvider from 'providers/Alert';
import AlertDetails from './AlertDetails';
export default AlertDetails;
function AlertDetailsPage(): JSX.Element {
return (
<AlertRuleProvider>
<AlertDetails />
</AlertRuleProvider>
);
}
export default AlertDetailsPage;

View File

@@ -1,229 +0,0 @@
import setLocalStorageApi from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import { AxiosError } from 'axios';
import ROUTES from 'constants/routes';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { useThemeMode } from 'hooks/useDarkMode';
import { THEME_MODE } from 'hooks/useDarkMode/constant';
import { useNotifications } from 'hooks/useNotifications';
import { KBarProvider } from 'kbar';
import history from 'lib/history';
import { useCallback } from 'react';
import { useMutation } from 'react-query';
import { UserPreference } from 'types/api/preferences/preference';
import { showErrorNotification } from 'utils/error';
import { useAppContext } from './App/App';
export function KBarCommandPaletteProvider({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
const { notifications } = useNotifications();
const { setAutoSwitch, setTheme } = useThemeMode();
const handleThemeChange = (value: string): void => {
logEvent('Account Settings: Theme Changed', {
theme: value,
});
if (value === 'auto') {
setAutoSwitch(true);
} else {
setAutoSwitch(false);
setTheme(value);
}
};
const onClickHandler = useCallback((key: string): void => {
history.push(key);
}, []);
const { updateUserPreferenceInContext } = useAppContext();
const { mutate: updateUserPreferenceMutation } = useMutation(
updateUserPreference,
{
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
const handleOpenSidebar = useCallback((): void => {
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'true');
// Update the context immediately
const save = {
name: USER_PREFERENCES.SIDENAV_PINNED,
value: true,
};
updateUserPreferenceInContext(save as UserPreference);
// Make the API call in the background
updateUserPreferenceMutation({
name: USER_PREFERENCES.SIDENAV_PINNED,
value: true,
});
}, [updateUserPreferenceInContext, updateUserPreferenceMutation]);
const handleCloseSidebar = useCallback((): void => {
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'false');
// Update the context immediately
const save = {
name: USER_PREFERENCES.SIDENAV_PINNED,
value: false,
};
updateUserPreferenceInContext(save as UserPreference);
// Make the API call in the background
updateUserPreferenceMutation({
name: USER_PREFERENCES.SIDENAV_PINNED,
value: false,
});
}, [updateUserPreferenceInContext, updateUserPreferenceMutation]);
const kbarActions = [
{
id: 'home',
name: 'Go to Home',
shortcut: ['shift + h'],
keywords: 'home',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.HOME);
},
},
{
id: 'dashboards',
name: 'Go to Dashboards',
shortcut: ['shift + d'],
keywords: 'dashboards',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.ALL_DASHBOARD);
},
},
{
id: 'services',
name: 'Go to Services',
shortcut: ['shift + s'],
keywords: 'services monitoring',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.APPLICATION);
},
},
{
id: 'traces',
name: 'Go to Traces',
shortcut: ['shift + t'],
keywords: 'traces',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.TRACES_EXPLORER);
},
},
{
id: 'logs',
name: 'Go to Logs',
shortcut: ['shift + l'],
keywords: 'logs',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.LOGS);
},
},
{
id: 'alerts',
name: 'Go to Alerts',
shortcut: ['shift + a'],
keywords: 'alerts',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.LIST_ALL_ALERT);
},
},
{
id: 'exceptions',
name: 'Go to Exceptions',
shortcut: ['shift + e'],
keywords: 'exceptions errors',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.ALL_ERROR);
},
},
{
id: 'messaging-queues',
name: 'Go to Messaging Queues',
shortcut: ['shift + m'],
keywords: 'messaging queues mq',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.MESSAGING_QUEUES_OVERVIEW);
},
},
{
id: 'my-settings',
name: 'Go to Account Settings',
keywords: 'account settings',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.MY_SETTINGS);
},
},
{
id: 'open-sidebar',
name: 'Open Sidebar',
keywords: 'sidebar navigation menu expand',
section: 'Settings',
perform: (): void => {
handleOpenSidebar();
},
},
{
id: 'collapse-sidebar',
name: 'Collapse Sidebar',
keywords: 'sidebar navigation menu collapse',
section: 'Settings',
perform: (): void => {
handleCloseSidebar();
},
},
{
id: 'dark-mode',
name: 'Switch to Dark Mode',
keywords: 'theme dark mode appearance',
section: 'Settings',
perform: (): void => {
handleThemeChange(THEME_MODE.DARK);
},
},
{
id: 'light-mode',
name: 'Switch to Light Mode [Beta]',
keywords: 'theme light mode appearance',
section: 'Settings',
perform: (): void => {
handleThemeChange(THEME_MODE.LIGHT);
},
},
{
id: 'system-theme',
name: 'Switch to System Theme',
keywords: 'system theme appearance',
section: 'Settings',
perform: (): void => {
handleThemeChange(THEME_MODE.SYSTEM);
},
},
];
return <KBarProvider actions={kbarActions}>{children}</KBarProvider>;
}

View File

@@ -0,0 +1,50 @@
import React, {
createContext,
ReactNode,
useContext,
useMemo,
useState,
} from 'react';
type CmdKContextType = {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
openCmdK: () => void;
closeCmdK: () => void;
};
const CmdKContext = createContext<CmdKContextType | null>(null);
export function CmdKProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
const [open, setOpen] = useState<boolean>(false);
function openCmdK(): void {
setOpen(true);
}
function closeCmdK(): void {
setOpen(false);
}
const value = useMemo<CmdKContextType>(
() => ({
open,
setOpen,
openCmdK,
closeCmdK,
}),
[open],
);
return <CmdKContext.Provider value={value}>{children}</CmdKContext.Provider>;
}
export function useCmdK(): CmdKContextType {
const ctx = useContext(CmdKContext);
if (!ctx) throw new Error('useCmdK must be used inside CmdKProvider');
return ctx;
}

View File

@@ -3583,7 +3583,7 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs@1.1.2":
"@radix-ui/react-compose-refs@1.1.2", "@radix-ui/react-compose-refs@^1.1.1":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz#a2c4c47af6337048ee78ff6dc0d090b390d2bb30"
integrity sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==
@@ -3600,6 +3600,26 @@
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.2.tgz#61628ef269a433382c364f6f1e3788a6dc213a36"
integrity sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==
"@radix-ui/react-dialog@^1.1.11", "@radix-ui/react-dialog@^1.1.6":
version "1.1.15"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz#1de3d7a7e9a17a9874d29c07f5940a18a119b632"
integrity sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==
dependencies:
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-dismissable-layer" "1.1.11"
"@radix-ui/react-focus-guards" "1.1.3"
"@radix-ui/react-focus-scope" "1.1.7"
"@radix-ui/react-id" "1.1.1"
"@radix-ui/react-portal" "1.1.9"
"@radix-ui/react-presence" "1.1.5"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-slot" "1.2.3"
"@radix-ui/react-use-controllable-state" "1.2.2"
aria-hidden "^1.2.4"
react-remove-scroll "^2.6.3"
"@radix-ui/react-direction@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.0.1.tgz#9cb61bf2ccf568f3421422d182637b7f47596c9b"
@@ -3657,7 +3677,7 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-id@1.1.1":
"@radix-ui/react-id@1.1.1", "@radix-ui/react-id@^1.1.0":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.1.tgz#1404002e79a03fe062b7e3864aa01e24bd1471f7"
integrity sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==
@@ -3726,7 +3746,7 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-portal@1.1.9", "@radix-ui/react-portal@^1.0.1":
"@radix-ui/react-portal@1.1.9":
version "1.1.9"
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz#14c3649fe48ec474ac51ed9f2b9f5da4d91c4472"
integrity sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==
@@ -3766,6 +3786,13 @@
dependencies:
"@radix-ui/react-slot" "1.2.3"
"@radix-ui/react-primitive@^2.0.2":
version "2.1.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz#2626ea309ebd63bf5767d3e7fc4081f81b993df0"
integrity sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==
dependencies:
"@radix-ui/react-slot" "1.2.4"
"@radix-ui/react-roving-focus@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz#e90c4a6a5f6ac09d3b8c1f5b5e81aab2f0db1974"
@@ -3797,6 +3824,13 @@
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-slot@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.4.tgz#63c0ba05fdf90cc49076b94029c852d7bac1fb83"
integrity sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-tabs@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz#993608eec55a5d1deddd446fa9978d2bc1053da2"
@@ -4048,11 +4082,6 @@
rc-resize-observer "^1.3.1"
rc-util "^5.38.0"
"@reach/observe-rect@^1.1.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.2.0.tgz#d7a6013b8aafcc64c778a0ccb83355a11204d3b2"
integrity sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==
"@react-dnd/asap@^5.0.1":
version "5.0.2"
resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-5.0.2.tgz#1f81f124c1cd6f39511c11a881cfb0f715343488"
@@ -4306,6 +4335,21 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/command@0.0.0":
version "0.0.0"
resolved "https://registry.yarnpkg.com/@signozhq/command/-/command-0.0.0.tgz#bd1e1cac7346e862dd61df64b756302e89e1a322"
integrity sha512-AwRYxZTi4o8SBOL4hmgcgbhCKXl2Qb/TUSLbSYEMFdiQSl5VYA8XZJv5fSYVMJkAIlOaHzFzR04XNEU7lZcBpw==
dependencies:
"@radix-ui/react-dialog" "^1.1.11"
"@radix-ui/react-icons" "^1.3.0"
"@radix-ui/react-slot" "^1.1.0"
class-variance-authority "^0.7.0"
clsx "^2.1.1"
cmdk "^1.1.1"
lucide-react "^0.445.0"
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/design-tokens@1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@signozhq/design-tokens/-/design-tokens-1.1.4.tgz#5d5de5bd9d19b6a3631383db015cc4b70c3f7661"
@@ -7397,6 +7441,16 @@ clsx@^2.1.1:
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
cmdk@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/cmdk/-/cmdk-1.1.1.tgz#b8524272699ccaa37aaf07f36850b376bf3d58e5"
integrity sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==
dependencies:
"@radix-ui/react-compose-refs" "^1.1.1"
"@radix-ui/react-dialog" "^1.1.6"
"@radix-ui/react-id" "^1.1.0"
"@radix-ui/react-primitive" "^2.0.2"
co@^4.6.0:
version "4.6.0"
resolved "https://registry.npmjs.org/co/-/co-4.6.0.tgz"
@@ -9439,11 +9493,6 @@ fast-diff@^1.1.2:
resolved "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz"
integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==
fast-equals@^2.0.3:
version "2.0.4"
resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-2.0.4.tgz#3add9410585e2d7364c2deeb6a707beadb24b927"
integrity sha512-caj/ZmjHljPrZtbzJ3kfH5ia/k4mTJe/qSiXAGzxZWRZgsgDV0cvNaQULqUX8t0/JVlzzEdYOwCN5DmzTxoD4w==
fast-glob@^3.2.11, fast-glob@^3.2.7, fast-glob@^3.3.0:
version "3.3.3"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818"
@@ -9797,11 +9846,6 @@ functions-have-names@^1.2.2, functions-have-names@^1.2.3:
resolved "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz"
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
fuse.js@^6.6.2:
version "6.6.2"
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.6.2.tgz#fe463fed4b98c0226ac3da2856a415576dc9a111"
integrity sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==
gensync@^1.0.0-beta.2:
version "1.0.0-beta.2"
resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz"
@@ -12018,17 +12062,6 @@ kapsule@1, kapsule@^1.14:
dependencies:
lodash-es "4"
kbar@0.1.0-beta.48:
version "0.1.0-beta.48"
resolved "https://registry.yarnpkg.com/kbar/-/kbar-0.1.0-beta.48.tgz#2db254cb2943f14200c5a5f47064135737527983"
integrity sha512-HD5A1dqfK6XGeoH4fRWTmRt4y76sDbtGxY4Dh2xNa5MYtvtKsqfz+nRZ0tKgcrjjGYN4rf5TLXMJuiE7Pb8rXg==
dependencies:
"@radix-ui/react-portal" "^1.0.1"
fast-equals "^2.0.3"
fuse.js "^6.6.2"
react-virtual "^2.8.2"
tiny-invariant "^1.2.0"
keyv@^4.0.0:
version "4.5.4"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
@@ -15596,13 +15629,6 @@ react-use@^17.3.2:
ts-easing "^0.2.0"
tslib "^2.1.0"
react-virtual@^2.8.2:
version "2.10.4"
resolved "https://registry.yarnpkg.com/react-virtual/-/react-virtual-2.10.4.tgz#08712f0acd79d7d6f7c4726f05651a13b24d8704"
integrity sha512-Ir6+oPQZTVHfa6+JL9M7cvMILstFZH/H3jqeYeKI4MSUX+rIruVwFC6nGVXw9wqAw8L0Kg2KvfXxI85OvYQdpQ==
dependencies:
"@reach/observe-rect" "^1.1.0"
react-virtuoso@4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.0.3.tgz#0dc8b10978095852d985b064157639b9fb9d9b1e"
@@ -17317,11 +17343,6 @@ tiny-invariant@^1.0.2, tiny-invariant@^1.0.6:
resolved "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz"
integrity sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==
tiny-invariant@^1.2.0:
version "1.3.3"
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==
tiny-warning@^1.0.0:
version "1.0.3"
resolved "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz"

9
go.mod
View File

@@ -8,7 +8,7 @@ require (
github.com/ClickHouse/clickhouse-go/v2 v2.40.1
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd
github.com/SigNoz/signoz-otel-collector v0.129.4
github.com/SigNoz/signoz-otel-collector v0.129.10-rc.9
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/cespare/xxhash/v2 v2.3.0
@@ -86,12 +86,19 @@ require (
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/redis/go-redis/extra/rediscmd/v9 v9.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 // indirect
go.opentelemetry.io/collector/config/configretry v1.34.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

16
go.sum
View File

@@ -106,8 +106,8 @@ github.com/SigNoz/expr v1.17.7-beta h1:FyZkleM5dTQ0O6muQfwGpoH5A2ohmN/XTasRCO72g
github.com/SigNoz/expr v1.17.7-beta/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd h1:Bk43AsDYe0fhkbj57eGXx8H3ZJ4zhmQXBnrW523ktj8=
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd/go.mod h1:nxRcH/OEdM8QxzH37xkGzomr1O0JpYBRS6pwjsWW6Pc=
github.com/SigNoz/signoz-otel-collector v0.129.4 h1:DGDu9y1I1FU+HX4eECPGmfhnXE4ys4yr7LL6znbf6to=
github.com/SigNoz/signoz-otel-collector v0.129.4/go.mod h1:xyR+coBzzO04p6Eu+ql2RVYUl/jFD+8hD9lArcc9U7g=
github.com/SigNoz/signoz-otel-collector v0.129.10-rc.9 h1:WmYDSSwzyW2yiJ3tPq5AFdjsrz3NBdtPkygtFKOsACw=
github.com/SigNoz/signoz-otel-collector v0.129.10-rc.9/go.mod h1:4eJCRUd/P4OiCHXvGYZK8q6oyBVGJFVj/G6qKSoN/TQ=
github.com/Yiling-J/theine-go v0.6.2 h1:1GeoXeQ0O0AUkiwj2S9Jc0Mzx+hpqzmqsJ4kIC4M9AY=
github.com/Yiling-J/theine-go v0.6.2/go.mod h1:08QpMa5JZ2pKN+UJCRrCasWYO1IKCdl54Xa836rpmDU=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
@@ -162,6 +162,12 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cactus/go-statsd-client/statsd v0.0.0-20200423205355-cb0885a1018c/go.mod h1:l/bIBLeOl9eX+wxJAzxS4TveKRtAqlyDpHjhkfO0MEI=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
@@ -178,6 +184,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
@@ -991,6 +999,8 @@ github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GH
github.com/trivago/tgo v1.0.7 h1:uaWH/XIy9aWYWpjm2CU3RpcqZXmX2ysQ9/Go+d9gyrM=
github.com/trivago/tgo v1.0.7/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkFnhc=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/uptrace/bun v1.2.9 h1:OOt2DlIcRUMSZPr6iXDFg/LaQd59kOxbAjpIVHddKRs=
github.com/uptrace/bun v1.2.9/go.mod h1:r2ZaaGs9Ru5bpGTr8GQfp8jp+TlCav9grYCPOu2CJSg=
github.com/uptrace/bun/dialect/pgdialect v1.2.9 h1:caf5uFbOGiXvadV6pA5gn87k0awFFxL1kuuY3SpxnWk=
@@ -1235,6 +1245,8 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=

View File

@@ -105,6 +105,12 @@ func (provider *provider) Set(ctx context.Context, orgID valuer.UUID, cacheKey s
return err
}
// To make sure ristretto does not go into no-op
if ttl < 0 {
provider.settings.Logger().WarnContext(ctx, "ttl is less than 0, setting it to 0")
ttl = 0
}
if cloneable, ok := data.(cachetypes.Cloneable); ok {
span.SetAttributes(attribute.Bool("memory.cloneable", true))
span.SetAttributes(attribute.Int64("memory.cost", 1))

View File

@@ -208,3 +208,13 @@ func WrapUnexpectedf(cause error, code Code, format string, args ...any) *base {
func NewUnexpectedf(code Code, format string, args ...any) *base {
return Newf(TypeInvalidInput, code, format, args...)
}
// WrapTimeoutf is a wrapper around Wrapf with TypeTimeout.
func WrapTimeoutf(cause error, code Code, format string, args ...any) *base {
return Wrapf(cause, TypeTimeout, code, format, args...)
}
// NewTimeoutf is a wrapper around Newf with TypeTimeout.
func NewTimeoutf(code Code, format string, args ...any) *base {
return Newf(TypeTimeout, code, format, args...)
}

View File

@@ -0,0 +1,35 @@
package metricsexplorer
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
)
type Config struct {
// TelemetryStore is the telemetrystore configuration
TelemetryStore TelemetryStoreConfig `mapstructure:"telemetrystore"`
}
type TelemetryStoreConfig struct {
// Threads is the number of threads to use for ClickHouse queries
Threads int `mapstructure:"threads"`
}
func NewConfigFactory() factory.ConfigFactory {
return factory.NewConfigFactory(factory.MustNewName("metricsexplorer"), newConfig)
}
func newConfig() factory.Config {
return Config{
TelemetryStore: TelemetryStoreConfig{
Threads: 8, // Default value
},
}
}
func (c Config) Validate() error {
if c.TelemetryStore.Threads <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metricsexplorer.telemetrystore.threads must be positive, got %d", c.TelemetryStore.Threads)
}
return nil
}

View File

@@ -96,7 +96,6 @@ func (h *handler) UpdateMetricMetadata(rw http.ResponseWriter, req *http.Request
// Set metric name from URL path
in.MetricName = metricName
orgID := valuer.MustNewUUID(claims.OrgID)
err = h.module.UpdateMetricMetadata(req.Context(), orgID, &in)
@@ -137,3 +136,47 @@ func (h *handler) GetMetricMetadata(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, metadata)
}
func (h *handler) GetMetricHighlights(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
metricName := strings.TrimSpace(req.URL.Query().Get("metricName"))
if metricName == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName query parameter is required"))
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
highlights, err := h.module.GetMetricHighlights(req.Context(), orgID, metricName)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, highlights)
}
func (h *handler) GetMetricAttributes(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
var in metricsexplorertypes.MetricAttributesRequest
if err := binding.JSON.BindBody(req.Body, &in); err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
out, err := h.module.GetMetricAttributes(req.Context(), orgID, &in)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, out)
}

View File

@@ -21,7 +21,7 @@ func generateMetricMetadataCacheKey(metricName string) string {
func getStatsOrderByColumn(order *qbtypes.OrderBy) (string, string, error) {
if order == nil {
return sqlColumnTimeSeries, qbtypes.OrderDirectionDesc.StringValue(), nil
return sqlColumnSamples, qbtypes.OrderDirectionDesc.StringValue(), nil
}
var columnName string

View File

@@ -2,6 +2,7 @@ package implmetricsexplorer
import (
"context"
"database/sql"
"fmt"
"log/slog"
"strings"
@@ -14,12 +15,14 @@ import (
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/metricsexplorertypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
sqlbuilder "github.com/huandu/go-sqlbuilder"
"golang.org/x/sync/errgroup"
)
type module struct {
@@ -29,10 +32,11 @@ type module struct {
condBuilder qbtypes.ConditionBuilder
logger *slog.Logger
cache cache.Cache
config metricsexplorer.Config
}
// NewModule constructs the metrics module with the provided dependencies.
func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetrytypes.MetadataStore, cache cache.Cache, providerSettings factory.ProviderSettings) metricsexplorer.Module {
func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetrytypes.MetadataStore, cache cache.Cache, providerSettings factory.ProviderSettings, cfg metricsexplorer.Config) metricsexplorer.Module {
fieldMapper := telemetrymetrics.NewFieldMapper()
condBuilder := telemetrymetrics.NewConditionBuilder(fieldMapper)
return &module{
@@ -42,6 +46,7 @@ func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetr
logger: providerSettings.Logger,
telemetryMetadataStore: telemetryMetadataStore,
cache: cache,
config: cfg,
}
}
@@ -94,7 +99,6 @@ func (m *module) GetStats(ctx context.Context, orgID valuer.UUID, req *metricsex
}, nil
}
// GetTreemap will return metrics treemap information once implemented.
func (m *module) GetTreemap(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.TreemapRequest) (*metricsexplorertypes.TreemapResponse, error) {
if err := req.Validate(); err != nil {
return nil, err
@@ -106,7 +110,7 @@ func (m *module) GetTreemap(ctx context.Context, orgID valuer.UUID, req *metrics
}
resp := &metricsexplorertypes.TreemapResponse{}
switch req.Treemap {
switch req.Mode {
case metricsexplorertypes.TreemapModeSamples:
entries, err := m.computeSamplesTreemap(ctx, req, filterWhereClause)
if err != nil {
@@ -190,6 +194,79 @@ func (m *module) UpdateMetricMetadata(ctx context.Context, orgID valuer.UUID, re
return nil
}
// GetMetricHighlights returns highlights for a metric including data points, last received, total time series, and active time series.
func (m *module) GetMetricHighlights(ctx context.Context, orgID valuer.UUID, metricName string) (*metricsexplorertypes.MetricHighlightsResponse, error) {
if metricName == "" {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "metric name is required")
}
var response metricsexplorertypes.MetricHighlightsResponse
g, gCtx := errgroup.WithContext(ctx)
// Fetch data points
g.Go(func() error {
dataPoints, err := m.getMetricDataPoints(gCtx, metricName)
if err != nil {
return err
}
response.DataPoints = dataPoints
return nil
})
// Fetch last received
g.Go(func() error {
lastReceived, err := m.getMetricLastReceived(gCtx, metricName)
if err != nil {
return err
}
response.LastReceived = lastReceived
return nil
})
// Fetch total time series
g.Go(func() error {
totalTimeSeries, err := m.getTotalTimeSeriesForMetricName(gCtx, metricName)
if err != nil {
return err
}
response.TotalTimeSeries = totalTimeSeries
return nil
})
// Fetch active time series (using 120 minutes as default duration)
g.Go(func() error {
activeTimeSeries, err := m.getActiveTimeSeriesForMetricName(gCtx, metricName, 120*time.Minute)
if err != nil {
return err
}
response.ActiveTimeSeries = activeTimeSeries
return nil
})
if err := g.Wait(); err != nil {
return nil, err
}
return &response, nil
}
func (m *module) GetMetricAttributes(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.MetricAttributesRequest) (*metricsexplorertypes.MetricAttributesResponse, error) {
if err := req.Validate(); err != nil {
return nil, err
}
attributes, err := m.fetchMetricAttributes(ctx, req.MetricName, req.Start, req.End)
if err != nil {
return nil, err
}
return &metricsexplorertypes.MetricAttributesResponse{
Attributes: attributes,
TotalKeys: int64(len(attributes)),
}, nil
}
func (m *module) fetchMetadataFromCache(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string]*metricsexplorertypes.MetricMetadata, []string) {
hits := make(map[string]*metricsexplorertypes.MetricMetadata)
misses := make([]string, 0)
@@ -231,8 +308,9 @@ func (m *module) fetchUpdatedMetadata(ctx context.Context, orgID valuer.UUID, me
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(ctx, query, args...)
rows, err := db.Query(valueCtx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch updated metrics metadata")
}
@@ -276,11 +354,11 @@ func (m *module) fetchTimeseriesMetadata(ctx context.Context, orgID valuer.UUID,
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"metric_name",
"ANY_VALUE(description) AS description",
"ANY_VALUE(type) AS metric_type",
"ANY_VALUE(unit) AS metric_unit",
"ANY_VALUE(temporality) AS temporality",
"ANY_VALUE(is_monotonic) AS is_monotonic",
"anyLast(description) AS description",
"anyLast(type) AS metric_type",
"anyLast(unit) AS metric_unit",
"anyLast(temporality) AS temporality",
"anyLast(is_monotonic) AS is_monotonic",
)
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4TableName))
sb.Where(sb.In("metric_name", args...))
@@ -288,8 +366,9 @@ func (m *module) fetchTimeseriesMetadata(ctx context.Context, orgID valuer.UUID,
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(ctx, query, args...)
rows, err := db.Query(valueCtx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch metrics metadata from timeseries table")
}
@@ -373,7 +452,7 @@ func (m *module) validateMetricLabels(ctx context.Context, req *metricsexplorert
return err
}
if !hasLabel {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metric '%s' cannot be set as histogram type", req.MetricName)
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metric '%s' cannot be set as histogram type: histogram metrics require the 'le' (less than or equal) label for bucket boundaries", req.MetricName)
}
}
@@ -383,7 +462,7 @@ func (m *module) validateMetricLabels(ctx context.Context, req *metricsexplorert
return err
}
if !hasLabel {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metric '%s' cannot be set as summary type", req.MetricName)
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metric '%s' cannot be set as summary type: summary metrics require the 'quantile' label for quantile values", req.MetricName)
}
}
@@ -400,9 +479,10 @@ func (m *module) checkForLabelInMetric(ctx context.Context, metricName string, l
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
var hasLabel bool
db := m.telemetryStore.ClickhouseDB()
err := db.QueryRow(ctx, query, args...).Scan(&hasLabel)
err := db.QueryRow(valueCtx, query, args...).Scan(&hasLabel)
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "error checking metric label %q", label)
}
@@ -428,8 +508,9 @@ func (m *module) insertMetricsMetadata(ctx context.Context, orgID valuer.UUID, r
query, args := ib.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
if err := db.Exec(ctx, query, args...); err != nil {
if err := db.Exec(valueCtx, query, args...); err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "failed to insert metrics metadata")
}
@@ -458,7 +539,6 @@ func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter,
return sqlbuilder.NewWhereClause(), nil
}
// TODO(nikhilmantri0902, srikanthccv): if this is the right way of dealing with whereClauseSelectors
whereClauseSelectors := querybuilder.QueryStringToKeysSelectors(expression)
for idx := range whereClauseSelectors {
whereClauseSelectors[idx].Signal = telemetrytypes.SignalMetrics
@@ -483,8 +563,8 @@ func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter,
FieldKeys: keys,
}
startNs := uint64(startMillis * 1_000_000)
endNs := uint64(endMillis * 1_000_000)
startNs := querybuilder.ToNanoSecs(uint64(startMillis))
endNs := querybuilder.ToNanoSecs(uint64(endMillis))
whereClause, err := querybuilder.PrepareWhereClause(expression, opts, startNs, endNs)
if err != nil {
@@ -581,8 +661,9 @@ func (m *module) fetchMetricsStatsWithSamples(
query, args := finalSB.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(ctx, query, args...)
rows, err := db.Query(valueCtx, query, args...)
if err != nil {
return nil, 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to execute metrics stats with samples query")
}
@@ -650,8 +731,9 @@ func (m *module) computeTimeseriesTreemap(ctx context.Context, req *metricsexplo
query, args := finalSB.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(ctx, query, args...)
rows, err := db.Query(valueCtx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to execute timeseries treemap query")
}
@@ -709,7 +791,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorer
)
sampleCountsSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, samplesTable))
sampleCountsSB.Where(sampleCountsSB.Between("unix_milli", req.Start, req.End))
sampleCountsSB.Where("metric_name IN (SELECT metric_name FROM __metric_candidates)")
sampleCountsSB.Where("metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates)")
if filterWhereClause != nil {
fingerprintSB := sqlbuilder.NewSelectBuilder()
@@ -719,7 +801,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorer
fingerprintSB.Where("NOT startsWith(metric_name, 'signoz')")
fingerprintSB.Where(fingerprintSB.E("__normalized", false))
fingerprintSB.AddWhereClause(sqlbuilder.CopyWhereClause(filterWhereClause))
fingerprintSB.Where("metric_name IN (SELECT metric_name FROM __metric_candidates)")
fingerprintSB.Where("metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates)")
fingerprintSB.GroupBy("fingerprint")
sampleCountsSB.Where("fingerprint IN (SELECT fingerprint FROM __filtered_fingerprints)")
@@ -749,8 +831,9 @@ func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorer
query, args := finalSB.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(ctx, query, args...)
rows, err := db.Query(valueCtx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to execute samples treemap query")
}
@@ -771,3 +854,138 @@ func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorer
return entries, nil
}
// getMetricDataPoints returns the total number of data points (samples) for a metric.
func (m *module) getMetricDataPoints(ctx context.Context, metricName string) (uint64, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("sum(count) AS data_points")
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4Agg30mTableName))
sb.Where(sb.E("metric_name", metricName))
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
db := m.telemetryStore.ClickhouseDB()
var dataPoints uint64
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
err := db.QueryRow(valueCtx, query, args...).Scan(&dataPoints)
if err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to get metrics data points")
}
return dataPoints, nil
}
// getMetricLastReceived returns the last received timestamp for a metric.
func (m *module) getMetricLastReceived(ctx context.Context, metricName string) (uint64, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("MAX(last_reported_unix_milli) AS last_received_time")
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
sb.Where(sb.E("metric_name", metricName))
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
db := m.telemetryStore.ClickhouseDB()
var lastReceived sql.NullInt64
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
err := db.QueryRow(valueCtx, query, args...).Scan(&lastReceived)
if err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to get last received timestamp")
}
if !lastReceived.Valid {
return 0, nil
}
return uint64(lastReceived.Int64), nil
}
// getTotalTimeSeriesForMetricName returns the total number of unique time series for a metric.
func (m *module) getTotalTimeSeriesForMetricName(ctx context.Context, metricName string) (uint64, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("uniq(fingerprint) AS time_series_count")
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV41weekTableName))
sb.Where(sb.E("metric_name", metricName))
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
db := m.telemetryStore.ClickhouseDB()
var timeSeriesCount uint64
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
err := db.QueryRow(valueCtx, query, args...).Scan(&timeSeriesCount)
if err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to get total time series count")
}
return timeSeriesCount, nil
}
// getActiveTimeSeriesForMetricName returns the number of active time series for a metric within the given duration.
func (m *module) getActiveTimeSeriesForMetricName(ctx context.Context, metricName string, duration time.Duration) (uint64, error) {
milli := time.Now().Add(-duration).UnixMilli()
sb := sqlbuilder.NewSelectBuilder()
sb.Select("uniq(fingerprint) AS active_time_series")
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4TableName))
sb.Where(sb.E("metric_name", metricName))
sb.Where(sb.GTE("unix_milli", milli))
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
db := m.telemetryStore.ClickhouseDB()
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
var activeTimeSeries uint64
err := db.QueryRow(valueCtx, query, args...).Scan(&activeTimeSeries)
if err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to get active time series count")
}
return activeTimeSeries, nil
}
func (m *module) fetchMetricAttributes(ctx context.Context, metricName string, start, end *int64) ([]metricsexplorertypes.MetricAttribute, error) {
// Build query using sqlbuilder
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"attr_name AS key",
"groupUniqArray(1000)(attr_string_value) AS values",
"uniq(attr_string_value) AS valueCount",
)
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
sb.Where(sb.E("metric_name", metricName))
sb.Where("NOT startsWith(attr_name, '__')")
// Add time range filtering if provided
if start != nil {
// Filter by start time: attributes that were active at or after start time
sb.Where(sb.GE("last_reported_unix_milli", *start))
}
if end != nil {
// Filter by end time: attributes that were active at or before end time
sb.Where(sb.LE("first_reported_unix_milli", *end))
}
sb.GroupBy("attr_name")
sb.OrderBy("valueCount DESC")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(valueCtx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch metric attributes")
}
defer rows.Close()
attributes := make([]metricsexplorertypes.MetricAttribute, 0)
for rows.Next() {
var attr metricsexplorertypes.MetricAttribute
if err := rows.Scan(&attr.Key, &attr.Values, &attr.ValueCount); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan metric attribute row")
}
attributes = append(attributes, attr)
}
if err := rows.Err(); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error iterating metric attribute rows")
}
return attributes, nil
}

View File

@@ -13,7 +13,9 @@ type Handler interface {
GetStats(http.ResponseWriter, *http.Request)
GetTreemap(http.ResponseWriter, *http.Request)
GetMetricMetadata(http.ResponseWriter, *http.Request)
GetMetricAttributes(http.ResponseWriter, *http.Request)
UpdateMetricMetadata(http.ResponseWriter, *http.Request)
GetMetricHighlights(http.ResponseWriter, *http.Request)
}
// Module represents the metrics module interface.
@@ -22,4 +24,6 @@ type Module interface {
GetTreemap(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.TreemapRequest) (*metricsexplorertypes.TreemapResponse, error)
GetMetricMetadataMulti(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string]*metricsexplorertypes.MetricMetadata, error)
UpdateMetricMetadata(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.UpdateMetricMetadataRequest) error
GetMetricHighlights(ctx context.Context, orgID valuer.UUID, metricName string) (*metricsexplorertypes.MetricHighlightsResponse, error)
GetMetricAttributes(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.MetricAttributesRequest) (*metricsexplorertypes.MetricAttributesResponse, error)
}

View File

@@ -2,11 +2,11 @@ package thirdpartyapi
import (
"fmt"
"github.com/SigNoz/signoz/pkg/types/thirdpartyapitypes"
"net"
"regexp"
"time"
"github.com/SigNoz/signoz/pkg/types/thirdpartyapitypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -287,11 +287,6 @@ func shouldIncludeRow(row *qbtypes.RawRow) bool {
return true
}
func containsKindStringOverride(expression string) bool {
kindStringPattern := regexp.MustCompile(`kind_string\s*[!=<>]+`)
return kindStringPattern.MatchString(expression)
}
func mergeGroupBy(base, additional []qbtypes.GroupByKey) []qbtypes.GroupByKey {
return append(base, additional...)
}
@@ -400,6 +395,8 @@ func buildRpsQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEn
}
func buildErrorQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEnvelope {
filter := buildBaseFilter(req.Filter)
filter.Expression = fmt.Sprintf("has_error = true AND (%s)", filter.Expression)
return qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -409,7 +406,7 @@ func buildErrorQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.Query
Aggregations: []qbtypes.TraceAggregation{
{Expression: "count()"},
},
Filter: buildErrorFilter(req.Filter),
Filter: filter,
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["server"], req.GroupBy),
},
}
@@ -526,25 +523,9 @@ func buildBaseFilter(additionalFilter *qbtypes.Filter) *qbtypes.Filter {
urlPathKeyLegacy, urlPathKey)
if additionalFilter != nil && additionalFilter.Expression != "" {
if containsKindStringOverride(additionalFilter.Expression) {
return &qbtypes.Filter{Expression: baseExpression}
}
// even if it contains kind_string we add with an AND so it doesn't matter if the user is overriding it.
baseExpression = fmt.Sprintf("(%s) AND (%s)", baseExpression, additionalFilter.Expression)
}
return &qbtypes.Filter{Expression: baseExpression}
}
func buildErrorFilter(additionalFilter *qbtypes.Filter) *qbtypes.Filter {
errorExpression := fmt.Sprintf("has_error = true AND (%s EXISTS OR %s EXISTS) AND kind_string = 'Client'",
urlPathKeyLegacy, urlPathKey)
if additionalFilter != nil && additionalFilter.Expression != "" {
if containsKindStringOverride(additionalFilter.Expression) {
return &qbtypes.Filter{Expression: errorExpression}
}
errorExpression = fmt.Sprintf("(%s) AND (%s)", errorExpression, additionalFilter.Expression)
}
return &qbtypes.Filter{Expression: errorExpression}
}

View File

@@ -6252,17 +6252,6 @@ LIMIT 40`, // added rand to get diff value every time we run this query
return fingerprints, nil
}
func (r *ClickHouseReader) DeleteMetricsMetadata(ctx context.Context, orgID valuer.UUID, metricName string) *model.ApiError {
delQuery := fmt.Sprintf(`ALTER TABLE %s.%s DELETE WHERE metric_name = ?;`, signozMetricDBName, signozUpdatedMetricsMetadataLocalTable)
valueCtx := context.WithValue(ctx, "clickhouse_max_threads", constants.MetricsExplorerClickhouseThreads)
err := r.db.Exec(valueCtx, delQuery, metricName)
if err != nil {
return &model.ApiError{Typ: "ClickHouseError", Err: err}
}
r.cache.Delete(ctx, orgID, constants.UpdatedMetricsMetadataCachePrefix+metricName)
return nil
}
func (r *ClickHouseReader) UpdateMetricsMetadata(ctx context.Context, orgID valuer.UUID, req *model.UpdateMetricsMetadata) *model.ApiError {
if req.MetricType == v3.MetricTypeHistogram {
labels := []string{"le"}
@@ -6292,10 +6281,7 @@ func (r *ClickHouseReader) UpdateMetricsMetadata(ctx context.Context, orgID valu
}
}
apiErr := r.DeleteMetricsMetadata(ctx, orgID, req.MetricName)
if apiErr != nil {
return apiErr
}
// Insert new metadata (keeping history of all updates)
insertQuery := fmt.Sprintf(`INSERT INTO %s.%s (metric_name, temporality, is_monotonic, type, description, unit, created_at)
VALUES ( ?, ?, ?, ?, ?, ?, ?);`, signozMetricDBName, signozUpdatedMetricsMetadataTable)
valueCtx := context.WithValue(ctx, "clickhouse_max_threads", constants.MetricsExplorerClickhouseThreads)
@@ -6364,9 +6350,19 @@ func (r *ClickHouseReader) GetUpdatedMetricsMetadata(ctx context.Context, orgID
var stillMissing []string
if len(missingMetrics) > 0 {
metricList := "'" + strings.Join(missingMetrics, "', '") + "'"
query := fmt.Sprintf(`SELECT metric_name, type, description, temporality, is_monotonic, unit
query := fmt.Sprintf(`SELECT
metric_name,
argMax(type, created_at) AS type,
argMax(description, created_at) AS description,
argMax(temporality, created_at) AS temporality,
argMax(is_monotonic, created_at) AS is_monotonic,
argMax(unit, created_at) AS unit
FROM %s.%s
WHERE metric_name IN (%s);`, signozMetricDBName, signozUpdatedMetricsMetadataTable, metricList)
WHERE metric_name IN (%s)
GROUP BY metric_name;`,
signozMetricDBName,
signozUpdatedMetricsMetadataTable,
metricList)
valueCtx := context.WithValue(ctx, "clickhouse_max_threads", constants.MetricsExplorerClickhouseThreads)
rows, err := r.db.Query(valueCtx, query)

View File

@@ -94,9 +94,21 @@
"unit": "Count",
"type": "Gauge"
},
{ "name": "aws_ApiGateway_Count_max", "unit": "Count", "type": "Gauge" },
{ "name": "aws_ApiGateway_Count_min", "unit": "Count", "type": "Gauge" },
{ "name": "aws_ApiGateway_Count_sum", "unit": "Count", "type": "Gauge" },
{
"name": "aws_ApiGateway_Count_max",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_Count_min",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_Count_sum",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_IntegrationLatency_count",
"unit": "Milliseconds",

View File

@@ -9,6 +9,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/thirdpartyapi"
"github.com/SigNoz/signoz/pkg/queryparser"
"io"
"math"
@@ -146,6 +147,8 @@ type APIHandler struct {
QuerierAPI *querierAPI.API
QueryParserAPI *queryparser.API
Signoz *signoz.SigNoz
}
@@ -176,6 +179,8 @@ type APIHandlerOpts struct {
QuerierAPI *querierAPI.API
QueryParserAPI *queryparser.API
Signoz *signoz.SigNoz
}
@@ -238,6 +243,7 @@ func NewAPIHandler(opts APIHandlerOpts) (*APIHandler, error) {
Signoz: opts.Signoz,
FieldsAPI: opts.FieldsAPI,
QuerierAPI: opts.QuerierAPI,
QueryParserAPI: opts.QueryParserAPI,
}
logsQueryBuilder := logsv4.PrepareLogsQuery
@@ -632,6 +638,8 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/span_percentile", am.ViewAccess(aH.Signoz.Handlers.SpanPercentile.GetSpanPercentileDetails)).Methods(http.MethodPost)
// Query Filter Analyzer api used to extract metric names and grouping columns from a query
router.HandleFunc("/api/v1/query_filter/analyze", am.ViewAccess(aH.QueryParserAPI.AnalyzeQueryFilter)).Methods(http.MethodPost)
}
func (ah *APIHandler) MetricExplorerRoutes(router *mux.Router, am *middleware.AuthZ) {
@@ -660,10 +668,12 @@ func (ah *APIHandler) MetricExplorerRoutes(router *mux.Router, am *middleware.Au
am.ViewAccess(ah.UpdateMetricsMetadata)).
Methods(http.MethodPost)
// v2 endpoints
router.HandleFunc("/api/v2/metrics/stats", am.ViewAccess(ah.Signoz.Handlers.Metrics.GetStats)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/metrics/treemap", am.ViewAccess(ah.Signoz.Handlers.Metrics.GetTreemap)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/metrics/metadata", am.ViewAccess(ah.Signoz.Handlers.Metrics.GetMetricMetadata)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/metrics/{metric_name}/metadata", am.ViewAccess(ah.Signoz.Handlers.Metrics.UpdateMetricMetadata)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/metrics/stats", am.ViewAccess(ah.Signoz.Handlers.MetricsExplorer.GetStats)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/metrics/treemap", am.ViewAccess(ah.Signoz.Handlers.MetricsExplorer.GetTreemap)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/metrics/attributes", am.ViewAccess(ah.Signoz.Handlers.MetricsExplorer.GetMetricAttributes)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/metrics/metadata", am.ViewAccess(ah.Signoz.Handlers.MetricsExplorer.GetMetricMetadata)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/metrics/{metric_name}/metadata", am.EditAccess(ah.Signoz.Handlers.MetricsExplorer.UpdateMetricMetadata)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/metric/highlights", am.ViewAccess(ah.Signoz.Handlers.MetricsExplorer.GetMetricHighlights)).Methods(http.MethodGet)
}
func Intersection(a, b []int) (c []int) {

View File

@@ -3,16 +3,15 @@ package metricsexplorer
import (
"encoding/json"
"fmt"
"github.com/gorilla/mux"
"net/http"
"strconv"
"github.com/gorilla/mux"
"github.com/SigNoz/signoz/pkg/query-service/constants"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/query-service/model/metrics_explorer"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
)
func ParseFilterKeySuggestions(r *http.Request) (*metrics_explorer.FilterKeyRequest, *model.ApiError) {

View File

@@ -10,6 +10,7 @@ import (
"slices"
"github.com/SigNoz/signoz/pkg/cache/memorycache"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/gorilla/handlers"
@@ -132,6 +133,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
FieldsAPI: fields.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.TelemetryStore),
Signoz: signoz,
QuerierAPI: querierAPI.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.Querier, signoz.Analytics),
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
})
if err != nil {
return nil, err

View File

@@ -127,7 +127,6 @@ type Reader interface {
GetInspectMetricsFingerprints(ctx context.Context, attributes []string, req *metrics_explorer.InspectMetricsRequest) ([]string, *model.ApiError)
GetInspectMetrics(ctx context.Context, req *metrics_explorer.InspectMetricsRequest, fingerprints []string) (*metrics_explorer.InspectMetricsResponse, *model.ApiError)
DeleteMetricsMetadata(ctx context.Context, orgID valuer.UUID, metricName string) *model.ApiError
UpdateMetricsMetadata(ctx context.Context, orgID valuer.UUID, req *model.UpdateMetricsMetadata) *model.ApiError
GetUpdatedMetricsMetadata(ctx context.Context, orgID valuer.UUID, metricNames ...string) (map[string]*model.UpdateMetricsMetadata, *model.ApiError)

View File

@@ -198,7 +198,6 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
FieldMapper: v.fieldMapper,
ConditionBuilder: v.conditionBuilder,
FullTextColumn: v.fullTextColumn,
JsonBodyPrefix: v.jsonBodyPrefix,
JsonKeyToKey: v.jsonKeyToKey,
}, 0, 0,
)

View File

@@ -0,0 +1,17 @@
package querybuilder
import (
"os"
)
var (
BodyJSONQueryEnabled = GetOrDefaultEnv("BODY_JSON_QUERY_ENABLED", "false") == "true"
)
func GetOrDefaultEnv(key string, fallback string) string {
v := os.Getenv(key)
if len(v) == 0 {
return fallback
}
return v
}

View File

@@ -7,7 +7,6 @@ import (
)
func TestQueryToKeys(t *testing.T) {
testCases := []struct {
query string
expectedKeys []telemetrytypes.FieldKeySelector
@@ -66,9 +65,9 @@ func TestQueryToKeys(t *testing.T) {
query: `body.user_ids[*] = 123`,
expectedKeys: []telemetrytypes.FieldKeySelector{
{
Name: "body.user_ids[*]",
Name: "user_ids[*]",
Signal: telemetrytypes.SignalUnspecified,
FieldContext: telemetrytypes.FieldContextUnspecified,
FieldContext: telemetrytypes.FieldContextBody,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
},

View File

@@ -162,7 +162,6 @@ func (b *resourceFilterStatementBuilder[T]) addConditions(
ConditionBuilder: b.conditionBuilder,
FieldKeys: keys,
FullTextColumn: b.fullTextColumn,
JsonBodyPrefix: b.jsonBodyPrefix,
JsonKeyToKey: b.jsonKeyToKey,
SkipFullTextFilter: true,
SkipFunctionCalls: true,

View File

@@ -33,7 +33,6 @@ type filterExpressionVisitor struct {
mainErrorURL string
builder *sqlbuilder.SelectBuilder
fullTextColumn *telemetrytypes.TelemetryFieldKey
jsonBodyPrefix string
jsonKeyToKey qbtypes.JsonKeyToFieldFunc
skipResourceFilter bool
skipFullTextFilter bool
@@ -53,7 +52,6 @@ type FilterExprVisitorOpts struct {
FieldKeys map[string][]*telemetrytypes.TelemetryFieldKey
Builder *sqlbuilder.SelectBuilder
FullTextColumn *telemetrytypes.TelemetryFieldKey
JsonBodyPrefix string
JsonKeyToKey qbtypes.JsonKeyToFieldFunc
SkipResourceFilter bool
SkipFullTextFilter bool
@@ -73,7 +71,6 @@ func newFilterExpressionVisitor(opts FilterExprVisitorOpts) *filterExpressionVis
fieldKeys: opts.FieldKeys,
builder: opts.Builder,
fullTextColumn: opts.FullTextColumn,
jsonBodyPrefix: opts.JsonBodyPrefix,
jsonKeyToKey: opts.JsonKeyToKey,
skipResourceFilter: opts.SkipResourceFilter,
skipFullTextFilter: opts.SkipFullTextFilter,
@@ -173,7 +170,7 @@ func PrepareWhereClause(query string, opts FilterExprVisitorOpts, startNs uint64
whereClause := sqlbuilder.NewWhereClause().AddWhereExpr(visitor.builder.Args, cond)
return &PreparedWhereClause{whereClause, visitor.warnings, visitor.mainWarnURL}, nil
return &PreparedWhereClause{WhereClause: whereClause, Warnings: visitor.warnings, WarningsDocURL: visitor.mainWarnURL}, nil
}
// Visit dispatches to the specific visit method based on node type
@@ -718,7 +715,7 @@ func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallCon
conds = append(conds, fmt.Sprintf("hasToken(LOWER(%s), LOWER(%s))", key.Name, v.builder.Var(value[0])))
} else {
// this is that all other functions only support array fields
if strings.HasPrefix(key.Name, v.jsonBodyPrefix) {
if key.FieldContext == telemetrytypes.FieldContextBody {
fieldName, _ = v.jsonKeyToKey(context.Background(), key, qbtypes.FilterOperatorUnknown, value)
} else {
// TODO(add docs for json body search)
@@ -809,10 +806,8 @@ func (v *filterExpressionVisitor) VisitValue(ctx *grammar.ValueContext) any {
// VisitKey handles field/column references
func (v *filterExpressionVisitor) VisitKey(ctx *grammar.KeyContext) any {
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(ctx.GetText())
keyName := strings.TrimPrefix(fieldKey.Name, v.jsonBodyPrefix)
keyName := fieldKey.Name
fieldKeysForName := v.fieldKeys[keyName]
@@ -846,11 +841,12 @@ func (v *filterExpressionVisitor) VisitKey(ctx *grammar.KeyContext) any {
// if there is a field with the same name as attribute/resource attribute
// Since it will ORed with the fieldKeysForName, it will not result empty
// when either of them have values
if strings.HasPrefix(fieldKey.Name, v.jsonBodyPrefix) && v.jsonBodyPrefix != "" {
if keyName != "" {
// Note: Skip this logic if body json query is enabled so we can look up the key inside fields
//
// TODO(Piyush): After entire migration this is supposed to be removed.
if !BodyJSONQueryEnabled && fieldKey.FieldContext == telemetrytypes.FieldContextBody {
fieldKeysForName = append(fieldKeysForName, &fieldKey)
}
}
if len(fieldKeysForName) == 0 {
// check if the key exists with {fieldContext}.{key}
@@ -860,7 +856,7 @@ func (v *filterExpressionVisitor) VisitKey(ctx *grammar.KeyContext) any {
return v.fieldKeys[keyWithContext]
}
if strings.HasPrefix(fieldKey.Name, v.jsonBodyPrefix) && v.jsonBodyPrefix != "" && keyName == "" {
if fieldKey.FieldContext == telemetrytypes.FieldContextBody && keyName == "" {
v.errors = append(v.errors, "missing key for body json search - expected key of the form `body.key` (ex: `body.status`)")
} else if !v.ignoreNotFoundKeys {
// TODO(srikanthccv): do we want to return an error here?

49
pkg/queryparser/api.go Normal file
View File

@@ -0,0 +1,49 @@
package queryparser
import (
"net/http"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/parsertypes"
)
type API struct {
queryParser QueryParser
settings factory.ProviderSettings
}
func NewAPI(settings factory.ProviderSettings, queryParser QueryParser) *API {
return &API{settings: settings, queryParser: queryParser}
}
// AnalyzeQueryFilter analyzes a query and extracts metric names and grouping columns
func (a *API) AnalyzeQueryFilter(w http.ResponseWriter, r *http.Request) {
// Limit request body size to 255 KB (CH query limit is 256 KB)
r.Body = http.MaxBytesReader(w, r.Body, 255*1024)
var req parsertypes.QueryFilterAnalyzeRequest
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
render.Error(w, err)
return
}
result, err := a.queryParser.AnalyzeQueryFilter(r.Context(), req.QueryType, req.Query)
if err != nil {
a.settings.Logger.ErrorContext(r.Context(), "failed to analyze query filter", "error", err)
render.Error(w, err)
return
}
// prepare the response
var resp parsertypes.QueryFilterAnalyzeResponse
for _, group := range result.GroupByColumns {
resp.Groups = append(resp.Groups, parsertypes.ColumnInfoResponse{
Name: group.Name,
Alias: group.Alias,
}) // add the group name and alias to the response
}
resp.MetricNames = append(resp.MetricNames, result.MetricNames...) // add the metric names to the response
render.Success(w, http.StatusOK, resp)
}

258
pkg/queryparser/api_test.go Normal file
View File

@@ -0,0 +1,258 @@
package queryparser
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"sort"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/types/parsertypes"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
func TestAPI_AnalyzeQueryFilter(t *testing.T) {
queryParser := New(instrumentationtest.New().ToProviderSettings())
aH := NewAPI(instrumentationtest.New().ToProviderSettings(), queryParser)
tests := []struct {
name string
requestBody parsertypes.QueryFilterAnalyzeRequest
expectedStatus int
expectedStatusStr string
expectedError bool
errorContains string
expectedMetrics []string
expectedGroups []parsertypes.ColumnInfoResponse
}{
{
name: "PromQL - Nested aggregation inside subquery",
requestBody: parsertypes.QueryFilterAnalyzeRequest{
Query: `max_over_time(sum(rate(cpu_usage_total[5m]))[1h:5m])`,
QueryType: querybuildertypesv5.QueryTypePromQL,
},
expectedStatus: http.StatusOK,
expectedStatusStr: "success",
expectedError: false,
expectedMetrics: []string{"cpu_usage_total"},
expectedGroups: []parsertypes.ColumnInfoResponse{},
},
{
name: "PromQL - Subquery with multiple metrics",
requestBody: parsertypes.QueryFilterAnalyzeRequest{
Query: `avg_over_time((foo + bar)[10m:1m])`,
QueryType: querybuildertypesv5.QueryTypePromQL,
},
expectedStatus: http.StatusOK,
expectedStatusStr: "success",
expectedError: false,
expectedMetrics: []string{"bar", "foo"},
expectedGroups: []parsertypes.ColumnInfoResponse{},
},
{
name: "PromQL - Simple meta-metric with grouping",
requestBody: parsertypes.QueryFilterAnalyzeRequest{
Query: `sum by (pod) (up)`,
QueryType: querybuildertypesv5.QueryTypePromQL,
},
expectedStatus: http.StatusOK,
expectedStatusStr: "success",
expectedError: false,
expectedMetrics: []string{"up"},
expectedGroups: []parsertypes.ColumnInfoResponse{{Name: "pod", Alias: ""}},
},
{
name: "ClickHouse - Simple CTE with GROUP BY",
requestBody: parsertypes.QueryFilterAnalyzeRequest{
Query: `WITH aggregated AS (
SELECT region as region_alias, sum(value) AS total
FROM metrics
WHERE metric_name = 'cpu_usage'
GROUP BY region
)
SELECT * FROM aggregated`,
QueryType: querybuildertypesv5.QueryTypeClickHouseSQL,
},
expectedStatus: http.StatusOK,
expectedStatusStr: "success",
expectedError: false,
expectedMetrics: []string{"cpu_usage"},
expectedGroups: []parsertypes.ColumnInfoResponse{{Name: "region", Alias: "region_alias"}},
},
{
name: "ClickHouse - CTE chain with last GROUP BY + Alias should be returned if exists",
requestBody: parsertypes.QueryFilterAnalyzeRequest{
Query: `WITH step1 AS (
SELECT service as service_alias, timestamp as ts, value
FROM metrics
WHERE metric_name = 'requests'
GROUP BY service, timestamp
),
step2 AS (
SELECT ts, avg(value) AS avg_value
FROM step1
GROUP BY ts
)
SELECT * FROM step2`,
QueryType: querybuildertypesv5.QueryTypeClickHouseSQL,
},
expectedStatus: http.StatusOK,
expectedStatusStr: "success",
expectedError: false,
expectedMetrics: []string{"requests"},
expectedGroups: []parsertypes.ColumnInfoResponse{{Name: "ts", Alias: ""}},
},
{
name: "ClickHouse - Outer GROUP BY overrides CTE GROUP BY + Alias should be returned if exists",
requestBody: parsertypes.QueryFilterAnalyzeRequest{
Query: `WITH cte AS (
SELECT region, service, value
FROM metrics
WHERE metric_name = 'memory'
GROUP BY region, service
)
SELECT region as region_alias, sum(value) as total
FROM cte
GROUP BY region`,
QueryType: querybuildertypesv5.QueryTypeClickHouseSQL,
},
expectedStatus: http.StatusOK,
expectedStatusStr: "success",
expectedError: false,
expectedMetrics: []string{"memory"},
expectedGroups: []parsertypes.ColumnInfoResponse{{Name: "region", Alias: "region_alias"}},
},
{
name: "ClickHouse - Invalid query should return error",
requestBody: parsertypes.QueryFilterAnalyzeRequest{
Query: `SELECT WHERE metric_name = 'memory' GROUP BY region, service`,
QueryType: querybuildertypesv5.QueryTypeClickHouseSQL,
},
expectedStatus: http.StatusBadRequest,
expectedStatusStr: "error",
expectedError: true,
errorContains: "failed to parse clickhouse query",
},
{
name: "Empty query should return error",
requestBody: parsertypes.QueryFilterAnalyzeRequest{
Query: "",
QueryType: querybuildertypesv5.QueryTypePromQL,
},
expectedStatus: http.StatusBadRequest,
expectedStatusStr: "error",
expectedError: true,
errorContains: "query is required and cannot be empty",
},
{
name: "Invalid queryType should return error",
requestBody: parsertypes.QueryFilterAnalyzeRequest{
Query: `sum(rate(cpu_usage[5m]))`,
QueryType: querybuildertypesv5.QueryTypeUnknown,
},
expectedStatus: http.StatusBadRequest,
expectedStatusStr: "error",
expectedError: true,
errorContains: "unsupported queryType",
},
{
name: "Invalid PromQL syntax should return error",
requestBody: parsertypes.QueryFilterAnalyzeRequest{
Query: `sum by ((foo)(bar))(http_requests_total)`,
QueryType: querybuildertypesv5.QueryTypePromQL,
},
expectedStatus: http.StatusBadRequest,
expectedStatusStr: "error",
expectedError: true,
errorContains: "failed to parse promql query",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create request body
reqBody, err := json.Marshal(tt.requestBody)
if err != nil {
t.Fatalf("failed to marshal request body: %v", err)
}
// Create HTTP request
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/v1/query_filter/analyze", bytes.NewBuffer(reqBody))
req.Header.Set("Content-Type", "application/json")
// Create response recorder
rr := httptest.NewRecorder()
// Call handler
aH.AnalyzeQueryFilter(rr, req)
// Check status code
if rr.Code != tt.expectedStatus {
t.Errorf("expected status %d, got %d", tt.expectedStatus, rr.Code)
}
// Parse response
var resp map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v, body: %s", err, rr.Body.String())
}
// Check status string
if resp["status"] != tt.expectedStatusStr {
t.Errorf("expected status '%s', got %v", tt.expectedStatusStr, resp["status"])
}
if tt.expectedError {
errorObj, ok := resp["error"].(map[string]interface{})
if !ok {
t.Fatalf("expected error to be a map, got %T", resp["error"])
}
errorMsg, ok := errorObj["message"].(string)
if !ok {
t.Fatalf("expected error message to be a string, got %T", errorObj["message"])
}
if !strings.Contains(errorMsg, tt.errorContains) {
t.Errorf("expected error message to contain '%s', got '%s'", tt.errorContains, errorMsg)
}
} else {
// Validate success response
data, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected data to be a map, got %T", resp["data"])
}
// Marshal data back to JSON and unmarshal into QueryFilterAnalyzeResponse struct
dataBytes, err := json.Marshal(data)
if err != nil {
t.Fatalf("failed to marshal data: %v", err)
}
var responseData parsertypes.QueryFilterAnalyzeResponse
if err := json.Unmarshal(dataBytes, &responseData); err != nil {
t.Fatalf("failed to unmarshal data into QueryFilterAnalyzeResponse: %v", err)
}
// Sort the arrays for comparison
gotMetrics := make([]string, len(responseData.MetricNames))
copy(gotMetrics, responseData.MetricNames)
sort.Strings(gotMetrics)
gotGroups := make([]parsertypes.ColumnInfoResponse, len(responseData.Groups))
copy(gotGroups, responseData.Groups)
// Compare using deep equal
if !reflect.DeepEqual(gotMetrics, tt.expectedMetrics) {
t.Errorf("expected metricNames %v, got %v", tt.expectedMetrics, gotMetrics)
}
if !reflect.DeepEqual(gotGroups, tt.expectedGroups) {
t.Errorf("expected groups %v, got %v", tt.expectedGroups, gotGroups)
}
}
})
}
}

View File

@@ -4,11 +4,18 @@
// This is useful for metrics discovery, and query analysis.
package queryfilterextractor
import "github.com/SigNoz/signoz/pkg/errors"
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
ExtractorCH = "qfe_ch"
ExtractorPromQL = "qfe_promql"
type ExtractorType struct {
valuer.String
}
var (
ExtractorTypeClickHouseSQL = ExtractorType{valuer.NewString("qfe_ch")}
ExtractorTypePromQL = ExtractorType{valuer.NewString("qfe_promql")}
)
// ColumnInfo represents a column in the query
@@ -46,13 +53,13 @@ type FilterExtractor interface {
Extract(query string) (*FilterResult, error)
}
func NewExtractor(extractorType string) (FilterExtractor, error) {
func NewExtractor(extractorType ExtractorType) (FilterExtractor, error) {
switch extractorType {
case ExtractorCH:
case ExtractorTypeClickHouseSQL:
return NewClickHouseFilterExtractor(), nil
case ExtractorPromQL:
case ExtractorTypePromQL:
return NewPromQLFilterExtractor(), nil
default:
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid extractor type: %s", extractorType)
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid extractor type: %s", extractorType)
}
}

View File

@@ -0,0 +1,14 @@
package queryparser
import (
"context"
"github.com/SigNoz/signoz/pkg/queryparser/queryfilterextractor"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
// QueryParser defines the interface for parsing and analyzing queries.
type QueryParser interface {
// AnalyzeQueryFilter extracts filter conditions from a given query string.
AnalyzeQueryFilter(ctx context.Context, queryType querybuildertypesv5.QueryType, query string) (*queryfilterextractor.FilterResult, error)
}

View File

@@ -0,0 +1,40 @@
package queryparser
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/queryparser/queryfilterextractor"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
type queryParserImpl struct {
settings factory.ProviderSettings
}
// New creates a new implementation of the QueryParser service.
func New(settings factory.ProviderSettings) QueryParser {
return &queryParserImpl{
settings: settings,
}
}
func (p *queryParserImpl) AnalyzeQueryFilter(ctx context.Context, queryType querybuildertypesv5.QueryType, query string) (*queryfilterextractor.FilterResult, error) {
var extractorType queryfilterextractor.ExtractorType
switch queryType {
case querybuildertypesv5.QueryTypePromQL:
extractorType = queryfilterextractor.ExtractorTypePromQL
case querybuildertypesv5.QueryTypeClickHouseSQL:
extractorType = queryfilterextractor.ExtractorTypeClickHouseSQL
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported queryType: %s. Supported values are '%s' and '%s'", queryType, querybuildertypesv5.QueryTypePromQL, querybuildertypesv5.QueryTypeClickHouseSQL)
}
// Create extractor
extractor, err := queryfilterextractor.NewExtractor(extractorType)
if err != nil {
return nil, err
}
return extractor.Extract(query)
}

View File

@@ -19,6 +19,7 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/ruler"
@@ -97,6 +98,9 @@ type Config struct {
// Tokenizer config
Tokenizer tokenizer.Config `mapstructure:"tokenizer"`
// MetricsExplorer config
MetricsExplorer metricsexplorer.Config `mapstructure:"metricsexplorer"`
}
// DeprecatedFlags are the flags that are deprecated and scheduled for removal.
@@ -156,6 +160,7 @@ func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.R
statsreporter.NewConfigFactory(),
gateway.NewConfigFactory(),
tokenizer.NewConfigFactory(),
metricsexplorer.NewConfigFactory(),
}
conf, err := config.New(ctx, resolverConfig, configFactories)

View File

@@ -48,7 +48,7 @@ type Handlers struct {
Session session.Handler
SpanPercentile spanpercentile.Handler
Services services.Handler
Metrics metricsexplorer.Handler
MetricsExplorer metricsexplorer.Handler
}
func NewHandlers(modules Modules, providerSettings factory.ProviderSettings, querier querier.Querier, licensing licensing.Licensing) Handlers {
@@ -65,7 +65,7 @@ func NewHandlers(modules Modules, providerSettings factory.ProviderSettings, que
AuthDomain: implauthdomain.NewHandler(modules.AuthDomain),
Session: implsession.NewHandler(modules.Session),
Services: implservices.NewHandler(modules.Services),
Metrics: implmetricsexplorer.NewHandler(modules.Metrics),
MetricsExplorer: implmetricsexplorer.NewHandler(modules.MetricsExplorer),
SpanPercentile: implspanpercentile.NewHandler(modules.SpanPercentile),
}
}

View File

@@ -36,7 +36,7 @@ func TestNewHandlers(t *testing.T) {
tokenizer := tokenizertest.New()
emailing := emailingtest.New()
require.NoError(t, err)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, Config{})
handlers := NewHandlers(modules, providerSettings, nil, nil)

View File

@@ -62,7 +62,7 @@ type Modules struct {
Session session.Module
Services services.Module
SpanPercentile spanpercentile.Module
Metrics metricsexplorer.Module
MetricsExplorer metricsexplorer.Module
}
func NewModules(
@@ -79,6 +79,7 @@ func NewModules(
authNs map[authtypes.AuthNProvider]authn.AuthN,
authz authz.AuthZ,
cache cache.Cache,
config Config,
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
@@ -101,6 +102,6 @@ func NewModules(
Session: implsession.NewModule(providerSettings, authNs, user, userGetter, implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs), tokenizer, orgGetter),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
Metrics: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, providerSettings),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, providerSettings, config.MetricsExplorer),
}
}

View File

@@ -36,7 +36,7 @@ func TestNewModules(t *testing.T) {
tokenizer := tokenizertest.New()
emailing := emailingtest.New()
require.NoError(t, err)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, Config{})
reflectVal := reflect.ValueOf(modules)
for i := 0; i < reflectVal.NumField(); i++ {

View File

@@ -20,6 +20,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sharder"
"github.com/SigNoz/signoz/pkg/sqlmigration"
"github.com/SigNoz/signoz/pkg/sqlmigrator"
@@ -62,6 +63,7 @@ type SigNoz struct {
Authz authz.AuthZ
Modules Modules
Handlers Handlers
QueryParser queryparser.QueryParser
}
func New(
@@ -309,6 +311,9 @@ func New(
return nil, err
}
// Initialize query parser
queryParser := queryparser.New(providerSettings)
// Initialize authns
store := sqlauthnstore.NewStore(sqlstore)
authNs, err := authNsCallback(ctx, providerSettings, store, licensing)
@@ -339,7 +344,7 @@ func New(
)
// Initialize all modules
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, config)
// Initialize all handlers for the modules
handlers := NewHandlers(modules, providerSettings, querier, licensing)
@@ -402,5 +407,6 @@ func New(
Authz: authz,
Modules: modules,
Handlers: handlers,
QueryParser: queryParser,
}, nil
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"slices"
"strings"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/errors"
@@ -52,7 +51,8 @@ func (c *conditionBuilder) conditionFor(
return "", err
}
if strings.HasPrefix(key.Name, BodyJSONStringSearchPrefix) {
// Check if this is a body JSON search - either by FieldContext
if key.FieldContext == telemetrytypes.FieldContextBody {
tblFieldName, value = GetBodyJSONKey(ctx, key, operator, value)
}
@@ -164,7 +164,8 @@ func (c *conditionBuilder) conditionFor(
// key membership checks, so depending on the column type, the condition changes
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:
if strings.HasPrefix(key.Name, BodyJSONStringSearchPrefix) {
// Check if this is a body JSON search - by FieldContext
if key.FieldContext == telemetrytypes.FieldContextBody {
if operator == qbtypes.FilterOperatorExists {
return GetBodyJSONKeyForExists(ctx, key, operator, value), nil
} else {
@@ -173,37 +174,46 @@ func (c *conditionBuilder) conditionFor(
}
var value any
switch column.Type {
case schema.JSONColumnType{}:
switch column.Type.GetType() {
case schema.ColumnTypeEnumJSON:
if operator == qbtypes.FilterOperatorExists {
return sb.IsNotNull(tblFieldName), nil
} else {
return sb.IsNull(tblFieldName), nil
}
case schema.ColumnTypeString, schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}:
case schema.ColumnTypeEnumLowCardinality:
switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
case schema.ColumnTypeEnumString:
value = ""
if operator == qbtypes.FilterOperatorExists {
return sb.NE(tblFieldName, value), nil
}
return sb.E(tblFieldName, value), nil
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for low cardinality column type %s", elementType)
}
case schema.ColumnTypeEnumString:
value = ""
if operator == qbtypes.FilterOperatorExists {
return sb.NE(tblFieldName, value), nil
} else {
return sb.E(tblFieldName, value), nil
}
case schema.ColumnTypeUInt64, schema.ColumnTypeUInt32, schema.ColumnTypeUInt8:
case schema.ColumnTypeEnumUInt64, schema.ColumnTypeEnumUInt32, schema.ColumnTypeEnumUInt8:
value = 0
if operator == qbtypes.FilterOperatorExists {
return sb.NE(tblFieldName, value), nil
} else {
return sb.E(tblFieldName, value), nil
}
case schema.MapColumnType{
KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
ValueType: schema.ColumnTypeString,
}, schema.MapColumnType{
KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
ValueType: schema.ColumnTypeBool,
}, schema.MapColumnType{
KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
ValueType: schema.ColumnTypeFloat64,
}:
case schema.ColumnTypeEnumMap:
keyType := column.Type.(schema.MapColumnType).KeyType
if _, ok := keyType.(schema.LowCardinalityColumnType); !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "key type %s is not supported for map column type %s", keyType, column.Type)
}
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumBool, schema.ColumnTypeEnumFloat64:
leftOperand := fmt.Sprintf("mapContains(%s, '%s')", column.Name, key.Name)
if key.Materialized {
leftOperand = telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key)
@@ -213,6 +223,9 @@ func (c *conditionBuilder) conditionFor(
} else {
return sb.NE(leftOperand, true), nil
}
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for map column type %s", valueType)
}
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for column type %s", column.Type)
}
@@ -238,7 +251,7 @@ func (c *conditionBuilder) ConditionFor(
// skip adding exists filter for intrinsic fields
// with an exception for body json search
field, _ := c.fm.FieldFor(ctx, key)
if slices.Contains(maps.Keys(IntrinsicFields), field) && !strings.HasPrefix(key.Name, BodyJSONStringSearchPrefix) {
if slices.Contains(maps.Keys(IntrinsicFields), field) && key.FieldContext != telemetrytypes.FieldContextBody {
return condition, nil
}

View File

@@ -465,7 +465,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
{
name: "Equal operator - int64",
key: telemetrytypes.TelemetryFieldKey{
Name: "body.http.status_code",
Name: "http.status_code",
FieldContext: telemetrytypes.FieldContextBody,
},
operator: qbtypes.FilterOperatorEqual,
value: 200,
@@ -475,7 +476,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
{
name: "Equal operator - float64",
key: telemetrytypes.TelemetryFieldKey{
Name: "body.duration_ms",
Name: "duration_ms",
FieldContext: telemetrytypes.FieldContextBody,
},
operator: qbtypes.FilterOperatorEqual,
value: 405.5,
@@ -485,7 +487,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
{
name: "Equal operator - string",
key: telemetrytypes.TelemetryFieldKey{
Name: "body.http.method",
Name: "http.method",
FieldContext: telemetrytypes.FieldContextBody,
},
operator: qbtypes.FilterOperatorEqual,
value: "GET",
@@ -495,7 +498,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
{
name: "Equal operator - bool",
key: telemetrytypes.TelemetryFieldKey{
Name: "body.http.success",
Name: "http.success",
FieldContext: telemetrytypes.FieldContextBody,
},
operator: qbtypes.FilterOperatorEqual,
value: true,
@@ -505,7 +509,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
{
name: "Exists operator",
key: telemetrytypes.TelemetryFieldKey{
Name: "body.http.status_code",
Name: "http.status_code",
FieldContext: telemetrytypes.FieldContextBody,
},
operator: qbtypes.FilterOperatorExists,
value: nil,
@@ -515,7 +520,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
{
name: "Not Exists operator",
key: telemetrytypes.TelemetryFieldKey{
Name: "body.http.status_code",
Name: "http.status_code",
FieldContext: telemetrytypes.FieldContextBody,
},
operator: qbtypes.FilterOperatorNotExists,
value: nil,
@@ -525,7 +531,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
{
name: "Greater than operator - string",
key: telemetrytypes.TelemetryFieldKey{
Name: "body.http.status_code",
Name: "http.status_code",
FieldContext: telemetrytypes.FieldContextBody,
},
operator: qbtypes.FilterOperatorGreaterThan,
value: "200",
@@ -535,7 +542,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
{
name: "Greater than operator - int64",
key: telemetrytypes.TelemetryFieldKey{
Name: "body.http.status_code",
Name: "http.status_code",
FieldContext: telemetrytypes.FieldContextBody,
},
operator: qbtypes.FilterOperatorGreaterThan,
value: 200,
@@ -545,7 +553,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
{
name: "Less than operator - string",
key: telemetrytypes.TelemetryFieldKey{
Name: "body.http.status_code",
Name: "http.status_code",
FieldContext: telemetrytypes.FieldContextBody,
},
operator: qbtypes.FilterOperatorLessThan,
value: "300",
@@ -555,7 +564,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
{
name: "Less than operator - int64",
key: telemetrytypes.TelemetryFieldKey{
Name: "body.http.status_code",
Name: "http.status_code",
FieldContext: telemetrytypes.FieldContextBody,
},
operator: qbtypes.FilterOperatorLessThan,
value: 300,
@@ -565,7 +575,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
{
name: "Contains operator - string",
key: telemetrytypes.TelemetryFieldKey{
Name: "body.http.status_code",
Name: "http.status_code",
FieldContext: telemetrytypes.FieldContextBody,
},
operator: qbtypes.FilterOperatorContains,
value: "200",
@@ -575,7 +586,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
{
name: "Not Contains operator - string",
key: telemetrytypes.TelemetryFieldKey{
Name: "body.http.status_code",
Name: "http.status_code",
FieldContext: telemetrytypes.FieldContextBody,
},
operator: qbtypes.FilterOperatorNotContains,
value: "200",
@@ -585,7 +597,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
{
name: "Between operator - string",
key: telemetrytypes.TelemetryFieldKey{
Name: "body.http.status_code",
Name: "http.status_code",
FieldContext: telemetrytypes.FieldContextBody,
},
operator: qbtypes.FilterOperatorBetween,
value: []any{"200", "300"},
@@ -595,7 +608,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
{
name: "Between operator - int64",
key: telemetrytypes.TelemetryFieldKey{
Name: "body.http.status_code",
Name: "http.status_code",
FieldContext: telemetrytypes.FieldContextBody,
},
operator: qbtypes.FilterOperatorBetween,
value: []any{400, 500},
@@ -605,7 +619,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
{
name: "In operator - string",
key: telemetrytypes.TelemetryFieldKey{
Name: "body.http.status_code",
Name: "http.status_code",
FieldContext: telemetrytypes.FieldContextBody,
},
operator: qbtypes.FilterOperatorIn,
value: []any{"200", "300"},
@@ -615,7 +630,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
{
name: "In operator - int64",
key: telemetrytypes.TelemetryFieldKey{
Name: "body.http.status_code",
Name: "http.status_code",
FieldContext: telemetrytypes.FieldContextBody,
},
operator: qbtypes.FilterOperatorIn,
value: []any{401, 404, 500},
@@ -625,7 +641,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
{
name: "Regexp operator - json body string",
key: telemetrytypes.TelemetryFieldKey{
Name: "body.http.method",
Name: "http.method",
FieldContext: telemetrytypes.FieldContextBody,
},
operator: qbtypes.FilterOperatorRegexp,
value: "GET|POST|PUT",
@@ -635,7 +652,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
{
name: "Not Regexp operator - json body string",
key: telemetrytypes.TelemetryFieldKey{
Name: "body.http.method",
Name: "http.method",
FieldContext: telemetrytypes.FieldContextBody,
},
operator: qbtypes.FilterOperatorNotRegexp,
value: "DELETE|PATCH",
@@ -645,7 +663,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
{
name: "Regexp operator - json body with dots in path",
key: telemetrytypes.TelemetryFieldKey{
Name: "body.user.email",
Name: "user.email",
FieldContext: telemetrytypes.FieldContextBody,
},
operator: qbtypes.FilterOperatorRegexp,
value: "^.*@example\\.com$",
@@ -655,7 +674,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
{
name: "Not Regexp operator - json body nested path",
key: telemetrytypes.TelemetryFieldKey{
Name: "body.response.headers.content-type",
Name: "response.headers.content-type",
FieldContext: telemetrytypes.FieldContextBody,
},
operator: qbtypes.FilterOperatorNotRegexp,
value: "^text/.*",

View File

@@ -1,6 +1,8 @@
package telemetrylogs
import (
"github.com/SigNoz/signoz-otel-collector/constants"
"github.com/SigNoz/signoz-otel-collector/exporter/jsontypeexporter"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -16,6 +18,8 @@ const (
LogsV2TimestampColumn = "timestamp"
LogsV2ObservedTimestampColumn = "observed_timestamp"
LogsV2BodyColumn = "body"
LogsV2BodyJSONColumn = constants.BodyJSONColumn
LogsV2BodyPromotedColumn = constants.BodyPromotedColumn
LogsV2TraceIDColumn = "trace_id"
LogsV2SpanIDColumn = "span_id"
LogsV2TraceFlagsColumn = "trace_flags"
@@ -30,6 +34,11 @@ const (
LogsV2AttributesBoolColumn = "attributes_bool"
LogsV2ResourcesStringColumn = "resources_string"
LogsV2ScopeStringColumn = "scope_string"
BodyJSONColumnPrefix = constants.BodyJSONColumnPrefix
BodyPromotedColumnPrefix = constants.BodyPromotedColumnPrefix
ArraySep = jsontypeexporter.ArraySeparator
ArrayAnyIndex = "[*]."
)
var (

View File

@@ -82,10 +82,13 @@ func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.Telemetry
case telemetrytypes.FieldDataTypeBool:
return logsV2Columns["attributes_bool"], nil
}
case telemetrytypes.FieldContextBody:
// body context fields are stored in the body column
return logsV2Columns["body"], nil
case telemetrytypes.FieldContextLog, telemetrytypes.FieldContextUnspecified:
col, ok := logsV2Columns[key.Name]
if !ok {
// check if the key has body JSON search
// check if the key has body JSON search (backward compatibility)
if strings.HasPrefix(key.Name, BodyJSONStringSearchPrefix) {
return logsV2Columns["body"], nil
}
@@ -103,8 +106,8 @@ func (m *fieldMapper) FieldFor(ctx context.Context, key *telemetrytypes.Telemetr
return "", err
}
switch column.Type {
case schema.JSONColumnType{}:
switch column.Type.GetType() {
case schema.ColumnTypeEnumJSON:
// json is only supported for resource context as of now
if key.FieldContext != telemetrytypes.FieldContextResource {
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource context fields are supported for json columns, got %s", key.FieldContext.String)
@@ -121,40 +124,32 @@ func (m *fieldMapper) FieldFor(ctx context.Context, key *telemetrytypes.Telemetr
} else {
return fmt.Sprintf("multiIf(%s.`%s` IS NOT NULL, %s.`%s`::String, mapContains(%s, '%s'), %s, NULL)", column.Name, key.Name, column.Name, key.Name, oldColumn.Name, key.Name, oldKeyName), nil
}
case schema.ColumnTypeString,
schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
schema.ColumnTypeUInt64,
schema.ColumnTypeUInt32,
schema.ColumnTypeUInt8:
case schema.ColumnTypeEnumLowCardinality:
switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
case schema.ColumnTypeEnumString:
return column.Name, nil
case schema.MapColumnType{
KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
ValueType: schema.ColumnTypeString,
}:
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for low cardinality column type %s", elementType)
}
case schema.ColumnTypeEnumString,
schema.ColumnTypeEnumUInt64, schema.ColumnTypeEnumUInt32, schema.ColumnTypeEnumUInt8:
return column.Name, nil
case schema.ColumnTypeEnumMap:
keyType := column.Type.(schema.MapColumnType).KeyType
if _, ok := keyType.(schema.LowCardinalityColumnType); !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "key type %s is not supported for map column type %s", keyType, column.Type)
}
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumBool, schema.ColumnTypeEnumFloat64:
// a key could have been materialized, if so return the materialized column name
if key.Materialized {
return telemetrytypes.FieldKeyToMaterializedColumnName(key), nil
}
return fmt.Sprintf("%s['%s']", column.Name, key.Name), nil
case schema.MapColumnType{
KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
ValueType: schema.ColumnTypeFloat64,
}:
// a key could have been materialized, if so return the materialized column name
if key.Materialized {
return telemetrytypes.FieldKeyToMaterializedColumnName(key), nil
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for map column type %s", valueType)
}
return fmt.Sprintf("%s['%s']", column.Name, key.Name), nil
case schema.MapColumnType{
KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
ValueType: schema.ColumnTypeBool,
}:
// a key could have been materialized, if so return the materialized column name
if key.Materialized {
return telemetrytypes.FieldKeyToMaterializedColumnName(key), nil
}
return fmt.Sprintf("%s['%s']", column.Name, key.Name), nil
}
// should not reach here
return column.Name, nil

View File

@@ -21,7 +21,6 @@ func TestLikeAndILikeWithoutWildcards_Warns(t *testing.T) {
ConditionBuilder: cb,
FieldKeys: keys,
FullTextColumn: DefaultFullTextColumn,
JsonBodyPrefix: BodyJSONStringSearchPrefix,
JsonKeyToKey: GetBodyJSONKey,
}
@@ -58,7 +57,6 @@ func TestLikeAndILikeWithWildcards_NoWarn(t *testing.T) {
ConditionBuilder: cb,
FieldKeys: keys,
FullTextColumn: DefaultFullTextColumn,
JsonBodyPrefix: BodyJSONStringSearchPrefix,
JsonKeyToKey: GetBodyJSONKey,
}

View File

@@ -27,7 +27,6 @@ func TestFilterExprLogsBodyJSON(t *testing.T) {
FullTextColumn: &telemetrytypes.TelemetryFieldKey{
Name: "body",
},
JsonBodyPrefix: "body",
JsonKeyToKey: GetBodyJSONKey,
}

View File

@@ -27,7 +27,6 @@ func TestFilterExprLogs(t *testing.T) {
ConditionBuilder: cb,
FieldKeys: keys,
FullTextColumn: DefaultFullTextColumn,
JsonBodyPrefix: BodyJSONStringSearchPrefix,
JsonKeyToKey: GetBodyJSONKey,
}
@@ -2448,7 +2447,6 @@ func TestFilterExprLogsConflictNegation(t *testing.T) {
ConditionBuilder: cb,
FieldKeys: keys,
FullTextColumn: DefaultFullTextColumn,
JsonBodyPrefix: BodyJSONStringSearchPrefix,
JsonKeyToKey: GetBodyJSONKey,
}

View File

@@ -69,7 +69,7 @@ func inferDataType(value any, operator qbtypes.FilterOperator, key *telemetrytyp
}
func getBodyJSONPath(key *telemetrytypes.TelemetryFieldKey) string {
parts := strings.Split(key.Name, ".")[1:]
parts := strings.Split(key.Name, ".")
newParts := []string{}
for _, part := range parts {
if strings.HasSuffix(part, "[*]") {

View File

@@ -589,7 +589,6 @@ func (b *logQueryStatementBuilder) addFilterCondition(
FieldKeys: keys,
SkipResourceFilter: true,
FullTextColumn: b.fullTextColumn,
JsonBodyPrefix: b.jsonBodyPrefix,
JsonKeyToKey: b.jsonKeyToKey,
Variables: variables,
}, start, end)

View File

@@ -8,4 +8,7 @@ const (
TagAttributesV2LocalTableName = "tag_attributes_v2"
LogAttributeKeysTblName = "distributed_logs_attribute_keys"
LogResourceKeysTblName = "distributed_logs_resource_keys"
PathTypesTableName = "distributed_json_path_types"
PromotedPathsTableName = "distributed_json_promoted_paths"
SkipIndexTableName = "system.data_skipping_indices"
)

View File

@@ -0,0 +1,496 @@
package telemetrymetadata
import (
"context"
"fmt"
"reflect"
"strings"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz-otel-collector/constants"
"github.com/SigNoz/signoz-otel-collector/utils"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrylogs"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/huandu/go-sqlbuilder"
)
var (
defaultPathLimit = 100 // Default limit to prevent full table scans
CodeUnknownJSONDataType = errors.MustNewCode("unknown_json_data_type")
CodeFailLoadPromotedPaths = errors.MustNewCode("fail_load_promoted_paths")
CodeFailCheckPathPromoted = errors.MustNewCode("fail_check_path_promoted")
CodeFailIterateBodyJSONKeys = errors.MustNewCode("fail_iterate_body_json_keys")
CodeFailExtractBodyJSONKeys = errors.MustNewCode("fail_extract_body_json_keys")
CodeFailLoadLogsJSONIndexes = errors.MustNewCode("fail_load_logs_json_indexes")
CodeFailListJSONValues = errors.MustNewCode("fail_list_json_values")
CodeFailScanJSONValue = errors.MustNewCode("fail_scan_json_value")
CodeFailScanVariant = errors.MustNewCode("fail_scan_variant")
CodeFailBuildJSONPathsQuery = errors.MustNewCode("fail_build_json_paths_query")
CodeNoPathsToQueryIndexes = errors.MustNewCode("no_paths_to_query_indexes_provided")
)
// GetBodyJSONPaths extracts body JSON paths from the path_types table
// This function can be used by both JSONQueryBuilder and metadata extraction
// uniquePathLimit: 0 for no limit, >0 for maximum number of unique paths to return
// - For startup load: set to 10000 to get top 10k unique paths
// - For lookup: set to 0 (no limit needed for single path)
// - For metadata API: set to desired pagination limit
//
// searchOperator: LIKE for pattern matching, EQUAL for exact match
// Returns: (paths, error)
// TODO(Piyush): Remove this lint skip
//
// nolint:unused
func getBodyJSONPaths(ctx context.Context, telemetryStore telemetrystore.TelemetryStore,
fieldKeySelectors []*telemetrytypes.FieldKeySelector) ([]*telemetrytypes.TelemetryFieldKey, bool, error) {
query, args, limit, err := buildGetBodyJSONPathsQuery(fieldKeySelectors)
if err != nil {
return nil, false, err
}
rows, err := telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, false, errors.WrapInternalf(err, CodeFailExtractBodyJSONKeys, "failed to extract body JSON keys")
}
defer rows.Close()
fieldKeys := []*telemetrytypes.TelemetryFieldKey{}
paths := []string{}
rowCount := 0
for rows.Next() {
var path string
var typesArray []string // ClickHouse returns array as []string
var lastSeen uint64
err = rows.Scan(&path, &typesArray, &lastSeen)
if err != nil {
return nil, false, errors.WrapInternalf(err, CodeFailExtractBodyJSONKeys, "failed to scan body JSON key row")
}
for _, typ := range typesArray {
mapping, found := telemetrytypes.MappingStringToJSONDataType[typ]
if !found {
return nil, false, errors.NewInternalf(CodeUnknownJSONDataType, "failed to map type string to JSON data type: %s", typ)
}
fieldKeys = append(fieldKeys, &telemetrytypes.TelemetryFieldKey{
Name: path,
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextBody,
FieldDataType: telemetrytypes.MappingJSONDataTypeToFieldDataType[mapping],
JSONDataType: &mapping,
})
}
paths = append(paths, path)
rowCount++
}
if rows.Err() != nil {
return nil, false, errors.WrapInternalf(rows.Err(), CodeFailIterateBodyJSONKeys, "error iterating body JSON keys")
}
promoted, err := GetPromotedPaths(ctx, telemetryStore.ClickhouseDB(), paths...)
if err != nil {
return nil, false, err
}
indexes, err := getJSONPathIndexes(ctx, telemetryStore, paths...)
if err != nil {
return nil, false, err
}
for _, fieldKey := range fieldKeys {
fieldKey.Materialized = promoted.Contains(fieldKey.Name)
fieldKey.Indexes = indexes[fieldKey.Name]
}
return fieldKeys, rowCount <= limit, nil
}
func buildGetBodyJSONPathsQuery(fieldKeySelectors []*telemetrytypes.FieldKeySelector) (string, []any, int, error) {
if len(fieldKeySelectors) == 0 {
return "", nil, defaultPathLimit, errors.NewInternalf(CodeFailBuildJSONPathsQuery, "no field key selectors provided")
}
from := fmt.Sprintf("%s.%s", DBName, PathTypesTableName)
// Build a better query using GROUP BY to deduplicate at database level
// This aggregates all types per path and gets the max last_seen, then applies LIMIT
sb := sqlbuilder.Select(
"path",
"groupArray(DISTINCT type) AS types",
"max(last_seen) AS last_seen",
).From(from)
limit := 0
// Add search filter if provided
orClauses := []string{}
for _, fieldKeySelector := range fieldKeySelectors {
// replace [*] with []
fieldKeySelector.Name = strings.ReplaceAll(fieldKeySelector.Name, telemetrylogs.ArrayAnyIndex, telemetrylogs.ArraySep)
// Extract search text for body JSON keys
keyName := CleanPathPrefixes(fieldKeySelector.Name)
if fieldKeySelector.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeExact {
orClauses = append(orClauses, sb.Equal("path", keyName))
} else {
// Pattern matching for metadata API (defaults to LIKE behavior for other operators)
orClauses = append(orClauses, sb.Like("path", querybuilder.FormatValueForContains(keyName)))
}
limit += fieldKeySelector.Limit
}
sb.Where(sb.Or(orClauses...))
// Group by path to get unique paths with aggregated types
sb.GroupBy("path")
// Order by max last_seen to get most recent paths first
sb.OrderBy("last_seen DESC")
if limit == 0 {
limit = defaultPathLimit
}
sb.Limit(limit)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return query, args, limit, nil
}
// TODO(Piyush): Remove this lint skip
//
// nolint:unused
func getJSONPathIndexes(ctx context.Context, telemetryStore telemetrystore.TelemetryStore, paths ...string) (map[string][]telemetrytypes.JSONDataTypeIndex, error) {
filteredPaths := []string{}
for _, path := range paths {
if strings.Contains(path, telemetrylogs.ArraySep) || strings.Contains(path, telemetrylogs.ArrayAnyIndex) {
continue
}
filteredPaths = append(filteredPaths, path)
}
if len(filteredPaths) == 0 {
return nil, errors.NewInternalf(CodeNoPathsToQueryIndexes, "no paths to query indexes provided")
}
// list indexes for the paths
indexesMap, err := ListLogsJSONIndexes(ctx, telemetryStore, filteredPaths...)
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailLoadLogsJSONIndexes, "failed to list JSON path indexes")
}
// build a set of indexes
cleanIndexes := make(map[string][]telemetrytypes.JSONDataTypeIndex)
for path, indexes := range indexesMap {
for _, index := range indexes {
columnExpr, columnType, err := schemamigrator.UnfoldJSONSubColumnIndexExpr(index.Expression)
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailLoadLogsJSONIndexes, "failed to unfold JSON sub column index expression: %s", index.Expression)
}
jsonDataType, found := telemetrytypes.MappingStringToJSONDataType[columnType]
if !found {
return nil, errors.NewInternalf(CodeUnknownJSONDataType, "failed to map column type to JSON data type: %s", columnType)
}
if jsonDataType == telemetrytypes.String {
cleanIndexes[path] = append(cleanIndexes[path], telemetrytypes.JSONDataTypeIndex{
Type: telemetrytypes.String,
ColumnExpression: columnExpr,
IndexExpression: index.Expression,
})
} else if strings.HasPrefix(index.Type, "minmax") {
cleanIndexes[path] = append(cleanIndexes[path], telemetrytypes.JSONDataTypeIndex{
Type: jsonDataType,
ColumnExpression: columnExpr,
IndexExpression: index.Expression,
})
}
}
}
return cleanIndexes, nil
}
func buildListLogsJSONIndexesQuery(cluster string, filters ...string) (string, []any) {
// This aggregates all types per path and gets the max last_seen, then applies LIMIT
sb := sqlbuilder.Select(
"name", "type_full", "expr", "granularity",
).From(fmt.Sprintf("clusterAllReplicas('%s', %s)", cluster, SkipIndexTableName))
sb.Where(sb.Equal("database", telemetrylogs.DBName))
sb.Where(sb.Equal("table", telemetrylogs.LogsV2LocalTableName))
sb.Where(sb.Or(
sb.ILike("expr", fmt.Sprintf("%%%s%%", querybuilder.FormatValueForContains(constants.BodyJSONColumnPrefix))),
sb.ILike("expr", fmt.Sprintf("%%%s%%", querybuilder.FormatValueForContains(constants.BodyPromotedColumnPrefix))),
))
filterExprs := []string{}
for _, filter := range filters {
filterExprs = append(filterExprs, sb.ILike("expr", fmt.Sprintf("%%%s%%", querybuilder.FormatValueForContains(filter))))
}
sb.Where(sb.Or(filterExprs...))
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
}
func ListLogsJSONIndexes(ctx context.Context, telemetryStore telemetrystore.TelemetryStore, filters ...string) (map[string][]schemamigrator.Index, error) {
query, args := buildListLogsJSONIndexesQuery(telemetryStore.Cluster(), filters...)
rows, err := telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailLoadLogsJSONIndexes, "failed to load string indexed columns")
}
defer rows.Close()
indexesMap := make(map[string][]schemamigrator.Index)
for rows.Next() {
var name string
var typeFull string
var expr string
var granularity uint64
if err := rows.Scan(&name, &typeFull, &expr, &granularity); err != nil {
return nil, errors.WrapInternalf(err, CodeFailLoadLogsJSONIndexes, "failed to scan string indexed column")
}
indexesMap[name] = append(indexesMap[name], schemamigrator.Index{
Name: name,
Type: typeFull,
Expression: expr,
Granularity: int(granularity),
})
}
return indexesMap, nil
}
func ListPromotedPaths(ctx context.Context, conn clickhouse.Conn) (map[string]struct{}, error) {
query := fmt.Sprintf("SELECT path FROM %s.%s", DBName, PromotedPathsTableName)
rows, err := conn.Query(ctx, query)
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailLoadPromotedPaths, "failed to load promoted paths")
}
defer rows.Close()
next := make(map[string]struct{})
for rows.Next() {
var path string
if err := rows.Scan(&path); err != nil {
return nil, errors.WrapInternalf(err, CodeFailLoadPromotedPaths, "failed to scan promoted path")
}
next[path] = struct{}{}
}
return next, nil
}
// TODO(Piyush): Remove this if not used in future
func ListJSONValues(ctx context.Context, conn clickhouse.Conn, path string, limit int) (*telemetrytypes.TelemetryFieldValues, bool, error) {
path = CleanPathPrefixes(path)
if strings.Contains(path, telemetrylogs.ArraySep) || strings.Contains(path, telemetrylogs.ArrayAnyIndex) {
return nil, false, errors.NewInvalidInputf(errors.CodeInvalidInput, "array paths are not supported")
}
promoted, err := IsPathPromoted(ctx, conn, path)
if err != nil {
return nil, false, err
}
if promoted {
path = telemetrylogs.BodyPromotedColumnPrefix + path
} else {
path = telemetrylogs.BodyJSONColumnPrefix + path
}
from := fmt.Sprintf("%s.%s", telemetrylogs.DBName, telemetrylogs.LogsV2TableName)
colExpr := func(typ telemetrytypes.JSONDataType) string {
return fmt.Sprintf("dynamicElement(%s, '%s')", path, typ.StringValue())
}
sb := sqlbuilder.Select(
colExpr(telemetrytypes.String),
colExpr(telemetrytypes.Int64),
colExpr(telemetrytypes.Float64),
colExpr(telemetrytypes.Bool),
colExpr(telemetrytypes.ArrayString),
colExpr(telemetrytypes.ArrayInt64),
colExpr(telemetrytypes.ArrayFloat64),
colExpr(telemetrytypes.ArrayBool),
colExpr(telemetrytypes.ArrayDynamic),
).From(from)
sb.Where(fmt.Sprintf("%s IS NOT NULL", path))
sb.Limit(limit)
contextWithTimeout, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := conn.Query(contextWithTimeout, query, args...)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return nil, false, errors.WrapTimeoutf(err, errors.CodeTimeout, "query timed out").WithAdditional("failed to list JSON values")
}
return nil, false, errors.WrapInternalf(err, CodeFailListJSONValues, "failed to list JSON values")
}
defer rows.Close()
// Get column types to determine proper scan types
colTypes := rows.ColumnTypes()
scanTargets := make([]any, len(colTypes))
for i := range colTypes {
scanTargets[i] = reflect.New(colTypes[i].ScanType()).Interface()
}
values := &telemetrytypes.TelemetryFieldValues{}
for rows.Next() {
// Create fresh scan targets for each row
scan := make([]any, len(colTypes))
for i := range colTypes {
scan[i] = reflect.New(colTypes[i].ScanType()).Interface()
}
if err := rows.Scan(scan...); err != nil {
return nil, false, errors.WrapInternalf(err, CodeFailListJSONValues, "failed to scan JSON value row")
}
// Extract values from scan targets and process them
// Column order: String, Int64, Float64, Bool, ArrayString, ArrayInt64, ArrayFloat64, ArrayBool, ArrayDynamic
var consume func(scan []any) error
consume = func(scan []any) error {
for _, value := range scan {
value := derefValue(value) // dereference the double pointer if it is a pointer
switch value := value.(type) {
case string:
values.StringValues = append(values.StringValues, value)
case int64:
values.NumberValues = append(values.NumberValues, float64(value))
case float64:
values.NumberValues = append(values.NumberValues, value)
case bool:
values.BoolValues = append(values.BoolValues, value)
case []*string:
for _, str := range value {
if str != nil {
values.StringValues = append(values.StringValues, *str)
}
}
case []*int64:
for _, num := range value {
if num != nil {
values.NumberValues = append(values.NumberValues, float64(*num))
}
}
case []*float64:
for _, num := range value {
if num != nil {
values.NumberValues = append(values.NumberValues, float64(*num))
}
}
case []*bool:
for _, boolVal := range value {
if boolVal != nil {
values.BoolValues = append(values.BoolValues, *boolVal)
}
}
case chcol.Variant:
if !value.Nil() {
if err := consume([]any{value.Any()}); err != nil {
return err
}
}
case []chcol.Variant:
extractedValues := make([]any, len(value))
for idx, variant := range value {
if !variant.Nil() && variant.Type() != "JSON" { // skip JSON values cuz they're relevant for nested keys
extractedValues[idx] = variant.Any()
}
}
if err := consume(extractedValues); err != nil {
return err
}
default:
if value == nil {
continue
}
return errors.NewInternalf(CodeFailScanJSONValue, "unknown JSON value type: %T", value)
}
}
return nil
}
if err := consume(scan); err != nil {
return nil, false, err
}
}
if err := rows.Err(); err != nil {
return nil, false, errors.WrapInternalf(err, CodeFailListJSONValues, "error iterating JSON values")
}
return values, true, nil
}
func derefValue(v any) any {
if v == nil {
return nil
}
val := reflect.ValueOf(v)
for val.Kind() == reflect.Ptr {
if val.IsNil() {
return nil
}
val = val.Elem()
}
return val.Interface()
}
// IsPathPromoted checks if a specific path is promoted
func IsPathPromoted(ctx context.Context, conn clickhouse.Conn, path string) (bool, error) {
split := strings.Split(path, telemetrylogs.ArraySep)
query := fmt.Sprintf("SELECT 1 FROM %s.%s WHERE path = ? LIMIT 1", DBName, PromotedPathsTableName)
rows, err := conn.Query(ctx, query, split[0])
if err != nil {
return false, errors.WrapInternalf(err, CodeFailCheckPathPromoted, "failed to check if path %s is promoted", path)
}
defer rows.Close()
return rows.Next(), nil
}
// GetPromotedPaths checks if a specific path is promoted
func GetPromotedPaths(ctx context.Context, conn clickhouse.Conn, paths ...string) (*utils.ConcurrentSet[string], error) {
sb := sqlbuilder.Select("path").From(fmt.Sprintf("%s.%s", DBName, PromotedPathsTableName))
pathConditions := []string{}
for _, path := range paths {
pathConditions = append(pathConditions, sb.Equal("path", path))
}
sb.Where(sb.Or(pathConditions...))
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := conn.Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailCheckPathPromoted, "failed to get promoted paths")
}
defer rows.Close()
promotedPaths := utils.NewConcurrentSet[string]()
for rows.Next() {
var path string
if err := rows.Scan(&path); err != nil {
return nil, errors.WrapInternalf(err, CodeFailCheckPathPromoted, "failed to scan promoted path")
}
promotedPaths.Insert(path)
}
return promotedPaths, nil
}
// TODO(Piyush): Remove this function
func CleanPathPrefixes(path string) string {
path = strings.TrimPrefix(path, telemetrytypes.BodyJSONStringSearchPrefix)
path = strings.TrimPrefix(path, telemetrylogs.BodyJSONColumnPrefix)
path = strings.TrimPrefix(path, telemetrylogs.BodyPromotedColumnPrefix)
return path
}

View File

@@ -0,0 +1,151 @@
package telemetrymetadata
import (
"fmt"
"testing"
"github.com/SigNoz/signoz-otel-collector/constants"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrylogs"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/require"
)
func TestBuildGetBodyJSONPathsQuery(t *testing.T) {
testCases := []struct {
name string
fieldKeySelectors []*telemetrytypes.FieldKeySelector
expectedSQL string
expectedArgs []any
expectedLimit int
}{
{
name: "Single search text with EQUAL operator",
fieldKeySelectors: []*telemetrytypes.FieldKeySelector{
{
Name: "user.name",
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
},
},
expectedSQL: "SELECT path, groupArray(DISTINCT type) AS types, max(last_seen) AS last_seen FROM signoz_metadata.distributed_json_path_types WHERE (path = ?) GROUP BY path ORDER BY last_seen DESC LIMIT ?",
expectedArgs: []any{"user.name", defaultPathLimit},
expectedLimit: defaultPathLimit,
},
{
name: "Single search text with LIKE operator",
fieldKeySelectors: []*telemetrytypes.FieldKeySelector{
{
Name: "user",
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeFuzzy,
},
},
expectedSQL: "SELECT path, groupArray(DISTINCT type) AS types, max(last_seen) AS last_seen FROM signoz_metadata.distributed_json_path_types WHERE (path LIKE ?) GROUP BY path ORDER BY last_seen DESC LIMIT ?",
expectedArgs: []any{"user", 100},
expectedLimit: 100,
},
{
name: "Multiple search texts with EQUAL operator",
fieldKeySelectors: []*telemetrytypes.FieldKeySelector{
{
Name: "user.name",
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
},
{
Name: "user.age",
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
},
},
expectedSQL: "SELECT path, groupArray(DISTINCT type) AS types, max(last_seen) AS last_seen FROM signoz_metadata.distributed_json_path_types WHERE (path = ? OR path = ?) GROUP BY path ORDER BY last_seen DESC LIMIT ?",
expectedArgs: []any{"user.name", "user.age", defaultPathLimit},
expectedLimit: defaultPathLimit,
},
{
name: "Multiple search texts with LIKE operator",
fieldKeySelectors: []*telemetrytypes.FieldKeySelector{
{
Name: "user",
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeFuzzy,
},
{
Name: "admin",
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeFuzzy,
},
},
expectedSQL: "SELECT path, groupArray(DISTINCT type) AS types, max(last_seen) AS last_seen FROM signoz_metadata.distributed_json_path_types WHERE (path LIKE ? OR path LIKE ?) GROUP BY path ORDER BY last_seen DESC LIMIT ?",
expectedArgs: []any{"user", "admin", defaultPathLimit},
expectedLimit: defaultPathLimit,
},
{
name: "Search with Contains operator (should default to LIKE)",
fieldKeySelectors: []*telemetrytypes.FieldKeySelector{
{
Name: "test",
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeFuzzy,
},
},
expectedSQL: "SELECT path, groupArray(DISTINCT type) AS types, max(last_seen) AS last_seen FROM signoz_metadata.distributed_json_path_types WHERE (path LIKE ?) GROUP BY path ORDER BY last_seen DESC LIMIT ?",
expectedArgs: []any{"test", defaultPathLimit},
expectedLimit: defaultPathLimit,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
query, args, limit, err := buildGetBodyJSONPathsQuery(tc.fieldKeySelectors)
require.NoError(t, err, "Error building query: %v", err)
require.Equal(t, tc.expectedSQL, query)
require.Equal(t, tc.expectedArgs, args)
require.Equal(t, tc.expectedLimit, limit)
})
}
}
func TestBuildListLogsJSONIndexesQuery(t *testing.T) {
testCases := []struct {
name string
cluster string
filters []string
expectedSQL string
expectedArgs []any
}{
{
name: "No filters",
cluster: "test-cluster",
filters: nil,
expectedSQL: "SELECT name, type_full, expr, granularity FROM clusterAllReplicas('test-cluster', system.data_skipping_indices) " +
"WHERE database = ? AND table = ? AND (LOWER(expr) LIKE LOWER(?) OR LOWER(expr) LIKE LOWER(?))",
expectedArgs: []any{
telemetrylogs.DBName,
telemetrylogs.LogsV2LocalTableName,
fmt.Sprintf("%%%s%%", querybuilder.FormatValueForContains(constants.BodyJSONColumnPrefix)),
fmt.Sprintf("%%%s%%", querybuilder.FormatValueForContains(constants.BodyPromotedColumnPrefix)),
},
},
{
name: "With filters",
cluster: "test-cluster",
filters: []string{"foo", "bar"},
expectedSQL: "SELECT name, type_full, expr, granularity FROM clusterAllReplicas('test-cluster', system.data_skipping_indices) " +
"WHERE database = ? AND table = ? AND (LOWER(expr) LIKE LOWER(?) OR LOWER(expr) LIKE LOWER(?)) AND (LOWER(expr) LIKE LOWER(?) OR LOWER(expr) LIKE LOWER(?))",
expectedArgs: []any{
telemetrylogs.DBName,
telemetrylogs.LogsV2LocalTableName,
fmt.Sprintf("%%%s%%", querybuilder.FormatValueForContains(constants.BodyJSONColumnPrefix)),
fmt.Sprintf("%%%s%%", querybuilder.FormatValueForContains(constants.BodyPromotedColumnPrefix)),
fmt.Sprintf("%%%s%%", querybuilder.FormatValueForContains("foo")),
fmt.Sprintf("%%%s%%", querybuilder.FormatValueForContains("bar")),
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
query, args := buildListLogsJSONIndexesQuery(tc.cluster, tc.filters...)
require.Equal(t, tc.expectedSQL, query)
require.Equal(t, tc.expectedArgs, args)
})
}
}

View File

@@ -1,7 +1,12 @@
package telemetrymetadata
import otelcollectorconst "github.com/SigNoz/signoz-otel-collector/constants"
const (
DBName = "signoz_metadata"
AttributesMetadataTableName = "distributed_attributes_metadata"
AttributesMetadataLocalTableName = "attributes_metadata"
PathTypesTableName = otelcollectorconst.DistributedPathTypesTable
PromotedPathsTableName = otelcollectorconst.DistributedPromotedPathsTable
SkipIndexTableName = "system.data_skipping_indices"
)

View File

@@ -165,45 +165,54 @@ func (c *conditionBuilder) conditionFor(
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:
var value any
switch column.Type {
case schema.JSONColumnType{}:
switch column.Type.GetType() {
case schema.ColumnTypeEnumJSON:
if operator == qbtypes.FilterOperatorExists {
return sb.IsNotNull(tblFieldName), nil
} else {
return sb.IsNull(tblFieldName), nil
}
case schema.ColumnTypeString,
schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
schema.FixedStringColumnType{Length: 32},
schema.DateTime64ColumnType{Precision: 9, Timezone: "UTC"}:
case schema.ColumnTypeEnumString,
schema.ColumnTypeEnumFixedString,
schema.ColumnTypeEnumDateTime64:
value = ""
if operator == qbtypes.FilterOperatorExists {
return sb.NE(tblFieldName, value), nil
} else {
return sb.E(tblFieldName, value), nil
}
case schema.ColumnTypeUInt64,
schema.ColumnTypeUInt32,
schema.ColumnTypeUInt8,
schema.ColumnTypeInt8,
schema.ColumnTypeInt16,
schema.ColumnTypeBool:
case schema.ColumnTypeEnumLowCardinality:
switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
case schema.ColumnTypeEnumString:
value = ""
if operator == qbtypes.FilterOperatorExists {
return sb.NE(tblFieldName, value), nil
}
return sb.E(tblFieldName, value), nil
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for low cardinality column type %s", elementType)
}
case schema.ColumnTypeEnumUInt64,
schema.ColumnTypeEnumUInt32,
schema.ColumnTypeEnumUInt8,
schema.ColumnTypeEnumInt8,
schema.ColumnTypeEnumInt16,
schema.ColumnTypeEnumBool:
value = 0
if operator == qbtypes.FilterOperatorExists {
return sb.NE(tblFieldName, value), nil
} else {
return sb.E(tblFieldName, value), nil
}
case schema.MapColumnType{
KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
ValueType: schema.ColumnTypeString,
}, schema.MapColumnType{
KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
ValueType: schema.ColumnTypeBool,
}, schema.MapColumnType{
KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
ValueType: schema.ColumnTypeFloat64,
}:
case schema.ColumnTypeEnumMap:
keyType := column.Type.(schema.MapColumnType).KeyType
if _, ok := keyType.(schema.LowCardinalityColumnType); !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "key type %s is not supported for map column type %s", keyType, column.Type)
}
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumBool, schema.ColumnTypeEnumFloat64:
leftOperand := fmt.Sprintf("mapContains(%s, '%s')", column.Name, key.Name)
if key.Materialized {
leftOperand = telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key)
@@ -213,6 +222,9 @@ func (c *conditionBuilder) conditionFor(
} else {
return sb.NE(leftOperand, true), nil
}
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for map column type %s", valueType)
}
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for column type %s", column.Type)
}

View File

@@ -239,8 +239,8 @@ func (m *defaultFieldMapper) FieldFor(
return "", err
}
switch column.Type {
case schema.JSONColumnType{}:
switch column.Type.GetType() {
case schema.ColumnTypeEnumJSON:
// json is only supported for resource context as of now
if key.FieldContext != telemetrytypes.FieldContextResource {
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource context fields are supported for json columns, got %s", key.FieldContext.String)
@@ -256,44 +256,38 @@ func (m *defaultFieldMapper) FieldFor(
} else {
return fmt.Sprintf("multiIf(%s.`%s` IS NOT NULL, %s.`%s`::String, mapContains(%s, '%s'), %s, NULL)", column.Name, key.Name, column.Name, key.Name, oldColumn.Name, key.Name, oldKeyName), nil
}
case schema.ColumnTypeString,
schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
schema.ColumnTypeUInt64,
schema.ColumnTypeUInt32,
schema.ColumnTypeInt8,
schema.ColumnTypeInt16,
schema.ColumnTypeBool,
schema.DateTime64ColumnType{Precision: 9, Timezone: "UTC"},
schema.FixedStringColumnType{Length: 32}:
case schema.ColumnTypeEnumString,
schema.ColumnTypeEnumUInt64,
schema.ColumnTypeEnumUInt32,
schema.ColumnTypeEnumInt8,
schema.ColumnTypeEnumInt16,
schema.ColumnTypeEnumBool,
schema.ColumnTypeEnumDateTime64,
schema.ColumnTypeEnumFixedString:
return column.Name, nil
case schema.MapColumnType{
KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
ValueType: schema.ColumnTypeString,
}:
case schema.ColumnTypeEnumLowCardinality:
switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
case schema.ColumnTypeEnumString:
return column.Name, nil
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "value type %s is not supported for low cardinality column type %s", elementType, column.Type)
}
case schema.ColumnTypeEnumMap:
keyType := column.Type.(schema.MapColumnType).KeyType
if _, ok := keyType.(schema.LowCardinalityColumnType); !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "key type %s is not supported for map column type %s", keyType, column.Type)
}
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumFloat64, schema.ColumnTypeEnumBool:
// a key could have been materialized, if so return the materialized column name
if key.Materialized {
return telemetrytypes.FieldKeyToMaterializedColumnName(key), nil
}
return fmt.Sprintf("%s['%s']", column.Name, key.Name), nil
case schema.MapColumnType{
KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
ValueType: schema.ColumnTypeFloat64,
}:
// a key could have been materialized, if so return the materialized column name
if key.Materialized {
return telemetrytypes.FieldKeyToMaterializedColumnName(key), nil
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "value type %s is not supported for map column type %s", valueType, column.Type)
}
return fmt.Sprintf("%s['%s']", column.Name, key.Name), nil
case schema.MapColumnType{
KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
ValueType: schema.ColumnTypeBool,
}:
// a key could have been materialized, if so return the materialized column name
if key.Materialized {
return telemetrytypes.FieldKeyToMaterializedColumnName(key), nil
}
return fmt.Sprintf("%s['%s']", column.Name, key.Name), nil
}
// should not reach here
return column.Name, nil

View File

@@ -93,6 +93,16 @@ func newConfig() factory.Config {
}
func (c Config) Validate() error {
// Ensure that lifetime idle is not negative
if c.Lifetime.Idle < 0 {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "lifetime::idle must not be negative")
}
// Ensure that lifetime max is not negative
if c.Lifetime.Max < 0 {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "lifetime::max must not be negative")
}
// Ensure that rotation interval is smaller than lifetime idle
if c.Rotation.Interval >= c.Lifetime.Idle {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "rotation::interval must be smaller than lifetime::idle")

View File

@@ -263,7 +263,7 @@ func (provider *provider) getOrSetIdentity(ctx context.Context, orgID, userID va
return nil, err
}
err = provider.cache.Set(ctx, orgID, identityCacheKey(identity.UserID), identity, -1)
err = provider.cache.Set(ctx, orgID, identityCacheKey(identity.UserID), identity, 0)
if err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to cache identity", "error", err)
}

View File

@@ -410,7 +410,7 @@ func (provider *provider) setToken(ctx context.Context, token *authtypes.Token,
}
func (provider *provider) setIdentity(ctx context.Context, identity *authtypes.Identity) error {
err := provider.cache.Set(ctx, emptyOrgID, identityCacheKey(identity.UserID), identity, -1)
err := provider.cache.Set(ctx, emptyOrgID, identityCacheKey(identity.UserID), identity, 0)
if err != nil {
return err
}
@@ -434,7 +434,7 @@ func (provider *provider) getOrGetSetIdentity(ctx context.Context, userID valuer
return nil, err
}
err = provider.cache.Set(ctx, emptyOrgID, identityCacheKey(userID), identity, -1)
err = provider.cache.Set(ctx, emptyOrgID, identityCacheKey(userID), identity, 0)
if err != nil {
return nil, err
}

View File

@@ -144,7 +144,7 @@ type TreemapRequest struct {
Start int64 `json:"start"`
End int64 `json:"end"`
Limit int `json:"limit"`
Treemap TreemapMode `json:"treemap"`
Mode TreemapMode `json:"mode"`
}
// Validate enforces basic constraints on TreemapRequest.
@@ -182,11 +182,11 @@ func (req *TreemapRequest) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be between 1 and 5000")
}
if req.Treemap != TreemapModeSamples && req.Treemap != TreemapModeTimeSeries {
if req.Mode != TreemapModeSamples && req.Mode != TreemapModeTimeSeries {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid treemap mode %q: supported values are %q or %q",
req.Treemap,
req.Mode,
TreemapModeSamples,
TreemapModeTimeSeries,
)
@@ -220,3 +220,61 @@ type TreemapResponse struct {
TimeSeries []TreemapEntry `json:"timeseries"`
Samples []TreemapEntry `json:"samples"`
}
// MetricHighlightsResponse is the output structure for the metric highlights endpoint.
type MetricHighlightsResponse struct {
DataPoints uint64 `json:"dataPoints"`
LastReceived uint64 `json:"lastReceived"`
TotalTimeSeries uint64 `json:"totalTimeSeries"`
ActiveTimeSeries uint64 `json:"activeTimeSeries"`
}
// MetricAttributesRequest represents the payload for the metric attributes endpoint.
type MetricAttributesRequest struct {
MetricName string `json:"metricName"`
Start *int64 `json:"start,omitempty"`
End *int64 `json:"end,omitempty"`
}
// Validate ensures MetricAttributesRequest contains acceptable values.
func (req *MetricAttributesRequest) Validate() error {
if req == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
}
if req.MetricName == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metric_name is required")
}
if req.Start != nil && req.End != nil {
if *req.Start >= *req.End {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "start (%d) must be less than end (%d)", *req.Start, *req.End)
}
}
return nil
}
// UnmarshalJSON validates input immediately after decoding.
func (req *MetricAttributesRequest) UnmarshalJSON(data []byte) error {
type raw MetricAttributesRequest
var decoded raw
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*req = MetricAttributesRequest(decoded)
return req.Validate()
}
// MetricAttribute represents a single attribute with its values and count.
type MetricAttribute struct {
Key string `json:"key"`
Values []string `json:"values"`
ValueCount uint64 `json:"valueCount"`
}
// MetricAttributesResponse is the output structure for the metric attributes endpoint.
type MetricAttributesResponse struct {
Attributes []MetricAttribute `json:"attributes"`
TotalKeys int64 `json:"totalKeys"`
}

View File

@@ -0,0 +1,49 @@
package parsertypes
import (
"encoding/json"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
// QueryFilterAnalyzeRequest represents the request body for query filter analysis
type QueryFilterAnalyzeRequest struct {
Query string `json:"query"`
QueryType querybuildertypesv5.QueryType `json:"queryType"`
}
// UnmarshalJSON implements custom JSON unmarshaling with validation and normalization
func (q *QueryFilterAnalyzeRequest) UnmarshalJSON(data []byte) error {
// Use a temporary struct to avoid infinite recursion
type Alias QueryFilterAnalyzeRequest
aux := (*Alias)(q)
if err := json.Unmarshal(data, aux); err != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to parse json: %v", err)
}
// Trim and validate query is not empty
q.Query = strings.TrimSpace(aux.Query)
if q.Query == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "query is required and cannot be empty")
}
// Validate query type
if aux.QueryType != querybuildertypesv5.QueryTypeClickHouseSQL && aux.QueryType != querybuildertypesv5.QueryTypePromQL {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported queryType: %v. Supported values are '%s' and '%s'", aux.QueryType, querybuildertypesv5.QueryTypePromQL, querybuildertypesv5.QueryTypeClickHouseSQL)
}
return nil
}
type ColumnInfoResponse struct {
Name string `json:"columnName"`
Alias string `json:"columnAlias"`
}
// QueryFilterAnalyzeResponse represents the response body for query filter analysis
type QueryFilterAnalyzeResponse struct {
MetricNames []string `json:"metricNames"`
Groups []ColumnInfoResponse `json:"groups"`
}

View File

@@ -17,6 +17,10 @@ var (
FieldSelectorMatchTypeFuzzy = FieldSelectorMatchType{valuer.NewString("fuzzy")}
)
// BodyJSONStringSearchPrefix is the prefix used for body JSON search queries
// e.g., "body.status" where "body." is the prefix
const BodyJSONStringSearchPrefix = `body.`
type TelemetryFieldKey struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
@@ -24,7 +28,10 @@ type TelemetryFieldKey struct {
Signal Signal `json:"signal,omitempty"`
FieldContext FieldContext `json:"fieldContext,omitempty"`
FieldDataType FieldDataType `json:"fieldDataType,omitempty"`
Materialized bool `json:"-"`
JSONDataType *JSONDataType `json:"-,omitempty"`
Indexes []JSONDataTypeIndex `json:"-"`
Materialized bool `json:"-"` // refers to promoted in case of body.... fields
}
func (f TelemetryFieldKey) String() string {

View File

@@ -36,6 +36,9 @@ import (
//
// - Use `log.` for explicit log context
// - `log.severity_text` will always resolve to `severity_text` of log record
//
// - Use `body.` to indicate and enforce body context
// - `body.key` will look for `key` in the body field
type FieldContext struct {
valuer.String
}
@@ -49,6 +52,7 @@ var (
FieldContextScope = FieldContext{valuer.NewString("scope")}
FieldContextAttribute = FieldContext{valuer.NewString("attribute")}
FieldContextEvent = FieldContext{valuer.NewString("event")}
FieldContextBody = FieldContext{valuer.NewString("body")}
FieldContextUnspecified = FieldContext{valuer.NewString("")}
// Map string representations to FieldContext values
@@ -65,6 +69,7 @@ var (
"point": FieldContextAttribute,
"attribute": FieldContextAttribute,
"event": FieldContextEvent,
"body": FieldContextBody,
"spanfield": FieldContextSpan,
"span": FieldContextSpan,
"logfield": FieldContextLog,
@@ -144,6 +149,8 @@ func (f FieldContext) TagType() string {
return "metricfield"
case FieldContextEvent:
return "eventfield"
case FieldContextBody:
return "body"
}
return ""
}

View File

@@ -31,6 +31,9 @@ var (
FieldDataTypeArrayInt64 = FieldDataType{valuer.NewString("[]int64")}
FieldDataTypeArrayNumber = FieldDataType{valuer.NewString("[]number")}
FieldDataTypeArrayObject = FieldDataType{valuer.NewString("[]object")}
FieldDataTypeArrayDynamic = FieldDataType{valuer.NewString("[]dynamic")}
// Map string representations to FieldDataType values
// We want to handle all the possible string representations of the data types.
// Even if the user uses some non-standard representation, we want to be able to

View File

@@ -1,6 +1,7 @@
package telemetrytypes
import (
"reflect"
"testing"
)
@@ -86,7 +87,7 @@ func TestGetFieldKeyFromKeyText(t *testing.T) {
for _, testCase := range testCases {
result := GetFieldKeyFromKeyText(testCase.keyText)
if result != testCase.expected {
if !reflect.DeepEqual(result, testCase.expected) {
t.Errorf("expected %v, got %v", testCase.expected, result)
}
}

View File

@@ -0,0 +1,80 @@
package telemetrytypes
type JSONDataTypeIndex struct {
Type JSONDataType
ColumnExpression string
IndexExpression string
}
type JSONDataType struct {
str string // Store the correct case for ClickHouse
IsArray bool
ScalerType string
IndexSupported bool
}
// Override StringValue to return the correct case
func (jdt JSONDataType) StringValue() string {
return jdt.str
}
var (
String = JSONDataType{"String", false, "", true}
Int64 = JSONDataType{"Int64", false, "", true}
Float64 = JSONDataType{"Float64", false, "", true}
Bool = JSONDataType{"Bool", false, "", false}
Dynamic = JSONDataType{"Dynamic", false, "", false}
ArrayString = JSONDataType{"Array(Nullable(String))", true, "String", false}
ArrayInt64 = JSONDataType{"Array(Nullable(Int64))", true, "Int64", false}
ArrayFloat64 = JSONDataType{"Array(Nullable(Float64))", true, "Float64", false}
ArrayBool = JSONDataType{"Array(Nullable(Bool))", true, "Bool", false}
ArrayDynamic = JSONDataType{"Array(Dynamic)", true, "Dynamic", false}
ArrayJSON = JSONDataType{"Array(JSON)", true, "JSON", false}
)
var MappingStringToJSONDataType = map[string]JSONDataType{
"String": String,
"Int64": Int64,
"Float64": Float64,
"Bool": Bool,
"Dynamic": Dynamic,
"Array(Nullable(String))": ArrayString,
"Array(Nullable(Int64))": ArrayInt64,
"Array(Nullable(Float64))": ArrayFloat64,
"Array(Nullable(Bool))": ArrayBool,
"Array(Dynamic)": ArrayDynamic,
"Array(JSON)": ArrayJSON,
}
var ScalerTypeToArrayType = map[JSONDataType]JSONDataType{
String: ArrayString,
Int64: ArrayInt64,
Float64: ArrayFloat64,
Bool: ArrayBool,
Dynamic: ArrayDynamic,
}
var MappingFieldDataTypeToJSONDataType = map[FieldDataType]JSONDataType{
FieldDataTypeString: String,
FieldDataTypeInt64: Int64,
FieldDataTypeFloat64: Float64,
FieldDataTypeNumber: Float64,
FieldDataTypeBool: Bool,
FieldDataTypeArrayString: ArrayString,
FieldDataTypeArrayInt64: ArrayInt64,
FieldDataTypeArrayFloat64: ArrayFloat64,
FieldDataTypeArrayBool: ArrayBool,
}
var MappingJSONDataTypeToFieldDataType = map[JSONDataType]FieldDataType{
String: FieldDataTypeString,
Int64: FieldDataTypeInt64,
Float64: FieldDataTypeFloat64,
Bool: FieldDataTypeBool,
ArrayString: FieldDataTypeArrayString,
ArrayInt64: FieldDataTypeArrayInt64,
ArrayFloat64: FieldDataTypeArrayFloat64,
ArrayBool: FieldDataTypeArrayBool,
ArrayDynamic: FieldDataTypeArrayDynamic,
ArrayJSON: FieldDataTypeArrayObject,
}

View File

@@ -11,7 +11,7 @@ type ThirdPartyApiRequest struct {
ShowIp bool `json:"show_ip,omitempty"`
Domain string `json:"domain,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Filter *qbtypes.Filter `json:"filters,omitempty"`
Filter *qbtypes.Filter `json:"filter,omitempty"`
GroupBy []qbtypes.GroupByKey `json:"groupBy,omitempty"`
}