mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-06 09:42:18 +00:00
Compare commits
1 Commits
integrate-
...
chore/push
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8322e09485 |
9
.github/CODEOWNERS
vendored
9
.github/CODEOWNERS
vendored
@@ -55,6 +55,7 @@
|
||||
/pkg/telemetrymetrics/ @srikanthccv
|
||||
/pkg/telemetrytraces/ @srikanthccv
|
||||
|
||||
|
||||
# Metrics
|
||||
|
||||
/pkg/types/metrictypes/ @srikanthccv
|
||||
@@ -90,14 +91,6 @@
|
||||
# AuthN / AuthZ Owners
|
||||
|
||||
/pkg/authz/ @vikrantgupta25
|
||||
/ee/authz/ @vikrantgupta25
|
||||
/pkg/authn/ @vikrantgupta25
|
||||
/ee/authn/ @vikrantgupta25
|
||||
/pkg/modules/user/ @vikrantgupta25
|
||||
/pkg/modules/session/ @vikrantgupta25
|
||||
/pkg/modules/organization/ @vikrantgupta25
|
||||
/pkg/modules/authdomain/ @vikrantgupta25
|
||||
/pkg/modules/role/ @vikrantgupta25
|
||||
|
||||
# Integration tests
|
||||
|
||||
|
||||
62
.vscode/launch.json
vendored
Normal file
62
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"name": "enterprise",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"buildFlags": [
|
||||
"-race",
|
||||
"-ldflags=-X github.com/SigNoz/signoz/pkg/version.version=dev -X github.com/SigNoz/signoz/pkg/version.variant=enterprise -X github.com/SigNoz/signoz/ee/zeus.url=https://api.staging.signoz.cloud"
|
||||
],
|
||||
"program": "${workspaceFolder}/cmd/enterprise/",
|
||||
"args": ["server"],
|
||||
"env": {
|
||||
"SIGNOZ_VERSION_BANNER_ENABLED": "true",
|
||||
"SIGNOZ_INSTRUMENTATION_LOGS_LEVEL": "debug",
|
||||
"SIGNOZ_SQLSTORE_PROVIDER": "sqlite",
|
||||
"SIGNOZ_SQLSTORE_SQLITE_PATH": "${workspaceFolder}/.dev/data/sqlite/enterprise.db",
|
||||
"SIGNOZ_WEB_ENABLED": "false",
|
||||
"SIGNOZ_SQLMIGRATOR_LOCK_INTERVAL": "1m",
|
||||
"SIGNOZ_ALERTMANAGER_PROVIDER": "signoz",
|
||||
"SIGNOZ_TELEMETRYSTORE_PROVIDER": "clickhouse",
|
||||
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER": "cluster",
|
||||
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN": "tcp://0.0.0.0:9001",
|
||||
"SIGNOZ_PROMETHEUS_ACTIVE__QUERY__TRACKER_ENABLED": "false",
|
||||
"SIGNOZ_EMAILING_ENABLED": "false",
|
||||
"DOT_METRICS_ENABLED": "true",
|
||||
"SIGNOZ_GLOBAL_INGESTION__URL": "http://localhost:3001",
|
||||
"SIGNOZ_TOKENIZER_PROVIDER": "opaque"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "community",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"buildFlags": [
|
||||
"-race",
|
||||
"-ldflags=-X github.com/SigNoz/signoz/pkg/version.version=dev -X github.com/SigNoz/signoz/pkg/version.variant=community -X github.com/SigNoz/signoz/ee/zeus.url=https://api.staging.signoz.cloud"
|
||||
],
|
||||
"program": "${workspaceFolder}/cmd/community/",
|
||||
"args": ["server"],
|
||||
"env": {
|
||||
"SIGNOZ_VERSION_BANNER_ENABLED": "true",
|
||||
"SIGNOZ_INSTRUMENTATION_LOGS_LEVEL": "debug",
|
||||
"SIGNOZ_SQLSTORE_PROVIDER": "sqlite",
|
||||
"SIGNOZ_SQLSTORE_SQLITE_PATH": "${workspaceFolder}/.dev/data/sqlite/community.db",
|
||||
"SIGNOZ_WEB_ENABLED": "false",
|
||||
"SIGNOZ_SQLMIGRATOR_LOCK_INTERVAL": "1m",
|
||||
"SIGNOZ_ALERTMANAGER_PROVIDER": "signoz",
|
||||
"SIGNOZ_TELEMETRYSTORE_PROVIDER": "clickhouse",
|
||||
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER": "cluster",
|
||||
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN": "tcp://0.0.0.0:9001",
|
||||
"SIGNOZ_PROMETHEUS_ACTIVE__QUERY__TRACKER_ENABLED": "false",
|
||||
"SIGNOZ_EMAILING_ENABLED": "false",
|
||||
"DOT_METRICS_ENABLED": "true",
|
||||
"SIGNOZ_GLOBAL_INGESTION__URL": "http://localhost:3001",
|
||||
"SIGNOZ_TOKENIZER_PROVIDER": "opaque"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -607,186 +607,6 @@ paths:
|
||||
summary: Update auth domain
|
||||
tags:
|
||||
- authdomains
|
||||
/api/v1/fields/keys:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns field keys
|
||||
operationId: GetFieldsKeys
|
||||
parameters:
|
||||
- in: query
|
||||
name: signal
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: source
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: startUnixMilli
|
||||
schema:
|
||||
format: int64
|
||||
type: integer
|
||||
- in: query
|
||||
name: endUnixMilli
|
||||
schema:
|
||||
format: int64
|
||||
type: integer
|
||||
- in: query
|
||||
name: fieldContext
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: fieldDataType
|
||||
schema:
|
||||
type: string
|
||||
- content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TelemetrytypesMetricContext'
|
||||
in: query
|
||||
name: metricContext
|
||||
- in: query
|
||||
name: name
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: searchText
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TelemetrytypesGettableFieldKeys'
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get field keys
|
||||
tags:
|
||||
- fields
|
||||
/api/v1/fields/values:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns field values
|
||||
operationId: GetFieldsValues
|
||||
parameters:
|
||||
- in: query
|
||||
name: signal
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: source
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: startUnixMilli
|
||||
schema:
|
||||
format: int64
|
||||
type: integer
|
||||
- in: query
|
||||
name: endUnixMilli
|
||||
schema:
|
||||
format: int64
|
||||
type: integer
|
||||
- in: query
|
||||
name: fieldContext
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: fieldDataType
|
||||
schema:
|
||||
type: string
|
||||
- content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TelemetrytypesMetricContext'
|
||||
in: query
|
||||
name: metricContext
|
||||
- in: query
|
||||
name: name
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: searchText
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: existingQuery
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TelemetrytypesGettableFieldValues'
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get field values
|
||||
tags:
|
||||
- fields
|
||||
/api/v1/getResetPasswordToken/{id}:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -3561,65 +3381,6 @@ components:
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
TelemetrytypesGettableFieldKeys:
|
||||
properties:
|
||||
complete:
|
||||
type: boolean
|
||||
keys:
|
||||
additionalProperties:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
type: array
|
||||
nullable: true
|
||||
type: object
|
||||
type: object
|
||||
TelemetrytypesGettableFieldValues:
|
||||
properties:
|
||||
complete:
|
||||
type: boolean
|
||||
values:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldValues'
|
||||
type: object
|
||||
TelemetrytypesMetricContext:
|
||||
properties:
|
||||
metricName:
|
||||
type: string
|
||||
type: object
|
||||
TelemetrytypesTelemetryFieldKey:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
fieldContext:
|
||||
type: string
|
||||
fieldDataType:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
signal:
|
||||
type: string
|
||||
unit:
|
||||
type: string
|
||||
type: object
|
||||
TelemetrytypesTelemetryFieldValues:
|
||||
properties:
|
||||
boolValues:
|
||||
items:
|
||||
type: boolean
|
||||
type: array
|
||||
numberValues:
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
type: array
|
||||
relatedValues:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
stringValues:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
type: object
|
||||
TypesChangePasswordRequest:
|
||||
properties:
|
||||
newPassword:
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/SigNoz/signoz/ee/query-service/integrations/gateway"
|
||||
"github.com/SigNoz/signoz/ee/query-service/usage"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/apis/fields"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
querierAPI "github.com/SigNoz/signoz/pkg/querier"
|
||||
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
@@ -53,6 +54,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
|
||||
FluxInterval: opts.FluxInterval,
|
||||
AlertmanagerAPI: alertmanager.NewAPI(signoz.Alertmanager),
|
||||
LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing),
|
||||
FieldsAPI: fields.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.TelemetryStore),
|
||||
Signoz: signoz,
|
||||
QuerierAPI: querierAPI.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.Querier, signoz.Analytics),
|
||||
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
|
||||
|
||||
@@ -236,6 +236,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
apiHandler.RegisterLogsRoutes(r, am)
|
||||
apiHandler.RegisterIntegrationRoutes(r, am)
|
||||
apiHandler.RegisterCloudIntegrationsRoutes(r, am)
|
||||
apiHandler.RegisterFieldsRoutes(r, am)
|
||||
apiHandler.RegisterQueryRangeV3Routes(r, am)
|
||||
apiHandler.RegisterInfraMetricsRoutes(r, am)
|
||||
apiHandler.RegisterQueryRangeV4Routes(r, am)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OPERATORS, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { Formula } from 'container/QueryBuilder/components/Formula';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { memo, useEffect, useMemo, useRef } from 'react';
|
||||
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -33,7 +33,6 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
addTraceOperator,
|
||||
panelType,
|
||||
initialDataSource,
|
||||
handleRunQuery,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const containerRef = useRef(null);
|
||||
@@ -158,29 +157,10 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const tagName = target?.tagName || '';
|
||||
|
||||
const isInputElement =
|
||||
['INPUT', 'TEXTAREA', 'SELECT'].includes(tagName) ||
|
||||
(target?.getAttribute('contenteditable') || '').toLowerCase() === 'true';
|
||||
|
||||
// Allow input elements in qb to run the query when Cmd/Ctrl + Enter is pressed
|
||||
if (isInputElement && (e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleRunQuery();
|
||||
}
|
||||
},
|
||||
[handleRunQuery],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryBuilderV2Provider>
|
||||
<div className="query-builder-v2">
|
||||
<div className="qb-content-container" onKeyDownCapture={handleKeyDown}>
|
||||
<div className="qb-content-container">
|
||||
{!isMultiQueryAllowed ? (
|
||||
<QueryV2
|
||||
ref={containerRef}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { get, isEmpty } from 'lodash-es';
|
||||
import { BarChart2, ChevronUp, ExternalLink, ScrollText } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
@@ -171,9 +171,6 @@ function QueryAddOns({
|
||||
|
||||
const [selectedViews, setSelectedViews] = useState<AddOn[]>([]);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
const prevAvailableKeysRef = useRef<Set<string> | null>(null);
|
||||
|
||||
const { handleChangeQueryData } = useQueryOperations({
|
||||
index,
|
||||
query,
|
||||
@@ -216,41 +213,23 @@ function QueryAddOns({
|
||||
}
|
||||
setAddOns(filteredAddOns);
|
||||
|
||||
const availableAddOnKeys = new Set(filteredAddOns.map((a) => a.key));
|
||||
const previousKeys = prevAvailableKeysRef.current;
|
||||
const hasAvailabilityItemsChanged =
|
||||
previousKeys !== null &&
|
||||
(previousKeys.size !== availableAddOnKeys.size ||
|
||||
[...availableAddOnKeys].some((key) => !previousKeys.has(key)));
|
||||
prevAvailableKeysRef.current = availableAddOnKeys;
|
||||
const activeAddOnKeys = new Set(
|
||||
Object.entries(ADD_ONS_KEYS_TO_QUERY_PATH)
|
||||
.filter(([, path]) => hasValue(get(query, path)))
|
||||
.map(([key]) => key),
|
||||
);
|
||||
|
||||
if (!initializedRef.current || hasAvailabilityItemsChanged) {
|
||||
initializedRef.current = true;
|
||||
|
||||
const activeAddOnKeys = new Set(
|
||||
Object.entries(ADD_ONS_KEYS_TO_QUERY_PATH)
|
||||
.filter(([, path]) => hasValue(get(query, path)))
|
||||
.map(([key]) => key),
|
||||
);
|
||||
|
||||
// Initial seeding from query values on mount
|
||||
setSelectedViews(
|
||||
filteredAddOns.filter(
|
||||
(addOn) =>
|
||||
activeAddOnKeys.has(addOn.key) && availableAddOnKeys.has(addOn.key),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedViews((prev) =>
|
||||
prev.filter((view) =>
|
||||
filteredAddOns.some((addOn) => addOn.key === view.key),
|
||||
const availableAddOnKeys = new Set(filteredAddOns.map((addOn) => addOn.key));
|
||||
// Filter and set selected views: add-ons that are both active and available
|
||||
setSelectedViews(
|
||||
filteredAddOns.filter(
|
||||
(addOn) =>
|
||||
activeAddOnKeys.has(addOn.key) && availableAddOnKeys.has(addOn.key),
|
||||
),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [panelType, isListViewPanel, query, showReduceTo]);
|
||||
}, [panelType, isListViewPanel, query]);
|
||||
|
||||
const handleOptionClick = (e: RadioChangeEvent): void => {
|
||||
if (selectedViews.find((view) => view.key === e.target.value.key)) {
|
||||
|
||||
@@ -1379,6 +1379,8 @@ function QuerySearch({
|
||||
run: (): boolean => {
|
||||
if (onRun && typeof onRun === 'function') {
|
||||
onRun(getCurrentExpression());
|
||||
} else {
|
||||
handleRunQuery();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -410,6 +410,8 @@ function TraceOperatorEditor({
|
||||
run: (): boolean => {
|
||||
if (onRun && typeof onRun === 'function') {
|
||||
onRun(value);
|
||||
} else {
|
||||
handleRunQuery();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -270,6 +270,44 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
|
||||
await waitFor(() => expect(onRun).toHaveBeenCalled(), { timeout: 2000 });
|
||||
});
|
||||
|
||||
it('calls handleRunQuery when Mod-Enter without onRun', async () => {
|
||||
const mockedHandleRunQuery = handleRunQueryMock as jest.MockedFunction<
|
||||
() => void
|
||||
>;
|
||||
mockedHandleRunQuery.mockClear();
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
queryData={initialQueriesMap.logs.builder.queryData[0]}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for CodeMirror to initialize
|
||||
await waitFor(() => {
|
||||
const editor = document.querySelector(CM_EDITOR_SELECTOR);
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
|
||||
await userEvent.click(editor);
|
||||
await userEvent.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
|
||||
|
||||
// Use fireEvent for keyboard shortcuts as userEvent might not work well with CodeMirror
|
||||
const modKey = navigator.platform.includes('Mac') ? 'metaKey' : 'ctrlKey';
|
||||
fireEvent.keyDown(editor, {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
[modKey]: true,
|
||||
keyCode: 13,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mockedHandleRunQuery).toHaveBeenCalled(), {
|
||||
timeout: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
it('initializes CodeMirror with expression from queryData.filter.expression on mount', async () => {
|
||||
const testExpression =
|
||||
"http.status_code >= 500 AND service.name = 'frontend'";
|
||||
|
||||
@@ -3,21 +3,14 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import { jest } from '@jest/globals';
|
||||
import { fireEvent, waitFor } from '@testing-library/react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import {
|
||||
Having,
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { Having, IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { UseQueryOperations } from 'types/common/operations.types';
|
||||
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
|
||||
import { QueryBuilderV2 } from '../../QueryBuilderV2';
|
||||
import { QueryV2 } from '../QueryV2';
|
||||
|
||||
// Local mocks for domain-specific heavy child components
|
||||
jest.mock(
|
||||
@@ -43,87 +36,16 @@ const mockedUseQueryOperations = jest.mocked(
|
||||
useQueryOperations,
|
||||
) as jest.MockedFunction<UseQueryOperations>;
|
||||
|
||||
describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
let handleRunQueryMock: jest.MockedFunction<() => void>;
|
||||
|
||||
describe('QueryV2 - base render', () => {
|
||||
beforeEach(() => {
|
||||
const mockCloneQuery = jest.fn() as jest.MockedFunction<
|
||||
(type: string, q: IBuilderQuery) => void
|
||||
>;
|
||||
handleRunQueryMock = jest.fn() as jest.MockedFunction<() => void>;
|
||||
const baseQuery: IBuilderQuery = {
|
||||
queryName: 'A',
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: '',
|
||||
aggregations: [],
|
||||
timeAggregation: '',
|
||||
spaceAggregation: '',
|
||||
temporality: '',
|
||||
functions: [],
|
||||
filter: undefined,
|
||||
filters: { items: [], op: 'AND' },
|
||||
groupBy: [],
|
||||
expression: '',
|
||||
disabled: false,
|
||||
having: [] as Having[],
|
||||
limit: 10,
|
||||
stepInterval: null,
|
||||
orderBy: [],
|
||||
legend: 'A',
|
||||
};
|
||||
|
||||
const currentQueryObj: Query = {
|
||||
id: 'test',
|
||||
unit: undefined,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
builder: {
|
||||
queryData: [baseQuery],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
};
|
||||
|
||||
const updateAllQueriesOperators: QueryBuilderContextType['updateAllQueriesOperators'] = (
|
||||
q,
|
||||
) => q;
|
||||
const updateQueriesData: QueryBuilderContextType['updateQueriesData'] = (q) =>
|
||||
q;
|
||||
|
||||
mockedUseQueryBuilder.mockReturnValue(({
|
||||
currentQuery: currentQueryObj,
|
||||
stagedQuery: null,
|
||||
lastUsedQuery: null,
|
||||
setLastUsedQuery: jest.fn(),
|
||||
supersetQuery: currentQueryObj,
|
||||
setSupersetQuery: jest.fn(),
|
||||
initialDataSource: null,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
isEnabledQuery: true,
|
||||
handleSetQueryData: jest.fn(),
|
||||
handleSetTraceOperatorData: jest.fn(),
|
||||
handleSetFormulaData: jest.fn(),
|
||||
handleSetQueryItemData: jest.fn(),
|
||||
handleSetConfig: jest.fn(),
|
||||
removeQueryBuilderEntityByIndex: jest.fn(),
|
||||
removeAllQueryBuilderEntities: jest.fn(),
|
||||
removeQueryTypeItemByIndex: jest.fn(),
|
||||
addNewBuilderQuery: jest.fn(),
|
||||
addNewFormula: jest.fn(),
|
||||
removeTraceOperator: jest.fn(),
|
||||
addTraceOperator: jest.fn(),
|
||||
// Only fields used by QueryV2
|
||||
cloneQuery: mockCloneQuery,
|
||||
addNewQueryItem: jest.fn(),
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
handleRunQuery: handleRunQueryMock,
|
||||
resetQuery: jest.fn(),
|
||||
handleOnUnitsChange: jest.fn(),
|
||||
updateAllQueriesOperators,
|
||||
updateQueriesData,
|
||||
initQueryBuilderData: jest.fn(),
|
||||
isStagedQueryUpdated: jest.fn(() => false),
|
||||
isDefaultQuery: jest.fn(() => false),
|
||||
panelType: null,
|
||||
} as unknown) as QueryBuilderContextType);
|
||||
|
||||
mockedUseQueryOperations.mockReturnValue({
|
||||
@@ -149,7 +71,40 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
});
|
||||
|
||||
it('renders limit input when dataSource is logs', () => {
|
||||
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
|
||||
const baseQuery: IBuilderQuery = {
|
||||
queryName: 'A',
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: '',
|
||||
aggregations: [],
|
||||
timeAggregation: '',
|
||||
spaceAggregation: '',
|
||||
temporality: '',
|
||||
functions: [],
|
||||
filter: undefined,
|
||||
filters: { items: [], op: 'AND' },
|
||||
groupBy: [],
|
||||
expression: '',
|
||||
disabled: false,
|
||||
having: [] as Having[],
|
||||
limit: 10,
|
||||
stepInterval: null,
|
||||
orderBy: [],
|
||||
legend: 'A',
|
||||
};
|
||||
|
||||
render(
|
||||
<QueryV2
|
||||
index={0}
|
||||
isAvailableToDisable
|
||||
query={baseQuery}
|
||||
version="v4"
|
||||
onSignalSourceChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
signalSourceChangeEnabled={false}
|
||||
queriesCount={1}
|
||||
showTraceOperator={false}
|
||||
hasTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Ensure the Limit add-on input is present and is of type number
|
||||
const limitInput = screen.getByPlaceholderText(
|
||||
@@ -160,43 +115,4 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
expect(limitInput).toHaveAttribute('name', 'limit');
|
||||
expect(limitInput).toHaveAttribute('data-testid', 'input-Limit');
|
||||
});
|
||||
|
||||
it('Cmd+Enter on an input triggers handleRunQuery via container handler', async () => {
|
||||
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
|
||||
|
||||
const limitInput = screen.getByPlaceholderText('Enter limit');
|
||||
fireEvent.keyDown(limitInput, {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
metaKey: true,
|
||||
});
|
||||
|
||||
expect(handleRunQueryMock).toHaveBeenCalled();
|
||||
|
||||
const legendInput = screen.getByPlaceholderText('Write legend format');
|
||||
fireEvent.keyDown(legendInput, {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
metaKey: true,
|
||||
});
|
||||
|
||||
expect(handleRunQueryMock).toHaveBeenCalled();
|
||||
|
||||
const CM_EDITOR_SELECTOR = '.cm-editor .cm-content';
|
||||
// Wait for CodeMirror to initialize
|
||||
await waitFor(() => {
|
||||
const editor = document.querySelector(CM_EDITOR_SELECTOR);
|
||||
expect(editor).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
|
||||
await userEvent.click(editor);
|
||||
fireEvent.keyDown(editor, {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
metaKey: true,
|
||||
});
|
||||
|
||||
expect(handleRunQueryMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
.license-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.license-section-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.license-section-title {
|
||||
color: #fff;
|
||||
font-family: Inter;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.08px;
|
||||
}
|
||||
}
|
||||
|
||||
.license-section-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.license-section-content-item {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--Slate-500, #161922);
|
||||
background: var(--Ink-400, #121317);
|
||||
border-radius: 3px;
|
||||
|
||||
.license-section-content-item-title-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
color: var(--Vanilla-300, #eee);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: normal;
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.license-section-content-item-description {
|
||||
color: var(--Vanilla-400, #c0c1c3);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.license-section {
|
||||
.license-section-header {
|
||||
.license-section-title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.license-section-content {
|
||||
.license-section-content-item {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
.license-section-content-item-title-action {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.license-section-content-item-description {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import './LicenseSection.styles.scss';
|
||||
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Typography } from 'antd';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
function LicenseSection(): JSX.Element | null {
|
||||
const { activeLicense } = useAppContext();
|
||||
const { notifications } = useNotifications();
|
||||
const [, handleCopyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const getMaskedKey = (key: string): string => {
|
||||
if (!key || key.length < 4) return key || 'N/A';
|
||||
return `${key.substring(0, 2)}********${key
|
||||
.substring(key.length - 2)
|
||||
.trim()}`;
|
||||
};
|
||||
|
||||
const handleCopyKey = (text: string): void => {
|
||||
handleCopyToClipboard(text);
|
||||
notifications.success({
|
||||
message: 'Copied to clipboard',
|
||||
});
|
||||
};
|
||||
|
||||
if (!activeLicense?.key) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="license-section">
|
||||
<div className="license-section-header">
|
||||
<div className="license-section-title">License</div>
|
||||
</div>
|
||||
|
||||
<div className="license-section-content">
|
||||
<div className="license-section-content-item">
|
||||
<div className="license-section-content-item-title-action">
|
||||
<span>License key</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Typography.Text code>{getMaskedKey(activeLicense.key)}</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label="Copy license key"
|
||||
data-testid="license-key-copy-btn"
|
||||
onClick={(): void => handleCopyKey(activeLicense.key)}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="license-section-content-item-description">
|
||||
Your SigNoz license key.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LicenseSection;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './LicenseSection';
|
||||
@@ -1,31 +1,8 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MySettingsContainer from 'container/MySettings';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'tests/test-utils';
|
||||
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
const toggleThemeFunction = jest.fn();
|
||||
const logEventFunction = jest.fn();
|
||||
const copyToClipboardFn = jest.fn();
|
||||
const editUserFn = jest.fn();
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
__esModule: true,
|
||||
useCopyToClipboard: (): [unknown, (text: string) => void] => [
|
||||
null,
|
||||
copyToClipboardFn,
|
||||
],
|
||||
}));
|
||||
|
||||
jest.mock('api/v1/user/id/update', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]): Promise<unknown> => editUserFn(...args),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
__esModule: true,
|
||||
@@ -67,7 +44,6 @@ const PASSWORD_VALIDATION_MESSAGE_TEST_ID = 'password-validation-message';
|
||||
describe('MySettings Flows', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
editUserFn.mockResolvedValue({});
|
||||
render(<MySettingsContainer />);
|
||||
});
|
||||
|
||||
@@ -239,71 +215,4 @@ describe('MySettings Flows', () => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('License section', () => {
|
||||
it('Should render license section content when license key exists', () => {
|
||||
expect(screen.getByText('License')).toBeInTheDocument();
|
||||
expect(screen.getByText('License key')).toBeInTheDocument();
|
||||
expect(screen.getByText('Your SigNoz license key.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should not render license section when license key is missing', () => {
|
||||
const { container } = render(<MySettingsContainer />, undefined, {
|
||||
appContextOverrides: {
|
||||
activeLicense: null,
|
||||
},
|
||||
});
|
||||
|
||||
const scoped = within(container);
|
||||
expect(scoped.queryByText('License')).not.toBeInTheDocument();
|
||||
expect(scoped.queryByText('License key')).not.toBeInTheDocument();
|
||||
expect(
|
||||
scoped.queryByText('Your SigNoz license key.'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should mask license key in the UI', () => {
|
||||
const { container } = render(<MySettingsContainer />, undefined, {
|
||||
appContextOverrides: {
|
||||
activeLicense: {
|
||||
key: 'abcd',
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
|
||||
expect(within(container).getByText('ab********cd')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should not mask license key if it is too short', () => {
|
||||
const { container } = render(<MySettingsContainer />, undefined, {
|
||||
appContextOverrides: {
|
||||
activeLicense: {
|
||||
key: 'abc',
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
|
||||
expect(within(container).getByText('abc')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should copy license key and show success toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<MySettingsContainer />, undefined, {
|
||||
appContextOverrides: {
|
||||
activeLicense: {
|
||||
key: 'test-license-key-12345',
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(within(container).getByTestId('license-key-copy-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(copyToClipboardFn).toHaveBeenCalledWith('test-license-key-12345');
|
||||
expect(successNotification).toHaveBeenCalledWith({
|
||||
message: 'Copied to clipboard',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useMutation } from 'react-query';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
import { showErrorNotification } from 'utils/error';
|
||||
|
||||
import LicenseSection from './LicenseSection';
|
||||
import TimezoneAdaptation from './TimezoneAdaptation/TimezoneAdaptation';
|
||||
import UserInfo from './UserInfo';
|
||||
|
||||
@@ -231,8 +230,6 @@ function MySettings(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LicenseSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,17 +20,13 @@ interface AttributeRecord {
|
||||
interface AttributeActionsProps {
|
||||
record: AttributeRecord;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: (fieldKey: string) => void;
|
||||
showPinned?: boolean;
|
||||
showCopyOptions?: boolean;
|
||||
onTogglePin: (fieldKey: string) => void;
|
||||
}
|
||||
|
||||
export default function AttributeActions({
|
||||
record,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
showPinned = true,
|
||||
showCopyOptions = true,
|
||||
}: AttributeActionsProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [isFilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
|
||||
@@ -95,7 +91,7 @@ export default function AttributeActions({
|
||||
}, [onCopyFieldValue, textToCopy]);
|
||||
|
||||
const handleTogglePin = useCallback((): void => {
|
||||
onTogglePin?.(record.field);
|
||||
onTogglePin(record.field);
|
||||
}, [onTogglePin, record.field]);
|
||||
|
||||
const moreActionsContent = (
|
||||
@@ -109,41 +105,35 @@ export default function AttributeActions({
|
||||
>
|
||||
Group By Attribute
|
||||
</Button>
|
||||
{showCopyOptions && (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Copy size={14} />}
|
||||
onClick={handleCopyFieldName}
|
||||
block
|
||||
>
|
||||
Copy Field Name
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Copy size={14} />}
|
||||
onClick={handleCopyFieldValue}
|
||||
block
|
||||
>
|
||||
Copy Field Value
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Copy size={14} />}
|
||||
onClick={handleCopyFieldName}
|
||||
block
|
||||
>
|
||||
Copy Field Name
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Copy size={14} />}
|
||||
onClick={handleCopyFieldValue}
|
||||
block
|
||||
>
|
||||
Copy Field Value
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx('action-btn', { 'action-btn--is-open': isOpen })}>
|
||||
{showPinned && (
|
||||
<Tooltip title={isPinned ? 'Unpin attribute' : 'Pin attribute'}>
|
||||
<Button
|
||||
className={`filter-btn periscope-btn ${isPinned ? 'pinned' : ''}`}
|
||||
aria-label={isPinned ? 'Unpin attribute' : 'Pin attribute'}
|
||||
icon={<Pin size={14} fill={isPinned ? 'currentColor' : 'none'} />}
|
||||
onClick={handleTogglePin}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={isPinned ? 'Unpin attribute' : 'Pin attribute'}>
|
||||
<Button
|
||||
className={`filter-btn periscope-btn ${isPinned ? 'pinned' : ''}`}
|
||||
aria-label={isPinned ? 'Unpin attribute' : 'Pin attribute'}
|
||||
icon={<Pin size={14} fill={isPinned ? 'currentColor' : 'none'} />}
|
||||
onClick={handleTogglePin}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Filter for value">
|
||||
<Button
|
||||
className="filter-btn periscope-btn"
|
||||
@@ -194,7 +184,4 @@ export default function AttributeActions({
|
||||
|
||||
AttributeActions.defaultProps = {
|
||||
isPinned: false,
|
||||
showPinned: true,
|
||||
showCopyOptions: true,
|
||||
onTogglePin: undefined,
|
||||
};
|
||||
|
||||
@@ -47,56 +47,15 @@
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 0px;
|
||||
gap: 16px;
|
||||
padding: 10px 12px;
|
||||
|
||||
.item {
|
||||
padding: 8px 12px;
|
||||
&,
|
||||
.attribute-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
position: relative; // ensure absolutely-positioned children anchor to the row
|
||||
}
|
||||
|
||||
// Show attribute actions on hover for hardcoded rows
|
||||
.attribute-actions-wrapper {
|
||||
display: none;
|
||||
gap: 8px;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
|
||||
// style the action button group
|
||||
.action-btn {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-slate-400);
|
||||
padding: 4px;
|
||||
gap: 3px;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-slate-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--bg-slate-500);
|
||||
.attribute-actions-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.span-name-wrapper {
|
||||
@@ -687,29 +646,6 @@
|
||||
|
||||
.description {
|
||||
.item {
|
||||
.attribute-actions-wrapper {
|
||||
display: none;
|
||||
gap: 8px;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
|
||||
.filter-btn {
|
||||
background: var(--bg-vanilla-200);
|
||||
&:hover {
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
.attribute-actions-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
.span-name-wrapper {
|
||||
.span-percentile-value-container {
|
||||
&.span-percentile-value-container-open {
|
||||
|
||||
@@ -21,7 +21,6 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import AttributeActions from 'container/SpanDetailsDrawer/Attributes/AttributeActions';
|
||||
import dayjs from 'dayjs';
|
||||
import useClickOutside from 'hooks/useClickOutside';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
@@ -104,10 +103,6 @@ interface IResourceAttribute {
|
||||
const DEFAULT_RESOURCE_ATTRIBUTES = {
|
||||
serviceName: 'service.name',
|
||||
name: 'name',
|
||||
spanId: 'span_id',
|
||||
spanKind: 'kind_string',
|
||||
statusCodeString: 'status_code_string',
|
||||
statusMessage: 'status_message',
|
||||
};
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
@@ -840,16 +835,6 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
{selectedSpan.spanId}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="attribute-actions-wrapper">
|
||||
<AttributeActions
|
||||
record={{
|
||||
field: DEFAULT_RESOURCE_ATTRIBUTES.spanId,
|
||||
value: selectedSpan.spanId,
|
||||
}}
|
||||
showPinned={false}
|
||||
showCopyOptions={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item">
|
||||
<Typography.Text className="attribute-key">start time</Typography.Text>
|
||||
@@ -878,16 +863,6 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="attribute-actions-wrapper">
|
||||
<AttributeActions
|
||||
record={{
|
||||
field: DEFAULT_RESOURCE_ATTRIBUTES.serviceName,
|
||||
value: selectedSpan.serviceName,
|
||||
}}
|
||||
showPinned={false}
|
||||
showCopyOptions={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item">
|
||||
@@ -897,16 +872,6 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
{selectedSpan.spanKind}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="attribute-actions-wrapper">
|
||||
<AttributeActions
|
||||
record={{
|
||||
field: DEFAULT_RESOURCE_ATTRIBUTES.spanKind,
|
||||
value: selectedSpan.spanKind,
|
||||
}}
|
||||
showPinned={false}
|
||||
showCopyOptions={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item">
|
||||
<Typography.Text className="attribute-key">
|
||||
@@ -917,16 +882,6 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
{selectedSpan.statusCodeString}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="attribute-actions-wrapper">
|
||||
<AttributeActions
|
||||
record={{
|
||||
field: DEFAULT_RESOURCE_ATTRIBUTES.statusCodeString,
|
||||
value: selectedSpan.statusCodeString,
|
||||
}}
|
||||
showPinned={false}
|
||||
showCopyOptions={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedSpan.statusMessage && (
|
||||
@@ -936,16 +891,6 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
attributeValue={selectedSpan.statusMessage}
|
||||
onExpand={showStatusMessageModal}
|
||||
/>
|
||||
<div className="attribute-actions-wrapper">
|
||||
<AttributeActions
|
||||
record={{
|
||||
field: DEFAULT_RESOURCE_ATTRIBUTES.statusMessage,
|
||||
value: selectedSpan.statusMessage,
|
||||
}}
|
||||
showPinned={false}
|
||||
showCopyOptions={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="item">
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import AttributeActions from '../Attributes/AttributeActions';
|
||||
|
||||
// Mock only Popover from antd to simplify hover/open behavior while keeping other components real
|
||||
jest.mock('antd', () => {
|
||||
const actual = jest.requireActual('antd');
|
||||
const MockPopover = ({
|
||||
content,
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
...rest
|
||||
}: any): JSX.Element => (
|
||||
<div
|
||||
data-testid="mock-popover-wrapper"
|
||||
onMouseEnter={(): void => onOpenChange?.(true)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
{open ? <div data-testid="mock-popover-content">{content}</div> : null}
|
||||
</div>
|
||||
);
|
||||
return { ...actual, Popover: MockPopover };
|
||||
});
|
||||
|
||||
// Mock getAggregateKeys API used inside useTraceActions to resolve autocomplete keys
|
||||
jest.mock('api/queryBuilder/getAttributeKeys', () => ({
|
||||
getAggregateKeys: jest.fn().mockResolvedValue({
|
||||
payload: {
|
||||
attributeKeys: [
|
||||
{
|
||||
key: 'http.method',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
isColumn: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const record = { field: 'http.method', value: 'GET' };
|
||||
|
||||
describe('AttributeActions (unit)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders core action buttons (pin, filter in/out, more)', async () => {
|
||||
render(<AttributeActions record={record} isPinned={false} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Pin attribute' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Filter for value' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Filter out value' }),
|
||||
).toBeInTheDocument();
|
||||
// more actions (ellipsis) button
|
||||
expect(
|
||||
document.querySelector('.lucide-ellipsis')?.closest('button'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies "Filter for" and calls redirectWithQueryBuilderData with correct query', async () => {
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
const currentQuery = {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: 'signoz_span_duration' },
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: '' },
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
|
||||
render(<AttributeActions record={record} />, undefined, {
|
||||
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
|
||||
});
|
||||
|
||||
const filterForBtn = screen.getByRole('button', { name: 'Filter for value' });
|
||||
|
||||
await userEvent.click(filterForBtn);
|
||||
await waitFor(() => {
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
filters: expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'http.method' }),
|
||||
op: '=',
|
||||
value: 'GET',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('applies "Filter out" and calls redirectWithQueryBuilderData with correct query', async () => {
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
const currentQuery = {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: 'signoz_span_duration' },
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: '' },
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
|
||||
render(<AttributeActions record={record} />, undefined, {
|
||||
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
|
||||
});
|
||||
|
||||
const filterOutBtn = screen.getByRole('button', { name: 'Filter out value' });
|
||||
|
||||
await userEvent.click(filterOutBtn);
|
||||
await waitFor(() => {
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
filters: expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'http.method' }),
|
||||
op: '!=',
|
||||
value: 'GET',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens more actions on hover and calls Group By handler; closes after click', async () => {
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
const currentQuery = {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: 'signoz_span_duration' },
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: '' },
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
render(<AttributeActions record={record} />, undefined, {
|
||||
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
|
||||
});
|
||||
|
||||
const ellipsisBtn = document
|
||||
.querySelector('.lucide-ellipsis')
|
||||
?.closest('button') as HTMLElement;
|
||||
expect(ellipsisBtn).toBeInTheDocument();
|
||||
|
||||
// hover to trigger Popover open via mock
|
||||
fireEvent.mouseEnter(ellipsisBtn.parentElement as Element);
|
||||
|
||||
// content appears
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Group By Attribute')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Group By Attribute'));
|
||||
await waitFor(() => {
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
builder: expect.objectContaining({
|
||||
queryData: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
groupBy: expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'http.method' }),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
// After clicking group by, popover should close
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('mock-popover-content')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('hides pin button when showPinned=false', async () => {
|
||||
render(<AttributeActions record={record} showPinned={false} />);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /pin attribute/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides copy options when showCopyOptions=false', async () => {
|
||||
render(<AttributeActions record={record} showCopyOptions={false} />);
|
||||
const ellipsisBtn = document
|
||||
.querySelector('.lucide-ellipsis')
|
||||
?.closest('button') as HTMLElement;
|
||||
fireEvent.mouseEnter(ellipsisBtn.parentElement as Element);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Copy Field Name')).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByText('Copy Field Value')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
12
go.mod
12
go.mod
@@ -16,7 +16,6 @@ require (
|
||||
github.com/coreos/go-oidc/v3 v3.14.1
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-co-op/gocron v1.30.1
|
||||
github.com/go-openapi/runtime v0.28.0
|
||||
github.com/go-openapi/strfmt v0.23.0
|
||||
@@ -93,12 +92,6 @@ require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/redis/go-redis/extra/rediscmd/v9 v9.15.1 // indirect
|
||||
@@ -106,11 +99,10 @@ require (
|
||||
github.com/swaggest/refl v1.4.0 // indirect
|
||||
github.com/swaggest/usecase v1.3.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 // indirect
|
||||
go.opentelemetry.io/collector/config/configretry v1.34.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
|
||||
golang.org/x/tools/godoc v0.1.0-deprecated // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
@@ -207,7 +199,7 @@ require (
|
||||
github.com/jpillora/backoff v1.0.0 // indirect
|
||||
github.com/jtolds/gls v4.20.0+incompatible // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||
|
||||
26
go.sum
26
go.sum
@@ -292,11 +292,7 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-co-op/gocron v1.30.1 h1:tjWUvJl5KrcwpkEkSXFSQFr4F9h5SfV/m4+RX0cV2fs=
|
||||
github.com/go-co-op/gocron v1.30.1/go.mod h1:39f6KNSGVOU1LO/ZOoZfcSxwlsJDQOKSu8erN0SH48Y=
|
||||
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
@@ -344,17 +340,9 @@ github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ
|
||||
github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58=
|
||||
github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-redis/redismock/v9 v9.2.0 h1:ZrMYQeKPECZPjOj5u9eyOjg8Nnb0BS9lkVIZ6IpsKLw=
|
||||
github.com/go-redis/redismock/v9 v9.2.0/go.mod h1:18KHfGDK4Y6c2R0H38EUGWAdc7ZQS9gfYxc94k7rWT0=
|
||||
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
|
||||
@@ -372,8 +360,6 @@ github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJA
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.9.5/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
@@ -650,8 +636,8 @@ github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCy
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knadh/koanf v1.5.0 h1:q2TSd/3Pyc/5yP9ldIrSdIz26MCcyNQzW0pEAugLPNs=
|
||||
github.com/knadh/koanf v1.5.0/go.mod h1:Hgyjp4y8v44hpZtPzs7JZfRAW5AhN7KfZcwv1RYggDs=
|
||||
github.com/knadh/koanf/v2 v2.2.0 h1:FZFwd9bUjpb8DyCWARUBy5ovuhDs1lI87dOEn2K8UVU=
|
||||
@@ -681,8 +667,6 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6Fm
|
||||
github.com/leodido/go-syslog/v4 v4.2.0 h1:A7vpbYxsO4e2E8udaurkLlxP5LDpDbmPMsGnuhb7jVk=
|
||||
github.com/leodido/go-syslog/v4 v4.2.0/go.mod h1:eJ8rUfDN5OS6dOkCOBYlg2a+hbAg6pJa99QXXgMrd98=
|
||||
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/leodido/ragel-machinery v0.0.0-20190525184631-5f46317e436b h1:11UHH39z1RhZ5dc4y4r/4koJo6IYFgTRMe/LlwRTEw0=
|
||||
github.com/leodido/ragel-machinery v0.0.0-20190525184631-5f46317e436b/go.mod h1:WZxr2/6a/Ar9bMDc2rN/LJrE/hF6bXE4LPyDSIxwAfg=
|
||||
github.com/linode/linodego v1.49.0 h1:MNd3qwvQzbXB5mCpvdCqlUIu1RPA9oC+50LyB9kK+GQ=
|
||||
@@ -1039,8 +1023,6 @@ github.com/trivago/tgo v1.0.7/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkF
|
||||
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/uptrace/bun v1.2.9 h1:OOt2DlIcRUMSZPr6iXDFg/LaQd59kOxbAjpIVHddKRs=
|
||||
github.com/uptrace/bun v1.2.9/go.mod h1:r2ZaaGs9Ru5bpGTr8GQfp8jp+TlCav9grYCPOu2CJSg=
|
||||
github.com/uptrace/bun/dialect/pgdialect v1.2.9 h1:caf5uFbOGiXvadV6pA5gn87k0awFFxL1kuuY3SpxnWk=
|
||||
@@ -1289,8 +1271,8 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
|
||||
127
pkg/apis/fields/api.go
Normal file
127
pkg/apis/fields/api.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package fields
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetadata"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymeter"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
telemetryMetadataStore telemetrytypes.MetadataStore
|
||||
}
|
||||
|
||||
// TODO: move this to module and remove metastore init
|
||||
func NewAPI(
|
||||
settings factory.ProviderSettings,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
) *API {
|
||||
telemetryMetadataStore := telemetrymetadata.NewTelemetryMetaStore(
|
||||
settings,
|
||||
telemetryStore,
|
||||
telemetrytraces.DBName,
|
||||
telemetrytraces.TagAttributesV2TableName,
|
||||
telemetrytraces.SpanAttributesKeysTblName,
|
||||
telemetrytraces.SpanIndexV3TableName,
|
||||
telemetrymetrics.DBName,
|
||||
telemetrymetrics.AttributesMetadataTableName,
|
||||
telemetrymeter.DBName,
|
||||
telemetrymeter.SamplesAgg1dTableName,
|
||||
telemetrylogs.DBName,
|
||||
telemetrylogs.LogsV2TableName,
|
||||
telemetrylogs.TagAttributesV2TableName,
|
||||
telemetrylogs.LogAttributeKeysTblName,
|
||||
telemetrylogs.LogResourceKeysTblName,
|
||||
telemetrymetadata.DBName,
|
||||
telemetrymetadata.AttributesMetadataLocalTableName,
|
||||
)
|
||||
|
||||
return &API{
|
||||
telemetryStore: telemetryStore,
|
||||
telemetryMetadataStore: telemetryMetadataStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) GetFieldsKeys(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
type fieldKeysResponse struct {
|
||||
Keys map[string][]*telemetrytypes.TelemetryFieldKey `json:"keys"`
|
||||
Complete bool `json:"complete"`
|
||||
}
|
||||
|
||||
bodyBytes, _ := io.ReadAll(r.Body)
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
ctx := r.Context()
|
||||
|
||||
fieldKeySelector, err := parseFieldKeyRequest(r)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
keys, complete, err := api.telemetryMetadataStore.GetKeys(ctx, fieldKeySelector)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
response := fieldKeysResponse{
|
||||
Keys: keys,
|
||||
Complete: complete,
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (api *API) GetFieldsValues(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
type fieldValuesResponse struct {
|
||||
Values *telemetrytypes.TelemetryFieldValues `json:"values"`
|
||||
Complete bool `json:"complete"`
|
||||
}
|
||||
|
||||
bodyBytes, _ := io.ReadAll(r.Body)
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
ctx := r.Context()
|
||||
|
||||
fieldValueSelector, err := parseFieldValueRequest(r)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
allValues, allComplete, err := api.telemetryMetadataStore.GetAllValues(ctx, fieldValueSelector)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
relatedValues, relatedComplete, err := api.telemetryMetadataStore.GetRelatedValues(ctx, fieldValueSelector)
|
||||
if err != nil {
|
||||
// we don't want to return error if we fail to get related values for some reason
|
||||
relatedValues = []string{}
|
||||
}
|
||||
|
||||
values := &telemetrytypes.TelemetryFieldValues{
|
||||
StringValues: allValues.StringValues,
|
||||
NumberValues: allValues.NumberValues,
|
||||
RelatedValues: relatedValues,
|
||||
}
|
||||
|
||||
response := fieldValuesResponse{
|
||||
Values: values,
|
||||
Complete: allComplete && relatedComplete,
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, response)
|
||||
}
|
||||
162
pkg/apis/fields/parse.go
Normal file
162
pkg/apis/fields/parse.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package fields
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func parseFieldKeyRequest(r *http.Request) (*telemetrytypes.FieldKeySelector, error) {
|
||||
var req telemetrytypes.FieldKeySelector
|
||||
var signal telemetrytypes.Signal
|
||||
var source telemetrytypes.Source
|
||||
var err error
|
||||
|
||||
signalStr := r.URL.Query().Get("signal")
|
||||
if signalStr != "" {
|
||||
signal = telemetrytypes.Signal{String: valuer.NewString(signalStr)}
|
||||
} else {
|
||||
signal = telemetrytypes.SignalUnspecified
|
||||
}
|
||||
|
||||
sourceStr := r.URL.Query().Get("source")
|
||||
if sourceStr != "" {
|
||||
source = telemetrytypes.Source{String: valuer.NewString(sourceStr)}
|
||||
} else {
|
||||
source = telemetrytypes.SourceUnspecified
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("limit") != "" {
|
||||
limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse limit")
|
||||
}
|
||||
req.Limit = limit
|
||||
} else {
|
||||
req.Limit = 1000
|
||||
}
|
||||
|
||||
var startUnixMilli, endUnixMilli int64
|
||||
|
||||
if r.URL.Query().Get("startUnixMilli") != "" {
|
||||
startUnixMilli, err = strconv.ParseInt(r.URL.Query().Get("startUnixMilli"), 10, 64)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse startUnixMilli")
|
||||
}
|
||||
// Round down to the nearest 6 hours (21600000 milliseconds)
|
||||
startUnixMilli -= startUnixMilli % 21600000
|
||||
}
|
||||
if r.URL.Query().Get("endUnixMilli") != "" {
|
||||
endUnixMilli, err = strconv.ParseInt(r.URL.Query().Get("endUnixMilli"), 10, 64)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse endUnixMilli")
|
||||
}
|
||||
}
|
||||
|
||||
// Parse fieldContext directly instead of using JSON unmarshalling.
|
||||
var fieldContext telemetrytypes.FieldContext
|
||||
fieldContextStr := r.URL.Query().Get("fieldContext")
|
||||
if fieldContextStr != "" {
|
||||
fieldContext = telemetrytypes.FieldContext{String: valuer.NewString(fieldContextStr)}
|
||||
}
|
||||
|
||||
// Parse fieldDataType directly instead of using JSON unmarshalling.
|
||||
var fieldDataType telemetrytypes.FieldDataType
|
||||
fieldDataTypeStr := r.URL.Query().Get("fieldDataType")
|
||||
if fieldDataTypeStr != "" {
|
||||
fieldDataType = telemetrytypes.FieldDataType{String: valuer.NewString(fieldDataTypeStr)}
|
||||
}
|
||||
|
||||
metricName := r.URL.Query().Get("metricName")
|
||||
var metricContext *telemetrytypes.MetricContext
|
||||
if metricName != "" {
|
||||
metricContext = &telemetrytypes.MetricContext{
|
||||
MetricName: metricName,
|
||||
}
|
||||
}
|
||||
|
||||
name := r.URL.Query().Get("searchText")
|
||||
|
||||
if name != "" && fieldContext == telemetrytypes.FieldContextUnspecified {
|
||||
parsedFieldKey := telemetrytypes.GetFieldKeyFromKeyText(name)
|
||||
if parsedFieldKey.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||
// Only apply inferred context if it is valid for the current signal
|
||||
if isContextValidForSignal(parsedFieldKey.FieldContext, signal) {
|
||||
name = parsedFieldKey.Name
|
||||
fieldContext = parsedFieldKey.FieldContext
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req = telemetrytypes.FieldKeySelector{
|
||||
StartUnixMilli: startUnixMilli,
|
||||
EndUnixMilli: endUnixMilli,
|
||||
Signal: signal,
|
||||
Source: source,
|
||||
Name: name,
|
||||
FieldContext: fieldContext,
|
||||
FieldDataType: fieldDataType,
|
||||
Limit: req.Limit,
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeFuzzy,
|
||||
MetricContext: metricContext,
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
func parseFieldValueRequest(r *http.Request) (*telemetrytypes.FieldValueSelector, error) {
|
||||
keySelector, err := parseFieldKeyRequest(r)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse field key request")
|
||||
}
|
||||
|
||||
name := r.URL.Query().Get("name")
|
||||
if name != "" && keySelector.FieldContext == telemetrytypes.FieldContextUnspecified {
|
||||
parsedFieldKey := telemetrytypes.GetFieldKeyFromKeyText(name)
|
||||
if parsedFieldKey.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||
// Only apply inferred context if it is valid for the current signal
|
||||
if isContextValidForSignal(parsedFieldKey.FieldContext, keySelector.Signal) {
|
||||
name = parsedFieldKey.Name
|
||||
keySelector.FieldContext = parsedFieldKey.FieldContext
|
||||
}
|
||||
}
|
||||
}
|
||||
keySelector.Name = name
|
||||
existingQuery := r.URL.Query().Get("existingQuery")
|
||||
value := r.URL.Query().Get("searchText")
|
||||
|
||||
// Parse limit for fieldValue request, fallback to default 50 if parsing fails.
|
||||
limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if err != nil {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
req := telemetrytypes.FieldValueSelector{
|
||||
FieldKeySelector: keySelector,
|
||||
ExistingQuery: existingQuery,
|
||||
Value: value,
|
||||
Limit: limit,
|
||||
}
|
||||
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
func isContextValidForSignal(ctx telemetrytypes.FieldContext, signal telemetrytypes.Signal) bool {
|
||||
if ctx == telemetrytypes.FieldContextResource ||
|
||||
ctx == telemetrytypes.FieldContextAttribute ||
|
||||
ctx == telemetrytypes.FieldContextScope {
|
||||
return true
|
||||
}
|
||||
|
||||
switch signal.StringValue() {
|
||||
case telemetrytypes.SignalLogs.StringValue():
|
||||
return ctx == telemetrytypes.FieldContextLog || ctx == telemetrytypes.FieldContextBody
|
||||
case telemetrytypes.SignalTraces.StringValue():
|
||||
return ctx == telemetrytypes.FieldContextSpan || ctx == telemetrytypes.FieldContextEvent || ctx == telemetrytypes.FieldContextTrace
|
||||
case telemetrytypes.SignalMetrics.StringValue():
|
||||
return ctx == telemetrytypes.FieldContextMetric
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addFieldsRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/fields/keys", handler.New(provider.authZ.ViewAccess(provider.fieldsHandler.GetFieldsKeys), handler.OpenAPIDef{
|
||||
ID: "GetFieldsKeys",
|
||||
Tags: []string{"fields"},
|
||||
Summary: "Get field keys",
|
||||
Description: "This endpoint returns field keys",
|
||||
Request: nil,
|
||||
RequestParams: new(telemetrytypes.PostableFieldKeysParams),
|
||||
RequestContentType: "",
|
||||
Response: new(telemetrytypes.GettableFieldKeys),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/fields/values", handler.New(provider.authZ.ViewAccess(provider.fieldsHandler.GetFieldsValues), handler.OpenAPIDef{
|
||||
ID: "GetFieldsValues",
|
||||
Tags: []string{"fields"},
|
||||
Summary: "Get field values",
|
||||
Description: "This endpoint returns field values",
|
||||
Request: nil,
|
||||
RequestParams: new(telemetrytypes.PostableFieldValueParams),
|
||||
RequestContentType: "",
|
||||
Response: new(telemetrytypes.GettableFieldValues),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/modules/authdomain"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/fields"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
@@ -40,7 +39,6 @@ type provider struct {
|
||||
dashboardModule dashboard.Module
|
||||
dashboardHandler dashboard.Handler
|
||||
metricsExplorerHandler metricsexplorer.Handler
|
||||
fieldsHandler fields.Handler
|
||||
}
|
||||
|
||||
func NewFactory(
|
||||
@@ -57,28 +55,9 @@ func NewFactory(
|
||||
dashboardModule dashboard.Module,
|
||||
dashboardHandler dashboard.Handler,
|
||||
metricsExplorerHandler metricsexplorer.Handler,
|
||||
fieldsHandler fields.Handler,
|
||||
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
|
||||
return newProvider(
|
||||
ctx,
|
||||
providerSettings,
|
||||
config,
|
||||
orgGetter,
|
||||
authz,
|
||||
orgHandler,
|
||||
userHandler,
|
||||
sessionHandler,
|
||||
authDomainHandler,
|
||||
preferenceHandler,
|
||||
globalHandler,
|
||||
promoteHandler,
|
||||
flaggerHandler,
|
||||
dashboardModule,
|
||||
dashboardHandler,
|
||||
metricsExplorerHandler,
|
||||
fieldsHandler,
|
||||
)
|
||||
return newProvider(ctx, providerSettings, config, orgGetter, authz, orgHandler, userHandler, sessionHandler, authDomainHandler, preferenceHandler, globalHandler, promoteHandler, flaggerHandler, dashboardModule, dashboardHandler, metricsExplorerHandler)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -99,7 +78,6 @@ func newProvider(
|
||||
dashboardModule dashboard.Module,
|
||||
dashboardHandler dashboard.Handler,
|
||||
metricsExplorerHandler metricsexplorer.Handler,
|
||||
fieldsHandler fields.Handler,
|
||||
) (apiserver.APIServer, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
|
||||
router := mux.NewRouter().UseEncodedPath()
|
||||
@@ -119,7 +97,6 @@ func newProvider(
|
||||
dashboardModule: dashboardModule,
|
||||
dashboardHandler: dashboardHandler,
|
||||
metricsExplorerHandler: metricsExplorerHandler,
|
||||
fieldsHandler: fieldsHandler,
|
||||
}
|
||||
|
||||
provider.authZ = middleware.NewAuthZ(settings.Logger(), orgGetter, authz)
|
||||
@@ -176,10 +153,6 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addFieldsRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,10 @@ import (
|
||||
var (
|
||||
ErrCodeInvalidRequestBody = errors.MustNewCode("invalid_request_body")
|
||||
ErrCodeInvalidRequestField = errors.MustNewCode("invalid_request_field")
|
||||
ErrCodeInvalidRequestQuery = errors.MustNewCode("invalid_request_query")
|
||||
)
|
||||
|
||||
var (
|
||||
JSON BindingBody = &jsonBinding{}
|
||||
Query BindingQuery = &queryBinding{}
|
||||
JSON Binding = &jsonBinding{}
|
||||
)
|
||||
|
||||
type bindBodyOptions struct {
|
||||
@@ -36,10 +34,6 @@ func WithUseNumber(useNumber bool) BindBodyOption {
|
||||
}
|
||||
}
|
||||
|
||||
type BindingBody interface {
|
||||
type Binding interface {
|
||||
BindBody(body io.Reader, obj any, opts ...BindBodyOption) error
|
||||
}
|
||||
|
||||
type BindingQuery interface {
|
||||
BindQuery(query map[string][]string, obj any) error
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const (
|
||||
ErrMessageInvalidField string = "request body contains invalid field value"
|
||||
)
|
||||
|
||||
var _ BindingBody = (*jsonBinding)(nil)
|
||||
var _ Binding = (*jsonBinding)(nil)
|
||||
|
||||
type jsonBinding struct{}
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package binding
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
ginbinding "github.com/gin-gonic/gin/binding"
|
||||
)
|
||||
|
||||
const (
|
||||
ErrMessageInvalidQuery string = "request query contains invalid fields, please verify the format and try again."
|
||||
)
|
||||
|
||||
var _ BindingQuery = (*queryBinding)(nil)
|
||||
|
||||
type queryBinding struct{}
|
||||
|
||||
func (b *queryBinding) BindQuery(query map[string][]string, obj any) error {
|
||||
err := ginbinding.MapFormWithTag(obj, query, "query")
|
||||
if err != nil {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidRequestQuery, ErrMessageInvalidQuery).WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package binding
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestQueryBinding_BindQuery(t *testing.T) {
|
||||
one := 1
|
||||
zero := 0
|
||||
testCases := []struct {
|
||||
name string
|
||||
query map[string][]string
|
||||
obj any
|
||||
expected any
|
||||
}{
|
||||
{name: "SingleIntField_NonEmptyValue", query: map[string][]string{"a": {"1"}}, obj: &struct{A int `query:"a"`}{}, expected: &struct{ A int }{A: 1}},
|
||||
{name: "SingleIntField_EmptyValue", query: map[string][]string{"a": {""}}, obj: &struct{A int `query:"a"`}{}, expected: &struct{ A int }{A: 0}},
|
||||
{name: "SingleIntField_MissingField", query: map[string][]string{}, obj: &struct{A int `query:"a"`}{}, expected: &struct{ A int }{A: 0}},
|
||||
{name: "SinglePointerIntField_NonEmptyValue", query: map[string][]string{"a": {"1"}}, obj: &struct{A *int `query:"a"`}{}, expected: &struct{ A *int }{A: &one}},
|
||||
{name: "SinglePointerIntField_EmptyValue", query: map[string][]string{"a": {""}}, obj: &struct{A *int `query:"a"`}{}, expected: &struct{ A *int }{A: &zero}},
|
||||
{name: "SinglePointerIntField_MissingField", query: map[string][]string{}, obj: &struct{A *int `query:"a"`}{}, expected: &struct{ A *int }{A: nil}},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
err := Query.BindQuery(testCase.query, testCase.obj)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, testCase.expected, testCase.obj)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,6 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
|
||||
|
||||
// Add request structure
|
||||
opCtx.AddReqStructure(handler.openAPIDef.Request, openapi.WithContentType(handler.openAPIDef.RequestContentType))
|
||||
opCtx.AddReqStructure(handler.openAPIDef.RequestParams)
|
||||
|
||||
// Add success response
|
||||
if handler.openAPIDef.Response != nil {
|
||||
|
||||
@@ -16,7 +16,6 @@ type OpenAPIDef struct {
|
||||
Summary string
|
||||
Description string
|
||||
Request any
|
||||
RequestParams any
|
||||
RequestContentType string
|
||||
Response any
|
||||
ResponseContentType string
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package fields
|
||||
|
||||
import "net/http"
|
||||
|
||||
type Handler interface {
|
||||
// Gets the fields keys for the given field key selector
|
||||
GetFieldsKeys(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Gets the fields values for the given field value selector
|
||||
GetFieldsValues(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package implfields
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/fields"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
telemetryMetadataStore telemetrytypes.MetadataStore
|
||||
}
|
||||
|
||||
func NewHandler(settings factory.ProviderSettings,telemetryMetadataStore telemetrytypes.MetadataStore,telemetryStore telemetrystore.TelemetryStore) fields.Handler {
|
||||
return &handler{
|
||||
telemetryStore: telemetryStore,
|
||||
telemetryMetadataStore: telemetryMetadataStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *handler) GetFieldsKeys(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
var params telemetrytypes.PostableFieldKeysParams
|
||||
if err := binding.Query.BindQuery(req.URL.Query(), ¶ms); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
fieldKeySelector, err := telemetrytypes.NewFieldKeySelectorFromPostableFieldKeysParams(params)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
keys, complete, err := handler.telemetryMetadataStore.GetKeys(ctx, fieldKeySelector)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, &telemetrytypes.GettableFieldKeys{
|
||||
Keys: keys,
|
||||
Complete: complete,
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *handler) GetFieldsValues(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
var params telemetrytypes.PostableFieldValueParams
|
||||
if err := binding.Query.BindQuery(req.URL.Query(), ¶ms); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
fieldValueSelector, err := telemetrytypes.NewFieldValueSelectorFromPostableFieldValueParams(params)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
allValues, allComplete, err := handler.telemetryMetadataStore.GetAllValues(ctx, fieldValueSelector)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
relatedValues, relatedComplete, err := handler.telemetryMetadataStore.GetRelatedValues(ctx, fieldValueSelector)
|
||||
if err != nil {
|
||||
// we don't want to return error if we fail to get related values for some reason
|
||||
relatedValues = []string{}
|
||||
}
|
||||
|
||||
values := &telemetrytypes.TelemetryFieldValues{
|
||||
StringValues: allValues.StringValues,
|
||||
NumberValues: allValues.NumberValues,
|
||||
RelatedValues: relatedValues,
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, &telemetrytypes.GettableFieldValues{
|
||||
Values: values,
|
||||
Complete: allComplete && relatedComplete,
|
||||
})
|
||||
}
|
||||
@@ -318,14 +318,13 @@ func (q *querier) applyFormulas(ctx context.Context, results map[string]*qbtypes
|
||||
}
|
||||
|
||||
// Check if we're dealing with time series or scalar data
|
||||
switch req.RequestType {
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
if req.RequestType == qbtypes.RequestTypeTimeSeries {
|
||||
result := q.processTimeSeriesFormula(ctx, results, formula, req)
|
||||
if result != nil {
|
||||
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
|
||||
results[name] = result
|
||||
}
|
||||
case qbtypes.RequestTypeScalar:
|
||||
} else if req.RequestType == qbtypes.RequestTypeScalar {
|
||||
result := q.processScalarFormula(ctx, results, formula, req)
|
||||
if result != nil {
|
||||
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
|
||||
@@ -582,14 +581,11 @@ func (q *querier) filterDisabledQueries(results map[string]*qbtypes.Result, req
|
||||
}
|
||||
|
||||
// formatScalarResultsAsTable formats scalar results as a unified table for UI display
|
||||
func (q *querier) formatScalarResultsAsTable(results map[string]*qbtypes.Result, req *qbtypes.QueryRangeRequest) map[string]any {
|
||||
func (q *querier) formatScalarResultsAsTable(results map[string]*qbtypes.Result, _ *qbtypes.QueryRangeRequest) map[string]any {
|
||||
if len(results) == 0 {
|
||||
return map[string]any{"table": &qbtypes.ScalarData{}}
|
||||
}
|
||||
|
||||
// apply default sorting if no order specified
|
||||
applyDefaultSort := !req.HasOrderSpecified()
|
||||
|
||||
// Convert all results to ScalarData first
|
||||
scalarResults := make(map[string]*qbtypes.ScalarData)
|
||||
for name, result := range results {
|
||||
@@ -604,13 +600,13 @@ func (q *querier) formatScalarResultsAsTable(results map[string]*qbtypes.Result,
|
||||
if len(scalarResults) == 1 {
|
||||
for _, sd := range scalarResults {
|
||||
if hasMultipleQueries(sd) {
|
||||
return map[string]any{"table": deduplicateRows(sd, applyDefaultSort)}
|
||||
return map[string]any{"table": deduplicateRows(sd)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise merge all results
|
||||
merged := mergeScalarData(scalarResults, applyDefaultSort)
|
||||
merged := mergeScalarData(scalarResults)
|
||||
return map[string]any{"table": merged}
|
||||
}
|
||||
|
||||
@@ -691,7 +687,7 @@ func hasMultipleQueries(sd *qbtypes.ScalarData) bool {
|
||||
}
|
||||
|
||||
// deduplicateRows removes duplicate rows based on group columns
|
||||
func deduplicateRows(sd *qbtypes.ScalarData, applyDefaultSort bool) *qbtypes.ScalarData {
|
||||
func deduplicateRows(sd *qbtypes.ScalarData) *qbtypes.ScalarData {
|
||||
// Find group column indices
|
||||
groupIndices := []int{}
|
||||
for i, col := range sd.Columns {
|
||||
@@ -700,9 +696,8 @@ func deduplicateRows(sd *qbtypes.ScalarData, applyDefaultSort bool) *qbtypes.Sca
|
||||
}
|
||||
}
|
||||
|
||||
// Build unique rows map, preserve order
|
||||
// Build unique rows map
|
||||
uniqueRows := make(map[string][]any)
|
||||
var keyOrder []string
|
||||
for _, row := range sd.Data {
|
||||
key := buildRowKey(row, groupIndices)
|
||||
if existing, found := uniqueRows[key]; found {
|
||||
@@ -716,20 +711,17 @@ func deduplicateRows(sd *qbtypes.ScalarData, applyDefaultSort bool) *qbtypes.Sca
|
||||
rowCopy := make([]any, len(row))
|
||||
copy(rowCopy, row)
|
||||
uniqueRows[key] = rowCopy
|
||||
keyOrder = append(keyOrder, key)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert back to slice, preserve the original order
|
||||
// Convert back to slice
|
||||
data := make([][]any, 0, len(uniqueRows))
|
||||
for _, key := range keyOrder {
|
||||
data = append(data, uniqueRows[key])
|
||||
for _, row := range uniqueRows {
|
||||
data = append(data, row)
|
||||
}
|
||||
|
||||
// sort by first aggregation (descending) if no order was specified
|
||||
if applyDefaultSort {
|
||||
sortByFirstAggregation(data, sd.Columns)
|
||||
}
|
||||
// Sort by first aggregation column
|
||||
sortByFirstAggregation(data, sd.Columns)
|
||||
|
||||
return &qbtypes.ScalarData{
|
||||
Columns: sd.Columns,
|
||||
@@ -738,7 +730,7 @@ func deduplicateRows(sd *qbtypes.ScalarData, applyDefaultSort bool) *qbtypes.Sca
|
||||
}
|
||||
|
||||
// mergeScalarData merges multiple scalar data results
|
||||
func mergeScalarData(results map[string]*qbtypes.ScalarData, applyDefaultSort bool) *qbtypes.ScalarData {
|
||||
func mergeScalarData(results map[string]*qbtypes.ScalarData) *qbtypes.ScalarData {
|
||||
// Collect unique group columns
|
||||
groupCols := []string{}
|
||||
groupColMap := make(map[string]*qbtypes.ColumnDescriptor)
|
||||
@@ -778,12 +770,10 @@ func mergeScalarData(results map[string]*qbtypes.ScalarData, applyDefaultSort bo
|
||||
}
|
||||
}
|
||||
|
||||
// Merge rows, preserve order
|
||||
// Merge rows
|
||||
rowMap := make(map[string][]any)
|
||||
var keyOrder []string
|
||||
|
||||
for _, queryName := range queryNames {
|
||||
sd := results[queryName]
|
||||
for queryName, sd := range results {
|
||||
// Create index mappings
|
||||
groupMap := make(map[string]int)
|
||||
for i, col := range sd.Columns {
|
||||
@@ -812,7 +802,6 @@ func mergeScalarData(results map[string]*qbtypes.ScalarData, applyDefaultSort bo
|
||||
newRow[i] = "n/a"
|
||||
}
|
||||
rowMap[key] = newRow
|
||||
keyOrder = append(keyOrder, key)
|
||||
}
|
||||
|
||||
// Set aggregation values for this query
|
||||
@@ -836,16 +825,14 @@ func mergeScalarData(results map[string]*qbtypes.ScalarData, applyDefaultSort bo
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to slice, preserving insertion order
|
||||
// Convert to slice
|
||||
data := make([][]any, 0, len(rowMap))
|
||||
for _, key := range keyOrder {
|
||||
data = append(data, rowMap[key])
|
||||
for _, row := range rowMap {
|
||||
data = append(data, row)
|
||||
}
|
||||
|
||||
// sort by first aggregation (descending) if no order was specified
|
||||
if applyDefaultSort {
|
||||
sortByFirstAggregation(data, columns)
|
||||
}
|
||||
// Sort by first aggregation column
|
||||
sortByFirstAggregation(data, columns)
|
||||
|
||||
return &qbtypes.ScalarData{
|
||||
Columns: columns,
|
||||
@@ -901,7 +888,7 @@ func sortByFirstAggregation(data [][]any, columns []*qbtypes.ColumnDescriptor) {
|
||||
|
||||
// compareValues compares two values for sorting (handles n/a and numeric types)
|
||||
func compareValues(a, b any) int {
|
||||
// n/a values gets pushed to the end
|
||||
// Handle n/a values
|
||||
if a == "n/a" && b == "n/a" {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/template"
|
||||
@@ -20,50 +19,6 @@ import (
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
)
|
||||
|
||||
// unquotedDottedNamePattern matches unquoted identifiers containing dots
|
||||
// that appear in metric or label name positions. This helps detect queries
|
||||
// using the old syntax that needs migration to UTF-8 quoted syntax.
|
||||
// Examples it matches: k8s.pod.name, deployment.environment, http.status_code
|
||||
var unquotedDottedNamePattern = regexp.MustCompile(`(?:^|[{,(\s])([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_]+)+)(?:[}\s,=!~)\[]|$)`)
|
||||
|
||||
// quotedMetricOutsideBracesPattern matches the incorrect syntax where a quoted
|
||||
// metric name appears outside of braces followed by a selector block.
|
||||
// Example: "kube_pod_status_ready_time"{"condition"="true"}
|
||||
// This is a common mistake when migrating to UTF-8 syntax.
|
||||
var quotedMetricOutsideBracesPattern = regexp.MustCompile(`"([^"]+)"\s*\{`)
|
||||
|
||||
// enhancePromQLError adds helpful context to PromQL parse errors,
|
||||
// particularly for UTF-8 syntax migration issues where metric and label
|
||||
// names containing dots need to be quoted.
|
||||
func enhancePromQLError(query string, parseErr error) error {
|
||||
errMsg := parseErr.Error()
|
||||
|
||||
if matches := quotedMetricOutsideBracesPattern.FindStringSubmatch(query); len(matches) > 1 {
|
||||
metricName := matches[1]
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid promql query: %s. Hint: The metric name should be inside the braces. Use {\"__name__\"=\"%s\", ...} or {\"%s\", ...} instead of \"%s\"{...}",
|
||||
errMsg,
|
||||
metricName,
|
||||
metricName,
|
||||
metricName,
|
||||
)
|
||||
}
|
||||
|
||||
if matches := unquotedDottedNamePattern.FindStringSubmatch(query); len(matches) > 1 {
|
||||
dottedName := matches[1]
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid promql query: %s. Hint: Metric and label names containing dots require quoted notation in the new UTF-8 syntax, e.g., use \"%s\" instead of %s",
|
||||
errMsg,
|
||||
dottedName,
|
||||
dottedName,
|
||||
)
|
||||
}
|
||||
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query: %s", errMsg)
|
||||
}
|
||||
|
||||
type promqlQuery struct {
|
||||
logger *slog.Logger
|
||||
promEngine prometheus.Prometheus
|
||||
@@ -126,7 +81,7 @@ func (q *promqlQuery) removeAllVarMatchers(query string, vars map[string]qbv5.Va
|
||||
|
||||
expr, err := parser.ParseExpr(query)
|
||||
if err != nil {
|
||||
return "", enhancePromQLError(query, err)
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query %q", query)
|
||||
}
|
||||
|
||||
// Create visitor and walk the AST
|
||||
@@ -206,7 +161,7 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, enhancePromQLError(query, err)
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query %q", query)
|
||||
}
|
||||
|
||||
res := qry.Exec(ctx)
|
||||
|
||||
@@ -2,10 +2,8 @@ package querier
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -174,268 +172,3 @@ func TestRemoveAllVarMatchers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnhancePromQLError(t *testing.T) {
|
||||
parseErr := errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "unexpected character: '.' at position 12")
|
||||
|
||||
t.Run("dotted name patterns", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantDottedNameHint bool
|
||||
wantDottedNameExample string
|
||||
}{
|
||||
{
|
||||
name: "query with unquoted dotted metric name",
|
||||
query: `sum(rate(k8s.container.restarts[5m]))`,
|
||||
wantDottedNameHint: true,
|
||||
wantDottedNameExample: "k8s.container.restarts",
|
||||
},
|
||||
{
|
||||
name: "query with unquoted dotted label in group by",
|
||||
query: `sum by (k8s.pod.name) (rate(requests_total[5m]))`,
|
||||
wantDottedNameHint: true,
|
||||
wantDottedNameExample: "k8s.pod.name",
|
||||
},
|
||||
{
|
||||
name: "query with unquoted dotted label in filter",
|
||||
query: `requests_total{k8s.namespace.name="default"}`,
|
||||
wantDottedNameHint: true,
|
||||
wantDottedNameExample: "k8s.namespace.name",
|
||||
},
|
||||
{
|
||||
name: "query with multiple unquoted dotted names",
|
||||
query: `sum by (k8s.pod.name, deployment.environment) (increase(k8s.container.restarts[15m]))`,
|
||||
wantDottedNameHint: true,
|
||||
wantDottedNameExample: "k8s.pod.name", // should match first one
|
||||
},
|
||||
{
|
||||
name: "query without dotted names - no hint",
|
||||
query: `sum(rate(http_requests_total[5m]))`,
|
||||
wantDottedNameHint: false,
|
||||
},
|
||||
{
|
||||
name: "query with properly quoted dotted names - no hint",
|
||||
query: `sum(rate({"k8s.container.restarts"}[5m]))`,
|
||||
wantDottedNameHint: false,
|
||||
},
|
||||
{
|
||||
name: "query with dotted name inside regex string - no hint",
|
||||
query: `requests_total{pod=~"k8s.pod.name.*"}`,
|
||||
wantDottedNameHint: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := enhancePromQLError(tt.query, parseErr)
|
||||
errMsg := err.Error()
|
||||
|
||||
assert.True(t, strings.Contains(errMsg, parseErr.Error()),
|
||||
"error should contain original parse error message")
|
||||
|
||||
if tt.wantDottedNameHint {
|
||||
assert.True(t, strings.Contains(errMsg, "Hint:"),
|
||||
"error should contain hint for dotted name query")
|
||||
assert.True(t, strings.Contains(errMsg, "UTF-8 syntax"),
|
||||
"error should mention UTF-8 syntax")
|
||||
assert.True(t, strings.Contains(errMsg, tt.wantDottedNameExample),
|
||||
"error should contain the dotted name example: %s", tt.wantDottedNameExample)
|
||||
} else {
|
||||
assert.False(t, strings.Contains(errMsg, "Hint:"),
|
||||
"error should not contain hint for non-dotted-name query")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("quoted metric outside braces patterns", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantHint bool
|
||||
wantMetricInHint string
|
||||
}{
|
||||
{
|
||||
name: "quoted metric name followed by selector",
|
||||
query: `"kube_pod_status_ready_time"{"condition"="true"}`,
|
||||
wantHint: true,
|
||||
wantMetricInHint: "kube_pod_status_ready_time",
|
||||
},
|
||||
{
|
||||
name: "quoted metric with space before brace",
|
||||
query: `"kube_pod_labels" {"label"!=""}`,
|
||||
wantHint: true,
|
||||
wantMetricInHint: "kube_pod_labels",
|
||||
},
|
||||
{
|
||||
name: "complex query with quoted metric outside braces",
|
||||
query: `min by (namespace) ("kube_pod_status_ready_time"{"condition"="true"})`,
|
||||
wantHint: true,
|
||||
wantMetricInHint: "kube_pod_status_ready_time",
|
||||
},
|
||||
{
|
||||
name: "label_replace with quoted metric outside braces",
|
||||
query: `label_replace("kube_pod_labels"{"label_cnpg_io_cluster"!=""}, "cluster","$1","label","(.+)")`,
|
||||
wantHint: true,
|
||||
wantMetricInHint: "kube_pod_labels",
|
||||
},
|
||||
{
|
||||
name: "correctly formatted query - no hint",
|
||||
query: `{"kube_pod_status_ready_time", condition="true"}`,
|
||||
wantHint: false,
|
||||
},
|
||||
{
|
||||
name: "old syntax without quotes - no hint for this pattern",
|
||||
query: `kube_pod_status_ready_time{condition="true"}`,
|
||||
wantHint: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := enhancePromQLError(tt.query, parseErr)
|
||||
errMsg := err.Error()
|
||||
|
||||
assert.True(t, strings.Contains(errMsg, parseErr.Error()),
|
||||
"error should contain original parse error message")
|
||||
|
||||
if tt.wantHint {
|
||||
assert.True(t, strings.Contains(errMsg, "Hint:"),
|
||||
"error should contain hint")
|
||||
assert.True(t, strings.Contains(errMsg, "inside the braces"),
|
||||
"error should mention putting metric inside braces")
|
||||
assert.True(t, strings.Contains(errMsg, tt.wantMetricInHint),
|
||||
"error should contain the metric name: %s", tt.wantMetricInHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUnquotedDottedNamePattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string // empty string means no match expected
|
||||
}{
|
||||
{
|
||||
name: "metric name at start",
|
||||
input: "k8s.pod.name",
|
||||
expected: "k8s.pod.name",
|
||||
},
|
||||
{
|
||||
name: "label in group by clause",
|
||||
input: "sum by (k8s.pod.name) (rate(x[5m]))",
|
||||
expected: "k8s.pod.name",
|
||||
},
|
||||
{
|
||||
name: "label in filter",
|
||||
input: "metric{k8s.namespace.name=\"default\"}",
|
||||
expected: "k8s.namespace.name",
|
||||
},
|
||||
{
|
||||
name: "metric with underscore and dots",
|
||||
input: "http_server.request.duration",
|
||||
expected: "http_server.request.duration",
|
||||
},
|
||||
{
|
||||
name: "quoted metric name - no match",
|
||||
input: `{"k8s.pod.name"}`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "inside regex string - no match",
|
||||
input: `{pod=~"k8s.pod.name.*"}`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "simple metric without dots - no match",
|
||||
input: "http_requests_total",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "single dot only - no match",
|
||||
input: "a.b",
|
||||
expected: "a.b",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
matches := unquotedDottedNamePattern.FindStringSubmatch(tt.input)
|
||||
if tt.expected == "" {
|
||||
assert.True(t, len(matches) < 2 || matches[1] == "",
|
||||
"expected no match for input %q but got %v", tt.input, matches)
|
||||
} else {
|
||||
assert.True(t, len(matches) >= 2,
|
||||
"expected match for input %q but got none", tt.input)
|
||||
if len(matches) >= 2 {
|
||||
assert.Equal(t, tt.expected, matches[1],
|
||||
"unexpected match for input %q", tt.input)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotedMetricOutsideBracesPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string // empty string means no match expected
|
||||
}{
|
||||
{
|
||||
name: "quoted metric followed by braces",
|
||||
input: `"kube_pod_status_ready_time"{"condition"="true"}`,
|
||||
expected: "kube_pod_status_ready_time",
|
||||
},
|
||||
{
|
||||
name: "quoted metric with space before brace",
|
||||
input: `"kube_pod_labels" {"label"!=""}`,
|
||||
expected: "kube_pod_labels",
|
||||
},
|
||||
{
|
||||
name: "quoted metric in label_replace",
|
||||
input: `label_replace("kube_pod_labels"{"x"="y"}, "a","b","c","d")`,
|
||||
expected: "kube_pod_labels",
|
||||
},
|
||||
{
|
||||
name: "quoted metric with dots",
|
||||
input: `"k8s.container.restarts"{"pod"="test"}`,
|
||||
expected: "k8s.container.restarts",
|
||||
},
|
||||
{
|
||||
name: "correct UTF-8 syntax - no match",
|
||||
input: `{"kube_pod_status_ready_time", condition="true"}`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "old syntax without quotes - no match",
|
||||
input: `kube_pod_status_ready_time{condition="true"}`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "quoted string in label value - no match",
|
||||
input: `metric{label="value"}{other="x"}`,
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
matches := quotedMetricOutsideBracesPattern.FindStringSubmatch(tt.input)
|
||||
if tt.expected == "" {
|
||||
assert.True(t, len(matches) < 2 || matches[1] == "",
|
||||
"expected no match for input %q but got %v", tt.input, matches)
|
||||
} else {
|
||||
assert.True(t, len(matches) >= 2,
|
||||
"expected match for input %q but got none", tt.input)
|
||||
if len(matches) >= 2 {
|
||||
assert.Equal(t, tt.expected, matches[1],
|
||||
"unexpected match for input %q", tt.input)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/apis/fields"
|
||||
errorsV2 "github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
@@ -144,6 +145,8 @@ type APIHandler struct {
|
||||
|
||||
LicensingAPI licensing.API
|
||||
|
||||
FieldsAPI *fields.API
|
||||
|
||||
QuerierAPI *querierAPI.API
|
||||
|
||||
QueryParserAPI *queryparser.API
|
||||
@@ -174,6 +177,8 @@ type APIHandlerOpts struct {
|
||||
|
||||
LicensingAPI licensing.API
|
||||
|
||||
FieldsAPI *fields.API
|
||||
|
||||
QuerierAPI *querierAPI.API
|
||||
|
||||
QueryParserAPI *queryparser.API
|
||||
@@ -238,6 +243,7 @@ func NewAPIHandler(opts APIHandlerOpts) (*APIHandler, error) {
|
||||
AlertmanagerAPI: opts.AlertmanagerAPI,
|
||||
LicensingAPI: opts.LicensingAPI,
|
||||
Signoz: opts.Signoz,
|
||||
FieldsAPI: opts.FieldsAPI,
|
||||
QuerierAPI: opts.QuerierAPI,
|
||||
QueryParserAPI: opts.QueryParserAPI,
|
||||
}
|
||||
@@ -393,6 +399,12 @@ func (aH *APIHandler) RegisterQueryRangeV3Routes(router *mux.Router, am *middlew
|
||||
subRouter.HandleFunc("/logs/livetail", am.ViewAccess(aH.QuerierAPI.QueryRawStream)).Methods(http.MethodGet)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) RegisterFieldsRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
subRouter := router.PathPrefix("/api/v1").Subrouter()
|
||||
|
||||
subRouter.HandleFunc("/fields/keys", am.ViewAccess(aH.FieldsAPI.GetFieldsKeys)).Methods(http.MethodGet)
|
||||
subRouter.HandleFunc("/fields/values", am.ViewAccess(aH.FieldsAPI.GetFieldsValues)).Methods(http.MethodGet)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) RegisterInfraMetricsRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
hostsSubRouter := router.PathPrefix("/api/v1/hosts").Subrouter()
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/gorilla/handlers"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/apis/fields"
|
||||
"github.com/SigNoz/signoz/pkg/cache"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/licensing/nooplicensing"
|
||||
@@ -132,6 +133,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
FluxInterval: config.Querier.FluxInterval,
|
||||
AlertmanagerAPI: alertmanager.NewAPI(signoz.Alertmanager),
|
||||
LicensingAPI: nooplicensing.NewLicenseAPI(),
|
||||
FieldsAPI: fields.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.TelemetryStore),
|
||||
Signoz: signoz,
|
||||
QuerierAPI: querierAPI.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.Querier, signoz.Analytics),
|
||||
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
|
||||
@@ -213,6 +215,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
|
||||
api.RegisterLogsRoutes(r, am)
|
||||
api.RegisterIntegrationRoutes(r, am)
|
||||
api.RegisterCloudIntegrationsRoutes(r, am)
|
||||
api.RegisterFieldsRoutes(r, am)
|
||||
api.RegisterQueryRangeV3Routes(r, am)
|
||||
api.RegisterInfraMetricsRoutes(r, am)
|
||||
api.RegisterWebSocketPaths(r, am)
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/apdex/implapdex"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/fields"
|
||||
"github.com/SigNoz/signoz/pkg/modules/fields/implfields"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
|
||||
@@ -27,8 +25,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
@@ -42,20 +38,10 @@ type Handlers struct {
|
||||
Services services.Handler
|
||||
MetricsExplorer metricsexplorer.Handler
|
||||
Global global.Handler
|
||||
Flagger flagger.Handler
|
||||
Fields fields.Handler
|
||||
FlaggerHandler flagger.Handler
|
||||
}
|
||||
|
||||
func NewHandlers(
|
||||
modules Modules,
|
||||
providerSettings factory.ProviderSettings,
|
||||
querier querier.Querier,
|
||||
licensing licensing.Licensing,
|
||||
global global.Global,
|
||||
flaggr flagger.Flagger,
|
||||
telemetryMetadataStore telemetrytypes.MetadataStore,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
) Handlers {
|
||||
func NewHandlers(modules Modules, providerSettings factory.ProviderSettings, querier querier.Querier, licensing licensing.Licensing, global global.Global, flaggerService flagger.Flagger) Handlers {
|
||||
return Handlers{
|
||||
SavedView: implsavedview.NewHandler(modules.SavedView),
|
||||
Apdex: implapdex.NewHandler(modules.Apdex),
|
||||
@@ -67,7 +53,6 @@ func NewHandlers(
|
||||
MetricsExplorer: implmetricsexplorer.NewHandler(modules.MetricsExplorer),
|
||||
SpanPercentile: implspanpercentile.NewHandler(modules.SpanPercentile),
|
||||
Global: signozglobal.NewHandler(global),
|
||||
Flagger: flagger.NewHandler(flaggr),
|
||||
Fields: implfields.NewHandler(providerSettings, telemetryMetadataStore,telemetryStore),
|
||||
FlaggerHandler: flagger.NewHandler(flaggerService),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestNewHandlers(t *testing.T) {
|
||||
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule)
|
||||
|
||||
handlers := NewHandlers(modules, providerSettings, nil, nil, nil, nil, nil,nil)
|
||||
handlers := NewHandlers(modules, providerSettings, nil, nil, nil, nil)
|
||||
|
||||
reflectVal := reflect.ValueOf(handlers)
|
||||
for i := 0; i < reflectVal.NumField(); i++ {
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation"
|
||||
"github.com/SigNoz/signoz/pkg/modules/authdomain"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/fields"
|
||||
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
@@ -48,7 +47,6 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ dashboard.Module }{},
|
||||
struct{ dashboard.Handler }{},
|
||||
struct{ metricsexplorer.Handler }{},
|
||||
struct{ fields.Handler }{},
|
||||
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -167,9 +167,15 @@ func NewSQLMigrationProviderFactories(
|
||||
func NewTelemetryStoreProviderFactories() factory.NamedMap[factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
clickhousetelemetrystore.NewFactory(
|
||||
telemetrystorehook.NewSettingsFactory(),
|
||||
telemetrystorehook.NewLoggingFactory(),
|
||||
telemetrystorehook.NewInstrumentationFactory(),
|
||||
telemetrystore.TelemetryStoreHookFactoryFunc(func(s string) factory.ProviderFactory[telemetrystore.TelemetryStoreHook, telemetrystore.Config] {
|
||||
return telemetrystorehook.NewSettingsFactory(s)
|
||||
}),
|
||||
telemetrystore.TelemetryStoreHookFactoryFunc(func(s string) factory.ProviderFactory[telemetrystore.TelemetryStoreHook, telemetrystore.Config] {
|
||||
return telemetrystorehook.NewLoggingFactory()
|
||||
}),
|
||||
telemetrystore.TelemetryStoreHookFactoryFunc(func(s string) factory.ProviderFactory[telemetrystore.TelemetryStoreHook, telemetrystore.Config] {
|
||||
return telemetrystorehook.NewInstrumentationFactory(s)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -237,11 +243,10 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
implpreference.NewHandler(modules.Preference),
|
||||
signozglobal.NewHandler(global),
|
||||
implpromote.NewHandler(modules.Promote),
|
||||
handlers.Flagger,
|
||||
handlers.FlaggerHandler,
|
||||
modules.Dashboard,
|
||||
handlers.Dashboard,
|
||||
handlers.MetricsExplorer,
|
||||
handlers.Fields,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -382,7 +382,7 @@ func New(
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboardModule)
|
||||
|
||||
// Initialize all handlers for the modules
|
||||
handlers := NewHandlers(modules, providerSettings, querier, licensing, global, flagger, telemetryMetadataStore, telemetrystore)
|
||||
handlers := NewHandlers(modules, providerSettings, querier, licensing, global, flagger)
|
||||
|
||||
// Initialize the API server
|
||||
apiserver, err := factory.NewProviderFromNamedMap(
|
||||
|
||||
@@ -16,13 +16,13 @@ type provider struct {
|
||||
hooks []telemetrystore.TelemetryStoreHook
|
||||
}
|
||||
|
||||
func NewFactory(hookFactories ...factory.ProviderFactory[telemetrystore.TelemetryStoreHook, telemetrystore.Config]) factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config] {
|
||||
func NewFactory(hookFactories ...telemetrystore.TelemetryStoreHookFactoryFunc) factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("clickhouse"), func(ctx context.Context, providerSettings factory.ProviderSettings, config telemetrystore.Config) (telemetrystore.TelemetryStore, error) {
|
||||
return New(ctx, providerSettings, config, hookFactories...)
|
||||
})
|
||||
}
|
||||
|
||||
func New(ctx context.Context, providerSettings factory.ProviderSettings, config telemetrystore.Config, hookFactories ...factory.ProviderFactory[telemetrystore.TelemetryStoreHook, telemetrystore.Config]) (telemetrystore.TelemetryStore, error) {
|
||||
func New(ctx context.Context, providerSettings factory.ProviderSettings, config telemetrystore.Config, hookFactories ...telemetrystore.TelemetryStoreHookFactoryFunc) (telemetrystore.TelemetryStore, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/telemetrystore/clickhousetelemetrystore")
|
||||
|
||||
options, err := clickhouse.ParseDSN(config.Clickhouse.DSN)
|
||||
@@ -40,10 +40,14 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var version string
|
||||
if err := chConn.QueryRow(ctx, "SELECT version()").Scan(&version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hooks := make([]telemetrystore.TelemetryStoreHook, len(hookFactories))
|
||||
for i, hookFactory := range hookFactories {
|
||||
hook, err := hookFactory.New(ctx, providerSettings, config)
|
||||
hook, err := hookFactory(version).New(ctx, providerSettings, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
)
|
||||
|
||||
type TelemetryStore interface {
|
||||
@@ -19,6 +20,7 @@ type TelemetryStoreHook interface {
|
||||
AfterQuery(ctx context.Context, event *QueryEvent)
|
||||
}
|
||||
|
||||
type TelemetryStoreHookFactoryFunc func(string) factory.ProviderFactory[TelemetryStoreHook, Config]
|
||||
|
||||
func WrapBeforeQuery(hooks []TelemetryStoreHook, ctx context.Context, event *QueryEvent) context.Context {
|
||||
for _, hook := range hooks {
|
||||
|
||||
@@ -13,21 +13,23 @@ import (
|
||||
)
|
||||
|
||||
type instrumentation struct {
|
||||
clickhouseVersion string
|
||||
clickhouseCluster string
|
||||
tracer trace.Tracer
|
||||
meter metric.Meter
|
||||
}
|
||||
|
||||
func NewInstrumentationFactory() factory.ProviderFactory[telemetrystore.TelemetryStoreHook, telemetrystore.Config] {
|
||||
func NewInstrumentationFactory(version string) factory.ProviderFactory[telemetrystore.TelemetryStoreHook, telemetrystore.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("instrumentation"), func(ctx context.Context, ps factory.ProviderSettings, c telemetrystore.Config) (telemetrystore.TelemetryStoreHook, error) {
|
||||
return NewInstrumentation(ctx, ps, c)
|
||||
return NewInstrumentation(ctx, ps, c, version)
|
||||
})
|
||||
}
|
||||
|
||||
func NewInstrumentation(ctx context.Context, providerSettings factory.ProviderSettings, config telemetrystore.Config) (telemetrystore.TelemetryStoreHook, error) {
|
||||
func NewInstrumentation(ctx context.Context, providerSettings factory.ProviderSettings, config telemetrystore.Config, version string) (telemetrystore.TelemetryStoreHook, error) {
|
||||
meter := providerSettings.MeterProvider.Meter("github.com/SigNoz/signoz/pkg/telemetrystore")
|
||||
|
||||
return &instrumentation{
|
||||
clickhouseVersion: version,
|
||||
clickhouseCluster: config.Clickhouse.Cluster,
|
||||
tracer: providerSettings.TracerProvider.Tracer("github.com/SigNoz/signoz/pkg/telemetrystore"),
|
||||
meter: meter,
|
||||
@@ -52,6 +54,7 @@ func (hook *instrumentation) AfterQuery(ctx context.Context, event *telemetrysto
|
||||
attrs = append(
|
||||
attrs,
|
||||
semconv.DBStatementKey.String(event.Query),
|
||||
attribute.String("db.version", hook.clickhouseVersion),
|
||||
semconv.DBSystemKey.String("clickhouse"),
|
||||
semconv.DBOperationKey.String(event.Operation),
|
||||
attribute.String("clickhouse.cluster", hook.clickhouseCluster),
|
||||
|
||||
@@ -2,6 +2,7 @@ package telemetrystorehook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
@@ -10,17 +11,19 @@ import (
|
||||
)
|
||||
|
||||
type provider struct {
|
||||
clickHouseVersion string
|
||||
settings telemetrystore.QuerySettings
|
||||
}
|
||||
|
||||
func NewSettingsFactory() factory.ProviderFactory[telemetrystore.TelemetryStoreHook, telemetrystore.Config] {
|
||||
func NewSettingsFactory(version string) factory.ProviderFactory[telemetrystore.TelemetryStoreHook, telemetrystore.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("settings"), func(ctx context.Context, providerSettings factory.ProviderSettings, config telemetrystore.Config) (telemetrystore.TelemetryStoreHook, error) {
|
||||
return NewSettings(ctx, providerSettings, config)
|
||||
return NewSettings(ctx, providerSettings, config, version)
|
||||
})
|
||||
}
|
||||
|
||||
func NewSettings(ctx context.Context, providerSettings factory.ProviderSettings, config telemetrystore.Config) (telemetrystore.TelemetryStoreHook, error) {
|
||||
func NewSettings(ctx context.Context, providerSettings factory.ProviderSettings, config telemetrystore.Config, version string) (telemetrystore.TelemetryStoreHook, error) {
|
||||
return &provider{
|
||||
clickHouseVersion: version,
|
||||
settings: config.Clickhouse.QuerySettings,
|
||||
}, nil
|
||||
}
|
||||
@@ -72,9 +75,12 @@ func (h *provider) BeforeQuery(ctx context.Context, _ *telemetrystore.QueryEvent
|
||||
settings["result_overflow_mode"] = ctx.Value("result_overflow_mode")
|
||||
}
|
||||
|
||||
// TODO(srikanthccv): enable it when the "Cannot read all data" issue is fixed
|
||||
// https://github.com/ClickHouse/ClickHouse/issues/82283
|
||||
settings["secondary_indices_enable_bulk_filtering"] = false
|
||||
// ClickHouse version check is added since this setting is not support on version below 25.5
|
||||
if strings.HasPrefix(h.clickHouseVersion, "25") && !h.settings.SecondaryIndicesEnableBulkFiltering {
|
||||
// TODO(srikanthccv): enable it when the "Cannot read all data" issue is fixed
|
||||
// https://github.com/ClickHouse/ClickHouse/issues/82283
|
||||
settings["secondary_indices_enable_bulk_filtering"] = false
|
||||
}
|
||||
|
||||
ctx = clickhouse.Context(ctx, clickhouse.WithSettings(settings))
|
||||
return ctx
|
||||
|
||||
@@ -276,31 +276,6 @@ func (r *QueryRangeRequest) NumAggregationForQuery(name string) int64 {
|
||||
return int64(numAgg)
|
||||
}
|
||||
|
||||
// HasOrderSpecified returns true if any query has an explicit order provided.
|
||||
func (r *QueryRangeRequest) HasOrderSpecified() bool {
|
||||
for _, query := range r.CompositeQuery.Queries {
|
||||
switch spec := query.Spec.(type) {
|
||||
case QueryBuilderQuery[TraceAggregation]:
|
||||
if len(spec.Order) > 0 {
|
||||
return true
|
||||
}
|
||||
case QueryBuilderQuery[LogAggregation]:
|
||||
if len(spec.Order) > 0 {
|
||||
return true
|
||||
}
|
||||
case QueryBuilderQuery[MetricAggregation]:
|
||||
if len(spec.Order) > 0 {
|
||||
return true
|
||||
}
|
||||
case QueryBuilderFormula:
|
||||
if len(spec.Order) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *QueryRangeRequest) FuncsForQuery(name string) []Function {
|
||||
funcs := []Function{}
|
||||
for _, query := range r.CompositeQuery.Queries {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz-otel-collector/exporter/jsontypeexporter"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -153,99 +152,3 @@ type FieldValueSelector struct {
|
||||
Value string `json:"value"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
|
||||
type GettableFieldKeys struct {
|
||||
Keys map[string][]*TelemetryFieldKey `json:"keys"`
|
||||
Complete bool `json:"complete"`
|
||||
}
|
||||
|
||||
type PostableFieldKeysParams struct {
|
||||
Signal Signal `query:"signal"`
|
||||
Source Source `query:"source"`
|
||||
Limit int `query:"limit"`
|
||||
StartUnixMilli int64 `query:"startUnixMilli"`
|
||||
EndUnixMilli int64 `query:"endUnixMilli"`
|
||||
FieldContext FieldContext `query:"fieldContext"`
|
||||
FieldDataType FieldDataType `query:"fieldDataType"`
|
||||
MetricContext *MetricContext `query:"metricContext"`
|
||||
Name string `query:"name"`
|
||||
SearchText string `query:"searchText"`
|
||||
}
|
||||
|
||||
type GettableFieldValues struct {
|
||||
Values *TelemetryFieldValues `json:"values"`
|
||||
Complete bool `json:"complete"`
|
||||
}
|
||||
|
||||
type PostableFieldValueParams struct {
|
||||
PostableFieldKeysParams
|
||||
ExistingQuery string `query:"existingQuery"`
|
||||
}
|
||||
|
||||
func NewFieldKeySelectorFromPostableFieldKeysParams(params PostableFieldKeysParams) (*FieldKeySelector, error) {
|
||||
var req FieldKeySelector
|
||||
var signal Signal
|
||||
|
||||
if params.Limit != 0 {
|
||||
req.Limit = params.Limit
|
||||
} else {
|
||||
req.Limit = 1000
|
||||
}
|
||||
|
||||
if params.StartUnixMilli != 0 {
|
||||
req.StartUnixMilli = params.StartUnixMilli
|
||||
// Round down to the nearest 6 hours (21600000 milliseconds)
|
||||
req.StartUnixMilli -= req.StartUnixMilli % 21600000
|
||||
}
|
||||
|
||||
if params.EndUnixMilli != 0 {
|
||||
req.EndUnixMilli = params.EndUnixMilli
|
||||
}
|
||||
|
||||
if params.SearchText != "" && params.FieldContext == FieldContextUnspecified {
|
||||
parsedFieldKey := GetFieldKeyFromKeyText(params.SearchText )
|
||||
if parsedFieldKey.FieldContext != FieldContextUnspecified {
|
||||
// Only apply inferred context if it is valid for the current signal
|
||||
if isContextValidForSignal(parsedFieldKey.FieldContext, signal) {
|
||||
req.Name = parsedFieldKey.Name
|
||||
req.FieldContext = parsedFieldKey.FieldContext
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
func NewFieldValueSelectorFromPostableFieldValueParams(params PostableFieldValueParams) (*FieldValueSelector, error) {
|
||||
var fieldValueSelector FieldValueSelector
|
||||
|
||||
keySelector, err := NewFieldKeySelectorFromPostableFieldKeysParams(params.PostableFieldKeysParams)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse field key request").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
if params.Name != "" && keySelector.FieldContext == FieldContextUnspecified {
|
||||
parsedFieldKey := GetFieldKeyFromKeyText(params.Name)
|
||||
if parsedFieldKey.FieldContext != FieldContextUnspecified {
|
||||
// Only apply inferred context if it is valid for the current signal
|
||||
if isContextValidForSignal(parsedFieldKey.FieldContext, keySelector.Signal) {
|
||||
fieldValueSelector.Name = parsedFieldKey.Name
|
||||
keySelector.FieldContext = parsedFieldKey.FieldContext
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keySelector.Name = fieldValueSelector.Name
|
||||
fieldValueSelector.ExistingQuery = params.ExistingQuery
|
||||
fieldValueSelector.Value = params.SearchText
|
||||
|
||||
|
||||
if params.Limit != 0 {
|
||||
fieldValueSelector.Limit = params.Limit
|
||||
} else {
|
||||
fieldValueSelector.Limit = 50
|
||||
}
|
||||
|
||||
return &fieldValueSelector, nil
|
||||
}
|
||||
@@ -154,21 +154,3 @@ func (f FieldContext) TagType() string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isContextValidForSignal(ctx FieldContext, signal Signal) bool {
|
||||
if ctx == FieldContextResource ||
|
||||
ctx == FieldContextAttribute ||
|
||||
ctx == FieldContextScope {
|
||||
return true
|
||||
}
|
||||
|
||||
switch signal.StringValue() {
|
||||
case SignalLogs.StringValue():
|
||||
return ctx == FieldContextLog || ctx == FieldContextBody
|
||||
case SignalTraces.StringValue():
|
||||
return ctx == FieldContextSpan || ctx == FieldContextEvent || ctx == FieldContextTrace
|
||||
case SignalMetrics.StringValue():
|
||||
return ctx == FieldContextMetric
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ def create_saml_client(
|
||||
"config": {
|
||||
"full.path": "false",
|
||||
"attribute.nameformat": "Basic",
|
||||
"single": "true", # ! this was changed to true as we need the groups in the single attribute section
|
||||
"single": "true", # ! this was changed to true as we need the groups in the single attribute section
|
||||
"friendly.name": "groups",
|
||||
"attribute.name": "groups",
|
||||
},
|
||||
@@ -322,9 +322,7 @@ def get_oidc_settings(idp: types.TestContainerIDP) -> dict:
|
||||
|
||||
|
||||
@pytest.fixture(name="create_user_idp", scope="function")
|
||||
def create_user_idp(
|
||||
idp: types.TestContainerIDP,
|
||||
) -> Callable[[str, str, bool, str, str], None]:
|
||||
def create_user_idp(idp: types.TestContainerIDP) -> Callable[[str, str, bool, str, str], None]:
|
||||
client = KeycloakAdmin(
|
||||
server_url=idp.container.host_configs["6060"].base(),
|
||||
username=IDP_ROOT_USERNAME,
|
||||
@@ -334,13 +332,7 @@ def create_user_idp(
|
||||
|
||||
created_users = []
|
||||
|
||||
def _create_user_idp(
|
||||
email: str,
|
||||
password: str,
|
||||
verified: bool = True,
|
||||
first_name: str = "",
|
||||
last_name: str = "",
|
||||
) -> None:
|
||||
def _create_user_idp(email: str, password: str, verified: bool = True, first_name: str = "", last_name: str = "") -> None:
|
||||
payload = {
|
||||
"username": email,
|
||||
"email": email,
|
||||
@@ -408,14 +400,14 @@ def create_group_idp(idp: types.TestContainerIDP) -> Callable[[str], str]:
|
||||
for group_id in created_groups:
|
||||
try:
|
||||
client.delete_group(group_id)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(name="create_user_idp_with_groups", scope="function")
|
||||
def create_user_idp_with_groups(
|
||||
idp: types.TestContainerIDP,
|
||||
create_group_idp: Callable[[str], str], # pylint: disable=redefined-outer-name
|
||||
create_group_idp: Callable[[str], str], # pylint: disable=redefined-outer-name
|
||||
) -> Callable[[str, str, bool, List[str]], None]:
|
||||
"""Creates a user in Keycloak IDP with specified groups."""
|
||||
client = KeycloakAdmin(
|
||||
@@ -458,14 +450,14 @@ def create_user_idp_with_groups(
|
||||
for user_id in created_users:
|
||||
try:
|
||||
client.delete_user(user_id)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(name="add_user_to_group", scope="function")
|
||||
def add_user_to_group(
|
||||
idp: types.TestContainerIDP,
|
||||
create_group_idp: Callable[[str], str], # pylint: disable=redefined-outer-name
|
||||
create_group_idp: Callable[[str], str], # pylint: disable=redefined-outer-name
|
||||
) -> Callable[[str, str], None]:
|
||||
"""Adds an existing user to a group."""
|
||||
client = KeycloakAdmin(
|
||||
@@ -486,7 +478,7 @@ def add_user_to_group(
|
||||
@pytest.fixture(name="create_user_idp_with_role", scope="function")
|
||||
def create_user_idp_with_role(
|
||||
idp: types.TestContainerIDP,
|
||||
create_group_idp: Callable[[str], str], # pylint: disable=redefined-outer-name
|
||||
create_group_idp: Callable[[str], str], # pylint: disable=redefined-outer-name
|
||||
) -> Callable[[str, str, bool, str, List[str]], None]:
|
||||
"""Creates a user in Keycloak IDP with a custom role attribute and optional groups."""
|
||||
client = KeycloakAdmin(
|
||||
@@ -532,14 +524,13 @@ def create_user_idp_with_role(
|
||||
for user_id in created_users:
|
||||
try:
|
||||
client.delete_user(user_id)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(name="setup_user_profile", scope="package")
|
||||
def setup_user_profile(idp: types.TestContainerIDP) -> Callable[[], None]:
|
||||
"""Setup Keycloak User Profile with signoz_role attribute."""
|
||||
|
||||
def _setup_user_profile() -> None:
|
||||
client = KeycloakAdmin(
|
||||
server_url=idp.container.host_configs["6060"].base(),
|
||||
@@ -547,36 +538,35 @@ def setup_user_profile(idp: types.TestContainerIDP) -> Callable[[], None]:
|
||||
password=IDP_ROOT_PASSWORD,
|
||||
realm_name="master",
|
||||
)
|
||||
|
||||
|
||||
# Get current user profile config
|
||||
profile = client.get_realm_users_profile()
|
||||
|
||||
|
||||
# Check if signoz_role attribute already exists
|
||||
attributes = profile.get("attributes", [])
|
||||
signoz_role_exists = any(
|
||||
attr.get("name") == "signoz_role" for attr in attributes
|
||||
)
|
||||
|
||||
signoz_role_exists = any(attr.get("name") == "signoz_role" for attr in attributes)
|
||||
|
||||
if not signoz_role_exists:
|
||||
# Add signoz_role attribute to user profile
|
||||
attributes.append(
|
||||
{
|
||||
"name": "signoz_role",
|
||||
"displayName": "SigNoz Role",
|
||||
"validations": {},
|
||||
"annotations": {},
|
||||
# "required": {
|
||||
# "roles": [] # Not required
|
||||
# },
|
||||
"permissions": {"view": ["admin", "user"], "edit": ["admin"]},
|
||||
"multivalued": False,
|
||||
}
|
||||
)
|
||||
attributes.append({
|
||||
"name": "signoz_role",
|
||||
"displayName": "SigNoz Role",
|
||||
"validations": {},
|
||||
"annotations": {},
|
||||
# "required": {
|
||||
# "roles": [] # Not required
|
||||
# },
|
||||
"permissions": {
|
||||
"view": ["admin", "user"],
|
||||
"edit": ["admin"]
|
||||
},
|
||||
"multivalued": False
|
||||
})
|
||||
profile["attributes"] = attributes
|
||||
|
||||
|
||||
# Update the realm user profile
|
||||
client.update_realm_users_profile(payload=profile)
|
||||
|
||||
|
||||
return _setup_user_profile
|
||||
|
||||
|
||||
@@ -585,7 +575,7 @@ def _ensure_groups_client_scope(client: KeycloakAdmin) -> None:
|
||||
# Check if groups scope exists
|
||||
scopes = client.get_client_scopes()
|
||||
groups_scope_exists = any(s.get("name") == "groups" for s in scopes)
|
||||
|
||||
|
||||
if not groups_scope_exists:
|
||||
# Create the groups client scope
|
||||
client.create_client_scope(
|
||||
@@ -662,11 +652,11 @@ def get_user_by_email(signoz: types.SigNoz, admin_token: str, email: str) -> dic
|
||||
|
||||
|
||||
def perform_oidc_login(
|
||||
signoz: types.SigNoz, # pylint: disable=unused-argument
|
||||
signoz: types.SigNoz, # pylint: disable=unused-argument
|
||||
idp: types.TestContainerIDP,
|
||||
driver: webdriver.Chrome,
|
||||
get_session_context: Callable[[str], str],
|
||||
idp_login: Callable[[str, str], None], # pylint: disable=redefined-outer-name
|
||||
idp_login: Callable[[str, str], None], # pylint: disable=redefined-outer-name
|
||||
email: str,
|
||||
password: str,
|
||||
) -> None:
|
||||
@@ -698,10 +688,10 @@ def get_saml_domain(signoz: types.SigNoz, admin_token: str) -> dict:
|
||||
|
||||
|
||||
def perform_saml_login(
|
||||
signoz: types.SigNoz, # pylint: disable=unused-argument
|
||||
signoz: types.SigNoz, # pylint: disable=unused-argument
|
||||
driver: webdriver.Chrome,
|
||||
get_session_context: Callable[[str], str],
|
||||
idp_login: Callable[[str, str], None], # pylint: disable=redefined-outer-name
|
||||
idp_login: Callable[[str, str], None], # pylint: disable=redefined-outer-name
|
||||
email: str,
|
||||
password: str,
|
||||
) -> None:
|
||||
|
||||
@@ -329,130 +329,3 @@ def find_named_result(
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def build_scalar_query(
|
||||
name: str,
|
||||
signal: str,
|
||||
aggregations: List[Dict],
|
||||
*,
|
||||
group_by: Optional[List[Dict]] = None,
|
||||
order: Optional[List[Dict]] = None,
|
||||
limit: Optional[int] = None,
|
||||
filter_expression: Optional[str] = None,
|
||||
having_expression: Optional[str] = None,
|
||||
step_interval: int = DEFAULT_STEP_INTERVAL,
|
||||
disabled: bool = False,
|
||||
) -> Dict:
|
||||
spec: Dict[str, Any] = {
|
||||
"name": name,
|
||||
"signal": signal,
|
||||
"stepInterval": step_interval,
|
||||
"disabled": disabled,
|
||||
"aggregations": aggregations,
|
||||
}
|
||||
|
||||
if group_by:
|
||||
spec["groupBy"] = group_by
|
||||
|
||||
if order:
|
||||
spec["order"] = order
|
||||
|
||||
if limit is not None:
|
||||
spec["limit"] = limit
|
||||
|
||||
if filter_expression:
|
||||
spec["filter"] = {"expression": filter_expression}
|
||||
|
||||
if having_expression:
|
||||
spec["having"] = {"expression": having_expression}
|
||||
|
||||
return {"type": "builder_query", "spec": spec}
|
||||
|
||||
|
||||
def build_group_by_field(
|
||||
name: str,
|
||||
field_data_type: str = "string",
|
||||
field_context: str = "resource",
|
||||
) -> Dict:
|
||||
return {
|
||||
"name": name,
|
||||
"fieldDataType": field_data_type,
|
||||
"fieldContext": field_context,
|
||||
}
|
||||
|
||||
|
||||
def build_order_by(name: str, direction: str = "desc") -> Dict:
|
||||
return {"key": {"name": name}, "direction": direction}
|
||||
|
||||
|
||||
def build_logs_aggregation(expression: str, alias: Optional[str] = None) -> Dict:
|
||||
agg: Dict[str, Any] = {"expression": expression}
|
||||
if alias:
|
||||
agg["alias"] = alias
|
||||
return agg
|
||||
|
||||
|
||||
def build_metrics_aggregation(
|
||||
metric_name: str,
|
||||
time_aggregation: str,
|
||||
space_aggregation: str,
|
||||
temporality: str = "cumulative",
|
||||
) -> Dict:
|
||||
return {
|
||||
"metricName": metric_name,
|
||||
"temporality": temporality,
|
||||
"timeAggregation": time_aggregation,
|
||||
"spaceAggregation": space_aggregation,
|
||||
}
|
||||
|
||||
|
||||
def get_scalar_table_data(response_json: Dict) -> List[List[Any]]:
|
||||
results = response_json.get("data", {}).get("data", {}).get("results", [])
|
||||
if not results:
|
||||
return []
|
||||
return results[0].get("data", [])
|
||||
|
||||
|
||||
def get_scalar_columns(response_json: Dict) -> List[Dict]:
|
||||
results = response_json.get("data", {}).get("data", {}).get("results", [])
|
||||
if not results:
|
||||
return []
|
||||
return results[0].get("columns", [])
|
||||
|
||||
|
||||
def assert_scalar_result_order(
|
||||
data: List[List[Any]],
|
||||
expected_order: List[tuple],
|
||||
context: str = "",
|
||||
) -> None:
|
||||
assert len(data) == len(expected_order), (
|
||||
f"{context}: Expected {len(expected_order)} rows, got {len(data)}. "
|
||||
f"Data: {data}"
|
||||
)
|
||||
|
||||
for i, (row, expected) in enumerate(zip(data, expected_order)):
|
||||
for j, expected_val in enumerate(expected):
|
||||
actual_val = row[j]
|
||||
assert actual_val == expected_val, (
|
||||
f"{context}: Row {i}, column {j} mismatch. "
|
||||
f"Expected {expected_val}, got {actual_val}. "
|
||||
f"Full row: {row}, expected: {expected}"
|
||||
)
|
||||
|
||||
|
||||
def assert_scalar_column_order(
|
||||
data: List[List[Any]],
|
||||
column_index: int,
|
||||
expected_values: List[Any],
|
||||
context: str = "",
|
||||
) -> None:
|
||||
assert len(data) == len(
|
||||
expected_values
|
||||
), f"{context}: Expected {len(expected_values)} rows, got {len(data)}"
|
||||
|
||||
actual_values = [row[column_index] for row in data]
|
||||
assert actual_values == expected_values, (
|
||||
f"{context}: Column {column_index} order mismatch. "
|
||||
f"Expected {expected_values}, got {actual_values}"
|
||||
)
|
||||
|
||||
@@ -78,14 +78,11 @@ def test_create_and_get_domain(
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
data = response.json()["data"]
|
||||
|
||||
|
||||
assert len(data) == 2
|
||||
|
||||
for domain in data:
|
||||
assert domain["name"] in [
|
||||
"domain-google.integration.test",
|
||||
"domain-saml.integration.test",
|
||||
]
|
||||
assert domain["name"] in ["domain-google.integration.test", "domain-saml.integration.test"]
|
||||
assert domain["ssoType"] in ["google_auth", "saml"]
|
||||
|
||||
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import uuid
|
||||
from http import HTTPStatus
|
||||
from typing import Any, Callable, Dict, List
|
||||
|
||||
import requests
|
||||
from selenium import webdriver
|
||||
from wiremock.resources.mappings import Mapping
|
||||
import uuid
|
||||
|
||||
from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
add_license,
|
||||
)
|
||||
from fixtures.idputils import (
|
||||
get_saml_domain,
|
||||
get_user_by_email,
|
||||
perform_saml_login,
|
||||
)
|
||||
from fixtures.idputils import get_saml_domain, perform_saml_login, get_user_by_email, delete_keycloak_client
|
||||
from fixtures.types import Operation, SigNoz, TestContainerDocker, TestContainerIDP
|
||||
|
||||
|
||||
@@ -262,9 +258,7 @@ def test_saml_role_mapping_single_group_admin(
|
||||
email = "admin-group-user@saml.integration.test"
|
||||
create_user_idp_with_groups(email, "password", True, ["signoz-admins"])
|
||||
|
||||
perform_saml_login(
|
||||
signoz, driver, get_session_context, idp_login, email, "password"
|
||||
)
|
||||
perform_saml_login(signoz, driver, get_session_context, idp_login, email, "password")
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
@@ -288,9 +282,7 @@ def test_saml_role_mapping_single_group_editor(
|
||||
email = "editor-group-user@saml.integration.test"
|
||||
create_user_idp_with_groups(email, "password", True, ["signoz-editors"])
|
||||
|
||||
perform_saml_login(
|
||||
signoz, driver, get_session_context, idp_login, email, "password"
|
||||
)
|
||||
perform_saml_login(signoz, driver, get_session_context, idp_login, email, "password")
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
@@ -314,13 +306,9 @@ def test_saml_role_mapping_multiple_groups_highest_wins(
|
||||
Expected: User gets EDITOR (highest of VIEWER and EDITOR).
|
||||
"""
|
||||
email = f"multi-group-user-{uuid.uuid4().hex[:8]}@saml.integration.test"
|
||||
create_user_idp_with_groups(
|
||||
email, "password", True, ["signoz-viewers", "signoz-editors"]
|
||||
)
|
||||
create_user_idp_with_groups(email, "password", True, ["signoz-viewers", "signoz-editors"])
|
||||
|
||||
perform_saml_login(
|
||||
signoz, driver, get_session_context, idp_login, email, "password"
|
||||
)
|
||||
perform_saml_login(signoz, driver, get_session_context, idp_login, email, "password")
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
@@ -345,9 +333,7 @@ def test_saml_role_mapping_explicit_viewer_group(
|
||||
email = "viewer-group-user@saml.integration.test"
|
||||
create_user_idp_with_groups(email, "password", True, ["signoz-viewers"])
|
||||
|
||||
perform_saml_login(
|
||||
signoz, driver, get_session_context, idp_login, email, "password"
|
||||
)
|
||||
perform_saml_login(signoz, driver, get_session_context, idp_login, email, "password")
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
@@ -371,9 +357,7 @@ def test_saml_role_mapping_unmapped_group_uses_default(
|
||||
email = "unmapped-group-user@saml.integration.test"
|
||||
create_user_idp_with_groups(email, "password", True, ["some-other-group"])
|
||||
|
||||
perform_saml_login(
|
||||
signoz, driver, get_session_context, idp_login, email, "password"
|
||||
)
|
||||
perform_saml_login(signoz, driver, get_session_context, idp_login, email, "password")
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
@@ -448,9 +432,7 @@ def test_saml_role_mapping_role_claim_takes_precedence(
|
||||
email = "role-claim-precedence@saml.integration.test"
|
||||
create_user_idp_with_role(email, "password", True, "ADMIN", ["signoz-editors"])
|
||||
|
||||
perform_saml_login(
|
||||
signoz, driver, get_session_context, idp_login, email, "password"
|
||||
)
|
||||
perform_saml_login(signoz, driver, get_session_context, idp_login, email, "password")
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
@@ -478,9 +460,7 @@ def test_saml_role_mapping_invalid_role_claim_fallback(
|
||||
email = "invalid-role-user@saml.integration.test"
|
||||
create_user_idp_with_role(email, "password", True, "SUPERADMIN", ["signoz-editors"])
|
||||
|
||||
perform_saml_login(
|
||||
signoz, driver, get_session_context, idp_login, email, "password"
|
||||
)
|
||||
perform_saml_login(signoz, driver, get_session_context, idp_login, email, "password")
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
@@ -508,9 +488,7 @@ def test_saml_role_mapping_case_insensitive(
|
||||
email = "lowercase-role-user@saml.integration.test"
|
||||
create_user_idp_with_role(email, "password", True, "admin", [])
|
||||
|
||||
perform_saml_login(
|
||||
signoz, driver, get_session_context, idp_login, email, "password"
|
||||
)
|
||||
perform_saml_login(signoz, driver, get_session_context, idp_login, email, "password")
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
@@ -521,7 +499,7 @@ def test_saml_role_mapping_case_insensitive(
|
||||
|
||||
def test_saml_name_mapping(
|
||||
signoz: SigNoz,
|
||||
idp: TestContainerIDP, # pylint: disable=unused-argument
|
||||
idp: TestContainerIDP, # pylint: disable=unused-argument
|
||||
driver: webdriver.Chrome,
|
||||
create_user_idp: Callable[[str, str, bool, str, str], None],
|
||||
idp_login: Callable[[str, str], None],
|
||||
@@ -530,26 +508,22 @@ def test_saml_name_mapping(
|
||||
) -> None:
|
||||
"""Test that user's display name is mapped from SAML displayName attribute."""
|
||||
email = "named-user@saml.integration.test"
|
||||
|
||||
|
||||
create_user_idp(email, "password", True, "Jane", "Smith")
|
||||
|
||||
perform_saml_login(
|
||||
signoz, driver, get_session_context, idp_login, email, "password"
|
||||
)
|
||||
|
||||
|
||||
perform_saml_login(signoz, driver, get_session_context, idp_login, email, "password")
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
|
||||
|
||||
assert found_user is not None
|
||||
assert (
|
||||
found_user["displayName"] == "Jane"
|
||||
) # We are only mapping the first name here
|
||||
assert found_user["displayName"] == "Jane" # We are only mapping the first name here
|
||||
assert found_user["role"] == "VIEWER"
|
||||
|
||||
|
||||
def test_saml_empty_name_fallback(
|
||||
signoz: SigNoz,
|
||||
idp: TestContainerIDP, # pylint: disable=unused-argument
|
||||
idp: TestContainerIDP, # pylint: disable=unused-argument
|
||||
driver: webdriver.Chrome,
|
||||
create_user_idp: Callable[[str, str, bool, str, str], None],
|
||||
idp_login: Callable[[str, str], None],
|
||||
@@ -558,15 +532,13 @@ def test_saml_empty_name_fallback(
|
||||
) -> None:
|
||||
"""Test that user without displayName in IDP still gets created."""
|
||||
email = "no-name@saml.integration.test"
|
||||
|
||||
|
||||
create_user_idp(email, "password", True)
|
||||
|
||||
perform_saml_login(
|
||||
signoz, driver, get_session_context, idp_login, email, "password"
|
||||
)
|
||||
|
||||
|
||||
perform_saml_login(signoz, driver, get_session_context, idp_login, email, "password")
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
|
||||
|
||||
assert found_user is not None
|
||||
assert found_user["role"] == "VIEWER"
|
||||
|
||||
@@ -11,11 +11,7 @@ from fixtures.auth import (
|
||||
USER_ADMIN_PASSWORD,
|
||||
add_license,
|
||||
)
|
||||
from fixtures.idputils import (
|
||||
get_oidc_domain,
|
||||
get_user_by_email,
|
||||
perform_oidc_login,
|
||||
)
|
||||
from fixtures.idputils import get_oidc_domain, get_user_by_email, perform_oidc_login, delete_keycloak_client
|
||||
from fixtures.types import Operation, SigNoz, TestContainerDocker, TestContainerIDP
|
||||
|
||||
|
||||
@@ -200,9 +196,7 @@ def test_oidc_role_mapping_single_group_admin(
|
||||
email = "admin-group-user@oidc.integration.test"
|
||||
create_user_idp_with_groups(email, "password123", True, ["signoz-admins"])
|
||||
|
||||
perform_oidc_login(
|
||||
signoz, idp, driver, get_session_context, idp_login, email, "password123"
|
||||
)
|
||||
perform_oidc_login(signoz, idp, driver, get_session_context, idp_login, email, "password123")
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
@@ -226,9 +220,7 @@ def test_oidc_role_mapping_single_group_editor(
|
||||
email = "editor-group-user@oidc.integration.test"
|
||||
create_user_idp_with_groups(email, "password123", True, ["signoz-editors"])
|
||||
|
||||
perform_oidc_login(
|
||||
signoz, idp, driver, get_session_context, idp_login, email, "password123"
|
||||
)
|
||||
perform_oidc_login(signoz, idp, driver, get_session_context, idp_login, email, "password123")
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
@@ -252,13 +244,9 @@ def test_oidc_role_mapping_multiple_groups_highest_wins(
|
||||
Expected: User gets ADMIN (highest of the two).
|
||||
"""
|
||||
email = "multi-group-user@oidc.integration.test"
|
||||
create_user_idp_with_groups(
|
||||
email, "password123", True, ["signoz-viewers", "signoz-admins"]
|
||||
)
|
||||
create_user_idp_with_groups(email, "password123", True, ["signoz-viewers", "signoz-admins"])
|
||||
|
||||
perform_oidc_login(
|
||||
signoz, idp, driver, get_session_context, idp_login, email, "password123"
|
||||
)
|
||||
perform_oidc_login(signoz, idp, driver, get_session_context, idp_login, email, "password123")
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
@@ -283,9 +271,7 @@ def test_oidc_role_mapping_explicit_viewer_group(
|
||||
email = "viewer-group-user@oidc.integration.test"
|
||||
create_user_idp_with_groups(email, "password123", True, ["signoz-viewers"])
|
||||
|
||||
perform_oidc_login(
|
||||
signoz, idp, driver, get_session_context, idp_login, email, "password123"
|
||||
)
|
||||
perform_oidc_login(signoz, idp, driver, get_session_context, idp_login, email, "password123")
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
@@ -309,9 +295,7 @@ def test_oidc_role_mapping_unmapped_group_uses_default(
|
||||
email = "unmapped-group-user@oidc.integration.test"
|
||||
create_user_idp_with_groups(email, "password123", True, ["some-other-group"])
|
||||
|
||||
perform_oidc_login(
|
||||
signoz, idp, driver, get_session_context, idp_login, email, "password123"
|
||||
)
|
||||
perform_oidc_login(signoz, idp, driver, get_session_context, idp_login, email, "password123")
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
@@ -389,9 +373,7 @@ def test_oidc_role_mapping_role_claim_takes_precedence(
|
||||
email = "role-claim-precedence@oidc.integration.test"
|
||||
create_user_idp_with_role(email, "password123", True, "ADMIN", ["signoz-editors"])
|
||||
|
||||
perform_oidc_login(
|
||||
signoz, idp, driver, get_session_context, idp_login, email, "password123"
|
||||
)
|
||||
perform_oidc_login(signoz, idp, driver, get_session_context, idp_login, email, "password123")
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
@@ -417,13 +399,9 @@ def test_oidc_role_mapping_invalid_role_claim_fallback(
|
||||
"""
|
||||
setup_user_profile()
|
||||
email = "invalid-role-user@oidc.integration.test"
|
||||
create_user_idp_with_role(
|
||||
email, "password123", True, "SUPERADMIN", ["signoz-editors"]
|
||||
)
|
||||
create_user_idp_with_role(email, "password123", True, "SUPERADMIN", ["signoz-editors"])
|
||||
|
||||
perform_oidc_login(
|
||||
signoz, idp, driver, get_session_context, idp_login, email, "password123"
|
||||
)
|
||||
perform_oidc_login(signoz, idp, driver, get_session_context, idp_login, email, "password123")
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
@@ -451,9 +429,7 @@ def test_oidc_role_mapping_case_insensitive(
|
||||
email = "lowercase-role-user@oidc.integration.test"
|
||||
create_user_idp_with_role(email, "password123", True, "editor", [])
|
||||
|
||||
perform_oidc_login(
|
||||
signoz, idp, driver, get_session_context, idp_login, email, "password123"
|
||||
)
|
||||
perform_oidc_login(signoz, idp, driver, get_session_context, idp_login, email, "password123")
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
found_user = get_user_by_email(signoz, admin_token, email)
|
||||
@@ -473,25 +449,29 @@ def test_oidc_name_mapping(
|
||||
) -> None:
|
||||
"""Test that user's display name is mapped from IDP name claim."""
|
||||
email = "named-user@oidc.integration.test"
|
||||
|
||||
|
||||
# Create user with explicit first/last name
|
||||
create_user_idp(email, "password123", True, first_name="John", last_name="Doe")
|
||||
|
||||
perform_oidc_login(
|
||||
signoz, idp, driver, get_session_context, idp_login, email, "password123"
|
||||
create_user_idp(
|
||||
email,
|
||||
"password123",
|
||||
True,
|
||||
first_name="John",
|
||||
last_name="Doe"
|
||||
)
|
||||
|
||||
perform_oidc_login(signoz, idp, driver, get_session_context, idp_login, email, "password123")
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
users = response.json()["data"]
|
||||
found_user = next((u for u in users if u["email"] == email), None)
|
||||
|
||||
|
||||
assert found_user is not None
|
||||
# Keycloak concatenates firstName + lastName into "name" claim
|
||||
assert found_user["displayName"] == "John Doe"
|
||||
@@ -509,25 +489,23 @@ def test_oidc_empty_name_uses_fallback(
|
||||
) -> None:
|
||||
"""Test that user without name in IDP still gets created (may have empty displayName)."""
|
||||
email = "no-name@oidc.integration.test"
|
||||
|
||||
|
||||
# Create user without first/last name
|
||||
create_user_idp(email, "password123", True)
|
||||
|
||||
perform_oidc_login(
|
||||
signoz, idp, driver, get_session_context, idp_login, email, "password123"
|
||||
)
|
||||
|
||||
perform_oidc_login(signoz, idp, driver, get_session_context, idp_login, email, "password123")
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
users = response.json()["data"]
|
||||
found_user = next((u for u in users if u["email"] == email), None)
|
||||
|
||||
|
||||
# User should still be created even with empty name
|
||||
assert found_user is not None
|
||||
assert found_user["role"] == "VIEWER"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user