Compare commits

..

9 Commits

Author SHA1 Message Date
Vinícius Lourenço
e4b2c5d894 chore: authz helpers 2026-02-19 17:39:05 -03:00
Abhi kumar
9f4d9eef46 fix: added fix for tooltip prox on hover in uplot (#10345)
* fix: added fix for tooltip prox on hover in uplot

* fix: added fix for series highlighting on focus

* fix: added support for stepinterval

* chore: added tests

* chore: pr review comments
2026-02-19 15:47:42 +05:30
Yunus M
d9cddbfb42 fix: hide scrollbar in variable selector as we expand the container horizontally (#10357) 2026-02-19 08:20:41 +00:00
Srikanth Chekuri
0828c13f7c chore: add list metrics (#10352) 2026-02-19 07:45:14 +00:00
Nikhil Soni
2c558b9bf7 fix: fix incosistent use of http attribute in ext. api (#10169)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: fix incosistent use of http attribute in ext. api

HTTP attributes like http.url, url.full along with server.name and net.peer.name
were used inconsitantly leading to bugs in aggregation query and they were
expensive to query as well since these attr are stored as json instead of
direct columns. Using columns like http_url optimises these queries since
it gets populated using all relevant attributes during ingestion itself.

* fix: switch to using http_host instead of external_http_url

external_http_url stores the hostname but the name
is confusing, so switching to http_host

* fix: use constants defined where possible

* fix: fix old attribute usage in tests
2026-02-19 06:21:32 +00:00
Yunus M
48b588fe48 chore: migrate .cursorrules to .cursor/rules/ format (#10351) 2026-02-19 11:28:02 +05:30
Srikanth Chekuri
5c86b80682 chore: add OpenAPI spec for /v5/query_range (#10239)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-02-18 20:21:37 +00:00
Piyush Singariya
75512a81c6 fix: json qb array expression (#10162)
* fix: json qb array expression

* fix: comment

* fix: unnecessary casting of float64

* revert: changes

---------

Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2026-02-18 23:31:23 +05:30
Karan Balani
6aaea79b73 chore: add tests for unique index on email and org_id in users table (#10331)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: add tests for unique index on email and org_id in users table

* chore: choose one status code

* chore: choose one status code

* chore: move unique index test into passwordauthn folder

* chore: moved to existing suite, remove register

* chore: better name for file and vars

* chore: fix var name

* chore: fix var name

* chore: fix var name
2026-02-18 19:05:50 +05:30
89 changed files with 8433 additions and 4323 deletions

7
.gitignore vendored
View File

@@ -1,8 +1,11 @@
node_modules
# editor
.vscode
!.vscode/settings.json
.zed
.idea
deploy/docker/environment_tiny/common_test
frontend/node_modules
@@ -31,8 +34,6 @@ frontend/yarn-debug.log*
frontend/yarn-error.log*
frontend/src/constants/env.ts
.idea
**/build
**/storage
**/locust-scripts/__pycache__/
@@ -229,5 +230,3 @@ cython_debug/
pyrightconfig.json
# cursor files
frontend/.cursor/

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ import (
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/middleware"
querierAPI "github.com/SigNoz/signoz/pkg/querier"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
@@ -106,7 +107,10 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost)
// v5
router.HandleFunc("/api/v5/query_range", am.ViewAccess(ah.queryRangeV5)).Methods(http.MethodPost)
router.Handle("/api/v5/query_range", handler.New(
am.ViewAccess(ah.queryRangeV5),
querierAPI.QueryRangeV5OpenAPIDef,
)).Methods(http.MethodPost)
router.HandleFunc("/api/v5/substitute_vars", am.ViewAccess(ah.QuerierAPI.ReplaceVariables)).Methods(http.MethodPost)

View File

@@ -0,0 +1,74 @@
---
description: Core testing conventions - imports, rendering, MSW, interactions, queries
globs: **/*.test.{ts,tsx}
alwaysApply: false
---
# Testing Conventions
## Imports
Always import from the test harness, never directly from `@testing-library/react`:
```ts
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { server, rest } from 'mocks-server/server';
```
## Router
Use the built-in router in `render`:
```ts
render(<Page />, undefined, { initialRoute: '/traces-explorer' });
```
Only mock `useLocation` / `useParams` if the test depends on their values.
## MSW
Global MSW server runs automatically. Override per-test:
```ts
server.use(
rest.get('*/api/v1/foo', (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ ok: true })))
);
```
Keep large response fixtures in `mocks-server/__mockdata_`.
## Interactions
- Prefer `userEvent` for real user interactions (click, type, select, tab).
- Use `fireEvent` only for low-level events not covered by `userEvent` (e.g., scroll, resize). Wrap in `act(...)` if needed.
- Always `await` interactions:
```ts
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByRole('button', { name: /save/i }));
```
## Timers
No global fake timers. Per-test only, for debounce/throttle:
```ts
jest.useFakeTimers();
const user = userEvent.setup({ advanceTimers: (ms) => jest.advanceTimersByTime(ms) });
await user.type(screen.getByRole('textbox'), 'query');
jest.advanceTimersByTime(400);
jest.useRealTimers();
```
## Queries
Prefer accessible queries: `getByRole` > `findByRole` > `getByLabelText` > visible text > `data-testid` (last resort).
## Anti-patterns
- Never import from `@testing-library/react` directly
- Never use global fake timers
- Never wrap `render` in `act(...)`
- Never mock infra dependencies locally (router, react-query)
- Limit to 3-5 focused tests per file

View File

@@ -0,0 +1,54 @@
---
description: When to use global vs local mocks in tests
globs: **/*.test.{ts,tsx}
alwaysApply: false
---
# Mock Strategy
## Use Global Mocks For
High-frequency dependencies (20+ test files):
- Core infrastructure: react-router-dom, react-query, antd
- Browser APIs: ResizeObserver, matchMedia, localStorage
- Utility libraries: date-fns, lodash
Available global mock files (from jest.config.ts):
- `uplot` -> `__mocks__/uplotMock.ts`
## Use Local Mocks For
- Business logic dependencies (API endpoints, custom hooks, domain components)
- Test-specific behavior (different data per test, error scenarios, loading states)
## Decision Tree
```
Used in 20+ test files?
YES -> Global mock
NO -> Business logic or test-specific?
YES -> Local mock
NO -> Consider global if usage grows
```
## Correct Usage
```ts
// Global mocks are already available - just import
import { useLocation } from 'react-router-dom';
// Local mocks for business logic
jest.mock('../api/tracesService', () => ({
getTraces: jest.fn(() => mockTracesData),
}));
```
## Anti-patterns
```ts
// Never re-mock globally mocked dependencies locally
jest.mock('react-router-dom', () => ({ ... }));
// Never put test-specific data in global mocks
jest.mock('../api/tracesService', () => ({ getTraces: jest.fn(() => specificTestData) }));
```

View File

@@ -0,0 +1,54 @@
---
description: TypeScript type safety requirements for Jest tests
globs: **/*.test.{ts,tsx}
alwaysApply: false
---
# TypeScript Type Safety in Tests
All Jest tests must be fully type-safe. Never use `any`.
## Mock Function Typing
```ts
// Use jest.mocked for module mocks
import useFoo from 'hooks/useFoo';
jest.mock('hooks/useFoo');
const mockUseFoo = jest.mocked(useFoo);
// Use jest.MockedFunction for standalone mocks
const mockFetch = jest.fn() as jest.MockedFunction<(id: number) => Promise<User>>;
```
## Mock Data
Define interfaces for all mock data:
```ts
const mockUser: User = { id: 1, name: 'John', email: 'john@example.com' };
const mockProps: ComponentProps = {
title: 'Test',
data: [mockUser],
onSelect: jest.fn() as jest.MockedFunction<(user: User) => void>,
};
```
## Hook Mocking Pattern
```ts
import useFoo from 'hooks/useFoo';
jest.mock('hooks/useFoo');
const mockUseFoo = jest.mocked(useFoo);
mockUseFoo.mockReturnValue(/* minimal shape */);
```
Prefer helpers (`rqSuccess`, `rqLoading`, `rqError`) for React Query results.
## Checklist
- All mock functions use `jest.MockedFunction<T>` or `jest.mocked()`
- All mock data has proper interfaces
- No `any` types in test files
- Component props are typed
- API response types are defined

View File

@@ -1,484 +0,0 @@
# Persona
You are an expert developer with deep knowledge of Jest, React Testing Library, MSW, and TypeScript, tasked with creating unit tests for this repository.
# Auto-detect TypeScript Usage
Check for TypeScript in the project through tsconfig.json or package.json dependencies.
Adjust syntax based on this detection.
# TypeScript Type Safety for Jest Tests
**CRITICAL**: All Jest tests MUST be fully type-safe with proper TypeScript types.
**Type Safety Requirements:**
- Use proper TypeScript interfaces for all mock data
- Type all Jest mock functions with `jest.MockedFunction<T>`
- Use generic types for React components and hooks
- Define proper return types for mock functions
- Use `as const` for literal types when needed
- Avoid `any` type use proper typing instead
# Unit Testing Focus
Focus on critical functionality (business logic, utility functions, component behavior)
Mock dependencies (API calls, external modules) before imports
Test multiple data scenarios (valid inputs, invalid inputs, edge cases)
Write maintainable tests with descriptive names grouped in describe blocks
# Global vs Local Mocks
**Use Global Mocks for:**
- High-frequency dependencies (20+ test files)
- Core infrastructure (react-router-dom, react-query, antd)
- Standard implementations across the app
- Browser APIs (ResizeObserver, matchMedia, localStorage)
- Utility libraries (date-fns, lodash)
**Use Local Mocks for:**
- Business logic dependencies (5-15 test files)
- Test-specific behavior (different data per test)
- API endpoints with specific responses
- Domain-specific components
- Error scenarios and edge cases
**Global Mock Files Available (from jest.config.ts):**
- `uplot` → `__mocks__/uplotMock.ts`
# Repo-specific Testing Conventions
## Imports
Always import from our harness:
```ts
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
```
For API mocks:
```ts
import { server, rest } from 'mocks-server/server';
```
Do not import directly from `@testing-library/react`.
## Router
Use the router built into render:
```ts
render(<Page />, undefined, { initialRoute: '/traces-explorer' });
```
Only mock `useLocation` / `useParams` if the test depends on them.
## Hook Mocks
Pattern:
```ts
import useFoo from 'hooks/useFoo';
jest.mock('hooks/useFoo');
const mockUseFoo = jest.mocked(useFoo);
mockUseFoo.mockReturnValue(/* minimal shape */ as any);
```
Prefer helpers (`rqSuccess`, `rqLoading`, `rqError`) for React Query results.
## MSW
Global MSW server runs automatically.
Override per-test:
```ts
server.use(
rest.get('*/api/v1/foo', (_req, res, ctx) => res(ctx.status(200), ctx.json({ ok: true })))
);
```
Keep large responses in `mocks-server/__mockdata_`.
## Interactions
- Prefer `userEvent` for real user interactions (click, type, select, tab).
- Use `fireEvent` only for low-level/programmatic events not covered by `userEvent` (e.g., scroll, resize, setting `element.scrollTop` for virtualization). Wrap in `act(...)` if needed.
- Always await interactions:
```ts
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByRole('button', { name: /save/i }));
```
```ts
// Example: virtualized list scroll (no userEvent helper)
const scroller = container.querySelector('[data-test-id="virtuoso-scroller"]') as HTMLElement;
scroller.scrollTop = targetScrollTop;
act(() => { fireEvent.scroll(scroller); });
```
## Timers
❌ No global fake timers.
✅ Per-test only, for debounce/throttle:
```ts
jest.useFakeTimers();
const user = userEvent.setup({ advanceTimers: (ms) => jest.advanceTimersByTime(ms) });
await user.type(screen.getByRole('textbox'), 'query');
jest.advanceTimersByTime(400);
jest.useRealTimers();
```
## Queries
Prefer accessible queries (`getByRole`, `findByRole`, `getByLabelText`).
Fallback: visible text.
Last resort: `data-testid`.
# Example Test (using only configured global mocks)
```ts
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { server, rest } from 'mocks-server/server';
import MyComponent from '../MyComponent';
describe('MyComponent', () => {
it('renders and interacts', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get('*/api/v1/example', (_req, res, ctx) => res(ctx.status(200), ctx.json({ value: 42 })))
);
render(<MyComponent />, undefined, { initialRoute: '/foo' });
expect(await screen.findByText(/value: 42/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /refresh/i }));
await waitFor(() => expect(screen.getByText(/loading/i)).toBeInTheDocument());
});
});
```
# Anti-patterns
❌ Importing RTL directly
❌ Using global fake timers
❌ Wrapping render in `act(...)`
❌ Mocking infra dependencies locally (router, react-query)
✅ Use our harness (`tests/test-utils`)
✅ Use MSW for API overrides
✅ Use userEvent + await
✅ Pin time only in tests that assert relative dates
# Best Practices
- **Critical Functionality**: Prioritize testing business logic and utilities
- **Dependency Mocking**: Global mocks for infra, local mocks for business logic
- **Data Scenarios**: Always test valid, invalid, and edge cases
- **Descriptive Names**: Make test intent clear
- **Organization**: Group related tests in describe
- **Consistency**: Match repo conventions
- **Edge Cases**: Test null, undefined, unexpected values
- **Limit Scope**: 35 focused tests per file
- **Use Helpers**: `rqSuccess`, `makeUser`, etc.
- **No Any**: Enforce type safety
# Example Test
```ts
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { server, rest } from 'mocks-server/server';
import MyComponent from '../MyComponent';
describe('MyComponent', () => {
it('renders and interacts', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get('*/api/v1/example', (_req, res, ctx) => res(ctx.status(200), ctx.json({ value: 42 })))
);
render(<MyComponent />, undefined, { initialRoute: '/foo' });
expect(await screen.findByText(/value: 42/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /refresh/i }));
await waitFor(() => expect(screen.getByText(/loading/i)).toBeInTheDocument());
});
});
```
# Anti-patterns
❌ Importing RTL directly
❌ Using global fake timers
❌ Wrapping render in `act(...)`
❌ Mocking infra dependencies locally (router, react-query)
✅ Use our harness (`tests/test-utils`)
✅ Use MSW for API overrides
✅ Use userEvent + await
✅ Pin time only in tests that assert relative dates
# TypeScript Type Safety Examples
## Proper Mock Typing
```ts
// ✅ GOOD - Properly typed mocks
interface User {
id: number;
name: string;
email: string;
}
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
// Type the mock functions
const mockFetchUser = jest.fn() as jest.MockedFunction<(id: number) => Promise<ApiResponse<User>>>;
const mockUpdateUser = jest.fn() as jest.MockedFunction<(user: User) => Promise<ApiResponse<User>>>;
// Mock implementation with proper typing
mockFetchUser.mockResolvedValue({
data: { id: 1, name: 'John Doe', email: 'john@example.com' },
status: 200,
message: 'Success'
});
// ❌ BAD - Using any type
const mockFetchUser = jest.fn() as any; // Don't do this
```
## React Component Testing with Types
```ts
// ✅ GOOD - Properly typed component testing
interface ComponentProps {
title: string;
data: User[];
onUserSelect: (user: User) => void;
isLoading?: boolean;
}
const TestComponent: React.FC<ComponentProps> = ({ title, data, onUserSelect, isLoading = false }) => {
// Component implementation
};
describe('TestComponent', () => {
it('should render with proper props', () => {
// Arrange - Type the props properly
const mockProps: ComponentProps = {
title: 'Test Title',
data: [{ id: 1, name: 'John', email: 'john@example.com' }],
onUserSelect: jest.fn() as jest.MockedFunction<(user: User) => void>,
isLoading: false
};
// Act
render(<TestComponent {...mockProps} />);
// Assert
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
});
```
## Hook Testing with Types
```ts
// ✅ GOOD - Properly typed hook testing
interface UseUserDataReturn {
user: User | null;
loading: boolean;
error: string | null;
refetch: () => void;
}
const useUserData = (id: number): UseUserDataReturn => {
// Hook implementation
};
describe('useUserData', () => {
it('should return user data with proper typing', () => {
// Arrange
const mockUser: User = { id: 1, name: 'John', email: 'john@example.com' };
mockFetchUser.mockResolvedValue({
data: mockUser,
status: 200,
message: 'Success'
});
// Act
const { result } = renderHook(() => useUserData(1));
// Assert
expect(result.current.user).toEqual(mockUser);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
});
```
## Global Mock Type Safety
```ts
// ✅ GOOD - Type-safe global mocks
// In __mocks__/routerMock.ts
export const mockUseLocation = (overrides: Partial<Location> = {}): Location => ({
pathname: '/traces',
search: '',
hash: '',
state: null,
key: 'test-key',
...overrides,
});
// In test files
const location = useLocation(); // Properly typed from global mock
expect(location.pathname).toBe('/traces');
```
# TypeScript Configuration for Jest
## Required Jest Configuration
```json
// jest.config.ts
{
"preset": "ts-jest/presets/js-with-ts-esm",
"globals": {
"ts-jest": {
"useESM": true,
"isolatedModules": true,
"tsconfig": "<rootDir>/tsconfig.jest.json"
}
},
"extensionsToTreatAsEsm": [".ts", ".tsx"],
"moduleFileExtensions": ["ts", "tsx", "js", "json"]
}
```
## TypeScript Jest Configuration
```json
// tsconfig.jest.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "@testing-library/jest-dom"],
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node"
},
"include": [
"src/**/*",
"**/*.test.ts",
"**/*.test.tsx",
"__mocks__/**/*"
]
}
```
## Common Type Safety Patterns
### Mock Function Typing
```ts
// ✅ GOOD - Proper mock function typing
const mockApiCall = jest.fn() as jest.MockedFunction<typeof apiCall>;
const mockEventHandler = jest.fn() as jest.MockedFunction<(event: Event) => void>;
// ❌ BAD - Using any
const mockApiCall = jest.fn() as any;
```
### Generic Mock Typing
```ts
// ✅ GOOD - Generic mock typing
interface MockApiResponse<T> {
data: T;
status: number;
}
const mockFetchData = jest.fn() as jest.MockedFunction<
<T>(endpoint: string) => Promise<MockApiResponse<T>>
>;
// Usage
mockFetchData<User>('/users').mockResolvedValue({
data: { id: 1, name: 'John' },
status: 200
});
```
### React Testing Library with Types
```ts
// ✅ GOOD - Typed testing utilities
import { render, screen, RenderResult } from '@testing-library/react';
import { ComponentProps } from 'react';
type TestComponentProps = ComponentProps<typeof TestComponent>;
const renderTestComponent = (props: Partial<TestComponentProps> = {}): RenderResult => {
const defaultProps: TestComponentProps = {
title: 'Test',
data: [],
onSelect: jest.fn(),
...props
};
return render(<TestComponent {...defaultProps} />);
};
```
### Error Handling with Types
```ts
// ✅ GOOD - Typed error handling
interface ApiError {
message: string;
code: number;
details?: Record<string, unknown>;
}
const mockApiError: ApiError = {
message: 'API Error',
code: 500,
details: { endpoint: '/users' }
};
mockFetchUser.mockRejectedValue(new Error(JSON.stringify(mockApiError)));
```
## Type Safety Checklist
- [ ] All mock functions use `jest.MockedFunction<T>`
- [ ] All mock data has proper interfaces
- [ ] No `any` types in test files
- [ ] Generic types are used where appropriate
- [ ] Error types are properly defined
- [ ] Component props are typed
- [ ] Hook return types are defined
- [ ] API response types are defined
- [ ] Global mocks are type-safe
- [ ] Test utilities are properly typed
# Mock Decision Tree
```
Is it used in 20+ test files?
├─ YES → Use Global Mock
│ ├─ react-router-dom
│ ├─ react-query
│ ├─ antd components
│ └─ browser APIs
└─ NO → Is it business logic?
├─ YES → Use Local Mock
│ ├─ API endpoints
│ ├─ Custom hooks
│ └─ Domain components
└─ NO → Is it test-specific?
├─ YES → Use Local Mock
│ ├─ Error scenarios
│ ├─ Loading states
│ └─ Specific data
└─ NO → Consider Global Mock
└─ If it becomes frequently used
```
# Common Anti-Patterns to Avoid
❌ **Don't mock global dependencies locally:**
```js
// BAD - This is already globally mocked
jest.mock('react-router-dom', () => ({ ... }));
```
❌ **Don't create global mocks for test-specific data:**
```js
// BAD - This should be local
jest.mock('../api/tracesService', () => ({
getTraces: jest.fn(() => specificTestData)
}));
```
✅ **Do use global mocks for infrastructure:**
```js
// GOOD - Use global mock
import { useLocation } from 'react-router-dom';
```
✅ **Do create local mocks for business logic:**
```js
// GOOD - Local mock for specific test needs
jest.mock('../api/tracesService', () => ({
getTraces: jest.fn(() => mockTracesData)
}));
```

View File

@@ -20,17 +20,20 @@ import { useMutation, useQuery } from 'react-query';
import { GeneratedAPIInstance } from '../../../index';
import type {
GetMetricAlerts200,
GetMetricAlertsParams,
GetMetricAlertsPathParameters,
GetMetricAttributes200,
GetMetricAttributesParams,
GetMetricAttributesPathParameters,
GetMetricDashboards200,
GetMetricDashboardsParams,
GetMetricDashboardsPathParameters,
GetMetricHighlights200,
GetMetricHighlightsParams,
GetMetricHighlightsPathParameters,
GetMetricMetadata200,
GetMetricMetadataParams,
GetMetricMetadataPathParameters,
GetMetricsStats200,
GetMetricsTreemap200,
MetricsexplorertypesMetricAttributesRequestDTO,
ListMetrics200,
ListMetricsParams,
MetricsexplorertypesStatsRequestDTO,
MetricsexplorertypesTreemapRequestDTO,
MetricsexplorertypesUpdateMetricMetadataRequestDTO,
@@ -43,30 +46,128 @@ type AwaitedInput<T> = PromiseLike<T> | T;
type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
/**
* This endpoint returns associated alerts for a specified metric
* @summary Get metric alerts
* This endpoint returns a list of distinct metric names within the specified time range
* @summary List metric names
*/
export const getMetricAlerts = (
params: GetMetricAlertsParams,
export const listMetrics = (
params?: ListMetricsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricAlerts200>({
url: `/api/v2/metric/alerts`,
return GeneratedAPIInstance<ListMetrics200>({
url: `/api/v2/metrics`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricAlertsQueryKey = (params?: GetMetricAlertsParams) => {
return ['getMetricAlerts', ...(params ? [params] : [])] as const;
export const getListMetricsQueryKey = (params?: ListMetricsParams) => {
return ['listMetrics', ...(params ? [params] : [])] as const;
};
export const getListMetricsQueryOptions = <
TData = Awaited<ReturnType<typeof listMetrics>>,
TError = RenderErrorResponseDTO
>(
params?: ListMetricsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listMetrics>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListMetricsQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listMetrics>>> = ({
signal,
}) => listMetrics(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listMetrics>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListMetricsQueryResult = NonNullable<
Awaited<ReturnType<typeof listMetrics>>
>;
export type ListMetricsQueryError = RenderErrorResponseDTO;
/**
* @summary List metric names
*/
export function useListMetrics<
TData = Awaited<ReturnType<typeof listMetrics>>,
TError = RenderErrorResponseDTO
>(
params?: ListMetricsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listMetrics>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListMetricsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary List metric names
*/
export const invalidateListMetrics = async (
queryClient: QueryClient,
params?: ListMetricsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListMetricsQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint returns associated alerts for a specified metric
* @summary Get metric alerts
*/
export const getMetricAlerts = (
{ metricName }: GetMetricAlertsPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricAlerts200>({
url: `/api/v2/metrics/${metricName}/alerts`,
method: 'GET',
signal,
});
};
export const getGetMetricAlertsQueryKey = ({
metricName,
}: GetMetricAlertsPathParameters) => {
return ['getMetricAlerts'] as const;
};
export const getGetMetricAlertsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricAlertsParams,
{ metricName }: GetMetricAlertsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
@@ -77,13 +178,19 @@ export const getGetMetricAlertsQueryOptions = <
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetMetricAlertsQueryKey(params);
const queryKey =
queryOptions?.queryKey ?? getGetMetricAlertsQueryKey({ metricName });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getMetricAlerts>>> = ({
signal,
}) => getMetricAlerts(params, signal);
}) => getMetricAlerts({ metricName }, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
TError,
TData
@@ -103,7 +210,7 @@ export function useGetMetricAlerts<
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricAlertsParams,
{ metricName }: GetMetricAlertsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
@@ -112,7 +219,7 @@ export function useGetMetricAlerts<
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricAlertsQueryOptions(params, options);
const queryOptions = getGetMetricAlertsQueryOptions({ metricName }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -128,11 +235,126 @@ export function useGetMetricAlerts<
*/
export const invalidateGetMetricAlerts = async (
queryClient: QueryClient,
params: GetMetricAlertsParams,
{ metricName }: GetMetricAlertsPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricAlertsQueryKey(params) },
{ queryKey: getGetMetricAlertsQueryKey({ metricName }) },
options,
);
return queryClient;
};
/**
* This endpoint returns attribute keys and their unique values for a specified metric
* @summary Get metric attributes
*/
export const getMetricAttributes = (
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricAttributes200>({
url: `/api/v2/metrics/${metricName}/attributes`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricAttributesQueryKey = (
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
) => {
return ['getMetricAttributes', ...(params ? [params] : [])] as const;
};
export const getGetMetricAttributesQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricAttributes>>,
TError = RenderErrorResponseDTO
>(
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetMetricAttributesQueryKey({ metricName }, params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricAttributes>>
> = ({ signal }) => getMetricAttributes({ metricName }, params, signal);
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricAttributesQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricAttributes>>
>;
export type GetMetricAttributesQueryError = RenderErrorResponseDTO;
/**
* @summary Get metric attributes
*/
export function useGetMetricAttributes<
TData = Awaited<ReturnType<typeof getMetricAttributes>>,
TError = RenderErrorResponseDTO
>(
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricAttributesQueryOptions(
{ metricName },
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get metric attributes
*/
export const invalidateGetMetricAttributes = async (
queryClient: QueryClient,
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricAttributesQueryKey({ metricName }, params) },
options,
);
@@ -144,28 +366,27 @@ export const invalidateGetMetricAlerts = async (
* @summary Get metric dashboards
*/
export const getMetricDashboards = (
params: GetMetricDashboardsParams,
{ metricName }: GetMetricDashboardsPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricDashboards200>({
url: `/api/v2/metric/dashboards`,
url: `/api/v2/metrics/${metricName}/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricDashboardsQueryKey = (
params?: GetMetricDashboardsParams,
) => {
return ['getMetricDashboards', ...(params ? [params] : [])] as const;
export const getGetMetricDashboardsQueryKey = ({
metricName,
}: GetMetricDashboardsPathParameters) => {
return ['getMetricDashboards'] as const;
};
export const getGetMetricDashboardsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricDashboardsParams,
{ metricName }: GetMetricDashboardsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
@@ -177,13 +398,18 @@ export const getGetMetricDashboardsQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey(params);
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey({ metricName });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricDashboards>>
> = ({ signal }) => getMetricDashboards(params, signal);
> = ({ signal }) => getMetricDashboards({ metricName }, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
TError,
TData
@@ -203,7 +429,7 @@ export function useGetMetricDashboards<
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricDashboardsParams,
{ metricName }: GetMetricDashboardsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
@@ -212,7 +438,10 @@ export function useGetMetricDashboards<
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricDashboardsQueryOptions(params, options);
const queryOptions = getGetMetricDashboardsQueryOptions(
{ metricName },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -228,11 +457,11 @@ export function useGetMetricDashboards<
*/
export const invalidateGetMetricDashboards = async (
queryClient: QueryClient,
params: GetMetricDashboardsParams,
{ metricName }: GetMetricDashboardsPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricDashboardsQueryKey(params) },
{ queryKey: getGetMetricDashboardsQueryKey({ metricName }) },
options,
);
@@ -244,28 +473,27 @@ export const invalidateGetMetricDashboards = async (
* @summary Get metric highlights
*/
export const getMetricHighlights = (
params: GetMetricHighlightsParams,
{ metricName }: GetMetricHighlightsPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricHighlights200>({
url: `/api/v2/metric/highlights`,
url: `/api/v2/metrics/${metricName}/highlights`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricHighlightsQueryKey = (
params?: GetMetricHighlightsParams,
) => {
return ['getMetricHighlights', ...(params ? [params] : [])] as const;
export const getGetMetricHighlightsQueryKey = ({
metricName,
}: GetMetricHighlightsPathParameters) => {
return ['getMetricHighlights'] as const;
};
export const getGetMetricHighlightsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricHighlightsParams,
{ metricName }: GetMetricHighlightsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
@@ -277,13 +505,18 @@ export const getGetMetricHighlightsQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricHighlightsQueryKey(params);
queryOptions?.queryKey ?? getGetMetricHighlightsQueryKey({ metricName });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricHighlights>>
> = ({ signal }) => getMetricHighlights(params, signal);
> = ({ signal }) => getMetricHighlights({ metricName }, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
TError,
TData
@@ -303,7 +536,7 @@ export function useGetMetricHighlights<
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricHighlightsParams,
{ metricName }: GetMetricHighlightsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
@@ -312,7 +545,10 @@ export function useGetMetricHighlights<
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricHighlightsQueryOptions(params, options);
const queryOptions = getGetMetricHighlightsQueryOptions(
{ metricName },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -328,11 +564,115 @@ export function useGetMetricHighlights<
*/
export const invalidateGetMetricHighlights = async (
queryClient: QueryClient,
params: GetMetricHighlightsParams,
{ metricName }: GetMetricHighlightsPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricHighlightsQueryKey(params) },
{ queryKey: getGetMetricHighlightsQueryKey({ metricName }) },
options,
);
return queryClient;
};
/**
* This endpoint returns metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
* @summary Get metric metadata
*/
export const getMetricMetadata = (
{ metricName }: GetMetricMetadataPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricMetadata200>({
url: `/api/v2/metrics/${metricName}/metadata`,
method: 'GET',
signal,
});
};
export const getGetMetricMetadataQueryKey = ({
metricName,
}: GetMetricMetadataPathParameters) => {
return ['getMetricMetadata'] as const;
};
export const getGetMetricMetadataQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = RenderErrorResponseDTO
>(
{ metricName }: GetMetricMetadataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricMetadataQueryKey({ metricName });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricMetadata>>
> = ({ signal }) => getMetricMetadata({ metricName }, signal);
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricMetadataQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricMetadata>>
>;
export type GetMetricMetadataQueryError = RenderErrorResponseDTO;
/**
* @summary Get metric metadata
*/
export function useGetMetricMetadata<
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = RenderErrorResponseDTO
>(
{ metricName }: GetMetricMetadataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricMetadataQueryOptions({ metricName }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get metric metadata
*/
export const invalidateGetMetricMetadata = async (
queryClient: QueryClient,
{ metricName }: GetMetricMetadataPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricMetadataQueryKey({ metricName }) },
options,
);
@@ -438,189 +778,6 @@ export const useUpdateMetricMetadata = <
return useMutation(mutationOptions);
};
/**
* This endpoint returns attribute keys and their unique values for a specified metric
* @summary Get metric attributes
*/
export const getMetricAttributes = (
metricsexplorertypesMetricAttributesRequestDTO: MetricsexplorertypesMetricAttributesRequestDTO,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricAttributes200>({
url: `/api/v2/metrics/attributes`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: metricsexplorertypesMetricAttributesRequestDTO,
signal,
});
};
export const getGetMetricAttributesMutationOptions = <
TError = RenderErrorResponseDTO,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
{ data: MetricsexplorertypesMetricAttributesRequestDTO },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
{ data: MetricsexplorertypesMetricAttributesRequestDTO },
TContext
> => {
const mutationKey = ['getMetricAttributes'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof getMetricAttributes>>,
{ data: MetricsexplorertypesMetricAttributesRequestDTO }
> = (props) => {
const { data } = props ?? {};
return getMetricAttributes(data);
};
return { mutationFn, ...mutationOptions };
};
export type GetMetricAttributesMutationResult = NonNullable<
Awaited<ReturnType<typeof getMetricAttributes>>
>;
export type GetMetricAttributesMutationBody = MetricsexplorertypesMetricAttributesRequestDTO;
export type GetMetricAttributesMutationError = RenderErrorResponseDTO;
/**
* @summary Get metric attributes
*/
export const useGetMetricAttributes = <
TError = RenderErrorResponseDTO,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
{ data: MetricsexplorertypesMetricAttributesRequestDTO },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
{ data: MetricsexplorertypesMetricAttributesRequestDTO },
TContext
> => {
const mutationOptions = getGetMetricAttributesMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint returns metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
* @summary Get metric metadata
*/
export const getMetricMetadata = (
params: GetMetricMetadataParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricMetadata200>({
url: `/api/v2/metrics/metadata`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricMetadataQueryKey = (
params?: GetMetricMetadataParams,
) => {
return ['getMetricMetadata', ...(params ? [params] : [])] as const;
};
export const getGetMetricMetadataQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricMetadataQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricMetadata>>
> = ({ signal }) => getMetricMetadata(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricMetadataQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricMetadata>>
>;
export type GetMetricMetadataQueryError = RenderErrorResponseDTO;
/**
* @summary Get metric metadata
*/
export function useGetMetricMetadata<
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricMetadataQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get metric metadata
*/
export const invalidateGetMetricMetadata = async (
queryClient: QueryClient,
params: GetMetricMetadataParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricMetadataQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint provides list of metrics with their number of samples and timeseries for the given time range
* @summary Get metrics statistics

View File

@@ -0,0 +1,107 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import type {
MutationFunction,
UseMutationOptions,
UseMutationResult,
} from 'react-query';
import { useMutation } from 'react-query';
import { GeneratedAPIInstance } from '../../../index';
import type {
Querybuildertypesv5QueryRangeRequestDTO,
QueryRangeV5200,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
type AwaitedInput<T> = PromiseLike<T> | T;
type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
/**
* Execute a composite query over a time range. Supports builder queries (traces, logs, metrics), formulas, trace operators, PromQL, and ClickHouse SQL.
* @summary Query range
*/
export const queryRangeV5 = (
querybuildertypesv5QueryRangeRequestDTO: Querybuildertypesv5QueryRangeRequestDTO,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<QueryRangeV5200>({
url: `/api/v5/query_range`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: querybuildertypesv5QueryRangeRequestDTO,
signal,
});
};
export const getQueryRangeV5MutationOptions = <
TError = RenderErrorResponseDTO,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof queryRangeV5>>,
TError,
{ data: Querybuildertypesv5QueryRangeRequestDTO },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof queryRangeV5>>,
TError,
{ data: Querybuildertypesv5QueryRangeRequestDTO },
TContext
> => {
const mutationKey = ['queryRangeV5'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof queryRangeV5>>,
{ data: Querybuildertypesv5QueryRangeRequestDTO }
> = (props) => {
const { data } = props ?? {};
return queryRangeV5(data);
};
return { mutationFn, ...mutationOptions };
};
export type QueryRangeV5MutationResult = NonNullable<
Awaited<ReturnType<typeof queryRangeV5>>
>;
export type QueryRangeV5MutationBody = Querybuildertypesv5QueryRangeRequestDTO;
export type QueryRangeV5MutationError = RenderErrorResponseDTO;
/**
* @summary Query range
*/
export const useQueryRangeV5 = <
TError = RenderErrorResponseDTO,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof queryRangeV5>>,
TError,
{ data: Querybuildertypesv5QueryRangeRequestDTO },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof queryRangeV5>>,
TError,
{ data: Querybuildertypesv5QueryRangeRequestDTO },
TContext
> => {
const mutationOptions = getQueryRangeV5MutationOptions(options);
return useMutation(mutationOptions);
};

File diff suppressed because it is too large Load Diff

View File

@@ -11,13 +11,10 @@ export const getMetricMetadata = async (
): Promise<SuccessResponseV2<MetricMetadataResponse> | ErrorResponseV2> => {
try {
const encodedMetricName = encodeURIComponent(metricName);
const response = await axios.get(
`/metrics/metadata?metricName=${encodedMetricName}`,
{
signal,
headers,
},
);
const response = await axios.get(`/metrics/${encodedMetricName}/metadata`, {
signal,
headers,
});
return {
httpStatusCode: response.status,

View File

@@ -0,0 +1,332 @@
import { ReactElement } from 'react-markdown/lib/react-markdown';
import { ENVIRONMENT } from 'constants/env';
import { BrandedPermission, buildPermission } from 'hooks/useAuthZ/utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import { GuardAuthZ } from './GuardAuthZ';
const BASE_URL = ENVIRONMENT.baseURL || '';
describe('GuardAuthZ', () => {
const TestChild = (): ReactElement => <div>Protected Content</div>;
const LoadingFallback = (): ReactElement => <div>Loading...</div>;
const ErrorFallback = (error: Error): ReactElement => (
<div>Error occurred: {error.message}</div>
);
const NoPermissionFallback = (_response: {
requiredPermissionName: BrandedPermission;
}): ReactElement => <div>Access denied</div>;
const NoPermissionFallbackWithSuggestions = (response: {
requiredPermissionName: BrandedPermission;
}): ReactElement => (
<div>
Access denied. Required permission: {response.requiredPermissionName}
</div>
);
it('should render children when permission is granted', async () => {
const permission = buildPermission('read', 'dashboard:*');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: true,
}),
);
}),
);
render(
<GuardAuthZ relation="read" object="dashboard:*">
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(screen.getByText('Protected Content')).toBeInTheDocument();
});
});
it('should render fallbackOnLoading when loading', () => {
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(200), ctx.json({}));
}),
);
render(
<GuardAuthZ
relation="read"
object="dashboard:*"
fallbackOnLoading={<LoadingFallback />}
>
<TestChild />
</GuardAuthZ>,
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should render null when loading and no fallbackOnLoading provided', () => {
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(200), ctx.json({}));
}),
);
const { container } = render(
<GuardAuthZ relation="read" object="dashboard:*">
<TestChild />
</GuardAuthZ>,
);
expect(container.firstChild).toBeNull();
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should render fallbackOnError when API error occurs', async () => {
const errorMessage = 'Internal Server Error';
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: errorMessage }));
}),
);
render(
<GuardAuthZ
relation="read"
object="dashboard:*"
fallbackOnError={ErrorFallback}
>
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(screen.getByText(/Error occurred:/)).toBeInTheDocument();
});
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should pass error object to fallbackOnError function', async () => {
const errorMessage = 'Network request failed';
let receivedError: Error | null = null;
const errorFallbackWithCapture = (error: Error): ReactElement => {
receivedError = error;
return <div>Captured error: {error.message}</div>;
};
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: errorMessage }));
}),
);
render(
<GuardAuthZ
relation="read"
object="dashboard:*"
fallbackOnError={errorFallbackWithCapture}
>
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(receivedError).not.toBeNull();
});
expect(receivedError).toBeInstanceOf(Error);
expect(screen.getByText(/Captured error:/)).toBeInTheDocument();
});
it('should render null when error occurs and no fallbackOnError provided', async () => {
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
}),
);
const { container } = render(
<GuardAuthZ relation="read" object="dashboard:*">
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(container.firstChild).toBeNull();
});
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should render fallbackOnNoPermissions when permission is denied', async () => {
const permission = buildPermission('update', 'dashboard:123');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: false,
}),
);
}),
);
render(
<GuardAuthZ
relation="update"
object="dashboard:123"
fallbackOnNoPermissions={NoPermissionFallback}
>
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(screen.getByText('Access denied')).toBeInTheDocument();
});
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should render null when permission is denied and no fallbackOnNoPermissions provided', async () => {
const permission = buildPermission('update', 'dashboard:123');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: false,
}),
);
}),
);
const { container } = render(
<GuardAuthZ relation="update" object="dashboard:123">
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(container.firstChild).toBeNull();
});
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should render null when permissions object is null', async () => {
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(200), ctx.json(null));
}),
);
const { container } = render(
<GuardAuthZ relation="read" object="dashboard:*">
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(container.firstChild).toBeNull();
});
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should pass requiredPermissionName to fallbackOnNoPermissions', async () => {
const permission = buildPermission('update', 'dashboard:123');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: false,
}),
);
}),
);
render(
<GuardAuthZ
relation="update"
object="dashboard:123"
fallbackOnNoPermissions={NoPermissionFallbackWithSuggestions}
>
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(
screen.getByText(/Access denied. Required permission:/),
).toBeInTheDocument();
});
expect(screen.getByText(new RegExp(permission))).toBeInTheDocument();
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should handle different relation and object combinations', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('delete', 'dashboard:456');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (req, res, ctx) => {
const body = req.body as {
permissions: Array<{ relation: string; object: string }>;
};
const perm = body.permissions[0];
const permKey = `${perm.relation}/${perm.object}`;
if (permKey === permission1) {
return res(
ctx.status(200),
ctx.json({
[permission1]: true,
}),
);
}
return res(
ctx.status(200),
ctx.json({
[permission2]: true,
}),
);
}),
);
const { rerender } = render(
<GuardAuthZ relation="read" object="dashboard:*">
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(screen.getByText('Protected Content')).toBeInTheDocument();
});
rerender(
<GuardAuthZ relation="delete" object="dashboard:456">
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(screen.getByText('Protected Content')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,50 @@
import { ReactElement } from 'react';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import {
AuthZObject,
AuthZRelation,
BrandedPermission,
buildPermission,
} from 'hooks/useAuthZ/utils';
export type GuardAuthZProps<R extends AuthZRelation> = {
children: ReactElement;
relation: R;
object: AuthZObject<R>;
fallbackOnLoading?: JSX.Element;
fallbackOnError?: (error: Error) => JSX.Element;
fallbackOnNoPermissions?: (response: {
requiredPermissionName: BrandedPermission;
}) => JSX.Element;
};
export function GuardAuthZ<R extends AuthZRelation>({
children,
relation,
object,
fallbackOnLoading,
fallbackOnError,
fallbackOnNoPermissions,
}: GuardAuthZProps<R>): JSX.Element | null {
const permission = buildPermission<R>(relation, object);
const { permissions, isLoading, error } = useAuthZ([permission]);
if (isLoading) {
return fallbackOnLoading ?? null;
}
if (error) {
return fallbackOnError?.(error) ?? null;
}
if (!permissions?.[permission]?.isGranted) {
return (
fallbackOnNoPermissions?.({
requiredPermissionName: permission,
}) ?? null
);
}
return children;
}

View File

@@ -0,0 +1,46 @@
.guard-authz-error-no-authz {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 24px;
.guard-authz-error-no-authz-content {
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 8px;
max-width: 500px;
}
img {
width: 32px;
height: 32px;
}
h3 {
font-size: 18px;
color: var(--bg-vanilla-100);
line-height: 18px;
}
p {
font-size: 14px;
color: var(--bg-vanilla-400);
line-height: 18px;
pre {
display: flex;
align-items: center;
background-color: var(--bg-slate-400);
cursor: pointer;
svg {
margin-left: 2px;
}
}
}
}

View File

@@ -0,0 +1,489 @@
import { ReactElement } from 'react';
import type { RouteComponentProps } from 'react-router-dom';
import { ENVIRONMENT } from 'constants/env';
import { buildPermission } from 'hooks/useAuthZ/utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import { createGuardedRoute } from './createGuardedRoute';
const BASE_URL = ENVIRONMENT.baseURL || '';
describe('createGuardedRoute', () => {
const TestComponent = ({ testProp }: { testProp: string }): ReactElement => (
<div>Test Component: {testProp}</div>
);
it('should render component when permission is granted', async () => {
const permission = buildPermission('read', 'dashboard:*');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: true,
}),
);
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:*',
);
const mockMatch = {
params: {},
isExact: true,
path: '/dashboard',
url: '/dashboard',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
});
});
it('should substitute route parameters in object string', async () => {
const permission = buildPermission('read', 'dashboard:123');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: true,
}),
);
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:{id}',
);
const mockMatch = {
params: { id: '123' },
isExact: true,
path: '/dashboard/:id',
url: '/dashboard/123',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
});
});
it('should handle multiple route parameters', async () => {
const permission = buildPermission('update', 'dashboard:123:456');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: true,
}),
);
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'update',
'dashboard:{id}:{version}',
);
const mockMatch = {
params: { id: '123', version: '456' },
isExact: true,
path: '/dashboard/:id/:version',
url: '/dashboard/123/456',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
});
});
it('should keep placeholder when route parameter is missing', async () => {
const permission = buildPermission('read', 'dashboard:{id}');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: true,
}),
);
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:{id}',
);
const mockMatch = {
params: {},
isExact: true,
path: '/dashboard',
url: '/dashboard',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
});
});
it('should render loading fallback when loading', () => {
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(200), ctx.json({}));
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:*',
);
const mockMatch = {
params: {},
isExact: true,
path: '/dashboard',
url: '/dashboard',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
expect(screen.getByText('SigNoz')).toBeInTheDocument();
expect(
screen.queryByText('Test Component: test-value'),
).not.toBeInTheDocument();
});
it('should render error fallback when API error occurs', async () => {
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:*',
);
const mockMatch = {
params: {},
isExact: true,
path: '/dashboard',
url: '/dashboard',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
});
expect(
screen.queryByText('Test Component: test-value'),
).not.toBeInTheDocument();
});
it('should render no permissions fallback when permission is denied', async () => {
const permission = buildPermission('update', 'dashboard:123');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: false,
}),
);
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'update',
'dashboard:{id}',
);
const mockMatch = {
params: { id: '123' },
isExact: true,
path: '/dashboard/:id',
url: '/dashboard/123',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
const heading = document.querySelector('h3');
expect(heading).toBeInTheDocument();
expect(heading?.textContent).toMatch(/permission to view/i);
});
expect(screen.getByText(permission, { exact: false })).toBeInTheDocument();
expect(
screen.queryByText('Test Component: test-value'),
).not.toBeInTheDocument();
});
it('should pass all props to wrapped component', async () => {
const permission = buildPermission('read', 'dashboard:*');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: true,
}),
);
}),
);
const ComponentWithMultipleProps = ({
prop1,
prop2,
prop3,
}: {
prop1: string;
prop2: number;
prop3: boolean;
}): ReactElement => (
<div>
{prop1} - {prop2} - {prop3.toString()}
</div>
);
const GuardedComponent = createGuardedRoute(
ComponentWithMultipleProps,
'read',
'dashboard:*',
);
const mockMatch = {
params: {},
isExact: true,
path: '/dashboard',
url: '/dashboard',
};
const props = {
prop1: 'value1',
prop2: 42,
prop3: true,
match: mockMatch,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText('value1 - 42 - true')).toBeInTheDocument();
});
});
it('should memoize resolved object based on route params', async () => {
const permission1 = buildPermission('read', 'dashboard:123');
const permission2 = buildPermission('read', 'dashboard:456');
let requestCount = 0;
const requestedPermissions: string[] = [];
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (req, res, ctx) => {
requestCount++;
const body = req.body as {
permissions: Array<{ relation: string; object: string }>;
};
const perm = body.permissions[0];
requestedPermissions.push(`${perm.relation}/${perm.object}`);
if (perm.object === 'dashboard:123') {
return res(
ctx.status(200),
ctx.json({
[permission1]: true,
}),
);
}
return res(
ctx.status(200),
ctx.json({
[permission2]: true,
}),
);
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:{id}',
);
const mockMatch1 = {
params: { id: '123' },
isExact: true,
path: '/dashboard/:id',
url: '/dashboard/123',
};
const props1 = {
testProp: 'test-value-1',
match: mockMatch1,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
const { unmount } = render(<GuardedComponent {...props1} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value-1')).toBeInTheDocument();
});
expect(requestCount).toBe(1);
expect(requestedPermissions).toContain('read/dashboard:123');
unmount();
const mockMatch2 = {
params: { id: '456' },
isExact: true,
path: '/dashboard/:id',
url: '/dashboard/456',
};
const props2 = {
testProp: 'test-value-2',
match: mockMatch2,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
render(<GuardedComponent {...props2} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value-2')).toBeInTheDocument();
});
expect(requestCount).toBe(2);
expect(requestedPermissions).toContain('read/dashboard:456');
});
it('should handle different relation types', async () => {
const permission = buildPermission('delete', 'dashboard:789');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: true,
}),
);
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'delete',
'dashboard:{id}',
);
const mockMatch = {
params: { id: '789' },
isExact: true,
path: '/dashboard/:id',
url: '/dashboard/789',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,79 @@
import { ComponentType, ReactElement, useMemo } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { toast } from '@signozhq/sonner';
import {
AuthZObject,
AuthZRelation,
BrandedPermission,
} from 'hooks/useAuthZ/utils';
import { Copy } from 'lucide-react';
import { useCopyToClipboard } from '../../hooks/useCopyToClipboard';
import ErrorBoundaryFallback from '../../pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import AppLoading from '../AppLoading/AppLoading';
import { GuardAuthZ } from './GuardAuthZ';
import './createGuardedRoute.styles.scss';
const onErrorFallback = (): JSX.Element => <ErrorBoundaryFallback />;
function OnNoPermissionsFallback(response: {
requiredPermissionName: BrandedPermission;
}): ReactElement {
const { copyToClipboard } = useCopyToClipboard();
const onClickToCopy = (): void => {
copyToClipboard(response.requiredPermissionName, 'required-permission');
toast.success('Permission copied to clipboard');
};
return (
<div className="guard-authz-error-no-authz">
<div className="guard-authz-error-no-authz-content">
<img src="/Icons/no-data.svg" alt="No permission" />
<h3>Uh-oh! You dont have permission to view this page.</h3>
<p>
You need the following permission to view this page:
<br />
<pre onClick={onClickToCopy}>
{response.requiredPermissionName} <Copy size={12} />
</pre>
Ask your SigNoz administrator to grant access.
</p>
</div>
</div>
);
}
// eslint-disable-next-line @typescript-eslint/ban-types
export function createGuardedRoute<P extends object, R extends AuthZRelation>(
Component: ComponentType<P>,
relation: R,
object: AuthZObject<R>,
): ComponentType<P & RouteComponentProps<Record<string, string>>> {
return function GuardedRouteComponent(
props: P & RouteComponentProps<Record<string, string>>,
): ReactElement {
const resolvedObject = useMemo(() => {
const paramPattern = /\{([^}]+)\}/g;
return object.replace(paramPattern, (match, paramName) => {
const paramValue = props.match?.params?.[paramName];
return paramValue !== undefined ? paramValue : match;
}) as AuthZObject<R>;
}, [props.match?.params]);
return (
<GuardAuthZ
relation={relation}
object={resolvedObject}
fallbackOnLoading={<AppLoading />}
fallbackOnError={onErrorFallback}
fallbackOnNoPermissions={(response): ReactElement => (
<OnNoPermissionsFallback {...response} />
)}
>
<Component {...props} />
</GuardAuthZ>
);
};
}

View File

@@ -0,0 +1,3 @@
export { createGuardedRoute } from './createGuardedRoute';
export type { GuardAuthZProps } from './GuardAuthZ';
export { GuardAuthZ } from './GuardAuthZ';

View File

@@ -14,6 +14,6 @@ export const VIEW_TYPES = {
export const SPAN_ATTRIBUTES = {
URL_PATH: 'http.url',
RESPONSE_STATUS_CODE: 'response_status_code',
SERVER_NAME: 'net.peer.name',
SERVER_NAME: 'http_host',
SERVER_PORT: 'net.peer.port',
} as const;

View File

@@ -4,6 +4,7 @@ import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen, waitFor, within } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
import TopErrors from '../Explorer/Domains/DomainDetails/TopErrors';
import { getTopErrorsQueryPayload } from '../utils';
@@ -215,7 +216,7 @@ describe('TopErrors', () => {
value: 'true',
}),
expect.objectContaining({
key: expect.objectContaining({ key: 'net.peer.name' }),
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.SERVER_NAME }),
op: '=',
value: 'test-domain',
}),

View File

@@ -638,7 +638,7 @@ export const getEndPointsQueryPayload = (
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.SERVER_NAME,
type: 'tag',
type: '',
},
op: '=',
value: domainName,
@@ -685,7 +685,7 @@ export const getEndPointsQueryPayload = (
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.SERVER_NAME,
type: 'tag',
type: '',
},
op: '=',
value: domainName,
@@ -733,7 +733,7 @@ export const getEndPointsQueryPayload = (
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.SERVER_NAME,
type: 'tag',
type: '',
},
op: '=',
value: domainName,
@@ -780,7 +780,7 @@ export const getEndPointsQueryPayload = (
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.SERVER_NAME,
type: 'tag',
type: '',
},
op: '=',
value: domainName,
@@ -1302,7 +1302,7 @@ export const getTopErrorsCoRelationQueryFilters = (
{
id: 'e8a043b7',
key: {
key: 'net.peer.name',
key: SPAN_ATTRIBUTES.SERVER_NAME,
dataType: DataTypes.String,
type: '',
},
@@ -2198,7 +2198,7 @@ export const getEndPointZeroStateQueryPayload = (
key: {
key: SPAN_ATTRIBUTES.SERVER_NAME,
dataType: DataTypes.String,
type: 'tag',
type: '',
},
op: '=',
value: domainName,
@@ -2793,7 +2793,7 @@ export const getStatusCodeBarChartWidgetData = (
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.SERVER_NAME,
type: 'tag',
type: '',
},
op: '=',
value: domainName,

View File

@@ -50,6 +50,10 @@
}
.variable-select {
.ant-select-selector {
overflow-y: hidden !important;
}
.ant-select-item {
display: flex;
align-items: center;

View File

@@ -1,3 +1,4 @@
import { ExecStats } from 'api/v5/v5';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
@@ -54,6 +55,13 @@ export function prepareBarPanelConfig({
minTimeScale?: number;
maxTimeScale?: number;
}): UPlotConfigBuilder {
const stepIntervals: ExecStats['stepIntervals'] = get(
apiResponse,
'data.newResult.meta.stepIntervals',
{},
);
const minStepInterval = Math.min(...Object.values(stepIntervals));
const builder = buildBaseConfig({
widget,
isDarkMode,
@@ -65,12 +73,7 @@ export function prepareBarPanelConfig({
panelType: PANEL_TYPES.BAR,
minTimeScale,
maxTimeScale,
});
builder.setCursor({
focus: {
prox: 1e3,
},
stepInterval: minStepInterval,
});
if (widget.stackedBarChart) {
@@ -78,12 +81,6 @@ export function prepareBarPanelConfig({
builder.setBands(getInitialStackedBands(seriesCount));
}
const stepIntervals: Record<string, number> = get(
apiResponse,
'data.newResult.meta.stepIntervals',
{},
);
const seriesList: QueryData[] = apiResponse?.data?.result || [];
seriesList.forEach((series) => {
const baseLabelName = getLabelName(

View File

@@ -1,3 +1,4 @@
import { ExecStats } from 'api/v5/v5';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
@@ -14,6 +15,7 @@ import {
VisibilityMode,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import get from 'lodash-es/get';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -54,6 +56,13 @@ export const prepareUPlotConfig = ({
minTimeScale?: number;
maxTimeScale?: number;
}): UPlotConfigBuilder => {
const stepIntervals: ExecStats['stepIntervals'] = get(
apiResponse,
'data.newResult.meta.stepIntervals',
{},
);
const minStepInterval = Math.min(...Object.values(stepIntervals));
const builder = buildBaseConfig({
widget,
isDarkMode,
@@ -65,9 +74,12 @@ export const prepareUPlotConfig = ({
panelType: PANEL_TYPES.TIME_SERIES,
minTimeScale,
maxTimeScale,
stepInterval: minStepInterval,
});
apiResponse.data?.result?.forEach((series) => {
const seriesList = apiResponse.data?.result || [];
seriesList.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,
series.queryName || '', // query

View File

@@ -0,0 +1,233 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { STEP_INTERVAL_MULTIPLIER } from 'lib/uPlotV2/constants';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { PanelMode } from '../../types';
import { buildBaseConfig } from '../baseConfigBuilder';
jest.mock(
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
() => ({
getStoredSeriesVisibility: jest.fn(),
}),
);
jest.mock('lib/uPlotV2/utils', () => ({
calculateWidthBasedOnStepInterval: jest.fn(),
}));
const calculateWidthBasedOnStepIntervalMock = jest.requireMock(
'lib/uPlotV2/utils',
).calculateWidthBasedOnStepInterval as jest.Mock;
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
__esModule: true,
default: jest.fn().mockReturnValue({ name: 'onClickPlugin' }),
}));
const createWidget = (overrides: Partial<Widgets> = {}): Widgets =>
({
id: 'widget-1',
yAxisUnit: 'ms',
isLogScale: false,
softMin: undefined,
softMax: undefined,
thresholds: [],
...overrides,
} as Widgets);
const createApiResponse = (
overrides: Partial<MetricRangePayloadProps> = {},
): MetricRangePayloadProps =>
({
data: { result: [], resultType: 'matrix', newResult: null },
...overrides,
} as MetricRangePayloadProps);
const baseProps = {
widget: createWidget(),
apiResponse: createApiResponse(),
isDarkMode: true,
panelMode: PanelMode.DASHBOARD_VIEW,
panelType: PANEL_TYPES.TIME_SERIES,
};
describe('buildBaseConfig', () => {
it('returns a UPlotConfigBuilder instance', () => {
const builder = buildBaseConfig(baseProps);
expect(builder).toBeDefined();
expect(typeof builder.getConfig).toBe('function');
expect(typeof builder.getLegendItems).toBe('function');
});
it('configures builder with widgetId and DASHBOARD_VIEW preferences', () => {
const builder = buildBaseConfig({
...baseProps,
panelMode: PanelMode.DASHBOARD_VIEW,
widget: createWidget({ id: 'my-widget' }),
});
expect(builder.getWidgetId()).toBe('my-widget');
expect(builder.getShouldSaveSelectionPreference()).toBe(true);
});
it('configures builder with IN_MEMORY selection when panelMode is DASHBOARD_EDIT', () => {
const builder = buildBaseConfig({
...baseProps,
panelMode: PanelMode.DASHBOARD_EDIT,
});
expect(builder.getShouldSaveSelectionPreference()).toBe(false);
const config = builder.getConfig();
expect(config.series).toBeDefined();
});
it('passes stepInterval to builder and cursor prox uses width * multiplier', () => {
const stepInterval = 60;
const mockWidth = 100;
calculateWidthBasedOnStepIntervalMock.mockReturnValue(mockWidth);
const builder = buildBaseConfig({
...baseProps,
stepInterval,
});
const config = builder.getConfig();
const prox = config.cursor?.hover?.prox;
expect(typeof prox).toBe('function');
const uPlotInstance = {} as uPlot;
const proxResult = (prox as (u: uPlot) => number)(uPlotInstance);
expect(calculateWidthBasedOnStepIntervalMock).toHaveBeenCalledWith({
uPlotInstance,
stepInterval,
});
expect(proxResult).toBe(mockWidth * STEP_INTERVAL_MULTIPLIER);
});
it('adds x scale with time config and min/max when provided', () => {
const builder = buildBaseConfig({
...baseProps,
minTimeScale: 1000,
maxTimeScale: 2000,
});
const config = builder.getConfig();
expect(config.scales?.x).toBeDefined();
expect(config.scales?.x?.time).toBe(true);
const range = config.scales?.x?.range;
expect(Array.isArray(range)).toBe(true);
expect((range as [number, number])[0]).toBe(1000);
});
it('configures log scale on y axis when widget.isLogScale is true', () => {
const builder = buildBaseConfig({
...baseProps,
widget: createWidget({ isLogScale: true }),
});
const config = builder.getConfig();
expect(config.scales?.y).toBeDefined();
expect(config.scales?.y?.log).toBe(10);
});
it('adds onClick plugin when onClick is a function', () => {
const onClickPlugin = jest.requireMock('lib/uPlotLib/plugins/onClickPlugin')
.default;
const onClick = jest.fn();
buildBaseConfig({
...baseProps,
onClick,
apiResponse: createApiResponse(),
});
expect(onClickPlugin).toHaveBeenCalledWith({
onClick,
apiResponse: expect.any(Object),
});
});
it('does not add onClick plugin when onClick is not a function', () => {
const onClickPlugin = jest.requireMock('lib/uPlotLib/plugins/onClickPlugin')
.default;
const builder = buildBaseConfig({
...baseProps,
});
const config = builder.getConfig();
const plugins = config.plugins ?? [];
expect(
plugins.some((p) => (p as { name?: string }).name === 'onClickPlugin'),
).toBe(false);
expect(onClickPlugin).not.toHaveBeenCalled();
});
it('adds thresholds from widget', () => {
const builder = buildBaseConfig({
...baseProps,
widget: createWidget({
thresholds: [
{
thresholdValue: 80,
thresholdColor: '#ff0000',
thresholdUnit: 'ms',
thresholdLabel: 'High',
},
] as Widgets['thresholds'],
}),
});
const config = builder.getConfig();
const drawHooks = config.hooks?.draw ?? [];
expect(drawHooks.length).toBeGreaterThan(0);
});
it('adds x and y axes with correct scaleKeys and panelType', () => {
const builder = buildBaseConfig(baseProps);
const config = builder.getConfig();
expect(config.axes).toHaveLength(2);
expect(config.axes?.[0].scale).toBe('x');
expect(config.axes?.[1].scale).toBe('y');
});
it('sets tzDate when timezone is provided', () => {
const builder = buildBaseConfig({
...baseProps,
timezone: {
name: 'America/New_York',
value: 'America/New_York',
offset: 'UTC-5',
searchIndex: 'America/New_York',
},
});
const config = builder.getConfig();
expect(config.tzDate).toBeDefined();
expect(typeof config.tzDate).toBe('function');
});
it('leaves tzDate undefined when timezone is not provided', () => {
const builder = buildBaseConfig(baseProps);
const config = builder.getConfig();
expect(config.tzDate).toBeUndefined();
});
it('register setSelect hook when onDragSelect is provided', () => {
const onDragSelect = jest.fn();
const builder = buildBaseConfig({
...baseProps,
onDragSelect,
});
const config = builder.getConfig();
expect(config.hooks?.setSelect).toBeDefined();
});
});

View File

@@ -26,6 +26,7 @@ export interface BaseConfigBuilderProps {
panelType: PANEL_TYPES;
minTimeScale?: number;
maxTimeScale?: number;
stepInterval?: number;
}
export function buildBaseConfig({
@@ -39,6 +40,7 @@ export function buildBaseConfig({
panelType,
minTimeScale,
maxTimeScale,
stepInterval,
}: BaseConfigBuilderProps): UPlotConfigBuilder {
const tzDate = timezone
? (timestamp: number): Date =>
@@ -56,6 +58,7 @@ export function buildBaseConfig({
].includes(panelMode)
? SelectionPreferencesSource.LOCAL_STORAGE
: SelectionPreferencesSource.IN_MEMORY,
stepInterval,
});
const thresholdOptions: ThresholdsDrawHookOptions = {

View File

@@ -0,0 +1,444 @@
import { ReactElement } from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { AllTheProviders } from 'tests/test-utils';
import { useAuthZ } from './useAuthZ';
import { BrandedPermission, buildPermission } from './utils';
const BASE_URL = ENVIRONMENT.baseURL || '';
const wrapper = ({ children }: { children: ReactElement }): ReactElement => (
<AllTheProviders>{children}</AllTheProviders>
);
describe('useAuthZ', () => {
it('should fetch and return permissions successfully', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const mockApiResponse = {
[permission1]: true,
[permission2]: false,
};
const expectedResponse = {
[permission1]: {
isGranted: true,
},
[permission2]: {
isGranted: false,
},
};
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(200), ctx.json(mockApiResponse));
}),
);
const { result } = renderHook(() => useAuthZ([permission1, permission2]), {
wrapper,
});
expect(result.current.isLoading).toBe(true);
expect(result.current.permissions).toBeNull();
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.error).toBeNull();
expect(result.current.permissions).toEqual(expectedResponse);
});
it('should handle API errors', async () => {
const permission = buildPermission('read', 'dashboard:*');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
}),
);
const { result } = renderHook(() => useAuthZ([permission]), {
wrapper,
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.error).not.toBeNull();
expect(result.current.permissions).toBeNull();
});
it('should refetch when permissions array changes', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const permission3 = buildPermission('delete', 'dashboard:456');
let requestCount = 0;
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (req, res, ctx) => {
requestCount++;
const body = req.body as {
permissions: Array<{ relation: string; object: string }>;
};
if (body.permissions.length === 1) {
return res(
ctx.status(200),
ctx.json({
[permission1]: true,
}),
);
}
return res(
ctx.status(200),
ctx.json({
[permission1]: true,
[permission2]: false,
[permission3]: true,
}),
);
}),
);
const { result, rerender } = renderHook<
ReturnType<typeof useAuthZ>,
BrandedPermission[]
>((permissions) => useAuthZ(permissions), {
wrapper,
initialProps: [permission1],
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(requestCount).toBe(1);
expect(result.current.permissions).toEqual({
[permission1]: {
isGranted: true,
},
});
rerender([permission1, permission2, permission3]);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(requestCount).toBe(2);
expect(result.current.permissions).toEqual({
[permission1]: {
isGranted: true,
},
[permission2]: {
isGranted: false,
},
[permission3]: {
isGranted: true,
},
});
});
it('should not refetch when permissions array order changes but content is the same', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
let requestCount = 0;
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
requestCount++;
return res(
ctx.status(200),
ctx.json({
[permission1]: true,
[permission2]: false,
}),
);
}),
);
const { result, rerender } = renderHook<
ReturnType<typeof useAuthZ>,
BrandedPermission[]
>((permissions) => useAuthZ(permissions), {
wrapper,
initialProps: [permission1, permission2],
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(requestCount).toBe(1);
rerender([permission2, permission1]);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(requestCount).toBe(1);
});
it('should handle empty permissions array', async () => {
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(200), ctx.json({}));
}),
);
const { result } = renderHook(() => useAuthZ([]), {
wrapper,
});
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
expect(result.current.permissions).toEqual({});
});
it('should send correct payload format to API', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
let receivedPayload: any = null;
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, async (req, res, ctx) => {
receivedPayload = await req.json();
return res(
ctx.status(200),
ctx.json({
[permission1]: true,
[permission2]: false,
}),
);
}),
);
const { result } = renderHook(() => useAuthZ([permission1, permission2]), {
wrapper,
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(receivedPayload).toEqual({
permissions: [
{ relation: 'read', object: 'dashboard:*' },
{ relation: 'update', object: 'dashboard:123' },
],
});
});
it('should batch multiple hooks into single flight request', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const permission3 = buildPermission('delete', 'dashboard:456');
const permission4 = buildPermission('create', 'dashboards');
let requestCount = 0;
const receivedPayloads: any[] = [];
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, async (req, res, ctx) => {
requestCount++;
const payload = await req.json();
receivedPayloads.push(payload);
const allPermissions = [permission1, permission2, permission3, permission4];
const response: Record<string, boolean> = {};
payload.permissions.forEach((p: { relation: string; object: string }) => {
const perm = `${p.relation}/${p.object}` as BrandedPermission;
if (allPermissions.includes(perm)) {
response[perm] = perm === permission1 || perm === permission3;
}
});
return res(ctx.status(200), ctx.json(response));
}),
);
const { result: result1 } = renderHook(() => useAuthZ([permission1]), {
wrapper,
});
const { result: result2 } = renderHook(() => useAuthZ([permission2]), {
wrapper,
});
const { result: result3 } = renderHook(() => useAuthZ([permission3]), {
wrapper,
});
await waitFor(
() => {
expect(result1.current.isLoading).toBe(false);
expect(result2.current.isLoading).toBe(false);
expect(result3.current.isLoading).toBe(false);
},
{ timeout: 200 },
);
expect(requestCount).toBe(1);
expect(receivedPayloads).toHaveLength(1);
expect(receivedPayloads[0].permissions).toHaveLength(3);
expect(receivedPayloads[0].permissions).toEqual(
expect.arrayContaining([
{ relation: 'read', object: 'dashboard:*' },
{ relation: 'update', object: 'dashboard:123' },
{ relation: 'delete', object: 'dashboard:456' },
]),
);
expect(result1.current.permissions).toEqual({
[permission1]: { isGranted: true },
});
expect(result2.current.permissions).toEqual({
[permission2]: { isGranted: false },
});
expect(result3.current.permissions).toEqual({
[permission3]: { isGranted: true },
});
});
it('should create separate batches for calls after single flight window', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const permission3 = buildPermission('delete', 'dashboard:456');
let requestCount = 0;
const receivedPayloads: any[] = [];
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, async (req, res, ctx) => {
requestCount++;
const payload = await req.json();
receivedPayloads.push(payload);
const response: Record<string, boolean> = {};
payload.permissions.forEach((p: { relation: string; object: string }) => {
const perm = `${p.relation}/${p.object}` as BrandedPermission;
response[perm] = perm === permission1 || perm === permission3;
});
return res(ctx.status(200), ctx.json(response));
}),
);
const { result: result1 } = renderHook(() => useAuthZ([permission1]), {
wrapper,
});
await waitFor(
() => {
expect(result1.current.isLoading).toBe(false);
},
{ timeout: 200 },
);
expect(requestCount).toBe(1);
expect(receivedPayloads[0].permissions).toHaveLength(1);
await new Promise((resolve) => setTimeout(resolve, 100));
const { result: result2 } = renderHook(() => useAuthZ([permission2]), {
wrapper,
});
const { result: result3 } = renderHook(() => useAuthZ([permission3]), {
wrapper,
});
await waitFor(
() => {
expect(result2.current.isLoading).toBe(false);
expect(result3.current.isLoading).toBe(false);
},
{ timeout: 200 },
);
expect(requestCount).toBe(2);
expect(receivedPayloads).toHaveLength(2);
expect(receivedPayloads[1].permissions).toHaveLength(2);
expect(receivedPayloads[1].permissions).toEqual(
expect.arrayContaining([
{ relation: 'update', object: 'dashboard:123' },
{ relation: 'delete', object: 'dashboard:456' },
]),
);
});
it('should not leak state between separate batches', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const permission3 = buildPermission('delete', 'dashboard:456');
let requestCount = 0;
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, async (req, res, ctx) => {
requestCount++;
const payload = await req.json();
const response: Record<string, boolean> = {};
payload.permissions.forEach((p: { relation: string; object: string }) => {
const perm = `${p.relation}/${p.object}` as BrandedPermission;
response[perm] = perm === permission1 || perm === permission3;
});
return res(ctx.status(200), ctx.json(response));
}),
);
const { result: result1 } = renderHook(() => useAuthZ([permission1]), {
wrapper,
});
await waitFor(
() => {
expect(result1.current.isLoading).toBe(false);
},
{ timeout: 200 },
);
expect(requestCount).toBe(1);
expect(result1.current.permissions).toEqual({
[permission1]: { isGranted: true },
});
await new Promise((resolve) => setTimeout(resolve, 100));
const { result: result2 } = renderHook(() => useAuthZ([permission2]), {
wrapper,
});
await waitFor(
() => {
expect(result2.current.isLoading).toBe(false);
},
{ timeout: 200 },
);
expect(requestCount).toBe(2);
expect(result1.current.permissions).toEqual({
[permission1]: { isGranted: true },
});
expect(result2.current.permissions).toEqual({
[permission2]: { isGranted: false },
});
expect(result1.current.permissions).not.toHaveProperty(permission2);
expect(result2.current.permissions).not.toHaveProperty(permission1);
});
});

View File

@@ -0,0 +1,136 @@
import { useMemo } from 'react';
import { useQueries } from 'react-query';
import api from 'api';
import { BrandedPermission } from './utils';
export type UseAuthZPermissionResult = {
isGranted: boolean;
};
export type AuthZCheckResponse = Record<
BrandedPermission,
UseAuthZPermissionResult
>;
export type UseAuthZResult = {
isLoading: boolean;
error: Error | null;
permissions: AuthZCheckResponse | null;
};
let ctx: Promise<AuthZCheckResponse> | null;
let pendingPermissions: BrandedPermission[] = [];
const SINGLE_FLIGHT_WAIT_TIME_MS = 50;
const AUTHZ_CACHE_TIME = 20_000;
function dispatchPermission(
permission: BrandedPermission,
): Promise<AuthZCheckResponse> {
pendingPermissions.push(permission);
if (!ctx) {
let resolve: (v: AuthZCheckResponse) => void, reject: (reason?: any) => void;
ctx = new Promise<AuthZCheckResponse>((r, re) => {
resolve = r;
reject = re;
});
setTimeout(() => {
const copiedPermissions = pendingPermissions.slice();
pendingPermissions = [];
ctx = null;
fetchManyPermissions(copiedPermissions).then(resolve).catch(reject);
}, SINGLE_FLIGHT_WAIT_TIME_MS);
}
return ctx;
}
async function fetchManyPermissions(
permissions: BrandedPermission[],
): Promise<AuthZCheckResponse> {
const permissionsPayload = permissions.map((permission) => {
const [relation, object] = permission.split('/');
return {
relation,
object,
};
});
const permissionsCheckResponse = await api
.post<Record<string, boolean>>('/permissions/check', {
permissions: permissionsPayload,
})
.then((response) => response.data);
return Object.keys(permissionsCheckResponse).reduce<AuthZCheckResponse>(
(acc, permission): AuthZCheckResponse => {
acc[permission as BrandedPermission] = {
isGranted: !!permissionsCheckResponse[permission],
};
return acc;
},
{} as AuthZCheckResponse,
);
}
export function useAuthZ(permissions: BrandedPermission[]): UseAuthZResult {
const queryResults = useQueries(
permissions.map((permission) => {
return {
queryKey: ['authz', permission],
cacheTime: AUTHZ_CACHE_TIME,
refetchOnMount: false,
refetchIntervalInBackground: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
queryFn: async (): Promise<AuthZCheckResponse> => {
const response = await dispatchPermission(permission);
return {
[permission]: {
isGranted: response[permission].isGranted,
},
};
},
};
}),
);
const isLoading = useMemo(() => queryResults.some((q) => q.isLoading), [
queryResults,
]);
const error = useMemo(
() =>
!isLoading
? (queryResults.find((q) => !!q.error)?.error as Error) || null
: null,
[isLoading, queryResults],
);
const data = useMemo(() => {
if (isLoading || error) {
return null;
}
return queryResults.reduce((acc, q) => {
if (!q.data) {
return acc;
}
for (const [key, value] of Object.entries(q.data)) {
acc[key as BrandedPermission] = value;
}
return acc;
}, {} as AuthZCheckResponse);
}, [isLoading, error, queryResults]);
return {
isLoading,
error,
permissions: data ?? null,
};
}

View File

@@ -0,0 +1,32 @@
export type AuthZRelation =
| 'list'
| 'create'
| 'read'
| 'update'
| 'delete'
| 'assignee';
export type AuthZResource = 'dashboard';
export type AuthZObject<R extends AuthZRelation> = R extends 'list' | 'create'
? `${AuthZResource}s`
: `${AuthZResource}:${string}`;
export type AuthZPermissionCheck<R extends AuthZRelation> = {
object: AuthZObject<R>;
relation: R;
};
export type BrandedPermission = string & { __brandedPermission: true };
export function buildPermission<R extends AuthZRelation>(
relation: R,
object: AuthZObject<R>,
): BrandedPermission {
return `${relation}/${object}` as BrandedPermission;
}
export function buildObjectString(
resource: AuthZResource,
objectId: string,
): `${AuthZResource}:${string}` {
return `${resource}:${objectId}`;
}

View File

@@ -7,11 +7,16 @@ import { merge } from 'lodash-es';
import noop from 'lodash-es/noop';
import uPlot, { Cursor, Hooks, Options } from 'uplot';
import {
DEFAULT_CURSOR_CONFIG,
DEFAULT_HOVER_PROXIMITY_VALUE,
DEFAULT_PLOT_CONFIG,
STEP_INTERVAL_MULTIPLIER,
} from '../constants';
import { calculateWidthBasedOnStepInterval } from '../utils';
import {
ConfigBuilder,
ConfigBuilderProps,
DEFAULT_CURSOR_CONFIG,
DEFAULT_PLOT_CONFIG,
LegendItem,
SelectionPreferencesSource,
} from './types';
@@ -45,6 +50,8 @@ export class UPlotConfigBuilder extends ConfigBuilder<
private axes: Record<string, UPlotAxisBuilder> = {};
private stepInterval: number | undefined;
readonly scales: UPlotScaleBuilder[] = [];
private bands: uPlot.Band[] = [];
@@ -79,6 +86,7 @@ export class UPlotConfigBuilder extends ConfigBuilder<
tzDate,
selectionPreferencesSource,
shouldSaveSelectionPreference,
stepInterval,
} = args ?? {};
if (widgetId) {
this.widgetId = widgetId;
@@ -96,6 +104,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
this.shouldSaveSelectionPreference = shouldSaveSelectionPreference;
}
if (stepInterval) {
this.stepInterval = stepInterval;
}
this.onDragSelect = noop;
if (onDragSelect) {
this.onDragSelect = onDragSelect;
@@ -372,6 +384,31 @@ export class UPlotConfigBuilder extends ConfigBuilder<
return this.widgetId;
}
/**
* Get cursor configuration
*/
getCursorConfig(): Cursor {
if (this.stepInterval) {
const cursorConfig = {
...DEFAULT_CURSOR_CONFIG,
hover: {
...DEFAULT_CURSOR_CONFIG.hover,
prox: this.stepInterval
? (uPlotInstance: uPlot): number => {
const width = calculateWidthBasedOnStepInterval({
uPlotInstance,
stepInterval: this.stepInterval ?? 0,
});
return width * STEP_INTERVAL_MULTIPLIER;
}
: DEFAULT_HOVER_PROXIMITY_VALUE,
},
};
return merge({}, DEFAULT_CURSOR_CONFIG, cursorConfig, this.cursor);
}
return merge({}, DEFAULT_CURSOR_CONFIG, this.cursor);
}
/**
* Build the final uPlot.Options configuration
*/
@@ -413,7 +450,7 @@ export class UPlotConfigBuilder extends ConfigBuilder<
config.hooks = this.hooks;
config.select = this.select;
config.cursor = merge({}, DEFAULT_CURSOR_CONFIG, this.cursor);
config.cursor = this.getCursorConfig();
config.tzDate = this.tzDate;
config.plugins = this.plugins.length > 0 ? this.plugins : undefined;
config.bands = this.bands.length > 0 ? this.bands : undefined;

View File

@@ -1,6 +1,7 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils';
import uPlot, { Series } from 'uplot';
import {
@@ -291,21 +292,16 @@ function getBarPathBuilder({
idx1: number,
): Series.Paths | null => {
let effectiveBarMaxWidth = barMaxWidth;
const widthBasedOnStepInterval = calculateWidthBasedOnStepInterval({
uPlotInstance: self,
stepInterval,
});
const xScale = self.scales.x as uPlot.Scale | undefined;
if (xScale && typeof xScale.min === 'number') {
const start = xScale.min as number;
const end = start + stepInterval;
const startPx = self.valToPos(start, 'x');
const endPx = self.valToPos(end, 'x');
const intervalPx = Math.abs(endPx - startPx);
if (intervalPx > 0) {
effectiveBarMaxWidth =
typeof barMaxWidth === 'number'
? Math.min(barMaxWidth, intervalPx)
: intervalPx;
}
if (widthBasedOnStepInterval > 0) {
effectiveBarMaxWidth = Math.min(
effectiveBarMaxWidth,
widthBasedOnStepInterval,
);
}
const barsCfgKey = `bars|${barAlignment}|${barWidthFactor}|${effectiveBarMaxWidth}`;

View File

@@ -1,6 +1,10 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import uPlot from 'uplot';
import {
DEFAULT_HOVER_PROXIMITY_VALUE,
STEP_INTERVAL_MULTIPLIER,
} from '../../constants';
import type { SeriesProps } from '../types';
import { DrawStyle, SelectionPreferencesSource } from '../types';
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
@@ -13,6 +17,14 @@ jest.mock(
}),
);
jest.mock('lib/uPlotV2/utils', () => ({
calculateWidthBasedOnStepInterval: jest.fn(),
}));
const calculateWidthBasedOnStepIntervalMock = jest.requireMock(
'lib/uPlotV2/utils',
).calculateWidthBasedOnStepInterval as jest.Mock;
const getStoredSeriesVisibilityMock = jest.requireMock(
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
) as {
@@ -384,6 +396,52 @@ describe('UPlotConfigBuilder', () => {
expect(config.cursor?.points).toBeDefined();
});
describe('getCursorConfig', () => {
it('returns default cursor merged with custom cursor when no stepInterval', () => {
const builder = new UPlotConfigBuilder();
builder.setCursor({
drag: { setScale: false },
});
const cursorConfig = builder.getCursorConfig();
expect(cursorConfig.drag?.setScale).toBe(false);
expect(cursorConfig.hover?.prox).toBe(DEFAULT_HOVER_PROXIMITY_VALUE);
expect(cursorConfig.points).toBeDefined();
});
it('returns hover prox as DEFAULT_HOVER_PROXIMITY_VALUE when stepInterval is not set', () => {
const builder = new UPlotConfigBuilder();
const cursorConfig = builder.getCursorConfig();
expect(cursorConfig.hover?.prox).toBe(DEFAULT_HOVER_PROXIMITY_VALUE);
});
it('returns hover prox as function when stepInterval is set, computing width * multiplier', () => {
const stepInterval = 60;
const mockWidth = 100;
calculateWidthBasedOnStepIntervalMock.mockReturnValue(mockWidth);
const builder = new UPlotConfigBuilder({ stepInterval });
const cursorConfig = builder.getCursorConfig();
expect(typeof cursorConfig.hover?.prox).toBe('function');
const uPlotInstance = {} as uPlot;
const proxResult = (cursorConfig.hover!.prox as (u: uPlot) => number)(
uPlotInstance,
);
expect(calculateWidthBasedOnStepIntervalMock).toHaveBeenCalledWith({
uPlotInstance,
stepInterval,
});
expect(proxResult).toBe(mockWidth * STEP_INTERVAL_MULTIPLIER);
});
});
it('adds plugins and includes them in config', () => {
const builder = new UPlotConfigBuilder();
const plugin: uPlot.Plugin = {

View File

@@ -1,6 +1,6 @@
import { PrecisionOption } from 'components/Graph/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import uPlot, { Cursor, Options, Series } from 'uplot';
import uPlot, { Series } from 'uplot';
import { ThresholdsDrawHookOptions } from '../hooks/types';
@@ -39,6 +39,7 @@ export interface ConfigBuilderProps {
tzDate?: uPlot.LocalDateFromUnix;
selectionPreferencesSource?: SelectionPreferencesSource;
shouldSaveSelectionPreference?: boolean;
stepInterval?: number;
}
/**
@@ -186,47 +187,3 @@ export interface LegendItem {
color: uPlot.Series['stroke'];
show: boolean;
}
export const DEFAULT_PLOT_CONFIG: Partial<Options> = {
focus: {
alpha: 0.3,
},
cursor: {
focus: {
prox: 30,
},
},
legend: {
show: false,
},
padding: [16, 16, 8, 8],
series: [],
hooks: {},
};
const POINTS_FILL_COLOR = '#FFFFFF';
export const DEFAULT_CURSOR_CONFIG: Cursor = {
drag: { setScale: true },
points: {
one: true,
size: (u, seriesIdx) => (u.series[seriesIdx]?.points?.size ?? 0) * 3,
width: (_u, _seriesIdx, size) => size / 4,
stroke: (u, seriesIdx): string => {
const points = u.series[seriesIdx]?.points;
const strokeFn =
typeof points?.stroke === 'function' ? points.stroke : undefined;
const strokeValue =
strokeFn !== undefined
? strokeFn(u, seriesIdx)
: typeof points?.stroke === 'string'
? points.stroke
: '';
return `${strokeValue}90`;
},
fill: (): string => POINTS_FILL_COLOR,
},
focus: {
prox: 30,
},
};

View File

@@ -0,0 +1,48 @@
import { Cursor, Options } from 'uplot';
const POINTS_FILL_COLOR = '#FFFFFF';
export const DEFAULT_HOVER_PROXIMITY_VALUE = 30; // only snap if within 30px horizontally
export const DEFAULT_FOCUS_PROXIMITY_VALUE = 1e6;
export const STEP_INTERVAL_MULTIPLIER = 3; // multiply the width computed by STEP_INTERVAL_MULTIPLIER to get the hover prox value
export const DEFAULT_PLOT_CONFIG: Partial<Options> = {
focus: {
alpha: 0.3,
},
legend: {
show: false,
},
padding: [16, 16, 8, 8],
series: [],
hooks: {},
};
export const DEFAULT_CURSOR_CONFIG: Cursor = {
drag: { setScale: true },
points: {
one: true,
size: (u, seriesIdx) => (u.series[seriesIdx]?.points?.size ?? 0) * 3,
width: (_u, _seriesIdx, size) => size / 4,
stroke: (u, seriesIdx): string => {
const points = u.series[seriesIdx]?.points;
const strokeFn =
typeof points?.stroke === 'function' ? points.stroke : undefined;
const strokeValue =
strokeFn !== undefined
? strokeFn(u, seriesIdx)
: typeof points?.stroke === 'string'
? points.stroke
: '';
return `${strokeValue}90`;
},
fill: (): string => POINTS_FILL_COLOR,
},
focus: {
prox: DEFAULT_FOCUS_PROXIMITY_VALUE,
},
hover: {
prox: DEFAULT_HOVER_PROXIMITY_VALUE,
bias: 0,
},
};

View File

@@ -87,7 +87,7 @@ export function shouldShowTooltipForSync(
export function shouldShowTooltipForInteraction(
controller: TooltipControllerState,
): boolean {
return controller.focusedSeriesIndex != null || controller.isAnySeriesActive;
return controller.focusedSeriesIndex != null;
}
export function updateHoverState(

View File

@@ -0,0 +1,70 @@
import uPlot from 'uplot';
import { calculateWidthBasedOnStepInterval } from '../index';
describe('calculateWidthBasedOnStepInterval', () => {
it('returns pixel width between start and start+stepInterval when xScale exists with numeric min', () => {
const valToPos = jest
.fn()
.mockReturnValueOnce(100) // startPx for start
.mockReturnValueOnce(250); // endPx for start + stepInterval
const uPlotInstance = ({
scales: { x: { min: 1000 } },
valToPos,
} as unknown) as uPlot;
const result = calculateWidthBasedOnStepInterval({
uPlotInstance,
stepInterval: 60,
});
expect(valToPos).toHaveBeenCalledWith(1000, 'x');
expect(valToPos).toHaveBeenCalledWith(1060, 'x');
expect(result).toBe(150); // Math.abs(250 - 100)
});
it('returns absolute pixel width when endPx is less than startPx', () => {
const valToPos = jest.fn().mockReturnValueOnce(250).mockReturnValueOnce(100);
const uPlotInstance = ({
scales: { x: { min: 0 } },
valToPos,
} as unknown) as uPlot;
const result = calculateWidthBasedOnStepInterval({
uPlotInstance,
stepInterval: 60,
});
expect(result).toBe(150); // Math.abs(100 - 250)
});
it('returns 0 when xScale is undefined', () => {
const uPlotInstance = ({
scales: { x: undefined },
valToPos: jest.fn(),
} as unknown) as uPlot;
const result = calculateWidthBasedOnStepInterval({
uPlotInstance,
stepInterval: 60,
});
expect(result).toBe(0);
});
it('returns 0 when xScale.min is not a number', () => {
const uPlotInstance = ({
scales: { x: { min: undefined } },
valToPos: jest.fn(),
} as unknown) as uPlot;
const result = calculateWidthBasedOnStepInterval({
uPlotInstance,
stepInterval: 60,
});
expect(result).toBe(0);
});
});

View File

@@ -0,0 +1,17 @@
export function calculateWidthBasedOnStepInterval({
uPlotInstance,
stepInterval,
}: {
uPlotInstance: uPlot;
stepInterval: number;
}): number {
const xScale = uPlotInstance.scales.x;
if (xScale && typeof xScale.min === 'number') {
const start = xScale.min as number;
const end = start + stepInterval;
const startPx = uPlotInstance.valToPos(start, 'x');
const endPx = uPlotInstance.valToPos(end, 'x');
return Math.abs(endPx - startPx);
}
return 0;
}

View File

@@ -10,6 +10,26 @@ import (
)
func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/metrics", handler.New(
provider.authZ.ViewAccess(provider.metricsExplorerHandler.ListMetrics),
handler.OpenAPIDef{
ID: "ListMetrics",
Tags: []string{"metrics"},
Summary: "List metric names",
Description: "This endpoint returns a list of distinct metric names within the specified time range",
Request: nil,
RequestQuery: new(metricsexplorertypes.ListMetricsParams),
RequestContentType: "",
Response: new(metricsexplorertypes.ListMetricsResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/stats", handler.New(
provider.authZ.ViewAccess(provider.metricsExplorerHandler.GetStats),
handler.OpenAPIDef{
@@ -48,26 +68,27 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metrics/attributes", handler.New(
if err := router.Handle("/api/v2/metrics/{metric_name}/attributes", handler.New(
provider.authZ.ViewAccess(provider.metricsExplorerHandler.GetMetricAttributes),
handler.OpenAPIDef{
ID: "GetMetricAttributes",
Tags: []string{"metrics"},
Summary: "Get metric attributes",
Description: "This endpoint returns attribute keys and their unique values for a specified metric",
Request: new(metricsexplorertypes.MetricAttributesRequest),
RequestContentType: "application/json",
Request: nil,
RequestQuery: new(metricsexplorertypes.MetricAttributesRequest),
RequestContentType: "",
Response: new(metricsexplorertypes.MetricAttributesResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/metadata", handler.New(
if err := router.Handle("/api/v2/metrics/{metric_name}/metadata", handler.New(
provider.authZ.ViewAccess(provider.metricsExplorerHandler.GetMetricMetadata),
handler.OpenAPIDef{
ID: "GetMetricMetadata",
@@ -75,7 +96,6 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
Summary: "Get metric metadata",
Description: "This endpoint returns metadata information like metric description, unit, type, temporality, monotonicity for a specified metric",
Request: nil,
RequestQuery: new(metricsexplorertypes.MetricNameParams),
RequestContentType: "",
Response: new(metricsexplorertypes.MetricMetadata),
ResponseContentType: "application/json",
@@ -106,7 +126,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metric/highlights", handler.New(
if err := router.Handle("/api/v2/metrics/{metric_name}/highlights", handler.New(
provider.authZ.ViewAccess(provider.metricsExplorerHandler.GetMetricHighlights),
handler.OpenAPIDef{
ID: "GetMetricHighlights",
@@ -114,7 +134,6 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
Summary: "Get metric highlights",
Description: "This endpoint returns highlights like number of datapoints, totaltimeseries, active time series, last received time for a specified metric",
Request: nil,
RequestQuery: new(metricsexplorertypes.MetricNameParams),
RequestContentType: "",
Response: new(metricsexplorertypes.MetricHighlightsResponse),
ResponseContentType: "application/json",
@@ -126,7 +145,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metric/alerts", handler.New(
if err := router.Handle("/api/v2/metrics/{metric_name}/alerts", handler.New(
provider.authZ.ViewAccess(provider.metricsExplorerHandler.GetMetricAlerts),
handler.OpenAPIDef{
ID: "GetMetricAlerts",
@@ -134,7 +153,6 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
Summary: "Get metric alerts",
Description: "This endpoint returns associated alerts for a specified metric",
Request: nil,
RequestQuery: new(metricsexplorertypes.MetricNameParams),
RequestContentType: "",
Response: new(metricsexplorertypes.MetricAlertsResponse),
ResponseContentType: "application/json",
@@ -146,7 +164,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metric/dashboards", handler.New(
if err := router.Handle("/api/v2/metrics/{metric_name}/dashboards", handler.New(
provider.authZ.ViewAccess(provider.metricsExplorerHandler.GetMetricDashboards),
handler.OpenAPIDef{
ID: "GetMetricDashboards",
@@ -154,7 +172,6 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
Summary: "Get metric dashboards",
Description: "This endpoint returns associated dashboards for a specified metric",
Request: nil,
RequestQuery: new(metricsexplorertypes.MetricNameParams),
RequestContentType: "",
Response: new(metricsexplorertypes.MetricDashboardsResponse),
ResponseContentType: "application/json",

View File

@@ -7,6 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/swaggest/openapi-go"
"github.com/swaggest/openapi-go/openapi3"
)
type ServeOpenAPIFunc func(openapi.OperationContext)
@@ -59,7 +60,39 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
}
// Add request structure
opCtx.AddReqStructure(handler.openAPIDef.Request, openapi.WithContentType(handler.openAPIDef.RequestContentType))
reqOpts := []openapi.ContentOption{openapi.WithContentType(handler.openAPIDef.RequestContentType)}
if len(handler.openAPIDef.RequestExamples) > 0 {
reqOpts = append(reqOpts, openapi.WithCustomize(func(cor openapi.ContentOrReference) {
rbOrRef, ok := cor.(*openapi3.RequestBodyOrRef)
if !ok || rbOrRef.RequestBody == nil {
return
}
ct := handler.openAPIDef.RequestContentType
if ct == "" {
ct = "application/json"
}
mt, exists := rbOrRef.RequestBody.Content[ct]
if !exists {
return
}
if mt.Examples == nil {
mt.Examples = make(map[string]openapi3.ExampleOrRef)
}
for _, ex := range handler.openAPIDef.RequestExamples {
val := ex.Value
oaExample := openapi3.Example{Value: &val}
if ex.Summary != "" {
oaExample.WithSummary(ex.Summary)
}
if ex.Description != "" {
oaExample.WithDescription(ex.Description)
}
mt.Examples[ex.Name] = openapi3.ExampleOrRef{Example: &oaExample}
}
rbOrRef.RequestBody.Content[ct] = mt
}))
}
opCtx.AddReqStructure(handler.openAPIDef.Request, reqOpts...)
// Add request query structure
opCtx.AddReqStructure(handler.openAPIDef.RequestQuery)

View File

@@ -9,6 +9,14 @@ import (
"github.com/swaggest/rest/openapi"
)
// OpenAPIExample is a named example for an OpenAPI operation.
type OpenAPIExample struct {
Name string
Summary string
Description string
Value any
}
// Def is the definition of an OpenAPI operation
type OpenAPIDef struct {
ID string
@@ -18,6 +26,7 @@ type OpenAPIDef struct {
Request any
RequestQuery any
RequestContentType string
RequestExamples []OpenAPIExample
Response any
ResponseContentType string
SuccessStatusCode int

View File

@@ -13,6 +13,14 @@ import (
"github.com/gorilla/mux"
)
func extractMetricName(req *http.Request) (string, error) {
metricName := mux.Vars(req)["metric_name"]
if metricName == "" {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "metric_name is required in URL path")
}
return metricName, nil
}
type handler struct {
module metricsexplorer.Module
}
@@ -24,6 +32,34 @@ func NewHandler(m metricsexplorer.Module) metricsexplorer.Handler {
}
}
func (h *handler) ListMetrics(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
var params metricsexplorertypes.ListMetricsParams
if err := binding.Query.BindQuery(req.URL.Query(), &params); err != nil {
render.Error(rw, err)
return
}
if err := params.Validate(); err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
out, err := h.module.ListMetrics(req.Context(), orgID, &params)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, out)
}
func (h *handler) GetStats(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
@@ -114,28 +150,23 @@ func (h *handler) GetMetricMetadata(rw http.ResponseWriter, req *http.Request) {
return
}
var params metricsexplorertypes.MetricNameParams
if err := binding.Query.BindQuery(req.URL.Query(), &params); err != nil {
render.Error(rw, err)
return
}
if params.MetricName == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName query parameter is required"))
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
metadataMap, err := h.module.GetMetricMetadataMulti(req.Context(), orgID, []string{params.MetricName})
metricName, err := extractMetricName(req)
if err != nil {
render.Error(rw, err)
return
}
metadata, ok := metadataMap[params.MetricName]
orgID := valuer.MustNewUUID(claims.OrgID)
metadataMap, err := h.module.GetMetricMetadataMulti(req.Context(), orgID, []string{metricName})
if err != nil {
render.Error(rw, err)
return
}
metadata, ok := metadataMap[metricName]
if !ok || metadata == nil {
render.Error(rw, errors.NewNotFoundf(errors.CodeNotFound, "metadata not found for metric %q", params.MetricName))
render.Error(rw, errors.NewNotFoundf(errors.CodeNotFound, "metadata not found for metric %q", metricName))
return
}
@@ -149,19 +180,14 @@ func (h *handler) GetMetricAlerts(rw http.ResponseWriter, req *http.Request) {
return
}
var params metricsexplorertypes.MetricNameParams
if err := binding.Query.BindQuery(req.URL.Query(), &params); err != nil {
metricName, err := extractMetricName(req)
if err != nil {
render.Error(rw, err)
return
}
if params.MetricName == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName query parameter is required"))
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
out, err := h.module.GetMetricAlerts(req.Context(), orgID, params.MetricName)
out, err := h.module.GetMetricAlerts(req.Context(), orgID, metricName)
if err != nil {
render.Error(rw, err)
return
@@ -176,19 +202,14 @@ func (h *handler) GetMetricDashboards(rw http.ResponseWriter, req *http.Request)
return
}
var params metricsexplorertypes.MetricNameParams
if err := binding.Query.BindQuery(req.URL.Query(), &params); err != nil {
metricName, err := extractMetricName(req)
if err != nil {
render.Error(rw, err)
return
}
if params.MetricName == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName query parameter is required"))
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
out, err := h.module.GetMetricDashboards(req.Context(), orgID, params.MetricName)
out, err := h.module.GetMetricDashboards(req.Context(), orgID, metricName)
if err != nil {
render.Error(rw, err)
return
@@ -203,19 +224,14 @@ func (h *handler) GetMetricHighlights(rw http.ResponseWriter, req *http.Request)
return
}
var params metricsexplorertypes.MetricNameParams
if err := binding.Query.BindQuery(req.URL.Query(), &params); err != nil {
metricName, err := extractMetricName(req)
if err != nil {
render.Error(rw, err)
return
}
if params.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, params.MetricName)
highlights, err := h.module.GetMetricHighlights(req.Context(), orgID, metricName)
if err != nil {
render.Error(rw, err)
return
@@ -230,8 +246,21 @@ func (h *handler) GetMetricAttributes(rw http.ResponseWriter, req *http.Request)
return
}
metricName, err := extractMetricName(req)
if err != nil {
render.Error(rw, err)
return
}
var in metricsexplorertypes.MetricAttributesRequest
if err := binding.JSON.BindBody(req.Body, &in); err != nil {
if err := binding.Query.BindQuery(req.URL.Query(), &in); err != nil {
render.Error(rw, err)
return
}
in.MetricName = metricName
if err := in.Validate(); err != nil {
render.Error(rw, err)
return
}

View File

@@ -56,6 +56,88 @@ func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetr
}
}
func (m *module) ListMetrics(ctx context.Context, orgID valuer.UUID, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
if err := params.Validate(); err != nil {
return nil, err
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("DISTINCT metric_name")
if params.Start != nil && params.End != nil {
start, end, distributedTsTable, _ := telemetrymetrics.WhichTSTableToUse(uint64(*params.Start), uint64(*params.End), nil)
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTsTable))
sb.Where(sb.Between("unix_milli", start, end))
} else {
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV41weekTableName))
}
sb.Where(sb.E("__normalized", false))
if params.Search != "" {
searchLower := strings.ToLower(params.Search)
searchLower = strings.ReplaceAll(searchLower, "%", "\\%")
searchLower = strings.ReplaceAll(searchLower, "_", "\\_")
sb.Where(sb.Like("lower(metric_name)", fmt.Sprintf("%%%s%%", searchLower)))
}
sb.OrderBy("metric_name ASC")
sb.Limit(params.Limit)
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 list metrics")
}
defer rows.Close()
metricNames := make([]string, 0)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan metric name")
}
metricNames = append(metricNames, name)
}
if err := rows.Err(); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error iterating metric names")
}
if len(metricNames) == 0 {
return &metricsexplorertypes.ListMetricsResponse{
Metrics: []metricsexplorertypes.ListMetric{},
}, nil
}
metadata, err := m.GetMetricMetadataMulti(ctx, orgID, metricNames)
if err != nil {
return nil, err
}
metrics := make([]metricsexplorertypes.ListMetric, 0, len(metricNames))
for _, name := range metricNames {
metric := metricsexplorertypes.ListMetric{
MetricName: name,
}
if meta, ok := metadata[name]; ok && meta != nil {
metric.Description = meta.Description
metric.MetricType = meta.MetricType
metric.MetricUnit = meta.MetricUnit
metric.Temporality = meta.Temporality
metric.IsMonotonic = meta.IsMonotonic
}
metrics = append(metrics, metric)
}
return &metricsexplorertypes.ListMetricsResponse{
Metrics: metrics,
}, nil
}
func (m *module) GetStats(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.StatsRequest) (*metricsexplorertypes.StatsResponse, error) {
if err := req.Validate(); err != nil {
return nil, err

View File

@@ -10,6 +10,7 @@ import (
// Handler exposes HTTP handlers for the metrics module.
type Handler interface {
ListMetrics(http.ResponseWriter, *http.Request)
GetStats(http.ResponseWriter, *http.Request)
GetTreemap(http.ResponseWriter, *http.Request)
GetMetricMetadata(http.ResponseWriter, *http.Request)
@@ -22,6 +23,7 @@ type Handler interface {
// Module represents the metrics module interface.
type Module interface {
ListMetrics(ctx context.Context, orgID valuer.UUID, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error)
GetStats(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.StatsRequest) (*metricsexplorertypes.StatsResponse, error)
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)

View File

@@ -12,60 +12,30 @@ import (
)
const (
urlPathKeyLegacy = "http.url"
serverAddressKeyLegacy = "net.peer.name"
urlPathKey = "url.full"
serverAddressKey = "server.address"
derivedKeyHTTPURL = "http_url" // https://signoz.io/docs/traces-management/guides/derived-fields-spans/#http_url
derivedKeyHTTPHost = "http_host"
)
var defaultStepInterval = 60 * time.Second
type SemconvFieldMapping struct {
LegacyField string
CurrentField string
FieldType telemetrytypes.FieldDataType
Context telemetrytypes.FieldContext
}
var dualSemconvGroupByKeys = map[string][]qbtypes.GroupByKey{
"server": {
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: serverAddressKey,
FieldDataType: telemetrytypes.FieldDataTypeString,
FieldContext: telemetrytypes.FieldContextAttribute,
Signal: telemetrytypes.SignalTraces,
},
var (
groupByKeyHTTPHost = qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: derivedKeyHTTPHost,
FieldDataType: telemetrytypes.FieldDataTypeString,
FieldContext: telemetrytypes.FieldContextSpan,
Signal: telemetrytypes.SignalTraces,
},
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: serverAddressKeyLegacy,
FieldDataType: telemetrytypes.FieldDataTypeString,
FieldContext: telemetrytypes.FieldContextAttribute,
Signal: telemetrytypes.SignalTraces,
},
}
groupByKeyHTTPURL = qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: derivedKeyHTTPURL,
FieldDataType: telemetrytypes.FieldDataTypeString,
FieldContext: telemetrytypes.FieldContextSpan,
Signal: telemetrytypes.SignalTraces,
},
},
"url": {
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: urlPathKey,
FieldDataType: telemetrytypes.FieldDataTypeString,
FieldContext: telemetrytypes.FieldContextAttribute,
Signal: telemetrytypes.SignalTraces,
},
},
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: urlPathKeyLegacy,
FieldDataType: telemetrytypes.FieldDataTypeString,
FieldContext: telemetrytypes.FieldContextAttribute,
Signal: telemetrytypes.SignalTraces,
},
},
},
}
}
)
func FilterIntermediateColumns(result *qbtypes.QueryRangeResponse) *qbtypes.QueryRangeResponse {
if result == nil || result.Data.Results == nil {
@@ -114,103 +84,6 @@ func FilterIntermediateColumns(result *qbtypes.QueryRangeResponse) *qbtypes.Quer
return result
}
func MergeSemconvColumns(result *qbtypes.QueryRangeResponse) *qbtypes.QueryRangeResponse {
if result == nil || result.Data.Results == nil {
return result
}
for _, res := range result.Data.Results {
scalarData, ok := res.(*qbtypes.ScalarData)
if !ok {
continue
}
serverAddressKeyIdx := -1
serverAddressKeyLegacyIdx := -1
for i, col := range scalarData.Columns {
if col.Name == serverAddressKey {
serverAddressKeyIdx = i
} else if col.Name == serverAddressKeyLegacy {
serverAddressKeyLegacyIdx = i
}
}
if serverAddressKeyIdx == -1 || serverAddressKeyLegacyIdx == -1 {
continue
}
var newRows [][]any
for _, row := range scalarData.Data {
if len(row) <= serverAddressKeyIdx || len(row) <= serverAddressKeyLegacyIdx {
continue
}
var serverName any
if isValidValue(row[serverAddressKeyIdx]) {
serverName = row[serverAddressKeyIdx]
} else if isValidValue(row[serverAddressKeyLegacyIdx]) {
serverName = row[serverAddressKeyLegacyIdx]
}
if serverName != nil {
newRow := make([]any, len(row)-1)
newRow[0] = serverName
targetIdx := 1
for i, val := range row {
if i != serverAddressKeyLegacyIdx && i != serverAddressKeyIdx {
if targetIdx < len(newRow) {
newRow[targetIdx] = val
targetIdx++
}
}
}
newRows = append(newRows, newRow)
}
}
newColumns := make([]*qbtypes.ColumnDescriptor, len(scalarData.Columns)-1)
targetIdx := 0
for i, col := range scalarData.Columns {
if i == serverAddressKeyIdx {
newCol := &qbtypes.ColumnDescriptor{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: serverAddressKeyLegacy,
FieldDataType: col.FieldDataType,
FieldContext: col.FieldContext,
Signal: col.Signal,
},
QueryName: col.QueryName,
AggregationIndex: col.AggregationIndex,
Meta: col.Meta,
Type: col.Type,
}
newColumns[targetIdx] = newCol
targetIdx++
} else if i != serverAddressKeyLegacyIdx {
newColumns[targetIdx] = col
targetIdx++
}
}
scalarData.Columns = newColumns
scalarData.Data = newRows
}
return result
}
func isValidValue(val any) bool {
if val == nil {
return false
}
if str, ok := val.(string); ok {
return str != "" && str != "n/a"
}
return true
}
func FilterResponse(results []*qbtypes.QueryRangeResponse) []*qbtypes.QueryRangeResponse {
filteredResults := make([]*qbtypes.QueryRangeResponse, 0, len(results))
@@ -261,7 +134,7 @@ func FilterResponse(results []*qbtypes.QueryRangeResponse) []*qbtypes.QueryRange
func shouldIncludeSeries(series *qbtypes.TimeSeries) bool {
for _, label := range series.Labels {
if label.Key.Name == serverAddressKeyLegacy || label.Key.Name == serverAddressKey {
if label.Key.Name == derivedKeyHTTPHost {
if strVal, ok := label.Value.(string); ok {
if net.ParseIP(strVal) != nil {
return false
@@ -274,12 +147,10 @@ func shouldIncludeSeries(series *qbtypes.TimeSeries) bool {
func shouldIncludeRow(row *qbtypes.RawRow) bool {
if row.Data != nil {
for _, key := range []string{serverAddressKeyLegacy, serverAddressKey} {
if domainVal, ok := row.Data[key]; ok {
if domainStr, ok := domainVal.(string); ok {
if net.ParseIP(domainStr) != nil {
return false
}
if domainVal, ok := row.Data[derivedKeyHTTPHost]; ok {
if domainStr, ok := domainVal.(string); ok {
if net.ParseIP(domainStr) != nil {
return false
}
}
}
@@ -287,8 +158,8 @@ func shouldIncludeRow(row *qbtypes.RawRow) bool {
return true
}
func mergeGroupBy(base, additional []qbtypes.GroupByKey) []qbtypes.GroupByKey {
return append(base, additional...)
func mergeGroupBy(base qbtypes.GroupByKey, additional []qbtypes.GroupByKey) []qbtypes.GroupByKey {
return append([]qbtypes.GroupByKey{base}, additional...)
}
func BuildDomainList(req *thirdpartyapitypes.ThirdPartyApiRequest) (*qbtypes.QueryRangeRequest, error) {
@@ -354,10 +225,10 @@ func buildEndpointsQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.Q
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: defaultStepInterval},
Aggregations: []qbtypes.TraceAggregation{
{Expression: "count_distinct(http.url)"},
{Expression: fmt.Sprintf("count_distinct(%s)", derivedKeyHTTPURL)},
},
Filter: buildBaseFilter(req.Filter),
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["server"], req.GroupBy),
GroupBy: mergeGroupBy(groupByKeyHTTPHost, req.GroupBy),
},
}
}
@@ -373,7 +244,7 @@ func buildLastSeenQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.Qu
{Expression: "max(timestamp)"},
},
Filter: buildBaseFilter(req.Filter),
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["server"], req.GroupBy),
GroupBy: mergeGroupBy(groupByKeyHTTPHost, req.GroupBy),
},
}
}
@@ -389,7 +260,7 @@ func buildRpsQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEn
{Expression: "rate()"},
},
Filter: buildBaseFilter(req.Filter),
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["server"], req.GroupBy),
GroupBy: mergeGroupBy(groupByKeyHTTPHost, req.GroupBy),
},
}
}
@@ -407,7 +278,7 @@ func buildErrorQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.Query
{Expression: "count()"},
},
Filter: filter,
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["server"], req.GroupBy),
GroupBy: mergeGroupBy(groupByKeyHTTPHost, req.GroupBy),
},
}
}
@@ -423,7 +294,7 @@ func buildTotalSpanQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.Q
{Expression: "count()"},
},
Filter: buildBaseFilter(req.Filter),
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["server"], req.GroupBy),
GroupBy: mergeGroupBy(groupByKeyHTTPHost, req.GroupBy),
},
}
}
@@ -439,7 +310,7 @@ func buildP99Query(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtypes.QueryEn
{Expression: "p99(duration_nano)"},
},
Filter: buildBaseFilter(req.Filter),
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["server"], req.GroupBy),
GroupBy: mergeGroupBy(groupByKeyHTTPHost, req.GroupBy),
},
}
}
@@ -462,10 +333,10 @@ func buildEndpointsInfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtyp
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: defaultStepInterval},
Aggregations: []qbtypes.TraceAggregation{
{Expression: "rate(http.url)"},
{Expression: fmt.Sprintf("rate(%s)", derivedKeyHTTPURL)},
},
Filter: buildBaseFilter(req.Filter),
GroupBy: mergeGroupBy(dualSemconvGroupByKeys["url"], req.GroupBy),
GroupBy: mergeGroupBy(groupByKeyHTTPURL, req.GroupBy),
},
}
}
@@ -519,8 +390,7 @@ func buildLastSeenInfoQuery(req *thirdpartyapitypes.ThirdPartyApiRequest) qbtype
}
func buildBaseFilter(additionalFilter *qbtypes.Filter) *qbtypes.Filter {
baseExpression := fmt.Sprintf("(%s EXISTS OR %s EXISTS) AND kind_string = 'Client'",
urlPathKeyLegacy, urlPathKey)
baseExpression := fmt.Sprintf("%s EXISTS AND kind_string = 'Client'", derivedKeyHTTPURL)
if additionalFilter != nil && additionalFilter.Expression != "" {
// even if it contains kind_string we add with an AND so it doesn't matter if the user is overriding it.

View File

@@ -1,9 +1,10 @@
package thirdpartyapi
import (
"github.com/SigNoz/signoz/pkg/types/thirdpartyapitypes"
"testing"
"github.com/SigNoz/signoz/pkg/types/thirdpartyapitypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
@@ -28,7 +29,7 @@ func TestFilterResponse(t *testing.T) {
{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{Name: "net.peer.name"},
Key: telemetrytypes.TelemetryFieldKey{Name: derivedKeyHTTPHost},
Value: "192.168.1.1",
},
},
@@ -36,7 +37,7 @@ func TestFilterResponse(t *testing.T) {
{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{Name: "net.peer.name"},
Key: telemetrytypes.TelemetryFieldKey{Name: derivedKeyHTTPHost},
Value: "example.com",
},
},
@@ -60,7 +61,7 @@ func TestFilterResponse(t *testing.T) {
{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{Name: "net.peer.name"},
Key: telemetrytypes.TelemetryFieldKey{Name: derivedKeyHTTPHost},
Value: "example.com",
},
},
@@ -84,12 +85,12 @@ func TestFilterResponse(t *testing.T) {
Rows: []*qbtypes.RawRow{
{
Data: map[string]any{
"net.peer.name": "192.168.1.1",
derivedKeyHTTPHost: "192.168.1.1",
},
},
{
Data: map[string]any{
"net.peer.name": "example.com",
derivedKeyHTTPHost: "example.com",
},
},
},
@@ -106,7 +107,7 @@ func TestFilterResponse(t *testing.T) {
Rows: []*qbtypes.RawRow{
{
Data: map[string]any{
"net.peer.name": "example.com",
derivedKeyHTTPHost: "example.com",
},
},
},

454
pkg/querier/openapi.go Normal file
View File

@@ -0,0 +1,454 @@
package querier
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
)
// QueryRangeV5OpenAPIDef is the OpenAPI definition for the /api/v5/query_range endpoint.
var QueryRangeV5OpenAPIDef = handler.OpenAPIDef{
ID: "QueryRangeV5",
Tags: []string{"query"},
Summary: "Query range",
Description: "Execute a composite query over a time range. Supports builder queries (traces, logs, metrics), formulas, trace operators, PromQL, and ClickHouse SQL.",
Request: new(qbtypes.QueryRangeRequest),
RequestContentType: "application/json",
RequestExamples: queryRangeV5Examples,
Response: new(qbtypes.QueryRangeResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: []handler.OpenAPISecurityScheme{
{Name: ctxtypes.AuthTypeAPIKey.StringValue(), Scopes: []string{"VIEWER"}},
{Name: ctxtypes.AuthTypeTokenizer.StringValue(), Scopes: []string{"VIEWER"}},
},
}
var queryRangeV5Examples = []handler.OpenAPIExample{
{
Name: "traces_time_series",
Summary: "Time series: count spans grouped by service",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{
map[string]any{"expression": "count()", "alias": "span_count"},
},
"stepInterval": "60s",
"filter": map[string]any{"expression": "service.name = 'frontend'"},
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
"order": []any{map[string]any{"key": map[string]any{"name": "span_count"}, "direction": "desc"}},
"limit": 10,
},
},
},
},
},
},
{
Name: "logs_time_series",
Summary: "Time series: count error logs grouped by service",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "logs",
"aggregations": []any{
map[string]any{"expression": "count()", "alias": "log_count"},
},
"stepInterval": "60s",
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
"order": []any{map[string]any{"key": map[string]any{"name": "log_count"}, "direction": "desc"}},
"limit": 10,
},
},
},
},
},
},
{
Name: "metrics_gauge_time_series",
Summary: "Time series: latest gauge value averaged across series",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{
map[string]any{"metricName": "system.cpu.utilization", "timeAggregation": "latest", "spaceAggregation": "avg"},
},
"stepInterval": "60s",
"groupBy": []any{map[string]any{"name": "host.name", "fieldContext": "resource"}},
},
},
},
},
},
},
{
Name: "metrics_rate_time_series",
Summary: "Time series: rate of cumulative counter",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{
map[string]any{"metricName": "http.server.duration.count", "timeAggregation": "rate", "spaceAggregation": "sum"},
},
"stepInterval": 120,
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
},
},
},
},
},
},
{
Name: "metrics_histogram_time_series",
Summary: "Time series: p99 latency from histogram",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{
map[string]any{"metricName": "http.server.duration.bucket", "spaceAggregation": "p99"},
},
"stepInterval": "60s",
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
},
},
},
},
},
},
{
Name: "logs_raw",
Summary: "Raw: fetch raw log records",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "raw",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "logs",
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
"selectFields": []any{
map[string]any{"name": "body", "fieldContext": "log"},
map[string]any{"name": "service.name", "fieldContext": "resource"},
},
"order": []any{
map[string]any{"key": map[string]any{"name": "timestamp", "fieldContext": "log"}, "direction": "desc"},
map[string]any{"key": map[string]any{"name": "id"}, "direction": "desc"},
},
"limit": 50,
"offset": 0,
},
},
},
},
},
},
{
Name: "traces_raw",
Summary: "Raw: fetch raw span records",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "raw",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"filter": map[string]any{"expression": "service.name = 'frontend' AND has_error = true"},
"selectFields": []any{
map[string]any{"name": "name", "fieldContext": "span"},
map[string]any{"name": "duration_nano", "fieldContext": "span"},
},
"order": []any{
map[string]any{"key": map[string]any{"name": "timestamp", "fieldContext": "span"}, "direction": "desc"},
},
"limit": 100,
},
},
},
},
},
},
{
Name: "traces_scalar",
Summary: "Scalar: total span count",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "scalar",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{
map[string]any{"expression": "count()", "alias": "span_count"},
},
"filter": map[string]any{"expression": "service.name = 'frontend'"},
},
},
},
},
},
},
{
Name: "logs_scalar",
Summary: "Scalar: total error log count",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "scalar",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "logs",
"aggregations": []any{
map[string]any{"expression": "count()", "alias": "error_count"},
},
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
},
},
},
},
},
},
{
Name: "metrics_scalar",
Summary: "Scalar: single reduced metric value",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "scalar",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{
map[string]any{"metricName": "http.server.duration.count", "timeAggregation": "rate", "spaceAggregation": "sum", "reduceTo": "sum"},
},
"stepInterval": "60s",
},
},
},
},
},
},
{
Name: "formula",
Summary: "Formula: error rate from two trace queries",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{map[string]any{"expression": "countIf(has_error = true)"}},
"stepInterval": "60s",
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
},
},
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "B",
"signal": "traces",
"aggregations": []any{map[string]any{"expression": "count()"}},
"stepInterval": "60s",
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
},
},
map[string]any{
"type": "builder_formula",
"spec": map[string]any{
"name": "error_rate",
"expression": "A / B * 100",
},
},
},
},
},
},
{
Name: "promql",
Summary: "PromQL: request rate with UTF-8 metric name",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "promql",
"spec": map[string]any{
"name": "request_rate",
"query": "sum(rate({\"http.server.duration.count\"}[5m])) by (\"service.name\")",
"step": 60,
},
},
},
},
},
},
{
Name: "clickhouse_sql_traces_time_series",
Summary: "ClickHouse SQL: traces time series with resource filter",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "clickhouse_sql",
"spec": map[string]any{
"name": "span_rate",
"query": "WITH __resource_filter AS (" +
" SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource" +
" WHERE seen_at_ts_bucket_start >= $start_timestamp - 1800 AND seen_at_ts_bucket_start <= $end_timestamp" +
" ) SELECT toStartOfInterval(timestamp, INTERVAL 60 SECOND) AS ts, count() AS value" +
" FROM signoz_traces.distributed_signoz_index_v3" +
" WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)" +
" AND timestamp >= $start_datetime AND timestamp <= $end_datetime" +
" AND ts_bucket_start >= $start_timestamp - 1800 AND ts_bucket_start <= $end_timestamp" +
" GROUP BY ts ORDER BY ts",
},
},
},
},
},
},
{
Name: "clickhouse_sql_logs_raw",
Summary: "ClickHouse SQL: raw logs with resource filter",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "raw",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "clickhouse_sql",
"spec": map[string]any{
"name": "recent_errors",
"query": "WITH __resource_filter AS (" +
" SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource" +
" WHERE seen_at_ts_bucket_start >= $start_timestamp - 1800 AND seen_at_ts_bucket_start <= $end_timestamp" +
" ) SELECT timestamp, body" +
" FROM signoz_logs.distributed_logs_v2" +
" WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)" +
" AND timestamp >= $start_timestamp_nano AND timestamp <= $end_timestamp_nano" +
" AND ts_bucket_start >= $start_timestamp - 1800 AND ts_bucket_start <= $end_timestamp" +
" AND severity_text = 'ERROR'" +
" ORDER BY timestamp DESC LIMIT 100",
},
},
},
},
},
},
{
Name: "clickhouse_sql_traces_scalar",
Summary: "ClickHouse SQL: scalar aggregate with resource filter",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "scalar",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "clickhouse_sql",
"spec": map[string]any{
"name": "total_spans",
"query": "WITH __resource_filter AS (" +
" SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource" +
" WHERE seen_at_ts_bucket_start >= $start_timestamp - 1800 AND seen_at_ts_bucket_start <= $end_timestamp" +
" ) SELECT count() AS value" +
" FROM signoz_traces.distributed_signoz_index_v3" +
" WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)" +
" AND timestamp >= $start_datetime AND timestamp <= $end_datetime" +
" AND ts_bucket_start >= $start_timestamp - 1800 AND ts_bucket_start <= $end_timestamp",
},
},
},
},
},
},
}

View File

@@ -5001,7 +5001,6 @@ func (aH *APIHandler) getDomainList(w http.ResponseWriter, r *http.Request) {
return
}
result = thirdpartyapi.MergeSemconvColumns(result)
result = thirdpartyapi.FilterIntermediateColumns(result)
// Filter IP addresses if ShowIp is false
@@ -5058,7 +5057,6 @@ func (aH *APIHandler) getDomainInfo(w http.ResponseWriter, r *http.Request) {
return
}
result = thirdpartyapi.MergeSemconvColumns(result)
result = thirdpartyapi.FilterIntermediateColumns(result)
// Filter IP addresses if ShowIp is false

View File

@@ -7,9 +7,9 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/assert"
. "github.com/smartystreets/goconvey/convey"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
@@ -96,10 +96,10 @@ var buildProcessorTestData = []struct {
func TestBuildLogParsingProcessors(t *testing.T) {
for _, test := range buildProcessorTestData {
t.Run(test.Name, func(t *testing.T) {
Convey(test.Name, t, func() {
err := updateProcessorConfigsInCollectorConf(test.agentConf, test.pipelineProcessor)
assert.NoError(t, err)
assert.Equal(t, test.outputConf, test.agentConf)
So(err, ShouldBeNil)
So(test.agentConf, ShouldResemble, test.outputConf)
})
}
@@ -202,11 +202,11 @@ var BuildLogsPipelineTestData = []struct {
func TestBuildLogsPipeline(t *testing.T) {
for _, test := range BuildLogsPipelineTestData {
t.Run(test.Name, func(t *testing.T) {
Convey(test.Name, t, func() {
v, err := buildCollectorPipelineProcessorsList(test.currentPipeline, test.logsPipeline)
assert.NoError(t, err)
So(err, ShouldBeNil)
fmt.Println(test.Name, "\n", test.currentPipeline, "\n", v, "\n", test.expectedPipeline)
assert.Equal(t, test.expectedPipeline, v)
So(v, ShouldResemble, test.expectedPipeline)
})
}
}
@@ -239,8 +239,19 @@ func TestPipelineAliasCollisionsDontResultInDuplicateCollectorProcessors(t *test
Alias: alias,
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -299,8 +310,19 @@ func TestPipelineRouterWorksEvenIfFirstOpIsDisabled(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -361,8 +383,19 @@ func TestPipeCharInAliasDoesntBreakCollectorConfig(t *testing.T) {
Alias: "test|pipeline",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Config: []pipelinetypes.PipelineOperator{
{

View File

@@ -11,13 +11,13 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/opamptypes"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/google/uuid"
@@ -140,8 +140,15 @@ func (ic *LogParsingPipelineController) getDefaultPipelines() ([]pipelinetypes.G
Alias: "NormalizeBodyDefault",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "body EXISTS",
Filter: &v3.FilterSet{
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "body",
},
Operator: v3.FilterOperatorExists,
},
},
},
Config: []pipelinetypes.PipelineOperator{
{

View File

@@ -9,14 +9,14 @@ import (
signozstanzahelper "github.com/SigNoz/signoz-otel-collector/processor/signozlogspipelineprocessor/stanza/operator/helper"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/google/uuid"
"github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza/entry"
"github.com/stretchr/testify/assert"
. "github.com/smartystreets/goconvey/convey"
"github.com/stretchr/testify/require"
)
@@ -203,10 +203,10 @@ var prepareProcessorTestData = []struct {
func TestPreparePipelineProcessor(t *testing.T) {
for _, test := range prepareProcessorTestData {
t.Run(test.Name, func(t *testing.T) {
Convey(test.Name, t, func() {
res, err := getOperators(test.Operators)
assert.NoError(t, err)
assert.Equal(t, test.Output, res)
So(err, ShouldBeNil)
So(res, ShouldResemble, test.Output)
})
}
}
@@ -214,8 +214,19 @@ func TestPreparePipelineProcessor(t *testing.T) {
func TestNoCollectorErrorsFromProcessorsForMismatchedLogs(t *testing.T) {
require := require.New(t)
testPipelineFilter := &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
testPipelineFilter := &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
}
makeTestPipeline := func(config []pipelinetypes.PipelineOperator) pipelinetypes.GettablePipeline {
return pipelinetypes.GettablePipeline{
@@ -459,8 +470,19 @@ func TestResourceFiltersWork(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "resource.service = 'nginx'",
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "service",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeResource,
},
Operator: "=",
Value: "nginx",
},
},
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -502,11 +524,11 @@ func TestResourceFiltersWork(t *testing.T) {
func TestPipelineFilterWithStringOpsShouldNotSpamWarningsIfAttributeIsMissing(t *testing.T) {
require := require.New(t)
for _, operator := range []string{
"CONTAINS",
"NOT CONTAINS",
"REGEXP",
"NOT REGEXP",
for _, operator := range []v3.FilterOperator{
v3.FilterOperatorContains,
v3.FilterOperatorNotContains,
v3.FilterOperatorRegex,
v3.FilterOperatorNotRegex,
} {
testPipeline := pipelinetypes.GettablePipeline{
StoreablePipeline: pipelinetypes.StoreablePipeline{
@@ -518,8 +540,19 @@ func TestPipelineFilterWithStringOpsShouldNotSpamWarningsIfAttributeIsMissing(t
Alias: "pipeline1",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: fmt.Sprintf("resource.service %s 'nginx'", operator),
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "service",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeResource,
},
Operator: operator,
Value: "nginx",
},
},
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -568,8 +601,19 @@ func TestAttributePathsContainingDollarDoNotBreakCollector(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "attribute.$test = 'test'",
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "$test",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "test",
},
},
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -620,8 +664,19 @@ func TestMembershipOpInProcessorFieldExpressions(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "attribute.http.method = 'GET'",
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "http.method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -700,7 +755,7 @@ func TestMembershipOpInProcessorFieldExpressions(t *testing.T) {
}
func TestContainsFilterIsCaseInsensitive(t *testing.T) {
// The CONTAINS and NOT CONTAINS query builder filters are case insensitive when querying logs.
// The contains and ncontains query builder filters are case insensitive when querying logs.
// Pipeline filter should also behave in the same way.
require := require.New(t)
@@ -718,8 +773,18 @@ func TestContainsFilterIsCaseInsensitive(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "body CONTAINS 'log'",
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{{
Key: v3.AttributeKey{
Key: "body",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeUnspecified,
IsColumn: true,
},
Operator: "contains",
Value: "log",
}},
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -741,8 +806,18 @@ func TestContainsFilterIsCaseInsensitive(t *testing.T) {
Alias: "pipeline2",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "body NOT CONTAINS 'ecom'",
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{{
Key: v3.AttributeKey{
Key: "body",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeUnspecified,
IsColumn: true,
},
Operator: "ncontains",
Value: "ecom",
}},
},
Config: []pipelinetypes.PipelineOperator{
{

View File

@@ -7,8 +7,8 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/google/uuid"
"github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza/entry"
"github.com/stretchr/testify/require"
@@ -25,8 +25,19 @@ func TestPipelinePreview(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -47,8 +58,19 @@ func TestPipelinePreview(t *testing.T) {
Alias: "pipeline2",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -137,8 +159,19 @@ func TestGrokParsingProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Config: []pipelinetypes.PipelineOperator{
{

View File

@@ -10,8 +10,8 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
"github.com/stretchr/testify/require"
)
@@ -30,8 +30,19 @@ func TestRegexProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -86,8 +97,19 @@ func TestGrokProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -142,8 +164,19 @@ func TestJSONProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -197,9 +230,20 @@ func TestTraceParsingProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Config: []pipelinetypes.PipelineOperator{},
},
}
@@ -295,8 +339,19 @@ func TestAddProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -349,8 +404,19 @@ func TestRemoveProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -403,8 +469,19 @@ func TestCopyProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -458,8 +535,19 @@ func TestMoveProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Config: []pipelinetypes.PipelineOperator{},
},

View File

@@ -7,8 +7,8 @@ import (
"testing"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/require"
)
@@ -23,8 +23,19 @@ func TestSeverityParsingProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -130,8 +141,19 @@ func TestSeverityParsingProcessor(t *testing.T) {
func TestNoCollectorErrorsFromSeverityParserForMismatchedLogs(t *testing.T) {
require := require.New(t)
testPipelineFilter := &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
testPipelineFilter := &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
}
makeTestPipeline := func(config []pipelinetypes.PipelineOperator) pipelinetypes.GettablePipeline {
return pipelinetypes.GettablePipeline{

View File

@@ -8,8 +8,8 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/require"
)
@@ -24,8 +24,19 @@ func TestTimestampParsingProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Config: []pipelinetypes.PipelineOperator{},
},

View File

@@ -2,16 +2,11 @@ package queryBuilderToExpr
import (
"fmt"
"maps"
"reflect"
"regexp"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrylogs"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
expr "github.com/antonmedv/expr"
"go.uber.org/zap"
)
@@ -20,233 +15,119 @@ var (
CodeExprCompilationFailed = errors.MustNewCode("expr_compilation_failed")
)
var logOperatorsToExpr = map[qbtypes.FilterOperator]string{
qbtypes.FilterOperatorEqual: "==",
qbtypes.FilterOperatorNotEqual: "!=",
qbtypes.FilterOperatorLessThan: "<",
qbtypes.FilterOperatorLessThanOrEq: "<=",
qbtypes.FilterOperatorGreaterThan: ">",
qbtypes.FilterOperatorGreaterThanOrEq: ">=",
qbtypes.FilterOperatorContains: "contains",
qbtypes.FilterOperatorNotContains: "not contains",
qbtypes.FilterOperatorRegexp: "matches",
qbtypes.FilterOperatorNotRegexp: "not matches",
qbtypes.FilterOperatorIn: "in",
qbtypes.FilterOperatorNotIn: "not in",
qbtypes.FilterOperatorExists: "in",
qbtypes.FilterOperatorNotExists: "not in",
// nlike and like are not supported yet
var logOperatorsToExpr = map[v3.FilterOperator]string{
v3.FilterOperatorEqual: "==",
v3.FilterOperatorNotEqual: "!=",
v3.FilterOperatorLessThan: "<",
v3.FilterOperatorLessThanOrEq: "<=",
v3.FilterOperatorGreaterThan: ">",
v3.FilterOperatorGreaterThanOrEq: ">=",
v3.FilterOperatorContains: "contains",
v3.FilterOperatorNotContains: "not contains",
v3.FilterOperatorRegex: "matches",
v3.FilterOperatorNotRegex: "not matches",
v3.FilterOperatorIn: "in",
v3.FilterOperatorNotIn: "not in",
v3.FilterOperatorExists: "in",
v3.FilterOperatorNotExists: "not in",
// we dont support like and nlike as of now.
}
func getName(key *telemetrytypes.TelemetryFieldKey) string {
if key == nil {
return ""
}
switch key.FieldContext {
case telemetrytypes.FieldContextAttribute:
return fmt.Sprintf(`attributes["%s"]`, key.Name)
case telemetrytypes.FieldContextResource:
return fmt.Sprintf(`resource["%s"]`, key.Name)
case telemetrytypes.FieldContextBody:
return fmt.Sprintf("%s.%s", key.FieldContext.StringValue(), key.Name)
default:
return key.Name
func getName(v v3.AttributeKey) string {
if v.Type == v3.AttributeKeyTypeTag {
return fmt.Sprintf(`attributes["%s"]`, v.Key)
} else if v.Type == v3.AttributeKeyTypeResource {
return fmt.Sprintf(`resource["%s"]`, v.Key)
}
return v.Key
}
func parseCondition(c qbtypes.FilterCondition) (string, error) {
if c.Key == nil {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "condition has no keys")
func getTypeName(v v3.AttributeKeyType) string {
if v == v3.AttributeKeyTypeTag {
return "attributes"
} else if v == v3.AttributeKeyTypeResource {
return "resource"
}
if _, ok := logOperatorsToExpr[c.Op]; !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "operator not supported: %d", c.Op)
}
value := exprFormattedValue(c.Value)
var filter string
switch c.Op {
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:
// EXISTS/NOT EXISTS checks are special:
// - For body fields, we check membership in fromJSON(body) using the JSON field name.
// - For attribute/resource fields, we check membership in the appropriate map
// ("attributes" or "resource") using the logical field name.
// - For intrinsic / toplevel fields (no explicit context), we fall back to
// equality against nil (see default case below).
switch c.Key.FieldContext {
case telemetrytypes.FieldContextBody:
// if body is a string and is a valid JSON, then check if the key exists in the JSON
quoted := exprFormattedValue(c.Key.Name)
jsonMembership := fmt.Sprintf(
`((type(body) == "string" && isJSON(body)) && %s %s %s)`,
quoted, logOperatorsToExpr[c.Op], "fromJSON(body)",
)
// if body is a map, then check if the key exists in the map
operator := qbtypes.FilterOperatorNotEqual
if c.Op == qbtypes.FilterOperatorNotExists {
operator = qbtypes.FilterOperatorEqual
}
nilCheckFilter := fmt.Sprintf("%s.%s %s nil", c.Key.FieldContext.StringValue(), c.Key.Name, logOperatorsToExpr[operator])
// join the two filters with OR
filter = fmt.Sprintf(`(%s or (type(body) == "map" && (%s)))`, jsonMembership, nilCheckFilter)
case telemetrytypes.FieldContextAttribute, telemetrytypes.FieldContextResource:
// Example: "http.method" in attributes
target := "resource"
if c.Key.FieldContext == telemetrytypes.FieldContextAttribute {
target = "attributes"
}
filter = fmt.Sprintf("%q %s %s", c.Key.Name, logOperatorsToExpr[c.Op], target)
default:
// if type of key is not available; is considered as TOP LEVEL key in OTEL Log Data model hence
// switch Exist and Not Exists operators with NOT EQUAL and EQUAL respectively
operator := qbtypes.FilterOperatorNotEqual
if c.Op == qbtypes.FilterOperatorNotExists {
operator = qbtypes.FilterOperatorEqual
}
filter = fmt.Sprintf("%s %s nil", c.Key.Name, logOperatorsToExpr[operator])
}
default:
filter = fmt.Sprintf("%s %s %s", getName(c.Key), logOperatorsToExpr[c.Op], value)
if c.Op == qbtypes.FilterOperatorContains || c.Op == qbtypes.FilterOperatorNotContains {
// `contains` and `ncontains` should be case insensitive to match how they work when querying logs.
filter = fmt.Sprintf(
"lower(%s) %s lower(%s)",
getName(c.Key), logOperatorsToExpr[c.Op], value,
)
}
// Avoid running operators on nil values
if c.Op != qbtypes.FilterOperatorEqual && c.Op != qbtypes.FilterOperatorNotEqual {
filter = fmt.Sprintf("%s != nil && %s", getName(c.Key), filter)
}
}
_, err := expr.Compile(filter)
if err != nil {
return "", err
}
return filter, nil
return ""
}
// Parse converts the QB filter Expression (query builder expression string) into
// the Expr expression string used by the collector. It parses the QB expression
// into a FilterExprNode tree, then serializes that tree to the Expr dialect.
func Parse(filter *qbtypes.Filter) (string, error) {
if filter == nil || strings.TrimSpace(filter.Expression) == "" {
return "", nil
}
node, err := querybuilder.ParseFilterExpr(filter.Expression)
if err != nil {
return "", err
}
if node == nil {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter expression; failed to extract filter expression tree")
}
for _, condition := range node.Flatten() {
if condition.Key == nil {
return "", errors.NewInvalidInputf(errors.CodeInternal, "condition has no key")
func Parse(filters *v3.FilterSet) (string, error) {
var res []string
for _, v := range filters.Items {
if _, ok := logOperatorsToExpr[v.Operator]; !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "operator not supported: %s", v.Operator)
}
_, found := telemetrylogs.IntrinsicFields[condition.Key.Name]
if condition.Key.FieldContext == telemetrytypes.FieldContextUnspecified && !found {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "field %q in filter expression must include a context prefix (attribute., resource., body.) OR can be one of the following fields: %v", condition.Key.Name, maps.Keys(telemetrylogs.IntrinsicFields))
}
name := getName(v.Key)
if condition.Op == qbtypes.FilterOperatorRegexp || condition.Op == qbtypes.FilterOperatorNotRegexp {
switch condition.Value.(type) {
case string:
if _, err := regexp.Compile(condition.Value.(string)); err != nil {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "value for regex operator must be a valid regex")
var filter string
switch v.Operator {
// uncomment following lines when new version of expr is used
// case v3.FilterOperatorIn, v3.FilterOperatorNotIn:
// filter = fmt.Sprintf("%s %s list%s", name, logOperatorsToExpr[v.Operator], exprFormattedValue(v.Value))
case v3.FilterOperatorExists, v3.FilterOperatorNotExists:
// accustom log filters like `body.log.message EXISTS` into EXPR language
// where User is attempting to check for keys present in JSON log body
if strings.HasPrefix(v.Key.Key, "body.") {
// if body is a string and is a valid JSON, then check if the key exists in the JSON
filter = fmt.Sprintf(`((type(body) == "string" && isJSON(body)) && %s %s %s)`, exprFormattedValue(strings.TrimPrefix(v.Key.Key, "body.")), logOperatorsToExpr[v.Operator], "fromJSON(body)")
// if body is a map, then check if the key exists in the map
operator := v3.FilterOperatorNotEqual
if v.Operator == v3.FilterOperatorNotExists {
operator = v3.FilterOperatorEqual
}
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "value for regex operator must be a string")
nilCheckFilter := fmt.Sprintf("%s %s nil", v.Key.Key, logOperatorsToExpr[operator])
// join the two filters with OR
filter = fmt.Sprintf(`(%s or (type(body) == "map" && (%s)))`, filter, nilCheckFilter)
} else if typ := getTypeName(v.Key.Type); typ != "" {
filter = fmt.Sprintf("%s %s %s", exprFormattedValue(v.Key.Key), logOperatorsToExpr[v.Operator], typ)
} else {
// if type of key is not available; is considered as TOP LEVEL key in OTEL Log Data model hence
// switch Exist and Not Exists operators with NOT EQUAL and EQUAL respectively
operator := v3.FilterOperatorNotEqual
if v.Operator == v3.FilterOperatorNotExists {
operator = v3.FilterOperatorEqual
}
filter = fmt.Sprintf("%s %s nil", v.Key.Key, logOperatorsToExpr[operator])
}
default:
filter = fmt.Sprintf("%s %s %s", name, logOperatorsToExpr[v.Operator], exprFormattedValue(v.Value))
if v.Operator == v3.FilterOperatorContains || v.Operator == v3.FilterOperatorNotContains {
// `contains` and `ncontains` should be case insensitive to match how they work when querying logs.
filter = fmt.Sprintf(
"lower(%s) %s lower(%s)",
name, logOperatorsToExpr[v.Operator], exprFormattedValue(v.Value),
)
}
// Avoid running operators on nil values
if v.Operator != v3.FilterOperatorEqual && v.Operator != v3.FilterOperatorNotEqual {
filter = fmt.Sprintf("%s != nil && %s", name, filter)
}
}
// check if the filter is a correct expression language
_, err := expr.Compile(filter)
if err != nil {
return "", err
}
res = append(res, filter)
}
return nodeToExpr(node)
}
func nodeToExpr(node *qbtypes.FilterExprNode) (string, error) {
if node == nil {
return "", nil
// check the final filter
q := strings.Join(res, " "+strings.ToLower(filters.Operator)+" ")
_, err := expr.Compile(q)
if err != nil {
return "", errors.WrapInternalf(err, CodeExprCompilationFailed, "failed to compile expression: %s", q)
}
var result string
switch node.Op {
case qbtypes.LogicalOpLeaf:
var parts []string
for _, c := range node.Conditions {
s, err := parseCondition(c)
if err != nil {
return "", err
}
parts = append(parts, s)
}
if len(parts) == 0 {
return "", nil
}
// For a simple leaf, just join conditions with AND without wrapping
// the whole clause in parentheses
result = strings.Join(parts, " and ")
case qbtypes.LogicalOpAnd:
var parts []string
for _, child := range node.Children {
if child == nil {
continue
}
s, err := nodeToExpr(child)
if err != nil {
return "", err
}
// When mixing AND/OR, we need parentheses around any OR child to
// preserve the intended precedence: (a and (b or c)).
if child.Op == qbtypes.LogicalOpOr {
s = fmt.Sprintf("(%s)", s)
}
parts = append(parts, s)
}
if len(parts) == 0 {
return "", nil
}
result = strings.Join(parts, " and ")
case qbtypes.LogicalOpOr:
var parts []string
for _, child := range node.Children {
if child == nil {
continue
}
s, err := nodeToExpr(child)
if err != nil {
return "", err
}
// When mixing AND/OR, we need parentheses around any AND child to
// preserve the intended precedence: ((a and b) or c).
if child.Op == qbtypes.LogicalOpAnd {
s = fmt.Sprintf("(%s)", s)
}
parts = append(parts, s)
}
if len(parts) == 0 {
return "", nil
}
result = strings.Join(parts, " or ")
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported logical op: %s", node.Op)
}
if node.Negated {
// Apply a leading NOT to this subtree. Always wrap the underlying
// expression in parentheses to preserve the intended precedence.
return fmt.Sprintf("not (%s)", result), nil
}
return result, nil
return q, nil
}
func exprFormattedValue(v interface{}) string {
@@ -254,12 +135,13 @@ func exprFormattedValue(v interface{}) string {
case uint8, uint16, uint32, uint64, int, int8, int16, int32, int64:
return fmt.Sprintf("%d", x)
case float32, float64:
return fmt.Sprintf("%v", x)
return fmt.Sprintf("%f", x)
case string:
return fmt.Sprintf("\"%s\"", quoteEscapedString(x))
case bool:
return fmt.Sprintf("%v", x)
case []any:
case []interface{}:
if len(x) == 0 {
return ""
}

View File

@@ -4,7 +4,7 @@ import (
"testing"
signozstanzahelper "github.com/SigNoz/signoz-otel-collector/processor/signozlogspipelineprocessor/stanza/operator/helper"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/expr-lang/expr/vm"
"github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza/entry"
"github.com/stretchr/testify/assert"
@@ -13,191 +13,183 @@ import (
func TestParseExpression(t *testing.T) {
var testCases = []struct {
Name string
Query *qbtypes.Filter
Query *v3.FilterSet
Expr string
ExpectError bool
}{
{
Name: "equal",
Query: &qbtypes.Filter{
Expression: "attribute.key = 'checkbody'",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "checkbody", Operator: "="},
}},
Expr: `attributes["key"] == "checkbody"`,
},
{
Name: "NOT equal (unary NOT)",
Query: &qbtypes.Filter{
Expression: "NOT (attribute.key = 'checkbody')",
},
Expr: `not (attributes["key"] == "checkbody")`,
},
{
Name: "not equal",
Query: &qbtypes.Filter{
Expression: "attribute.key != 'checkbody'",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "checkbody", Operator: "!="},
}},
Expr: `attributes["key"] != "checkbody"`,
},
{
Name: "less than",
Query: &qbtypes.Filter{
Expression: "attribute.key < 10",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag}, Value: 10, Operator: "<"},
}},
Expr: `attributes["key"] != nil && attributes["key"] < 10`,
},
{
Name: "greater than",
Query: &qbtypes.Filter{
Expression: "attribute.key > 10",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag}, Value: 10, Operator: ">"},
}},
Expr: `attributes["key"] != nil && attributes["key"] > 10`,
},
{
Name: "less than equal",
Query: &qbtypes.Filter{
Expression: "attribute.key <= 10",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: 10, Operator: "<="},
}},
Expr: `attributes["key"] != nil && attributes["key"] <= 10`,
},
{
Name: "greater than equal",
Query: &qbtypes.Filter{
Expression: "attribute.key >= 10",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: 10, Operator: ">="},
}},
Expr: `attributes["key"] != nil && attributes["key"] >= 10`,
},
// case sensitive
{
Name: "body contains",
Query: &qbtypes.Filter{
Expression: "body contains 'checkbody'",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "checkbody", Operator: "contains"},
}},
Expr: `body != nil && lower(body) contains lower("checkbody")`,
},
{
Name: "body.log.message exists",
Query: &qbtypes.Filter{
Expression: "body.log.message exists",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body.log.message", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
Expr: `(((type(body) == "string" && isJSON(body)) && "log.message" in fromJSON(body)) or (type(body) == "map" && (body.log.message != nil)))`,
},
{
Name: "body.log.message not exists",
Query: &qbtypes.Filter{
Expression: "body.log.message not exists",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body.log.message", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
}},
Expr: `(((type(body) == "string" && isJSON(body)) && "log.message" not in fromJSON(body)) or (type(body) == "map" && (body.log.message == nil)))`,
},
{
Name: "body NOT CONTAINS",
Query: &qbtypes.Filter{
Expression: "body NOT CONTAINS 'checkbody'",
},
Name: "body ncontains",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "checkbody", Operator: "ncontains"},
}},
Expr: `body != nil && lower(body) not contains lower("checkbody")`,
},
{
Name: "body REGEXP",
Query: &qbtypes.Filter{
Expression: "body REGEXP '[0-1]+regex$'",
},
Name: "body regex",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-1]+regex$", Operator: "regex"},
}},
Expr: `body != nil && body matches "[0-1]+regex$"`,
},
{
Name: "body NOT REGEXP",
Query: &qbtypes.Filter{
Expression: "body NOT REGEXP '[0-1]+regex$'",
},
Name: "body not regex",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-1]+regex$", Operator: "nregex"},
}},
Expr: `body != nil && body not matches "[0-1]+regex$"`,
},
{
Name: "regex with escape characters",
Query: &qbtypes.Filter{
Expression: "body REGEXP '^Executing \\[\\S+@\\S+:[0-9]+\\] \\S+\".*'",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: `^Executing \[\S+@\S+:[0-9]+\] \S+".*`, Operator: "regex"},
}},
Expr: `body != nil && body matches "^Executing \\[\\S+@\\S+:[0-9]+\\] \\S+\".*"`,
},
{
Name: "invalid regex",
Query: &qbtypes.Filter{
Expression: "body not REGEXP '[0-9]++'",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-9]++", Operator: "nregex"},
}},
Expr: `body != nil && lower(body) not matches "[0-9]++"`,
ExpectError: true,
},
{
Name: "in",
Query: &qbtypes.Filter{
Expression: "attribute.key in [1,2,3,4]",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: []any{1, 2, 3, 4}, Operator: "in"},
}},
Expr: `attributes["key"] != nil && attributes["key"] in [1,2,3,4]`,
},
{
Name: "not in",
Query: &qbtypes.Filter{
Expression: "attribute.key not in ['1','2']",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: []any{"1", "2"}, Operator: "nin"},
}},
Expr: `attributes["key"] != nil && attributes["key"] not in ['1','2']`,
},
{
Name: "exists",
Query: &qbtypes.Filter{
Expression: "attribute.key exists",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "exists"},
}},
Expr: `"key" in attributes`,
},
{
Name: "not exists",
Query: &qbtypes.Filter{
Expression: "attribute.key not exists",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "nexists"},
}},
Expr: `"key" not in attributes`,
},
{
Name: "trace_id not exists",
Query: &qbtypes.Filter{
Expression: "trace_id not exists",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
}},
Expr: `trace_id == nil`,
},
{
Name: "trace_id exists",
Query: &qbtypes.Filter{
Expression: "trace_id exists",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
Expr: `trace_id != nil`,
},
{
Name: "span_id not exists",
Query: &qbtypes.Filter{
Expression: "span_id not exists",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "span_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
}},
Expr: `span_id == nil`,
},
{
Name: "span_id exists",
Query: &qbtypes.Filter{
Expression: "span_id exists",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "span_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
Expr: `span_id != nil`,
},
{
Name: "Multi filter",
Query: &qbtypes.Filter{
Expression: "attribute.key <= 10 and body not regexp '[0-1]+regex$' and attribute.key not exists",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: 10, Operator: "<="},
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-1]+regex$", Operator: "nregex"},
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "nexists"},
}},
Expr: `attributes["key"] != nil && attributes["key"] <= 10 and body != nil && body not matches "[0-1]+regex$" and "key" not in attributes`,
},
{
Name: "incorrect multi filter",
Query: &qbtypes.Filter{
Expression: "attribute.key <= 10 and body not regexp '[0-9]++' and attribute.key not exists",
},
ExpectError: true,
},
{
Name: "attributes. is unsupported",
Query: &qbtypes.Filter{
Expression: "attributes.key = 'checkbody'",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: 10, Operator: "<="},
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-9]++", Operator: "nregex"},
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "nexists"},
}},
Expr: `attributes["key"] != nil && attributes["key"] <= 10 and body not matches "[0-9]++" and "key" not in attributes`,
ExpectError: true,
},
}
@@ -275,241 +267,248 @@ func TestExpressionVSEntry(t *testing.T) {
var testCases = []struct {
Name string
Query *qbtypes.Filter
Query *v3.FilterSet
ExpectedMatches []int
}{
{
Name: "resource equal (env)",
Query: &qbtypes.Filter{
Expression: "resource.env = 'prod'",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "prod", Operator: "="},
}},
ExpectedMatches: []int{0, 1, 4, 5, 9, 11, 13, 14, 17, 18},
},
{
Name: "NOT resource equal (unary NOT)",
Query: &qbtypes.Filter{
Expression: "NOT (resource.env = 'prod')",
},
ExpectedMatches: []int{2, 3, 6, 7, 8, 10, 12, 15, 16, 19, 20},
},
{
Name: "resource not equal (env)",
Query: &qbtypes.Filter{
Expression: "resource.env != 'prod'",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "prod", Operator: "!="},
}},
ExpectedMatches: []int{2, 3, 6, 7, 8, 10, 12, 15, 16, 19, 20},
},
{
Name: "attribute less than (numeric)",
Query: &qbtypes.Filter{
Expression: "attribute.count < 8",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "count", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag}, Value: 8, Operator: "<"},
}},
ExpectedMatches: []int{4},
},
{
Name: "attribute greater than (numeric)",
Query: &qbtypes.Filter{
Expression: "attribute.count > 8",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "count", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag}, Value: 8, Operator: ">"},
}},
ExpectedMatches: []int{5},
},
{
Name: "body contains (case insensitive)",
Query: &qbtypes.Filter{
Expression: "body contains 'checkbody'",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "checkbody", Operator: "contains"},
}},
ExpectedMatches: []int{2, 9, 10, 16},
},
{
Name: "body NOT CONTAINS",
Query: &qbtypes.Filter{
Expression: "body NOT CONTAINS 'checkbody'",
},
Name: "body ncontains",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "checkbody", Operator: "ncontains"},
}},
ExpectedMatches: []int{0, 1, 3, 4, 5, 6, 7, 8, 11, 12, 13, 14, 15, 17},
},
{
Name: "body.msg (case insensitive)",
Query: &qbtypes.Filter{
Expression: "body.msg contains 'checkbody'",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body.msg", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: false}, Value: "checkbody", Operator: "contains"},
}},
ExpectedMatches: []int{2, 9, 10, 18},
},
{
Name: "body REGEXP",
Query: &qbtypes.Filter{
Expression: "body REGEXP '[0-1]+regex'",
},
Name: "body regex",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-1]+regex", Operator: "regex"},
}},
ExpectedMatches: []int{4},
},
{
Name: "body NOT REGEXP",
Query: &qbtypes.Filter{
Expression: "body NOT REGEXP '[0-1]+regex'",
},
Name: "body not regex",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-1]+regex", Operator: "nregex"},
}},
ExpectedMatches: []int{0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17},
},
// body.log.message exists/nexists: expr checks "log.message" in fromJSON(body); nested key
// semantics depend on signoz stanza helper. Omitted here to avoid coupling to env shape.
{
Name: "body top-level key exists (body.msg)",
Query: &qbtypes.Filter{
Expression: "body.msg exists",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body.msg", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
ExpectedMatches: []int{0, 1, 2, 3, 4, 5, 9, 10, 11, 12, 18},
},
{
Name: "body top-level key not exists (body.missing)",
Query: &qbtypes.Filter{
Expression: "body.missing not exists",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body.missing", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
}},
ExpectedMatches: []int{0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 18, 20},
},
{
Name: "attribute exists",
Query: &qbtypes.Filter{
Expression: "attribute.service exists",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "service", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "exists"},
}},
ExpectedMatches: []int{6, 7, 8, 15},
},
{
Name: "attribute not exists",
Query: &qbtypes.Filter{
Expression: "attribute.service not exists",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "service", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "nexists"},
}},
ExpectedMatches: []int{0, 1, 2, 3, 4, 5, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19, 20},
},
{
Name: "trace_id exists",
Query: &qbtypes.Filter{
Expression: "trace_id exists",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
ExpectedMatches: []int{1, 2, 5, 7, 12, 15, 19},
},
{
Name: "trace_id not exists",
Query: &qbtypes.Filter{
Expression: "trace_id not exists",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
}},
ExpectedMatches: []int{0, 3, 4, 6, 8, 9, 10, 11, 13, 14, 16, 17, 18, 20},
},
{
Name: "span_id exists",
Query: &qbtypes.Filter{
Expression: "span_id exists",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "span_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
ExpectedMatches: []int{1, 3, 5, 12, 17, 20},
},
{
Name: "span_id not exists",
Query: &qbtypes.Filter{
Expression: "span_id not exists",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "span_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
}},
ExpectedMatches: []int{0, 2, 4, 6, 7, 8, 9, 10, 11, 13, 14, 15, 16, 18, 19},
},
{
Name: "in (attribute in list)",
Query: &qbtypes.Filter{
Expression: "attribute.level in ['info', 'error']",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: []any{"info", "error"}, Operator: "in"},
}},
ExpectedMatches: []int{0, 1, 2, 14, 16, 20},
},
{
Name: "not in (attribute not in list)",
Query: &qbtypes.Filter{
Expression: "attribute.level not in ['error', 'warn']",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: []any{"error", "warn"}, Operator: "nin"},
}},
ExpectedMatches: []int{0, 2, 3, 16, 20},
},
{
Name: "multi filter AND",
Query: &qbtypes.Filter{
Expression: "resource.env = 'staging' and attribute.level = 'info'",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "staging", Operator: "="},
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "info", Operator: "="},
}},
ExpectedMatches: []int{2, 16},
},
{
Name: "multi filter AND (two attributes)",
Query: &qbtypes.Filter{
Expression: "attribute.service = 'auth' and attribute.level not exists",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "service", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "auth", Operator: "="},
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "nexists"},
}},
ExpectedMatches: []int{6, 7},
},
// Multi-filter variations: body + attribute, three conditions, trace/span + attribute
{
Name: "multi filter AND body contains + attribute",
Query: &qbtypes.Filter{
Expression: "body contains 'Connection' and resource.env = 'prod'",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "Connection", Operator: "contains"},
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "prod", Operator: "="},
}},
ExpectedMatches: []int{14},
},
{
Name: "multi filter AND body contains + service",
Query: &qbtypes.Filter{
Expression: "body contains 'login' and attribute.service = 'auth'",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "login", Operator: "contains"},
{Key: v3.AttributeKey{Key: "service", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "auth", Operator: "="},
}},
ExpectedMatches: []int{6, 15},
},
{
Name: "multi filter AND env + level (prod error)",
Query: &qbtypes.Filter{
Expression: "resource.env = 'prod' and attribute.level = 'error'",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "prod", Operator: "="},
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "error", Operator: "="},
}},
ExpectedMatches: []int{1, 14},
},
{
Name: "multi filter AND three conditions (staging + checkbody + info)",
Query: &qbtypes.Filter{
Expression: "resource.env = 'staging' and body contains 'checkbody' and attribute.level = 'info'",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "staging", Operator: "="},
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "checkbody", Operator: "contains"},
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "info", Operator: "="},
}},
ExpectedMatches: []int{2, 16},
},
{
Name: "multi filter AND trace_id exists + body contains",
Query: &qbtypes.Filter{
Expression: "trace_id exists and body contains 'checkbody'",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "checkbody", Operator: "contains"},
}},
ExpectedMatches: []int{2},
},
{
Name: "multi filter AND span_id nexists + service auth",
Query: &qbtypes.Filter{
Expression: "span_id not exists and attribute.service = 'auth'",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "span_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
{Key: v3.AttributeKey{Key: "service", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "auth", Operator: "="},
}},
ExpectedMatches: []int{6, 7, 15},
},
{
Name: "multi filter AND body regex + attribute",
Query: &qbtypes.Filter{
Expression: "body regexp '[0-1]+regex' and attribute.code = '200'",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-1]+regex", Operator: "regex"},
{Key: v3.AttributeKey{Key: "code", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "200", Operator: "="},
}},
ExpectedMatches: []int{4},
},
{
Name: "multi filter AND no trace_id + no span_id + env prod",
Query: &qbtypes.Filter{
Expression: "trace_id not exists and span_id not exists and resource.env = 'prod'",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
{Key: v3.AttributeKey{Key: "span_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "prod", Operator: "="},
}},
ExpectedMatches: []int{0, 4, 9, 11, 13, 14, 18},
},
{
Name: "multi filter AND level warn + body contains",
Query: &qbtypes.Filter{
Expression: "attribute.level = 'warn' and body contains 'disk'",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "warn", Operator: "="},
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "disk", Operator: "contains"},
}},
ExpectedMatches: []int{17},
},
{
Name: "no matches (resource value not present)",
Query: &qbtypes.Filter{
Expression: "resource.env = 'never'",
},
Name: "no matches (attribute value not present)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "never", Operator: "="},
}},
ExpectedMatches: []int{},
},
{
Name: "attribute equal and trace_id exists",
Query: &qbtypes.Filter{
Expression: "attribute.code = '404' and trace_id exists",
},
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "code", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "404", Operator: "="},
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
ExpectedMatches: []int{5},
},
}

View File

@@ -1,390 +0,0 @@
package querybuilder
import (
"fmt"
"strconv"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
grammar "github.com/SigNoz/signoz/pkg/parser/grammar"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/antlr4-go/antlr/v4"
)
// ParseFilterExpr parses a v5 filter expression and returns a logical
// tree that preserves full boolean structure (AND/OR, parentheses, NOT) as
// well as the individual conditions (keys, operator, values).
//
// This can be reused by callers that need deeper introspection into the logic
// of the filter expression without constructing any SQL or query-engine
// specific structures.
func ParseFilterExpr(expr string) (*qbtypes.FilterExprNode, error) {
if strings.TrimSpace(expr) == "" {
return qbtypes.NewEmptyFilterExprNode(), nil
}
// Setup the ANTLR parsing pipeline (same grammar as PrepareWhereClause).
input := antlr.NewInputStream(expr)
lexer := grammar.NewFilterQueryLexer(input)
lexerErrorListener := NewErrorListener()
lexer.RemoveErrorListeners()
lexer.AddErrorListener(lexerErrorListener)
tokens := antlr.NewCommonTokenStream(lexer, 0)
parserErrorListener := NewErrorListener()
parser := grammar.NewFilterQueryParser(tokens)
parser.RemoveErrorListeners()
parser.AddErrorListener(parserErrorListener)
tree := parser.Query()
// Handle syntax errors
if len(parserErrorListener.SyntaxErrors) > 0 {
combinedErrors := errors.Newf(
errors.TypeInvalidInput,
errors.CodeInvalidInput,
"Found %d syntax errors while parsing the search expression.",
len(parserErrorListener.SyntaxErrors),
)
additionals := make([]string, 0, len(parserErrorListener.SyntaxErrors))
for _, err := range parserErrorListener.SyntaxErrors {
if err.Error() != "" {
additionals = append(additionals, err.Error())
}
}
return nil, combinedErrors.WithAdditional(additionals...)
}
visitor := &filterTreeVisitor{}
rootAny := visitor.Visit(tree)
if len(visitor.errors) > 0 {
combinedErrors := errors.Newf(
errors.TypeInvalidInput,
errors.CodeInvalidInput,
"Found %d errors while parsing the search expression.",
len(visitor.errors),
)
return nil, combinedErrors.WithAdditional(visitor.errors...)
}
root, _ := rootAny.(*qbtypes.FilterExprNode)
return root, nil
}
// filterTreeVisitor builds a FilterExprNode tree from the parse tree.
type filterTreeVisitor struct {
errors []string
}
// Visit dispatches based on node type.
func (v *filterTreeVisitor) Visit(tree antlr.ParseTree) any {
if tree == nil {
return nil
}
switch t := tree.(type) {
case *grammar.QueryContext:
return v.VisitQuery(t)
case *grammar.ExpressionContext:
return v.VisitExpression(t)
case *grammar.OrExpressionContext:
return v.VisitOrExpression(t)
case *grammar.AndExpressionContext:
return v.VisitAndExpression(t)
case *grammar.UnaryExpressionContext:
return v.VisitUnaryExpression(t)
case *grammar.PrimaryContext:
return v.VisitPrimary(t)
case *grammar.ComparisonContext:
return v.VisitComparison(t)
default:
return nil
}
}
func (v *filterTreeVisitor) VisitQuery(ctx *grammar.QueryContext) any {
return v.Visit(ctx.Expression())
}
func (v *filterTreeVisitor) VisitExpression(ctx *grammar.ExpressionContext) any {
return v.Visit(ctx.OrExpression())
}
func (v *filterTreeVisitor) VisitOrExpression(ctx *grammar.OrExpressionContext) any {
andExprs := ctx.AllAndExpression()
children := make([]*qbtypes.FilterExprNode, 0, len(andExprs))
for _, andExpr := range andExprs {
if node, ok := v.Visit(andExpr).(*qbtypes.FilterExprNode); ok && node != nil {
children = append(children, node)
}
}
if len(children) == 0 {
return nil
}
if len(children) == 1 {
return children[0]
}
return &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpOr,
Children: children,
}
}
func (v *filterTreeVisitor) VisitAndExpression(ctx *grammar.AndExpressionContext) any {
unaryExprs := ctx.AllUnaryExpression()
children := make([]*qbtypes.FilterExprNode, 0, len(unaryExprs))
for _, unary := range unaryExprs {
if node, ok := v.Visit(unary).(*qbtypes.FilterExprNode); ok && node != nil {
children = append(children, node)
}
}
if len(children) == 0 {
return nil
}
if len(children) == 1 {
return children[0]
}
return &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpAnd,
Children: children,
}
}
func (v *filterTreeVisitor) VisitUnaryExpression(ctx *grammar.UnaryExpressionContext) any {
node, _ := v.Visit(ctx.Primary()).(*qbtypes.FilterExprNode)
if node == nil {
return nil
}
if ctx.NOT() != nil {
node.Negated = !node.Negated
}
return node
}
func (v *filterTreeVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any {
switch {
case ctx.OrExpression() != nil:
return v.Visit(ctx.OrExpression())
case ctx.Comparison() != nil:
return v.Visit(ctx.Comparison())
default:
// We intentionally ignore FullText/FunctionCall here for the tree
// representation. They can be added later if needed.
return nil
}
}
// VisitComparison builds a leaf node with a single ParsedFilterCondition.
func (v *filterTreeVisitor) VisitComparison(ctx *grammar.ComparisonContext) any {
key := v.buildKey(ctx.Key())
if key == nil {
return nil
}
// Handle EXISTS specially
if ctx.EXISTS() != nil {
op := qbtypes.FilterOperatorExists
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotExists
}
return &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{{
Key: key,
Op: op,
Value: nil,
}},
}
}
// Handle IN / NOT IN
if ctx.InClause() != nil || ctx.NotInClause() != nil {
values := v.buildValuesFromInClause(ctx.InClause(), ctx.NotInClause())
op := qbtypes.FilterOperatorIn
if ctx.NotInClause() != nil {
op = qbtypes.FilterOperatorNotIn
}
return &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{{
Key: key,
Op: op,
Value: values,
}},
}
}
// Handle BETWEEN / NOT BETWEEN
if ctx.BETWEEN() != nil {
valuesCtx := ctx.AllValue()
if len(valuesCtx) != 2 {
v.errors = append(v.errors, "BETWEEN operator requires exactly two values")
return nil
}
value1 := v.buildValue(valuesCtx[0])
value2 := v.buildValue(valuesCtx[1])
op := qbtypes.FilterOperatorBetween
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotBetween
}
return &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{{
Key: key,
Op: op,
Value: []any{value1, value2},
}},
}
}
// All remaining operators have exactly one value.
valuesCtx := ctx.AllValue()
if len(valuesCtx) == 0 {
v.errors = append(v.errors, "comparison operator requires a value")
return nil
}
value := v.buildValue(valuesCtx[0])
var op qbtypes.FilterOperator
switch {
case ctx.EQUALS() != nil:
op = qbtypes.FilterOperatorEqual
case ctx.NOT_EQUALS() != nil || ctx.NEQ() != nil:
op = qbtypes.FilterOperatorNotEqual
case ctx.LT() != nil:
op = qbtypes.FilterOperatorLessThan
case ctx.LE() != nil:
op = qbtypes.FilterOperatorLessThanOrEq
case ctx.GT() != nil:
op = qbtypes.FilterOperatorGreaterThan
case ctx.GE() != nil:
op = qbtypes.FilterOperatorGreaterThanOrEq
case ctx.LIKE() != nil:
op = qbtypes.FilterOperatorLike
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotLike
}
case ctx.ILIKE() != nil:
op = qbtypes.FilterOperatorILike
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotILike
}
case ctx.REGEXP() != nil:
op = qbtypes.FilterOperatorRegexp
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotRegexp
}
case ctx.CONTAINS() != nil:
op = qbtypes.FilterOperatorContains
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotContains
}
default:
v.errors = append(v.errors, fmt.Sprintf("unsupported comparison operator in expression: %s", ctx.GetText()))
return nil
}
return &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{{
Key: key,
Op: op,
Value: value,
}},
}
}
// buildKey turns a key context into a TelemetryFieldKey.
func (v *filterTreeVisitor) buildKey(ctx grammar.IKeyContext) *telemetrytypes.TelemetryFieldKey {
if ctx == nil {
return nil
}
key := telemetrytypes.GetFieldKeyFromKeyText(ctx.GetText())
return &key
}
// buildValuesFromInClause handles the IN/NOT IN value side.
func (v *filterTreeVisitor) buildValuesFromInClause(in grammar.IInClauseContext, notIn grammar.INotInClauseContext) []any {
var ctxVal any
if in != nil {
ctxVal = v.visitInClause(in)
} else if notIn != nil {
ctxVal = v.visitNotInClause(notIn)
}
switch ret := ctxVal.(type) {
case []any:
return ret
case any:
if ret != nil {
return []any{ret}
}
}
return nil
}
func (v *filterTreeVisitor) visitInClause(ctx grammar.IInClauseContext) any {
if ctx.ValueList() != nil {
return v.visitValueList(ctx.ValueList())
}
return v.buildValue(ctx.Value())
}
func (v *filterTreeVisitor) visitNotInClause(ctx grammar.INotInClauseContext) any {
if ctx.ValueList() != nil {
return v.visitValueList(ctx.ValueList())
}
return v.buildValue(ctx.Value())
}
func (v *filterTreeVisitor) visitValueList(ctx grammar.IValueListContext) any {
values := ctx.AllValue()
parts := make([]any, 0, len(values))
for _, val := range values {
parts = append(parts, v.buildValue(val))
}
return parts
}
// buildValue converts literal values into Go types (string, float64, bool).
func (v *filterTreeVisitor) buildValue(ctx grammar.IValueContext) any {
switch {
case ctx == nil:
return nil
case ctx.QUOTED_TEXT() != nil:
txt := ctx.QUOTED_TEXT().GetText()
return trimQuotes(txt)
case ctx.NUMBER() != nil:
number, err := strconv.ParseFloat(ctx.NUMBER().GetText(), 64)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to parse number %s", ctx.NUMBER().GetText()))
return nil
}
return number
case ctx.BOOL() != nil:
boolText := strings.ToLower(ctx.BOOL().GetText())
return boolText == "true"
case ctx.KEY() != nil:
return ctx.KEY().GetText()
default:
return nil
}
}

View File

@@ -1,355 +0,0 @@
package querybuilder
import (
"reflect"
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// helper to build a simple key with unspecified context/datatype.
func fk(_ *testing.T, name string) *telemetrytypes.TelemetryFieldKey {
key := telemetrytypes.GetFieldKeyFromKeyText(name)
return &key
}
func TestParseFilterExpr_NestedConditions(t *testing.T) {
tests := []struct {
name string
expr string
want *qbtypes.FilterExprNode
wantErr bool
}{
{
name: "empty expression returns nil",
expr: " ",
want: qbtypes.NewEmptyFilterExprNode(),
},
{
name: "invalid expression returns error",
expr: "attributes.key =",
wantErr: true,
},
{
name: "single simple equality leaf",
expr: "attributes.host.name = 'frontend'",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.host.name"),
Op: qbtypes.FilterOperatorEqual,
Value: "frontend",
},
},
},
},
{
name: "AND with BETWEEN numeric range and NOT boolean comparison",
expr: "attributes.status_code BETWEEN 500 AND 599 AND NOT attributes.is_error = true",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpAnd,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.status_code"),
Op: qbtypes.FilterOperatorBetween,
Value: []any{
float64(500),
float64(599),
},
},
},
},
{
Negated: true,
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.is_error"),
Op: qbtypes.FilterOperatorEqual,
Value: true,
},
},
},
},
},
},
{
name: "OR with IN list and NOT IN list using different syntaxes",
expr: "attributes.service.name IN ('api','worker') OR resource.region NOT IN [\"us-east-1\",\"us-west-2\"]",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpOr,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.service.name"),
Op: qbtypes.FilterOperatorIn,
Value: []any{
"api",
"worker",
},
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "resource.region"),
Op: qbtypes.FilterOperatorNotIn,
Value: []any{
"us-east-1",
"us-west-2",
},
},
},
},
},
},
},
{
name: "AND chain of string pattern operators with NOT variants",
expr: "attributes.message LIKE 'error%' AND attributes.message NOT ILIKE '%debug%' AND attributes.message REGEXP 'err[0-9]+' AND attributes.message NOT CONTAINS 'trace'",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpAnd,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.message"),
Op: qbtypes.FilterOperatorLike,
Value: "error%",
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.message"),
Op: qbtypes.FilterOperatorNotILike,
Value: "%debug%",
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.message"),
Op: qbtypes.FilterOperatorRegexp,
Value: "err[0-9]+",
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.message"),
Op: qbtypes.FilterOperatorNotContains,
Value: "trace",
},
},
},
},
},
},
{
name: "EXISTS and NOT EXISTS in OR expression",
expr: "attributes.host EXISTS OR attributes.cluster NOT EXISTS",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpOr,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.host"),
Op: qbtypes.FilterOperatorExists,
Value: nil,
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.cluster"),
Op: qbtypes.FilterOperatorNotExists,
Value: nil,
},
},
},
},
},
},
{
name: "KEY used as value in equality",
expr: "attributes.left = other_key",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.left"),
Op: qbtypes.FilterOperatorEqual,
Value: "other_key",
},
},
},
},
{
name: "nested OR inside AND with NOT on inner group",
// NOT applies to the whole parenthesized OR group.
expr: "attributes.env = 'prod' AND NOT (attributes.team = 'core' OR attributes.team = 'platform')",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpAnd,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.env"),
Op: qbtypes.FilterOperatorEqual,
Value: "prod",
},
},
},
{
Negated: true,
Op: qbtypes.LogicalOpOr,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.team"),
Op: qbtypes.FilterOperatorEqual,
Value: "core",
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.team"),
Op: qbtypes.FilterOperatorEqual,
Value: "platform",
},
},
},
},
},
},
},
},
{
name: "multiple nesting levels mixing AND/OR",
expr: "(attributes.status = 'critical' OR attributes.status = 'warning') AND (resource.region = 'us-east-1' OR (resource.region = 'us-west-2' AND attributes.tier = 'backend'))",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpAnd,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpOr,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.status"),
Op: qbtypes.FilterOperatorEqual,
Value: "critical",
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.status"),
Op: qbtypes.FilterOperatorEqual,
Value: "warning",
},
},
},
},
},
{
Op: qbtypes.LogicalOpOr,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "resource.region"),
Op: qbtypes.FilterOperatorEqual,
Value: "us-east-1",
},
},
},
{
Op: qbtypes.LogicalOpAnd,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "resource.region"),
Op: qbtypes.FilterOperatorEqual,
Value: "us-west-2",
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.tier"),
Op: qbtypes.FilterOperatorEqual,
Value: "backend",
},
},
},
},
},
},
},
},
},
},
{
name: "random test",
expr: "attributes.status, attributes.status_code = 200",
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
got, err := ParseFilterExpr(tt.expr)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("unexpected tree for expr %q\n got: %#v\n want: %#v", tt.expr, got, tt.want)
}
})
}
}

View File

@@ -1,7 +1,10 @@
package signoz
import (
"bytes"
"context"
"encoding/json"
"net/http"
"os"
"reflect"
@@ -22,10 +25,13 @@ import (
"github.com/SigNoz/signoz/pkg/modules/promote"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/gorilla/mux"
"github.com/swaggest/jsonschema-go"
"github.com/swaggest/openapi-go"
"github.com/swaggest/openapi-go/openapi3"
"gopkg.in/yaml.v2"
)
type OpenAPI struct {
@@ -57,6 +63,10 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
return nil, err
}
// Register routes that live outside the APIServer modules
// so they are discovered by the OpenAPI walker.
registerQueryRoutes(apiserver.Router())
reflector := openapi3.NewReflector()
reflector.JSONSchemaReflector().DefaultOptions = append(reflector.JSONSchemaReflector().DefaultOptions, jsonschema.InterceptDefName(func(t reflect.Type, defaultDefName string) string {
if defaultDefName == "RenderSuccessResponse" {
@@ -90,10 +100,67 @@ func (openapi *OpenAPI) CreateAndWrite(path string) error {
return err
}
spec, err := openapi.reflector.Spec.MarshalYAML()
// The library's MarshalYAML does a JSON round-trip that converts all numbers
// to float64, causing large integers (e.g. epoch millisecond timestamps) to
// render in scientific notation (1.6409952e+12).
jsonData, err := openapi.reflector.Spec.MarshalJSON()
if err != nil {
return err
}
dec := json.NewDecoder(bytes.NewReader(jsonData))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
return err
}
convertJSONNumbers(v)
spec, err := yaml.Marshal(v)
if err != nil {
return err
}
return os.WriteFile(path, spec, 0o600)
}
// convertJSONNumbers recursively walks a decoded JSON structure and converts
// json.Number values to int64 (preferred) or float64 so that YAML marshaling
// renders them as plain numbers instead of quoted strings.
func convertJSONNumbers(v interface{}) {
switch val := v.(type) {
case map[string]interface{}:
for k, elem := range val {
if n, ok := elem.(json.Number); ok {
if i, err := n.Int64(); err == nil {
val[k] = i
} else if f, err := n.Float64(); err == nil {
val[k] = f
}
} else {
convertJSONNumbers(elem)
}
}
case []interface{}:
for i, elem := range val {
if n, ok := elem.(json.Number); ok {
if i64, err := n.Int64(); err == nil {
val[i] = i64
} else if f, err := n.Float64(); err == nil {
val[i] = f
}
} else {
convertJSONNumbers(elem)
}
}
}
}
func registerQueryRoutes(router *mux.Router) {
router.Handle("/api/v5/query_range", handler.New(
func(http.ResponseWriter, *http.Request) {},
querier.QueryRangeV5OpenAPIDef,
)).Methods(http.MethodPost)
}

View File

@@ -169,7 +169,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddAnonymousPublicDashboardTransactionFactory(sqlstore),
sqlmigration.NewAddRootUserFactory(sqlstore, sqlschema),
sqlmigration.NewAddUserEmailOrgIDIndexFactory(sqlstore, sqlschema),
sqlmigration.NewMigratePipelineFiltersV5Factory(sqlstore),
)
}

View File

@@ -1,135 +0,0 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"strings"
"github.com/SigNoz/signoz/pkg/factory"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/queryBuilderToExpr"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type migratePipelineFiltersV5 struct {
store sqlstore.SQLStore
logger *slog.Logger
}
func NewMigratePipelineFiltersV5Factory(
store sqlstore.SQLStore,
) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("migrate_pipeline_filters_v5"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return newMigratePipelineFiltersV5(ctx, c, store, ps.Logger)
},
)
}
func newMigratePipelineFiltersV5(
_ context.Context,
_ Config,
store sqlstore.SQLStore,
logger *slog.Logger,
) (SQLMigration, error) {
if logger == nil {
logger = slog.Default()
}
return &migratePipelineFiltersV5{
store: store,
logger: logger,
}, nil
}
func (migration *migratePipelineFiltersV5) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *migratePipelineFiltersV5) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
var pipelines []pipelinetypes.StoreablePipeline
if err := tx.NewSelect().
Table("pipelines").
Column("id", "filter").
Scan(ctx, &pipelines); err != nil {
return err
}
for _, p := range pipelines {
raw := strings.TrimSpace(p.FilterString)
if raw == "" {
continue
}
// Skip migration if this already looks like a v5 filter
// ({ "expression": "..." }), but still persist the normalized form
// if we changed it.
var vf qbtypes.Filter
if err := json.Unmarshal([]byte(raw), &vf); err == nil && strings.TrimSpace(vf.Expression) != "" {
continue
}
var filterSet v3.FilterSet
if err := json.Unmarshal([]byte(raw), &filterSet); err != nil {
return err
}
// Attempt to treat the existing JSON as a v3 FilterSet and build a v5
// expression from it, using the normalized payload.
expr, migrated, err := transition.BuildFilterExpressionFromFilterSet(ctx, migration.logger, "logs", &filterSet)
if err != nil || !migrated || strings.TrimSpace(expr) == "" {
return err
}
filter := &qbtypes.Filter{Expression: expr}
_, err = queryBuilderToExpr.Parse(filter)
if err != nil {
return err
}
// Store back as v5-style Filter JSON.
out, err := json.Marshal(filter)
if err != nil {
return err
}
if _, err := tx.NewUpdate().
Table("pipelines").
Set("filter = ?", string(out)).
Where("id = ?", p.ID).
Exec(ctx); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (migration *migratePipelineFiltersV5) Down(ctx context.Context, db *bun.DB) error {
// Not reversible: v3 FilterSet structure is lost once we convert to a v5 expression.
return nil
}

View File

@@ -61,13 +61,11 @@ var (
}
)
type fieldMapper struct {
}
type fieldMapper struct {}
func NewFieldMapper() qbtypes.FieldMapper {
return &fieldMapper{}
}
func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.TelemetryFieldKey) (*schema.Column, error) {
switch key.FieldContext {
case telemetrytypes.FieldContextResource:
@@ -254,12 +252,27 @@ func (m *fieldMapper) buildFieldForJSON(key *telemetrytypes.TelemetryFieldKey) (
"plan length is less than 2 for promoted path: %s", key.Name)
}
// promoted column first then body_json column
// TODO(Piyush): Change this in future for better performance
expr = fmt.Sprintf("coalesce(%s, %s)",
fmt.Sprintf("dynamicElement(%s, '%s')", plan[1].FieldPath(), plan[1].TerminalConfig.ElemType.StringValue()),
expr,
node := plan[1]
promotedExpr := fmt.Sprintf(
"dynamicElement(%s, '%s')",
node.FieldPath(),
node.TerminalConfig.ElemType.StringValue(),
)
// dynamicElement returns NULL for scalar types or an empty array for array types.
if node.TerminalConfig.ElemType.IsArray {
expr = fmt.Sprintf(
"if(length(%s) > 0, %s, %s)",
promotedExpr,
promotedExpr,
expr,
)
} else {
// promoted column first then body_json column
// TODO(Piyush): Change this in future for better performance
expr = fmt.Sprintf("coalesce(%s, %s)", promotedExpr, expr)
}
}
return expr, nil
@@ -281,8 +294,7 @@ func (m *fieldMapper) buildArrayConcat(plan telemetrytypes.JSONAccessPlan) (stri
}
// Build arrayMap expressions for ALL available branches at the root level.
// Iterate branches in deterministic order (JSON then Dynamic) so generated SQL
// is stable across environments; map iteration order is random in Go.
// Iterate branches in deterministic order (JSON then Dynamic)
var arrayMapExpressions []string
for _, node := range plan {
for _, branchType := range node.BranchesInOrder() {

View File

@@ -32,7 +32,6 @@ func NewJSONConditionBuilder(key *telemetrytypes.TelemetryFieldKey, valueType te
// BuildCondition builds the full WHERE condition for body_json JSON paths
func (c *jsonConditionBuilder) buildJSONCondition(operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
conditions := []string{}
for _, node := range c.key.JSONPlan {
condition, err := c.emitPlannedCondition(node, operator, value, sb)
@@ -73,9 +72,9 @@ func (c *jsonConditionBuilder) buildTerminalCondition(node *telemetrytypes.JSONA
// switch operator for array membership checks
switch operator {
case qbtypes.FilterOperatorContains, qbtypes.FilterOperatorIn:
case qbtypes.FilterOperatorContains:
operator = qbtypes.FilterOperatorEqual
case qbtypes.FilterOperatorNotContains, qbtypes.FilterOperatorNotIn:
case qbtypes.FilterOperatorNotContains:
operator = qbtypes.FilterOperatorNotEqual
}
}
@@ -191,13 +190,14 @@ func (c *jsonConditionBuilder) buildArrayMembershipCondition(node *telemetrytype
arrayExpr = typedArrayExpr()
}
fieldExpr, value := querybuilder.DataTypeCollisionHandledFieldName(&localKeyCopy, value, "x", operator)
key := "x"
fieldExpr, value := querybuilder.DataTypeCollisionHandledFieldName(&localKeyCopy, value, key, operator)
op, err := c.applyOperator(sb, fieldExpr, operator, value)
if err != nil {
return "", err
}
return fmt.Sprintf("arrayExists(%s -> %s, %s)", fieldExpr, op, arrayExpr), nil
return fmt.Sprintf("arrayExists(%s -> %s, %s)", key, op, arrayExpr), nil
}
// recurseArrayHops recursively builds array traversal conditions
@@ -279,27 +279,31 @@ func (c *jsonConditionBuilder) applyOperator(sb *sqlbuilder.SelectBuilder, field
case qbtypes.FilterOperatorNotContains:
return sb.NotILike(fieldExpr, fmt.Sprintf("%%%v%%", value)), nil
case qbtypes.FilterOperatorIn, qbtypes.FilterOperatorNotIn:
// emulate IN/NOT IN using OR/AND over equals to leverage indexes consistently
values, ok := value.([]any)
if !ok {
values = []any{value}
}
conds := []string{}
for _, v := range values {
if operator == qbtypes.FilterOperatorIn {
conds = append(conds, sb.E(fieldExpr, v))
} else {
conds = append(conds, sb.NE(fieldExpr, v))
}
}
if operator == qbtypes.FilterOperatorIn {
return sb.Or(conds...), nil
return sb.In(fieldExpr, values...), nil
}
return sb.And(conds...), nil
return sb.NotIn(fieldExpr, values...), nil
case qbtypes.FilterOperatorExists:
return fmt.Sprintf("%s IS NOT NULL", fieldExpr), nil
case qbtypes.FilterOperatorNotExists:
return fmt.Sprintf("%s IS NULL", fieldExpr), nil
// between and not between
case qbtypes.FilterOperatorBetween, qbtypes.FilterOperatorNotBetween:
values, ok := value.([]any)
if !ok {
return "", qbtypes.ErrBetweenValues
}
if len(values) != 2 {
return "", qbtypes.ErrBetweenValues
}
if operator == qbtypes.FilterOperatorBetween {
return sb.Between(fieldExpr, values[0], values[1]), nil
}
return sb.NotBetween(fieldExpr, values[0], values[1]), nil
default:
return "", qbtypes.ErrUnsupportedOperator
}

View File

@@ -316,7 +316,7 @@ func TestStatementBuilderListQueryBody(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(toFloat64(x) -> toFloat64(x) = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(toFloat64(x) -> toFloat64(x) = ?, arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> toFloat64(x) = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> toFloat64(x) = ?, arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "%1.65%", 1.65, "%1.65%", 1.65, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
},
@@ -345,7 +345,7 @@ func TestStatementBuilderListQueryBody(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> x = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> x = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "%true%", true, "%true%", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
},
@@ -360,12 +360,55 @@ func TestStatementBuilderListQueryBody(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(toString(x) -> toString(x) = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> toString(x) = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "%passed%", "passed", "%passed%", "passed", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
},
expectedErr: nil,
},
{
name: "Dynamic array IN Operator",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "body.education[].parameters IN [1.65, 1.99]"},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(x -> toFloat64(x) IN (?, ?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.education`-> (arrayExists(x -> x IN (?, ?), arrayMap(x->dynamicElement(x, 'Array(Nullable(Float64))'), arrayFilter(x->(dynamicType(x) = 'Array(Nullable(Float64))'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), 1.65, 1.99, 1.65, 1.99, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
},
expectedErr: nil,
},
{
name: "Integer BETWEEN Operator",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "education[].awards[].semester BETWEEN 2 AND 4"},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (arrayExists(`body_json.education`-> (arrayExists(`body_json.education[].awards`-> toFloat64(dynamicElement(`body_json.education[].awards`.`semester`, 'Int64')) BETWEEN ? AND ?, dynamicElement(`body_json.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')) OR arrayExists(`body_json.education[].awards`-> toFloat64(dynamicElement(`body_json.education[].awards`.`semester`, 'Int64')) BETWEEN ? AND ?, arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_json.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (arrayExists(`body_json.education`-> (arrayExists(`body_json.education[].awards`-> toFloat64(dynamicElement(`body_json.education[].awards`.`semester`, 'Int64')) BETWEEN ? AND ?, dynamicElement(`body_json.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')) OR arrayExists(`body_json.education[].awards`-> toFloat64(dynamicElement(`body_json.education[].awards`.`semester`, 'Int64')) BETWEEN ? AND ?, arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_json.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{float64(2), float64(4), float64(2), float64(4), uint64(1747945619), uint64(1747983448), float64(2), float64(4), float64(2), float64(4), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},
{
name: "Integer IN Operator",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "education[].awards[].semester IN [2, 4]"},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (arrayExists(`body_json.education`-> (arrayExists(`body_json.education[].awards`-> toFloat64(dynamicElement(`body_json.education[].awards`.`semester`, 'Int64')) IN (?, ?), dynamicElement(`body_json.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')) OR arrayExists(`body_json.education[].awards`-> toFloat64(dynamicElement(`body_json.education[].awards`.`semester`, 'Int64')) IN (?, ?), arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_json.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (arrayExists(`body_json.education`-> (arrayExists(`body_json.education[].awards`-> toFloat64(dynamicElement(`body_json.education[].awards`.`semester`, 'Int64')) IN (?, ?), dynamicElement(`body_json.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')) OR arrayExists(`body_json.education[].awards`-> toFloat64(dynamicElement(`body_json.education[].awards`.`semester`, 'Int64')) IN (?, ?), arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_json.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{float64(2), float64(4), float64(2), float64(4), uint64(1747945619), uint64(1747983448), float64(2), float64(4), float64(2), float64(4), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},
{
name: "Equals to 'sports' inside array of awards",
requestType: qbtypes.RequestTypeRaw,
@@ -389,7 +432,7 @@ func TestStatementBuilderListQueryBody(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.interests`-> arrayExists(`body_json.interests[].entities`-> arrayExists(`body_json.interests[].entities[].reviews`-> arrayExists(`body_json.interests[].entities[].reviews[].entries`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(Int64))')) OR arrayExists(toFloat64(x) -> toFloat64(x) = ?, dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(Int64))'))), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata`.`positions`, 'Array(JSON(max_dynamic_types=0, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews[].entries`.`metadata`, 'Array(JSON(max_dynamic_types=1, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews`.`entries`, 'Array(JSON(max_dynamic_types=2, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities`.`reviews`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), dynamicElement(`body_json.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_json.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.interests`-> arrayExists(`body_json.interests[].entities`-> arrayExists(`body_json.interests[].entities[].reviews`-> arrayExists(`body_json.interests[].entities[].reviews[].entries`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(String))')) OR arrayExists(toFloat64OrNull(x) -> toFloat64OrNull(x) = ?, dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(String))'))), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata`.`positions`, 'Array(JSON(max_dynamic_types=0, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews[].entries`.`metadata`, 'Array(JSON(max_dynamic_types=1, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews`.`entries`, 'Array(JSON(max_dynamic_types=2, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities`.`reviews`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), dynamicElement(`body_json.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_json.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.interests`-> arrayExists(`body_json.interests[].entities`-> arrayExists(`body_json.interests[].entities[].reviews`-> arrayExists(`body_json.interests[].entities[].reviews[].entries`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(Int64))')) OR arrayExists(x -> toFloat64(x) = ?, dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(Int64))'))), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata`.`positions`, 'Array(JSON(max_dynamic_types=0, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews[].entries`.`metadata`, 'Array(JSON(max_dynamic_types=1, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews`.`entries`, 'Array(JSON(max_dynamic_types=2, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities`.`reviews`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), dynamicElement(`body_json.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_json.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.interests`-> arrayExists(`body_json.interests[].entities`-> arrayExists(`body_json.interests[].entities[].reviews`-> arrayExists(`body_json.interests[].entities[].reviews[].entries`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(String))')) OR arrayExists(x -> toFloat64OrNull(x) = ?, dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(String))'))), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata`.`positions`, 'Array(JSON(max_dynamic_types=0, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews[].entries`.`metadata`, 'Array(JSON(max_dynamic_types=1, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews`.`entries`, 'Array(JSON(max_dynamic_types=2, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities`.`reviews`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), dynamicElement(`body_json.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_json.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "%4%", float64(4), "%4%", float64(4), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{"Key `interests[].entities[].reviews[].entries[].metadata[].positions[].ratings` is ambiguous, found 2 different combinations of field context / data type: [name=interests[].entities[].reviews[].entries[].metadata[].positions[].ratings,context=body,datatype=[]int64,jsondatatype=Array(Nullable(Int64)) name=interests[].entities[].reviews[].entries[].metadata[].positions[].ratings,context=body,datatype=[]string,jsondatatype=Array(Nullable(String))]."},
},
@@ -404,7 +447,7 @@ func TestStatementBuilderListQueryBody(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.interests`-> arrayExists(`body_json.interests[].entities`-> arrayExists(`body_json.interests[].entities[].reviews`-> arrayExists(`body_json.interests[].entities[].reviews[].entries`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(Int64))')) OR arrayExists(toString(x) -> toString(x) = ?, dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(Int64))'))), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata`.`positions`, 'Array(JSON(max_dynamic_types=0, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews[].entries`.`metadata`, 'Array(JSON(max_dynamic_types=1, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews`.`entries`, 'Array(JSON(max_dynamic_types=2, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities`.`reviews`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), dynamicElement(`body_json.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_json.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.interests`-> arrayExists(`body_json.interests[].entities`-> arrayExists(`body_json.interests[].entities[].reviews`-> arrayExists(`body_json.interests[].entities[].reviews[].entries`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(String))')) OR arrayExists(x -> x = ?, dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(String))'))), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata`.`positions`, 'Array(JSON(max_dynamic_types=0, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews[].entries`.`metadata`, 'Array(JSON(max_dynamic_types=1, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews`.`entries`, 'Array(JSON(max_dynamic_types=2, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities`.`reviews`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), dynamicElement(`body_json.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_json.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.interests`-> arrayExists(`body_json.interests[].entities`-> arrayExists(`body_json.interests[].entities[].reviews`-> arrayExists(`body_json.interests[].entities[].reviews[].entries`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(Int64))')) OR arrayExists(x -> toString(x) = ?, dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(Int64))'))), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata`.`positions`, 'Array(JSON(max_dynamic_types=0, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews[].entries`.`metadata`, 'Array(JSON(max_dynamic_types=1, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews`.`entries`, 'Array(JSON(max_dynamic_types=2, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities`.`reviews`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), dynamicElement(`body_json.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_json.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.interests`-> arrayExists(`body_json.interests[].entities`-> arrayExists(`body_json.interests[].entities[].reviews`-> arrayExists(`body_json.interests[].entities[].reviews[].entries`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(String))')) OR arrayExists(x -> x = ?, dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(String))'))), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata`.`positions`, 'Array(JSON(max_dynamic_types=0, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews[].entries`.`metadata`, 'Array(JSON(max_dynamic_types=1, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews`.`entries`, 'Array(JSON(max_dynamic_types=2, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities`.`reviews`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), dynamicElement(`body_json.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_json.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "%Good%", "Good", "%Good%", "Good", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{"Key `interests[].entities[].reviews[].entries[].metadata[].positions[].ratings` is ambiguous, found 2 different combinations of field context / data type: [name=interests[].entities[].reviews[].entries[].metadata[].positions[].ratings,context=body,datatype=[]int64,jsondatatype=Array(Nullable(Int64)) name=interests[].entities[].reviews[].entries[].metadata[].positions[].ratings,context=body,datatype=[]string,jsondatatype=Array(Nullable(String))]."},
},
@@ -492,7 +535,7 @@ func TestStatementBuilderListQueryBodyPromoted(t *testing.T) {
disableBodyJSONQuery(t)
}()
statementBuilder := buildJSONTestStatementBuilder(t, "education")
statementBuilder := buildJSONTestStatementBuilder(t, "education", "tags")
cases := []struct {
name string
requestType qbtypes.RequestType
@@ -500,6 +543,20 @@ func TestStatementBuilderListQueryBodyPromoted(t *testing.T) {
expected qbtypes.Statement
expectedErr error
}{
{
name: "Has Array promoted uses body fallback",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "has(body.tags, 'production')"},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND has(if(length(dynamicElement(body_json_promoted.`tags`, 'Array(Nullable(String))')) > 0, dynamicElement(body_json_promoted.`tags`, 'Array(Nullable(String))'), dynamicElement(body_json.`tags`, 'Array(Nullable(String))')), ?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "production", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},
{
name: "Key inside Array(JSON) exists",
requestType: qbtypes.RequestTypeRaw,
@@ -551,7 +608,7 @@ func TestStatementBuilderListQueryBodyPromoted(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(toFloat64(x) -> toFloat64(x) = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(toFloat64(x) -> toFloat64(x) = ?, dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))'))) OR (arrayExists(`body_json.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(toFloat64(x) -> toFloat64(x) = ?, arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(toFloat64(x) -> toFloat64(x) = ?, arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> toFloat64(x) = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> toFloat64(x) = ?, dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))'))) OR (arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> toFloat64(x) = ?, arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> toFloat64(x) = ?, arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "%1.65%", 1.65, "%1.65%", 1.65, "%1.65%", 1.65, "%1.65%", 1.65, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,materialized=true,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,materialized=true,jsondatatype=Array(Dynamic)]."},
},
@@ -580,7 +637,7 @@ func TestStatementBuilderListQueryBodyPromoted(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> x = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> x = ?, dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))'))) OR (arrayExists(`body_json.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> x = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> x = ?, dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))'))) OR (arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "%true%", true, "%true%", true, "%true%", true, "%true%", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,materialized=true,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,materialized=true,jsondatatype=Array(Dynamic)]."},
},
@@ -595,7 +652,7 @@ func TestStatementBuilderListQueryBodyPromoted(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(toString(x) -> toString(x) = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(toString(x) -> toString(x) = ?, dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))'))) OR (arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> toString(x) = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> toString(x) = ?, dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))'))) OR (arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "%passed%", "passed", "%passed%", "passed", "%passed%", "passed", "%passed%", "passed", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,materialized=true,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,materialized=true,jsondatatype=Array(Dynamic)]."},
},

View File

@@ -3,12 +3,12 @@ package telemetrylogs
import (
"context"
"fmt"
"reflect"
"strconv"
"strings"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func parseStrValue(valueStr string, operator qbtypes.FilterOperator) (telemetrytypes.FieldDataType, any) {
@@ -41,31 +41,55 @@ func parseStrValue(valueStr string, operator qbtypes.FilterOperator) (telemetryt
}
func InferDataType(value any, operator qbtypes.FilterOperator, key *telemetrytypes.TelemetryFieldKey) (telemetrytypes.FieldDataType, any) {
// check if the value is a int, float, string, bool
valueType := telemetrytypes.FieldDataTypeUnspecified
switch v := value.(type) {
case []any:
// take the first element and infer the type
if len(v) > 0 {
valueType, _ = InferDataType(v[0], operator, key)
if operator.IsArrayOperator() && reflect.ValueOf(value).Kind() != reflect.Slice {
value = []any{value}
}
// closure to calculate the data type of the value
var closure func(value any, key *telemetrytypes.TelemetryFieldKey) (telemetrytypes.FieldDataType, any)
closure = func(value any, key *telemetrytypes.TelemetryFieldKey) (telemetrytypes.FieldDataType, any) {
// check if the value is a int, float, string, bool
valueType := telemetrytypes.FieldDataTypeUnspecified
switch v := value.(type) {
case []any:
// take the first element and infer the type
var scalerType telemetrytypes.FieldDataType
if len(v) > 0 {
// Note: [[...]] Slices inside Slices are not handled yet
if reflect.ValueOf(v[0]).Kind() == reflect.Slice {
return telemetrytypes.FieldDataTypeUnspecified, value
}
scalerType, _ = closure(v[0], key)
}
arrayType := telemetrytypes.ScalerFieldTypeToArrayFieldType[scalerType]
switch {
// decide on the field data type based on the key
case key.FieldDataType.IsArray():
return arrayType, v
default:
// TODO(Piyush): backward compatibility for the old String based JSON QB queries
if strings.HasSuffix(key.Name, telemetrytypes.ArrayAnyIndexSuffix) {
return arrayType, v
}
return scalerType, v
}
case uint8, uint16, uint32, uint64, int, int8, int16, int32, int64:
valueType = telemetrytypes.FieldDataTypeInt64
case float32, float64:
valueType = telemetrytypes.FieldDataTypeFloat64
case string:
valueType, value = parseStrValue(v, operator)
case bool:
valueType = telemetrytypes.FieldDataTypeBool
}
return valueType, v
case uint8, uint16, uint32, uint64, int, int8, int16, int32, int64:
valueType = telemetrytypes.FieldDataTypeInt64
case float32, float64:
valueType = telemetrytypes.FieldDataTypeFloat64
case string:
valueType, value = parseStrValue(v, operator)
case bool:
valueType = telemetrytypes.FieldDataTypeBool
return valueType, value
}
// check if it is array
if strings.HasSuffix(key.Name, "[*]") || strings.HasSuffix(key.Name, "[]") {
valueType = telemetrytypes.FieldDataType{String: valuer.NewString(fmt.Sprintf("[]%s", valueType.StringValue()))}
}
return valueType, value
// calculate the data type of the value
return closure(value, key)
}
func getBodyJSONPath(key *telemetrytypes.TelemetryFieldKey) string {

View File

@@ -421,6 +421,38 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
},
expectedErr: nil,
},
{
name: "IN operator with json search",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{
Expression: "body.user_names[*] IN 'john_doe'",
},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((JSONExtract(JSON_QUERY(body, '$.\"user_names\"[*]'), 'Array(String)') = ?) AND JSON_EXISTS(body, '$.\"user_names\"[*]')) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "john_doe", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},
{
name: "has with json search",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{
Expression: "has(body.user_names[*], 'john_doe')",
},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND has(JSONExtract(JSON_QUERY(body, '$.\"user_names\"[*]'), 'Array(String)'), ?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "john_doe", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},
}
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()

View File

@@ -112,7 +112,8 @@ func (t *telemetryMetaStore) buildBodyJSONPaths(ctx context.Context,
}
for _, fieldKey := range fieldKeys {
fieldKey.Materialized = promoted.Contains(fieldKey.Name)
promotedKey := strings.Split(fieldKey.Name, telemetrytypes.ArraySep)[0]
fieldKey.Materialized = promoted.Contains(promotedKey)
fieldKey.Indexes = indexes[fieldKey.Name]
}
@@ -501,7 +502,8 @@ func (t *telemetryMetaStore) GetPromotedPaths(ctx context.Context, paths ...stri
sb := sqlbuilder.Select("path").From(fmt.Sprintf("%s.%s", DBName, PromotedPathsTableName))
pathConditions := []string{}
for _, path := range paths {
pathConditions = append(pathConditions, sb.Equal("path", path))
split := strings.Split(path, telemetrytypes.ArraySep)
pathConditions = append(pathConditions, sb.Equal("path", split[0]))
}
sb.Where(sb.Or(pathConditions...))

View File

@@ -1,79 +0,0 @@
package transition
import (
"context"
"encoding/json"
"log/slog"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
)
// BuildFilterExpressionFromFilterSet converts a v3-style FilterSet JSON
// ({"op": "...", "items": [...]}) into a v5-style filter expression string
// (for example: "attribute.http.method = 'GET' AND resource.env = 'prod'").
//
// It reuses migrateCommon.createFilterExpression so that all the existing
// semantics around operators, variables, data types, and ambiguity handling
// are preserved.
//
// dataSource determines which ambiguity set to use ("logs", "traces", etc.).
// For log pipelines, pass "logs".
//
// Returns:
// - expression: the generated filter expression string
// - migrated: true if an expression was generated, false if there was
// nothing to migrate (e.g. empty filters)
// - err: non-nil only if the input JSON could not be parsed
func BuildFilterExpressionFromFilterSet(
ctx context.Context,
logger *slog.Logger,
dataSource string,
filterSet *v3.FilterSet,
) (expression string, migrated bool, err error) {
if filterSet == nil {
return "", false, nil
}
filterJSON, err := json.Marshal(filterSet)
if err != nil {
return "", false, err
}
var filters map[string]any
if err := json.Unmarshal([]byte(filterJSON), &filters); err != nil {
return "", false, err
}
mc := NewMigrateCommon(logger)
// add keys with type into ambiguity set to preserve context in generated expression
for _, item := range filterSet.Items {
if item.Key.Type == v3.AttributeKeyTypeUnspecified {
continue
}
mc.ambiguity[dataSource] = append(mc.ambiguity[dataSource], item.Key.Key)
}
// Shape expected by migrateCommon.createFilterExpression:
// queryData["dataSource"] == "logs" | "traces" | "metrics"
// queryData["filters"] == map[string]any{"op": "...", "items": [...]}
queryData := map[string]any{
"dataSource": dataSource,
"filters": filters,
}
if !mc.createFilterExpression(ctx, queryData) {
return "", false, nil
}
filterAny, ok := queryData["filter"].(map[string]any)
if !ok {
return "", false, nil
}
expr, ok := filterAny["expression"].(string)
if !ok || expr == "" {
return "", false, nil
}
return expr, true, nil
}

View File

@@ -1,220 +0,0 @@
package transition
import (
"context"
"log/slog"
"testing"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
)
func TestBuildFilterExpressionFromFilterSet(t *testing.T) {
ctx := context.Background()
logger := slog.Default()
tests := []struct {
name string
dataSource string
filterSet *v3.FilterSet
wantExpr string
wantMigrated bool
wantErr bool
}{
{
name: "empty filter JSON",
dataSource: "logs",
filterSet: &v3.FilterSet{},
wantExpr: "",
wantMigrated: false,
wantErr: false,
},
{
name: "empty items array",
dataSource: "logs",
filterSet: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{}},
wantExpr: "",
wantMigrated: false,
wantErr: false,
},
{
name: "multiple filter items with AND operator",
dataSource: "logs",
filterSet: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{Key: "http.method", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag},
Operator: v3.FilterOperatorEqual,
Value: "GET",
},
{
Key: v3.AttributeKey{Key: "environment", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource},
Operator: v3.FilterOperatorEqual,
Value: "prod",
},
},
},
wantExpr: `(attribute.http.method = 'GET' AND resource.environment = 'prod')`,
wantMigrated: true,
wantErr: false,
},
{
name: "multiple filter items with OR operator",
dataSource: "logs",
filterSet: &v3.FilterSet{
Operator: "OR",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag},
Operator: v3.FilterOperatorEqual,
Value: "error",
},
{
Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag},
Operator: v3.FilterOperatorEqual,
Value: "warn",
},
},
},
wantExpr: `(attribute.level = 'error' OR attribute.level = 'warn')`,
wantMigrated: true,
wantErr: false,
},
{
name: "in operator with array value",
dataSource: "logs",
filterSet: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{{
Key: v3.AttributeKey{Key: "service.name", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag},
Operator: v3.FilterOperatorIn,
Value: []string{"api", "web", "worker"},
}},
},
wantExpr: `attribute.service.name IN ['api', 'web', 'worker']`,
wantMigrated: true,
wantErr: false,
},
{
name: "contains operator",
dataSource: "logs",
filterSet: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{{
Key: v3.AttributeKey{Key: "message", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag},
Operator: v3.FilterOperatorContains,
Value: "error",
}},
},
wantExpr: `attribute.message CONTAINS 'error'`,
wantMigrated: true,
wantErr: false,
},
{
name: "not exists operator",
dataSource: "logs",
filterSet: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{{
Key: v3.AttributeKey{Key: "trace.id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag},
Operator: v3.FilterOperatorNotExists,
Value: nil,
}},
},
wantExpr: `attribute.trace.id NOT EXISTS`,
wantMigrated: true,
wantErr: false,
},
{
name: "regex operator",
dataSource: "logs",
filterSet: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{{
Key: v3.AttributeKey{Key: "body"},
Operator: v3.FilterOperatorRegex,
Value: ".*",
}},
},
wantExpr: `body REGEXP '.*'`,
wantMigrated: true,
wantErr: false,
},
{
name: "has operator",
dataSource: "logs",
filterSet: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{{
Key: v3.AttributeKey{Key: "tags", DataType: v3.AttributeKeyDataTypeArrayString, Type: v3.AttributeKeyTypeTag},
Operator: v3.FilterOperatorHas,
Value: "production",
}},
},
wantExpr: `has(attribute.tags, 'production')`,
wantMigrated: true,
wantErr: false,
},
{
name: "complex filter with multiple operators",
dataSource: "logs",
filterSet: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{Key: "http.method", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag},
Operator: v3.FilterOperatorEqual,
Value: "POST",
},
{
Key: v3.AttributeKey{Key: "http.status_code", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag},
Operator: v3.FilterOperatorGreaterThanOrEq,
Value: float64(400),
},
{
Key: v3.AttributeKey{Key: "resource.env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource},
Operator: v3.FilterOperatorEqual,
Value: "prod",
},
},
},
wantExpr: `(attribute.http.method = 'POST' AND attribute.http.status_code >= 400 AND resource.resource.env = 'prod')`,
wantMigrated: true,
wantErr: false,
},
{
name: "filter with resource type (non-ambiguous key)",
dataSource: "logs",
filterSet: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{{
Key: v3.AttributeKey{Key: "service.name", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource},
Operator: v3.FilterOperatorEqual,
Value: "frontend",
}},
},
wantExpr: `resource.service.name = 'frontend'`,
wantMigrated: true,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
expr, migrated, err := BuildFilterExpressionFromFilterSet(ctx, logger, tt.dataSource, tt.filterSet)
if (err != nil) != tt.wantErr {
t.Errorf("BuildFilterExpressionFromFilterSet() error = %v, wantErr %v", err, tt.wantErr)
return
}
if migrated != tt.wantMigrated {
t.Errorf("BuildFilterExpressionFromFilterSet() migrated = %v, want %v", migrated, tt.wantMigrated)
}
if expr != tt.wantExpr {
t.Errorf("BuildFilterExpressionFromFilterSet() expression = %v, want %v", expr, tt.wantExpr)
}
})
}
}

View File

@@ -19,8 +19,7 @@ type migrateCommon struct {
func NewMigrateCommon(logger *slog.Logger) *migrateCommon {
return &migrateCommon{
logger: logger,
ambiguity: make(map[string][]string),
logger: logger,
}
}

View File

@@ -260,11 +260,11 @@ type MetricHighlightsResponse struct {
ActiveTimeSeries uint64 `json:"activeTimeSeries" required:"true"`
}
// MetricAttributesRequest represents the payload for the metric attributes endpoint.
// MetricAttributesRequest represents the query parameters for the metric attributes endpoint.
type MetricAttributesRequest struct {
MetricName string `json:"metricName" required:"true"`
Start *int64 `json:"start,omitempty"`
End *int64 `json:"end,omitempty"`
MetricName string `json:"-"`
Start *int64 `query:"start"`
End *int64 `query:"end"`
}
// Validate ensures MetricAttributesRequest contains acceptable values.
@@ -273,10 +273,6 @@ func (req *MetricAttributesRequest) Validate() error {
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)
@@ -286,17 +282,6 @@ func (req *MetricAttributesRequest) Validate() error {
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" required:"true"`
@@ -310,6 +295,48 @@ type MetricAttributesResponse struct {
TotalKeys int64 `json:"totalKeys" required:"true"`
}
type MetricNameParams struct {
MetricName string `query:"metricName" required:"true"`
// ListMetricsParams represents the query parameters for the list metrics endpoint.
type ListMetricsParams struct {
Start *int64 `query:"start"`
End *int64 `query:"end"`
Limit int `query:"limit"`
Search string `query:"searchText"`
}
// Validate ensures ListMetricsParams contains acceptable values.
func (p *ListMetricsParams) Validate() error {
if p.Start != nil && *p.Start <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "start must be greater than 0")
}
if p.End != nil && *p.End <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "end must be greater than 0")
}
if p.Start != nil && p.End != nil && *p.Start >= *p.End {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "start (%d) must be less than end (%d)", *p.Start, *p.End)
}
if p.Limit < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit cannot be negative")
}
if p.Limit == 0 {
p.Limit = 100
}
if p.Limit > 5000 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must not exceed 5000")
}
return nil
}
// ListMetric represents a single metric with its metadata in the list metrics response.
type ListMetric struct {
MetricName string `json:"metricName" required:"true"`
Description string `json:"description" required:"true"`
MetricType metrictypes.Type `json:"type" required:"true"`
MetricUnit string `json:"unit" required:"true"`
Temporality metrictypes.Temporality `json:"temporality" required:"true"`
IsMonotonic bool `json:"isMonotonic" required:"true"`
}
// ListMetricsResponse represents the response for the list metrics endpoint.
type ListMetricsResponse struct {
Metrics []ListMetric `json:"metrics" required:"true" nullable:"true"`
}

View File

@@ -172,7 +172,6 @@ var (
func (TimeAggregation) Enum() []any {
return []any{
TimeAggregationUnspecified,
TimeAggregationLatest,
TimeAggregationSum,
TimeAggregationAvg,
@@ -205,7 +204,6 @@ var (
func (SpaceAggregation) Enum() []any {
return []any{
SpaceAggregationUnspecified,
SpaceAggregationSum,
SpaceAggregationAvg,
SpaceAggregationMin,

View File

@@ -7,9 +7,9 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/queryBuilderToExpr"
"github.com/SigNoz/signoz/pkg/types"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/uptrace/bun"
)
@@ -57,7 +57,7 @@ type StoreablePipeline struct {
type GettablePipeline struct {
StoreablePipeline
Filter *qbtypes.Filter `json:"filter"`
Filter *v3.FilterSet `json:"filter"`
Config []PipelineOperator `json:"config"`
}
@@ -72,7 +72,7 @@ func (i *GettablePipeline) ParseRawConfig() error {
}
func (i *GettablePipeline) ParseFilter() error {
f := qbtypes.Filter{}
f := v3.FilterSet{}
err := json.Unmarshal([]byte(i.FilterString), &f)
if err != nil {
return errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to parse filter")
@@ -200,7 +200,7 @@ type PostablePipeline struct {
Alias string `json:"alias"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
Filter *qbtypes.Filter `json:"filter"`
Filter *v3.FilterSet `json:"filter"`
Config []PipelineOperator `json:"config"`
}
@@ -218,14 +218,6 @@ func (p *PostablePipeline) IsValid() error {
}
// check the filter
if p.Filter == nil || strings.TrimSpace(p.Filter.Expression) == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "filter.expression is required")
}
// Validate that every field in the expression has an explicit context
// (attribute., resource., body., etc) so later pipeline processing does not
// need to guess. We do not validate that the field actually
// exists only that the context is specified.
_, err := queryBuilderToExpr.Parse(p.Filter)
if err != nil {
return err

View File

@@ -3,13 +3,24 @@ package pipelinetypes
import (
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/assert"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
. "github.com/smartystreets/goconvey/convey"
)
func TestIsValidPostablePipeline(t *testing.T) {
validPipelineFilter := &qbtypes.Filter{
Expression: `attribute.method = "GET"`,
validPipelineFilterSet := &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
}
var correctQueriesTest = []struct {
@@ -23,7 +34,7 @@ func TestIsValidPostablePipeline(t *testing.T) {
Name: "pipeline 1",
Alias: "pipeline1",
Enabled: true,
Filter: validPipelineFilter,
Filter: validPipelineFilterSet,
Config: []PipelineOperator{},
},
IsValid: false,
@@ -35,7 +46,7 @@ func TestIsValidPostablePipeline(t *testing.T) {
Name: "pipeline 1",
Alias: "pipeline1",
Enabled: true,
Filter: validPipelineFilter,
Filter: validPipelineFilterSet,
Config: []PipelineOperator{},
},
IsValid: false,
@@ -47,7 +58,7 @@ func TestIsValidPostablePipeline(t *testing.T) {
Name: "pipeline 1",
Alias: "pipeline1",
Enabled: true,
Filter: validPipelineFilter,
Filter: validPipelineFilterSet,
Config: []PipelineOperator{},
},
IsValid: true,
@@ -59,21 +70,19 @@ func TestIsValidPostablePipeline(t *testing.T) {
Name: "pipeline 1",
Alias: "pipeline1",
Enabled: true,
Filter: &qbtypes.Filter{
Expression: "",
},
},
IsValid: false,
},
{
Name: "Filter without context prefix on field",
Pipeline: PostablePipeline{
OrderID: 1,
Name: "pipeline 1",
Alias: "pipeline1",
Enabled: true,
Filter: &qbtypes.Filter{
Expression: `method = "GET"`,
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeUnspecified,
},
Operator: "regex",
Value: "[0-9A-Z*",
},
},
},
},
IsValid: false,
@@ -85,19 +94,19 @@ func TestIsValidPostablePipeline(t *testing.T) {
Name: "pipeline 1",
Alias: "pipeline1",
Enabled: true,
Filter: validPipelineFilter,
Filter: validPipelineFilterSet,
},
IsValid: true,
},
}
for _, test := range correctQueriesTest {
t.Run(test.Name, func(t *testing.T) {
Convey(test.Name, t, func() {
err := test.Pipeline.IsValid()
if test.IsValid {
assert.NoError(t, err)
So(err, ShouldBeNil)
} else {
assert.Error(t, err)
So(err, ShouldBeError)
}
})
}
@@ -356,12 +365,12 @@ var operatorTest = []struct {
func TestValidOperator(t *testing.T) {
for _, test := range operatorTest {
t.Run(test.Name, func(t *testing.T) {
Convey(test.Name, t, func() {
err := isValidOperator(test.Operator)
if test.IsValid {
assert.NoError(t, err)
So(err, ShouldBeNil)
} else {
assert.Error(t, err)
So(err, ShouldBeError)
}
})
}

View File

@@ -10,10 +10,35 @@ import (
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/swaggest/jsonschema-go"
)
type Step struct{ time.Duration }
var _ jsonschema.Exposer = Step{}
// JSONSchema returns a custom schema for Step that accepts either a duration string or a number (seconds).
func (Step) JSONSchema() (jsonschema.Schema, error) {
s := jsonschema.Schema{}
s.WithDescription("Step interval. Accepts a Go duration string (e.g., \"60s\", \"1m\", \"1h\") or a number representing seconds (e.g., 60).")
strSchema := jsonschema.Schema{}
strSchema.WithType(jsonschema.String.Type())
strSchema.WithExamples("60s", "5m", "1h")
strSchema.WithDescription("Duration string (e.g., \"60s\", \"5m\", \"1h\").")
numSchema := jsonschema.Schema{}
numSchema.WithType(jsonschema.Number.Type())
numSchema.WithExamples(60, 300, 3600)
numSchema.WithDescription("Duration in seconds.")
s.OneOf = []jsonschema.SchemaOrBool{
strSchema.ToSchemaOrBool(),
numSchema.ToSchemaOrBool(),
}
return s, nil
}
func (s *Step) UnmarshalJSON(b []byte) error {
if len(b) == 0 {
return nil
@@ -161,6 +186,17 @@ func (f FilterOperator) IsStringSearchOperator() bool {
}
}
// IsArrayOperator returns true if the operator works with array values only
func (f FilterOperator) IsArrayOperator() bool {
switch f {
case FilterOperatorIn, FilterOperatorNotIn,
FilterOperatorBetween, FilterOperatorNotBetween:
return true
default:
return false
}
}
type OrderDirection struct {
valuer.String
}
@@ -170,6 +206,14 @@ var (
OrderDirectionDesc = OrderDirection{valuer.NewString("desc")}
)
// Enum returns the acceptable values for OrderDirection.
func (OrderDirection) Enum() []any {
return []any{
OrderDirectionAsc,
OrderDirectionDesc,
}
}
var (
OrderDirectionMap = map[string]OrderDirection{
"asc": OrderDirectionAsc,
@@ -192,6 +236,19 @@ var (
ReduceToMedian = ReduceTo{valuer.NewString("median")}
)
// Enum returns the acceptable values for ReduceTo.
func (ReduceTo) Enum() []any {
return []any{
ReduceToSum,
ReduceToCount,
ReduceToAvg,
ReduceToMin,
ReduceToMax,
ReduceToLast,
ReduceToMedian,
}
}
// FunctionReduceTo applies the reduceTo operator to a time series and returns a new series with the reduced value
// reduceTo can be one of: last, sum, avg, min, max, count, median
// if reduceTo is not recognized, the function returns the original series

View File

@@ -1,66 +0,0 @@
package querybuildertypesv5
import "github.com/SigNoz/signoz/pkg/types/telemetrytypes"
// LogicalOp represents how child expressions are combined.
type LogicalOp string
const (
// LogicalOpLeaf represents a leaf node containing one or more simple conditions.
LogicalOpLeaf LogicalOp = "LEAF"
// LogicalOpAnd represents an AND combination of children.
LogicalOpAnd LogicalOp = "AND"
// LogicalOpOr represents an OR combination of children.
LogicalOpOr LogicalOp = "OR"
)
// FilterExprNode is a reusable logical representation of a filter expression.
//
// - Leaf nodes (Op == LogicalOpLeaf) contain one or more ParsedFilterCondition.
// - Non-leaf nodes (Op == LogicalOpAnd/LogicalOpOr) contain Children.
// - Negated indicates a leading NOT applied to this subtree.
type FilterExprNode struct {
Op LogicalOp
Negated bool
Conditions []FilterCondition
Children []*FilterExprNode
}
func NewEmptyFilterExprNode() *FilterExprNode {
return &FilterExprNode{
Op: LogicalOpLeaf,
}
}
func (f *FilterExprNode) Flatten() []FilterCondition {
var conditions []FilterCondition
var walk func(node *FilterExprNode)
walk = func(node *FilterExprNode) {
if node == nil {
return
}
if node.Op == LogicalOpLeaf {
conditions = append(conditions, node.Conditions...)
}
for _, child := range node.Children {
walk(child)
}
}
walk(f)
return conditions
}
// FilterCondition represents a single comparison or existence check
// extracted from a filter expression.
//
// - Keys: one or more logical field keys on the left-hand side (see where_clause_visitor.VisitKey
// for why one expression key can resolve to multiple TelemetryFieldKeys).
// - Op: filter operator (e.g. =, !=, in, exists, between).
// - Value: right-hand side literal (any type: single value, slice for IN/NOT IN, nil for EXISTS, etc.).
type FilterCondition struct {
Key *telemetrytypes.TelemetryFieldKey
Op FilterOperator
Value any
}

View File

@@ -36,6 +36,30 @@ var (
FunctionNameFillZero = FunctionName{valuer.NewString("fillZero")}
)
// Enum returns the acceptable values for FunctionName.
func (FunctionName) Enum() []any {
return []any{
FunctionNameCutOffMin,
FunctionNameCutOffMax,
FunctionNameClampMin,
FunctionNameClampMax,
FunctionNameAbsolute,
FunctionNameRunningDiff,
FunctionNameLog2,
FunctionNameLog10,
FunctionNameCumulativeSum,
FunctionNameEWMA3,
FunctionNameEWMA5,
FunctionNameEWMA7,
FunctionNameMedian3,
FunctionNameMedian5,
FunctionNameMedian7,
FunctionNameTimeShift,
FunctionNameAnomaly,
FunctionNameFillZero,
}
}
// Validate checks if the FunctionName is valid and one of the known types
func (fn FunctionName) Validate() error {
validFunctions := []FunctionName{

View File

@@ -16,6 +16,17 @@ var (
JoinTypeCross = JoinType{valuer.NewString("cross")}
)
// Enum returns the acceptable values for JoinType.
func (JoinType) Enum() []any {
return []any{
JoinTypeInner,
JoinTypeLeft,
JoinTypeRight,
JoinTypeFull,
JoinTypeCross,
}
}
type QueryRef struct {
Name string `json:"name"`
}

View File

@@ -2,6 +2,8 @@ package querybuildertypesv5
import (
"context"
"github.com/swaggest/jsonschema-go"
)
type Query interface {
@@ -29,4 +31,12 @@ type ExecStats struct {
StepIntervals map[string]uint64 `json:"stepIntervals,omitempty"`
}
var _ jsonschema.Preparer = &ExecStats{}
// PrepareJSONSchema adds description to the ExecStats schema.
func (e *ExecStats) PrepareJSONSchema(schema *jsonschema.Schema) error {
schema.WithDescription("Execution statistics for the query, including rows scanned, bytes scanned, and duration.")
return nil
}
type TimeRange struct{ From, To uint64 } // ms since epoch

View File

@@ -16,3 +16,17 @@ var (
QueryTypeClickHouseSQL = QueryType{valuer.NewString("clickhouse_sql")}
QueryTypePromQL = QueryType{valuer.NewString("promql")}
)
// Enum returns the acceptable values for QueryType.
func (QueryType) Enum() []any {
return []any{
QueryTypeBuilder,
QueryTypeFormula,
// Not yet supported.
// QueryTypeSubQuery,
// QueryTypeJoin,
QueryTypeTraceOperator,
QueryTypeClickHouseSQL,
QueryTypePromQL,
}
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/swaggest/jsonschema-go"
)
type QueryEnvelope struct {
@@ -18,6 +19,71 @@ type QueryEnvelope struct {
Spec any `json:"spec"`
}
// queryEnvelopeBuilderTrace is the OpenAPI schema for a QueryEnvelope with type=builder_query and signal=traces.
type queryEnvelopeBuilderTrace struct {
Type QueryType `json:"type" description:"The type of the query."`
Spec QueryBuilderQuery[TraceAggregation] `json:"spec" description:"The trace builder query specification."`
}
// queryEnvelopeBuilderLog is the OpenAPI schema for a QueryEnvelope with type=builder_query and signal=logs.
type queryEnvelopeBuilderLog struct {
Type QueryType `json:"type" description:"The type of the query."`
Spec QueryBuilderQuery[LogAggregation] `json:"spec" description:"The log builder query specification."`
}
// queryEnvelopeBuilderMetric is the OpenAPI schema for a QueryEnvelope with type=builder_query and signal=metrics.
type queryEnvelopeBuilderMetric struct {
Type QueryType `json:"type" description:"The type of the query."`
Spec QueryBuilderQuery[MetricAggregation] `json:"spec" description:"The metric builder query specification."`
}
// queryEnvelopeFormula is the OpenAPI schema for a QueryEnvelope with type=builder_formula.
type queryEnvelopeFormula struct {
Type QueryType `json:"type" description:"The type of the query."`
Spec QueryBuilderFormula `json:"spec" description:"The formula specification."`
}
// queryEnvelopeJoin is the OpenAPI schema for a QueryEnvelope with type=builder_join.
// type queryEnvelopeJoin struct {
// Type QueryType `json:"type" description:"The type of the query."`
// Spec QueryBuilderJoin `json:"spec" description:"The join specification."`
// }
// queryEnvelopeTraceOperator is the OpenAPI schema for a QueryEnvelope with type=builder_trace_operator.
type queryEnvelopeTraceOperator struct {
Type QueryType `json:"type" description:"The type of the query."`
Spec QueryBuilderTraceOperator `json:"spec" description:"The trace operator specification."`
}
// queryEnvelopePromQL is the OpenAPI schema for a QueryEnvelope with type=promql.
type queryEnvelopePromQL struct {
Type QueryType `json:"type" description:"The type of the query."`
Spec PromQuery `json:"spec" description:"The PromQL query specification."`
}
// queryEnvelopeClickHouseSQL is the OpenAPI schema for a QueryEnvelope with type=clickhouse_sql.
type queryEnvelopeClickHouseSQL struct {
Type QueryType `json:"type" description:"The type of the query."`
Spec ClickHouseQuery `json:"spec" description:"The ClickHouse SQL query specification."`
}
var _ jsonschema.OneOfExposer = QueryEnvelope{}
// JSONSchemaOneOf returns the oneOf variants for the QueryEnvelope discriminated union.
// Each variant represents a different query type with its corresponding spec schema.
func (QueryEnvelope) JSONSchemaOneOf() []any {
return []any{
queryEnvelopeBuilderTrace{},
queryEnvelopeBuilderLog{},
queryEnvelopeBuilderMetric{},
queryEnvelopeFormula{},
// queryEnvelopeJoin{},
queryEnvelopeTraceOperator{},
queryEnvelopePromQL{},
queryEnvelopeClickHouseSQL{},
}
}
// implement custom json unmarshaler for the QueryEnvelope
func (q *QueryEnvelope) UnmarshalJSON(data []byte) error {
var shadow struct {
@@ -130,6 +196,12 @@ type CompositeQuery struct {
Queries []QueryEnvelope `json:"queries"`
}
// PrepareJSONSchema adds description to the CompositeQuery schema.
func (c *CompositeQuery) PrepareJSONSchema(schema *jsonschema.Schema) error {
schema.WithDescription("Composite query containing one or more query envelopes. Each query envelope specifies its type and corresponding spec.")
return nil
}
// UnmarshalJSON implements custom JSON unmarshaling to provide better error messages
func (c *CompositeQuery) UnmarshalJSON(data []byte) error {
type Alias CompositeQuery
@@ -192,6 +264,16 @@ var (
TextBoxVariableType = VariableType{valuer.NewString("text")}
)
// Enum returns the acceptable values for VariableType.
func (VariableType) Enum() []any {
return []any{
QueryVariableType,
DynamicVariableType,
CustomVariableType,
TextBoxVariableType,
}
}
type VariableItem struct {
Type VariableType `json:"type"`
Value any `json:"value"`
@@ -217,6 +299,12 @@ type QueryRangeRequest struct {
FormatOptions *FormatOptions `json:"formatOptions,omitempty"`
}
// PrepareJSONSchema adds description to the QueryRangeRequest schema.
func (q *QueryRangeRequest) PrepareJSONSchema(schema *jsonschema.Schema) error {
schema.WithDescription("Request body for the v5 query range endpoint. Supports builder queries (traces, logs, metrics), formulas, joins, trace operators, PromQL, and ClickHouse SQL queries.")
return nil
}
func (r *QueryRangeRequest) StepIntervalForQuery(name string) int64 {
stepsMap := make(map[string]int64)
for _, query := range r.CompositeQuery.Queries {

View File

@@ -30,3 +30,15 @@ var (
func (r RequestType) IsAggregation() bool {
return r == RequestTypeTimeSeries || r == RequestTypeScalar || r == RequestTypeDistribution
}
// Enum implements jsonschema.Enum; returns the acceptable values for RequestType.
func (RequestType) Enum() []any {
return []any{
RequestTypeScalar,
RequestTypeTimeSeries,
RequestTypeRaw,
RequestTypeRawStream,
RequestTypeTrace,
// RequestTypeDistribution,
}
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/swaggest/jsonschema-go"
)
type QBEvent struct {
@@ -42,6 +43,17 @@ type QueryData struct {
Results []any `json:"results"`
}
var _ jsonschema.OneOfExposer = QueryData{}
// JSONSchemaOneOf documents the polymorphic result types in QueryData.Results.
func (QueryData) JSONSchemaOneOf() []any {
return []any{
TimeSeriesData{},
ScalarData{},
RawData{},
}
}
type QueryRangeResponse struct {
Type RequestType `json:"type"`
Data QueryData `json:"data"`
@@ -52,6 +64,14 @@ type QueryRangeResponse struct {
QBEvent *QBEvent `json:"-"`
}
var _ jsonschema.Preparer = &QueryRangeResponse{}
// PrepareJSONSchema adds description to the QueryRangeResponse schema.
func (q *QueryRangeResponse) PrepareJSONSchema(schema *jsonschema.Schema) error {
schema.WithDescription("Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series, ScalarData for scalar, or RawData for raw requests.")
return nil
}
type TimeSeriesData struct {
QueryName string `json:"queryName"`
Aggregations []*AggregationBucket `json:"aggregations"`
@@ -159,6 +179,14 @@ var (
ColumnTypeAggregation = ColumnType{valuer.NewString("aggregation")}
)
// Enum returns the acceptable values for ColumnType.
func (ColumnType) Enum() []any {
return []any{
ColumnTypeGroup,
ColumnTypeAggregation,
}
}
type ColumnDescriptor struct {
telemetrytypes.TelemetryFieldKey
QueryName string `json:"queryName"`

View File

@@ -23,8 +23,10 @@ const (
// e.g., "body.status" where "body." is the prefix
BodyJSONStringSearchPrefix = "body."
ArraySep = jsontypeexporter.ArraySeparator
ArraySepSuffix = "[]"
// TODO(Piyush): Remove once we've migrated to the new array syntax
ArrayAnyIndex = "[*]."
ArrayAnyIndex = "[*]."
ArrayAnyIndexSuffix = "[*]"
)
type TelemetryFieldKey struct {

View File

@@ -172,3 +172,18 @@ func isContextValidForSignal(ctx FieldContext, signal Signal) bool {
}
return true
}
// Enum returns the acceptable values for FieldContext.
func (FieldContext) Enum() []any {
return []any{
FieldContextMetric,
FieldContextLog,
FieldContextSpan,
// FieldContextTrace,
FieldContextResource,
// FieldContextScope,
FieldContextAttribute,
// FieldContextEvent,
FieldContextBody,
}
}

View File

@@ -93,6 +93,14 @@ var (
FieldDataTypeArrayFloat64: "Array(Float64)",
FieldDataTypeArrayBool: "Array(Bool)",
}
ScalerFieldTypeToArrayFieldType = map[FieldDataType]FieldDataType{
FieldDataTypeString: FieldDataTypeArrayString,
FieldDataTypeBool: FieldDataTypeArrayBool,
FieldDataTypeNumber: FieldDataTypeArrayNumber,
FieldDataTypeInt64: FieldDataTypeArrayInt64,
FieldDataTypeFloat64: FieldDataTypeArrayFloat64,
}
)
func (f FieldDataType) CHDataType() string {
@@ -169,3 +177,19 @@ func (f FieldDataType) TagDataType() string {
return "string"
}
}
// Enum returns the acceptable values for FieldDataType.
func (FieldDataType) Enum() []any {
return []any{
FieldDataTypeString,
FieldDataTypeBool,
FieldDataTypeFloat64,
FieldDataTypeInt64,
FieldDataTypeNumber,
// FieldDataTypeArrayString,
// FieldDataTypeArrayFloat64,
// FieldDataTypeArrayBool,
// FieldDataTypeArrayInt64,
// FieldDataTypeArrayNumber,
}
}

View File

@@ -12,3 +12,12 @@ var (
SignalMetrics = Signal{valuer.NewString("metrics")}
SignalUnspecified = Signal{valuer.NewString("")}
)
// Enum returns the acceptable values for Signal.
func (Signal) Enum() []any {
return []any{
SignalTraces,
SignalLogs,
SignalMetrics,
}
}

View File

@@ -10,3 +10,10 @@ var (
SourceMeter = Source{valuer.NewString("meter")}
SourceUnspecified = Source{valuer.NewString("")}
)
// Enum returns the acceptable values for Source.
func (Source) Enum() []any {
return []any{
SourceMeter,
}
}

View File

@@ -65,6 +65,7 @@ func TestJSONTypeSet() (map[string][]JSONDataType, MetadataStore) {
"interests[].entities[].reviews[].entries[].metadata[].positions[].unit": {String},
"interests[].entities[].reviews[].entries[].metadata[].positions[].ratings": {ArrayInt64, ArrayString},
"message": {String},
"tags": {ArrayString},
}
return types, nil

View File

@@ -0,0 +1,62 @@
from http import HTTPStatus
from typing import Callable
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import SigNoz
DUPLICATE_USER_EMAIL = "duplicate@integration.test"
def test_duplicate_user_invite_rejected(
signoz: SigNoz,
get_token: Callable[[str, str], str],
):
"""
Verify that the unique index on (email, org_id) in the users table prevents
creating duplicate users. This invites a new user, accepts the invite, then
tries to invite and accept the same email again expecting a failure.
"""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Step 1: Invite a new user.
initial_invite_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": DUPLICATE_USER_EMAIL, "role": "EDITOR"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert initial_invite_response.status_code == HTTPStatus.CREATED
initial_invite_token = initial_invite_response.json()["data"]["token"]
# Step 2: Accept the invite to create the user.
initial_accept_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
json={"token": initial_invite_token, "password": "password123Z$"},
timeout=2,
)
assert initial_accept_response.status_code == HTTPStatus.CREATED
# Step 3: Invite the same email again.
duplicate_invite_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": DUPLICATE_USER_EMAIL, "role": "VIEWER"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
# The invite creation itself may be rejected if the app checks for existing users.
if duplicate_invite_response.status_code != HTTPStatus.CREATED:
assert duplicate_invite_response.status_code == HTTPStatus.CONFLICT
return
duplicate_invite_token = duplicate_invite_response.json()["data"]["token"]
# Step 4: Accept the duplicate invite — should fail due to unique constraint.
duplicate_accept_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
json={"token": duplicate_invite_token, "password": "password123Z$"},
timeout=2,
)
assert duplicate_accept_response.status_code == HTTPStatus.CONFLICT