mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-05 12:40:46 +01:00
Compare commits
12 Commits
tvats-flak
...
hotfix/pip
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
decf6760d3 | ||
|
|
1e20dc7a87 | ||
|
|
a6ac14344e | ||
|
|
e4e1c4b9e0 | ||
|
|
816ae7760e | ||
|
|
985523df93 | ||
|
|
5c9606bf9b | ||
|
|
f9461415bb | ||
|
|
d93713c184 | ||
|
|
0b5d3e939f | ||
|
|
089f9eb4c6 | ||
|
|
6c659e3b26 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -55,6 +55,7 @@ jobs:
|
||||
- queriermetrics
|
||||
- querierscalar
|
||||
- queriercommon
|
||||
- querierai
|
||||
- rawexportdata
|
||||
- promqlconformance
|
||||
- querierauthz
|
||||
|
||||
@@ -6902,6 +6902,7 @@ components:
|
||||
Querybuildertypesv5QueryEnvelope:
|
||||
discriminator:
|
||||
mapping:
|
||||
builder_ai_query: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderAI'
|
||||
builder_formula: '#/components/schemas/Querybuildertypesv5QueryEnvelopeFormula'
|
||||
builder_query: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilder'
|
||||
builder_trace_operator: '#/components/schemas/Querybuildertypesv5QueryEnvelopeTraceOperator'
|
||||
@@ -6910,6 +6911,7 @@ components:
|
||||
propertyName: type
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilder'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderAI'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeFormula'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeTraceOperator'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopePromQL'
|
||||
@@ -6924,6 +6926,15 @@ components:
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
Querybuildertypesv5QueryEnvelopeBuilderAI:
|
||||
properties:
|
||||
spec:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
|
||||
type:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQL:
|
||||
properties:
|
||||
spec:
|
||||
@@ -7037,6 +7048,7 @@ components:
|
||||
Querybuildertypesv5QueryType:
|
||||
enum:
|
||||
- builder_query
|
||||
- builder_ai_query
|
||||
- builder_formula
|
||||
- builder_trace_operator
|
||||
- clickhouse_sql
|
||||
@@ -15477,6 +15489,72 @@ paths:
|
||||
summary: Lock dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/dashboards/{id}/migrate:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'This endpoint retries the v1→v2 (Perses) migration on a dashboard
|
||||
still stored in the v1 schema and returns the v2-shape result. It is idempotent:
|
||||
a dashboard already in the v2 schema is returned unchanged.'
|
||||
operationId: MigrateDashboardV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Migrate dashboard to v2
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/factor_password/forgot:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -276,6 +276,10 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
|
||||
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.MigrateV2(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updatable)
|
||||
}
|
||||
|
||||
@@ -80,15 +80,6 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
fineGrainedAuthz := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureUseFineGrainedAuthz, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureUseFineGrainedAuthz.String()),
|
||||
Active: fineGrainedAuthz,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
aiObservability := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureEnableAIObservability.String()),
|
||||
@@ -107,15 +98,6 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
infraMonitoringV2 := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureUseInfraMonitoringV2, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureUseInfraMonitoringV2.String()),
|
||||
Active: infraMonitoringV2,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
if constants.IsDotMetricsEnabled {
|
||||
for idx, feature := range featureSet {
|
||||
if feature.Name == licensetypes.DotMetricsEnabled {
|
||||
|
||||
@@ -52,6 +52,8 @@ import type {
|
||||
ListDashboardsV2200,
|
||||
ListDashboardsV2Params,
|
||||
LockDashboardV2PathParameters,
|
||||
MigrateDashboardV2200,
|
||||
MigrateDashboardV2PathParameters,
|
||||
PatchDashboardV2200,
|
||||
PatchDashboardV2PathParameters,
|
||||
PinDashboardV2PathParameters,
|
||||
@@ -1804,6 +1806,85 @@ export const useLockDashboardV2 = <
|
||||
> => {
|
||||
return useMutation(getLockDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint retries the v1→v2 (Perses) migration on a dashboard still stored in the v1 schema and returns the v2-shape result. It is idempotent: a dashboard already in the v2 schema is returned unchanged.
|
||||
* @summary Migrate dashboard to v2
|
||||
*/
|
||||
export const migrateDashboardV2 = (
|
||||
{ id }: MigrateDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<MigrateDashboardV2200>({
|
||||
url: `/api/v2/dashboards/${id}/migrate`,
|
||||
method: 'POST',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getMigrateDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof migrateDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: MigrateDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof migrateDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: MigrateDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['migrateDashboardV2'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof migrateDashboardV2>>,
|
||||
{ pathParams: MigrateDashboardV2PathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return migrateDashboardV2(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type MigrateDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof migrateDashboardV2>>
|
||||
>;
|
||||
|
||||
export type MigrateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Migrate dashboard to v2
|
||||
*/
|
||||
export const useMigrateDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof migrateDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: MigrateDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof migrateDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: MigrateDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getMigrateDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.
|
||||
* @summary Get public dashboard data (v2)
|
||||
|
||||
@@ -4301,6 +4301,18 @@ export interface Querybuildertypesv5QueryEnvelopeBuilderDTO {
|
||||
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5QueryEnvelopeBuilderAIDTOType {
|
||||
builder_ai_query = 'builder_ai_query',
|
||||
}
|
||||
export interface Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
|
||||
spec?: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @enum builder_ai_query
|
||||
*/
|
||||
type: Querybuildertypesv5QueryEnvelopeBuilderAIDTOType;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -4484,6 +4496,7 @@ export interface Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO {
|
||||
|
||||
export type Querybuildertypesv5QueryEnvelopeDTO =
|
||||
| Querybuildertypesv5QueryEnvelopeBuilderDTO
|
||||
| Querybuildertypesv5QueryEnvelopeBuilderAIDTO
|
||||
| Querybuildertypesv5QueryEnvelopeFormulaDTO
|
||||
| Querybuildertypesv5QueryEnvelopeTraceOperatorDTO
|
||||
| Querybuildertypesv5QueryEnvelopePromQLDTO
|
||||
@@ -8287,6 +8300,7 @@ export interface Querybuildertypesv5QueryRangeResponseDTO {
|
||||
|
||||
export enum Querybuildertypesv5QueryTypeDTO {
|
||||
builder_query = 'builder_query',
|
||||
builder_ai_query = 'builder_ai_query',
|
||||
builder_formula = 'builder_formula',
|
||||
builder_trace_operator = 'builder_trace_operator',
|
||||
clickhouse_sql = 'clickhouse_sql',
|
||||
@@ -11164,6 +11178,17 @@ export type UnlockDashboardV2PathParameters = {
|
||||
export type LockDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type MigrateDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type MigrateDashboardV2200 = {
|
||||
data: DashboardtypesGettableDashboardV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetFeatures200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { getNavigationReferrer } from 'lib/navigation';
|
||||
import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
import { isCustomTimeRange } from 'store/globalTime';
|
||||
|
||||
export enum Events {
|
||||
UPDATE_GRAPH_VISIBILITY_STATE = 'UPDATE_GRAPH_VISIBILITY_STATE',
|
||||
UPDATE_GRAPH_MANAGER_TABLE = 'UPDATE_GRAPH_MANAGER_TABLE',
|
||||
@@ -45,155 +39,3 @@ export enum InfraMonitoringEvents {
|
||||
StatefulSet = 'statefulSet',
|
||||
Volumes = 'volumes',
|
||||
}
|
||||
|
||||
export function logInfraFilterCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
source: 'quick_filter' | 'search' | 'host_status_toggle',
|
||||
expression: string,
|
||||
extraKeys?: string[],
|
||||
): void {
|
||||
const expressionKeys = extractQueryPairs(expression?.trim() || '').map(
|
||||
(pair) => pair.key,
|
||||
);
|
||||
|
||||
if (extraKeys) {
|
||||
extraKeys.forEach((key) => expressionKeys.push(key));
|
||||
}
|
||||
|
||||
if (expressionKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void logEvent('infra_filter_customized', {
|
||||
entity_type: entityType,
|
||||
source,
|
||||
expression_keys: [...new Set(expressionKeys)],
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraMonitoringListViewedEvent(
|
||||
entity: InfraMonitoringEntity,
|
||||
): void {
|
||||
const referrer = getNavigationReferrer();
|
||||
|
||||
void logEvent('infra_list_viewed', {
|
||||
entity,
|
||||
referrer,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraTimeRangeCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
rangeLabel: string,
|
||||
): void {
|
||||
void logEvent('infra_time_range_customized', {
|
||||
entity_type: entityType,
|
||||
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraColumnCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
columnsList: string[],
|
||||
fontSize: string,
|
||||
maxLinesPerRow: number,
|
||||
source: 'list' | 'expanded',
|
||||
): void {
|
||||
void logEvent('infra_column_customized', {
|
||||
entity_type: entityType,
|
||||
columns_list: columnsList,
|
||||
font_size: fontSize,
|
||||
max_lines_per_row: maxLinesPerRow,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraColumnSortedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
columnKey: string,
|
||||
direction: 'asc' | 'desc',
|
||||
source: 'list' | 'expanded',
|
||||
): void {
|
||||
void logEvent('infra_column_sorted', {
|
||||
entity_type: entityType,
|
||||
column_key: columnKey,
|
||||
direction,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraDrawerTimeRangeCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
rangeLabel: string,
|
||||
): void {
|
||||
void logEvent('infra_drawer_time_range_customized', {
|
||||
entity_type: entityType,
|
||||
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraDrawerFilterCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
tab: 'metrics' | 'logs' | 'traces' | 'events' | 'pod_metrics',
|
||||
expression: string,
|
||||
filterSource: 'search' | 'logs',
|
||||
): void {
|
||||
const expressionKeys = extractQueryPairs(expression?.trim() || '').map(
|
||||
(pair) => pair.key,
|
||||
);
|
||||
|
||||
if (expressionKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void logEvent('infra_drawer_filter_customized', {
|
||||
entity_type: entityType,
|
||||
tab,
|
||||
expression_keys: [...new Set(expressionKeys)],
|
||||
filter_source: filterSource,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraGroupByCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
groupByKeysList: string[],
|
||||
): void {
|
||||
void logEvent('infra_group_by_customized', {
|
||||
entity_type: entityType,
|
||||
group_by_keys_list: groupByKeysList,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraDrawerTabViewedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
tab: string,
|
||||
isDefaultTab: boolean,
|
||||
): void {
|
||||
void logEvent('infra_drawer_tab_viewed', {
|
||||
entity_type: entityType,
|
||||
tab,
|
||||
is_default_tab: isDefaultTab,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraExplorerNavigatedEvent(params: {
|
||||
entityType: InfraMonitoringEntity;
|
||||
destination:
|
||||
| 'metrics_explorer'
|
||||
| 'logs_explorer'
|
||||
| 'traces_explorer'
|
||||
| 'k8s_list';
|
||||
source: 'chart_compass_icon' | 'tab_cta_button' | 'stats_card';
|
||||
tab: string;
|
||||
sourceKey: string | null;
|
||||
drawerDurationMsAtNavigation: number | null;
|
||||
}): void {
|
||||
void logEvent('infra_explorer_navigated', {
|
||||
entity_type: params.entityType,
|
||||
destination: params.destination,
|
||||
source: params.source,
|
||||
tab: params.tab,
|
||||
source_key: params.sourceKey,
|
||||
drawer_duration_ms_at_navigation: params.drawerDurationMsAtNavigation,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ export enum FeatureKeys {
|
||||
ANOMALY_DETECTION = 'anomaly_detection',
|
||||
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
|
||||
USE_JSON_BODY = 'use_json_body',
|
||||
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
|
||||
USE_INFRA_MONITORING_V2 = 'use_infra_monitoring_v2',
|
||||
ENABLE_AI_OBSERVABILITY = 'enable_ai_observability',
|
||||
ENABLE_METRICS_REDUCTION = 'enable_metrics_reduction',
|
||||
}
|
||||
|
||||
@@ -17,11 +17,7 @@ import {
|
||||
QuickFilterChangeEventData,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraFilterCustomizedEvent,
|
||||
logInfraMonitoringListViewedEvent,
|
||||
} from 'constants/events';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import K8sBaseDetails, {
|
||||
K8sDetailsFilters,
|
||||
@@ -57,6 +53,10 @@ import styles from './InfraMonitoringHosts.module.scss';
|
||||
import { ArrowUpToLine, Filter } from '@signozhq/icons';
|
||||
import { NANO_SECOND_MULTIPLIER, useGlobalTimeStore } from 'store/globalTime';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import {
|
||||
logInfraFilterCustomizedEvent,
|
||||
logInfraMonitoringListViewedEvent,
|
||||
} from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
function Hosts(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
import { logInfraFilterCustomizedEvent } from 'constants/events';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import {
|
||||
StatusFilterValue,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
import styles from './StatusFilter.module.scss';
|
||||
import { logInfraFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
const statusOptions: Array<{
|
||||
label: string;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Progress } from '@signozhq/ui/progress';
|
||||
import {
|
||||
@@ -10,6 +9,7 @@ import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/K8
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { TextNoData } from 'container/InfraMonitoringK8sV2/components';
|
||||
import { getStrokeColorForPercent } from 'container/InfraMonitoringK8sV2/components/EntityProgressBar.utils';
|
||||
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
|
||||
import {
|
||||
getHostQueryPayload,
|
||||
@@ -18,26 +18,6 @@ import {
|
||||
|
||||
import infraHostsStyles from './InfraMonitoringHosts.module.scss';
|
||||
|
||||
export function getProgressColor(percent: number): string {
|
||||
if (percent >= 90) {
|
||||
return Color.BG_SAKURA_500;
|
||||
}
|
||||
if (percent >= 60) {
|
||||
return Color.BG_AMBER_500;
|
||||
}
|
||||
return Color.BG_FOREST_500;
|
||||
}
|
||||
|
||||
export function getMemoryProgressColor(percent: number): string {
|
||||
if (percent >= 90) {
|
||||
return Color.BG_CHERRY_500;
|
||||
}
|
||||
if (percent >= 60) {
|
||||
return Color.BG_AMBER_500;
|
||||
}
|
||||
return Color.BG_FOREST_500;
|
||||
}
|
||||
|
||||
export type HostDetailMetadataConfigType =
|
||||
K8sDetailsMetadataConfig<InframonitoringtypesHostRecordDTO>;
|
||||
export const hostDetailsMetadataConfig: HostDetailMetadataConfigType[] = [
|
||||
@@ -79,7 +59,7 @@ export const hostDetailsMetadataConfig: HostDetailMetadataConfigType[] = [
|
||||
render: (value): React.ReactNode => (
|
||||
<Progress
|
||||
percent={Number(Number(value).toFixed(1))}
|
||||
strokeColor={getProgressColor(Number(value))}
|
||||
strokeColor={getStrokeColorForPercent('cpu', Number(value))}
|
||||
showInfo
|
||||
/>
|
||||
),
|
||||
@@ -90,7 +70,7 @@ export const hostDetailsMetadataConfig: HostDetailMetadataConfigType[] = [
|
||||
render: (value): React.ReactNode => (
|
||||
<Progress
|
||||
percent={Number(Number(value).toFixed(1))}
|
||||
strokeColor={getMemoryProgressColor(Number(value))}
|
||||
strokeColor={getStrokeColorForPercent('memory', Number(value))}
|
||||
showInfo
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -9,6 +9,7 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
|
||||
import { getGroupByEl } from 'container/InfraMonitoringK8sV2/Base/utils';
|
||||
import {
|
||||
EntityProgressBar,
|
||||
EntityProgressThresholds,
|
||||
ExpandButtonWrapper,
|
||||
GroupedStatusCounts,
|
||||
ValidateColumnValueWrapper,
|
||||
@@ -98,7 +99,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'hostName',
|
||||
id: INFRA_MONITORING_ATTR_KEYS.HOST_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Hostname"
|
||||
@@ -108,7 +109,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
|
||||
),
|
||||
accessorFn: (row): string => row.hostName ?? '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
@@ -168,7 +169,10 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
|
||||
{
|
||||
id: 'cpu',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/host-monitoring#cpu-usage">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/host-monitoring#cpu-usage"
|
||||
tooltip={<EntityProgressThresholds type="cpu" />}
|
||||
>
|
||||
CPU Usage
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -195,7 +199,9 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
|
||||
id: 'memory',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader
|
||||
tooltip="Excluding cache memory."
|
||||
tooltip={
|
||||
<EntityProgressThresholds type="memory" note="Excluding cache memory." />
|
||||
}
|
||||
docPath="/infrastructure-monitoring/host-monitoring#memory-usage"
|
||||
>
|
||||
Memory Usage (WSS)
|
||||
@@ -221,9 +227,12 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'diskUsage',
|
||||
id: 'disk_usage',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/host-monitoring#disk-usage">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/host-monitoring#disk-usage"
|
||||
tooltip={<EntityProgressThresholds type="disk" />}
|
||||
>
|
||||
Disk Usage
|
||||
</ColumnHeader>
|
||||
),
|
||||
|
||||
@@ -3,13 +3,14 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import styles from './ColumnHeader.module.scss';
|
||||
import cx from 'classnames';
|
||||
import { MouseEventHandler } from 'react';
|
||||
|
||||
const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
|
||||
|
||||
interface ColumnHeaderProps {
|
||||
children?: React.ReactNode;
|
||||
docPath?: string;
|
||||
tooltip?: string;
|
||||
tooltip?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -19,6 +20,9 @@ function ColumnHeader({
|
||||
tooltip,
|
||||
className,
|
||||
}: ColumnHeaderProps): JSX.Element {
|
||||
const stopPropagationHandler: MouseEventHandler = (e): void =>
|
||||
e.stopPropagation();
|
||||
|
||||
const renderContent = (): React.ReactNode => {
|
||||
if (children) {
|
||||
return children;
|
||||
@@ -30,21 +34,25 @@ function ColumnHeader({
|
||||
const renderInfoIcon = (): React.ReactNode => {
|
||||
if (docPath) {
|
||||
const tooltipTitle = tooltip || 'Not sure what this means?';
|
||||
const isJustStringTitle = typeof tooltipTitle === 'string';
|
||||
|
||||
return (
|
||||
<TooltipSimple
|
||||
arrow
|
||||
title={
|
||||
<>
|
||||
<div onClick={stopPropagationHandler}>
|
||||
{tooltipTitle}{' '}
|
||||
<a
|
||||
href={`${DOCS_BASE_URL}${docPath}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
onClick={stopPropagationHandler}
|
||||
>
|
||||
Learn more.
|
||||
{isJustStringTitle
|
||||
? 'Learn more.'
|
||||
: 'Check the documentation to learn more.'}
|
||||
</a>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.infoIcon}>
|
||||
@@ -56,7 +64,9 @@ function ColumnHeader({
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipSimple title={tooltip}>
|
||||
<TooltipSimple
|
||||
title={<div onClick={stopPropagationHandler}>{tooltip}</div>}
|
||||
>
|
||||
<div className={styles.infoIcon}>
|
||||
<Info size="md" />
|
||||
</div>
|
||||
|
||||
@@ -12,11 +12,7 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraDrawerTabViewedEvent,
|
||||
logInfraExplorerNavigatedEvent,
|
||||
} from 'constants/events';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
@@ -50,6 +46,8 @@ import { K8sBaseDetailsContentProps } from './types';
|
||||
import { getDrawerDurationMs } from './useDrawerLifecycleStore';
|
||||
|
||||
import styles from '../EntityDetailsUtils/entityDetails.module.scss';
|
||||
import { logInfraDrawerTabViewedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
|
||||
import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function K8sBaseDetailsContent<T>({
|
||||
|
||||
@@ -9,11 +9,7 @@ import TanStackTable, {
|
||||
useHiddenColumnIds,
|
||||
useTableParams,
|
||||
} from 'components/TanStackTableView';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraColumnSortedEvent,
|
||||
logInfraTimeRangeCustomizedEvent,
|
||||
} from 'constants/events';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
@@ -48,6 +44,10 @@ import { K8sInstrumentationChecksCallout } from './components/K8sInstrumentation
|
||||
|
||||
import styles from './K8sBaseList.module.scss';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
logInfraColumnSortedEvent,
|
||||
logInfraTimeRangeCustomizedEvent,
|
||||
} from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
export type K8sBaseListEmptyStateContext = {
|
||||
isError: boolean;
|
||||
@@ -128,6 +128,8 @@ export function K8sBaseList<
|
||||
|
||||
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
|
||||
rowHeight: 42,
|
||||
headerHeight: 58,
|
||||
paginationHeight: 52,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -436,16 +438,17 @@ export function K8sBaseList<
|
||||
isFetching={isFetching}
|
||||
cancelQuery={cancelQuery}
|
||||
/>
|
||||
|
||||
<K8sInstrumentationChecksCallout entity={entity} />
|
||||
|
||||
<K8sTableToolbar
|
||||
entity={entity}
|
||||
eventCategory={eventCategory}
|
||||
leftFilters={leftFilters}
|
||||
onOpenOptionsDrawer={handleOpenOptionsDrawer}
|
||||
/>
|
||||
|
||||
<div ref={containerRef} className={styles.tableContainer}>
|
||||
<K8sInstrumentationChecksCallout entity={entity} />
|
||||
|
||||
<K8sTableToolbar
|
||||
entity={entity}
|
||||
eventCategory={eventCategory}
|
||||
leftFilters={leftFilters}
|
||||
onOpenOptionsDrawer={handleOpenOptionsDrawer}
|
||||
/>
|
||||
|
||||
{isError && (
|
||||
<Typography>
|
||||
{data?.error?.toString() || 'Something went wrong'}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
--tanstack-table-resize-handle-hover-bg: var(--l1-border);
|
||||
--tanstack-table-row-height: 36px;
|
||||
|
||||
--tanstack-cell-padding-left-override: 15px;
|
||||
--tanstack-cell-padding-left-override: 26px;
|
||||
--tanstack-cell-padding-right-override: 15px;
|
||||
|
||||
& [data-hide-expanded='true'] {
|
||||
|
||||
@@ -10,19 +10,19 @@ import TanStackTable, {
|
||||
TableColumnDef,
|
||||
TanStackTableStateProvider,
|
||||
} from 'components/TanStackTableView';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { CornerDownRight } from '@signozhq/icons';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
|
||||
import { logInfraColumnSortedEvent } from 'constants/events';
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../constants';
|
||||
import {
|
||||
SelectedItemParams,
|
||||
useInfraMonitoringGroupBy,
|
||||
@@ -36,6 +36,9 @@ import { useInfraMonitoringFontSize } from './useInfraMonitoringTablePreferences
|
||||
|
||||
import styles from './K8sExpandedRow.module.scss';
|
||||
import { buildExpressionFromGroupMeta } from './utils';
|
||||
import { logInfraColumnSortedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
import { getUnstableCurrentSearchParams } from 'container/TopNav/DateTimeSelectionV2/utils/getUnstableCurrentSearchParams';
|
||||
import { QueryParams } from 'constants/query';
|
||||
|
||||
const EXPANDED_ROW_LIMIT = 10;
|
||||
|
||||
@@ -92,7 +95,6 @@ export function K8sExpandedRow<
|
||||
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
|
||||
const [, setMainOrderBy] = useInfraMonitoringOrderBy();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const urlQuery = useUrlQuery();
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -258,13 +260,26 @@ export function K8sExpandedRow<
|
||||
},
|
||||
};
|
||||
|
||||
const newUrlQuery = new URLSearchParams(urlQuery.toString());
|
||||
newUrlQuery.set(
|
||||
const searchParams = getUnstableCurrentSearchParams();
|
||||
|
||||
searchParams.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(updatedQuery)),
|
||||
);
|
||||
|
||||
safeNavigate(`${location.pathname}?${newUrlQuery.toString()}`);
|
||||
searchParams.delete(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
|
||||
searchParams.delete(INFRA_MONITORING_K8S_PARAMS_KEYS.EXPANDED);
|
||||
searchParams.delete(orderByParamKey);
|
||||
searchParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE, '1');
|
||||
|
||||
if (orderBy) {
|
||||
searchParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY,
|
||||
JSON.stringify(orderBy),
|
||||
);
|
||||
}
|
||||
|
||||
safeNavigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
};
|
||||
|
||||
const total = data?.total ?? 0;
|
||||
@@ -276,6 +291,7 @@ export function K8sExpandedRow<
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
className={styles.viewAllButton}
|
||||
data-testid="expanded-row-view-all"
|
||||
onClick={handleViewAllClick}
|
||||
prefix={<CornerDownRight size={14} />}
|
||||
>
|
||||
|
||||
@@ -2,10 +2,7 @@ import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraFilterCustomizedEvent,
|
||||
} from 'constants/events';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
@@ -24,6 +21,7 @@ import {
|
||||
import { useInfraMonitoringPageListing } from '../hooks';
|
||||
|
||||
import styles from './K8sHeader.module.scss';
|
||||
import { logInfraFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
interface K8sHeaderProps {
|
||||
controlListPrefix?: React.ReactNode;
|
||||
|
||||
@@ -4,19 +4,34 @@ import { Select } from 'antd';
|
||||
import { Download, SlidersVertical } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraGroupByCustomizedEvent,
|
||||
} from 'constants/events';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import {
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../constants';
|
||||
import {
|
||||
useInfraMonitoringGroupBy,
|
||||
useInfraMonitoringOrderBy,
|
||||
useInfraMonitoringPageListing,
|
||||
} from '../hooks';
|
||||
import { useInfraMonitoringGroupByData } from './useInfraMonitoringGroupByData';
|
||||
|
||||
import styles from './K8sTableToolbar.module.scss';
|
||||
import { logInfraGroupByCustomizedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
const NAME_COLUMN_KEYS: Set<string> = new Set([
|
||||
INFRA_MONITORING_ATTR_KEYS.HOST_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
|
||||
]);
|
||||
|
||||
interface K8sTableToolbarProps {
|
||||
entity: InfraMonitoringEntity;
|
||||
@@ -37,11 +52,17 @@ function K8sTableToolbar({
|
||||
useInfraMonitoringGroupByData(entity);
|
||||
|
||||
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
|
||||
const handleGroupByChange = useCallback(
|
||||
(value: string[]) => {
|
||||
void setCurrentPage(1);
|
||||
|
||||
if (orderBy && NAME_COLUMN_KEYS.has(orderBy.columnName)) {
|
||||
void setOrderBy(null);
|
||||
}
|
||||
|
||||
void setGroupBy(value);
|
||||
|
||||
void logEvent(InfraMonitoringEvents.GroupByChanged, {
|
||||
@@ -52,15 +73,16 @@ function K8sTableToolbar({
|
||||
|
||||
logInfraGroupByCustomizedEvent(entity, value);
|
||||
},
|
||||
[entity, eventCategory, setCurrentPage, setGroupBy],
|
||||
[entity, eventCategory, orderBy, setCurrentPage, setOrderBy, setGroupBy],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.groupByContainer}>
|
||||
<div className={styles.groupByContainer} data-testid="k8s-table-group-by">
|
||||
<div className={styles.groupByLabel}>Group by</div>
|
||||
<Select
|
||||
className={styles.groupBySelect}
|
||||
data-testid="k8s-table-group-by-select"
|
||||
loading={isLoadingGroupByFilters}
|
||||
mode="multiple"
|
||||
value={groupBy}
|
||||
|
||||
@@ -1370,4 +1370,127 @@ describe('K8sBaseList', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupBy change clears orderBy', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
|
||||
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
fetchListDataMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [{ id: 'item-1' }],
|
||||
total: 1,
|
||||
error: null,
|
||||
});
|
||||
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/infra_monitoring/checks', (_, res, ctx) =>
|
||||
res(ctx.json({ status: 'success', data: { ready: true } })),
|
||||
),
|
||||
rest.get('http://localhost/api/v1/fields/keys', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
keys: {
|
||||
resource: [{ name: 'k8s.namespace.name' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear orderBy for name columns when groupBy is changed', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: {
|
||||
// k8s.pod.name is a name column - should be cleared
|
||||
orderBy: JSON.stringify({ columnName: 'k8s.pod.name', order: 'desc' }),
|
||||
},
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('k8s-table-group-by')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open group by dropdown using testId
|
||||
const groupByContainer = screen.getByTestId('k8s-table-group-by-select');
|
||||
const groupBySelect = groupByContainer.querySelector(
|
||||
'.ant-select-selector',
|
||||
) as Element;
|
||||
await user.click(groupBySelect);
|
||||
|
||||
// Wait for options to load and click on the namespace option
|
||||
const namespaceOption = await screen.findByTitle('k8s.namespace.name');
|
||||
await user.click(namespaceOption);
|
||||
|
||||
// Verify orderBy was cleared (set to null) for name column
|
||||
await waitFor(() => {
|
||||
const orderByCalls = onUrlUpdateMock.mock.calls
|
||||
.map((call) => call[0].searchParams.get('orderBy'))
|
||||
.filter((v) => v !== undefined);
|
||||
|
||||
const hasOrderByCleared = orderByCalls.some((v) => v === null);
|
||||
expect(hasOrderByCleared).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep orderBy for non-name columns when groupBy is changed', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: {
|
||||
// cpu is NOT a name column - should be kept
|
||||
orderBy: JSON.stringify({ columnName: 'cpu', order: 'desc' }),
|
||||
},
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('k8s-table-group-by')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open group by dropdown using testId
|
||||
const groupByContainer = screen.getByTestId('k8s-table-group-by-select');
|
||||
const groupBySelect = groupByContainer.querySelector(
|
||||
'.ant-select-selector',
|
||||
) as Element;
|
||||
await user.click(groupBySelect);
|
||||
|
||||
// Wait for options to load and click on the namespace option
|
||||
const namespaceOption = await screen.findByTitle('k8s.namespace.name');
|
||||
await user.click(namespaceOption);
|
||||
|
||||
// Verify orderBy was NOT cleared for non-name column
|
||||
await waitFor(() => {
|
||||
const orderByCalls = onUrlUpdateMock.mock.calls
|
||||
.map((call) => call[0].searchParams.get('orderBy'))
|
||||
.filter((v) => v !== undefined);
|
||||
|
||||
// orderBy should never be set to null
|
||||
const hasOrderByCleared = orderByCalls.some((v) => v === null);
|
||||
expect(hasOrderByCleared).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { TableColumnDef, useColumnStore } from 'components/TanStackTableView';
|
||||
import { logInfraColumnCustomizedEvent } from 'constants/events';
|
||||
|
||||
import { InfraMonitoringEntity } from '../../constants';
|
||||
import { useInfraMonitoringTablePreferencesStore } from '../useInfraMonitoringTablePreferencesStore';
|
||||
import { useLogEventForColumnCustomized } from '../useLogEventForColumnCustomized';
|
||||
import { logInfraColumnCustomizedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
jest.mock('constants/events', () => ({
|
||||
jest.mock('container/InfraMonitoringK8sV2/Base/events', () => ({
|
||||
logInfraColumnCustomizedEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Compass } from '@signozhq/icons';
|
||||
import { TextNoData } from '../../../components/TextNoData';
|
||||
import { logInfraExplorerNavigatedEvent } from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
} from '../../../constants';
|
||||
import { getDrawerDurationMs } from '../../useDrawerLifecycleStore';
|
||||
import styles from './EntityCountsSection.module.scss';
|
||||
import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
export interface EntityCountConfig<T> {
|
||||
label: string;
|
||||
|
||||
113
frontend/src/container/InfraMonitoringK8sV2/Base/events.ts
Normal file
113
frontend/src/container/InfraMonitoringK8sV2/Base/events.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { getNavigationReferrer } from 'lib/navigation';
|
||||
import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
import { isCustomTimeRange } from 'store/globalTime';
|
||||
|
||||
export function logInfraFilterCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
source: 'quick_filter' | 'search' | 'host_status_toggle',
|
||||
expression: string,
|
||||
extraKeys?: string[],
|
||||
): void {
|
||||
const expressionKeys = extractQueryPairs(expression?.trim() || '').map(
|
||||
(pair) => pair.key,
|
||||
);
|
||||
|
||||
if (extraKeys) {
|
||||
extraKeys.forEach((key) => expressionKeys.push(key));
|
||||
}
|
||||
|
||||
if (expressionKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void logEvent('infra_filter_customized', {
|
||||
entity_type: entityType,
|
||||
source,
|
||||
expression_keys: [...new Set(expressionKeys)],
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraMonitoringListViewedEvent(
|
||||
entity: InfraMonitoringEntity,
|
||||
): void {
|
||||
const referrer = getNavigationReferrer();
|
||||
|
||||
void logEvent('infra_list_viewed', {
|
||||
entity,
|
||||
referrer,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraTimeRangeCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
rangeLabel: string,
|
||||
): void {
|
||||
void logEvent('infra_time_range_customized', {
|
||||
entity_type: entityType,
|
||||
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraColumnCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
columnsList: string[],
|
||||
fontSize: string,
|
||||
maxLinesPerRow: number,
|
||||
source: 'list' | 'expanded',
|
||||
): void {
|
||||
void logEvent('infra_column_customized', {
|
||||
entity_type: entityType,
|
||||
columns_list: columnsList,
|
||||
font_size: fontSize,
|
||||
max_lines_per_row: maxLinesPerRow,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraColumnSortedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
columnKey: string,
|
||||
direction: 'asc' | 'desc',
|
||||
source: 'list' | 'expanded',
|
||||
): void {
|
||||
void logEvent('infra_column_sorted', {
|
||||
entity_type: entityType,
|
||||
column_key: columnKey,
|
||||
direction,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraGroupByCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
groupByKeysList: string[],
|
||||
): void {
|
||||
void logEvent('infra_group_by_customized', {
|
||||
entity_type: entityType,
|
||||
group_by_keys_list: groupByKeysList,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraExplorerNavigatedEvent(params: {
|
||||
entityType: InfraMonitoringEntity;
|
||||
destination:
|
||||
| 'metrics_explorer'
|
||||
| 'logs_explorer'
|
||||
| 'traces_explorer'
|
||||
| 'k8s_list';
|
||||
source: 'chart_compass_icon' | 'tab_cta_button' | 'stats_card';
|
||||
tab: string;
|
||||
sourceKey: string | null;
|
||||
drawerDurationMsAtNavigation: number | null;
|
||||
}): void {
|
||||
void logEvent('infra_explorer_navigated', {
|
||||
entity_type: params.entityType,
|
||||
destination: params.destination,
|
||||
source: params.source,
|
||||
tab: params.tab,
|
||||
source_key: params.sourceKey,
|
||||
drawer_duration_ms_at_navigation: params.drawerDurationMsAtNavigation,
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
useColumnOrder,
|
||||
useHiddenColumnIds,
|
||||
} from 'components/TanStackTableView';
|
||||
import { logInfraColumnCustomizedEvent } from 'constants/events';
|
||||
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
useInfraMonitoringLineClamp,
|
||||
} from './useInfraMonitoringTablePreferencesStore';
|
||||
import { sortByColumnOrder } from './utils';
|
||||
import { logInfraColumnCustomizedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
interface UseEmitColumnCustomizedParams<TData> {
|
||||
entity: InfraMonitoringEntity;
|
||||
|
||||
@@ -60,7 +60,7 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'clusterName',
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Cluster Name"
|
||||
@@ -70,7 +70,7 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
|
||||
),
|
||||
accessorFn: (row): string => row.clusterName || '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SelectedItemParams } from '../hooks';
|
||||
import { formatBytes, getPodStatusItems } from '../commonUtils';
|
||||
import {
|
||||
EntityProgressBar,
|
||||
EntityProgressThresholds,
|
||||
GroupedStatusCounts,
|
||||
TextNoData,
|
||||
ValidateColumnValueWrapper,
|
||||
@@ -69,7 +70,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'daemonsetName',
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="DaemonSet Name"
|
||||
@@ -80,7 +81,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] || '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
@@ -174,7 +175,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
{
|
||||
id: 'cpu_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-req-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-req-usage-"
|
||||
tooltip={<EntityProgressThresholds type="cpu-request" />}
|
||||
>
|
||||
CPU Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -192,7 +196,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
entity={InfraMonitoringEntity.DAEMONSETS}
|
||||
attribute="CPU Request"
|
||||
>
|
||||
<EntityProgressBar value={cpuRequest} type="request" />
|
||||
<EntityProgressBar value={cpuRequest} type="cpu-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -200,7 +204,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
{
|
||||
id: 'cpu_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-limit-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-limit-usage-"
|
||||
tooltip={<EntityProgressThresholds type="cpu-limit" />}
|
||||
>
|
||||
CPU Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -217,7 +224,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
entity={InfraMonitoringEntity.DAEMONSETS}
|
||||
attribute="CPU Limit"
|
||||
>
|
||||
<EntityProgressBar value={cpuLimit} type="limit" />
|
||||
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -251,7 +258,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
{
|
||||
id: 'memory_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-req-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-req-usage-"
|
||||
tooltip={<EntityProgressThresholds type="memory-request" />}
|
||||
>
|
||||
Memory Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -269,7 +279,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
entity={InfraMonitoringEntity.DAEMONSETS}
|
||||
attribute="Memory Request"
|
||||
>
|
||||
<EntityProgressBar value={memoryRequest} type="request" />
|
||||
<EntityProgressBar value={memoryRequest} type="memory-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -277,7 +287,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
{
|
||||
id: 'memory_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-limit-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-limit-usage-"
|
||||
tooltip={<EntityProgressThresholds type="memory-limit" />}
|
||||
>
|
||||
Memory Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -294,7 +307,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
entity={InfraMonitoringEntity.DAEMONSETS}
|
||||
attribute="Memory Limit"
|
||||
>
|
||||
<EntityProgressBar value={memoryLimit} type="limit" />
|
||||
<EntityProgressBar value={memoryLimit} type="memory-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SelectedItemParams } from '../hooks';
|
||||
import { formatBytes, getPodStatusItems } from '../commonUtils';
|
||||
import {
|
||||
EntityProgressBar,
|
||||
EntityProgressThresholds,
|
||||
GroupedStatusCounts,
|
||||
TextNoData,
|
||||
ValidateColumnValueWrapper,
|
||||
@@ -70,7 +71,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'deploymentName',
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Deployment Name"
|
||||
@@ -81,7 +82,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] || '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
@@ -162,7 +163,10 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
{
|
||||
id: 'cpu_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-req-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-req-usage-"
|
||||
tooltip={<EntityProgressThresholds type="cpu-request" />}
|
||||
>
|
||||
CPU Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -180,7 +184,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
entity={InfraMonitoringEntity.DEPLOYMENTS}
|
||||
attribute="CPU Request"
|
||||
>
|
||||
<EntityProgressBar value={cpuRequest} type="request" />
|
||||
<EntityProgressBar value={cpuRequest} type="cpu-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -188,7 +192,10 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
{
|
||||
id: 'cpu_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-limit-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-limit-usage-"
|
||||
tooltip={<EntityProgressThresholds type="cpu-limit" />}
|
||||
>
|
||||
CPU Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -205,7 +212,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
entity={InfraMonitoringEntity.DEPLOYMENTS}
|
||||
attribute="CPU Limit"
|
||||
>
|
||||
<EntityProgressBar value={cpuLimit} type="limit" />
|
||||
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -238,7 +245,10 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
{
|
||||
id: 'memory_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#mem-req-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/deployments#mem-req-usage-"
|
||||
tooltip={<EntityProgressThresholds type="memory-request" />}
|
||||
>
|
||||
Memory Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -256,7 +266,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
entity={InfraMonitoringEntity.DEPLOYMENTS}
|
||||
attribute="Memory Request"
|
||||
>
|
||||
<EntityProgressBar value={memoryRequest} type="request" />
|
||||
<EntityProgressBar value={memoryRequest} type="memory-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -264,7 +274,10 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
{
|
||||
id: 'memory_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#mem-limit-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/deployments#mem-limit-usage-"
|
||||
tooltip={<EntityProgressThresholds type="memory-limit" />}
|
||||
>
|
||||
Memory Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -281,7 +294,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
entity={InfraMonitoringEntity.DEPLOYMENTS}
|
||||
attribute="Memory Limit"
|
||||
>
|
||||
<EntityProgressBar value={memoryLimit} type="limit" />
|
||||
<EntityProgressBar value={memoryLimit} type="memory-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -3,10 +3,7 @@ import { Undo } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraDrawerTimeRangeCustomizedEvent,
|
||||
} from 'constants/events';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
@@ -17,6 +14,7 @@ import {
|
||||
import { useEntityDetailsTime } from './useEntityDetailsTime';
|
||||
|
||||
import styles from './EntityDateTimeSelector.module.scss';
|
||||
import { logInfraDrawerTimeRangeCustomizedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
|
||||
|
||||
interface EntityDateTimeSelectorProps {
|
||||
eventEntity: string;
|
||||
|
||||
@@ -16,10 +16,7 @@ import {
|
||||
combineInitialAndUserExpression,
|
||||
getUserExpressionFromCombined,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraDrawerFilterCustomizedEvent,
|
||||
} from 'constants/events';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import Controls from 'container/Controls';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import LoadingContainer from 'container/InfraMonitoringK8sV2/LoadingContainer';
|
||||
@@ -41,6 +38,7 @@ import { getEntityEventsQueryPayload, isEventsKeyNotFoundError } from './utils';
|
||||
|
||||
import styles from './EntityEvents.module.scss';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { logInfraDrawerFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
|
||||
|
||||
interface EventDataType {
|
||||
key: string;
|
||||
|
||||
@@ -20,10 +20,7 @@ import {
|
||||
combineInitialAndUserExpression,
|
||||
getUserExpressionFromCombined,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraDrawerFilterCustomizedEvent,
|
||||
} from 'constants/events';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
@@ -53,6 +50,7 @@ import { isModifierKeyPressed } from 'utils/app';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { logInfraDrawerFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
|
||||
|
||||
interface Props {
|
||||
eventEntity: string;
|
||||
|
||||
@@ -2,10 +2,7 @@ import { useCallback, useMemo, useRef } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraExplorerNavigatedEvent,
|
||||
} from 'constants/events';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
@@ -35,6 +32,7 @@ import { isKeyNotFoundError } from '../utils';
|
||||
|
||||
import styles from './EntityMetrics.module.scss';
|
||||
import { MetricsTable } from './MetricsTable';
|
||||
import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
interface EntityMetricsProps<T> {
|
||||
entity: T;
|
||||
|
||||
@@ -16,10 +16,7 @@ import {
|
||||
getUserExpressionFromCombined,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraDrawerFilterCustomizedEvent,
|
||||
} from 'constants/events';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import Controls from 'container/Controls';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
@@ -41,6 +38,7 @@ import { getEntityTracesQueryPayload } from './utils';
|
||||
|
||||
import styles from './EntityTraces.module.scss';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { logInfraDrawerFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
|
||||
|
||||
interface Props {
|
||||
eventEntity: string;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
import { isCustomTimeRange } from 'store/globalTime';
|
||||
|
||||
export function logInfraDrawerTimeRangeCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
rangeLabel: string,
|
||||
): void {
|
||||
void logEvent('infra_drawer_time_range_customized', {
|
||||
entity_type: entityType,
|
||||
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraDrawerFilterCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
tab: 'metrics' | 'logs' | 'traces' | 'events' | 'pod_metrics',
|
||||
expression: string,
|
||||
filterSource: 'search' | 'logs',
|
||||
): void {
|
||||
const expressionKeys = extractQueryPairs(expression?.trim() || '').map(
|
||||
(pair) => pair.key,
|
||||
);
|
||||
|
||||
if (expressionKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void logEvent('infra_drawer_filter_customized', {
|
||||
entity_type: entityType,
|
||||
tab,
|
||||
expression_keys: [...new Set(expressionKeys)],
|
||||
filter_source: filterSource,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraDrawerTabViewedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
tab: string,
|
||||
isDefaultTab: boolean,
|
||||
): void {
|
||||
void logEvent('infra_drawer_tab_viewed', {
|
||||
entity_type: entityType,
|
||||
tab,
|
||||
is_default_tab: isDefaultTab,
|
||||
});
|
||||
}
|
||||
@@ -51,14 +51,14 @@ import {
|
||||
} from './hooks';
|
||||
|
||||
import styles from './InfraMonitoringK8s.module.scss';
|
||||
import {
|
||||
logInfraFilterCustomizedEvent,
|
||||
logInfraMonitoringListViewedEvent,
|
||||
InfraMonitoringEvents,
|
||||
} from 'constants/events';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { NANO_SECOND_MULTIPLIER, useGlobalTimeStore } from 'store/globalTime';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import {
|
||||
logInfraFilterCustomizedEvent,
|
||||
logInfraMonitoringListViewedEvent,
|
||||
} from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
export default function InfraMonitoringK8s(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SelectedItemParams } from '../hooks';
|
||||
import { formatBytes, getPodStatusItems } from '../commonUtils';
|
||||
import {
|
||||
EntityProgressBar,
|
||||
EntityProgressThresholds,
|
||||
GroupedStatusCounts,
|
||||
TextNoData,
|
||||
ValidateColumnValueWrapper,
|
||||
@@ -63,7 +64,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'jobName',
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Job Name"
|
||||
@@ -74,7 +75,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME] || '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
@@ -158,7 +159,10 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
{
|
||||
id: 'cpu_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-req-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-req-usage-"
|
||||
tooltip={<EntityProgressThresholds type="cpu-request" />}
|
||||
>
|
||||
CPU Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -176,7 +180,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
attribute="CPU Request"
|
||||
rowId={rowId}
|
||||
>
|
||||
<EntityProgressBar value={cpuRequest} type="request" />
|
||||
<EntityProgressBar value={cpuRequest} type="cpu-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -184,7 +188,10 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
{
|
||||
id: 'cpu_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-limit-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-limit-usage-"
|
||||
tooltip={<EntityProgressThresholds type="cpu-limit" />}
|
||||
>
|
||||
CPU Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -201,7 +208,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
attribute="CPU Limit"
|
||||
rowId={rowId}
|
||||
>
|
||||
<EntityProgressBar value={cpuLimit} type="limit" />
|
||||
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -234,7 +241,10 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
{
|
||||
id: 'memory_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#mem-req-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/jobs#mem-req-usage-"
|
||||
tooltip={<EntityProgressThresholds type="memory-request" />}
|
||||
>
|
||||
Memory Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -252,7 +262,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
attribute="Memory Request"
|
||||
rowId={rowId}
|
||||
>
|
||||
<EntityProgressBar value={memoryRequest} type="request" />
|
||||
<EntityProgressBar value={memoryRequest} type="memory-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -260,7 +270,10 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
{
|
||||
id: 'memory_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#mem-limit-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/jobs#mem-limit-usage-"
|
||||
tooltip={<EntityProgressThresholds type="memory-limit" />}
|
||||
>
|
||||
Memory Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -277,7 +290,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
attribute="Memory Limit"
|
||||
rowId={rowId}
|
||||
>
|
||||
<EntityProgressBar value={memoryLimit} type="limit" />
|
||||
<EntityProgressBar value={memoryLimit} type="memory-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -111,7 +111,8 @@ export const namespaceWidgetInfo = [
|
||||
{
|
||||
title: 'CPU Usage (cores)',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#cpu-usage-cores',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/namespaces/#cpu-usage-cores-1',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage (bytes)',
|
||||
|
||||
@@ -66,7 +66,7 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'namespaceName',
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Namespace Name"
|
||||
@@ -76,7 +76,7 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
|
||||
),
|
||||
accessorFn: (row): string => row.namespaceName || '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
|
||||
@@ -57,7 +57,7 @@ export const nodeWidgetInfo = [
|
||||
{
|
||||
title: 'CPU Usage (cores)',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#cpu-usage-cores',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#cpu-usage-cores-1',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage (bytes)',
|
||||
|
||||
@@ -68,7 +68,7 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'nodeName',
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Node Name"
|
||||
@@ -78,7 +78,7 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
|
||||
),
|
||||
accessorFn: (row): string => row.nodeName || '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
|
||||
@@ -67,7 +67,7 @@ export const podWidgetInfo = [
|
||||
{
|
||||
title: 'CPU Usage (cores)',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/pods/#cpu-usage-cores',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/pods/#cpu-usage-cores-1',
|
||||
},
|
||||
{
|
||||
title: 'CPU Request, Limit Utilization',
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '../commonUtils';
|
||||
import {
|
||||
EntityProgressBar,
|
||||
EntityProgressThresholds,
|
||||
GroupedStatusCounts,
|
||||
TextNoData,
|
||||
ValidateColumnValueWrapper,
|
||||
@@ -68,7 +69,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'podName',
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Pod Name"
|
||||
@@ -79,7 +80,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
@@ -96,7 +97,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string => row.podStatus,
|
||||
width: { min: 160 },
|
||||
width: { min: 250 },
|
||||
enableSort: false,
|
||||
visibilityBehavior: 'hidden-on-expand',
|
||||
cell: ({ row }): React.ReactNode => {
|
||||
@@ -175,7 +176,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
),
|
||||
accessorFn: (row): number => row.podRestarts,
|
||||
width: { min: 140 },
|
||||
enableSort: true,
|
||||
enableSort: false,
|
||||
cell: ({ value, rowId }): React.ReactNode => {
|
||||
const restarts = value as number;
|
||||
return (
|
||||
@@ -193,7 +194,10 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
{
|
||||
id: 'cpu_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#cpu-req-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/pods#cpu-req-usage-"
|
||||
tooltip={<EntityProgressThresholds type="cpu-request" />}
|
||||
>
|
||||
CPU Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -210,7 +214,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
entity={InfraMonitoringEntity.PODS}
|
||||
attribute="CPU Request"
|
||||
>
|
||||
<EntityProgressBar value={cpuRequest} type="request" />
|
||||
<EntityProgressBar value={cpuRequest} type="cpu-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -218,7 +222,10 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
{
|
||||
id: 'cpu_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#cpu-limit-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/pods#cpu-limit-usage-"
|
||||
tooltip={<EntityProgressThresholds type="cpu-limit" />}
|
||||
>
|
||||
CPU Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -234,7 +241,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
entity={InfraMonitoringEntity.PODS}
|
||||
attribute="CPU Limit"
|
||||
>
|
||||
<EntityProgressBar value={cpuLimit} type="limit" />
|
||||
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -266,7 +273,10 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
{
|
||||
id: 'memory_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#mem-req-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/pods#mem-req-usage-"
|
||||
tooltip={<EntityProgressThresholds type="memory-request" />}
|
||||
>
|
||||
Memory Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -283,7 +293,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
entity={InfraMonitoringEntity.PODS}
|
||||
attribute="Memory Request"
|
||||
>
|
||||
<EntityProgressBar value={memoryRequest} type="request" />
|
||||
<EntityProgressBar value={memoryRequest} type="memory-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -291,7 +301,10 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
{
|
||||
id: 'memory_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#mem-limit-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/pods#mem-limit-usage-"
|
||||
tooltip={<EntityProgressThresholds type="memory-limit" />}
|
||||
>
|
||||
Memory Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -307,7 +320,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
entity={InfraMonitoringEntity.PODS}
|
||||
attribute="Memory Limit"
|
||||
>
|
||||
<EntityProgressBar value={memoryLimit} type="limit" />
|
||||
<EntityProgressBar value={memoryLimit} type="memory-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SelectedItemParams } from '../hooks';
|
||||
import { formatBytes, getPodStatusItems } from '../commonUtils';
|
||||
import {
|
||||
EntityProgressBar,
|
||||
EntityProgressThresholds,
|
||||
GroupedStatusCounts,
|
||||
TextNoData,
|
||||
ValidateColumnValueWrapper,
|
||||
@@ -70,7 +71,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'statefulsetName',
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="StatefulSet Name"
|
||||
@@ -81,7 +82,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME] || '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
@@ -165,7 +166,10 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
{
|
||||
id: 'cpu_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-req-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-req-usage-"
|
||||
tooltip={<EntityProgressThresholds type="cpu-request" />}
|
||||
>
|
||||
CPU Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -183,7 +187,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
entity={InfraMonitoringEntity.STATEFULSETS}
|
||||
attribute="CPU Request"
|
||||
>
|
||||
<EntityProgressBar value={cpuRequest} type="request" />
|
||||
<EntityProgressBar value={cpuRequest} type="cpu-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -191,7 +195,10 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
{
|
||||
id: 'cpu_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-limit-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-limit-usage-"
|
||||
tooltip={<EntityProgressThresholds type="cpu-limit" />}
|
||||
>
|
||||
CPU Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -208,7 +215,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
entity={InfraMonitoringEntity.STATEFULSETS}
|
||||
attribute="CPU Limit"
|
||||
>
|
||||
<EntityProgressBar value={cpuLimit} type="limit" />
|
||||
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -242,7 +249,10 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
{
|
||||
id: 'memory_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-req-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-req-usage-"
|
||||
tooltip={<EntityProgressThresholds type="memory-request" />}
|
||||
>
|
||||
Memory Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -260,7 +270,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
entity={InfraMonitoringEntity.STATEFULSETS}
|
||||
attribute="Memory Request"
|
||||
>
|
||||
<EntityProgressBar value={memoryRequest} type="request" />
|
||||
<EntityProgressBar value={memoryRequest} type="memory-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -268,7 +278,10 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
{
|
||||
id: 'memory_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-limit-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-limit-usage-"
|
||||
tooltip={<EntityProgressThresholds type="memory-limit" />}
|
||||
>
|
||||
Memory Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -285,7 +298,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
entity={InfraMonitoringEntity.STATEFULSETS}
|
||||
attribute="Memory Limit"
|
||||
>
|
||||
<EntityProgressBar value={memoryLimit} type="limit" />
|
||||
<EntityProgressBar value={memoryLimit} type="memory-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -64,7 +64,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pvcName',
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="PVC Name"
|
||||
@@ -74,7 +74,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
|
||||
),
|
||||
accessorFn: (row): string => row.persistentVolumeClaimName || '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
@@ -195,7 +195,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'inodesUsed',
|
||||
id: 'inodes_used',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/volumes#volume-inodes-used">
|
||||
Inodes Used
|
||||
@@ -219,7 +219,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'inodesFree',
|
||||
id: 'inodes_free',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/volumes#volume-inodes-free">
|
||||
Inodes Free
|
||||
|
||||
@@ -26,48 +26,6 @@ export function formatBytes(bytes: number, decimals = 2): string {
|
||||
return `${parseFloat((bytes / k ** i).toFixed(decimals))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns stroke color for request utilization parameters according to current value
|
||||
*/
|
||||
export function getStrokeColorForRequestUtilization(value: number): string {
|
||||
const percent = Number((value * 100).toFixed(1));
|
||||
// Orange
|
||||
if (percent <= 50) {
|
||||
return Color.BG_AMBER_500;
|
||||
}
|
||||
// Green
|
||||
if (percent > 50 && percent <= 100) {
|
||||
return Color.BG_FOREST_500;
|
||||
}
|
||||
// Regular Red
|
||||
if (percent > 100 && percent <= 150) {
|
||||
return Color.BG_SAKURA_500;
|
||||
}
|
||||
// Dark Red
|
||||
return Color.BG_CHERRY_600;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns stroke color for limit utilization parameters according to current value
|
||||
*/
|
||||
export function getStrokeColorForLimitUtilization(value: number): string {
|
||||
const percent = Number((value * 100).toFixed(1));
|
||||
// Green
|
||||
if (percent <= 60) {
|
||||
return Color.BG_FOREST_500;
|
||||
}
|
||||
// Yellow
|
||||
if (percent > 60 && percent <= 80) {
|
||||
return Color.BG_AMBER_200;
|
||||
}
|
||||
// Orange
|
||||
if (percent > 80 && percent <= 95) {
|
||||
return Color.BG_AMBER_500;
|
||||
}
|
||||
// Red
|
||||
return Color.BG_SAKURA_500;
|
||||
}
|
||||
|
||||
export const POD_STATUS_COLORS: Record<
|
||||
InframonitoringtypesPodStatusDTO,
|
||||
BadgeColor
|
||||
|
||||
@@ -1,35 +1,11 @@
|
||||
import { Progress } from '@signozhq/ui/progress';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import {
|
||||
getMemoryProgressColor,
|
||||
getProgressColor,
|
||||
} from 'container/InfraMonitoringHostsV2/constants';
|
||||
|
||||
import {
|
||||
getStrokeColorForLimitUtilization,
|
||||
getStrokeColorForRequestUtilization,
|
||||
} from '../commonUtils';
|
||||
|
||||
import styles from './EntityProgressBar.module.scss';
|
||||
|
||||
type EntityProgressBarType = 'request' | 'limit' | 'cpu' | 'memory' | 'disk';
|
||||
|
||||
function getStrokeColor(type: EntityProgressBarType, value: number): string {
|
||||
switch (type) {
|
||||
case 'limit':
|
||||
return getStrokeColorForLimitUtilization(value);
|
||||
case 'request':
|
||||
return getStrokeColorForRequestUtilization(value);
|
||||
case 'cpu':
|
||||
return getProgressColor(Number((value * 100).toFixed(1)));
|
||||
case 'memory':
|
||||
return getMemoryProgressColor(Number((value * 100).toFixed(1)));
|
||||
case 'disk':
|
||||
return getProgressColor(Number((value * 100).toFixed(1)));
|
||||
default:
|
||||
return getStrokeColorForRequestUtilization(value);
|
||||
}
|
||||
}
|
||||
import {
|
||||
EntityProgressBarType,
|
||||
getStrokeColor,
|
||||
} from './EntityProgressBar.utils';
|
||||
|
||||
export function EntityProgressBar({
|
||||
value,
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
|
||||
export type EntityProgressBarType =
|
||||
| 'cpu-request'
|
||||
| 'cpu-limit'
|
||||
| 'memory-request'
|
||||
| 'memory-limit'
|
||||
| 'cpu'
|
||||
| 'memory'
|
||||
| 'disk';
|
||||
|
||||
export interface EntityProgressThreshold {
|
||||
matches: (percent: number) => boolean;
|
||||
color: string;
|
||||
range: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const CPU_REQUEST_THRESHOLDS: EntityProgressThreshold[] = [
|
||||
{
|
||||
matches: (percent): boolean => percent <= 50,
|
||||
color: Color.BG_AMBER_500,
|
||||
range: '≤ 50%',
|
||||
label: 'Over-requested',
|
||||
description:
|
||||
'CPU usage is at most half of the request. The rest of the request stays reserved on the node.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent <= 100,
|
||||
color: Color.BG_FOREST_500,
|
||||
range: '> 50% - 100%',
|
||||
label: 'Right-sized',
|
||||
description: 'CPU usage is close to the request and stays within it.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent <= 150,
|
||||
color: Color.BG_SAKURA_500,
|
||||
range: '> 100% - 150%',
|
||||
label: 'Over request',
|
||||
description:
|
||||
'CPU usage is above the request. The extra CPU is not guaranteed and depends on spare node capacity.',
|
||||
},
|
||||
{
|
||||
matches: (): boolean => true,
|
||||
color: Color.BG_CHERRY_600,
|
||||
range: '> 150%',
|
||||
label: 'Request badly undersized',
|
||||
description:
|
||||
'CPU usage is more than 1.5x the request, so most of the CPU in use is not guaranteed.',
|
||||
},
|
||||
];
|
||||
|
||||
const CPU_LIMIT_THRESHOLDS: EntityProgressThreshold[] = [
|
||||
{
|
||||
matches: (percent): boolean => percent <= 60,
|
||||
color: Color.BG_FOREST_500,
|
||||
range: '≤ 60%',
|
||||
label: 'Healthy',
|
||||
description: 'CPU usage is well below the limit.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent <= 80,
|
||||
color: Color.BG_AMBER_200,
|
||||
range: '> 60% - 80%',
|
||||
label: 'Watch',
|
||||
description: 'CPU usage is approaching the limit.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent <= 95,
|
||||
color: Color.BG_AMBER_500,
|
||||
range: '> 80% - 95%',
|
||||
label: 'Near limit',
|
||||
description:
|
||||
'CPU usage is close to the limit. Usage above the limit is throttled.',
|
||||
},
|
||||
{
|
||||
matches: (): boolean => true,
|
||||
color: Color.BG_SAKURA_500,
|
||||
range: '> 95%',
|
||||
label: 'At limit',
|
||||
description:
|
||||
'CPU usage is at the limit, so the container is likely being throttled.',
|
||||
},
|
||||
];
|
||||
|
||||
const MEMORY_REQUEST_THRESHOLDS: EntityProgressThreshold[] = [
|
||||
{
|
||||
matches: (percent): boolean => percent <= 50,
|
||||
color: Color.BG_AMBER_500,
|
||||
range: '≤ 50%',
|
||||
label: 'Over-requested',
|
||||
description:
|
||||
'Memory usage is at most half of the request. The rest of the request stays reserved on the node.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent <= 100,
|
||||
color: Color.BG_FOREST_500,
|
||||
range: '> 50% - 100%',
|
||||
label: 'Right-sized',
|
||||
description: 'Memory usage is close to the request and stays within it.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent <= 150,
|
||||
color: Color.BG_SAKURA_500,
|
||||
range: '> 100% - 150%',
|
||||
label: 'Over request',
|
||||
description:
|
||||
'Memory usage is above the request. The extra memory is not guaranteed and is reclaimed first under node memory pressure.',
|
||||
},
|
||||
{
|
||||
matches: (): boolean => true,
|
||||
color: Color.BG_CHERRY_600,
|
||||
range: '> 150%',
|
||||
label: 'Request badly undersized',
|
||||
description:
|
||||
'Memory usage is more than 1.5x the request, so most of the memory in use is not guaranteed.',
|
||||
},
|
||||
];
|
||||
|
||||
const MEMORY_LIMIT_THRESHOLDS: EntityProgressThreshold[] = [
|
||||
{
|
||||
matches: (percent): boolean => percent <= 60,
|
||||
color: Color.BG_FOREST_500,
|
||||
range: '≤ 60%',
|
||||
label: 'Healthy',
|
||||
description: 'Memory usage is well below the limit.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent <= 80,
|
||||
color: Color.BG_AMBER_200,
|
||||
range: '> 60% - 80%',
|
||||
label: 'Watch',
|
||||
description: 'Memory usage is approaching the limit.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent <= 95,
|
||||
color: Color.BG_AMBER_500,
|
||||
range: '> 80% - 95%',
|
||||
label: 'Near limit',
|
||||
description:
|
||||
'Memory usage is close to the limit. Unlike CPU, memory is not throttled: reaching the limit ends in an OOM kill.',
|
||||
},
|
||||
{
|
||||
matches: (): boolean => true,
|
||||
color: Color.BG_SAKURA_500,
|
||||
range: '> 95%',
|
||||
label: 'At limit',
|
||||
description:
|
||||
'Memory usage is at the limit, so an OOM kill and container restart are likely.',
|
||||
},
|
||||
];
|
||||
|
||||
const CPU_THRESHOLDS: EntityProgressThreshold[] = [
|
||||
{
|
||||
matches: (percent): boolean => percent < 60,
|
||||
color: Color.BG_FOREST_500,
|
||||
range: '< 60%',
|
||||
label: 'Healthy',
|
||||
description: 'CPU usage is well below the available capacity.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent < 90,
|
||||
color: Color.BG_AMBER_500,
|
||||
range: '60% - 89.9%',
|
||||
label: 'Elevated',
|
||||
description: 'CPU usage is high relative to the available capacity.',
|
||||
},
|
||||
{
|
||||
matches: (): boolean => true,
|
||||
color: Color.BG_SAKURA_500,
|
||||
range: '≥ 90%',
|
||||
label: 'Critical',
|
||||
description: 'CPU usage is close to the available capacity.',
|
||||
},
|
||||
];
|
||||
|
||||
const MEMORY_THRESHOLDS: EntityProgressThreshold[] = [
|
||||
{
|
||||
matches: (percent): boolean => percent < 60,
|
||||
color: Color.BG_FOREST_500,
|
||||
range: '< 60%',
|
||||
label: 'Healthy',
|
||||
description: 'Memory usage is well below the available capacity.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent < 90,
|
||||
color: Color.BG_AMBER_500,
|
||||
range: '60% - 89.9%',
|
||||
label: 'Elevated',
|
||||
description: 'Memory usage is high relative to the available capacity.',
|
||||
},
|
||||
{
|
||||
matches: (): boolean => true,
|
||||
color: Color.BG_CHERRY_500,
|
||||
range: '≥ 90%',
|
||||
label: 'Critical',
|
||||
description:
|
||||
'Memory usage is close to the available capacity. Unlike CPU, memory is not throttled: running out ends in an OOM kill.',
|
||||
},
|
||||
];
|
||||
|
||||
const DISK_THRESHOLDS: EntityProgressThreshold[] = [
|
||||
{
|
||||
matches: (percent): boolean => percent < 60,
|
||||
color: Color.BG_FOREST_500,
|
||||
range: '< 60%',
|
||||
label: 'Healthy',
|
||||
description: 'Most of the volume is still free.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent < 90,
|
||||
color: Color.BG_AMBER_500,
|
||||
range: '60% - 89.9%',
|
||||
label: 'Elevated',
|
||||
description: 'Used space is high relative to the volume capacity.',
|
||||
},
|
||||
{
|
||||
matches: (): boolean => true,
|
||||
color: Color.BG_SAKURA_500,
|
||||
range: '≥ 90%',
|
||||
label: 'Critical',
|
||||
description: 'The volume is nearly full. Writes fail once no space is left.',
|
||||
},
|
||||
];
|
||||
|
||||
export const THRESHOLDS_BY_TYPE: Record<
|
||||
EntityProgressBarType,
|
||||
EntityProgressThreshold[]
|
||||
> = {
|
||||
'cpu-request': CPU_REQUEST_THRESHOLDS,
|
||||
'cpu-limit': CPU_LIMIT_THRESHOLDS,
|
||||
'memory-request': MEMORY_REQUEST_THRESHOLDS,
|
||||
'memory-limit': MEMORY_LIMIT_THRESHOLDS,
|
||||
cpu: CPU_THRESHOLDS,
|
||||
memory: MEMORY_THRESHOLDS,
|
||||
disk: DISK_THRESHOLDS,
|
||||
};
|
||||
|
||||
export function getStrokeColorForPercent(
|
||||
type: EntityProgressBarType,
|
||||
percent: number,
|
||||
): string {
|
||||
const thresholds = THRESHOLDS_BY_TYPE[type];
|
||||
const match = thresholds.find((threshold) => threshold.matches(percent));
|
||||
return (match ?? thresholds[thresholds.length - 1]).color;
|
||||
}
|
||||
|
||||
export function getStrokeColor(
|
||||
type: EntityProgressBarType,
|
||||
value: number,
|
||||
): string {
|
||||
return getStrokeColorForPercent(type, Number((value * 100).toFixed(1)));
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
max-width: 320px;
|
||||
text-align: left;
|
||||
text-wrap: wrap;
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
|
||||
.threshold {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 3px;
|
||||
border-radius: 1px;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--ept-color);
|
||||
}
|
||||
|
||||
.thresholdBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.thresholdHeading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.range {
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import {
|
||||
EntityProgressBarType,
|
||||
THRESHOLDS_BY_TYPE,
|
||||
} from './EntityProgressBar.utils';
|
||||
import styles from './EntityProgressThresholds.module.scss';
|
||||
|
||||
interface EntityProgressThresholdsProps {
|
||||
type: EntityProgressBarType;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export function EntityProgressThresholds({
|
||||
type,
|
||||
note,
|
||||
}: EntityProgressThresholdsProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
data-testid={`entity-progress-thresholds-${type}`}
|
||||
>
|
||||
{note && (
|
||||
<Typography.Text as="p" size="small">
|
||||
{note}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{THRESHOLDS_BY_TYPE[type].map((threshold) => (
|
||||
<div key={threshold.range} className={styles.threshold}>
|
||||
<span
|
||||
className={styles.swatch}
|
||||
style={{ '--ept-color': threshold.color } as React.CSSProperties}
|
||||
/>
|
||||
<div className={styles.thresholdBody}>
|
||||
<div className={styles.thresholdHeading}>
|
||||
<Typography.Text as="span" size="small" weight="medium">
|
||||
{threshold.label}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
color="muted"
|
||||
className={styles.range}
|
||||
>
|
||||
{threshold.range}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text as="p" size="small" color="muted">
|
||||
{threshold.description}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { THRESHOLDS_BY_TYPE } from '../EntityProgressBar.utils';
|
||||
import { EntityProgressThresholds } from '../EntityProgressThresholds';
|
||||
|
||||
describe('EntityProgressThresholds', () => {
|
||||
it('renders every threshold band for the given type', () => {
|
||||
render(<EntityProgressThresholds type="cpu-limit" />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('entity-progress-thresholds-cpu-limit'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
THRESHOLDS_BY_TYPE['cpu-limit'].forEach((threshold) => {
|
||||
expect(screen.getByText(threshold.label)).toBeInTheDocument();
|
||||
expect(screen.getByText(threshold.range)).toBeInTheDocument();
|
||||
expect(screen.getByText(threshold.description)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the note above the threshold bands when provided', () => {
|
||||
render(
|
||||
<EntityProgressThresholds type="memory" note="Excluding cache memory." />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Excluding cache memory.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
export { EntityProgressBar } from './EntityProgressBar';
|
||||
export { EntityProgressThresholds } from './EntityProgressThresholds';
|
||||
export { ValidateColumnValueWrapper } from './ValidateColumnValueWrapper';
|
||||
export { ExpandButtonWrapper } from './ExpandButtonWrapper';
|
||||
export {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { defaultFeatureFlags, render, screen } from 'tests/test-utils';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import {
|
||||
invalidLicense,
|
||||
setupAuthzAdmin,
|
||||
@@ -59,23 +58,6 @@ function renderEditPage(
|
||||
|
||||
describe('CreateEditRolePage - Feature Gate', () => {
|
||||
describe('create mode - feature disabled', () => {
|
||||
it('shows error when fine-grained authz flag is inactive', async () => {
|
||||
renderCreatePage({
|
||||
featureFlags: defaultFeatureFlags.map((f) =>
|
||||
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
|
||||
? { ...f, active: false }
|
||||
: f,
|
||||
),
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('feature-gate-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/Custom roles feature is not available/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when license is invalid', async () => {
|
||||
renderCreatePage({ activeLicense: invalidLicense });
|
||||
|
||||
@@ -113,23 +95,6 @@ describe('CreateEditRolePage - Feature Gate', () => {
|
||||
const ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
|
||||
const ROLE_NAME = 'test-role';
|
||||
|
||||
it('shows error when fine-grained authz flag is inactive', async () => {
|
||||
renderEditPage(ROLE_ID, ROLE_NAME, {
|
||||
featureFlags: defaultFeatureFlags.map((f) =>
|
||||
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
|
||||
? { ...f, active: false }
|
||||
: f,
|
||||
),
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('feature-gate-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/Custom roles feature is not available/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when license is invalid', async () => {
|
||||
renderEditPage(ROLE_ID, ROLE_NAME, { activeLicense: invalidLicense });
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { defaultFeatureFlags, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
invalidLicense,
|
||||
setupAuthzAdmin,
|
||||
@@ -33,26 +32,6 @@ describe('ViewRolePage - Feature Gate', () => {
|
||||
});
|
||||
|
||||
describe('feature disabled', () => {
|
||||
it('shows error when fine-grained authz flag is inactive', async () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
appContextOverrides: {
|
||||
featureFlags: defaultFeatureFlags.map((f) =>
|
||||
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
|
||||
? { ...f, active: false }
|
||||
: f,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('feature-gate-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/Custom roles feature is not available/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when license is invalid', async () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
|
||||
@@ -4,13 +4,7 @@ import {
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
defaultFeatureFlags,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
} from 'tests/test-utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import {
|
||||
invalidLicense,
|
||||
setupAuthzAdmin,
|
||||
@@ -191,30 +185,6 @@ describe('RolesSettings', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('hides the create button and disables row clicks when fine-grained authz flag is inactive', async () => {
|
||||
render(<RolesSettings />, undefined, {
|
||||
appContextOverrides: {
|
||||
featureFlags: defaultFeatureFlags.map((f) =>
|
||||
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
|
||||
? { ...f, active: false }
|
||||
: f,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /custom role/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const rows = document.querySelectorAll('.roles-table-row');
|
||||
rows.forEach((row) => {
|
||||
expect(row).not.toHaveClass('roles-table-row--clickable');
|
||||
expect(row.getAttribute('role')).not.toBe('button');
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the create button and disables row clicks when license is not valid', async () => {
|
||||
render(<RolesSettings />, undefined, {
|
||||
appContextOverrides: { activeLicense: invalidLicense },
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
* This was introduced to fix a sync bug between Nuqs and react-router-dom
|
||||
*
|
||||
* We are using the wrong adapter for nuqs because the correct one only supports v6/v7,
|
||||
* and we are at version v5. This causes the nuqs/react-router-dom to be out of sync.
|
||||
* and we are at version v5. Nuqs writes params straight to the History API, which
|
||||
* react-router v5 never observes, so `useLocation().search` (and `useUrlQuery()`) can
|
||||
* be several nuqs updates behind the real URL.
|
||||
*
|
||||
* We can revert this commit once we migrate react-router-dom to v6, or once we migrate
|
||||
* to DateTimeSelectionV3
|
||||
* Use this whenever you need to build a navigation target on top of the current
|
||||
* params, otherwise stale values get republished and nuqs adopts them back on its
|
||||
* next flush (it snapshots `window.location.search`).
|
||||
*
|
||||
* We can revert this once we migrate react-router-dom to v6.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
export function useIsInfraMonitoringV2(): boolean {
|
||||
const { featureFlags } = useAppContext();
|
||||
return Boolean(
|
||||
featureFlags?.find(
|
||||
(flag) => flag.name === FeatureKeys.USE_INFRA_MONITORING_V2,
|
||||
)?.active,
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { LicenseStatus } from 'types/api/licensesV3/getActive';
|
||||
|
||||
@@ -6,22 +5,12 @@ export const useRolesFeatureGate = (): {
|
||||
isRolesEnabled: boolean;
|
||||
isLoading: boolean;
|
||||
} => {
|
||||
const {
|
||||
activeLicense,
|
||||
featureFlags,
|
||||
isFetchingActiveLicense,
|
||||
isFetchingFeatureFlags,
|
||||
} = useAppContext();
|
||||
const { activeLicense, isFetchingActiveLicense } = useAppContext();
|
||||
|
||||
const isValidLicense = activeLicense?.status === LicenseStatus.VALID;
|
||||
const isFineGrainedAuthzEnabled =
|
||||
featureFlags?.find((f) => f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ)
|
||||
?.active ?? false;
|
||||
|
||||
return {
|
||||
isRolesEnabled: isValidLicense && isFineGrainedAuthzEnabled,
|
||||
isLoading:
|
||||
(isFetchingActiveLicense && !activeLicense) ||
|
||||
(isFetchingFeatureFlags && !featureFlags),
|
||||
isRolesEnabled: isValidLicense,
|
||||
isLoading: isFetchingActiveLicense && !activeLicense,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import getMinAgo from './getStartAndEndTime/getMinAgo';
|
||||
|
||||
const validCustomTimeRegex = /^(\d+)([mhdw])$/;
|
||||
const validCustomTimeRegex = /^(\d+)(months?|[mhdw])$/;
|
||||
|
||||
export const isValidShortHandDateTimeFormat = (time: string): boolean =>
|
||||
validCustomTimeRegex.test(time);
|
||||
|
||||
@@ -52,12 +52,12 @@ const makeDashboard = (
|
||||
...overrides,
|
||||
}) as unknown as DashboardListItem;
|
||||
|
||||
const renderRow = (dashboard: DashboardListItem): void => {
|
||||
const renderRow = (dashboard: DashboardListItem, canEdit = true): void => {
|
||||
render(
|
||||
<DashboardRow
|
||||
dashboard={dashboard}
|
||||
index={0}
|
||||
canEdit
|
||||
canEdit={canEdit}
|
||||
showUpdatedAt={false}
|
||||
showUpdatedBy={false}
|
||||
/>,
|
||||
@@ -105,6 +105,19 @@ describe('DashboardRow', () => {
|
||||
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('legacy-dashboard-id')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('legacy-dashboard-retry-migration'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('withholds the retry action from a row the user cannot edit', async () => {
|
||||
renderRow(makeDashboard({ legacy: true }), false);
|
||||
|
||||
await userEvent.click(screen.getByTestId('dashboard-title-0'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('legacy-dashboard-retry-migration'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -252,6 +252,7 @@ function DashboardRow({
|
||||
open={isLegacyDialogOpen}
|
||||
dashboardId={id}
|
||||
dashboardName={name}
|
||||
canEdit={canEdit}
|
||||
onClose={(): void => setIsLegacyDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -18,19 +18,30 @@ jest.mock('container/Integrations/utils', () => ({
|
||||
handleContactSupport: (isCloud: boolean): void => mockContactSupport(isCloud),
|
||||
}));
|
||||
|
||||
const mockRetryMigration = jest.fn();
|
||||
let isMigrating = false;
|
||||
jest.mock('../../hooks/useRetryMigration', () => ({
|
||||
useRetryMigration: (): {
|
||||
retryMigration: jest.Mock;
|
||||
isMigrating: boolean;
|
||||
} => ({ retryMigration: mockRetryMigration, isMigrating }),
|
||||
}));
|
||||
|
||||
const DASHBOARD_ID = '0f9a1b2c-3d4e-5f6a-7b8c-9d0e1f2a3b4c';
|
||||
|
||||
describe('LegacyDashboardDialog', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
isMigrating = false;
|
||||
});
|
||||
|
||||
const setup = (open = true): void => {
|
||||
const setup = ({ open = true, canEdit = true } = {}): void => {
|
||||
render(
|
||||
<LegacyDashboardDialog
|
||||
open={open}
|
||||
dashboardId={DASHBOARD_ID}
|
||||
dashboardName="My Legacy Dashboard"
|
||||
canEdit={canEdit}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -57,8 +68,31 @@ describe('LegacyDashboardDialog', () => {
|
||||
expect(mockContactSupport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries the migration for the dashboard', async () => {
|
||||
setup();
|
||||
await userEvent.click(screen.getByTestId('legacy-dashboard-retry-migration'));
|
||||
expect(mockRetryMigration).toHaveBeenCalledWith(DASHBOARD_ID);
|
||||
});
|
||||
|
||||
it('blocks retry and close while the migration is in flight', () => {
|
||||
isMigrating = true;
|
||||
setup();
|
||||
expect(screen.getByTestId('legacy-dashboard-retry-migration')).toBeDisabled();
|
||||
expect(screen.getByTestId('legacy-dashboard-close')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('offers only the support path without edit access', () => {
|
||||
setup({ canEdit: false });
|
||||
expect(
|
||||
screen.queryByTestId('legacy-dashboard-retry-migration'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('legacy-dashboard-contact-support'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
setup(false);
|
||||
setup({ open: false });
|
||||
expect(screen.queryByTestId('legacy-dashboard-id')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ArrowUpRight, Copy } from '@signozhq/icons';
|
||||
import { ArrowUpRight, Copy, RotateCw } from '@signozhq/icons';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -9,28 +9,34 @@ import { handleContactSupport } from 'container/Integrations/utils';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
|
||||
import { useRetryMigration } from '../../hooks/useRetryMigration';
|
||||
|
||||
import styles from './LegacyDashboardDialog.module.scss';
|
||||
|
||||
interface LegacyDashboardDialogProps {
|
||||
open: boolean;
|
||||
dashboardId: string;
|
||||
dashboardName: string;
|
||||
canEdit: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explains why a legacy (pre-v2) dashboard can't be opened in the new experience
|
||||
* and hands the user the dashboard ID to share with support. Legacy rows are
|
||||
* surfaced by the list API with `legacy: true` but have no v2 spec to render.
|
||||
* and offers to re-run the migration. Legacy rows are surfaced by the list API
|
||||
* with `legacy: true` but have no v2 spec to render. Retrying needs edit access,
|
||||
* so viewers only get the dashboard ID to share with support.
|
||||
*/
|
||||
function LegacyDashboardDialog({
|
||||
open,
|
||||
dashboardId,
|
||||
dashboardName,
|
||||
canEdit,
|
||||
onClose,
|
||||
}: LegacyDashboardDialogProps): JSX.Element {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
const { retryMigration, isMigrating } = useRetryMigration(onClose);
|
||||
|
||||
const onCopyId = (): void => {
|
||||
copyToClipboard(dashboardId);
|
||||
@@ -49,6 +55,14 @@ function LegacyDashboardDialog({
|
||||
});
|
||||
};
|
||||
|
||||
const onRetryMigration = (): void => {
|
||||
retryMigration(dashboardId);
|
||||
void logEvent(DashboardListEvents.LegacyDialogAction, {
|
||||
action: 'retryMigration',
|
||||
dashboardId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title="This dashboard isn't available in the new experience"
|
||||
@@ -65,14 +79,15 @@ function LegacyDashboardDialog({
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="md"
|
||||
disabled={isMigrating}
|
||||
onClick={onClose}
|
||||
testId="legacy-dashboard-close"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
variant={canEdit ? 'outlined' : 'solid'}
|
||||
color={canEdit ? 'secondary' : 'primary'}
|
||||
size="md"
|
||||
suffix={<ArrowUpRight size={14} />}
|
||||
onClick={onContactSupport}
|
||||
@@ -80,6 +95,20 @@ function LegacyDashboardDialog({
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
prefix={<RotateCw size={14} />}
|
||||
disabled={isMigrating}
|
||||
loading={isMigrating}
|
||||
onClick={onRetryMigration}
|
||||
testId="legacy-dashboard-retry-migration"
|
||||
>
|
||||
Retry migration
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -87,8 +116,10 @@ function LegacyDashboardDialog({
|
||||
<Typography.Text className={styles.description}>
|
||||
<strong>{dashboardName || 'This dashboard'}</strong> hasn't been
|
||||
migrated to the new dashboard experience yet, so it can't be opened
|
||||
here. Share the dashboard ID below with support and we'll help you
|
||||
move it over.
|
||||
here.{' '}
|
||||
{canEdit
|
||||
? "Retrying the migration often works once we've handled the case that blocked it. If it still fails, share the dashboard ID below with support."
|
||||
: "Share the dashboard ID below with support and we'll help you move it over."}
|
||||
</Typography.Text>
|
||||
|
||||
<div className={styles.idField}>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
invalidateListDashboardsForUserV2,
|
||||
useMigrateDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
|
||||
import { useRetryMigration } from '../useRetryMigration';
|
||||
|
||||
jest.mock('react-query', () => ({
|
||||
useQueryClient: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useMigrateDashboardV2: jest.fn(),
|
||||
invalidateListDashboardsForUserV2: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const queryClient = { invalidateQueries: jest.fn() };
|
||||
const mockMutate = jest.fn();
|
||||
const onMigrated = jest.fn();
|
||||
|
||||
type MutationHandlers = {
|
||||
onSuccess: () => Promise<void>;
|
||||
onError: (error: unknown) => void;
|
||||
};
|
||||
|
||||
let captured: MutationHandlers;
|
||||
|
||||
// Stands in for the generated mutation hook: records the handlers the hook wires
|
||||
// up so each one can be driven directly, and reports the requested in-flight state.
|
||||
function setup(isLoading = false): {
|
||||
retryMigration: (id: string) => void;
|
||||
isMigrating: boolean;
|
||||
} {
|
||||
(useMigrateDashboardV2 as jest.Mock).mockImplementation(
|
||||
(options: { mutation: MutationHandlers }) => {
|
||||
captured = options.mutation;
|
||||
return { mutate: mockMutate, isLoading };
|
||||
},
|
||||
);
|
||||
return renderHook(() => useRetryMigration(onMigrated)).result.current;
|
||||
}
|
||||
|
||||
// A 501 from GET/POST on an un-migrated dashboard carries the render error envelope.
|
||||
const envelopeError = {
|
||||
response: {
|
||||
status: 501,
|
||||
data: {
|
||||
error: { code: 'dashboard_invalid_data', message: 'not in v6 schema' },
|
||||
},
|
||||
},
|
||||
message: 'Request failed with status code 501',
|
||||
};
|
||||
|
||||
// A gateway failure responds without an envelope, so there is no backend reason to show.
|
||||
const bodylessError = {
|
||||
response: { status: 502, data: '<html>bad gateway</html>' },
|
||||
message: 'Request failed with status code 502',
|
||||
};
|
||||
|
||||
describe('useRetryMigration', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useQueryClient as jest.Mock).mockReturnValue(queryClient);
|
||||
});
|
||||
|
||||
it('sends the dashboard id as a path parameter', () => {
|
||||
setup().retryMigration('dash-1');
|
||||
expect(mockMutate).toHaveBeenCalledWith({ pathParams: { id: 'dash-1' } });
|
||||
});
|
||||
|
||||
it('refreshes the list, confirms with a toast and reports success', async () => {
|
||||
setup();
|
||||
await captured.onSuccess();
|
||||
|
||||
expect(invalidateListDashboardsForUserV2).toHaveBeenCalledWith(queryClient);
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
'Dashboard migrated to the new experience',
|
||||
);
|
||||
expect(onMigrated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('surfaces the backend reason and does not report success on failure', () => {
|
||||
setup();
|
||||
captured.onError(envelopeError);
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('not in v6 schema');
|
||||
expect(onMigrated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('points the user at support when the failure carries no reason', () => {
|
||||
setup();
|
||||
captured.onError(bodylessError);
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'Could not migrate this dashboard. Please contact support.',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the in-flight state from the mutation', () => {
|
||||
expect(setup(true).isMigrating).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
invalidateListDashboardsForUserV2,
|
||||
useMigrateDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
const FAILURE_MESSAGE =
|
||||
'Could not migrate this dashboard. Please contact support.';
|
||||
|
||||
export interface UseRetryMigrationResult {
|
||||
// Re-run the v1 to v2 migration for a dashboard.
|
||||
retryMigration: (id: string) => void;
|
||||
isMigrating: boolean;
|
||||
}
|
||||
|
||||
// Wraps the retry-migration mutation for a legacy (pre-v2) dashboard: refreshes
|
||||
// the personalized list so the row loses its legacy flag, and reports the
|
||||
// backend's reason as a toast when the dashboard still can't be converted.
|
||||
export function useRetryMigration(
|
||||
onMigrated?: () => void,
|
||||
): UseRetryMigrationResult {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const migrate = useMigrateDashboardV2({
|
||||
mutation: {
|
||||
onSuccess: async (): Promise<void> => {
|
||||
await invalidateListDashboardsForUserV2(queryClient);
|
||||
toast.success('Dashboard migrated to the new experience');
|
||||
onMigrated?.();
|
||||
},
|
||||
onError: (error): void => {
|
||||
toast.error(toAPIError(error, FAILURE_MESSAGE).getErrorMessage());
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const retryMigration = useCallback(
|
||||
(id: string): void => {
|
||||
migrate.mutate({ pathParams: { id } });
|
||||
},
|
||||
[migrate],
|
||||
);
|
||||
|
||||
return { retryMigration, isMigrating: migrate.isLoading };
|
||||
}
|
||||
@@ -1,47 +1,15 @@
|
||||
import { Suspense } from 'react';
|
||||
import Loadable from 'components/Loadable';
|
||||
import { TabRoutes } from 'components/RouteTab/types';
|
||||
import Spinner from 'components/Spinner';
|
||||
import ROUTES from 'constants/routes';
|
||||
import InfraMonitoringHosts from 'container/InfraMonitoringHosts';
|
||||
import InfraMonitoringK8s from 'container/InfraMonitoringK8s';
|
||||
import { useIsInfraMonitoringV2 } from 'hooks/useIsInfraMonitoringV2';
|
||||
import InfraMonitoringHostsV2 from 'container/InfraMonitoringHostsV2';
|
||||
import InfraMonitoringK8sV2 from 'container/InfraMonitoringK8sV2';
|
||||
import { Inbox } from '@signozhq/icons';
|
||||
|
||||
const InfraMonitoringHostsV2 = Loadable(
|
||||
() => import('container/InfraMonitoringHostsV2'),
|
||||
);
|
||||
|
||||
const InfraMonitoringK8sV2 = Loadable(
|
||||
() => import('container/InfraMonitoringK8sV2'),
|
||||
);
|
||||
|
||||
function HostsContainer(): JSX.Element {
|
||||
const isInfraMonitoringV2 = useIsInfraMonitoringV2();
|
||||
|
||||
if (isInfraMonitoringV2) {
|
||||
return (
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<InfraMonitoringHostsV2 />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return <InfraMonitoringHosts />;
|
||||
return <InfraMonitoringHostsV2 />;
|
||||
}
|
||||
|
||||
function KubernetesContainer(): JSX.Element {
|
||||
const isInfraMonitoringV2 = useIsInfraMonitoringV2();
|
||||
|
||||
if (isInfraMonitoringV2) {
|
||||
return (
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<InfraMonitoringK8sV2 />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return <InfraMonitoringK8s />;
|
||||
return <InfraMonitoringK8sV2 />;
|
||||
}
|
||||
|
||||
export const Hosts: TabRoutes = {
|
||||
|
||||
@@ -150,13 +150,6 @@ export const defaultFeatureFlags = [
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.USE_FINE_GRAINED_AUTHZ,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
];
|
||||
|
||||
export function getAppContextMock(
|
||||
|
||||
@@ -69,7 +69,12 @@ export const getMetricsExplorerUrl = ({
|
||||
const params = new URLSearchParams();
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
// `unit` must always be present: the query builder provider rewrites (and
|
||||
// pushes a new history entry for) any compositeQuery missing a key of
|
||||
// `initialQueriesMap`, which traps the browser back button.
|
||||
// Since this is only being used by infra-monitoring, I will keep this fix one line
|
||||
// instead of going and update each chart configuration.
|
||||
encodeURIComponent(JSON.stringify({ unit: '', ...query })),
|
||||
);
|
||||
|
||||
if (relativeTime) {
|
||||
|
||||
@@ -85,6 +85,23 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/migrate", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.MigrateV2), handler.OpenAPIDef{
|
||||
ID: "MigrateDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Migrate dashboard to v2",
|
||||
Description: "This endpoint retries the v1→v2 (Perses) migration on a dashboard still stored in the v1 schema and returns the v2-shape result. It is idempotent: a dashboard already in the v2 schema is returned unchanged.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.GetV2), handler.OpenAPIDef{
|
||||
ID: "GetDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
|
||||
@@ -10,11 +10,8 @@ var (
|
||||
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
|
||||
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
|
||||
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
|
||||
FeatureUseFineGrainedAuthz = featuretypes.MustNewName("use_fine_grained_authz")
|
||||
FeatureEnableAIObservability = featuretypes.MustNewName("enable_ai_observability")
|
||||
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
|
||||
FeatureUseInfraMonitoringV2 = featuretypes.MustNewName("use_infra_monitoring_v2")
|
||||
|
||||
FeatureUsePrometheusClickhouseV2 = featuretypes.MustNewName("use_prometheus_clickhouse_v2")
|
||||
)
|
||||
|
||||
@@ -76,14 +73,6 @@ func MustNewRegistry() featuretypes.Registry {
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
&featuretypes.Feature{
|
||||
Name: FeatureUseFineGrainedAuthz,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
Stage: featuretypes.StageExperimental,
|
||||
Description: "Controls whether fine-grained authorization is enabled",
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
&featuretypes.Feature{
|
||||
Name: FeatureEnableAIObservability,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
@@ -100,14 +89,6 @@ func MustNewRegistry() featuretypes.Registry {
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
&featuretypes.Feature{
|
||||
Name: FeatureUseInfraMonitoringV2,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
Stage: featuretypes.StageExperimental,
|
||||
Description: "Controls whether infra monitoring v2 is enabled",
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
&featuretypes.Feature{
|
||||
Name: FeatureUsePrometheusClickhouseV2,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
|
||||
@@ -63,6 +63,9 @@ type Module interface {
|
||||
|
||||
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
// MigrateV2 retries the v1→v2 migration on a dashboard still stored in the v1 schema.
|
||||
MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
ListV2(ctx context.Context, orgID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error)
|
||||
|
||||
ListForUserV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardForUserV2, error)
|
||||
@@ -132,6 +135,8 @@ type Handler interface {
|
||||
|
||||
GetV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
MigrateV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListForUserV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -207,6 +207,38 @@ func (handler *handler) GetV2(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
|
||||
}
|
||||
|
||||
func (handler *handler) MigrateV2(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
|
||||
return
|
||||
}
|
||||
dashboardID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
dashboard, err := handler.module.MigrateV2(ctx, orgID, dashboardID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
|
||||
}
|
||||
|
||||
func (handler *handler) LockV2(rw http.ResponseWriter, r *http.Request) {
|
||||
handler.lockUnlockV2(rw, r, true)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/transition"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
@@ -19,7 +20,6 @@ func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy stri
|
||||
}
|
||||
|
||||
dashboard := postable.NewDashboardV2(orgID, createdBy, source)
|
||||
var storableDashboard *dashboardtypes.StorableDashboard
|
||||
|
||||
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := m.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, dashboard.ID, postable.Tags)
|
||||
@@ -32,14 +32,13 @@ func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy stri
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storableDashboard = storable
|
||||
return m.store.Create(ctx, storable)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.analytics.TrackUser(ctx, orgID.String(), creator.String(), "Dashboard Created", dashboardtypes.NewStatsFromStorableDashboards([]*dashboardtypes.StorableDashboard{storableDashboard}))
|
||||
m.analytics.TrackUser(ctx, orgID.String(), creator.String(), "Dashboard Created", dashboardtypes.NewStatsFromPostableDashboardV2(postable))
|
||||
return dashboard, nil
|
||||
}
|
||||
|
||||
@@ -121,6 +120,51 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
|
||||
return storable.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
// MigrateV2 retries the v1→v2 migration on a dashboard still stored as v1 (one the
|
||||
// bulk 103 migration skipped or failed). Idempotent: an already-v2 one is unchanged.
|
||||
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
storable, err := module.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Already migrated: return as-is.
|
||||
if storable.IsV2() {
|
||||
tags, err := module.tagModule.ListForResource(ctx, orgID, coretypes.KindDashboard, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storable.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
// v1→v2 needs v5-shaped queries; run v4→v5 in place first.
|
||||
transition.NewDashboardMigrateV5(module.settings.Logger(), nil, nil).Migrate(ctx, storable.Data)
|
||||
|
||||
v2, err := storable.ConvertV1ToV2()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, v2.ID, tagtypes.NewPostableTagsFromTags(v2.Tags))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v2.Tags = resolvedTags
|
||||
|
||||
storableV2, err := v2.ToStorableDashboard()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return module.store.Update(ctx, orgID, storableV2)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return v2, nil
|
||||
}
|
||||
|
||||
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
if err := updatable.Validate(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -213,18 +213,18 @@ func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", llmpricingruletypes.GenAIRequestModel)},
|
||||
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", telemetrytypes.GenAIRequestModel)},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{Expression: "count()", Alias: "spanCount"},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: llmpricingruletypes.GenAIRequestModel,
|
||||
Name: telemetrytypes.GenAIRequestModel,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: llmpricingruletypes.GenAIProviderName,
|
||||
Name: telemetrytypes.GenAIProviderName,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
@@ -254,9 +254,9 @@ func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]
|
||||
switch c.Type {
|
||||
case qbtypes.ColumnTypeGroup:
|
||||
switch c.Name {
|
||||
case llmpricingruletypes.GenAIRequestModel:
|
||||
case telemetrytypes.GenAIRequestModel:
|
||||
modelIdx = i
|
||||
case llmpricingruletypes.GenAIProviderName:
|
||||
case telemetrytypes.GenAIProviderName:
|
||||
providerIdx = i
|
||||
}
|
||||
case qbtypes.ColumnTypeAggregation:
|
||||
|
||||
@@ -250,7 +250,7 @@ func (handler *handler) ReplaceVariables(rw http.ResponseWriter, req *http.Reque
|
||||
errs := []error{}
|
||||
|
||||
for idx, item := range queryRangeRequest.CompositeQuery.Queries {
|
||||
if item.Type == qbtypes.QueryTypeBuilder {
|
||||
if item.Type == qbtypes.QueryTypeBuilder || item.Type == qbtypes.QueryTypeBuilderAI {
|
||||
switch spec := item.Spec.(type) {
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
|
||||
if spec.Filter != nil && spec.Filter.Expression != "" {
|
||||
|
||||
@@ -249,6 +249,7 @@ func (q *querier) buildPreviewProviders(
|
||||
func rendersStandaloneStatement(t qbtypes.QueryType) bool {
|
||||
switch t {
|
||||
case qbtypes.QueryTypeBuilder,
|
||||
qbtypes.QueryTypeBuilderAI,
|
||||
qbtypes.QueryTypePromQL,
|
||||
qbtypes.QueryTypeClickHouseSQL,
|
||||
qbtypes.QueryTypeTraceOperator:
|
||||
|
||||
@@ -61,6 +61,7 @@ type querier struct {
|
||||
// stay clean.
|
||||
promV2 prometheus.Prometheus
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
|
||||
@@ -89,6 +90,7 @@ func New(
|
||||
promEngine prometheus.Prometheus,
|
||||
promV2 prometheus.Prometheus,
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
|
||||
@@ -111,6 +113,7 @@ func New(
|
||||
promEngine: promEngine,
|
||||
promV2: promV2,
|
||||
traceStmtBuilder: traceStmtBuilder,
|
||||
aiTraceStmtBuilder: aiTraceStmtBuilder,
|
||||
logStmtBuilder: logStmtBuilder,
|
||||
auditStmtBuilder: auditStmtBuilder,
|
||||
metricStmtBuilder: metricStmtBuilder,
|
||||
@@ -295,6 +298,16 @@ func (q *querier) buildQueries(
|
||||
}
|
||||
queries[traceOpQuery.Name] = toq
|
||||
steps[traceOpQuery.Name] = traceOpQuery.StepInterval
|
||||
case qbtypes.QueryTypeBuilderAI:
|
||||
spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
|
||||
if !ok {
|
||||
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid AI builder query spec %T", query.Spec)
|
||||
}
|
||||
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.aiTraceStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
queries[spec.Name] = bq
|
||||
steps[spec.Name] = spec.StepInterval
|
||||
case qbtypes.QueryTypeBuilder:
|
||||
switch spec := query.Spec.(type) {
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
|
||||
@@ -361,6 +374,11 @@ func (q *querier) populateQBEvent(event *qbtypes.QBEvent, queries []qbtypes.Quer
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
|
||||
event.MetricsUsed = true
|
||||
}
|
||||
case qbtypes.QueryTypeBuilderAI:
|
||||
filter := query.GetFilter()
|
||||
event.FilterApplied = event.FilterApplied || (filter != nil && filter.Expression != "")
|
||||
event.GroupByApplied = event.GroupByApplied || len(query.GetGroupBy()) > 0
|
||||
event.TracesUsed = true
|
||||
case qbtypes.QueryTypePromQL:
|
||||
event.MetricsUsed = true
|
||||
case qbtypes.QueryTypeTraceOperator:
|
||||
@@ -923,7 +941,8 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.traceStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
// reuse the original query's statement builder so an AI query keeps its AI builder
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
|
||||
case *builderQuery[qbtypes.LogAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
@@ -1280,6 +1299,8 @@ func (q *querier) adjustStepInterval(queries []qbtypes.QueryEnvelope, start, end
|
||||
if qe.GetStepInterval().Seconds() == 0 {
|
||||
qe.SetStepInterval(secondsStep(metricRecommended))
|
||||
}
|
||||
case qbtypes.QueryTypeBuilderAI:
|
||||
clampStep(qe, traceLogRecommended, traceLogMin, &warnings)
|
||||
case qbtypes.QueryTypeTraceOperator:
|
||||
clampStep(qe, traceLogRecommended, traceLogMin, &warnings)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
|
||||
nil, // prometheus
|
||||
nil, // promV2
|
||||
nil, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
nil, // metricStmtBuilder
|
||||
@@ -123,6 +124,7 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
|
||||
nil, // prometheus
|
||||
nil, // promV2
|
||||
nil, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
&mockMetricStmtBuilder{},
|
||||
|
||||
@@ -21,6 +21,7 @@ func NewFactory(
|
||||
promV2 prometheus.Prometheus,
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
|
||||
@@ -43,6 +44,7 @@ func NewFactory(
|
||||
prometheus,
|
||||
promV2,
|
||||
traceStmtBuilder,
|
||||
aiTraceStmtBuilder,
|
||||
logStmtBuilder,
|
||||
auditStmtBuilder,
|
||||
metricStmtBuilder,
|
||||
|
||||
@@ -1599,15 +1599,6 @@ func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
fineGrainedAuthz := aH.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureUseFineGrainedAuthz, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureUseFineGrainedAuthz.String()),
|
||||
Active: fineGrainedAuthz,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
aiObservability := aH.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureEnableAIObservability, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureEnableAIObservability.String()),
|
||||
@@ -1617,15 +1608,6 @@ func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
infraMonitoringV2 := aH.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureUseInfraMonitoringV2, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureUseInfraMonitoringV2.String()),
|
||||
Active: infraMonitoringV2,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
if constants.IsDotMetricsEnabled {
|
||||
for idx, feature := range featureSet {
|
||||
if feature.Name == licensetypes.DotMetricsEnabled {
|
||||
|
||||
@@ -239,16 +239,9 @@ func processJSONParser(parent *pipelinetypes.PipelineOperator) ([]pipelinetypes.
|
||||
return nil, errors.NewInternalf(CodeInvalidOperatorType, "operator type received %s", parent.Type)
|
||||
}
|
||||
|
||||
parseFromNotNilCheck, err := fieldNotNilCheck(parent.ParseFrom)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, CodeFieldNilCheckType,
|
||||
"couldn't generate nil check for parseFrom of json parser op %s: %s", parent.Name, err,
|
||||
)
|
||||
}
|
||||
parent.If = fmt.Sprintf(
|
||||
`%s && ((type(%s) == "string" && isJSON(%s) && type(fromJSON(unquote(%s))) == "map" ) || type(%s) == "map")`,
|
||||
parseFromNotNilCheck, parent.ParseFrom, parent.ParseFrom, parent.ParseFrom, parent.ParseFrom,
|
||||
)
|
||||
// on_error: send_quiet replaces the expensive isJSON `if` check;
|
||||
// parse failures pass the record through unchanged without noisy logs.
|
||||
parent.OnError = signozstanzahelper.SendOnErrorQuiet
|
||||
if parent.EnableFlattening {
|
||||
parent.MaxFlatteningDepth = constants.MaxJSONFlatteningDepth
|
||||
}
|
||||
@@ -298,7 +291,7 @@ func processJSONParser(parent *pipelinetypes.PipelineOperator) ([]pipelinetypes.
|
||||
}
|
||||
|
||||
// JSONMapping: host
|
||||
err = generateMoveOperators(mapping[pipelinetypes.Host], `resource["host.name"]`)
|
||||
err := generateMoveOperators(mapping[pipelinetypes.Host], `resource["host.name"]`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -324,6 +324,17 @@ func TestNoCollectorErrorsFromProcessorsForMismatchedLogs(t *testing.T) {
|
||||
makeTestLog("mismatching log", map[string]string{
|
||||
"test_json": "bad json",
|
||||
}),
|
||||
}, {
|
||||
"json parser should quietly ignore log with non JSON body",
|
||||
pipelinetypes.PipelineOperator{
|
||||
ID: "json",
|
||||
Type: "json_parser",
|
||||
Enabled: true,
|
||||
Name: "json parser",
|
||||
ParseFrom: "body",
|
||||
ParseTo: "attributes",
|
||||
},
|
||||
makeTestLog("plain text log", map[string]string{}),
|
||||
}, {
|
||||
"move parser should ignore non matching logs",
|
||||
pipelinetypes.PipelineOperator{
|
||||
@@ -894,8 +905,8 @@ func TestProcessJSONParser_WithFlatteningAndMapping(t *testing.T) {
|
||||
require.Equal(t, 1, parentOp.MaxFlatteningDepth)
|
||||
require.Nil(t, parentOp.Mapping) // Mapping should be removed
|
||||
require.Nil(t, parent.Mapping) // Mapping should be removed
|
||||
require.Contains(t, parentOp.If, `isJSON(body)`)
|
||||
require.Contains(t, parentOp.If, `type(body)`)
|
||||
require.Empty(t, parentOp.If)
|
||||
require.Equal(t, signozstanzahelper.SendOnErrorQuiet, parentOp.OnError)
|
||||
|
||||
require.Equal(t, 1+totalOps, len(ops))
|
||||
|
||||
@@ -951,7 +962,8 @@ func TestProcessJSONParser_WithoutMapping(t *testing.T) {
|
||||
require.True(t, op.EnableFlattening)
|
||||
require.True(t, op.EnablePaths)
|
||||
require.Equal(t, "parsed", op.PathPrefix)
|
||||
require.Contains(t, op.If, `isJSON(body)`)
|
||||
require.Empty(t, op.If)
|
||||
require.Equal(t, signozstanzahelper.SendOnErrorQuiet, op.OnError)
|
||||
}
|
||||
|
||||
func TestProcessJSONParser_Simple(t *testing.T) {
|
||||
@@ -975,7 +987,8 @@ func TestProcessJSONParser_Simple(t *testing.T) {
|
||||
require.False(t, op.EnableFlattening)
|
||||
require.False(t, op.EnablePaths)
|
||||
require.Equal(t, "", op.PathPrefix)
|
||||
require.Contains(t, op.If, `isJSON(body)`)
|
||||
require.Empty(t, op.If)
|
||||
require.Equal(t, signozstanzahelper.SendOnErrorQuiet, op.OnError)
|
||||
}
|
||||
|
||||
func TestProcessJSONParser_InvalidType(t *testing.T) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/aistatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/auditstatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/logsstatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/meterstatementbuilder"
|
||||
@@ -117,6 +118,8 @@ func NewTestManager(t *testing.T, testOpts *TestManagerOptions) *Manager {
|
||||
ctx := context.Background()
|
||||
traceStmtBuilder, err := tracesstatementbuilder.NewFactory(telemetryStore, metadataStore, flagger).New(ctx, providerSettings, cfg)
|
||||
require.NoError(t, err)
|
||||
aiTraceStmtBuilder, err := aistatementbuilder.NewFactory(telemetryStore, metadataStore, flagger).New(ctx, providerSettings, cfg)
|
||||
require.NoError(t, err)
|
||||
traceOperatorStmtBuilder, err := tracesstatementbuilder.NewOperatorFactory(telemetryStore, metadataStore, flagger).New(ctx, providerSettings, cfg)
|
||||
require.NoError(t, err)
|
||||
logStmtBuilder, err := logsstatementbuilder.NewFactory(telemetryStore, metadataStore, flagger).New(ctx, providerSettings, cfg)
|
||||
@@ -128,7 +131,7 @@ func NewTestManager(t *testing.T, testOpts *TestManagerOptions) *Manager {
|
||||
meterStmtBuilder, err := meterstatementbuilder.NewFactory(metadataStore, flagger).New(ctx, providerSettings, cfg)
|
||||
require.NoError(t, err)
|
||||
bucketCache := querier.NewBucketCache(providerSettings, cache, 0, 0)
|
||||
providerFactory := signozquerier.NewFactory(telemetryStore, prometheus, nil, metadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger)
|
||||
providerFactory := signozquerier.NewFactory(telemetryStore, prometheus, nil, metadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger)
|
||||
mockQuerier, err := providerFactory.New(context.Background(), providerSettings, querier.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
|
||||
nil, // prometheus
|
||||
nil, // promV2
|
||||
nil, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
metricStmtBuilder,
|
||||
@@ -77,6 +78,7 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
|
||||
nil, // prometheus
|
||||
nil, // promV2
|
||||
nil, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
logStmtBuilder,
|
||||
nil, // auditStmtBuilder
|
||||
nil, // metricStmtBuilder
|
||||
@@ -113,6 +115,7 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
|
||||
nil, // prometheus
|
||||
nil, // promV2
|
||||
traceStmtBuilder,
|
||||
nil, // aiTraceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
nil, // metricStmtBuilder
|
||||
|
||||
@@ -49,12 +49,17 @@ func ExistsExpression(columns []*schema.Column, key *telemetrytypes.TelemetryFie
|
||||
}
|
||||
return rawPath + " IS NULL", nil
|
||||
case schema.ColumnTypeEnumString,
|
||||
schema.ColumnTypeEnumFixedString,
|
||||
schema.ColumnTypeEnumDateTime64:
|
||||
schema.ColumnTypeEnumFixedString:
|
||||
if exists {
|
||||
return comparison("<>", "''"), nil
|
||||
}
|
||||
return comparison("=", "''"), nil
|
||||
case schema.ColumnTypeEnumDateTime64:
|
||||
zero := fmt.Sprintf("toDateTime64(0, %d)", column.Type.(schema.DateTime64ColumnType).Precision)
|
||||
if exists {
|
||||
return comparison("<>", zero), nil
|
||||
}
|
||||
return comparison("=", zero), nil
|
||||
case schema.ColumnTypeEnumLowCardinality:
|
||||
switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
|
||||
case schema.ColumnTypeEnumString:
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
@@ -194,6 +195,16 @@ func DataTypeCollisionHandledFieldName(key *telemetrytypes.TelemetryFieldKey, va
|
||||
return tblFieldName, value
|
||||
}
|
||||
|
||||
// ColumnIsTemporal reports whether a column carries a time value.
|
||||
func ColumnIsTemporal(col *schema.Column) bool {
|
||||
switch col.Type.GetType() {
|
||||
case schema.ColumnTypeEnumDateTime64, schema.ColumnTypeEnumDateTime,
|
||||
schema.ColumnTypeEnumDate, schema.ColumnTypeEnumDate32:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func castFloat(col string) string { return fmt.Sprintf("toFloat64OrNull(%s)", col) }
|
||||
func castFloatHack(col string) string { return fmt.Sprintf("toFloat64(%s)", col) }
|
||||
func castString(col string) string { return fmt.Sprintf("toString(%s)", col) }
|
||||
|
||||
166
pkg/querybuilder/filter_split.go
Normal file
166
pkg/querybuilder/filter_split.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/antlr4-go/antlr/v4"
|
||||
)
|
||||
|
||||
// SplitFilterForAggregates partitions a filter expression on the top-level AND into a
|
||||
// span-level part (WHERE) and a trace-level part (HAVING over per-trace aggregates).
|
||||
// A key is trace-level when it carries the trace field context or its bare name is in
|
||||
// aggregateNames; any other explicit context is span-level. An OR mixing the two
|
||||
// classes is an error.
|
||||
func SplitFilterForAggregates(query string, aggregateNames map[string]struct{}) (spanExpr string, havingExpr string, err error) {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
tree, syntaxErrors := parseFilterQuery(query)
|
||||
if len(syntaxErrors) > 0 {
|
||||
combinedErrors := errors.Newf(
|
||||
errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"Found %d syntax errors while parsing the filter expression.",
|
||||
len(syntaxErrors),
|
||||
)
|
||||
additionals := make([]string, 0, len(syntaxErrors))
|
||||
for _, syntaxError := range syntaxErrors {
|
||||
if syntaxError.Error() != "" {
|
||||
additionals = append(additionals, syntaxError.Error())
|
||||
}
|
||||
}
|
||||
// TODO: add troubleshooting link to the filter query syntax guide once it's published.
|
||||
return "", "", combinedErrors.WithAdditional(additionals...)
|
||||
}
|
||||
|
||||
s := filterSplitter{query: []rune(query), aggregateNames: aggregateNames}
|
||||
s.visit(tree)
|
||||
|
||||
if s.mixed {
|
||||
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"trace-level and span-level filters cannot be combined within an OR/NOT group; separate them with a top-level AND")
|
||||
}
|
||||
return strings.Join(s.span, " AND "), strings.Join(s.having, " AND "), nil
|
||||
}
|
||||
|
||||
func parseFilterQuery(query string) (antlr.Tree, []*SyntaxErr) {
|
||||
lexerErrorListener := NewErrorListener()
|
||||
lexer := grammar.NewFilterQueryLexer(antlr.NewInputStream(query))
|
||||
lexer.RemoveErrorListeners()
|
||||
lexer.AddErrorListener(lexerErrorListener)
|
||||
|
||||
parserErrorListener := NewErrorListener()
|
||||
parser := grammar.NewFilterQueryParser(antlr.NewCommonTokenStream(lexer, 0))
|
||||
parser.RemoveErrorListeners()
|
||||
parser.AddErrorListener(parserErrorListener)
|
||||
|
||||
tree := parser.Query()
|
||||
return tree, append(lexerErrorListener.SyntaxErrors, parserErrorListener.SyntaxErrors...)
|
||||
}
|
||||
|
||||
// filterSplitter flattens the top-level AND chain and routes each atom to the span or
|
||||
// having bucket by the class of the keys it references.
|
||||
type filterSplitter struct {
|
||||
query []rune
|
||||
aggregateNames map[string]struct{}
|
||||
span []string
|
||||
having []string
|
||||
mixed bool
|
||||
}
|
||||
|
||||
func (s *filterSplitter) visit(node antlr.Tree) {
|
||||
switch n := node.(type) {
|
||||
case *grammar.QueryContext:
|
||||
if n.Expression() != nil {
|
||||
s.visit(n.Expression())
|
||||
}
|
||||
case *grammar.ExpressionContext:
|
||||
if n.OrExpression() != nil {
|
||||
s.visit(n.OrExpression())
|
||||
}
|
||||
case *grammar.OrExpressionContext:
|
||||
// a real OR is kept whole so a class-mixing OR can be rejected
|
||||
if ands := n.AllAndExpression(); len(ands) == 1 {
|
||||
s.visit(ands[0])
|
||||
} else {
|
||||
s.route(n)
|
||||
}
|
||||
case *grammar.AndExpressionContext:
|
||||
for _, u := range n.AllUnaryExpression() {
|
||||
s.visit(u)
|
||||
}
|
||||
case *grammar.UnaryExpressionContext:
|
||||
if n.NOT() != nil {
|
||||
s.route(n)
|
||||
} else if n.Primary() != nil {
|
||||
s.visit(n.Primary())
|
||||
}
|
||||
case *grammar.PrimaryContext:
|
||||
if n.OrExpression() != nil { // parenthesized sub-expression
|
||||
s.visit(n.OrExpression())
|
||||
} else {
|
||||
s.route(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// route classifies an atom and appends its original source text to the right bucket.
|
||||
func (s *filterSplitter) route(atom antlr.ParserRuleContext) {
|
||||
isTrace, isSpan := classifyKeys(atom, s.aggregateNames)
|
||||
if isTrace && isSpan {
|
||||
s.mixed = true
|
||||
return
|
||||
}
|
||||
text := atomSourceText(s.query, atom)
|
||||
// re-wrap an OR group (its source slice excludes the enclosing parens) so the
|
||||
// " AND " rejoin cannot invert OR/AND precedence
|
||||
if or, ok := atom.(*grammar.OrExpressionContext); ok && len(or.AllAndExpression()) > 1 {
|
||||
text = "(" + text + ")"
|
||||
}
|
||||
if isTrace {
|
||||
s.having = append(s.having, text)
|
||||
} else {
|
||||
s.span = append(s.span, text)
|
||||
}
|
||||
}
|
||||
|
||||
// classifyKeys reports whether a subtree references trace-level and/or span-level
|
||||
// keys. An unknown name under the trace context stays trace-level so the aggregate
|
||||
// validation rejects it with a targeted error.
|
||||
func classifyKeys(node antlr.Tree, aggregateNames map[string]struct{}) (isTrace, isSpan bool) {
|
||||
kc, ok := node.(*grammar.KeyContext)
|
||||
if ok {
|
||||
key := telemetrytypes.GetFieldKeyFromKeyText(kc.GetText())
|
||||
switch key.FieldContext {
|
||||
case telemetrytypes.FieldContextTrace:
|
||||
isTrace = true
|
||||
case telemetrytypes.FieldContextUnspecified:
|
||||
_, isTrace = aggregateNames[key.Name]
|
||||
isSpan = !isTrace
|
||||
default:
|
||||
isSpan = true
|
||||
}
|
||||
return
|
||||
}
|
||||
for i := 0; i < node.GetChildCount(); i++ {
|
||||
t, s := classifyKeys(node.GetChild(i), aggregateNames)
|
||||
isTrace = isTrace || t
|
||||
isSpan = isSpan || s
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// atomSourceText slices the input by token offsets to preserve whitespace (the token
|
||||
// stream drops it, gluing word operators to operands). ANTLR offsets are rune indices,
|
||||
// hence the rune slice.
|
||||
func atomSourceText(query []rune, atom antlr.ParserRuleContext) string {
|
||||
start, stop := atom.GetStart(), atom.GetStop()
|
||||
if start == nil || stop == nil || start.GetStart() < 0 || stop.GetStop() >= len(query) || stop.GetStop() < start.GetStart() {
|
||||
return atom.GetText()
|
||||
}
|
||||
return string(query[start.GetStart() : stop.GetStop()+1])
|
||||
}
|
||||
219
pkg/querybuilder/filter_split_test.go
Normal file
219
pkg/querybuilder/filter_split_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSplitFilterForAggregates(t *testing.T) {
|
||||
agg := map[string]struct{}{"completion_tokens": {}, "span_count": {}, "prompt_tokens": {}}
|
||||
|
||||
type tc struct {
|
||||
name string
|
||||
query string
|
||||
span string // expected span-level (WHERE) part; "" => empty
|
||||
having string // expected trace-level (HAVING) part; "" => empty
|
||||
wantErr bool
|
||||
}
|
||||
|
||||
cases := []tc{
|
||||
// --- empty input ---------------------------------------------------------
|
||||
{
|
||||
name: "empty",
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
query: " ",
|
||||
},
|
||||
|
||||
// --- single class --------------------------------------------------------
|
||||
{
|
||||
name: "span only",
|
||||
query: "service.name = 'x'",
|
||||
span: "service.name = 'x'",
|
||||
},
|
||||
{
|
||||
name: "agg only bare",
|
||||
query: "completion_tokens > 1000",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
{
|
||||
// the user-facing `trace.` prefix marks a trace-level aggregate.
|
||||
name: "agg only trace prefix",
|
||||
query: "trace.completion_tokens > 1000",
|
||||
having: "trace.completion_tokens > 1000",
|
||||
},
|
||||
{
|
||||
// routes trace-level so aggregate validation rejects it with a targeted error
|
||||
name: "unknown aggregate under trace context stays trace-level",
|
||||
query: "trace.not_an_aggregate > 1000",
|
||||
having: "trace.not_an_aggregate > 1000",
|
||||
},
|
||||
|
||||
{
|
||||
// ANTLR token offsets are rune indices; slicing must not shift after a multi-byte char
|
||||
name: "unicode value before the split",
|
||||
query: "service.name = 'héllo' AND completion_tokens > 1000",
|
||||
span: "service.name = 'héllo'",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
|
||||
// --- top-level AND splits across the two buckets -------------------------
|
||||
{
|
||||
name: "span AND agg",
|
||||
query: "service.name = 'x' AND completion_tokens > 1000",
|
||||
span: "service.name = 'x'",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
{
|
||||
// order within a bucket is preserved; the two span atoms join with AND.
|
||||
name: "span AND span AND agg",
|
||||
query: "service.name = 'x' AND kind_string = 'Internal' AND completion_tokens > 1000",
|
||||
span: "service.name = 'x' AND kind_string = 'Internal'",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
{
|
||||
// a parenthesized top-level AND still splits across the two buckets.
|
||||
name: "parenthesized span AND agg",
|
||||
query: "(service.name = 'x' AND completion_tokens > 1000)",
|
||||
span: "service.name = 'x'",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
|
||||
// --- OR groups are re-wrapped in parens so a later AND-join can't invert
|
||||
// precedence (`a AND (b OR c)` must not flatten to `a AND b OR c`) ------
|
||||
{
|
||||
name: "agg OR agg",
|
||||
query: "completion_tokens > 1000 OR span_count > 3",
|
||||
having: "(completion_tokens > 1000 OR span_count > 3)",
|
||||
},
|
||||
{
|
||||
name: "span OR span",
|
||||
query: "service.name = 'x' OR kind_string = 'Internal'",
|
||||
span: "(service.name = 'x' OR kind_string = 'Internal')",
|
||||
},
|
||||
{
|
||||
name: "span AND (span OR span)",
|
||||
query: "service.name = 'x' AND (kind_string = 'Internal' OR kind_string = 'Client')",
|
||||
span: "service.name = 'x' AND (kind_string = 'Internal' OR kind_string = 'Client')",
|
||||
},
|
||||
{
|
||||
name: "agg AND (agg OR agg)",
|
||||
query: "prompt_tokens > 5 AND (completion_tokens > 1000 OR span_count > 3)",
|
||||
having: "prompt_tokens > 5 AND (completion_tokens > 1000 OR span_count > 3)",
|
||||
},
|
||||
{
|
||||
// the OR group routes to span, the trailing aggregate to having.
|
||||
name: "span AND (span OR span) AND agg",
|
||||
query: "a.b = 'x' AND (c.d = 'y' OR e.f = 'z') AND completion_tokens > 1000",
|
||||
span: "a.b = 'x' AND (c.d = 'y' OR e.f = 'z')",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
|
||||
// --- a nested AND group flattens across the buckets (no spurious parens) --
|
||||
{
|
||||
name: "(span AND agg) AND agg",
|
||||
query: "(service.name = 'x' AND completion_tokens > 1000) AND prompt_tokens > 5",
|
||||
span: "service.name = 'x'",
|
||||
having: "completion_tokens > 1000 AND prompt_tokens > 5",
|
||||
},
|
||||
|
||||
// --- NOT wrapping a single-class group is routed whole to that class ------
|
||||
{
|
||||
name: "not agg",
|
||||
query: "NOT (completion_tokens > 1000)",
|
||||
having: "NOT (completion_tokens > 1000)",
|
||||
},
|
||||
{
|
||||
name: "not span",
|
||||
query: "NOT (service.name = 'x')",
|
||||
span: "NOT (service.name = 'x')",
|
||||
},
|
||||
|
||||
// --- an explicit non-trace context escapes the aggregate-alias shadow -----
|
||||
{
|
||||
// a span attribute named like an aggregate stays reachable via `attribute.`.
|
||||
name: "attribute prefix on aggregate name routes span-level",
|
||||
query: "attribute.completion_tokens > 5",
|
||||
span: "attribute.completion_tokens > 5",
|
||||
},
|
||||
{
|
||||
name: "span prefix on aggregate name routes span-level",
|
||||
query: "span.completion_tokens > 5",
|
||||
span: "span.completion_tokens > 5",
|
||||
},
|
||||
{
|
||||
name: "prefixed attribute AND bare aggregate split across buckets",
|
||||
query: "attribute.completion_tokens > 5 AND completion_tokens > 1000",
|
||||
span: "attribute.completion_tokens > 5",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
|
||||
// --- class-mixing is rejected in an OR group, a NOT group, or a nested OR -
|
||||
{
|
||||
name: "agg OR span rejected",
|
||||
query: "completion_tokens > 1000 OR service.name = 'x'",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "not mixed rejected",
|
||||
query: "NOT (completion_tokens > 1000 AND service.name = 'x')",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "span AND (agg OR span) rejected",
|
||||
query: "service.name = 'x' AND (completion_tokens > 1000 OR kind_string = 'Client')",
|
||||
wantErr: true,
|
||||
},
|
||||
|
||||
// --- syntax errors are rejected, not silently dropped by error recovery ---
|
||||
{
|
||||
// recovery would yield an empty tree → both buckets empty → filter ignored.
|
||||
name: "lone paren rejected",
|
||||
query: ")",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "unbalanced parens rejected",
|
||||
query: "((",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "bare operator rejected",
|
||||
query: "AND",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
// lexer-level error: recovery drops the whole expression.
|
||||
name: "unterminated quote rejected",
|
||||
query: "'unterminated",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
// recovery would keep the rest — a partially applied filter with no error
|
||||
name: "garbage atom alongside valid agg rejected",
|
||||
query: ") AND completion_tokens > 5",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing value rejected",
|
||||
query: "completion_tokens >",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
span, having, err := SplitFilterForAggregates(c.query, agg)
|
||||
if c.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, c.span, span, "span part")
|
||||
assert.Equal(t, c.having, having, "having part")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,17 @@ func NewHavingExpressionRewriter() *HavingExpressionRewriter {
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite rewrites and validates a HAVING expression against a caller-supplied column
|
||||
// map (user-facing name -> SQL identifier). Values are inlined, so the result is a
|
||||
// bare boolean expression with no bound args.
|
||||
func (r *HavingExpressionRewriter) Rewrite(expression string, columnMap map[string]string) (string, error) {
|
||||
if len(strings.TrimSpace(expression)) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
r.columnMap = columnMap
|
||||
return r.rewriteAndValidate(expression)
|
||||
}
|
||||
|
||||
// RewriteForTraces rewrites and validates the HAVING expression for a traces query.
|
||||
func (r *HavingExpressionRewriter) RewriteForTraces(expression string, aggregations []qbtypes.TraceAggregation) (string, error) {
|
||||
if len(strings.TrimSpace(expression)) == 0 {
|
||||
|
||||
@@ -82,6 +82,9 @@ func resourcesForQuery(query gjson.Result, variables map[string]qbtypes.Variable
|
||||
switch queryType {
|
||||
case qbtypes.QueryTypeBuilder.StringValue(), qbtypes.QueryTypeSubQuery.StringValue():
|
||||
return resourcesForBuilderQuery(queryType, query.Get("spec"), variables)
|
||||
case qbtypes.QueryTypeBuilderAI.StringValue():
|
||||
// always a traces query; the signal may be absent from the payload
|
||||
return builderQueryResourceRefs(queryType, coretypes.ResourceTelemetryResourceTraces, query.Get("spec"), variables)
|
||||
case qbtypes.QueryTypePromQL.StringValue():
|
||||
return []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: typeWildcard}}, nil
|
||||
case qbtypes.QueryTypeClickHouseSQL.StringValue():
|
||||
@@ -103,7 +106,10 @@ func resourcesForBuilderQuery(queryType string, spec gjson.Result, variables map
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return builderQueryResourceRefs(queryType, resource, spec, variables)
|
||||
}
|
||||
|
||||
func builderQueryResourceRefs(queryType string, resource coretypes.Resource, spec gjson.Result, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
|
||||
ids, err := builderQuerySelectors(queryType, spec.Get("filter.expression").String(), variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -92,6 +92,20 @@ func TestQueryRangeResources(t *testing.T) {
|
||||
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ai builder query maps to traces resource without a signal",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{"filter":{"expression":"signoz.workspace.key.id = 'checkout'"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/signoz.workspace.key.id/checkout"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ai builder query without filter is wildcard",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "promql is wildcard only",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"promql","spec":{"query":"up"}}]}}`,
|
||||
|
||||
@@ -83,7 +83,10 @@ func newFilterExpressionVisitor(opts FilterExprVisitorOpts) *filterExpressionVis
|
||||
}
|
||||
|
||||
type PreparedWhereClause struct {
|
||||
WhereClause *sqlbuilder.WhereClause
|
||||
WhereClause *sqlbuilder.WhereClause
|
||||
// Expr is the bare predicate ($n markers bound to opts.Builder), embeddable
|
||||
// outside a WHERE clause (e.g. inside countIf).
|
||||
Expr string
|
||||
Warnings []string
|
||||
WarningsDocURL string
|
||||
RequiresCostGuard bool
|
||||
@@ -173,7 +176,7 @@ func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (PreparedWhere
|
||||
|
||||
whereClause := sqlbuilder.NewWhereClause().AddWhereExpr(visitor.builder.Args, cond)
|
||||
|
||||
return PreparedWhereClause{WhereClause: whereClause, Warnings: visitor.warnings, WarningsDocURL: visitor.mainWarnURL, RequiresCostGuard: visitor.requiresCostGuard}, nil
|
||||
return PreparedWhereClause{WhereClause: whereClause, Expr: cond, Warnings: visitor.warnings, WarningsDocURL: visitor.mainWarnURL, RequiresCostGuard: visitor.requiresCostGuard}, nil
|
||||
}
|
||||
|
||||
// Visit dispatches to the specific visit method based on node type.
|
||||
|
||||
@@ -293,9 +293,9 @@ func NewStatsReporterProviderFactories(aggregator statsreporter.Aggregator, orgG
|
||||
)
|
||||
}
|
||||
|
||||
func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, promV2 prometheus.Prometheus, metadataStore telemetrytypes.MetadataStore, traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation], logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder, bucketCache querier.BucketCache, flagger flagger.Flagger) factory.NamedMap[factory.ProviderFactory[querier.Querier, querier.Config]] {
|
||||
func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, promV2 prometheus.Prometheus, metadataStore telemetrytypes.MetadataStore, traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation], aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation], logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder, bucketCache querier.BucketCache, flagger flagger.Flagger) factory.NamedMap[factory.ProviderFactory[querier.Querier, querier.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
signozquerier.NewFactory(telemetryStore, prometheus, promV2, metadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
|
||||
signozquerier.NewFactory(telemetryStore, prometheus, promV2, metadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlmigrator"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/aistatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/auditstatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/logsstatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/meterstatementbuilder"
|
||||
@@ -100,9 +101,9 @@ type SigNoz struct {
|
||||
|
||||
// newQueryStack assembles the query stack once and returns, in order: the shared
|
||||
// telemetry metadata store (reused elsewhere in signoz.New), the per-signal
|
||||
// statement builders (trace, log, audit, metric, meter, trace-operator), and the
|
||||
// bucket cache. It is the only place that imports the concrete statement-builder
|
||||
// sub-packages.
|
||||
// statement builders (trace, ai-trace, log, audit, metric, meter, trace-operator),
|
||||
// and the bucket cache. It is the only place that imports the concrete
|
||||
// statement-builder sub-packages.
|
||||
func newQueryStack(
|
||||
ctx context.Context,
|
||||
settings factory.ProviderSettings,
|
||||
@@ -113,6 +114,7 @@ func newQueryStack(
|
||||
) (
|
||||
telemetrytypes.MetadataStore,
|
||||
qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||
qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||
qbtypes.StatementBuilder[qbtypes.MetricAggregation],
|
||||
@@ -126,32 +128,36 @@ func newQueryStack(
|
||||
cfg := config.Querier.Config
|
||||
traceStmtBuilder, err := tracesstatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
aiTraceStmtBuilder, err := aistatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
traceOperatorStmtBuilder, err := tracesstatementbuilder.NewOperatorFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
logStmtBuilder, err := logsstatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
auditStmtBuilder, err := auditstatementbuilder.NewFactory(metadataStore, fl).New(ctx, settings, cfg)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
metricStmtBuilder, err := metricsstatementbuilder.NewFactory(metadataStore, fl).New(ctx, settings, cfg)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
meterStmtBuilder, err := meterstatementbuilder.NewFactory(metadataStore, fl).New(ctx, settings, cfg)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
bucketCache := querier.NewBucketCache(settings, cache, config.Querier.CacheTTL, config.Querier.FluxInterval)
|
||||
|
||||
return metadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, nil
|
||||
return metadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, nil
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -336,7 +342,7 @@ func New(
|
||||
|
||||
// Assemble the query stack (metadata store, statement builders, bucket cache) once,
|
||||
// and reuse the single metadata store everywhere downstream.
|
||||
telemetryMetadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, err := newQueryStack(ctx, providerSettings, config, telemetrystore, cache, flagger)
|
||||
telemetryMetadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, err := newQueryStack(ctx, providerSettings, config, telemetrystore, cache, flagger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -346,7 +352,7 @@ func New(
|
||||
ctx,
|
||||
providerSettings,
|
||||
config.Querier,
|
||||
NewQuerierProviderFactories(telemetrystore, prometheus, promV2, telemetryMetadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
|
||||
NewQuerierProviderFactories(telemetrystore, prometheus, promV2, telemetryMetadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
|
||||
config.Querier.Provider(),
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
78
pkg/statementbuilder/aistatementbuilder/statement_builder.go
Normal file
78
pkg/statementbuilder/aistatementbuilder/statement_builder.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package aistatementbuilder
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder"
|
||||
scopedtraces "github.com/SigNoz/signoz/pkg/statementbuilder/scopedtracesstatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// NewFactory returns the provider factory for builder_ai_query: the gen_ai Scope
|
||||
// paired with the domain-neutral scoped-trace builder.
|
||||
func NewFactory(
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
fl flagger.Flagger,
|
||||
) factory.ProviderFactory[qbtypes.StatementBuilder[qbtypes.TraceAggregation], statementbuilder.Config] {
|
||||
return scopedtraces.NewFactory(factory.MustNewName("ai"), Scope(), telemetryStore, metadataStore, fl)
|
||||
}
|
||||
|
||||
// Scope describes gen_ai for the scoped trace builder: an AI trace has >=1 gen_ai
|
||||
// LLM, tool, or agent span, and its list adds AI/LLM per-trace metrics.
|
||||
func Scope() scopedtraces.TraceScope {
|
||||
gateKeyNames := []string{telemetrytypes.GenAIRequestModel, telemetrytypes.GenAIToolName, telemetrytypes.GenAIAgentName}
|
||||
gateExprs := make([]string, 0, len(gateKeyNames))
|
||||
gateKeys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(gateKeyNames))
|
||||
for _, name := range gateKeyNames {
|
||||
gateExprs = append(gateExprs, name+" EXISTS")
|
||||
gateKeys = append(gateKeys, &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
})
|
||||
}
|
||||
|
||||
defs := telemetrytypes.GenAIFieldDefinitions
|
||||
reqModel := defs[telemetrytypes.GenAIRequestModel]
|
||||
toolName := defs[telemetrytypes.GenAIToolName]
|
||||
inTok := defs[telemetrytypes.GenAIUsageInputTokens]
|
||||
outTok := defs[telemetrytypes.GenAIUsageOutputTokens]
|
||||
cost := defs[telemetrytypes.SignozGenAITotalCost]
|
||||
inMsg := defs[telemetrytypes.GenAIInputMessages]
|
||||
outMsg := defs[telemetrytypes.GenAIOutputMessages]
|
||||
|
||||
str := telemetrytypes.FieldDataTypeString
|
||||
columns := append(scopedtraces.CommonTraceColumns(),
|
||||
// LLM calls only (request model present), not the full gate.
|
||||
scopedtraces.TraceColumn{Alias: "llm_call_count", Orderable: true, Expr: scopedtraces.CountExists(&reqModel)},
|
||||
scopedtraces.TraceColumn{Alias: "tool_call_count", Orderable: true, Expr: scopedtraces.CountExists(&toolName)},
|
||||
scopedtraces.TraceColumn{Alias: "distinct_tool_count", Orderable: true, Expr: scopedtraces.UniqCount(&toolName, str)},
|
||||
// tokens live only on LLM spans, so a plain sum needs no gate scoping.
|
||||
scopedtraces.TraceColumn{Alias: "input_tokens", Orderable: true, Expr: scopedtraces.Reduce(scopedtraces.AggSum, &inTok)},
|
||||
scopedtraces.TraceColumn{Alias: "output_tokens", Orderable: true, Expr: scopedtraces.Reduce(scopedtraces.AggSum, &outTok)},
|
||||
scopedtraces.TraceColumn{Alias: "total_tokens", Orderable: true, Expr: scopedtraces.SumOfKeys(telemetrytypes.FieldDataTypeFloat64, &inTok, &outTok)},
|
||||
// per-span cost attached by the SigNoz LLM pricing processor.
|
||||
scopedtraces.TraceColumn{Alias: "estimated_total_cost", Orderable: true, Expr: scopedtraces.Reduce(scopedtraces.AggSum, &cost)},
|
||||
// slowest single LLM call in the trace.
|
||||
scopedtraces.TraceColumn{Alias: "max_llm_duration_nano", Orderable: true, Expr: scopedtraces.ScopedToKeyColumn(scopedtraces.AggMax, scopedtraces.IntrinsicSpanKey("duration_nano"), &reqModel)},
|
||||
// errors across the whole trace (any span), so display-only.
|
||||
scopedtraces.TraceColumn{Alias: "error_count", Expr: scopedtraces.CondCount(scopedtraces.IntrinsicSpanKey("has_error"), qbtypes.FilterOperatorEqual, true)},
|
||||
// timestamp of the last gen_ai span (LLM/tool/agent), hence gate-scoped.
|
||||
scopedtraces.TraceColumn{Alias: "last_activity_time", Orderable: true, Expr: scopedtraces.ScopedReduce(scopedtraces.AggMax, scopedtraces.IntrinsicSpanKey("timestamp"))},
|
||||
// previews: first call's input (the prompt), last call's output (the answer).
|
||||
scopedtraces.TraceColumn{Alias: "input", SpanLevel: true, Expr: scopedtraces.PickBy(&inMsg, str, scopedtraces.IntrinsicSpanKey("timestamp"), scopedtraces.PickEarliest)},
|
||||
scopedtraces.TraceColumn{Alias: "output", SpanLevel: true, Expr: scopedtraces.PickBy(&outMsg, str, scopedtraces.IntrinsicSpanKey("timestamp"), scopedtraces.PickLatest)},
|
||||
)
|
||||
|
||||
return scopedtraces.TraceScope{
|
||||
FilterExpression: strings.Join(gateExprs, " OR "),
|
||||
FieldKeys: gateKeys,
|
||||
Columns: columns,
|
||||
DefaultOrderAlias: "last_activity_time",
|
||||
}
|
||||
}
|
||||
1058
pkg/statementbuilder/aistatementbuilder/statement_builder_test.go
Normal file
1058
pkg/statementbuilder/aistatementbuilder/statement_builder_test.go
Normal file
File diff suppressed because it is too large
Load Diff
199
pkg/statementbuilder/scopedtracesstatementbuilder/aggregate.go
Normal file
199
pkg/statementbuilder/scopedtracesstatementbuilder/aggregate.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package scopedtracesstatementbuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// Aggregate renders one column's SQL through the resolvers and lists the attribute
|
||||
// keys it references so the builder can pre-fetch their metadata. Build one with the
|
||||
// constructors below; the zero value is not usable.
|
||||
type Aggregate struct {
|
||||
keys []*telemetrytypes.TelemetryFieldKey
|
||||
render func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, preds *predicateResolver) (expr string, err error)
|
||||
}
|
||||
|
||||
// IntrinsicSpanKey references an intrinsic span-index field (timestamp, name, …).
|
||||
func IntrinsicSpanKey(name string) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
}
|
||||
}
|
||||
|
||||
// AggFunc is a ClickHouse aggregate function name.
|
||||
type AggFunc string
|
||||
|
||||
const (
|
||||
AggSum AggFunc = "sum"
|
||||
AggMax AggFunc = "max"
|
||||
AggMin AggFunc = "min"
|
||||
)
|
||||
|
||||
// PickDirection selects the earliest (argMin) or latest (argMax) span by ordering.
|
||||
type PickDirection int
|
||||
|
||||
const (
|
||||
PickLatest PickDirection = iota
|
||||
PickEarliest
|
||||
)
|
||||
|
||||
// CountAll renders count().
|
||||
func CountAll() Aggregate {
|
||||
return Aggregate{render: func(context.Context, valuer.UUID, uint64, uint64, *columnResolver, *predicateResolver) (string, error) {
|
||||
return "count()", nil
|
||||
}}
|
||||
}
|
||||
|
||||
// FieldReduce renders <fn>(<field>) over a field-mapper-resolved column.
|
||||
func FieldReduce(fn AggFunc, key *telemetrytypes.TelemetryFieldKey) Aggregate {
|
||||
return Aggregate{render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, _ *predicateResolver) (string, error) {
|
||||
f, err := cols.FieldFor(ctx, orgID, startNs, endNs, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s(%s)", fn, f), nil
|
||||
}}
|
||||
}
|
||||
|
||||
// TraceDuration renders the full-trace wall duration: last span end minus first
|
||||
// span start.
|
||||
func TraceDuration(tsKey, durationKey *telemetrytypes.TelemetryFieldKey) Aggregate {
|
||||
return Aggregate{render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, _ *predicateResolver) (string, error) {
|
||||
ts, err := cols.FieldFor(ctx, orgID, startNs, endNs, tsKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dur, err := cols.FieldFor(ctx, orgID, startNs, endNs, durationKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tsNano := tracestelemetryschema.UnixNanoExpr(ts)
|
||||
return fmt.Sprintf("(max(%s + %s) - min(%s))", tsNano, dur, tsNano), nil
|
||||
}}
|
||||
}
|
||||
|
||||
// FieldAnyWhere renders anyIf(<field>, <cond>) — the field value from any span
|
||||
// matching the condition.
|
||||
func FieldAnyWhere(valueKey, condKey *telemetrytypes.TelemetryFieldKey, op qbtypes.FilterOperator, condValue any) Aggregate {
|
||||
return Aggregate{render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, preds *predicateResolver) (string, error) {
|
||||
v, err := cols.FieldFor(ctx, orgID, startNs, endNs, valueKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cond, err := preds.ConditionFor(ctx, orgID, startNs, endNs, condKey, op, condValue)
|
||||
return fmt.Sprintf("anyIf(%s, %s)", v, cond), err
|
||||
}}
|
||||
}
|
||||
|
||||
// AnyValue renders any(<value>) over a metadata-resolved attribute value.
|
||||
func AnyValue(key *telemetrytypes.TelemetryFieldKey, dt telemetrytypes.FieldDataType) Aggregate {
|
||||
return Aggregate{keys: []*telemetrytypes.TelemetryFieldKey{key}, render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, _ *predicateResolver) (string, error) {
|
||||
v, err := cols.ValueFor(ctx, orgID, startNs, endNs, key, dt)
|
||||
return fmt.Sprintf("any(%s)", v), err
|
||||
}}
|
||||
}
|
||||
|
||||
// CountExists renders countIf(<key> EXISTS) — counts spans carrying key.
|
||||
func CountExists(key *telemetrytypes.TelemetryFieldKey) Aggregate {
|
||||
return Aggregate{keys: []*telemetrytypes.TelemetryFieldKey{key}, render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, _ *columnResolver, preds *predicateResolver) (string, error) {
|
||||
cond, err := preds.ExistsFor(ctx, orgID, startNs, endNs, key)
|
||||
return fmt.Sprintf("countIf(%s)", cond), err
|
||||
}}
|
||||
}
|
||||
|
||||
// CondCount renders countIf(<cond>) over a condition-builder-resolved predicate.
|
||||
func CondCount(key *telemetrytypes.TelemetryFieldKey, op qbtypes.FilterOperator, value any) Aggregate {
|
||||
return Aggregate{render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, _ *columnResolver, preds *predicateResolver) (string, error) {
|
||||
cond, err := preds.ConditionFor(ctx, orgID, startNs, endNs, key, op, value)
|
||||
return fmt.Sprintf("countIf(%s)", cond), err
|
||||
}}
|
||||
}
|
||||
|
||||
// Reduce renders <fn>(<value>) over a resolved numeric attribute value.
|
||||
func Reduce(fn AggFunc, valueKey *telemetrytypes.TelemetryFieldKey) Aggregate {
|
||||
return Aggregate{keys: []*telemetrytypes.TelemetryFieldKey{valueKey}, render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, _ *predicateResolver) (string, error) {
|
||||
v, err := cols.ValueFor(ctx, orgID, startNs, endNs, valueKey, telemetrytypes.FieldDataTypeFloat64)
|
||||
return fmt.Sprintf("%s(%s)", fn, v), err
|
||||
}}
|
||||
}
|
||||
|
||||
// ScopedReduce renders <fn>If(<field>, <gate mask>) over a field-mapper-resolved column.
|
||||
func ScopedReduce(fn AggFunc, key *telemetrytypes.TelemetryFieldKey) Aggregate {
|
||||
return Aggregate{render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, preds *predicateResolver) (string, error) {
|
||||
f, err := cols.FieldFor(ctx, orgID, startNs, endNs, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%sIf(%s, %s)", fn, f, preds.maskExpr), nil
|
||||
}}
|
||||
}
|
||||
|
||||
// ScopedToKeyColumn renders <fn>If(<field>, <scopeKey> EXISTS) — a span-index field
|
||||
// aggregated over spans carrying scopeKey (e.g. max LLM latency).
|
||||
func ScopedToKeyColumn(fn AggFunc, columnKey, scopeKey *telemetrytypes.TelemetryFieldKey) Aggregate {
|
||||
return Aggregate{keys: []*telemetrytypes.TelemetryFieldKey{scopeKey}, render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, preds *predicateResolver) (string, error) {
|
||||
col, err := cols.FieldFor(ctx, orgID, startNs, endNs, columnKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cond, err := preds.ExistsFor(ctx, orgID, startNs, endNs, scopeKey)
|
||||
return fmt.Sprintf("%sIf(%s, %s)", fn, col, cond), err
|
||||
}}
|
||||
}
|
||||
|
||||
// PickBy renders argMinIf/argMaxIf(<value>, <orderField>, <value> EXISTS) — the value
|
||||
// from the earliest/latest span that carries it.
|
||||
func PickBy(valueKey *telemetrytypes.TelemetryFieldKey, dt telemetrytypes.FieldDataType, orderKey *telemetrytypes.TelemetryFieldKey, dir PickDirection) Aggregate {
|
||||
fn := "argMaxIf"
|
||||
if dir == PickEarliest {
|
||||
fn = "argMinIf"
|
||||
}
|
||||
return Aggregate{keys: []*telemetrytypes.TelemetryFieldKey{valueKey}, render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, preds *predicateResolver) (string, error) {
|
||||
v, err := cols.ValueFor(ctx, orgID, startNs, endNs, valueKey, dt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
order, err := cols.FieldFor(ctx, orgID, startNs, endNs, orderKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cond, err := preds.ExistsFor(ctx, orgID, startNs, endNs, valueKey)
|
||||
return fmt.Sprintf("%s(%s, %s, %s)", fn, v, order, cond), err
|
||||
}}
|
||||
}
|
||||
|
||||
// UniqCount renders uniqIf(<value>, <value> EXISTS) — distinct count of an attribute.
|
||||
func UniqCount(valueKey *telemetrytypes.TelemetryFieldKey, dt telemetrytypes.FieldDataType) Aggregate {
|
||||
return Aggregate{keys: []*telemetrytypes.TelemetryFieldKey{valueKey}, render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, preds *predicateResolver) (string, error) {
|
||||
v, err := cols.ValueFor(ctx, orgID, startNs, endNs, valueKey, dt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cond, err := preds.ExistsFor(ctx, orgID, startNs, endNs, valueKey)
|
||||
return fmt.Sprintf("uniqIf(%s, %s)", v, cond), err
|
||||
}}
|
||||
}
|
||||
|
||||
// SumOfKeys renders coalesce(sum(<v1>), 0) + coalesce(sum(<v2>), 0) + …; coalesced
|
||||
// because a key absent from every span sums to NULL and NULL + n = NULL.
|
||||
func SumOfKeys(dt telemetrytypes.FieldDataType, valueKeys ...*telemetrytypes.TelemetryFieldKey) Aggregate {
|
||||
return Aggregate{keys: valueKeys, render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, cols *columnResolver, _ *predicateResolver) (string, error) {
|
||||
parts := make([]string, 0, len(valueKeys))
|
||||
for _, k := range valueKeys {
|
||||
v, err := cols.ValueFor(ctx, orgID, startNs, endNs, k, dt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("coalesce(sum(%s), 0)", v))
|
||||
}
|
||||
return strings.Join(parts, " + "), nil
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package scopedtracesstatementbuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// columnResolver resolves keys to bare column/value expressions through the shared
|
||||
// field mapper. It binds no args, so its expressions embed in any builder; predicates
|
||||
// (which do bind args) are the predicateResolver's job.
|
||||
type columnResolver struct {
|
||||
fm qbtypes.FieldMapper
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey
|
||||
}
|
||||
|
||||
func newColumnResolver(fm qbtypes.FieldMapper, keys map[string][]*telemetrytypes.TelemetryFieldKey) *columnResolver {
|
||||
return &columnResolver{fm: fm, keys: keys}
|
||||
}
|
||||
|
||||
func (r *columnResolver) FieldFor(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
|
||||
return r.fm.FieldFor(ctx, orgID, startNs, endNs, key)
|
||||
}
|
||||
|
||||
// ValueFor returns the value expression for an attribute key.
|
||||
func (r *columnResolver) ValueFor(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, key *telemetrytypes.TelemetryFieldKey, dt telemetrytypes.FieldDataType) (string, error) {
|
||||
// TODO(nitya): Fix this as this is not correct way
|
||||
if cands := r.keys[key.Name]; len(cands) > 0 {
|
||||
key = cands[0]
|
||||
}
|
||||
expr, err := r.fm.ColumnExpressionFor(ctx, orgID, startNs, endNs, key, dt, r.keys)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// a materialized column name carries `$$`, which Build would otherwise unescape
|
||||
// to a single `$` and reference the wrong column
|
||||
return sqlbuilder.Escape(expr), nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package scopedtracesstatementbuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// predicateResolver resolves key + operator + value to boolean predicates through the
|
||||
// shared condition builder. Args bind into sb as $n markers, so returned predicates
|
||||
// can be embedded anywhere in sb; maskExpr is set by the builder after resolveMask
|
||||
// (Scoped* aggregates embed it).
|
||||
type predicateResolver struct {
|
||||
cb qbtypes.ConditionBuilder
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey
|
||||
sb *sqlbuilder.SelectBuilder
|
||||
maskExpr string
|
||||
}
|
||||
|
||||
func newPredicateResolver(cb qbtypes.ConditionBuilder, keys map[string][]*telemetrytypes.TelemetryFieldKey, sb *sqlbuilder.SelectBuilder) *predicateResolver {
|
||||
return &predicateResolver{cb: cb, keys: keys, sb: sb}
|
||||
}
|
||||
|
||||
// ConditionFor returns a boolean predicate for key via the condition builder
|
||||
// (materialized column when present, else map access), args bound into sb.
|
||||
func (r *predicateResolver) ConditionFor(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, key *telemetrytypes.TelemetryFieldKey, op qbtypes.FilterOperator, value any) (string, error) {
|
||||
conds, _, err := r.cb.ConditionFor(ctx, orgID, startNs, endNs, key, r.keys, qbtypes.ConditionBuilderOptions{}, op, value, r.sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(conds) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
// one condition per data-type variant of the key; OR them all
|
||||
if len(conds) == 1 {
|
||||
return conds[0], nil
|
||||
}
|
||||
return r.sb.Or(conds...), nil
|
||||
}
|
||||
|
||||
// ExistsFor returns the EXISTS predicate for key.
|
||||
func (r *predicateResolver) ExistsFor(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
|
||||
return r.ConditionFor(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorExists, nil)
|
||||
}
|
||||
58
pkg/statementbuilder/scopedtracesstatementbuilder/scope.go
Normal file
58
pkg/statementbuilder/scopedtracesstatementbuilder/scope.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package scopedtracesstatementbuilder
|
||||
|
||||
import (
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// TraceScope configures the scoped trace builder: which spans are in scope and which
|
||||
// per-trace columns the list computes.
|
||||
type TraceScope struct {
|
||||
// FilterExpression is the grammar-level (EXISTS) gate, used on the delegated
|
||||
// span-list path.
|
||||
FilterExpression string
|
||||
// FieldKeys are the gate's keys, used to build the per-span mask.
|
||||
FieldKeys []*telemetrytypes.TelemetryFieldKey
|
||||
Columns []TraceColumn
|
||||
// DefaultOrderAlias is sorted by (desc) when the query gives no order.
|
||||
DefaultOrderAlias string
|
||||
}
|
||||
|
||||
// TraceColumn is one per-trace output column.
|
||||
type TraceColumn struct {
|
||||
// Alias must not reuse a physical span-index column name (e.g. duration_nano):
|
||||
// ClickHouse resolves bare identifiers to same-SELECT aliases first, so any
|
||||
// expression referencing that column would silently bind to the alias.
|
||||
Alias string
|
||||
// Orderable columns can be used in ORDER BY and the aggregate filter; all-span
|
||||
// aggregates are display-only and set false.
|
||||
Orderable bool
|
||||
// SpanLevel columns surface a real span/resource attribute; a filter on them is
|
||||
// applied span-level, so they are excluded from the trace-level aliases.
|
||||
SpanLevel bool
|
||||
Expr Aggregate
|
||||
}
|
||||
|
||||
// CommonTraceColumns are domain-neutral columns any trace list can reuse; all
|
||||
// aggregate over every span, so none is Orderable.
|
||||
func CommonTraceColumns() []TraceColumn {
|
||||
ts := IntrinsicSpanKey("timestamp")
|
||||
duration := IntrinsicSpanKey("duration_nano")
|
||||
name := IntrinsicSpanKey("name")
|
||||
parentSpanID := IntrinsicSpanKey("parent_span_id")
|
||||
serviceName := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
return []TraceColumn{
|
||||
{Alias: "start_time", Expr: FieldReduce(AggMin, ts)},
|
||||
{Alias: "end_time", Expr: FieldReduce(AggMax, ts)},
|
||||
// not plain "duration_nano": an alias would shadow the intrinsic span field
|
||||
{Alias: "trace_duration_nano", Expr: TraceDuration(ts, duration)},
|
||||
{Alias: "span_count", Expr: CountAll()},
|
||||
{Alias: "root_span_name", Expr: FieldAnyWhere(name, parentSpanID, qbtypes.FilterOperatorEqual, "")},
|
||||
{Alias: "service.name", SpanLevel: true, Expr: AnyValue(serviceName, telemetrytypes.FieldDataTypeString)},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
package scopedtracesstatementbuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/resourcefilter"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/tracesstatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
qbvariables "github.com/SigNoz/signoz/pkg/variables"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnsupportedRequestType = errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported request type for the scoped trace builder")
|
||||
)
|
||||
|
||||
// scopedTraceStatementBuilder builds a trace list scoped to one span category
|
||||
// (e.g. gen_ai spans); the TraceScope decides which spans are in scope and which
|
||||
// per-trace columns to compute.
|
||||
type scopedTraceStatementBuilder struct {
|
||||
logger *slog.Logger
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
fm qbtypes.FieldMapper
|
||||
cb qbtypes.ConditionBuilder
|
||||
scope TraceScope
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
resourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
}
|
||||
|
||||
var _ qbtypes.StatementBuilder[qbtypes.TraceAggregation] = (*scopedTraceStatementBuilder)(nil)
|
||||
|
||||
// NewFactory returns a provider factory for a scoped trace statement builder. The
|
||||
// package is domain-neutral: the caller supplies the factory name and the TraceScope
|
||||
// (see aistatementbuilder for the gen_ai scope).
|
||||
func NewFactory(
|
||||
name factory.Name,
|
||||
scope TraceScope,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
fl flagger.Flagger,
|
||||
) factory.ProviderFactory[qbtypes.StatementBuilder[qbtypes.TraceAggregation], statementbuilder.Config] {
|
||||
return factory.NewProviderFactory(
|
||||
name,
|
||||
func(ctx context.Context, settings factory.ProviderSettings, cfg statementbuilder.Config) (qbtypes.StatementBuilder[qbtypes.TraceAggregation], error) {
|
||||
traceStmtBuilder, err := tracesstatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fm := tracestelemetryschema.NewFieldMapper()
|
||||
cb := tracestelemetryschema.NewConditionBuilder(fm)
|
||||
return NewScopedTraceStatementBuilder(settings, metadataStore, fm, cb, scope, traceStmtBuilder, fl), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// NewScopedTraceStatementBuilder wires the generic trace-list builder;
|
||||
// traceStmtBuilder is the delegate for the span-list path.
|
||||
func NewScopedTraceStatementBuilder(
|
||||
settings factory.ProviderSettings,
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
fieldMapper qbtypes.FieldMapper,
|
||||
conditionBuilder qbtypes.ConditionBuilder,
|
||||
scope TraceScope,
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
fl flagger.Flagger,
|
||||
) qbtypes.StatementBuilder[qbtypes.TraceAggregation] {
|
||||
scopedSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/statementbuilder/scopedtracesstatementbuilder")
|
||||
|
||||
resourceFilterStmtBuilder := resourcefilter.New[qbtypes.TraceAggregation](
|
||||
settings,
|
||||
tracestelemetryschema.DBName,
|
||||
tracestelemetryschema.TracesResourceV3TableName,
|
||||
telemetrytypes.SignalTraces,
|
||||
telemetrytypes.SourceUnspecified,
|
||||
metadataStore,
|
||||
nil,
|
||||
fl,
|
||||
)
|
||||
|
||||
return &scopedTraceStatementBuilder{
|
||||
logger: scopedSettings.Logger(),
|
||||
metadataStore: metadataStore,
|
||||
fm: fieldMapper,
|
||||
cb: conditionBuilder,
|
||||
scope: scope,
|
||||
traceStmtBuilder: traceStmtBuilder,
|
||||
resourceFilterStmtBuilder: resourceFilterStmtBuilder,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) Build(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start uint64,
|
||||
end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
switch requestType {
|
||||
case qbtypes.RequestTypeTrace:
|
||||
return b.buildTraceListQuery(ctx, orgID, querybuilder.ToNanoSecs(start), querybuilder.ToNanoSecs(end), query, variables)
|
||||
case qbtypes.RequestTypeRaw:
|
||||
return b.buildDelegated(ctx, orgID, start, end, requestType, query, variables)
|
||||
default:
|
||||
return nil, ErrUnsupportedRequestType
|
||||
}
|
||||
}
|
||||
|
||||
// buildDelegated ANDs the base gate into the user filter and delegates to the
|
||||
// standard trace builder (the span-list / raw path).
|
||||
func (b *scopedTraceStatementBuilder) buildDelegated(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
gate := b.scope.FilterExpression
|
||||
expr := gate
|
||||
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
|
||||
expr = fmt.Sprintf("(%s) AND (%s)", gate, query.Filter.Expression)
|
||||
}
|
||||
|
||||
// shallow copy; only Filter is replaced, caller's query untouched
|
||||
gated := query
|
||||
gated.Filter = &qbtypes.Filter{Expression: expr}
|
||||
|
||||
return b.traceStmtBuilder.Build(ctx, orgID, start, end, requestType, gated, variables)
|
||||
}
|
||||
|
||||
// buildTraceListQuery wires the CTE pipeline (start/end are nanoseconds):
|
||||
// matched (windowed, mask-pruned top-N trace_ids) → ranked (their [start,end] from
|
||||
// the summary table) → buckets (ts_bucket_start prune) → enrichment (every per-trace
|
||||
// column over each trace's full extent). Only Orderable columns are computable in the
|
||||
// matched pass, so only they can be ordered or filtered on.
|
||||
func (b *scopedTraceStatementBuilder) buildTraceListQuery(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end uint64,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
|
||||
startBucket := start/querybuilder.NsToSeconds - querybuilder.BucketAdjustment
|
||||
endBucket := end / querybuilder.NsToSeconds
|
||||
|
||||
limit := query.Limit
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// Condition args bind into the builder an expression is embedded in, so the
|
||||
// matched and enrichment passes each resolve against their own builder.
|
||||
keys, err := b.fetchKeys(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matchedSB := sqlbuilder.NewSelectBuilder()
|
||||
maskExpr, resolved, err := b.resolveFor(ctx, orgID, start, end, keys, matchedSB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enrichSB := sqlbuilder.NewSelectBuilder()
|
||||
_, enrichResolved, err := b.resolveFor(ctx, orgID, start, end, keys, enrichSB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orders, err := b.resolveListOrders(query.Order, resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orderableSet := orderableAliasSet(resolved)
|
||||
|
||||
resourceFrag, resourceArgs, resourcePred, err := b.maybeAttachResourceFilter(ctx, orgID, query, start, end, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fp, err := b.splitFilter(ctx, orgID, query, b.aggregateAliasSet(), orderableSet, start, end, variables, matchedSB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
matchedFrag, matchedArgs, err := b.buildMatchedCTE(matchedSB, start, end, startBucket, endBucket, resolved, orders, orderableSet, maskExpr, fp, resourcePred, limit, query.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rankedFrag, rankedArgs := b.buildRankedCTE(start, end)
|
||||
|
||||
adj := querybuilder.BucketAdjustment // 30-min bucket width in seconds
|
||||
bucketsFrag := fmt.Sprintf("buckets AS (SELECT DISTINCT b AS ts_bucket FROM ranked "+
|
||||
"ARRAY JOIN range("+
|
||||
"toUInt64(intDiv(toUnixTimestamp(t_start), %d) * %d - %d), "+
|
||||
"toUInt64(intDiv(toUnixTimestamp(t_end), %d) * %d + %d), "+
|
||||
"%d) AS b)", adj, adj, adj, adj, adj, adj, adj)
|
||||
|
||||
mainSQL, mainArgs := b.buildEnrichmentSelect(enrichSB, enrichResolved, orders)
|
||||
|
||||
cteFragments := []string{matchedFrag, rankedFrag, bucketsFrag}
|
||||
cteArgs := [][]any{matchedArgs, rankedArgs, nil}
|
||||
|
||||
// __resource_filter must precede `matched`, which references it.
|
||||
if resourceFrag != "" {
|
||||
cteFragments = append([]string{resourceFrag}, cteFragments...)
|
||||
cteArgs = append([][]any{resourceArgs}, cteArgs...)
|
||||
}
|
||||
|
||||
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL + " SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000"
|
||||
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
|
||||
return &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: fp.warnings,
|
||||
WarningsDocURL: fp.warningsURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// maybeAttachResourceFilter builds the __resource_filter CTE and the fingerprint
|
||||
// predicate narrowing the span scan; empty fragments when the filter has no resource
|
||||
// conditions. Deliberately no skip-fingerprint fallback: falling back would leave the
|
||||
// resource conditions in the OR'd span-filter bucket and change trace membership.
|
||||
func (b *scopedTraceStatementBuilder) maybeAttachResourceFilter(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
start, end uint64,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (cteFrag string, cteArgs []any, fingerprintPred string, err error) {
|
||||
stmt, err := b.resourceFilterStmtBuilder.Build(
|
||||
ctx, orgID, start, end, qbtypes.RequestTypeRaw, query, variables,
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, "", err
|
||||
}
|
||||
if stmt == nil {
|
||||
return "", nil, "", nil
|
||||
}
|
||||
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args,
|
||||
"resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)", nil
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) fetchKeys(ctx context.Context, orgID valuer.UUID) (map[string][]*telemetrytypes.TelemetryFieldKey, error) {
|
||||
fields := b.resolverFieldKeys()
|
||||
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(fields))
|
||||
for _, k := range fields {
|
||||
selectors = append(selectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: k.Name,
|
||||
Signal: k.Signal,
|
||||
FieldContext: k.FieldContext,
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
|
||||
})
|
||||
}
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, selectors)
|
||||
return keys, err
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) resolverFieldKeys() []*telemetrytypes.TelemetryFieldKey {
|
||||
seen := make(map[string]struct{})
|
||||
var out []*telemetrytypes.TelemetryFieldKey
|
||||
add := func(k *telemetrytypes.TelemetryFieldKey) {
|
||||
if k == nil {
|
||||
return
|
||||
}
|
||||
if _, dup := seen[k.Name]; dup {
|
||||
return
|
||||
}
|
||||
seen[k.Name] = struct{}{}
|
||||
out = append(out, k)
|
||||
}
|
||||
for _, k := range b.scope.FieldKeys {
|
||||
add(k)
|
||||
}
|
||||
for _, c := range b.scope.Columns {
|
||||
for _, k := range c.Expr.keys {
|
||||
add(k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveFor renders the gate mask and every scope column with condition args bound
|
||||
// into sb.
|
||||
func (b *scopedTraceStatementBuilder) resolveFor(ctx context.Context, orgID valuer.UUID, start, end uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey, sb *sqlbuilder.SelectBuilder) (string, []resolvedColumn, error) {
|
||||
cols := newColumnResolver(b.fm, keys)
|
||||
preds := newPredicateResolver(b.cb, keys, sb)
|
||||
maskExpr, err := b.resolveMask(ctx, orgID, start, end, preds)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
preds.maskExpr = maskExpr
|
||||
resolved, err := b.resolveColumns(ctx, orgID, start, end, cols, preds)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return maskExpr, resolved, nil
|
||||
}
|
||||
|
||||
// resolveMask builds the per-span in-scope mask: OR of the gate keys' EXISTS predicates.
|
||||
func (b *scopedTraceStatementBuilder) resolveMask(ctx context.Context, orgID valuer.UUID, start, end uint64, preds *predicateResolver) (string, error) {
|
||||
fieldKeys := b.scope.FieldKeys
|
||||
parts := make([]string, 0, len(fieldKeys))
|
||||
for _, key := range fieldKeys {
|
||||
e, err := preds.ExistsFor(ctx, orgID, start, end, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts = append(parts, e)
|
||||
}
|
||||
return "(" + strings.Join(parts, " OR ") + ")", nil
|
||||
}
|
||||
|
||||
type resolvedColumn struct {
|
||||
alias string
|
||||
expr string
|
||||
orderable bool
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) resolveColumns(ctx context.Context, orgID valuer.UUID, start, end uint64, cols *columnResolver, preds *predicateResolver) ([]resolvedColumn, error) {
|
||||
out := make([]resolvedColumn, 0, len(b.scope.Columns))
|
||||
for _, c := range b.scope.Columns {
|
||||
expr, err := c.Expr.render(ctx, orgID, start, end, cols, preds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, resolvedColumn{alias: c.Alias, expr: expr, orderable: c.Orderable})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type listOrder struct {
|
||||
alias string
|
||||
direction string
|
||||
}
|
||||
|
||||
// resolveListOrders maps order keys to resolved orderable columns; non-orderable
|
||||
// columns are rejected.
|
||||
func (b *scopedTraceStatementBuilder) resolveListOrders(order []qbtypes.OrderBy, resolved []resolvedColumn) ([]listOrder, error) {
|
||||
byAlias := make(map[string]resolvedColumn, len(resolved))
|
||||
orderable := make([]string, 0, len(resolved))
|
||||
for _, rc := range resolved {
|
||||
byAlias[rc.alias] = rc
|
||||
if rc.orderable {
|
||||
orderable = append(orderable, rc.alias)
|
||||
}
|
||||
}
|
||||
|
||||
if len(order) == 0 {
|
||||
return []listOrder{{alias: b.scope.DefaultOrderAlias, direction: "DESC"}}, nil
|
||||
}
|
||||
|
||||
orders := make([]listOrder, 0, len(order))
|
||||
for _, o := range order {
|
||||
direction := "DESC"
|
||||
if o.Direction == qbtypes.OrderDirectionAsc {
|
||||
direction = "ASC"
|
||||
}
|
||||
rc, ok := byAlias[o.Key.Name]
|
||||
if !ok || !rc.orderable {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"unsupported order key %q for the trace list; orderable keys: %s", o.Key.Name, strings.Join(orderable, ", "))
|
||||
}
|
||||
orders = append(orders, listOrder{alias: rc.alias, direction: direction})
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// filterParts is the user filter split into a span-level predicate and a trace-level
|
||||
// HAVING expression.
|
||||
type filterParts struct {
|
||||
spanPred string
|
||||
hasSpanFilter bool
|
||||
havingExpr string
|
||||
warnings []string
|
||||
warningsURL string
|
||||
}
|
||||
|
||||
// splitFilter splits query.Filter into a span-level predicate (args bound into sb)
|
||||
// and a trace-level HAVING (explicit query.Having ANDed on), then validates the
|
||||
// trace-level part against the matched-pass aggregates.
|
||||
func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, orgID valuer.UUID, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], classifySet, orderableSet map[string]struct{}, start, end uint64, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (filterParts, error) {
|
||||
var fp filterParts
|
||||
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
|
||||
spanExpr, traceExpr, err := querybuilder.SplitFilterForAggregates(query.Filter.Expression, classifySet)
|
||||
if err != nil {
|
||||
return fp, err
|
||||
}
|
||||
fp.havingExpr = traceExpr
|
||||
if strings.TrimSpace(spanExpr) != "" {
|
||||
pred, warnings, url, err := b.resolveSpanPredicate(ctx, orgID, start, end, spanExpr, variables, sb)
|
||||
if err != nil {
|
||||
return fp, err
|
||||
}
|
||||
// pred is empty when all span-level keys were resource attributes
|
||||
// already handled by __resource_filter
|
||||
if strings.TrimSpace(pred) != "" {
|
||||
fp.spanPred, fp.hasSpanFilter = pred, true
|
||||
}
|
||||
fp.warnings, fp.warningsURL = warnings, url
|
||||
}
|
||||
}
|
||||
if query.Having != nil && strings.TrimSpace(query.Having.Expression) != "" {
|
||||
if fp.havingExpr != "" {
|
||||
fp.havingExpr = fmt.Sprintf("(%s) AND (%s)", fp.havingExpr, query.Having.Expression)
|
||||
} else {
|
||||
fp.havingExpr = query.Having.Expression
|
||||
}
|
||||
}
|
||||
// the HAVING is a plain text rewrite, so substitute variables here
|
||||
if strings.TrimSpace(fp.havingExpr) != "" && len(variables) > 0 {
|
||||
replaced, err := qbvariables.ReplaceVariablesInExpression(fp.havingExpr, variables)
|
||||
if err != nil {
|
||||
return fp, err
|
||||
}
|
||||
fp.havingExpr = replaced
|
||||
}
|
||||
if err := validateAggregateFilter(fp.havingExpr, orderableSet); err != nil {
|
||||
return fp, err
|
||||
}
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
// resolveSpanPredicate resolves a span-level filter expression to a bare boolean
|
||||
// predicate, args bound into sb.
|
||||
func (b *scopedTraceStatementBuilder) resolveSpanPredicate(ctx context.Context, orgID valuer.UUID, start, end uint64, expr string, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (string, []string, string, error) {
|
||||
selectors := querybuilder.QueryStringToKeysSelectors(expr)
|
||||
for i := range selectors {
|
||||
selectors[i].Signal = telemetrytypes.SignalTraces
|
||||
}
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, selectors)
|
||||
if err != nil {
|
||||
return "", nil, "", err
|
||||
}
|
||||
prepared, err := querybuilder.PrepareWhereClause(expr, querybuilder.FilterExprVisitorOpts{
|
||||
Context: ctx,
|
||||
OrgID: orgID,
|
||||
Logger: b.logger,
|
||||
FieldMapper: b.fm,
|
||||
ConditionBuilder: b.cb,
|
||||
FieldKeys: keys,
|
||||
Builder: sb,
|
||||
// resource conditions are handled by __resource_filter
|
||||
SkipResourceFilter: true,
|
||||
Variables: variables,
|
||||
StartNs: start,
|
||||
EndNs: end,
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, "", err
|
||||
}
|
||||
if prepared.IsEmpty() {
|
||||
return "", nil, "", nil
|
||||
}
|
||||
return prepared.Expr, prepared.Warnings, prepared.WarningsDocURL, nil
|
||||
}
|
||||
|
||||
// buildMatchedCTE builds `matched`: one windowed GROUP BY trace_id scan fusing gate +
|
||||
// span filter + HAVING + ORDER BY + LIMIT/OFFSET, selecting only the aliases ORDER BY
|
||||
// / HAVING reference. Expressions carry $n markers bound to sb, so each can appear
|
||||
// several times and every occurrence resolves to the same arg.
|
||||
func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuilder, start, end, startBucket, endBucket uint64, resolved []resolvedColumn, orders []listOrder, orderableSet map[string]struct{}, maskExpr string, fp filterParts, resourcePred string, limit, offset int) (string, []any, error) {
|
||||
needed := neededMatchedAliases(orders, fp.havingExpr, orderableSet)
|
||||
selects := []string{"trace_id"}
|
||||
for _, rc := range resolved {
|
||||
if _, ok := needed[rc.alias]; !ok {
|
||||
continue
|
||||
}
|
||||
selects = append(selects, rc.expr+" AS "+quoteAlias(rc.alias))
|
||||
}
|
||||
sb.Select(selects...)
|
||||
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName))
|
||||
|
||||
// prune widened by the span filter so its spans survive for the countIf below
|
||||
prune := "(" + maskExpr
|
||||
if fp.hasSpanFilter {
|
||||
prune += " OR " + fp.spanPred
|
||||
}
|
||||
prune += ")"
|
||||
where := []string{
|
||||
sb.GE("timestamp", fmt.Sprintf("%d", start)),
|
||||
sb.L("timestamp", fmt.Sprintf("%d", end)),
|
||||
sb.GE("ts_bucket_start", startBucket),
|
||||
sb.LE("ts_bucket_start", endBucket),
|
||||
prune,
|
||||
}
|
||||
if resourcePred != "" {
|
||||
where = append(where, resourcePred)
|
||||
}
|
||||
sb.Where(where...)
|
||||
sb.GroupBy("trace_id")
|
||||
|
||||
// gate/span existence checks are only needed when the WHERE was widened;
|
||||
// otherwise the mask alone enforces the gate
|
||||
var having []string
|
||||
if fp.hasSpanFilter {
|
||||
having = append(having, "countIf("+maskExpr+") > 0")
|
||||
having = append(having, "countIf("+fp.spanPred+") > 0")
|
||||
}
|
||||
if strings.TrimSpace(fp.havingExpr) != "" {
|
||||
// the rewriter matches raw key text, so map the trace. form alongside the bare name
|
||||
columnMap := make(map[string]string, len(orderableSet)*2)
|
||||
for a := range orderableSet {
|
||||
columnMap[a] = quoteAlias(a)
|
||||
columnMap[telemetrytypes.FieldContextTrace.StringValue()+"."+a] = quoteAlias(a)
|
||||
}
|
||||
hv, err := querybuilder.NewHavingExpressionRewriter().Rewrite(fp.havingExpr, columnMap)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if hv != "" {
|
||||
// escape user text so a literal $ isn't read as an arg marker; the countIf
|
||||
// entries hold live $n markers and must stay unescaped
|
||||
having = append(having, sqlbuilder.Escape(hv))
|
||||
}
|
||||
}
|
||||
if len(having) > 0 {
|
||||
sb.Having(strings.Join(having, " AND "))
|
||||
}
|
||||
|
||||
sb.OrderBy(orderClause(orders)...)
|
||||
sb.Limit(limit)
|
||||
if offset > 0 {
|
||||
sb.Offset(offset)
|
||||
}
|
||||
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return fmt.Sprintf("matched AS (%s)", sql), args, nil
|
||||
}
|
||||
|
||||
// buildRankedCTE builds `ranked`: [start,end] bounds per matched trace from the
|
||||
// trace-summary table.
|
||||
func (b *scopedTraceStatementBuilder) buildRankedCTE(start, end uint64) (string, []any) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("trace_id", "min(start) AS t_start", "max(end) AS t_end")
|
||||
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.TraceSummaryTableName))
|
||||
sb.Where(
|
||||
"trace_id GLOBAL IN (SELECT trace_id FROM matched)",
|
||||
"end >= fromUnixTimestamp64Nano("+sb.Var(start)+")",
|
||||
"start < fromUnixTimestamp64Nano("+sb.Var(end)+")",
|
||||
)
|
||||
sb.GroupBy("trace_id")
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return fmt.Sprintf("ranked AS (%s)", sql), args
|
||||
}
|
||||
|
||||
// buildEnrichmentSelect builds the final SELECT: every per-trace column for the
|
||||
// matched traces over their full extent, scanning only their buckets.
|
||||
//
|
||||
// Accepted discrepancy: matched ranks/paginates on window-clipped values while this
|
||||
// pass ORDER BYs full-trace values, so a trace can sort differently than it ranked;
|
||||
// page membership is unaffected (LIMIT/OFFSET runs only in matched).
|
||||
func (b *scopedTraceStatementBuilder) buildEnrichmentSelect(sb *sqlbuilder.SelectBuilder, resolved []resolvedColumn, orders []listOrder) (string, []any) {
|
||||
selects := []string{"trace_id"}
|
||||
for _, rc := range resolved {
|
||||
selects = append(selects, rc.expr+" AS "+quoteAlias(rc.alias))
|
||||
}
|
||||
sb.Select(selects...)
|
||||
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName))
|
||||
sb.Where(
|
||||
"ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)",
|
||||
"trace_id GLOBAL IN (SELECT trace_id FROM ranked)",
|
||||
)
|
||||
sb.GroupBy("trace_id")
|
||||
sb.OrderBy(orderClause(orders)...)
|
||||
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
}
|
||||
|
||||
// aggregateAliasSet is every trace-level column alias, used to classify filter keys;
|
||||
// SpanLevel columns are filtered span-level, so skip them.
|
||||
func (b *scopedTraceStatementBuilder) aggregateAliasSet() map[string]struct{} {
|
||||
set := make(map[string]struct{}, len(b.scope.Columns))
|
||||
for _, c := range b.scope.Columns {
|
||||
if !c.SpanLevel {
|
||||
set[c.Alias] = struct{}{}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// orderableAliasSet is the subset of aliases computable in the matched pass.
|
||||
func orderableAliasSet(resolved []resolvedColumn) map[string]struct{} {
|
||||
set := make(map[string]struct{})
|
||||
for _, rc := range resolved {
|
||||
if rc.orderable {
|
||||
set[rc.alias] = struct{}{}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// neededMatchedAliases is the minimal alias set the matched pass must select: those
|
||||
// in ORDER BY plus those in the aggregate HAVING.
|
||||
func neededMatchedAliases(orders []listOrder, havingExpr string, orderableSet map[string]struct{}) map[string]struct{} {
|
||||
needed := make(map[string]struct{})
|
||||
for _, o := range orders {
|
||||
needed[o.alias] = struct{}{}
|
||||
}
|
||||
for _, name := range traceAggregateNames(havingExpr) {
|
||||
if _, ok := orderableSet[name]; ok {
|
||||
needed[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return needed
|
||||
}
|
||||
|
||||
// traceAggregateNames extracts the aggregate names a trace-level HAVING references;
|
||||
// only unspecified- and trace-context selectors name aggregates.
|
||||
func traceAggregateNames(havingExpr string) []string {
|
||||
var names []string
|
||||
for _, sel := range querybuilder.QueryStringToKeysSelectors(havingExpr) {
|
||||
if sel.FieldContext == telemetrytypes.FieldContextUnspecified || sel.FieldContext == telemetrytypes.FieldContextTrace {
|
||||
names = append(names, sel.Name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// validateAggregateFilter rejects a trace-level filter referencing an aggregate not
|
||||
// computable in the matched pass.
|
||||
func validateAggregateFilter(havingExpr string, orderableSet map[string]struct{}) error {
|
||||
if strings.TrimSpace(havingExpr) == "" {
|
||||
return nil
|
||||
}
|
||||
allowed := make([]string, 0, len(orderableSet))
|
||||
for a := range orderableSet {
|
||||
allowed = append(allowed, a)
|
||||
}
|
||||
sort.Strings(allowed)
|
||||
for _, name := range traceAggregateNames(havingExpr) {
|
||||
if _, ok := orderableSet[name]; !ok {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"aggregate %q cannot be used in the trace-list filter; filterable aggregates: %s", name, strings.Join(allowed, ", "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// orderClause renders the ORDER BY terms plus the trace_id tiebreak.
|
||||
func orderClause(orders []listOrder) []string {
|
||||
out := make([]string, 0, len(orders)+1)
|
||||
for _, o := range orders {
|
||||
out = append(out, fmt.Sprintf("%s %s", quoteAlias(o.alias), o.direction))
|
||||
}
|
||||
return append(out, "trace_id DESC")
|
||||
}
|
||||
|
||||
// quoteAlias backticks an alias containing characters special to the SQL builder.
|
||||
func quoteAlias(alias string) string {
|
||||
if strings.ContainsAny(alias, ".$`") {
|
||||
return "`" + alias + "`"
|
||||
}
|
||||
return alias
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user