Compare commits

...

10 Commits

Author SHA1 Message Date
nityanandagohain
8eb66acdc1 feat: ai-011y quickfilters support 2026-08-05 10:43:08 +05:30
Vinicius Lourenço
e4e1c4b9e0 refactor(infrastructure-monitoring-v2): more ux/ui fixes after bug-bash (#12402)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(k8s-expanded-row): ensure values are correctly aligned

Ref: https://app.notion.com/p/signoz/Alignment-in-rows-inside-a-group-wrt-column-headers-is-not-correct-3b1fcc6bcd1980dd910bff84d1defe47?v=65cfcc6bcd1982a6bee688d8fd55420c&source=copy_link

* fix(pods): ensure table aligment on group by

Ref: https://app.notion.com/p/signoz/Alignment-in-rows-inside-a-group-wrt-column-headers-is-not-correct-3b1fcc6bcd1980dd910bff84d1defe47?v=65cfcc6bcd1982a6bee688d8fd55420c&source=copy_link

* fix(infra-monitoring): not allowing name columns to be sorted

Ref: https://app.notion.com/p/signoz/No-option-to-order-resources-by-name-3b1fcc6bcd198019a729d2575731387e?v=65cfcc6bcd1982a6bee688d8fd55420c&source=copy_link

* fix(infra-monitoring): cannot sort name columns when group by is enabled

Ref: https://app.notion.com/p/signoz/Cannot-use-sort-when-group-by-is-added-3b2fcc6bcd19806d9514d27e4da26c35?v=65cfcc6bcd1982a6bee688d8fd55420c&source=copy_link

* fix(infra-monitoring): sort key was not correct

Ref: https://app.notion.com/p/signoz/Review-all-sortable-columns-3b2fcc6bcd1980c4b267f3e35d4e875f?v=65cfcc6bcd1982a6bee688d8fd55420c&source=copy_link

* feat(infra-monitoring): add descriptions for each threshold of entity progress

Ref: https://app.notion.com/p/signoz/For-usage-metrics-the-info-icon-can-explain-the-colours-right-there-instead-of-just-saying-to-go-to-3b1fcc6bcd1980fc81a4f2b8179541b9?v=65cfcc6bcd1982a6bee688d8fd55420c&source=copy_link

* fix(infra-monitoring): explorer button not allowing the back button

Ref: https://app.notion.com/p/signoz/After-click-to-go-to-metrics-explorer-on-chart-we-cannot-go-back-3b1fcc6bcd1980d7b52fd7dbe0b3e29f?v=65cfcc6bcd1982a6bee688d8fd55420c&source=copy_link

* fix(infra-monitoring): polluted navigation history

Fixes the following issue:
- Go to namespaces
- Select “generator” and open the details
- Close the details
- Go to pods
- Group by namespaces

Expand “generator”, this will cause it to be redirected to namespaces category

Ref: https://app.notion.com/p/signoz/wrong-redirect-when-navigating-between-categories-3b2fcc6bcd1980e996fedd639ce72b01?v=65cfcc6bcd1982a6bee688d8fd55420c&source=copy_link

* fix(infra-monitoring): get min max not detecting 1month as valid interval

Ref: https://app.notion.com/p/signoz/1month-is-not-supported-to-be-received-as-relativeTime-on-metrics-explorer-3b2fcc6bcd1980c0bd17f1e3d869e987?v=65cfcc6bcd1982a6bee688d8fd55420c&source=copy_link

* refactor(infra-monitoring): update table statistics for better auto page size

Ref: https://app.notion.com/p/signoz/change-the-baseline-values-for-calculated-page-size-3b2fcc6bcd19807caafbdedb725b6ab7?v=65cfcc6bcd1982a6bee688d8fd55420c&source=copy_link
2026-08-04 19:44:04 +00:00
Nityananda Gohain
816ae7760e feat: QB support for llm trace list and span list (#12027)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: support llm trace list and span list

* fix: take perf into consideration

* fix: more tests

* fix: more cleanup

* fix: cleanup and more tests

* fix: add resource fingerprint cte

* fix: edge cases and correct cost key

* fix: update integration test

* fix: update openapi

* fix: address comments

* fix: address comments

* fix: fix tests

* fix: add back the flag in metadata

* fix: remove source and change to builder ai query

* fix: updated openapi

* fix: minor cleanup

* fix: refactor as requested

* fix: address comments

* fix: address comments

* fix: remove tracefield. explicit rejection

* fix: remove comment

* fix: remove accidentally added file

* fix: add NewFactory

* fix: refactor integration tests

* fix: address comment

* fix: use assert

* fix: use assert and condense comments
2026-08-04 16:44:43 +00:00
Nageshbansal
985523df93 fix(version): detect docker under private cgroup namespaces (#12397)
* fix(version): detect docker under private cgroup namespaces

Docker defaults containers to a private cgroup namespace on cgroup v2
hosts (20.10+), which remaps /proc/self/cgroup to "0::/" and defeats
the runtime-name sniff, so compose installs report
deployment.mode=unknown. Check the container manager marker files
(/run/.containerenv, /.dockerenv) and the container env var first,
mirroring systemd's detect_container, and fall back to mountinfo when
the cgroup path is namespaced away.

* fix(version): drop the docker case from the container env check

docker never sets the container variable; /.dockerenv already covers it
2026-08-04 16:40:46 +00:00
Nageshbansal
5c9606bf9b feat(version): detect more cloud platforms (#12403)
Add metadata endpoint checks for openstack (covers OVHcloud), oracle,
alibaba, akamai (linode), ibm, and scaleway, and the FLY_APP_NAME
environment variable for fly.io. OpenStack must come before AWS since
it exposes an EC2-compatible endpoint.
2026-08-04 16:39:03 +00:00
Vikrant Gupta
f9461415bb chore(flagger): remove use_fine_grained_authz feature flag (#12400) 2026-08-04 16:27:28 +00:00
Tushar Vats
d93713c184 fix(querybuilder): keep DateTime64 columns native in aggregations (#12382)
A numeric coercion yields seconds since epoch, so max(timestamp) came back
as 1758113657.04 and the third-party APIs "Last Seen" rendered as January
1970. Both rules key on the physical column type, since no FieldDataType
denotes a timestamp.

- a time column is never coerced: it reaches the aggregate in its native
  type and the driver returns a time.Time
- a bare `timestamp` resolves to the intrinsic column alone; a same-named
  numeric attribute no longer joins the candidate union, where the mixed
  branches failed with "no supertype for DateTime64, Float64"
- the exists guard leaves the String bucket: `timestamp <> ''` becomes a
  typed epoch-zero comparison, verified equivalent on ClickHouse 25.5
  including a non-UTC server timezone
- max/min/quantile/count keep working; sum/avg and the rate_* variants now
  fail at the database instead of returning a rescaled number, pinned by
  integration tests until they are rejected up front
- tests/fixtures/traces.py wrote kind_string as the enum member name
  ("SPAN_KIND_CLIENT"), so any feature filtering on it matched nothing; it
  now maps the six kinds to the exporter's form ("Client")
- Last Seen assertions pin the encoding and the exact instant, rather than
  accepting either an RFC 3339 string or epoch millis
- drop the /overview/domain integration tests and the fixture surface that
  served only that route; the endpoint is no longer used
2026-08-04 15:07:48 +00:00
Vinicius Lourenço
0b5d3e939f chore(infrastructure-monitoring-v2): remove feature flag (#12398)
* Revert "chore: added infraMonitoringV2 feature flag (#12031)"

This reverts commit 1d441b63cf.

* chore(infrastructure-monitoring-v2): remove feature flag
2026-08-04 13:23:56 +00:00
Naman Verma
089f9eb4c6 chore: add api to retry migration for a dashboard (#12387)
* chore: add api to retry migration for a dashboard

* feat(dashboard): add retry migration action for legacy dashboards

The legacy-dashboard dialog only offered the dashboard ID and a link to
support. Now that the v1->v2 migration can be re-run on demand, let an
editor trigger it from there and fall back to support only if it still
fails.

Retrying needs edit access (the endpoint is EDITOR-gated), so viewers
keep the ID-and-support dialog unchanged.

---------

Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
2026-08-04 13:16:43 +00:00
Vinicius Lourenço
6c659e3b26 refactor(infrastructure-monitoring-v2): ensure pods docs redirect to right place & move events out of constants (#12359)
* fix(pods): redirecting to the wrong doc for chart

* refactor(events): move out of src/constants

* test(use-log): fix issue with mock

* docs(infra-monitoring): fix more broken doc path

* fix(pods): disable sort for pod restarts
2026-08-04 13:07:05 +00:00
124 changed files with 6281 additions and 695 deletions

View File

@@ -55,6 +55,7 @@ jobs:
- queriermetrics
- querierscalar
- queriercommon
- querierai
- rawexportdata
- promqlconformance
- querierauthz

BIN
cmd/enterprise/db-shm Normal file

Binary file not shown.

BIN
cmd/enterprise/db-wal Normal file

Binary file not shown.

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
});
}

View File

@@ -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',
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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
/>
),

View File

@@ -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>
),

View File

@@ -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>

View File

@@ -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>({

View File

@@ -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'}

View File

@@ -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'] {

View File

@@ -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} />}
>

View File

@@ -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;

View File

@@ -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}

View File

@@ -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);
});
});
});
});

View File

@@ -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(),
}));

View File

@@ -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;

View 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,
});
}

View File

@@ -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;

View File

@@ -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',

View File

@@ -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>
);
},

View File

@@ -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>
);
},

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
});
}

View File

@@ -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);

View File

@@ -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>
);
},

View File

@@ -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)',

View File

@@ -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',

View File

@@ -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)',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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>
);
},

View File

@@ -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>
);
},

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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)));
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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();
});
});

View File

@@ -1,4 +1,5 @@
export { EntityProgressBar } from './EntityProgressBar';
export { EntityProgressThresholds } from './EntityProgressThresholds';
export { ValidateColumnValueWrapper } from './ValidateColumnValueWrapper';
export { ExpandButtonWrapper } from './ExpandButtonWrapper';
export {

View File

@@ -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 });

View File

@@ -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),

View File

@@ -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 },

View File

@@ -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.
*/
/**

View File

@@ -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,
);
}

View File

@@ -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,
};
};

View File

@@ -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);

View File

@@ -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();
});
});
});

View File

@@ -252,6 +252,7 @@ function DashboardRow({
open={isLegacyDialogOpen}
dashboardId={id}
dashboardName={name}
canEdit={canEdit}
onClose={(): void => setIsLegacyDialogOpen(false)}
/>
)}

View File

@@ -49,6 +49,7 @@
.footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -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();
});
});

View File

@@ -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&apos;t been
migrated to the new dashboard experience yet, so it can&apos;t be opened
here. Share the dashboard ID below with support and we&apos;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}>

View File

@@ -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);
});
});

View File

@@ -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 };
}

View File

@@ -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 = {

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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"},

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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"
@@ -121,6 +122,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

View File

@@ -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:

View File

@@ -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 != "" {

View File

@@ -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:

View File

@@ -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)
}

View File

@@ -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{},

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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

View File

@@ -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:

View File

@@ -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) }

View 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])
}

View 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")
})
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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"}}]}}`,

View File

@@ -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.

View File

@@ -234,6 +234,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewUpdateRoleTransactionGroupsFactory(),
sqlmigration.NewFillDashboardSpecCollectionsFactory(sqlstore, dashboardStore),
sqlmigration.NewScrubEmailChannelTransportFactory(sqlstore),
sqlmigration.NewAddAiObservabilityQuickFiltersFactory(sqlstore),
)
}
@@ -293,9 +294,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),
)
}

View File

@@ -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 {

View File

@@ -0,0 +1,142 @@
package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addAiObservabilityQuickFilters struct {
sqlstore sqlstore.SQLStore
}
func NewAddAiObservabilityQuickFiltersFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_ai_observability_filters"), func(ctx context.Context, providerSettings factory.ProviderSettings, config Config) (SQLMigration, error) {
return &addAiObservabilityQuickFilters{sqlstore: sqlstore}, nil
})
}
func (migration *addAiObservabilityQuickFilters) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *addAiObservabilityQuickFilters) Up(ctx context.Context, db *bun.DB) error {
// keep in sync with the ai_observability defaults in quickfiltertypes.NewDefaultQuickFilter
aiObservabilityFilters := []map[string]interface{}{
{"key": "hasError", "dataType": "bool", "type": "tag"},
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "service.name", "dataType": "string", "type": "resource"},
{"key": telemetrytypes.GenAIOperationName, "dataType": "string", "type": "tag"},
{"key": telemetrytypes.GenAIProviderName, "dataType": "string", "type": "tag"},
{"key": telemetrytypes.GenAIRequestModel, "dataType": "string", "type": "tag"},
{"key": telemetrytypes.GenAIToolName, "dataType": "string", "type": "tag"},
{"key": telemetrytypes.GenAIAgentName, "dataType": "string", "type": "tag"},
{"key": "estimated_total_cost", "dataType": "float64", "type": "trace"},
{"key": "input_tokens", "dataType": "float64", "type": "trace"},
{"key": "output_tokens", "dataType": "float64", "type": "trace"},
{"key": "total_tokens", "dataType": "float64", "type": "trace"},
{"key": "llm_call_count", "dataType": "float64", "type": "trace"},
{"key": "tool_call_count", "dataType": "float64", "type": "trace"},
{"key": "distinct_tool_count", "dataType": "float64", "type": "trace"},
}
aiObservabilityJSON, err := json.Marshal(aiObservabilityFilters)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal ai monitoring filters")
}
type signal struct {
valuer.String
}
type identifiable struct {
ID valuer.UUID `json:"id" bun:"id,pk,type:text"`
}
type timeAuditable struct {
CreatedAt time.Time `bun:"created_at" json:"createdAt"`
UpdatedAt time.Time `bun:"updated_at" json:"updatedAt"`
}
type quickFilterType struct {
bun.BaseModel `bun:"table:quick_filter"`
identifiable
OrgID valuer.UUID `bun:"org_id,type:text,notnull"`
Filter string `bun:"filter,type:text,notnull"`
Signal signal `bun:"signal,type:text,notnull"`
timeAuditable
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
var filtersToInsert []quickFilterType
for _, orgIDStr := range orgIDs {
orgID, err := valuer.NewUUID(orgIDStr)
if err != nil {
return err
}
filtersToInsert = append(filtersToInsert, quickFilterType{
identifiable: identifiable{
ID: valuer.GenerateUUID(),
},
OrgID: orgID,
Filter: string(aiObservabilityJSON),
Signal: signal{valuer.NewString("ai_observability")},
timeAuditable: timeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
})
}
if len(filtersToInsert) > 0 {
_, err = tx.NewInsert().
Model(&filtersToInsert).
On("CONFLICT (org_id, signal) DO UPDATE").
Set("filter = EXCLUDED.filter, updated_at = EXCLUDED.updated_at").
Exec(ctx)
if err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (migration *addAiObservabilityQuickFilters) Down(ctx context.Context, db *bun.DB) error {
return nil
}

View 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",
}
}

File diff suppressed because it is too large Load Diff

View 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
}}
}

View File

@@ -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
}

View File

@@ -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)
}

View 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)},
}
}

Some files were not shown because too many files have changed in this diff Show More