mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-04 20:20:42 +01:00
Compare commits
2 Commits
main
...
tvats-flak
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ec8f81768 | ||
|
|
0fc257c4c6 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -55,7 +55,6 @@ jobs:
|
||||
- queriermetrics
|
||||
- querierscalar
|
||||
- queriercommon
|
||||
- querierai
|
||||
- rawexportdata
|
||||
- promqlconformance
|
||||
- querierauthz
|
||||
|
||||
@@ -6902,7 +6902,6 @@ 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'
|
||||
@@ -6911,7 +6910,6 @@ components:
|
||||
propertyName: type
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilder'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderAI'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeFormula'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeTraceOperator'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopePromQL'
|
||||
@@ -6926,15 +6924,6 @@ components:
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
Querybuildertypesv5QueryEnvelopeBuilderAI:
|
||||
properties:
|
||||
spec:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
|
||||
type:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQL:
|
||||
properties:
|
||||
spec:
|
||||
@@ -7048,7 +7037,6 @@ components:
|
||||
Querybuildertypesv5QueryType:
|
||||
enum:
|
||||
- builder_query
|
||||
- builder_ai_query
|
||||
- builder_formula
|
||||
- builder_trace_operator
|
||||
- clickhouse_sql
|
||||
@@ -15489,72 +15477,6 @@ paths:
|
||||
summary: Lock dashboard (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/dashboards/{id}/migrate:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'This endpoint retries the v1→v2 (Perses) migration on a dashboard
|
||||
still stored in the v1 schema and returns the v2-shape result. It is idempotent:
|
||||
a dashboard already in the v2 schema is returned unchanged.'
|
||||
operationId: MigrateDashboardV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Migrate dashboard to v2
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/factor_password/forgot:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -276,10 +276,6 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
|
||||
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.MigrateV2(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updatable)
|
||||
}
|
||||
|
||||
@@ -80,6 +80,15 @@ 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()),
|
||||
@@ -98,6 +107,15 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
infraMonitoringV2 := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureUseInfraMonitoringV2, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureUseInfraMonitoringV2.String()),
|
||||
Active: infraMonitoringV2,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
if constants.IsDotMetricsEnabled {
|
||||
for idx, feature := range featureSet {
|
||||
if feature.Name == licensetypes.DotMetricsEnabled {
|
||||
|
||||
@@ -52,8 +52,6 @@ import type {
|
||||
ListDashboardsV2200,
|
||||
ListDashboardsV2Params,
|
||||
LockDashboardV2PathParameters,
|
||||
MigrateDashboardV2200,
|
||||
MigrateDashboardV2PathParameters,
|
||||
PatchDashboardV2200,
|
||||
PatchDashboardV2PathParameters,
|
||||
PinDashboardV2PathParameters,
|
||||
@@ -1806,85 +1804,6 @@ export const useLockDashboardV2 = <
|
||||
> => {
|
||||
return useMutation(getLockDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint retries the v1→v2 (Perses) migration on a dashboard still stored in the v1 schema and returns the v2-shape result. It is idempotent: a dashboard already in the v2 schema is returned unchanged.
|
||||
* @summary Migrate dashboard to v2
|
||||
*/
|
||||
export const migrateDashboardV2 = (
|
||||
{ id }: MigrateDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<MigrateDashboardV2200>({
|
||||
url: `/api/v2/dashboards/${id}/migrate`,
|
||||
method: 'POST',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getMigrateDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof migrateDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: MigrateDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof migrateDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: MigrateDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['migrateDashboardV2'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof migrateDashboardV2>>,
|
||||
{ pathParams: MigrateDashboardV2PathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return migrateDashboardV2(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type MigrateDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof migrateDashboardV2>>
|
||||
>;
|
||||
|
||||
export type MigrateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Migrate dashboard to v2
|
||||
*/
|
||||
export const useMigrateDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof migrateDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: MigrateDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof migrateDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: MigrateDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getMigrateDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.
|
||||
* @summary Get public dashboard data (v2)
|
||||
|
||||
@@ -4301,18 +4301,6 @@ 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
|
||||
@@ -4496,7 +4484,6 @@ export interface Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO {
|
||||
|
||||
export type Querybuildertypesv5QueryEnvelopeDTO =
|
||||
| Querybuildertypesv5QueryEnvelopeBuilderDTO
|
||||
| Querybuildertypesv5QueryEnvelopeBuilderAIDTO
|
||||
| Querybuildertypesv5QueryEnvelopeFormulaDTO
|
||||
| Querybuildertypesv5QueryEnvelopeTraceOperatorDTO
|
||||
| Querybuildertypesv5QueryEnvelopePromQLDTO
|
||||
@@ -8300,7 +8287,6 @@ 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',
|
||||
@@ -11178,17 +11164,6 @@ export type UnlockDashboardV2PathParameters = {
|
||||
export type LockDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type MigrateDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type MigrateDashboardV2200 = {
|
||||
data: DashboardtypesGettableDashboardV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetFeatures200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
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',
|
||||
@@ -39,3 +45,155 @@ export enum InfraMonitoringEvents {
|
||||
StatefulSet = 'statefulSet',
|
||||
Volumes = 'volumes',
|
||||
}
|
||||
|
||||
export function logInfraFilterCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
source: 'quick_filter' | 'search' | 'host_status_toggle',
|
||||
expression: string,
|
||||
extraKeys?: string[],
|
||||
): void {
|
||||
const expressionKeys = extractQueryPairs(expression?.trim() || '').map(
|
||||
(pair) => pair.key,
|
||||
);
|
||||
|
||||
if (extraKeys) {
|
||||
extraKeys.forEach((key) => expressionKeys.push(key));
|
||||
}
|
||||
|
||||
if (expressionKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void logEvent('infra_filter_customized', {
|
||||
entity_type: entityType,
|
||||
source,
|
||||
expression_keys: [...new Set(expressionKeys)],
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraMonitoringListViewedEvent(
|
||||
entity: InfraMonitoringEntity,
|
||||
): void {
|
||||
const referrer = getNavigationReferrer();
|
||||
|
||||
void logEvent('infra_list_viewed', {
|
||||
entity,
|
||||
referrer,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraTimeRangeCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
rangeLabel: string,
|
||||
): void {
|
||||
void logEvent('infra_time_range_customized', {
|
||||
entity_type: entityType,
|
||||
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraColumnCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
columnsList: string[],
|
||||
fontSize: string,
|
||||
maxLinesPerRow: number,
|
||||
source: 'list' | 'expanded',
|
||||
): void {
|
||||
void logEvent('infra_column_customized', {
|
||||
entity_type: entityType,
|
||||
columns_list: columnsList,
|
||||
font_size: fontSize,
|
||||
max_lines_per_row: maxLinesPerRow,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraColumnSortedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
columnKey: string,
|
||||
direction: 'asc' | 'desc',
|
||||
source: 'list' | 'expanded',
|
||||
): void {
|
||||
void logEvent('infra_column_sorted', {
|
||||
entity_type: entityType,
|
||||
column_key: columnKey,
|
||||
direction,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraDrawerTimeRangeCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
rangeLabel: string,
|
||||
): void {
|
||||
void logEvent('infra_drawer_time_range_customized', {
|
||||
entity_type: entityType,
|
||||
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraDrawerFilterCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
tab: 'metrics' | 'logs' | 'traces' | 'events' | 'pod_metrics',
|
||||
expression: string,
|
||||
filterSource: 'search' | 'logs',
|
||||
): void {
|
||||
const expressionKeys = extractQueryPairs(expression?.trim() || '').map(
|
||||
(pair) => pair.key,
|
||||
);
|
||||
|
||||
if (expressionKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void logEvent('infra_drawer_filter_customized', {
|
||||
entity_type: entityType,
|
||||
tab,
|
||||
expression_keys: [...new Set(expressionKeys)],
|
||||
filter_source: filterSource,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraGroupByCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
groupByKeysList: string[],
|
||||
): void {
|
||||
void logEvent('infra_group_by_customized', {
|
||||
entity_type: entityType,
|
||||
group_by_keys_list: groupByKeysList,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraDrawerTabViewedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
tab: string,
|
||||
isDefaultTab: boolean,
|
||||
): void {
|
||||
void logEvent('infra_drawer_tab_viewed', {
|
||||
entity_type: entityType,
|
||||
tab,
|
||||
is_default_tab: isDefaultTab,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraExplorerNavigatedEvent(params: {
|
||||
entityType: InfraMonitoringEntity;
|
||||
destination:
|
||||
| 'metrics_explorer'
|
||||
| 'logs_explorer'
|
||||
| 'traces_explorer'
|
||||
| 'k8s_list';
|
||||
source: 'chart_compass_icon' | 'tab_cta_button' | 'stats_card';
|
||||
tab: string;
|
||||
sourceKey: string | null;
|
||||
drawerDurationMsAtNavigation: number | null;
|
||||
}): void {
|
||||
void logEvent('infra_explorer_navigated', {
|
||||
entity_type: params.entityType,
|
||||
destination: params.destination,
|
||||
source: params.source,
|
||||
tab: params.tab,
|
||||
source_key: params.sourceKey,
|
||||
drawer_duration_ms_at_navigation: params.drawerDurationMsAtNavigation,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export enum FeatureKeys {
|
||||
ANOMALY_DETECTION = 'anomaly_detection',
|
||||
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
|
||||
USE_JSON_BODY = 'use_json_body',
|
||||
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
|
||||
USE_INFRA_MONITORING_V2 = 'use_infra_monitoring_v2',
|
||||
ENABLE_AI_OBSERVABILITY = 'enable_ai_observability',
|
||||
ENABLE_METRICS_REDUCTION = 'enable_metrics_reduction',
|
||||
}
|
||||
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
QuickFilterChangeEventData,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraFilterCustomizedEvent,
|
||||
logInfraMonitoringListViewedEvent,
|
||||
} from 'constants/events';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import K8sBaseDetails, {
|
||||
K8sDetailsFilters,
|
||||
@@ -53,10 +57,6 @@ import styles from './InfraMonitoringHosts.module.scss';
|
||||
import { ArrowUpToLine, Filter } from '@signozhq/icons';
|
||||
import { NANO_SECOND_MULTIPLIER, useGlobalTimeStore } from 'store/globalTime';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import {
|
||||
logInfraFilterCustomizedEvent,
|
||||
logInfraMonitoringListViewedEvent,
|
||||
} from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
function Hosts(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
import { logInfraFilterCustomizedEvent } from 'constants/events';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import {
|
||||
StatusFilterValue,
|
||||
@@ -8,7 +9,6 @@ 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;
|
||||
|
||||
@@ -12,7 +12,11 @@ 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 } from 'constants/events';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraDrawerTabViewedEvent,
|
||||
logInfraExplorerNavigatedEvent,
|
||||
} from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
@@ -46,8 +50,6 @@ import { K8sBaseDetailsContentProps } from './types';
|
||||
import { getDrawerDurationMs } from './useDrawerLifecycleStore';
|
||||
|
||||
import styles from '../EntityDetailsUtils/entityDetails.module.scss';
|
||||
import { logInfraDrawerTabViewedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
|
||||
import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function K8sBaseDetailsContent<T>({
|
||||
|
||||
@@ -9,7 +9,11 @@ import TanStackTable, {
|
||||
useHiddenColumnIds,
|
||||
useTableParams,
|
||||
} from 'components/TanStackTableView';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraColumnSortedEvent,
|
||||
logInfraTimeRangeCustomizedEvent,
|
||||
} from 'constants/events';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
@@ -44,10 +48,6 @@ 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;
|
||||
|
||||
@@ -21,6 +21,7 @@ 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 {
|
||||
SelectedItemParams,
|
||||
@@ -35,7 +36,6 @@ import { useInfraMonitoringFontSize } from './useInfraMonitoringTablePreferences
|
||||
|
||||
import styles from './K8sExpandedRow.module.scss';
|
||||
import { buildExpressionFromGroupMeta } from './utils';
|
||||
import { logInfraColumnSortedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
const EXPANDED_ROW_LIMIT = 10;
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ 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 } from 'constants/events';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraFilterCustomizedEvent,
|
||||
} from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
@@ -21,7 +24,6 @@ import {
|
||||
import { useInfraMonitoringPageListing } from '../hooks';
|
||||
|
||||
import styles from './K8sHeader.module.scss';
|
||||
import { logInfraFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
interface K8sHeaderProps {
|
||||
controlListPrefix?: React.ReactNode;
|
||||
|
||||
@@ -4,7 +4,10 @@ import { Select } from 'antd';
|
||||
import { Download, SlidersVertical } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraGroupByCustomizedEvent,
|
||||
} from 'constants/events';
|
||||
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import {
|
||||
@@ -14,7 +17,6 @@ import {
|
||||
import { useInfraMonitoringGroupByData } from './useInfraMonitoringGroupByData';
|
||||
|
||||
import styles from './K8sTableToolbar.module.scss';
|
||||
import { logInfraGroupByCustomizedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
interface K8sTableToolbarProps {
|
||||
entity: InfraMonitoringEntity;
|
||||
|
||||
@@ -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('container/InfraMonitoringK8sV2/Base/events', () => ({
|
||||
jest.mock('constants/events', () => ({
|
||||
logInfraColumnCustomizedEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -16,7 +17,6 @@ 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;
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { getNavigationReferrer } from 'lib/navigation';
|
||||
import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
import { isCustomTimeRange } from 'store/globalTime';
|
||||
|
||||
export function logInfraFilterCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
source: 'quick_filter' | 'search' | 'host_status_toggle',
|
||||
expression: string,
|
||||
extraKeys?: string[],
|
||||
): void {
|
||||
const expressionKeys = extractQueryPairs(expression?.trim() || '').map(
|
||||
(pair) => pair.key,
|
||||
);
|
||||
|
||||
if (extraKeys) {
|
||||
extraKeys.forEach((key) => expressionKeys.push(key));
|
||||
}
|
||||
|
||||
if (expressionKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void logEvent('infra_filter_customized', {
|
||||
entity_type: entityType,
|
||||
source,
|
||||
expression_keys: [...new Set(expressionKeys)],
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraMonitoringListViewedEvent(
|
||||
entity: InfraMonitoringEntity,
|
||||
): void {
|
||||
const referrer = getNavigationReferrer();
|
||||
|
||||
void logEvent('infra_list_viewed', {
|
||||
entity,
|
||||
referrer,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraTimeRangeCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
rangeLabel: string,
|
||||
): void {
|
||||
void logEvent('infra_time_range_customized', {
|
||||
entity_type: entityType,
|
||||
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraColumnCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
columnsList: string[],
|
||||
fontSize: string,
|
||||
maxLinesPerRow: number,
|
||||
source: 'list' | 'expanded',
|
||||
): void {
|
||||
void logEvent('infra_column_customized', {
|
||||
entity_type: entityType,
|
||||
columns_list: columnsList,
|
||||
font_size: fontSize,
|
||||
max_lines_per_row: maxLinesPerRow,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraColumnSortedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
columnKey: string,
|
||||
direction: 'asc' | 'desc',
|
||||
source: 'list' | 'expanded',
|
||||
): void {
|
||||
void logEvent('infra_column_sorted', {
|
||||
entity_type: entityType,
|
||||
column_key: columnKey,
|
||||
direction,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraGroupByCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
groupByKeysList: string[],
|
||||
): void {
|
||||
void logEvent('infra_group_by_customized', {
|
||||
entity_type: entityType,
|
||||
group_by_keys_list: groupByKeysList,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraExplorerNavigatedEvent(params: {
|
||||
entityType: InfraMonitoringEntity;
|
||||
destination:
|
||||
| 'metrics_explorer'
|
||||
| 'logs_explorer'
|
||||
| 'traces_explorer'
|
||||
| 'k8s_list';
|
||||
source: 'chart_compass_icon' | 'tab_cta_button' | 'stats_card';
|
||||
tab: string;
|
||||
sourceKey: string | null;
|
||||
drawerDurationMsAtNavigation: number | null;
|
||||
}): void {
|
||||
void logEvent('infra_explorer_navigated', {
|
||||
entity_type: params.entityType,
|
||||
destination: params.destination,
|
||||
source: params.source,
|
||||
tab: params.tab,
|
||||
source_key: params.sourceKey,
|
||||
drawer_duration_ms_at_navigation: params.drawerDurationMsAtNavigation,
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
useColumnOrder,
|
||||
useHiddenColumnIds,
|
||||
} from 'components/TanStackTableView';
|
||||
import { logInfraColumnCustomizedEvent } from 'constants/events';
|
||||
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
useInfraMonitoringLineClamp,
|
||||
} from './useInfraMonitoringTablePreferencesStore';
|
||||
import { sortByColumnOrder } from './utils';
|
||||
import { logInfraColumnCustomizedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
interface UseEmitColumnCustomizedParams<TData> {
|
||||
entity: InfraMonitoringEntity;
|
||||
|
||||
@@ -3,7 +3,10 @@ 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 } from 'constants/events';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraDrawerTimeRangeCustomizedEvent,
|
||||
} from 'constants/events';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
@@ -14,7 +17,6 @@ import {
|
||||
import { useEntityDetailsTime } from './useEntityDetailsTime';
|
||||
|
||||
import styles from './EntityDateTimeSelector.module.scss';
|
||||
import { logInfraDrawerTimeRangeCustomizedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
|
||||
|
||||
interface EntityDateTimeSelectorProps {
|
||||
eventEntity: string;
|
||||
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
combineInitialAndUserExpression,
|
||||
getUserExpressionFromCombined,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraDrawerFilterCustomizedEvent,
|
||||
} from 'constants/events';
|
||||
import Controls from 'container/Controls';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import LoadingContainer from 'container/InfraMonitoringK8sV2/LoadingContainer';
|
||||
@@ -38,7 +41,6 @@ import { getEntityEventsQueryPayload, isEventsKeyNotFoundError } from './utils';
|
||||
|
||||
import styles from './EntityEvents.module.scss';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { logInfraDrawerFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
|
||||
|
||||
interface EventDataType {
|
||||
key: string;
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
combineInitialAndUserExpression,
|
||||
getUserExpressionFromCombined,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraDrawerFilterCustomizedEvent,
|
||||
} from 'constants/events';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
@@ -50,7 +53,6 @@ import { isModifierKeyPressed } from 'utils/app';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { logInfraDrawerFilterCustomizedEvent } from 'container/InfraMonitoringK8sV2/EntityDetailsUtils/events';
|
||||
|
||||
interface Props {
|
||||
eventEntity: string;
|
||||
|
||||
@@ -2,7 +2,10 @@ import { useCallback, useMemo, useRef } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraExplorerNavigatedEvent,
|
||||
} 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';
|
||||
@@ -32,7 +35,6 @@ import { isKeyNotFoundError } from '../utils';
|
||||
|
||||
import styles from './EntityMetrics.module.scss';
|
||||
import { MetricsTable } from './MetricsTable';
|
||||
import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
interface EntityMetricsProps<T> {
|
||||
entity: T;
|
||||
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
getUserExpressionFromCombined,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import {
|
||||
InfraMonitoringEvents,
|
||||
logInfraDrawerFilterCustomizedEvent,
|
||||
} from 'constants/events';
|
||||
import Controls from 'container/Controls';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
@@ -38,7 +41,6 @@ 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;
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
import { isCustomTimeRange } from 'store/globalTime';
|
||||
|
||||
export function logInfraDrawerTimeRangeCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
rangeLabel: string,
|
||||
): void {
|
||||
void logEvent('infra_drawer_time_range_customized', {
|
||||
entity_type: entityType,
|
||||
range_label: isCustomTimeRange(rangeLabel) ? 'custom' : rangeLabel,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraDrawerFilterCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
tab: 'metrics' | 'logs' | 'traces' | 'events' | 'pod_metrics',
|
||||
expression: string,
|
||||
filterSource: 'search' | 'logs',
|
||||
): void {
|
||||
const expressionKeys = extractQueryPairs(expression?.trim() || '').map(
|
||||
(pair) => pair.key,
|
||||
);
|
||||
|
||||
if (expressionKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void logEvent('infra_drawer_filter_customized', {
|
||||
entity_type: entityType,
|
||||
tab,
|
||||
expression_keys: [...new Set(expressionKeys)],
|
||||
filter_source: filterSource,
|
||||
});
|
||||
}
|
||||
|
||||
export function logInfraDrawerTabViewedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
tab: string,
|
||||
isDefaultTab: boolean,
|
||||
): void {
|
||||
void logEvent('infra_drawer_tab_viewed', {
|
||||
entity_type: entityType,
|
||||
tab,
|
||||
is_default_tab: isDefaultTab,
|
||||
});
|
||||
}
|
||||
@@ -51,14 +51,14 @@ import {
|
||||
} from './hooks';
|
||||
|
||||
import styles from './InfraMonitoringK8s.module.scss';
|
||||
import { 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';
|
||||
InfraMonitoringEvents,
|
||||
} from 'constants/events';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { NANO_SECOND_MULTIPLIER, useGlobalTimeStore } from 'store/globalTime';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
|
||||
export default function InfraMonitoringK8s(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
|
||||
@@ -111,8 +111,7 @@ export const namespaceWidgetInfo = [
|
||||
{
|
||||
title: 'CPU Usage (cores)',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/namespaces/#cpu-usage-cores-1',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#cpu-usage-cores',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage (bytes)',
|
||||
|
||||
@@ -57,7 +57,7 @@ export const nodeWidgetInfo = [
|
||||
{
|
||||
title: 'CPU Usage (cores)',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#cpu-usage-cores-1',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#cpu-usage-cores',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage (bytes)',
|
||||
|
||||
@@ -67,7 +67,7 @@ export const podWidgetInfo = [
|
||||
{
|
||||
title: 'CPU Usage (cores)',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/pods/#cpu-usage-cores-1',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/pods/#cpu-usage-cores',
|
||||
},
|
||||
{
|
||||
title: 'CPU Request, Limit Utilization',
|
||||
|
||||
@@ -175,7 +175,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
),
|
||||
accessorFn: (row): number => row.podRestarts,
|
||||
width: { min: 140 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
cell: ({ value, rowId }): React.ReactNode => {
|
||||
const restarts = value as number;
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { defaultFeatureFlags, render, screen } from 'tests/test-utils';
|
||||
import {
|
||||
invalidLicense,
|
||||
setupAuthzAdmin,
|
||||
@@ -58,6 +59,23 @@ 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 });
|
||||
|
||||
@@ -95,6 +113,23 @@ describe('CreateEditRolePage - Feature Gate', () => {
|
||||
const ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
|
||||
const ROLE_NAME = 'test-role';
|
||||
|
||||
it('shows error when fine-grained authz flag is inactive', async () => {
|
||||
renderEditPage(ROLE_ID, ROLE_NAME, {
|
||||
featureFlags: defaultFeatureFlags.map((f) =>
|
||||
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
|
||||
? { ...f, active: false }
|
||||
: f,
|
||||
),
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('feature-gate-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/Custom roles feature is not available/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when license is invalid', async () => {
|
||||
renderEditPage(ROLE_ID, ROLE_NAME, { activeLicense: invalidLicense });
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { defaultFeatureFlags, render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
invalidLicense,
|
||||
setupAuthzAdmin,
|
||||
@@ -32,6 +33,26 @@ describe('ViewRolePage - Feature Gate', () => {
|
||||
});
|
||||
|
||||
describe('feature disabled', () => {
|
||||
it('shows error when fine-grained authz flag is inactive', async () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
appContextOverrides: {
|
||||
featureFlags: defaultFeatureFlags.map((f) =>
|
||||
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
|
||||
? { ...f, active: false }
|
||||
: f,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('feature-gate-error-banner'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/Custom roles feature is not available/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when license is invalid', async () => {
|
||||
render(<ViewRolePage />, undefined, {
|
||||
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
|
||||
|
||||
@@ -4,7 +4,13 @@ import {
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import {
|
||||
defaultFeatureFlags,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
} from 'tests/test-utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import {
|
||||
invalidLicense,
|
||||
setupAuthzAdmin,
|
||||
@@ -185,6 +191,30 @@ 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 },
|
||||
|
||||
11
frontend/src/hooks/useIsInfraMonitoringV2.ts
Normal file
11
frontend/src/hooks/useIsInfraMonitoringV2.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
export function useIsInfraMonitoringV2(): boolean {
|
||||
const { featureFlags } = useAppContext();
|
||||
return Boolean(
|
||||
featureFlags?.find(
|
||||
(flag) => flag.name === FeatureKeys.USE_INFRA_MONITORING_V2,
|
||||
)?.active,
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { LicenseStatus } from 'types/api/licensesV3/getActive';
|
||||
|
||||
@@ -5,12 +6,22 @@ export const useRolesFeatureGate = (): {
|
||||
isRolesEnabled: boolean;
|
||||
isLoading: boolean;
|
||||
} => {
|
||||
const { activeLicense, isFetchingActiveLicense } = useAppContext();
|
||||
const {
|
||||
activeLicense,
|
||||
featureFlags,
|
||||
isFetchingActiveLicense,
|
||||
isFetchingFeatureFlags,
|
||||
} = useAppContext();
|
||||
|
||||
const isValidLicense = activeLicense?.status === LicenseStatus.VALID;
|
||||
const isFineGrainedAuthzEnabled =
|
||||
featureFlags?.find((f) => f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ)
|
||||
?.active ?? false;
|
||||
|
||||
return {
|
||||
isRolesEnabled: isValidLicense,
|
||||
isLoading: isFetchingActiveLicense && !activeLicense,
|
||||
isRolesEnabled: isValidLicense && isFineGrainedAuthzEnabled,
|
||||
isLoading:
|
||||
(isFetchingActiveLicense && !activeLicense) ||
|
||||
(isFetchingFeatureFlags && !featureFlags),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -52,12 +52,12 @@ const makeDashboard = (
|
||||
...overrides,
|
||||
}) as unknown as DashboardListItem;
|
||||
|
||||
const renderRow = (dashboard: DashboardListItem, canEdit = true): void => {
|
||||
const renderRow = (dashboard: DashboardListItem): void => {
|
||||
render(
|
||||
<DashboardRow
|
||||
dashboard={dashboard}
|
||||
index={0}
|
||||
canEdit={canEdit}
|
||||
canEdit
|
||||
showUpdatedAt={false}
|
||||
showUpdatedBy={false}
|
||||
/>,
|
||||
@@ -105,19 +105,6 @@ describe('DashboardRow', () => {
|
||||
|
||||
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('legacy-dashboard-id')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('legacy-dashboard-retry-migration'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('withholds the retry action from a row the user cannot edit', async () => {
|
||||
renderRow(makeDashboard({ legacy: true }), false);
|
||||
|
||||
await userEvent.click(screen.getByTestId('dashboard-title-0'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('legacy-dashboard-retry-migration'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -252,7 +252,6 @@ function DashboardRow({
|
||||
open={isLegacyDialogOpen}
|
||||
dashboardId={id}
|
||||
dashboardName={name}
|
||||
canEdit={canEdit}
|
||||
onClose={(): void => setIsLegacyDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -49,7 +49,6 @@
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -18,30 +18,19 @@ 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, canEdit = true } = {}): void => {
|
||||
const setup = (open = true): void => {
|
||||
render(
|
||||
<LegacyDashboardDialog
|
||||
open={open}
|
||||
dashboardId={DASHBOARD_ID}
|
||||
dashboardName="My Legacy Dashboard"
|
||||
canEdit={canEdit}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -68,31 +57,8 @@ 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({ open: false });
|
||||
setup(false);
|
||||
expect(screen.queryByTestId('legacy-dashboard-id')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ArrowUpRight, Copy, RotateCw } from '@signozhq/icons';
|
||||
import { ArrowUpRight, Copy } from '@signozhq/icons';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -9,34 +9,28 @@ 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 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.
|
||||
* 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.
|
||||
*/
|
||||
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);
|
||||
@@ -55,14 +49,6 @@ 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"
|
||||
@@ -79,15 +65,14 @@ function LegacyDashboardDialog({
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="md"
|
||||
disabled={isMigrating}
|
||||
onClick={onClose}
|
||||
testId="legacy-dashboard-close"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant={canEdit ? 'outlined' : 'solid'}
|
||||
color={canEdit ? 'secondary' : 'primary'}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
suffix={<ArrowUpRight size={14} />}
|
||||
onClick={onContactSupport}
|
||||
@@ -95,20 +80,6 @@ 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>
|
||||
}
|
||||
>
|
||||
@@ -116,10 +87,8 @@ function LegacyDashboardDialog({
|
||||
<Typography.Text className={styles.description}>
|
||||
<strong>{dashboardName || 'This dashboard'}</strong> hasn't been
|
||||
migrated to the new dashboard experience yet, so it can't be opened
|
||||
here.{' '}
|
||||
{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."}
|
||||
here. Share the dashboard ID below with support and we'll help you
|
||||
move it over.
|
||||
</Typography.Text>
|
||||
|
||||
<div className={styles.idField}>
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
invalidateListDashboardsForUserV2,
|
||||
useMigrateDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
const FAILURE_MESSAGE =
|
||||
'Could not migrate this dashboard. Please contact support.';
|
||||
|
||||
export interface UseRetryMigrationResult {
|
||||
// Re-run the v1 to v2 migration for a dashboard.
|
||||
retryMigration: (id: string) => void;
|
||||
isMigrating: boolean;
|
||||
}
|
||||
|
||||
// Wraps the retry-migration mutation for a legacy (pre-v2) dashboard: refreshes
|
||||
// the personalized list so the row loses its legacy flag, and reports the
|
||||
// backend's reason as a toast when the dashboard still can't be converted.
|
||||
export function useRetryMigration(
|
||||
onMigrated?: () => void,
|
||||
): UseRetryMigrationResult {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const migrate = useMigrateDashboardV2({
|
||||
mutation: {
|
||||
onSuccess: async (): Promise<void> => {
|
||||
await invalidateListDashboardsForUserV2(queryClient);
|
||||
toast.success('Dashboard migrated to the new experience');
|
||||
onMigrated?.();
|
||||
},
|
||||
onError: (error): void => {
|
||||
toast.error(toAPIError(error, FAILURE_MESSAGE).getErrorMessage());
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const retryMigration = useCallback(
|
||||
(id: string): void => {
|
||||
migrate.mutate({ pathParams: { id } });
|
||||
},
|
||||
[migrate],
|
||||
);
|
||||
|
||||
return { retryMigration, isMigrating: migrate.isLoading };
|
||||
}
|
||||
@@ -1,15 +1,47 @@
|
||||
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 InfraMonitoringHostsV2 from 'container/InfraMonitoringHostsV2';
|
||||
import InfraMonitoringK8sV2 from 'container/InfraMonitoringK8sV2';
|
||||
import InfraMonitoringHosts from 'container/InfraMonitoringHosts';
|
||||
import InfraMonitoringK8s from 'container/InfraMonitoringK8s';
|
||||
import { useIsInfraMonitoringV2 } from 'hooks/useIsInfraMonitoringV2';
|
||||
import { Inbox } from '@signozhq/icons';
|
||||
|
||||
const InfraMonitoringHostsV2 = Loadable(
|
||||
() => import('container/InfraMonitoringHostsV2'),
|
||||
);
|
||||
|
||||
const InfraMonitoringK8sV2 = Loadable(
|
||||
() => import('container/InfraMonitoringK8sV2'),
|
||||
);
|
||||
|
||||
function HostsContainer(): JSX.Element {
|
||||
return <InfraMonitoringHostsV2 />;
|
||||
const isInfraMonitoringV2 = useIsInfraMonitoringV2();
|
||||
|
||||
if (isInfraMonitoringV2) {
|
||||
return (
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<InfraMonitoringHostsV2 />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return <InfraMonitoringHosts />;
|
||||
}
|
||||
|
||||
function KubernetesContainer(): JSX.Element {
|
||||
return <InfraMonitoringK8sV2 />;
|
||||
const isInfraMonitoringV2 = useIsInfraMonitoringV2();
|
||||
|
||||
if (isInfraMonitoringV2) {
|
||||
return (
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<InfraMonitoringK8sV2 />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return <InfraMonitoringK8s />;
|
||||
}
|
||||
|
||||
export const Hosts: TabRoutes = {
|
||||
|
||||
@@ -150,6 +150,13 @@ export const defaultFeatureFlags = [
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
{
|
||||
name: FeatureKeys.USE_FINE_GRAINED_AUTHZ,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
];
|
||||
|
||||
export function getAppContextMock(
|
||||
|
||||
@@ -85,23 +85,6 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}/migrate", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.MigrateV2), handler.OpenAPIDef{
|
||||
ID: "MigrateDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Migrate dashboard to v2",
|
||||
Description: "This endpoint retries the v1→v2 (Perses) migration on a dashboard still stored in the v1 schema and returns the v2-shape result. It is idempotent: a dashboard already in the v2 schema is returned unchanged.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.GetV2), handler.OpenAPIDef{
|
||||
ID: "GetDashboardV2",
|
||||
Tags: []string{"dashboard"},
|
||||
|
||||
@@ -10,8 +10,11 @@ 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")
|
||||
)
|
||||
|
||||
@@ -73,6 +76,14 @@ 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,
|
||||
@@ -89,6 +100,14 @@ func MustNewRegistry() featuretypes.Registry {
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
&featuretypes.Feature{
|
||||
Name: FeatureUseInfraMonitoringV2,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
Stage: featuretypes.StageExperimental,
|
||||
Description: "Controls whether infra monitoring v2 is enabled",
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
&featuretypes.Feature{
|
||||
Name: FeatureUsePrometheusClickhouseV2,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
|
||||
@@ -63,9 +63,6 @@ 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)
|
||||
@@ -135,8 +132,6 @@ type Handler interface {
|
||||
|
||||
GetV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
MigrateV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListForUserV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -207,38 +207,6 @@ func (handler *handler) GetV2(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
|
||||
}
|
||||
|
||||
func (handler *handler) MigrateV2(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
if id == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
|
||||
return
|
||||
}
|
||||
dashboardID, err := valuer.NewUUID(id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
dashboard, err := handler.module.MigrateV2(ctx, orgID, dashboardID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
|
||||
}
|
||||
|
||||
func (handler *handler) LockV2(rw http.ResponseWriter, r *http.Request) {
|
||||
handler.lockUnlockV2(rw, r, true)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ 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"
|
||||
@@ -122,51 +121,6 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
|
||||
return storable.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
// MigrateV2 retries the v1→v2 migration on a dashboard still stored as v1 (one the
|
||||
// bulk 103 migration skipped or failed). Idempotent: an already-v2 one is unchanged.
|
||||
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
storable, err := module.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Already migrated: return as-is.
|
||||
if storable.IsV2() {
|
||||
tags, err := module.tagModule.ListForResource(ctx, orgID, coretypes.KindDashboard, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storable.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
// v1→v2 needs v5-shaped queries; run v4→v5 in place first.
|
||||
transition.NewDashboardMigrateV5(module.settings.Logger(), nil, nil).Migrate(ctx, storable.Data)
|
||||
|
||||
v2, err := storable.ConvertV1ToV2()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, v2.ID, tagtypes.NewPostableTagsFromTags(v2.Tags))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v2.Tags = resolvedTags
|
||||
|
||||
storableV2, err := v2.ToStorableDashboard()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return module.store.Update(ctx, orgID, storableV2)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return v2, nil
|
||||
}
|
||||
|
||||
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
if err := updatable.Validate(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -213,18 +213,18 @@ func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", telemetrytypes.GenAIRequestModel)},
|
||||
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", llmpricingruletypes.GenAIRequestModel)},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{Expression: "count()", Alias: "spanCount"},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: telemetrytypes.GenAIRequestModel,
|
||||
Name: llmpricingruletypes.GenAIRequestModel,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: telemetrytypes.GenAIProviderName,
|
||||
Name: llmpricingruletypes.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 telemetrytypes.GenAIRequestModel:
|
||||
case llmpricingruletypes.GenAIRequestModel:
|
||||
modelIdx = i
|
||||
case telemetrytypes.GenAIProviderName:
|
||||
case llmpricingruletypes.GenAIProviderName:
|
||||
providerIdx = i
|
||||
}
|
||||
case qbtypes.ColumnTypeAggregation:
|
||||
|
||||
@@ -250,7 +250,7 @@ func (handler *handler) ReplaceVariables(rw http.ResponseWriter, req *http.Reque
|
||||
errs := []error{}
|
||||
|
||||
for idx, item := range queryRangeRequest.CompositeQuery.Queries {
|
||||
if item.Type == qbtypes.QueryTypeBuilder || item.Type == qbtypes.QueryTypeBuilderAI {
|
||||
if item.Type == qbtypes.QueryTypeBuilder {
|
||||
switch spec := item.Spec.(type) {
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
|
||||
if spec.Filter != nil && spec.Filter.Expression != "" {
|
||||
|
||||
@@ -249,7 +249,6 @@ func (q *querier) buildPreviewProviders(
|
||||
func rendersStandaloneStatement(t qbtypes.QueryType) bool {
|
||||
switch t {
|
||||
case qbtypes.QueryTypeBuilder,
|
||||
qbtypes.QueryTypeBuilderAI,
|
||||
qbtypes.QueryTypePromQL,
|
||||
qbtypes.QueryTypeClickHouseSQL,
|
||||
qbtypes.QueryTypeTraceOperator:
|
||||
|
||||
@@ -61,7 +61,6 @@ 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]
|
||||
@@ -90,7 +89,6 @@ 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],
|
||||
@@ -113,7 +111,6 @@ func New(
|
||||
promEngine: promEngine,
|
||||
promV2: promV2,
|
||||
traceStmtBuilder: traceStmtBuilder,
|
||||
aiTraceStmtBuilder: aiTraceStmtBuilder,
|
||||
logStmtBuilder: logStmtBuilder,
|
||||
auditStmtBuilder: auditStmtBuilder,
|
||||
metricStmtBuilder: metricStmtBuilder,
|
||||
@@ -298,16 +295,6 @@ 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]:
|
||||
@@ -374,11 +361,6 @@ 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:
|
||||
@@ -941,8 +923,7 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
// 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{})
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.traceStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
|
||||
case *builderQuery[qbtypes.LogAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
@@ -1299,8 +1280,6 @@ func (q *querier) adjustStepInterval(queries []qbtypes.QueryEnvelope, start, end
|
||||
if qe.GetStepInterval().Seconds() == 0 {
|
||||
qe.SetStepInterval(secondsStep(metricRecommended))
|
||||
}
|
||||
case qbtypes.QueryTypeBuilderAI:
|
||||
clampStep(qe, traceLogRecommended, traceLogMin, &warnings)
|
||||
case qbtypes.QueryTypeTraceOperator:
|
||||
clampStep(qe, traceLogRecommended, traceLogMin, &warnings)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
|
||||
nil, // prometheus
|
||||
nil, // promV2
|
||||
nil, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
nil, // metricStmtBuilder
|
||||
@@ -124,7 +123,6 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
|
||||
nil, // prometheus
|
||||
nil, // promV2
|
||||
nil, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
&mockMetricStmtBuilder{},
|
||||
|
||||
@@ -21,7 +21,6 @@ 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],
|
||||
@@ -44,7 +43,6 @@ func NewFactory(
|
||||
prometheus,
|
||||
promV2,
|
||||
traceStmtBuilder,
|
||||
aiTraceStmtBuilder,
|
||||
logStmtBuilder,
|
||||
auditStmtBuilder,
|
||||
metricStmtBuilder,
|
||||
|
||||
@@ -1599,6 +1599,15 @@ 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()),
|
||||
@@ -1608,6 +1617,15 @@ 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 {
|
||||
|
||||
@@ -19,7 +19,6 @@ 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"
|
||||
@@ -118,8 +117,6 @@ 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)
|
||||
@@ -131,7 +128,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, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger)
|
||||
providerFactory := signozquerier.NewFactory(telemetryStore, prometheus, nil, metadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger)
|
||||
mockQuerier, err := providerFactory.New(context.Background(), providerSettings, querier.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
|
||||
nil, // prometheus
|
||||
nil, // promV2
|
||||
nil, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
metricStmtBuilder,
|
||||
@@ -78,7 +77,6 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
|
||||
nil, // prometheus
|
||||
nil, // promV2
|
||||
nil, // traceStmtBuilder
|
||||
nil, // aiTraceStmtBuilder
|
||||
logStmtBuilder,
|
||||
nil, // auditStmtBuilder
|
||||
nil, // metricStmtBuilder
|
||||
@@ -115,7 +113,6 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
|
||||
nil, // prometheus
|
||||
nil, // promV2
|
||||
traceStmtBuilder,
|
||||
nil, // aiTraceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
nil, // metricStmtBuilder
|
||||
|
||||
@@ -49,17 +49,12 @@ func ExistsExpression(columns []*schema.Column, key *telemetrytypes.TelemetryFie
|
||||
}
|
||||
return rawPath + " IS NULL", nil
|
||||
case schema.ColumnTypeEnumString,
|
||||
schema.ColumnTypeEnumFixedString:
|
||||
schema.ColumnTypeEnumFixedString,
|
||||
schema.ColumnTypeEnumDateTime64:
|
||||
if exists {
|
||||
return comparison("<>", "''"), nil
|
||||
}
|
||||
return comparison("=", "''"), nil
|
||||
case schema.ColumnTypeEnumDateTime64:
|
||||
zero := fmt.Sprintf("toDateTime64(0, %d)", column.Type.(schema.DateTime64ColumnType).Precision)
|
||||
if exists {
|
||||
return comparison("<>", zero), nil
|
||||
}
|
||||
return comparison("=", zero), nil
|
||||
case schema.ColumnTypeEnumLowCardinality:
|
||||
switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
|
||||
case schema.ColumnTypeEnumString:
|
||||
|
||||
@@ -8,7 +8,6 @@ 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"
|
||||
)
|
||||
@@ -195,16 +194,6 @@ 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) }
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
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])
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSplitFilterForAggregates(t *testing.T) {
|
||||
agg := map[string]struct{}{"completion_tokens": {}, "span_count": {}, "prompt_tokens": {}}
|
||||
|
||||
type tc struct {
|
||||
name string
|
||||
query string
|
||||
span string // expected span-level (WHERE) part; "" => empty
|
||||
having string // expected trace-level (HAVING) part; "" => empty
|
||||
wantErr bool
|
||||
}
|
||||
|
||||
cases := []tc{
|
||||
// --- empty input ---------------------------------------------------------
|
||||
{
|
||||
name: "empty",
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
query: " ",
|
||||
},
|
||||
|
||||
// --- single class --------------------------------------------------------
|
||||
{
|
||||
name: "span only",
|
||||
query: "service.name = 'x'",
|
||||
span: "service.name = 'x'",
|
||||
},
|
||||
{
|
||||
name: "agg only bare",
|
||||
query: "completion_tokens > 1000",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
{
|
||||
// the user-facing `trace.` prefix marks a trace-level aggregate.
|
||||
name: "agg only trace prefix",
|
||||
query: "trace.completion_tokens > 1000",
|
||||
having: "trace.completion_tokens > 1000",
|
||||
},
|
||||
{
|
||||
// routes trace-level so aggregate validation rejects it with a targeted error
|
||||
name: "unknown aggregate under trace context stays trace-level",
|
||||
query: "trace.not_an_aggregate > 1000",
|
||||
having: "trace.not_an_aggregate > 1000",
|
||||
},
|
||||
|
||||
{
|
||||
// ANTLR token offsets are rune indices; slicing must not shift after a multi-byte char
|
||||
name: "unicode value before the split",
|
||||
query: "service.name = 'héllo' AND completion_tokens > 1000",
|
||||
span: "service.name = 'héllo'",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
|
||||
// --- top-level AND splits across the two buckets -------------------------
|
||||
{
|
||||
name: "span AND agg",
|
||||
query: "service.name = 'x' AND completion_tokens > 1000",
|
||||
span: "service.name = 'x'",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
{
|
||||
// order within a bucket is preserved; the two span atoms join with AND.
|
||||
name: "span AND span AND agg",
|
||||
query: "service.name = 'x' AND kind_string = 'Internal' AND completion_tokens > 1000",
|
||||
span: "service.name = 'x' AND kind_string = 'Internal'",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
{
|
||||
// a parenthesized top-level AND still splits across the two buckets.
|
||||
name: "parenthesized span AND agg",
|
||||
query: "(service.name = 'x' AND completion_tokens > 1000)",
|
||||
span: "service.name = 'x'",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
|
||||
// --- OR groups are re-wrapped in parens so a later AND-join can't invert
|
||||
// precedence (`a AND (b OR c)` must not flatten to `a AND b OR c`) ------
|
||||
{
|
||||
name: "agg OR agg",
|
||||
query: "completion_tokens > 1000 OR span_count > 3",
|
||||
having: "(completion_tokens > 1000 OR span_count > 3)",
|
||||
},
|
||||
{
|
||||
name: "span OR span",
|
||||
query: "service.name = 'x' OR kind_string = 'Internal'",
|
||||
span: "(service.name = 'x' OR kind_string = 'Internal')",
|
||||
},
|
||||
{
|
||||
name: "span AND (span OR span)",
|
||||
query: "service.name = 'x' AND (kind_string = 'Internal' OR kind_string = 'Client')",
|
||||
span: "service.name = 'x' AND (kind_string = 'Internal' OR kind_string = 'Client')",
|
||||
},
|
||||
{
|
||||
name: "agg AND (agg OR agg)",
|
||||
query: "prompt_tokens > 5 AND (completion_tokens > 1000 OR span_count > 3)",
|
||||
having: "prompt_tokens > 5 AND (completion_tokens > 1000 OR span_count > 3)",
|
||||
},
|
||||
{
|
||||
// the OR group routes to span, the trailing aggregate to having.
|
||||
name: "span AND (span OR span) AND agg",
|
||||
query: "a.b = 'x' AND (c.d = 'y' OR e.f = 'z') AND completion_tokens > 1000",
|
||||
span: "a.b = 'x' AND (c.d = 'y' OR e.f = 'z')",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
|
||||
// --- a nested AND group flattens across the buckets (no spurious parens) --
|
||||
{
|
||||
name: "(span AND agg) AND agg",
|
||||
query: "(service.name = 'x' AND completion_tokens > 1000) AND prompt_tokens > 5",
|
||||
span: "service.name = 'x'",
|
||||
having: "completion_tokens > 1000 AND prompt_tokens > 5",
|
||||
},
|
||||
|
||||
// --- NOT wrapping a single-class group is routed whole to that class ------
|
||||
{
|
||||
name: "not agg",
|
||||
query: "NOT (completion_tokens > 1000)",
|
||||
having: "NOT (completion_tokens > 1000)",
|
||||
},
|
||||
{
|
||||
name: "not span",
|
||||
query: "NOT (service.name = 'x')",
|
||||
span: "NOT (service.name = 'x')",
|
||||
},
|
||||
|
||||
// --- an explicit non-trace context escapes the aggregate-alias shadow -----
|
||||
{
|
||||
// a span attribute named like an aggregate stays reachable via `attribute.`.
|
||||
name: "attribute prefix on aggregate name routes span-level",
|
||||
query: "attribute.completion_tokens > 5",
|
||||
span: "attribute.completion_tokens > 5",
|
||||
},
|
||||
{
|
||||
name: "span prefix on aggregate name routes span-level",
|
||||
query: "span.completion_tokens > 5",
|
||||
span: "span.completion_tokens > 5",
|
||||
},
|
||||
{
|
||||
name: "prefixed attribute AND bare aggregate split across buckets",
|
||||
query: "attribute.completion_tokens > 5 AND completion_tokens > 1000",
|
||||
span: "attribute.completion_tokens > 5",
|
||||
having: "completion_tokens > 1000",
|
||||
},
|
||||
|
||||
// --- class-mixing is rejected in an OR group, a NOT group, or a nested OR -
|
||||
{
|
||||
name: "agg OR span rejected",
|
||||
query: "completion_tokens > 1000 OR service.name = 'x'",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "not mixed rejected",
|
||||
query: "NOT (completion_tokens > 1000 AND service.name = 'x')",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "span AND (agg OR span) rejected",
|
||||
query: "service.name = 'x' AND (completion_tokens > 1000 OR kind_string = 'Client')",
|
||||
wantErr: true,
|
||||
},
|
||||
|
||||
// --- syntax errors are rejected, not silently dropped by error recovery ---
|
||||
{
|
||||
// recovery would yield an empty tree → both buckets empty → filter ignored.
|
||||
name: "lone paren rejected",
|
||||
query: ")",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "unbalanced parens rejected",
|
||||
query: "((",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "bare operator rejected",
|
||||
query: "AND",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
// lexer-level error: recovery drops the whole expression.
|
||||
name: "unterminated quote rejected",
|
||||
query: "'unterminated",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
// recovery would keep the rest — a partially applied filter with no error
|
||||
name: "garbage atom alongside valid agg rejected",
|
||||
query: ") AND completion_tokens > 5",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing value rejected",
|
||||
query: "completion_tokens >",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
span, having, err := SplitFilterForAggregates(c.query, agg)
|
||||
if c.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, c.span, span, "span part")
|
||||
assert.Equal(t, c.having, having, "having part")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,17 +18,6 @@ func NewHavingExpressionRewriter() *HavingExpressionRewriter {
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite rewrites and validates a HAVING expression against a caller-supplied column
|
||||
// map (user-facing name -> SQL identifier). Values are inlined, so the result is a
|
||||
// bare boolean expression with no bound args.
|
||||
func (r *HavingExpressionRewriter) Rewrite(expression string, columnMap map[string]string) (string, error) {
|
||||
if len(strings.TrimSpace(expression)) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
r.columnMap = columnMap
|
||||
return r.rewriteAndValidate(expression)
|
||||
}
|
||||
|
||||
// RewriteForTraces rewrites and validates the HAVING expression for a traces query.
|
||||
func (r *HavingExpressionRewriter) RewriteForTraces(expression string, aggregations []qbtypes.TraceAggregation) (string, error) {
|
||||
if len(strings.TrimSpace(expression)) == 0 {
|
||||
|
||||
@@ -82,9 +82,6 @@ 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():
|
||||
@@ -106,10 +103,7 @@ func resourcesForBuilderQuery(queryType string, spec gjson.Result, variables map
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return builderQueryResourceRefs(queryType, resource, spec, variables)
|
||||
}
|
||||
|
||||
func builderQueryResourceRefs(queryType string, resource coretypes.Resource, spec gjson.Result, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
|
||||
ids, err := builderQuerySelectors(queryType, spec.Get("filter.expression").String(), variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -92,20 +92,6 @@ func TestQueryRangeResources(t *testing.T) {
|
||||
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ai builder query maps to traces resource without a signal",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{"filter":{"expression":"signoz.workspace.key.id = 'checkout'"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/signoz.workspace.key.id/checkout"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ai builder query without filter is wildcard",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "promql is wildcard only",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"promql","spec":{"query":"up"}}]}}`,
|
||||
|
||||
@@ -83,10 +83,7 @@ func newFilterExpressionVisitor(opts FilterExprVisitorOpts) *filterExpressionVis
|
||||
}
|
||||
|
||||
type PreparedWhereClause struct {
|
||||
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
|
||||
WhereClause *sqlbuilder.WhereClause
|
||||
Warnings []string
|
||||
WarningsDocURL string
|
||||
RequiresCostGuard bool
|
||||
@@ -176,7 +173,7 @@ func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (PreparedWhere
|
||||
|
||||
whereClause := sqlbuilder.NewWhereClause().AddWhereExpr(visitor.builder.Args, cond)
|
||||
|
||||
return PreparedWhereClause{WhereClause: whereClause, Expr: cond, Warnings: visitor.warnings, WarningsDocURL: visitor.mainWarnURL, RequiresCostGuard: visitor.requiresCostGuard}, nil
|
||||
return PreparedWhereClause{WhereClause: whereClause, Warnings: visitor.warnings, WarningsDocURL: visitor.mainWarnURL, RequiresCostGuard: visitor.requiresCostGuard}, nil
|
||||
}
|
||||
|
||||
// Visit dispatches to the specific visit method based on node type.
|
||||
|
||||
@@ -293,9 +293,9 @@ func NewStatsReporterProviderFactories(aggregator statsreporter.Aggregator, orgG
|
||||
)
|
||||
}
|
||||
|
||||
func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, promV2 prometheus.Prometheus, metadataStore telemetrytypes.MetadataStore, traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation], 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]] {
|
||||
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]] {
|
||||
return factory.MustNewNamedMap(
|
||||
signozquerier.NewFactory(telemetryStore, prometheus, promV2, metadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
|
||||
signozquerier.NewFactory(telemetryStore, prometheus, promV2, metadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ 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"
|
||||
@@ -101,9 +100,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, 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.
|
||||
// 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.
|
||||
func newQueryStack(
|
||||
ctx context.Context,
|
||||
settings factory.ProviderSettings,
|
||||
@@ -114,7 +113,6 @@ func newQueryStack(
|
||||
) (
|
||||
telemetrytypes.MetadataStore,
|
||||
qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||
qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||
qbtypes.StatementBuilder[qbtypes.MetricAggregation],
|
||||
@@ -128,36 +126,32 @@ 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, 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
|
||||
return 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, nil, err
|
||||
return 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, nil, err
|
||||
return 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, nil, err
|
||||
return 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, nil, err
|
||||
return 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, nil, err
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
bucketCache := querier.NewBucketCache(settings, cache, config.Querier.CacheTTL, config.Querier.FluxInterval)
|
||||
|
||||
return metadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, nil
|
||||
return metadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, nil
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -342,7 +336,7 @@ func New(
|
||||
|
||||
// Assemble the query stack (metadata store, statement builders, bucket cache) once,
|
||||
// and reuse the single metadata store everywhere downstream.
|
||||
telemetryMetadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, err := newQueryStack(ctx, providerSettings, config, telemetrystore, cache, flagger)
|
||||
telemetryMetadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, err := newQueryStack(ctx, providerSettings, config, telemetrystore, cache, flagger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -352,7 +346,7 @@ func New(
|
||||
ctx,
|
||||
providerSettings,
|
||||
config.Querier,
|
||||
NewQuerierProviderFactories(telemetrystore, prometheus, promV2, telemetryMetadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
|
||||
NewQuerierProviderFactories(telemetrystore, prometheus, promV2, telemetryMetadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
|
||||
config.Querier.Provider(),
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
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
@@ -1,199 +0,0 @@
|
||||
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
|
||||
}}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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)},
|
||||
}
|
||||
}
|
||||
@@ -1,668 +0,0 @@
|
||||
package scopedtracesstatementbuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/resourcefilter"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/tracesstatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
qbvariables "github.com/SigNoz/signoz/pkg/variables"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnsupportedRequestType = errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported request type for the scoped trace builder")
|
||||
)
|
||||
|
||||
// scopedTraceStatementBuilder builds a trace list scoped to one span category
|
||||
// (e.g. gen_ai spans); the TraceScope decides which spans are in scope and which
|
||||
// per-trace columns to compute.
|
||||
type scopedTraceStatementBuilder struct {
|
||||
logger *slog.Logger
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
fm qbtypes.FieldMapper
|
||||
cb qbtypes.ConditionBuilder
|
||||
scope TraceScope
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
resourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
}
|
||||
|
||||
var _ qbtypes.StatementBuilder[qbtypes.TraceAggregation] = (*scopedTraceStatementBuilder)(nil)
|
||||
|
||||
// NewFactory returns a provider factory for a scoped trace statement builder. The
|
||||
// package is domain-neutral: the caller supplies the factory name and the TraceScope
|
||||
// (see aistatementbuilder for the gen_ai scope).
|
||||
func NewFactory(
|
||||
name factory.Name,
|
||||
scope TraceScope,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
fl flagger.Flagger,
|
||||
) factory.ProviderFactory[qbtypes.StatementBuilder[qbtypes.TraceAggregation], statementbuilder.Config] {
|
||||
return factory.NewProviderFactory(
|
||||
name,
|
||||
func(ctx context.Context, settings factory.ProviderSettings, cfg statementbuilder.Config) (qbtypes.StatementBuilder[qbtypes.TraceAggregation], error) {
|
||||
traceStmtBuilder, err := tracesstatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fm := tracestelemetryschema.NewFieldMapper()
|
||||
cb := tracestelemetryschema.NewConditionBuilder(fm)
|
||||
return NewScopedTraceStatementBuilder(settings, metadataStore, fm, cb, scope, traceStmtBuilder, fl), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// NewScopedTraceStatementBuilder wires the generic trace-list builder;
|
||||
// traceStmtBuilder is the delegate for the span-list path.
|
||||
func NewScopedTraceStatementBuilder(
|
||||
settings factory.ProviderSettings,
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
fieldMapper qbtypes.FieldMapper,
|
||||
conditionBuilder qbtypes.ConditionBuilder,
|
||||
scope TraceScope,
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
fl flagger.Flagger,
|
||||
) qbtypes.StatementBuilder[qbtypes.TraceAggregation] {
|
||||
scopedSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/statementbuilder/scopedtracesstatementbuilder")
|
||||
|
||||
resourceFilterStmtBuilder := resourcefilter.New[qbtypes.TraceAggregation](
|
||||
settings,
|
||||
tracestelemetryschema.DBName,
|
||||
tracestelemetryschema.TracesResourceV3TableName,
|
||||
telemetrytypes.SignalTraces,
|
||||
telemetrytypes.SourceUnspecified,
|
||||
metadataStore,
|
||||
nil,
|
||||
fl,
|
||||
)
|
||||
|
||||
return &scopedTraceStatementBuilder{
|
||||
logger: scopedSettings.Logger(),
|
||||
metadataStore: metadataStore,
|
||||
fm: fieldMapper,
|
||||
cb: conditionBuilder,
|
||||
scope: scope,
|
||||
traceStmtBuilder: traceStmtBuilder,
|
||||
resourceFilterStmtBuilder: resourceFilterStmtBuilder,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) Build(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start uint64,
|
||||
end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
switch requestType {
|
||||
case qbtypes.RequestTypeTrace:
|
||||
return b.buildTraceListQuery(ctx, orgID, querybuilder.ToNanoSecs(start), querybuilder.ToNanoSecs(end), query, variables)
|
||||
case qbtypes.RequestTypeRaw:
|
||||
return b.buildDelegated(ctx, orgID, start, end, requestType, query, variables)
|
||||
default:
|
||||
return nil, ErrUnsupportedRequestType
|
||||
}
|
||||
}
|
||||
|
||||
// buildDelegated ANDs the base gate into the user filter and delegates to the
|
||||
// standard trace builder (the span-list / raw path).
|
||||
func (b *scopedTraceStatementBuilder) buildDelegated(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
gate := b.scope.FilterExpression
|
||||
expr := gate
|
||||
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
|
||||
expr = fmt.Sprintf("(%s) AND (%s)", gate, query.Filter.Expression)
|
||||
}
|
||||
|
||||
// shallow copy; only Filter is replaced, caller's query untouched
|
||||
gated := query
|
||||
gated.Filter = &qbtypes.Filter{Expression: expr}
|
||||
|
||||
return b.traceStmtBuilder.Build(ctx, orgID, start, end, requestType, gated, variables)
|
||||
}
|
||||
|
||||
// buildTraceListQuery wires the CTE pipeline (start/end are nanoseconds):
|
||||
// matched (windowed, mask-pruned top-N trace_ids) → ranked (their [start,end] from
|
||||
// the summary table) → buckets (ts_bucket_start prune) → enrichment (every per-trace
|
||||
// column over each trace's full extent). Only Orderable columns are computable in the
|
||||
// matched pass, so only they can be ordered or filtered on.
|
||||
func (b *scopedTraceStatementBuilder) buildTraceListQuery(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end uint64,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
|
||||
startBucket := start/querybuilder.NsToSeconds - querybuilder.BucketAdjustment
|
||||
endBucket := end / querybuilder.NsToSeconds
|
||||
|
||||
limit := query.Limit
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// Condition args bind into the builder an expression is embedded in, so the
|
||||
// matched and enrichment passes each resolve against their own builder.
|
||||
keys, err := b.fetchKeys(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matchedSB := sqlbuilder.NewSelectBuilder()
|
||||
maskExpr, resolved, err := b.resolveFor(ctx, orgID, start, end, keys, matchedSB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enrichSB := sqlbuilder.NewSelectBuilder()
|
||||
_, enrichResolved, err := b.resolveFor(ctx, orgID, start, end, keys, enrichSB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orders, err := b.resolveListOrders(query.Order, resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orderableSet := orderableAliasSet(resolved)
|
||||
|
||||
resourceFrag, resourceArgs, resourcePred, err := b.maybeAttachResourceFilter(ctx, orgID, query, start, end, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fp, err := b.splitFilter(ctx, orgID, query, b.aggregateAliasSet(), orderableSet, start, end, variables, matchedSB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
matchedFrag, matchedArgs, err := b.buildMatchedCTE(matchedSB, start, end, startBucket, endBucket, resolved, orders, orderableSet, maskExpr, fp, resourcePred, limit, query.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rankedFrag, rankedArgs := b.buildRankedCTE(start, end)
|
||||
|
||||
adj := querybuilder.BucketAdjustment // 30-min bucket width in seconds
|
||||
bucketsFrag := fmt.Sprintf("buckets AS (SELECT DISTINCT b AS ts_bucket FROM ranked "+
|
||||
"ARRAY JOIN range("+
|
||||
"toUInt64(intDiv(toUnixTimestamp(t_start), %d) * %d - %d), "+
|
||||
"toUInt64(intDiv(toUnixTimestamp(t_end), %d) * %d + %d), "+
|
||||
"%d) AS b)", adj, adj, adj, adj, adj, adj, adj)
|
||||
|
||||
mainSQL, mainArgs := b.buildEnrichmentSelect(enrichSB, enrichResolved, orders)
|
||||
|
||||
cteFragments := []string{matchedFrag, rankedFrag, bucketsFrag}
|
||||
cteArgs := [][]any{matchedArgs, rankedArgs, nil}
|
||||
|
||||
// __resource_filter must precede `matched`, which references it.
|
||||
if resourceFrag != "" {
|
||||
cteFragments = append([]string{resourceFrag}, cteFragments...)
|
||||
cteArgs = append([][]any{resourceArgs}, cteArgs...)
|
||||
}
|
||||
|
||||
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL + " SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000"
|
||||
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
|
||||
return &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: fp.warnings,
|
||||
WarningsDocURL: fp.warningsURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// maybeAttachResourceFilter builds the __resource_filter CTE and the fingerprint
|
||||
// predicate narrowing the span scan; empty fragments when the filter has no resource
|
||||
// conditions. Deliberately no skip-fingerprint fallback: falling back would leave the
|
||||
// resource conditions in the OR'd span-filter bucket and change trace membership.
|
||||
func (b *scopedTraceStatementBuilder) maybeAttachResourceFilter(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
start, end uint64,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (cteFrag string, cteArgs []any, fingerprintPred string, err error) {
|
||||
stmt, err := b.resourceFilterStmtBuilder.Build(
|
||||
ctx, orgID, start, end, qbtypes.RequestTypeRaw, query, variables,
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, "", err
|
||||
}
|
||||
if stmt == nil {
|
||||
return "", nil, "", nil
|
||||
}
|
||||
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args,
|
||||
"resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)", nil
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) fetchKeys(ctx context.Context, orgID valuer.UUID) (map[string][]*telemetrytypes.TelemetryFieldKey, error) {
|
||||
fields := b.resolverFieldKeys()
|
||||
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(fields))
|
||||
for _, k := range fields {
|
||||
selectors = append(selectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: k.Name,
|
||||
Signal: k.Signal,
|
||||
FieldContext: k.FieldContext,
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
|
||||
})
|
||||
}
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, selectors)
|
||||
return keys, err
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) resolverFieldKeys() []*telemetrytypes.TelemetryFieldKey {
|
||||
seen := make(map[string]struct{})
|
||||
var out []*telemetrytypes.TelemetryFieldKey
|
||||
add := func(k *telemetrytypes.TelemetryFieldKey) {
|
||||
if k == nil {
|
||||
return
|
||||
}
|
||||
if _, dup := seen[k.Name]; dup {
|
||||
return
|
||||
}
|
||||
seen[k.Name] = struct{}{}
|
||||
out = append(out, k)
|
||||
}
|
||||
for _, k := range b.scope.FieldKeys {
|
||||
add(k)
|
||||
}
|
||||
for _, c := range b.scope.Columns {
|
||||
for _, k := range c.Expr.keys {
|
||||
add(k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveFor renders the gate mask and every scope column with condition args bound
|
||||
// into sb.
|
||||
func (b *scopedTraceStatementBuilder) resolveFor(ctx context.Context, orgID valuer.UUID, start, end uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey, sb *sqlbuilder.SelectBuilder) (string, []resolvedColumn, error) {
|
||||
cols := newColumnResolver(b.fm, keys)
|
||||
preds := newPredicateResolver(b.cb, keys, sb)
|
||||
maskExpr, err := b.resolveMask(ctx, orgID, start, end, preds)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
preds.maskExpr = maskExpr
|
||||
resolved, err := b.resolveColumns(ctx, orgID, start, end, cols, preds)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return maskExpr, resolved, nil
|
||||
}
|
||||
|
||||
// resolveMask builds the per-span in-scope mask: OR of the gate keys' EXISTS predicates.
|
||||
func (b *scopedTraceStatementBuilder) resolveMask(ctx context.Context, orgID valuer.UUID, start, end uint64, preds *predicateResolver) (string, error) {
|
||||
fieldKeys := b.scope.FieldKeys
|
||||
parts := make([]string, 0, len(fieldKeys))
|
||||
for _, key := range fieldKeys {
|
||||
e, err := preds.ExistsFor(ctx, orgID, start, end, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts = append(parts, e)
|
||||
}
|
||||
return "(" + strings.Join(parts, " OR ") + ")", nil
|
||||
}
|
||||
|
||||
type resolvedColumn struct {
|
||||
alias string
|
||||
expr string
|
||||
orderable bool
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) resolveColumns(ctx context.Context, orgID valuer.UUID, start, end uint64, cols *columnResolver, preds *predicateResolver) ([]resolvedColumn, error) {
|
||||
out := make([]resolvedColumn, 0, len(b.scope.Columns))
|
||||
for _, c := range b.scope.Columns {
|
||||
expr, err := c.Expr.render(ctx, orgID, start, end, cols, preds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, resolvedColumn{alias: c.Alias, expr: expr, orderable: c.Orderable})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type listOrder struct {
|
||||
alias string
|
||||
direction string
|
||||
}
|
||||
|
||||
// resolveListOrders maps order keys to resolved orderable columns; non-orderable
|
||||
// columns are rejected.
|
||||
func (b *scopedTraceStatementBuilder) resolveListOrders(order []qbtypes.OrderBy, resolved []resolvedColumn) ([]listOrder, error) {
|
||||
byAlias := make(map[string]resolvedColumn, len(resolved))
|
||||
orderable := make([]string, 0, len(resolved))
|
||||
for _, rc := range resolved {
|
||||
byAlias[rc.alias] = rc
|
||||
if rc.orderable {
|
||||
orderable = append(orderable, rc.alias)
|
||||
}
|
||||
}
|
||||
|
||||
if len(order) == 0 {
|
||||
return []listOrder{{alias: b.scope.DefaultOrderAlias, direction: "DESC"}}, nil
|
||||
}
|
||||
|
||||
orders := make([]listOrder, 0, len(order))
|
||||
for _, o := range order {
|
||||
direction := "DESC"
|
||||
if o.Direction == qbtypes.OrderDirectionAsc {
|
||||
direction = "ASC"
|
||||
}
|
||||
rc, ok := byAlias[o.Key.Name]
|
||||
if !ok || !rc.orderable {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"unsupported order key %q for the trace list; orderable keys: %s", o.Key.Name, strings.Join(orderable, ", "))
|
||||
}
|
||||
orders = append(orders, listOrder{alias: rc.alias, direction: direction})
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// filterParts is the user filter split into a span-level predicate and a trace-level
|
||||
// HAVING expression.
|
||||
type filterParts struct {
|
||||
spanPred string
|
||||
hasSpanFilter bool
|
||||
havingExpr string
|
||||
warnings []string
|
||||
warningsURL string
|
||||
}
|
||||
|
||||
// splitFilter splits query.Filter into a span-level predicate (args bound into sb)
|
||||
// and a trace-level HAVING (explicit query.Having ANDed on), then validates the
|
||||
// trace-level part against the matched-pass aggregates.
|
||||
func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, orgID valuer.UUID, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], classifySet, orderableSet map[string]struct{}, start, end uint64, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (filterParts, error) {
|
||||
var fp filterParts
|
||||
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
|
||||
spanExpr, traceExpr, err := querybuilder.SplitFilterForAggregates(query.Filter.Expression, classifySet)
|
||||
if err != nil {
|
||||
return fp, err
|
||||
}
|
||||
fp.havingExpr = traceExpr
|
||||
if strings.TrimSpace(spanExpr) != "" {
|
||||
pred, warnings, url, err := b.resolveSpanPredicate(ctx, orgID, start, end, spanExpr, variables, sb)
|
||||
if err != nil {
|
||||
return fp, err
|
||||
}
|
||||
// pred is empty when all span-level keys were resource attributes
|
||||
// already handled by __resource_filter
|
||||
if strings.TrimSpace(pred) != "" {
|
||||
fp.spanPred, fp.hasSpanFilter = pred, true
|
||||
}
|
||||
fp.warnings, fp.warningsURL = warnings, url
|
||||
}
|
||||
}
|
||||
if query.Having != nil && strings.TrimSpace(query.Having.Expression) != "" {
|
||||
if fp.havingExpr != "" {
|
||||
fp.havingExpr = fmt.Sprintf("(%s) AND (%s)", fp.havingExpr, query.Having.Expression)
|
||||
} else {
|
||||
fp.havingExpr = query.Having.Expression
|
||||
}
|
||||
}
|
||||
// the HAVING is a plain text rewrite, so substitute variables here
|
||||
if strings.TrimSpace(fp.havingExpr) != "" && len(variables) > 0 {
|
||||
replaced, err := qbvariables.ReplaceVariablesInExpression(fp.havingExpr, variables)
|
||||
if err != nil {
|
||||
return fp, err
|
||||
}
|
||||
fp.havingExpr = replaced
|
||||
}
|
||||
if err := validateAggregateFilter(fp.havingExpr, orderableSet); err != nil {
|
||||
return fp, err
|
||||
}
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
// resolveSpanPredicate resolves a span-level filter expression to a bare boolean
|
||||
// predicate, args bound into sb.
|
||||
func (b *scopedTraceStatementBuilder) resolveSpanPredicate(ctx context.Context, orgID valuer.UUID, start, end uint64, expr string, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (string, []string, string, error) {
|
||||
selectors := querybuilder.QueryStringToKeysSelectors(expr)
|
||||
for i := range selectors {
|
||||
selectors[i].Signal = telemetrytypes.SignalTraces
|
||||
}
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, selectors)
|
||||
if err != nil {
|
||||
return "", nil, "", err
|
||||
}
|
||||
prepared, err := querybuilder.PrepareWhereClause(expr, querybuilder.FilterExprVisitorOpts{
|
||||
Context: ctx,
|
||||
OrgID: orgID,
|
||||
Logger: b.logger,
|
||||
FieldMapper: b.fm,
|
||||
ConditionBuilder: b.cb,
|
||||
FieldKeys: keys,
|
||||
Builder: sb,
|
||||
// resource conditions are handled by __resource_filter
|
||||
SkipResourceFilter: true,
|
||||
Variables: variables,
|
||||
StartNs: start,
|
||||
EndNs: end,
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, "", err
|
||||
}
|
||||
if prepared.IsEmpty() {
|
||||
return "", nil, "", nil
|
||||
}
|
||||
return prepared.Expr, prepared.Warnings, prepared.WarningsDocURL, nil
|
||||
}
|
||||
|
||||
// buildMatchedCTE builds `matched`: one windowed GROUP BY trace_id scan fusing gate +
|
||||
// span filter + HAVING + ORDER BY + LIMIT/OFFSET, selecting only the aliases ORDER BY
|
||||
// / HAVING reference. Expressions carry $n markers bound to sb, so each can appear
|
||||
// several times and every occurrence resolves to the same arg.
|
||||
func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuilder, start, end, startBucket, endBucket uint64, resolved []resolvedColumn, orders []listOrder, orderableSet map[string]struct{}, maskExpr string, fp filterParts, resourcePred string, limit, offset int) (string, []any, error) {
|
||||
needed := neededMatchedAliases(orders, fp.havingExpr, orderableSet)
|
||||
selects := []string{"trace_id"}
|
||||
for _, rc := range resolved {
|
||||
if _, ok := needed[rc.alias]; !ok {
|
||||
continue
|
||||
}
|
||||
selects = append(selects, rc.expr+" AS "+quoteAlias(rc.alias))
|
||||
}
|
||||
sb.Select(selects...)
|
||||
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName))
|
||||
|
||||
// prune widened by the span filter so its spans survive for the countIf below
|
||||
prune := "(" + maskExpr
|
||||
if fp.hasSpanFilter {
|
||||
prune += " OR " + fp.spanPred
|
||||
}
|
||||
prune += ")"
|
||||
where := []string{
|
||||
sb.GE("timestamp", fmt.Sprintf("%d", start)),
|
||||
sb.L("timestamp", fmt.Sprintf("%d", end)),
|
||||
sb.GE("ts_bucket_start", startBucket),
|
||||
sb.LE("ts_bucket_start", endBucket),
|
||||
prune,
|
||||
}
|
||||
if resourcePred != "" {
|
||||
where = append(where, resourcePred)
|
||||
}
|
||||
sb.Where(where...)
|
||||
sb.GroupBy("trace_id")
|
||||
|
||||
// gate/span existence checks are only needed when the WHERE was widened;
|
||||
// otherwise the mask alone enforces the gate
|
||||
var having []string
|
||||
if fp.hasSpanFilter {
|
||||
having = append(having, "countIf("+maskExpr+") > 0")
|
||||
having = append(having, "countIf("+fp.spanPred+") > 0")
|
||||
}
|
||||
if strings.TrimSpace(fp.havingExpr) != "" {
|
||||
// the rewriter matches raw key text, so map the trace. form alongside the bare name
|
||||
columnMap := make(map[string]string, len(orderableSet)*2)
|
||||
for a := range orderableSet {
|
||||
columnMap[a] = quoteAlias(a)
|
||||
columnMap[telemetrytypes.FieldContextTrace.StringValue()+"."+a] = quoteAlias(a)
|
||||
}
|
||||
hv, err := querybuilder.NewHavingExpressionRewriter().Rewrite(fp.havingExpr, columnMap)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if hv != "" {
|
||||
// escape user text so a literal $ isn't read as an arg marker; the countIf
|
||||
// entries hold live $n markers and must stay unescaped
|
||||
having = append(having, sqlbuilder.Escape(hv))
|
||||
}
|
||||
}
|
||||
if len(having) > 0 {
|
||||
sb.Having(strings.Join(having, " AND "))
|
||||
}
|
||||
|
||||
sb.OrderBy(orderClause(orders)...)
|
||||
sb.Limit(limit)
|
||||
if offset > 0 {
|
||||
sb.Offset(offset)
|
||||
}
|
||||
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return fmt.Sprintf("matched AS (%s)", sql), args, nil
|
||||
}
|
||||
|
||||
// buildRankedCTE builds `ranked`: [start,end] bounds per matched trace from the
|
||||
// trace-summary table.
|
||||
func (b *scopedTraceStatementBuilder) buildRankedCTE(start, end uint64) (string, []any) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("trace_id", "min(start) AS t_start", "max(end) AS t_end")
|
||||
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.TraceSummaryTableName))
|
||||
sb.Where(
|
||||
"trace_id GLOBAL IN (SELECT trace_id FROM matched)",
|
||||
"end >= fromUnixTimestamp64Nano("+sb.Var(start)+")",
|
||||
"start < fromUnixTimestamp64Nano("+sb.Var(end)+")",
|
||||
)
|
||||
sb.GroupBy("trace_id")
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return fmt.Sprintf("ranked AS (%s)", sql), args
|
||||
}
|
||||
|
||||
// buildEnrichmentSelect builds the final SELECT: every per-trace column for the
|
||||
// matched traces over their full extent, scanning only their buckets.
|
||||
//
|
||||
// Accepted discrepancy: matched ranks/paginates on window-clipped values while this
|
||||
// pass ORDER BYs full-trace values, so a trace can sort differently than it ranked;
|
||||
// page membership is unaffected (LIMIT/OFFSET runs only in matched).
|
||||
func (b *scopedTraceStatementBuilder) buildEnrichmentSelect(sb *sqlbuilder.SelectBuilder, resolved []resolvedColumn, orders []listOrder) (string, []any) {
|
||||
selects := []string{"trace_id"}
|
||||
for _, rc := range resolved {
|
||||
selects = append(selects, rc.expr+" AS "+quoteAlias(rc.alias))
|
||||
}
|
||||
sb.Select(selects...)
|
||||
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName))
|
||||
sb.Where(
|
||||
"ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)",
|
||||
"trace_id GLOBAL IN (SELECT trace_id FROM ranked)",
|
||||
)
|
||||
sb.GroupBy("trace_id")
|
||||
sb.OrderBy(orderClause(orders)...)
|
||||
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
}
|
||||
|
||||
// aggregateAliasSet is every trace-level column alias, used to classify filter keys;
|
||||
// SpanLevel columns are filtered span-level, so skip them.
|
||||
func (b *scopedTraceStatementBuilder) aggregateAliasSet() map[string]struct{} {
|
||||
set := make(map[string]struct{}, len(b.scope.Columns))
|
||||
for _, c := range b.scope.Columns {
|
||||
if !c.SpanLevel {
|
||||
set[c.Alias] = struct{}{}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// orderableAliasSet is the subset of aliases computable in the matched pass.
|
||||
func orderableAliasSet(resolved []resolvedColumn) map[string]struct{} {
|
||||
set := make(map[string]struct{})
|
||||
for _, rc := range resolved {
|
||||
if rc.orderable {
|
||||
set[rc.alias] = struct{}{}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// neededMatchedAliases is the minimal alias set the matched pass must select: those
|
||||
// in ORDER BY plus those in the aggregate HAVING.
|
||||
func neededMatchedAliases(orders []listOrder, havingExpr string, orderableSet map[string]struct{}) map[string]struct{} {
|
||||
needed := make(map[string]struct{})
|
||||
for _, o := range orders {
|
||||
needed[o.alias] = struct{}{}
|
||||
}
|
||||
for _, name := range traceAggregateNames(havingExpr) {
|
||||
if _, ok := orderableSet[name]; ok {
|
||||
needed[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return needed
|
||||
}
|
||||
|
||||
// traceAggregateNames extracts the aggregate names a trace-level HAVING references;
|
||||
// only unspecified- and trace-context selectors name aggregates.
|
||||
func traceAggregateNames(havingExpr string) []string {
|
||||
var names []string
|
||||
for _, sel := range querybuilder.QueryStringToKeysSelectors(havingExpr) {
|
||||
if sel.FieldContext == telemetrytypes.FieldContextUnspecified || sel.FieldContext == telemetrytypes.FieldContextTrace {
|
||||
names = append(names, sel.Name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// validateAggregateFilter rejects a trace-level filter referencing an aggregate not
|
||||
// computable in the matched pass.
|
||||
func validateAggregateFilter(havingExpr string, orderableSet map[string]struct{}) error {
|
||||
if strings.TrimSpace(havingExpr) == "" {
|
||||
return nil
|
||||
}
|
||||
allowed := make([]string, 0, len(orderableSet))
|
||||
for a := range orderableSet {
|
||||
allowed = append(allowed, a)
|
||||
}
|
||||
sort.Strings(allowed)
|
||||
for _, name := range traceAggregateNames(havingExpr) {
|
||||
if _, ok := orderableSet[name]; !ok {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"aggregate %q cannot be used in the trace-list filter; filterable aggregates: %s", name, strings.Join(allowed, ", "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// orderClause renders the ORDER BY terms plus the trace_id tiebreak.
|
||||
func orderClause(orders []listOrder) []string {
|
||||
out := make([]string, 0, len(orders)+1)
|
||||
for _, o := range orders {
|
||||
out = append(out, fmt.Sprintf("%s %s", quoteAlias(o.alias), o.direction))
|
||||
}
|
||||
return append(out, "trace_id DESC")
|
||||
}
|
||||
|
||||
// quoteAlias backticks an alias containing characters special to the SQL builder.
|
||||
func quoteAlias(alias string) string {
|
||||
if strings.ContainsAny(alias, ".$`") {
|
||||
return "`" + alias + "`"
|
||||
}
|
||||
return alias
|
||||
}
|
||||
@@ -1168,27 +1168,6 @@ func enrichWithIntrinsicMetricKeys(keys map[string][]*telemetrytypes.TelemetryFi
|
||||
return keys
|
||||
}
|
||||
|
||||
// enrichWithGenAIKeys adds keys that can be queried for GenAI signals, even though they have not been ingested yet.
|
||||
func enrichWithGenAIKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, selectors []*telemetrytypes.FieldKeySelector) map[string][]*telemetrytypes.TelemetryFieldKey {
|
||||
for _, selector := range selectors {
|
||||
if selector.Signal != telemetrytypes.SignalTraces && selector.Signal != telemetrytypes.SignalUnspecified {
|
||||
continue
|
||||
}
|
||||
for name, def := range telemetrytypes.GenAIFieldDefinitions {
|
||||
if len(keys[name]) > 0 {
|
||||
continue // already resolved from ingested data
|
||||
}
|
||||
if !selectorMatchesIntrinsicField(selector, def) {
|
||||
continue
|
||||
}
|
||||
keyCopy := def
|
||||
keys[name] = []*telemetrytypes.TelemetryFieldKey{&keyCopy}
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func selectorMatchesIntrinsicField(selector *telemetrytypes.FieldKeySelector, definition telemetrytypes.TelemetryFieldKey) bool {
|
||||
if selector.FieldContext != telemetrytypes.FieldContextUnspecified && selector.FieldContext != definition.FieldContext {
|
||||
return false
|
||||
@@ -1274,9 +1253,6 @@ func (t *telemetryMetaStore) GetKeys(ctx context.Context, orgID valuer.UUID, fie
|
||||
|
||||
applyBackwardCompatibleKeys(mapOfKeys)
|
||||
mapOfKeys = enrichWithIntrinsicMetricKeys(mapOfKeys, selectors)
|
||||
if t.fl.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, featuretypes.NewFlaggerEvaluationContext(orgID)) {
|
||||
mapOfKeys = enrichWithGenAIKeys(mapOfKeys, selectors)
|
||||
}
|
||||
|
||||
return mapOfKeys, complete, nil
|
||||
}
|
||||
@@ -1355,9 +1331,6 @@ func (t *telemetryMetaStore) GetKeysMulti(ctx context.Context, orgID valuer.UUID
|
||||
|
||||
applyBackwardCompatibleKeys(mapOfKeys)
|
||||
mapOfKeys = enrichWithIntrinsicMetricKeys(mapOfKeys, fieldKeySelectors)
|
||||
if t.fl.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, featuretypes.NewFlaggerEvaluationContext(orgID)) {
|
||||
mapOfKeys = enrichWithGenAIKeys(mapOfKeys, fieldKeySelectors)
|
||||
}
|
||||
|
||||
return mapOfKeys, complete, nil
|
||||
}
|
||||
|
||||
@@ -382,13 +382,7 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
coerced := value
|
||||
// a time column keeps its native type; coercing it would yield seconds
|
||||
if temporal, err := m.columnIsTemporal(ctx, startNs, endNs, key); err != nil {
|
||||
return "", err
|
||||
} else if !temporal {
|
||||
coerced, _ = querybuilder.DataTypeCollisionHandledFieldName(key, dummyValue, value, qbtypes.FilterOperatorUnknown)
|
||||
}
|
||||
coerced, _ := querybuilder.DataTypeCollisionHandledFieldName(key, dummyValue, value, qbtypes.FilterOperatorUnknown)
|
||||
stmts = append(stmts, guard, coerced)
|
||||
}
|
||||
return fmt.Sprintf("multiIf(%s, NULL)", strings.Join(stmts, ", ")), nil
|
||||
@@ -427,20 +421,6 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
return fmt.Sprintf("multiIf(%s, NULL)", strings.Join(args, ", ")), nil
|
||||
}
|
||||
|
||||
// columnIsTemporal reports whether key resolves to a single time column, after evolution
|
||||
// selection. Multiple columns mean an attribute-map union, which is never temporal.
|
||||
func (m *fieldMapper) columnIsTemporal(ctx context.Context, startNs, endNs uint64, key *telemetrytypes.TelemetryFieldKey) (bool, error) {
|
||||
columns, err := m.getColumn(ctx, startNs, endNs, key)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
newColumns, _, err := qbtypes.SelectEvolutionsForColumns(columns, key.Evolutions, startNs, endNs)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return len(newColumns) == 1 && querybuilder.ColumnIsTemporal(newColumns[0]), nil
|
||||
}
|
||||
|
||||
// columnMatchesDataType reports whether a metadata field's data type is consistent with a
|
||||
// column's ClickHouse type. A bare key's column is only unioned with same-named metadata
|
||||
// keys that could be the same field; a string attribute named `timestamp` is corrupt
|
||||
@@ -452,11 +432,9 @@ func columnMatchesDataType(col *schema.Column, dt telemetrytypes.FieldDataType)
|
||||
switch col.Type.GetType() {
|
||||
case schema.ColumnTypeEnumBool:
|
||||
return dt == telemetrytypes.FieldDataTypeBool
|
||||
case schema.ColumnTypeEnumDateTime64:
|
||||
return false
|
||||
case schema.ColumnTypeEnumUInt64, schema.ColumnTypeEnumUInt32,
|
||||
schema.ColumnTypeEnumInt8, schema.ColumnTypeEnumInt16,
|
||||
schema.ColumnTypeEnumFloat64:
|
||||
schema.ColumnTypeEnumFloat64, schema.ColumnTypeEnumDateTime64:
|
||||
return dt == telemetrytypes.FieldDataTypeInt64 ||
|
||||
dt == telemetrytypes.FieldDataTypeFloat64 ||
|
||||
dt == telemetrytypes.FieldDataTypeNumber
|
||||
|
||||
@@ -202,104 +202,3 @@ func TestFieldForResourceWithEvolution(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestColumnExpressionForTemporalColumn covers the time column: ClickHouse converts it to a
|
||||
// number as seconds since epoch, so it must reach the aggregation in its native type. Every
|
||||
// exists guard and every non-temporal coercion is left exactly as it was.
|
||||
func TestColumnExpressionForTemporalColumn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tsStart := uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano())
|
||||
tsEnd := uint64(time.Date(2024, 6, 5, 0, 0, 0, 0, time.UTC).UnixNano())
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
key telemetrytypes.TelemetryFieldKey
|
||||
requiredDataType telemetrytypes.FieldDataType
|
||||
expectedResult string
|
||||
}{
|
||||
{
|
||||
name: "time column is not coerced under a numeric aggregation",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "timestamp"},
|
||||
requiredDataType: telemetrytypes.FieldDataTypeFloat64,
|
||||
expectedResult: "multiIf(timestamp <> toDateTime64(0, 9), timestamp, NULL)",
|
||||
},
|
||||
{
|
||||
name: "time column is not coerced under a group by",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "timestamp"},
|
||||
requiredDataType: telemetrytypes.FieldDataTypeString,
|
||||
expectedResult: "multiIf(timestamp <> toDateTime64(0, 9), timestamp, NULL)",
|
||||
},
|
||||
{
|
||||
name: "numeric intrinsic keeps its cast and guard",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "duration_nano"},
|
||||
requiredDataType: telemetrytypes.FieldDataTypeFloat64,
|
||||
expectedResult: "multiIf(duration_nano <> 0, accurateCastOrNull(duration_nano, 'Float64'), NULL)",
|
||||
},
|
||||
{
|
||||
name: "string intrinsic keeps its cast and guard",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "name"},
|
||||
requiredDataType: telemetrytypes.FieldDataTypeFloat64,
|
||||
expectedResult: "multiIf(name <> '', accurateCastOrNull(name, 'Float64'), NULL)",
|
||||
},
|
||||
{
|
||||
name: "map-backed attribute keeps its exists guard",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.id",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
requiredDataType: telemetrytypes.FieldDataTypeString,
|
||||
expectedResult: "multiIf(mapContains(attributes_string, 'user.id'), attributes_string['user.id'], NULL)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fm := NewFieldMapper()
|
||||
result, err := fm.ColumnExpressionFor(ctx, valuer.UUID{}, tsStart, tsEnd, &tc.key, tc.requiredDataType, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedResult, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestColumnExpressionForTimestampAttributeCollision covers a user attribute that shares its
|
||||
// name with the intrinsic time column. It must not join the candidate union: a second branch
|
||||
// would force a numeric supertype across the multiIf and put the DateTime64 back into
|
||||
// seconds since epoch. The attribute stays reachable under its explicit context.
|
||||
func TestColumnExpressionForTimestampAttributeCollision(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tsStart := uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano())
|
||||
tsEnd := uint64(time.Date(2024, 6, 5, 0, 0, 0, 0, time.UTC).UnixNano())
|
||||
|
||||
keys := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"timestamp": {
|
||||
{
|
||||
Name: "timestamp",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeNumber,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
fm := NewFieldMapper()
|
||||
|
||||
t.Run("bare timestamp resolves to the intrinsic column alone", func(t *testing.T) {
|
||||
bare := telemetrytypes.TelemetryFieldKey{Name: "timestamp"}
|
||||
result, err := fm.ColumnExpressionFor(ctx, valuer.UUID{}, tsStart, tsEnd, &bare, telemetrytypes.FieldDataTypeFloat64, keys)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "multiIf(timestamp <> toDateTime64(0, 9), timestamp, NULL)", result)
|
||||
})
|
||||
|
||||
t.Run("explicit attribute context still reaches the attribute", func(t *testing.T) {
|
||||
attr := telemetrytypes.TelemetryFieldKey{
|
||||
Name: "timestamp",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeNumber,
|
||||
}
|
||||
result, err := fm.ColumnExpressionFor(ctx, valuer.UUID{}, tsStart, tsEnd, &attr, telemetrytypes.FieldDataTypeFloat64, keys)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, "attributes_number['timestamp']")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,11 +51,11 @@ func (f *TraceTimeRangeFinder) GetTraceTimeRangeMulti(ctx context.Context, trace
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
count(),
|
||||
%s,
|
||||
%s
|
||||
toUnixTimestamp64Nano(min(start)),
|
||||
toUnixTimestamp64Nano(max(end))
|
||||
FROM %s.%s
|
||||
WHERE trace_id IN (%s)
|
||||
`, UnixNanoExpr("min(start)"), UnixNanoExpr("max(end)"), DBName, TraceSummaryTableName, strings.Join(placeholders, ", "))
|
||||
`, DBName, TraceSummaryTableName, strings.Join(placeholders, ", "))
|
||||
|
||||
row := f.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...)
|
||||
|
||||
@@ -76,9 +76,3 @@ func (f *TraceTimeRangeFinder) GetTraceTimeRangeMulti(ctx context.Context, trace
|
||||
|
||||
return startNano, endNano, true, nil
|
||||
}
|
||||
|
||||
// UnixNanoExpr renders the conversion of a timestamp-typed column expression
|
||||
// (DateTime64(9)) to Unix epoch nanoseconds.
|
||||
func UnixNanoExpr(expr string) string {
|
||||
return fmt.Sprintf("toUnixTimestamp64Nano(%s)", expr)
|
||||
}
|
||||
|
||||
@@ -179,7 +179,6 @@ func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind Pan
|
||||
normalizePreV5GroupBy(q)
|
||||
normalizePreV5PageSize(q, rowLimitPanel)
|
||||
normalizeQueryLimit(q)
|
||||
normalizeQueryOffset(q)
|
||||
if needsAggregation {
|
||||
ensureDefaultAggregation(q)
|
||||
}
|
||||
@@ -199,7 +198,6 @@ func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind Pan
|
||||
assignMissingFormulaNames(formulas)
|
||||
for _, f := range formulas {
|
||||
normalizePreV5QueryData(f, widgetType, panelKind)
|
||||
normalizeQueryLimit(f)
|
||||
name := d.readString(f, "queryName")
|
||||
env := qb.WrapInV5Envelope(name, f, string(qb.QueryTypeFormula.StringValue()))
|
||||
backfillFormulaFields(env, f)
|
||||
@@ -221,8 +219,6 @@ func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind Pan
|
||||
normalizePreV5QueryData(op, widgetType, panelKind)
|
||||
normalizePreV5GroupBy(op)
|
||||
normalizeOrderByKeys(op)
|
||||
normalizeQueryLimit(op)
|
||||
normalizeQueryOffset(op)
|
||||
name := d.readString(op, "queryName")
|
||||
out = append(out, traceOperatorEnvelope(name, expression, op))
|
||||
}
|
||||
|
||||
@@ -618,32 +618,15 @@ func normalizePreV5PageSize(query map[string]any, rowLimitPanel bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeQueryLimit coerces limit to the int the v5 decode expects: v1 stored it
|
||||
// as a string ("5") or float, both of which fail the typed decode. An unparseable
|
||||
// value or one above the v5 maximum (MaxQueryLimit) is dropped, leaving the query
|
||||
// unlimited (the field is optional).
|
||||
// normalizeQueryLimit drops a limit above the v5 maximum (MaxQueryLimit); v1 allowed
|
||||
// larger/unbounded limits, and an over-max value fails validation. Removing it leaves
|
||||
// the query unlimited (the field is optional).
|
||||
func normalizeQueryLimit(query map[string]any) {
|
||||
if query["limit"] == nil {
|
||||
return
|
||||
}
|
||||
limit, ok := coerceFloat(query["limit"])
|
||||
if !ok || limit > qb.MaxQueryLimit {
|
||||
delete(query, "limit")
|
||||
return
|
||||
}
|
||||
query["limit"] = int(limit)
|
||||
}
|
||||
|
||||
// normalizeQueryOffset coerces offset to the int the v5 decode expects; v1 could
|
||||
// store it as a string. An unparseable value is dropped (offset defaults to 0).
|
||||
func normalizeQueryOffset(query map[string]any) {
|
||||
if query["offset"] == nil {
|
||||
return
|
||||
}
|
||||
offset, ok := coerceFloat(query["offset"])
|
||||
if !ok {
|
||||
delete(query, "offset")
|
||||
return
|
||||
}
|
||||
query["offset"] = int(offset)
|
||||
if limit > qb.MaxQueryLimit {
|
||||
delete(query, "limit")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,18 @@ import (
|
||||
const (
|
||||
LLMCostFeatureType agentConf.AgentFeatureType = "llm_pricing"
|
||||
|
||||
GenAIRequestModel = "gen_ai.request.model"
|
||||
GenAIProviderName = "gen_ai.provider.name"
|
||||
GenAIUsageInputTokens = "gen_ai.usage.input_tokens"
|
||||
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
|
||||
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
|
||||
GenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
|
||||
|
||||
SignozGenAICostInput = "_signoz.gen_ai.cost_input"
|
||||
SignozGenAICostOutput = "_signoz.gen_ai.cost_output"
|
||||
SignozGenAICostCacheRead = "_signoz.gen_ai.cost_cache_read"
|
||||
SignozGenAICostCacheWrite = "_signoz.gen_ai.cost_cache_write"
|
||||
SignozGenAITotalCost = "_signoz.gen_ai.total_cost"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -84,11 +83,11 @@ func buildProcessorConfig(rules []*LLMPricingRule) *LLMPricingRuleProcessorConfi
|
||||
|
||||
return &LLMPricingRuleProcessorConfig{
|
||||
Attrs: LLMPricingRuleProcessorAttrs{
|
||||
Model: telemetrytypes.GenAIRequestModel,
|
||||
In: telemetrytypes.GenAIUsageInputTokens,
|
||||
Out: telemetrytypes.GenAIUsageOutputTokens,
|
||||
CacheRead: telemetrytypes.GenAIUsageCacheReadInputTokens,
|
||||
CacheWrite: telemetrytypes.GenAIUsageCacheCreationInputTokens,
|
||||
Model: GenAIRequestModel,
|
||||
In: GenAIUsageInputTokens,
|
||||
Out: GenAIUsageOutputTokens,
|
||||
CacheRead: GenAIUsageCacheReadInputTokens,
|
||||
CacheWrite: GenAIUsageCacheCreationInputTokens,
|
||||
},
|
||||
DefaultPricing: LLMPricingRuleProcessorDefaultPricing{
|
||||
Rules: pricingRules,
|
||||
@@ -98,7 +97,7 @@ func buildProcessorConfig(rules []*LLMPricingRule) *LLMPricingRuleProcessorConfi
|
||||
Out: SignozGenAICostOutput,
|
||||
CacheRead: SignozGenAICostCacheRead,
|
||||
CacheWrite: SignozGenAICostCacheWrite,
|
||||
Total: telemetrytypes.SignozGenAITotalCost,
|
||||
Total: SignozGenAITotalCost,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ type QueryType struct {
|
||||
var (
|
||||
QueryTypeUnknown = QueryType{valuer.NewString("unknown")}
|
||||
QueryTypeBuilder = QueryType{valuer.NewString("builder_query")}
|
||||
QueryTypeBuilderAI = QueryType{valuer.NewString("builder_ai_query")}
|
||||
QueryTypeFormula = QueryType{valuer.NewString("builder_formula")}
|
||||
QueryTypeSubQuery = QueryType{valuer.NewString("builder_sub_query")}
|
||||
QueryTypeJoin = QueryType{valuer.NewString("builder_join")}
|
||||
@@ -22,7 +21,6 @@ var (
|
||||
func (QueryType) Enum() []any {
|
||||
return []any{
|
||||
QueryTypeBuilder,
|
||||
QueryTypeBuilderAI,
|
||||
QueryTypeFormula,
|
||||
// Not yet supported.
|
||||
// QueryTypeSubQuery,
|
||||
|
||||
@@ -62,13 +62,6 @@ type queryEnvelopeBuilder struct {
|
||||
Spec builderQuerySpec `json:"spec" description:"The builder query specification."`
|
||||
}
|
||||
|
||||
// queryEnvelopeBuilderAI is the OpenAPI schema for a builder_ai_query QueryEnvelope.
|
||||
// The spec is always a traces builder query (the signal is implied by the type).
|
||||
type queryEnvelopeBuilderAI struct {
|
||||
Type QueryType `json:"type" required:"true" description:"The type of the query."`
|
||||
Spec QueryBuilderQuery[TraceAggregation] `json:"spec" description:"The AI builder query specification."`
|
||||
}
|
||||
|
||||
// queryEnvelopeFormula is the OpenAPI schema for a QueryEnvelope with type=builder_formula.
|
||||
type queryEnvelopeFormula struct {
|
||||
Type QueryType `json:"type" required:"true" description:"The type of the query."`
|
||||
@@ -107,7 +100,6 @@ var _ jsonschema.OneOfExposer = QueryEnvelope{}
|
||||
func (QueryEnvelope) JSONSchemaOneOf() []any {
|
||||
return []any{
|
||||
queryEnvelopeBuilder{},
|
||||
queryEnvelopeBuilderAI{},
|
||||
queryEnvelopeFormula{},
|
||||
// queryEnvelopeJoin{}, // deferred — see commented queryEnvelopeJoin above
|
||||
queryEnvelopeTraceOperator{},
|
||||
@@ -128,7 +120,6 @@ func (QueryEnvelope) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
"propertyName": "type",
|
||||
"mapping": map[string]string{
|
||||
QueryTypeBuilder.StringValue(): "#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilder",
|
||||
QueryTypeBuilderAI.StringValue(): "#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderAI",
|
||||
QueryTypeFormula.StringValue(): "#/components/schemas/Querybuildertypesv5QueryEnvelopeFormula",
|
||||
QueryTypeTraceOperator.StringValue(): "#/components/schemas/Querybuildertypesv5QueryEnvelopeTraceOperator",
|
||||
QueryTypePromQL.StringValue(): "#/components/schemas/Querybuildertypesv5QueryEnvelopePromQL",
|
||||
@@ -159,15 +150,6 @@ func (q *QueryEnvelope) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
q.Spec = spec
|
||||
|
||||
case QueryTypeBuilderAI:
|
||||
// the signal is implied by the type, so pin it
|
||||
var spec QueryBuilderQuery[TraceAggregation]
|
||||
if err := json.Unmarshal(shadow.Spec, &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
spec.Signal = telemetrytypes.SignalTraces
|
||||
q.Spec = spec
|
||||
|
||||
case QueryTypeFormula:
|
||||
var spec QueryBuilderFormula
|
||||
if err := json.Unmarshal(shadow.Spec, &spec); err != nil {
|
||||
@@ -212,7 +194,7 @@ func (q *QueryEnvelope) UnmarshalJSON(data []byte) error {
|
||||
"unknown query type %q",
|
||||
shadow.Type,
|
||||
).WithAdditional(
|
||||
"Valid query types are: builder_query, builder_ai_query, builder_sub_query, builder_formula, builder_join, builder_trace_operator, promql, clickhouse_sql",
|
||||
"Valid query types are: builder_query, builder_sub_query, builder_formula, builder_join, builder_trace_operator, promql, clickhouse_sql",
|
||||
).WithSuggestions(errors.NewValidReferences(errors.NounQueryTypes, QueryType{}.Enum()...))
|
||||
}
|
||||
|
||||
|
||||
@@ -641,7 +641,7 @@ func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
|
||||
// Builder query names must be unique across the composite query.
|
||||
queryNames := make(map[string]bool)
|
||||
for _, envelope := range r.CompositeQuery.Queries {
|
||||
if envelope.Type == QueryTypeBuilder || envelope.Type == QueryTypeSubQuery || envelope.Type == QueryTypeBuilderAI {
|
||||
if envelope.Type == QueryTypeBuilder || envelope.Type == QueryTypeSubQuery {
|
||||
name := envelope.GetQueryName()
|
||||
if name != "" {
|
||||
if queryNames[name] {
|
||||
@@ -710,7 +710,7 @@ func (c *CompositeQuery) Validate(opts ...ValidationOption) error {
|
||||
}
|
||||
|
||||
// Check name uniqueness for builder queries
|
||||
if envelope.Type == QueryTypeBuilder || envelope.Type == QueryTypeSubQuery || envelope.Type == QueryTypeBuilderAI {
|
||||
if envelope.Type == QueryTypeBuilder || envelope.Type == QueryTypeSubQuery {
|
||||
name := envelope.GetQueryName()
|
||||
if name != "" {
|
||||
if queryNames[name] {
|
||||
@@ -744,15 +744,6 @@ func validateQueryEnvelope(envelope QueryEnvelope, opts ...ValidationOption) err
|
||||
"unknown query spec type",
|
||||
)
|
||||
}
|
||||
case QueryTypeBuilderAI:
|
||||
spec, ok := envelope.Spec.(QueryBuilderQuery[TraceAggregation])
|
||||
if !ok {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid AI builder query spec",
|
||||
)
|
||||
}
|
||||
return spec.Validate(opts...)
|
||||
case QueryTypeFormula:
|
||||
spec, ok := envelope.Spec.(QueryBuilderFormula)
|
||||
if !ok {
|
||||
@@ -822,7 +813,7 @@ func validateQueryEnvelope(envelope QueryEnvelope, opts ...ValidationOption) err
|
||||
"unknown query type: %s",
|
||||
envelope.Type,
|
||||
).WithAdditional(
|
||||
"Valid query types are: builder_query, builder_ai_query, builder_sub_query, builder_formula, builder_join, promql, clickhouse_sql, trace_operator",
|
||||
"Valid query types are: builder_query, builder_sub_query, builder_formula, builder_join, promql, clickhouse_sql, trace_operator",
|
||||
).WithSuggestions(errors.NewValidReferences(errors.NounQueryTypes, QueryType{}.Enum()...))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,6 @@ var (
|
||||
"log": FieldContextLog,
|
||||
"metric": FieldContextMetric,
|
||||
"tracefield": FieldContextTrace,
|
||||
"trace": FieldContextTrace,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
package telemetrytypes
|
||||
|
||||
// OpenTelemetry gen_ai semantic-convention attribute keys. Single source of truth
|
||||
// shared by the AI query builder and the LLM pricing pipeline.
|
||||
const (
|
||||
GenAIRequestModel = "gen_ai.request.model"
|
||||
GenAIToolName = "gen_ai.tool.name"
|
||||
GenAIAgentName = "gen_ai.agent.name"
|
||||
GenAIProviderName = "gen_ai.provider.name"
|
||||
|
||||
GenAIUsageInputTokens = "gen_ai.usage.input_tokens"
|
||||
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
|
||||
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
|
||||
GenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
|
||||
|
||||
GenAIInputMessages = "gen_ai.input.messages"
|
||||
GenAIOutputMessages = "gen_ai.output.messages"
|
||||
|
||||
// SignozGenAITotalCost is not OTel semconv: it is the per-span cost the SigNoz
|
||||
// LLM pricing processor attaches.
|
||||
SignozGenAITotalCost = "_signoz.gen_ai.total_cost"
|
||||
)
|
||||
|
||||
// GenAIFieldDefinitions are the gen_ai span attributes the AI query builder relies
|
||||
// on, surfaced by the metadata store even before ingestion so the AI gate/columns
|
||||
// resolve on a fresh install.
|
||||
var GenAIFieldDefinitions = map[string]TelemetryFieldKey{
|
||||
GenAIRequestModel: {Name: GenAIRequestModel, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
|
||||
GenAIToolName: {Name: GenAIToolName, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
|
||||
GenAIAgentName: {Name: GenAIAgentName, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
|
||||
GenAIProviderName: {Name: GenAIProviderName, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
|
||||
|
||||
GenAIUsageInputTokens: {Name: GenAIUsageInputTokens, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
|
||||
GenAIUsageOutputTokens: {Name: GenAIUsageOutputTokens, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
|
||||
GenAIUsageCacheReadInputTokens: {Name: GenAIUsageCacheReadInputTokens, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
|
||||
GenAIUsageCacheCreationInputTokens: {Name: GenAIUsageCacheCreationInputTokens, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
|
||||
SignozGenAITotalCost: {Name: SignozGenAITotalCost, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
|
||||
|
||||
GenAIInputMessages: {Name: GenAIInputMessages, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
|
||||
GenAIOutputMessages: {Name: GenAIOutputMessages, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
|
||||
}
|
||||
@@ -10,7 +10,6 @@ const wildcardSelector = "*"
|
||||
|
||||
var telemetryGrantQueryTypes = map[string]bool{
|
||||
"builder_query": true,
|
||||
"builder_ai_query": true,
|
||||
"builder_sub_query": true,
|
||||
"promql": false,
|
||||
"clickhouse_sql": false,
|
||||
|
||||
@@ -62,23 +62,9 @@ func detectMode() string {
|
||||
return "kubernetes"
|
||||
}
|
||||
|
||||
// Check if running in podman, which sets the container environment variable
|
||||
if os.Getenv("container") == "podman" {
|
||||
return "podman"
|
||||
}
|
||||
|
||||
// Check for container manager marker files, Podman must come before Docker — mirrors systemd's detect_container to avoid mistaking another container manager for docker
|
||||
if _, err := os.Stat("/run/.containerenv"); err == nil {
|
||||
return "podman"
|
||||
}
|
||||
if _, err := os.Stat("/.dockerenv"); err == nil {
|
||||
return "docker"
|
||||
}
|
||||
|
||||
// Check if running in a container and identify the runtime
|
||||
cgroupData := ""
|
||||
if data, err := os.ReadFile("/proc/self/cgroup"); err == nil {
|
||||
cgroupData = string(data)
|
||||
cgroupData := string(data)
|
||||
switch {
|
||||
case strings.Contains(cgroupData, "docker"):
|
||||
return "docker"
|
||||
@@ -91,19 +77,6 @@ func detectMode() string {
|
||||
}
|
||||
}
|
||||
|
||||
// Check mount points when the cgroup path is namespaced away ("0::/"), Docker must come before containerd — docker's containerd snapshotter leaves io.containerd paths in plain docker containers
|
||||
if strings.TrimSpace(cgroupData) == "0::/" {
|
||||
if data, err := os.ReadFile("/proc/self/mountinfo"); err == nil {
|
||||
mountData := string(data)
|
||||
switch {
|
||||
case strings.Contains(mountData, "/docker/containers/"):
|
||||
return "docker"
|
||||
case strings.Contains(mountData, "io.containerd"):
|
||||
return "containerd"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if running as a binary
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
// Check if the executable is in a standard binary location
|
||||
@@ -141,8 +114,6 @@ func detectPlatform() string {
|
||||
return "nomad"
|
||||
case os.Getenv("CONTAINER_APP_HOSTNAME") != "":
|
||||
return "aca"
|
||||
case os.Getenv("FLY_APP_NAME") != "":
|
||||
return "flyio"
|
||||
}
|
||||
|
||||
// Try to detect cloud provider through metadata endpoints
|
||||
@@ -158,16 +129,6 @@ func detectPlatform() string {
|
||||
}
|
||||
}
|
||||
|
||||
// OpenStack metadata (OVHcloud and other OpenStack providers), Must come before AWS Detection — OpenStack exposes an EC2-compatible endpoint, causing false AWS detection.
|
||||
if req, err := http.NewRequest(http.MethodGet, "http://169.254.169.254/openstack", nil); err == nil {
|
||||
if resp, err := client.Do(req); err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
return "openstack"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AWS metadata
|
||||
if req, err := http.NewRequest(http.MethodGet, "http://169.254.169.254/latest/meta-data/", nil); err == nil {
|
||||
if resp, err := client.Do(req); err == nil {
|
||||
@@ -220,58 +181,5 @@ func detectPlatform() string {
|
||||
}
|
||||
}
|
||||
|
||||
// Oracle metadata
|
||||
if req, err := http.NewRequest(http.MethodGet, "http://169.254.169.254/opc/v2/instance/", nil); err == nil {
|
||||
req.Header.Add("Authorization", "Bearer Oracle")
|
||||
if resp, err := client.Do(req); err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
return "oracle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Alibaba metadata
|
||||
if req, err := http.NewRequest(http.MethodGet, "http://100.100.100.200/latest/meta-data/", nil); err == nil {
|
||||
if resp, err := client.Do(req); err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
return "alibaba"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Akamai (Linode) metadata
|
||||
if req, err := http.NewRequest(http.MethodPut, "http://169.254.169.254/v1/token", nil); err == nil {
|
||||
req.Header.Add("Metadata-Token-Expiry-Seconds", "60")
|
||||
if resp, err := client.Do(req); err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
return "akamai"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IBM metadata
|
||||
if req, err := http.NewRequest(http.MethodPut, "http://169.254.169.254/instance_identity/v1/token?version=2022-03-08", nil); err == nil {
|
||||
req.Header.Add("Metadata-Flavor", "ibm")
|
||||
if resp, err := client.Do(req); err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
return "ibm"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scaleway metadata
|
||||
if req, err := http.NewRequest(http.MethodGet, "http://169.254.42.42/conf", nil); err == nil {
|
||||
if resp, err := client.Do(req); err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
return "scaleway"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
14
tests/fixtures/idp.py
vendored
14
tests/fixtures/idp.py
vendored
@@ -7,6 +7,7 @@ import pytest
|
||||
import requests
|
||||
from keycloak import KeycloakAdmin
|
||||
from selenium import webdriver
|
||||
from selenium.common.exceptions import WebDriverException
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.support.wait import WebDriverWait
|
||||
@@ -370,11 +371,20 @@ def idp_login(driver: webdriver.Chrome) -> Callable[[str, str], None]:
|
||||
password_field.send_keys(password)
|
||||
|
||||
# Click the login button
|
||||
idp_host = urlparse(driver.current_url).netloc
|
||||
login_button = wait.until(EC.element_to_be_clickable((By.ID, "kc-login")))
|
||||
login_button.click()
|
||||
|
||||
# Wait till kc-login element has vanished from the page, which means that a redirection is taking place.
|
||||
wait.until(EC.invisibility_of_element((By.ID, "kc-login")))
|
||||
# Wait till the browser has left the idp host — not just the login page: keycloak's SAML flow inserts an
|
||||
# auto-submitting interstitial on the idp whose POST is what creates the user in signoz. The button is
|
||||
# re-queried per poll; a mid-navigation WebDriverException (detached node) just retries the poll.
|
||||
def _left_idp(drv: webdriver.Chrome) -> bool:
|
||||
try:
|
||||
return urlparse(drv.current_url).netloc != idp_host and not drv.find_elements(By.ID, "kc-login")
|
||||
except WebDriverException:
|
||||
return False
|
||||
|
||||
wait.until(_left_idp)
|
||||
|
||||
return _idp_login
|
||||
|
||||
|
||||
9
tests/fixtures/querier.py
vendored
9
tests/fixtures/querier.py
vendored
@@ -78,15 +78,12 @@ class BuilderQuery:
|
||||
signal: str
|
||||
name: str = "A"
|
||||
source: str | None = None
|
||||
query_type: str = "builder_query"
|
||||
limit: int | None = None
|
||||
offset: int | None = None
|
||||
filter_expression: str | None = None
|
||||
having_expression: str | None = None
|
||||
select_fields: list[TelemetryFieldKey] | None = None
|
||||
order: list[OrderBy] | None = None
|
||||
aggregations: list[Aggregation | MetricAggregation] | None = None
|
||||
group_by: list[TelemetryFieldKey] | None = None
|
||||
step_interval: int | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
@@ -102,20 +99,16 @@ class BuilderQuery:
|
||||
spec["offset"] = self.offset
|
||||
if self.filter_expression:
|
||||
spec["filter"] = {"expression": self.filter_expression}
|
||||
if self.having_expression:
|
||||
spec["having"] = {"expression": self.having_expression}
|
||||
if self.select_fields:
|
||||
spec["selectFields"] = [f.to_dict() for f in self.select_fields]
|
||||
if self.order:
|
||||
spec["order"] = [o.to_dict() if hasattr(o, "to_dict") else o for o in self.order]
|
||||
if self.aggregations:
|
||||
spec["aggregations"] = [agg.to_dict() if hasattr(agg, "to_dict") else agg for agg in self.aggregations]
|
||||
if self.group_by:
|
||||
spec["groupBy"] = [k.to_dict() for k in self.group_by]
|
||||
if self.step_interval is not None:
|
||||
spec["stepInterval"] = self.step_interval
|
||||
|
||||
return {"type": self.query_type, "spec": spec}
|
||||
return {"type": "builder_query", "spec": spec}
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
123
tests/fixtures/querierai.py
vendored
123
tests/fixtures/querierai.py
vendored
@@ -1,123 +0,0 @@
|
||||
"""
|
||||
Trace builders for the querierai suite. Every builder pins its spans a few seconds
|
||||
before the given `now` so `query_window(now)` covers them.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
|
||||
def query_window(now: datetime) -> tuple[int, int]:
|
||||
"""[now-10min, now+1min) in epoch millis — wide enough for every builder here."""
|
||||
return (
|
||||
int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int((now + timedelta(minutes=1)).timestamp() * 1000),
|
||||
)
|
||||
|
||||
|
||||
def root_span(*, now: datetime, trace_id: str, span_id: str, resources: dict[str, str], duration_s: float) -> Traces:
|
||||
"""The non-gen_ai entry span every AI trace hangs off; alone, it is a trace the
|
||||
AI gate must exclude."""
|
||||
return Traces(
|
||||
timestamp=now - timedelta(seconds=5),
|
||||
duration=timedelta(seconds=duration_s),
|
||||
trace_id=trace_id,
|
||||
span_id=span_id,
|
||||
parent_span_id="",
|
||||
name="POST /api/chat",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={"http.request.method": "POST"},
|
||||
)
|
||||
|
||||
|
||||
def ai_trace(
|
||||
*,
|
||||
now: datetime,
|
||||
service: str,
|
||||
user: str,
|
||||
in_tokens: int | None,
|
||||
out_tokens: int,
|
||||
cost: float,
|
||||
model: str = "gpt-4o-mini",
|
||||
environment: str = "production",
|
||||
) -> list[Traces]:
|
||||
"""A minimal AI trace: root span + one LLM span with gen_ai attributes.
|
||||
in_tokens=None omits the input-tokens attribute entirely (not zero)."""
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
root_id = TraceIdGenerator.span_id()
|
||||
resources = {"service.name": service, "deployment.environment": environment}
|
||||
|
||||
attributes = {
|
||||
"gen_ai.request.model": model,
|
||||
"gen_ai.system": "openai",
|
||||
"gen_ai.user.id": user,
|
||||
# numeric values land in attributes_number
|
||||
"gen_ai.usage.output_tokens": out_tokens,
|
||||
"_signoz.gen_ai.total_cost": cost,
|
||||
}
|
||||
if in_tokens is not None:
|
||||
attributes["gen_ai.usage.input_tokens"] = in_tokens
|
||||
|
||||
llm = Traces(
|
||||
timestamp=now - timedelta(seconds=4),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="chat gpt-4o-mini",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes=attributes,
|
||||
)
|
||||
return [
|
||||
root_span(now=now, trace_id=trace_id, span_id=root_id, resources=resources, duration_s=1.1),
|
||||
llm,
|
||||
]
|
||||
|
||||
|
||||
def ai_trace_mixed_spans(*, now: datetime, service: str, user: str) -> list[Traces]:
|
||||
"""Root + LLM + tool + agent spans; only the LLM span carries gen_ai.request.model."""
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
root_id = TraceIdGenerator.span_id()
|
||||
resources = {"service.name": service, "deployment.environment": "production"}
|
||||
|
||||
def child(name: str, kind: TracesKind, attributes: dict, offset_s: float) -> Traces:
|
||||
return Traces(
|
||||
timestamp=now - timedelta(seconds=offset_s),
|
||||
duration=timedelta(seconds=0.5),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name=name,
|
||||
kind=kind,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes=attributes,
|
||||
)
|
||||
|
||||
return [
|
||||
root_span(now=now, trace_id=trace_id, span_id=root_id, resources=resources, duration_s=4),
|
||||
child(
|
||||
"chat gpt-4o-mini",
|
||||
TracesKind.SPAN_KIND_CLIENT,
|
||||
{
|
||||
"gen_ai.request.model": "gpt-4o-mini",
|
||||
"gen_ai.system": "openai",
|
||||
"gen_ai.user.id": user,
|
||||
"gen_ai.usage.input_tokens": 100,
|
||||
"gen_ai.usage.output_tokens": 20,
|
||||
},
|
||||
4,
|
||||
),
|
||||
child(
|
||||
"execute_tool",
|
||||
TracesKind.SPAN_KIND_INTERNAL,
|
||||
{"gen_ai.tool.name": "get_weather", "gen_ai.tool.type": "function"},
|
||||
3,
|
||||
),
|
||||
child("agent.step", TracesKind.SPAN_KIND_INTERNAL, {"gen_ai.agent.name": "chat-agent"}, 2),
|
||||
]
|
||||
69
tests/fixtures/thirdpartyapi.py
vendored
69
tests/fixtures/thirdpartyapi.py
vendored
@@ -1,69 +0,0 @@
|
||||
"""Shared helpers for the third-party (external) API monitoring domain list.
|
||||
|
||||
A translator over v5 builder queries that answers with a UI-formatted scalar table, so the
|
||||
response is read by column rather than by series.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
|
||||
DOMAIN_LIST_ENDPOINT = "/api/v1/third-party-apis/overview/list"
|
||||
|
||||
# The group-by key the translator always prepends; its column holds the domain name.
|
||||
DOMAIN_COLUMN = "http_host"
|
||||
|
||||
REQUEST_TIMEOUT = 30
|
||||
|
||||
|
||||
def make_third_party_apis_request(
|
||||
signoz: types.SigNoz,
|
||||
token: str,
|
||||
start_ms: int,
|
||||
end_ms: int,
|
||||
*,
|
||||
show_ip: bool = True,
|
||||
filter_expression: str | None = None,
|
||||
group_by: list[dict] | None = None,
|
||||
timeout: int = REQUEST_TIMEOUT,
|
||||
) -> requests.Response:
|
||||
"""POST a domain-list request."""
|
||||
payload: dict[str, Any] = {
|
||||
"start": start_ms,
|
||||
"end": end_ms,
|
||||
"show_ip": show_ip,
|
||||
}
|
||||
if filter_expression is not None:
|
||||
payload["filter"] = {"expression": filter_expression}
|
||||
if group_by is not None:
|
||||
payload["groupBy"] = group_by
|
||||
|
||||
return requests.post(
|
||||
signoz.self.host_configs["8080"].get(DOMAIN_LIST_ENDPOINT),
|
||||
timeout=timeout,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json=payload,
|
||||
)
|
||||
|
||||
|
||||
def scalar_result(response: requests.Response) -> dict:
|
||||
"""The single scalar table from a third-party-apis response."""
|
||||
return response.json()["data"]["data"]["results"][0]
|
||||
|
||||
|
||||
def index_columns_by_query(result: dict) -> dict[str, int]:
|
||||
"""queryName -> column index, aggregation columns only; group columns are addressed by
|
||||
name (DOMAIN_COLUMN) since they share their query's name."""
|
||||
return {col["queryName"]: i for i, col in enumerate(result["columns"]) if col["columnType"] == "aggregation"}
|
||||
|
||||
|
||||
def domain_column_index(result: dict) -> int:
|
||||
return next(i for i, col in enumerate(result["columns"]) if col["name"] == DOMAIN_COLUMN)
|
||||
|
||||
|
||||
def rows_by_domain(result: dict) -> dict[str, list[Any]]:
|
||||
"""domain name -> its row. Suites share a stack, so look up the domains you seeded."""
|
||||
index = domain_column_index(result)
|
||||
return {row[index]: row for row in result["data"]}
|
||||
18
tests/fixtures/traces.py
vendored
18
tests/fixtures/traces.py
vendored
@@ -65,22 +65,6 @@ class TracesKind(Enum):
|
||||
def from_value(cls, value: int) -> "TracesKind":
|
||||
return cls(value)
|
||||
|
||||
def kind_string(self) -> str:
|
||||
"""The `kind_string` column value, mirroring ptrace.SpanKind.String() — the exporter
|
||||
writes `otelSpan.Kind().String()`. Features filter on these, e.g. third-party-apis
|
||||
requires `kind_string = 'Client'`."""
|
||||
return _KIND_STRINGS[self]
|
||||
|
||||
|
||||
_KIND_STRINGS = {
|
||||
TracesKind.SPAN_KIND_UNSPECIFIED: "Unspecified",
|
||||
TracesKind.SPAN_KIND_INTERNAL: "Internal",
|
||||
TracesKind.SPAN_KIND_SERVER: "Server",
|
||||
TracesKind.SPAN_KIND_CLIENT: "Client",
|
||||
TracesKind.SPAN_KIND_PRODUCER: "Producer",
|
||||
TracesKind.SPAN_KIND_CONSUMER: "Consumer",
|
||||
}
|
||||
|
||||
|
||||
class TracesStatusCode(Enum):
|
||||
STATUS_CODE_UNSET = 0
|
||||
@@ -360,7 +344,7 @@ class Traces(ABC):
|
||||
self.flags = flags
|
||||
self.name = name
|
||||
self.kind = kind.value
|
||||
self.kind_string = kind.kind_string()
|
||||
self.kind_string = kind.name
|
||||
self.status_code = status_code.value
|
||||
self.status_message = status_message
|
||||
self.status_code_string = status_code.name
|
||||
|
||||
@@ -1,632 +0,0 @@
|
||||
"""
|
||||
Integration tests for query_type="builder_ai_query" over the traces signal.
|
||||
|
||||
Each test tags its spans with a unique service.name and filters on it, so tests do
|
||||
not interfere with each other's data. Shared builders live in fixtures/querierai.py.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.querier import (
|
||||
BuilderQuery,
|
||||
OrderBy,
|
||||
RequestType,
|
||||
TelemetryFieldKey,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.querierai import (
|
||||
ai_trace,
|
||||
ai_trace_mixed_spans,
|
||||
query_window,
|
||||
root_span,
|
||||
)
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
|
||||
def test_ai_list_excludes_non_ai(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""Trace list returns AI traces and excludes the non-AI trace."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-list"
|
||||
|
||||
ai = ai_trace(now=now, service=service, user="alice", in_tokens=100, out_tokens=20, cost=0.5)
|
||||
# a lone root span, i.e. a trace with no gen_ai spans at all
|
||||
non_ai = root_span(
|
||||
now=now,
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
resources={"service.name": service},
|
||||
duration_s=1,
|
||||
)
|
||||
ai_trace_id = ai[0].trace_id
|
||||
insert_traces([*ai, non_ai])
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
query = BuilderQuery(
|
||||
signal="traces",
|
||||
query_type="builder_ai_query",
|
||||
name="A",
|
||||
filter_expression=f"service.name = '{service}'",
|
||||
limit=10,
|
||||
)
|
||||
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
body = json.dumps(response.json())
|
||||
assert ai_trace_id in body, f"expected AI trace {ai_trace_id} in list response"
|
||||
assert non_ai.trace_id not in body, f"non-AI trace {non_ai.trace_id} should be excluded by the gate"
|
||||
|
||||
|
||||
def test_ai_list_having_aggregate_filter(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""Span + aggregate condition in one filter box splits into WHERE + HAVING; bare
|
||||
and `trace.` spellings behave identically; an output-only aggregate is rejected."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-having"
|
||||
|
||||
small = ai_trace(now=now, service=service, user="alice", in_tokens=10, out_tokens=20, cost=0.1)
|
||||
large = ai_trace(now=now, service=service, user="bob", in_tokens=10, out_tokens=500, cost=0.2)
|
||||
small_id = small[0].trace_id
|
||||
large_id = large[0].trace_id
|
||||
insert_traces(small + large)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
for spelling in ("output_tokens", "trace.output_tokens"):
|
||||
query = BuilderQuery(
|
||||
signal="traces",
|
||||
query_type="builder_ai_query",
|
||||
name="A",
|
||||
filter_expression=f"service.name = '{service}' AND {spelling} > 100",
|
||||
limit=10,
|
||||
)
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
|
||||
assert response.status_code == HTTPStatus.OK, f"{spelling}: {response.text}"
|
||||
|
||||
body = json.dumps(response.json())
|
||||
assert large_id in body, f"{spelling}: trace with 500 out-tokens should pass > 100"
|
||||
assert small_id not in body, f"{spelling}: trace with 20 out-tokens should be filtered out by HAVING"
|
||||
|
||||
# output-only aggregate gets the targeted rejection.
|
||||
bad = BuilderQuery(
|
||||
signal="traces",
|
||||
query_type="builder_ai_query",
|
||||
name="A",
|
||||
filter_expression="trace.span_count > 3",
|
||||
limit=10,
|
||||
)
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [bad.to_dict()], request_type="trace")
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert "cannot be used" in response.text
|
||||
|
||||
|
||||
def test_ai_list_order_limit_offset(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""Trace list honors order by (aggregate column) + limit + offset (pagination)."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-order"
|
||||
|
||||
traces: list[Traces] = []
|
||||
for out in (100, 200, 300, 400, 500):
|
||||
traces += ai_trace(now=now, service=service, user="u", in_tokens=10, out_tokens=out, cost=0.1)
|
||||
insert_traces(traces)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
def page(offset: int) -> list[int]:
|
||||
query = BuilderQuery(
|
||||
signal="traces",
|
||||
query_type="builder_ai_query",
|
||||
name="A",
|
||||
filter_expression=f"service.name = '{service}'",
|
||||
order=[OrderBy(key=TelemetryFieldKey(name="output_tokens"), direction="desc")],
|
||||
limit=2,
|
||||
offset=offset,
|
||||
)
|
||||
resp = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
rows = resp.json()["data"]["data"]["results"][0]["rows"]
|
||||
return [int(r["data"]["output_tokens"]) for r in rows]
|
||||
|
||||
assert page(0) == [500, 400], "first page: highest output_tokens, desc"
|
||||
assert page(2) == [300, 200], "second page (offset 2): next two, desc"
|
||||
|
||||
|
||||
def test_ai_span_list_limit(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""Span list honors limit (delegated raw path): 6 gen_ai spans available, capped to 4."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-spanlimit"
|
||||
insert_traces(ai_trace_mixed_spans(now=now, service=service, user="a") + ai_trace_mixed_spans(now=now, service=service, user="b"))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
query = BuilderQuery(
|
||||
signal="traces",
|
||||
query_type="builder_ai_query",
|
||||
name="A",
|
||||
filter_expression=f"service.name = '{service}'",
|
||||
limit=4,
|
||||
)
|
||||
resp = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type=RequestType.RAW)
|
||||
assert resp.status_code == HTTPStatus.OK, resp.text
|
||||
rows = resp.json()["data"]["data"]["results"][0]["rows"]
|
||||
assert len(rows) == 4, f"limit should cap at 4 (6 gen_ai spans available), got {len(rows)}"
|
||||
|
||||
|
||||
def test_ai_span_list_excludes_non_gen_ai_spans(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""Span list (raw) returns only gen_ai spans; the root span is excluded by the gate."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-spanlist"
|
||||
insert_traces(ai_trace_mixed_spans(now=now, service=service, user="alice"))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
query = BuilderQuery(
|
||||
signal="traces",
|
||||
query_type="builder_ai_query",
|
||||
name="A",
|
||||
filter_expression=f"service.name = '{service}'",
|
||||
select_fields=[TelemetryFieldKey(name="name", field_context="span")],
|
||||
limit=50,
|
||||
)
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type=RequestType.RAW)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
rows = response.json()["data"]["data"]["results"][0]["rows"]
|
||||
names = sorted(r["data"]["name"] for r in rows)
|
||||
assert names == ["agent.step", "chat gpt-4o-mini", "execute_tool"], names
|
||||
assert "POST /api/chat" not in names # root span excluded
|
||||
|
||||
|
||||
def test_ai_list_having_or_aggregates(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""Two trace-level aggregates OR-ed in the filter box (regression guard for
|
||||
OR-group whitespace handling)."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-having-or"
|
||||
|
||||
small = ai_trace(now=now, service=service, user="a", in_tokens=10, out_tokens=20, cost=0.1)
|
||||
large = ai_trace(now=now, service=service, user="b", in_tokens=10, out_tokens=500, cost=0.2)
|
||||
small_id, large_id = small[0].trace_id, large[0].trace_id
|
||||
insert_traces(small + large)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
query = BuilderQuery(
|
||||
signal="traces",
|
||||
query_type="builder_ai_query",
|
||||
name="A",
|
||||
filter_expression=f"service.name = '{service}' AND (output_tokens > 100 OR input_tokens > 1000)",
|
||||
limit=10,
|
||||
)
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
body = json.dumps(response.json())
|
||||
assert large_id in body
|
||||
assert small_id not in body
|
||||
|
||||
|
||||
def test_ai_list_resource_filter_isolates_by_fingerprint(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""A resource attribute in the filter routes into the fingerprint CTE: the
|
||||
production trace is kept, the staging one dropped."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-resfilter"
|
||||
|
||||
prod = ai_trace(now=now, service=service, user="a", in_tokens=10, out_tokens=20, cost=0.1, environment="production")
|
||||
stag = ai_trace(now=now, service=service, user="b", in_tokens=10, out_tokens=20, cost=0.1, environment="staging")
|
||||
prod_id, stag_id = prod[0].trace_id, stag[0].trace_id
|
||||
insert_traces(prod + stag)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
query = BuilderQuery(
|
||||
signal="traces",
|
||||
query_type="builder_ai_query",
|
||||
name="A",
|
||||
filter_expression=(f"resource.service.name = '{service}' AND resource.deployment.environment = 'production'"),
|
||||
limit=10,
|
||||
)
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
body = json.dumps(response.json())
|
||||
assert prod_id in body, "production trace should match the resource filter"
|
||||
assert stag_id not in body, "staging trace should be excluded by the resource fingerprint prune"
|
||||
|
||||
|
||||
def test_ai_list_rejects_aggregate_or_span_filter(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""Aggregate columns may not be OR-ed with span-level keys; span OR span is fine."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-orfilter"
|
||||
# seed a trace so service.name resolves as a known key in this window (resource
|
||||
# keys are discovered from ingested data).
|
||||
insert_traces(ai_trace(now=now, service=service, user="a", in_tokens=10, out_tokens=20, cost=0.1))
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
# aggregate OR span -> rejected
|
||||
bad = BuilderQuery(
|
||||
signal="traces",
|
||||
query_type="builder_ai_query",
|
||||
name="A",
|
||||
limit=10,
|
||||
filter_expression=f"output_tokens > 1000 OR service.name = '{service}'",
|
||||
)
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [bad.to_dict()], request_type="trace")
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert "cannot be combined" in response.text
|
||||
|
||||
# span OR span -> accepted (result content doesn't matter; just not an error)
|
||||
ok = BuilderQuery(
|
||||
signal="traces",
|
||||
query_type="builder_ai_query",
|
||||
name="A",
|
||||
limit=10,
|
||||
filter_expression=f"service.name = '{service}' OR has_error = true",
|
||||
)
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [ok.to_dict()], request_type="trace")
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
|
||||
def test_ai_list_nested_group_span_or_and_aggregate(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""service.name = X AND (has_error = true OR gen_ai.request.model = 'gpt-4o') AND
|
||||
total_tokens > 100: the nested OR group must not flatten, span predicates go to
|
||||
WHERE, the aggregate to HAVING."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-nested"
|
||||
|
||||
t_ok = ai_trace(now=now, service=service, user="a", model="gpt-4o", in_tokens=10, out_tokens=500, cost=0.1)
|
||||
t_or_miss = ai_trace(now=now, service=service, user="b", model="gpt-4o-mini", in_tokens=10, out_tokens=500, cost=0.1)
|
||||
t_agg_miss = ai_trace(now=now, service=service, user="c", model="gpt-4o", in_tokens=10, out_tokens=20, cost=0.1)
|
||||
insert_traces(t_ok + t_or_miss + t_agg_miss)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
query = BuilderQuery(
|
||||
signal="traces",
|
||||
query_type="builder_ai_query",
|
||||
name="A",
|
||||
filter_expression=(f"service.name = '{service}' AND (has_error = true OR gen_ai.request.model = 'gpt-4o') AND total_tokens > 100"),
|
||||
limit=10,
|
||||
)
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
body = json.dumps(response.json())
|
||||
assert t_ok[0].trace_id in body
|
||||
assert t_or_miss[0].trace_id not in body, "nested (span OR span) group must exclude the wrong-model, no-error trace"
|
||||
assert t_agg_miss[0].trace_id not in body, "HAVING total_tokens > 100 must exclude the low-token trace"
|
||||
|
||||
|
||||
def test_ai_list_rejects_unknown_aggregate_key(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""A trace-level filter on an unknown aggregate name is rejected, not silently run."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
query = BuilderQuery(
|
||||
signal="traces",
|
||||
query_type="builder_ai_query",
|
||||
name="A",
|
||||
limit=10,
|
||||
filter_expression="trace.bogus_tokens > 1",
|
||||
)
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
|
||||
def test_ai_list_rejects_order_by_span_attribute(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""Only gen_ai-scoped aggregates are orderable; ordering by a span/resource key errors."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
query = BuilderQuery(
|
||||
signal="traces",
|
||||
query_type="builder_ai_query",
|
||||
name="A",
|
||||
limit=5,
|
||||
order=[OrderBy(key=TelemetryFieldKey(name="service.name"), direction="asc")],
|
||||
)
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert "order key" in response.text
|
||||
|
||||
|
||||
def test_ai_list_total_tokens_output_only(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""A trace with only output tokens must still total: sum over an absent attribute
|
||||
is NULL and NULL + n = NULL, hence the coalesce."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-total-coalesce"
|
||||
insert_traces(ai_trace(now=now, service=service, user="a", in_tokens=None, out_tokens=300, cost=0.1))
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
query = BuilderQuery(
|
||||
signal="traces",
|
||||
query_type="builder_ai_query",
|
||||
name="A",
|
||||
filter_expression=f"service.name = '{service}'",
|
||||
limit=10,
|
||||
)
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
rows = response.json()["data"]["data"]["results"][0]["rows"]
|
||||
assert len(rows) == 1, f"expected one trace, got: {rows}"
|
||||
data = rows[0]["data"]
|
||||
assert data["input_tokens"] is None, data # attribute absent -> NULL, not 0
|
||||
assert data["output_tokens"] == 300, data
|
||||
assert data["total_tokens"] == 300, f"total must coalesce the missing input side: {data}"
|
||||
|
||||
|
||||
def test_ai_list_variable_in_aggregate_filter(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""A query variable in a trace-level condition is substituted into the HAVING."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-having-var"
|
||||
|
||||
small = ai_trace(now=now, service=service, user="a", in_tokens=10, out_tokens=20, cost=0.1)
|
||||
large = ai_trace(now=now, service=service, user="b", in_tokens=10, out_tokens=500, cost=0.2)
|
||||
small_id, large_id = small[0].trace_id, large[0].trace_id
|
||||
insert_traces(small + large)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
query = BuilderQuery(
|
||||
signal="traces",
|
||||
query_type="builder_ai_query",
|
||||
name="A",
|
||||
filter_expression=f"service.name = '{service}' AND trace.output_tokens > $threshold",
|
||||
limit=10,
|
||||
)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[query.to_dict()],
|
||||
request_type="trace",
|
||||
variables={"threshold": {"type": "custom", "value": 100}},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
body = json.dumps(response.json())
|
||||
assert large_id in body
|
||||
assert small_id not in body
|
||||
|
||||
|
||||
def test_ai_list_messages_first_input_last_output(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""`input` is the first LLM span's prompt, `output` the last LLM span's answer."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-messages"
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
root_id = TraceIdGenerator.span_id()
|
||||
resources = {"service.name": service}
|
||||
|
||||
def llm(offset_s: float, prompt: str, answer: str) -> Traces:
|
||||
return Traces(
|
||||
timestamp=now - timedelta(seconds=offset_s),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="chat",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={
|
||||
"gen_ai.request.model": "gpt-4o-mini",
|
||||
"gen_ai.input.messages": prompt,
|
||||
"gen_ai.output.messages": answer,
|
||||
},
|
||||
)
|
||||
|
||||
# earlier call is the "first" (its input is the prompt), later call is the "last"
|
||||
# (its output is the final answer).
|
||||
insert_traces(
|
||||
[
|
||||
root_span(now=now, trace_id=trace_id, span_id=root_id, resources=resources, duration_s=4),
|
||||
llm(4, "first prompt", "first answer"),
|
||||
llm(2, "second prompt", "second answer"),
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
query = BuilderQuery(
|
||||
signal="traces",
|
||||
query_type="builder_ai_query",
|
||||
name="A",
|
||||
limit=10,
|
||||
filter_expression=f"service.name = '{service}'",
|
||||
)
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
rows = response.json()["data"]["data"]["results"][0]["rows"]
|
||||
assert len(rows) == 1, f"expected one trace, got: {rows}"
|
||||
data = rows[0]["data"]
|
||||
assert data["input"] == "first prompt", f"input should be the earliest call's prompt: {data}"
|
||||
assert data["output"] == "second answer", f"output should be the latest call's answer: {data}"
|
||||
|
||||
|
||||
def test_ai_list_enrichment_values(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""End-to-end values of the derived per-trace columns. One trace: root + 1 errored
|
||||
LLM + 3 tool spans (get_weather x2, get_time x1) + 1 agent span."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
service = "ai-it-metrics"
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
root_id = TraceIdGenerator.span_id()
|
||||
resources = {"service.name": service}
|
||||
|
||||
def tool(name: str, offset_s: float) -> Traces:
|
||||
return Traces(
|
||||
timestamp=now - timedelta(seconds=offset_s),
|
||||
duration=timedelta(seconds=0.2),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="execute_tool",
|
||||
kind=TracesKind.SPAN_KIND_INTERNAL,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={"gen_ai.tool.name": name, "gen_ai.tool.type": "function"},
|
||||
)
|
||||
|
||||
llm = Traces(
|
||||
timestamp=now - timedelta(seconds=4),
|
||||
duration=timedelta(seconds=2),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="chat gpt-4o-mini",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_ERROR, # -> has_error, drives error_count
|
||||
resources=resources,
|
||||
attributes={
|
||||
"gen_ai.request.model": "gpt-4o-mini",
|
||||
"gen_ai.usage.input_tokens": 100,
|
||||
"gen_ai.usage.output_tokens": 20,
|
||||
"_signoz.gen_ai.total_cost": 0.5,
|
||||
},
|
||||
)
|
||||
agent = Traces(
|
||||
timestamp=now - timedelta(seconds=1),
|
||||
duration=timedelta(seconds=0.5),
|
||||
trace_id=trace_id,
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
parent_span_id=root_id,
|
||||
name="agent.step",
|
||||
kind=TracesKind.SPAN_KIND_INTERNAL,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources=resources,
|
||||
attributes={"gen_ai.agent.name": "chat-agent"},
|
||||
)
|
||||
insert_traces(
|
||||
[
|
||||
root_span(now=now, trace_id=trace_id, span_id=root_id, resources=resources, duration_s=4),
|
||||
llm,
|
||||
tool("get_weather", 3),
|
||||
tool("get_weather", 2.5),
|
||||
tool("get_time", 2),
|
||||
agent,
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms, end_ms = query_window(now)
|
||||
|
||||
query = BuilderQuery(
|
||||
signal="traces",
|
||||
query_type="builder_ai_query",
|
||||
name="A",
|
||||
limit=10,
|
||||
filter_expression=f"service.name = '{service}'",
|
||||
)
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
rows = response.json()["data"]["data"]["results"][0]["rows"]
|
||||
assert len(rows) == 1, f"expected one trace, got: {rows}"
|
||||
data = rows[0]["data"]
|
||||
|
||||
assert data["span_count"] == 6, data # root + llm + 3 tools + agent
|
||||
assert data["llm_call_count"] == 1, data # only the request.model span, not tool/agent
|
||||
assert data["tool_call_count"] == 3, data # all three tool spans
|
||||
assert data["distinct_tool_count"] == 2, data # get_weather, get_time
|
||||
assert data["input_tokens"] == 100, data
|
||||
assert data["output_tokens"] == 20, data
|
||||
assert data["total_tokens"] == 120, data # input + output
|
||||
assert data["estimated_total_cost"] == pytest.approx(0.5), data
|
||||
assert data["error_count"] == 1, data # the errored LLM span
|
||||
assert data["max_llm_duration_nano"] > 0, data # scoped max over LLM spans
|
||||
@@ -1,181 +0,0 @@
|
||||
"""Aggregations over the intrinsic `timestamp` column.
|
||||
|
||||
`timestamp` is DateTime64(9), so a numeric coercion rescales it to seconds — the #5824
|
||||
regression. Order-based aggregations answer with an instant; sum/avg have no time meaning.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.querier import RequestType, make_query_request
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
# Suites share one stack, so every query scopes to this service to keep other tests'
|
||||
# spans out of the aggregate.
|
||||
SERVICE = "timestamp-agg-svc"
|
||||
|
||||
|
||||
def seed_two_spans(insert_traces: Callable[[list[Traces]], None], oldest: datetime, newest: datetime) -> None:
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=ts,
|
||||
duration=timedelta(milliseconds=10),
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name="op",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": SERVICE},
|
||||
attributes={},
|
||||
)
|
||||
for ts in (oldest, newest)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def query_timestamp_aggregation(signoz: types.SigNoz, token: str, expression: str, now: datetime):
|
||||
return make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
end_ms=int((now + timedelta(minutes=1)).timestamp() * 1000),
|
||||
request_type=RequestType.SCALAR,
|
||||
queries=[
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"stepInterval": "60s",
|
||||
"filter": {"expression": f"service.name = '{SERVICE}'"},
|
||||
"aggregations": [{"expression": expression}],
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expression,bound", [("max(timestamp)", "newest"), ("min(timestamp)", "oldest")])
|
||||
def test_timestamp_order_aggregations_return_the_instant(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
expression: str,
|
||||
bound: str,
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Two spans for one service, 3 and 1 minutes ago.
|
||||
|
||||
Tests:
|
||||
max/min answer with the extreme span's instant. Coerced to Float64 the value would be
|
||||
~1.7e9 seconds, which reads as January 1970 when a consumer treats it as milliseconds.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
oldest, newest = now - timedelta(minutes=3), now - timedelta(minutes=1)
|
||||
seed_two_spans(insert_traces, oldest, newest)
|
||||
|
||||
response = query_timestamp_aggregation(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), expression, now)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
raw = response.json()["data"]["data"]["results"][0]["data"][0][0]
|
||||
# a native DateTime64 serializes as an RFC 3339 instant
|
||||
assert isinstance(raw, str), f"{expression} returned {raw!r}, expected an instant"
|
||||
actual = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
|
||||
expected = newest if bound == "newest" else oldest
|
||||
assert abs((actual - expected).total_seconds()) < 1, f"{expression} returned {raw!r} ({actual.isoformat()}), expected {expected.isoformat()}"
|
||||
|
||||
|
||||
def test_timestamp_quantile_returns_an_instant_within_range(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Tests:
|
||||
ClickHouse computes quantiles over DateTime64 natively, so p99 lands inside the
|
||||
seeded range rather than erroring or collapsing to epoch.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
oldest, newest = now - timedelta(minutes=3), now - timedelta(minutes=1)
|
||||
seed_two_spans(insert_traces, oldest, newest)
|
||||
|
||||
response = query_timestamp_aggregation(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), "p99(timestamp)", now)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
raw = response.json()["data"]["data"]["results"][0]["data"][0][0]
|
||||
assert isinstance(raw, str), f"p99(timestamp) returned {raw!r}, expected an instant"
|
||||
actual = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
|
||||
assert oldest - timedelta(seconds=1) <= actual <= newest + timedelta(seconds=1), f"p99(timestamp) returned {actual.isoformat()}, outside [{oldest.isoformat()}, {newest.isoformat()}]"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expression,expected", [("count(timestamp)", 2), ("rate(timestamp)", None)])
|
||||
def test_timestamp_counting_aggregations_are_numeric(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
expression: str,
|
||||
expected: int | None,
|
||||
) -> None:
|
||||
"""
|
||||
Tests:
|
||||
count and rate count rows rather than reading the column's value, so they stay
|
||||
numeric for a time column.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
seed_two_spans(insert_traces, now - timedelta(minutes=3), now - timedelta(minutes=1))
|
||||
|
||||
response = query_timestamp_aggregation(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), expression, now)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
raw = response.json()["data"]["data"]["results"][0]["data"][0][0]
|
||||
assert isinstance(raw, (int, float)) and not isinstance(raw, bool), f"{expression} returned {raw!r}, expected a number"
|
||||
if expected is not None:
|
||||
assert raw == expected, f"{expression} returned {raw!r}, expected {expected}"
|
||||
else:
|
||||
assert raw > 0, f"{expression} returned {raw!r}, expected a positive rate"
|
||||
|
||||
|
||||
# KNOWN GAP, pinned deliberately: ClickHouse rejects sum/avg over DateTime64, and rate_*
|
||||
# additionally divides a time by an interval; both surface as the generic 500. They returned
|
||||
# a meaningless seconds number before #5824. Flip to BAD_REQUEST once validated up front.
|
||||
@pytest.mark.parametrize(
|
||||
"expression",
|
||||
[
|
||||
"sum(timestamp)",
|
||||
"avg(timestamp)",
|
||||
"rate_sum(timestamp)",
|
||||
"rate_avg(timestamp)",
|
||||
"rate_min(timestamp)",
|
||||
"rate_max(timestamp)",
|
||||
],
|
||||
)
|
||||
def test_timestamp_arithmetic_aggregations_are_unsupported(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
expression: str,
|
||||
) -> None:
|
||||
"""
|
||||
Tests:
|
||||
Aggregations that cannot produce a time from a time column fail rather than
|
||||
answering with a rescaled number.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
seed_two_spans(insert_traces, now - timedelta(minutes=3), now - timedelta(minutes=1))
|
||||
|
||||
response = query_timestamp_aggregation(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), expression, now)
|
||||
|
||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, f"{expression} returned {response.status_code}: {response.text[:200]}"
|
||||
@@ -1,551 +0,0 @@
|
||||
"""Integration tests for the third-party (external) API monitoring domain list.
|
||||
|
||||
A translator over v5 builder queries, so these cover the contract it exposes (columns,
|
||||
grouping, base filter, IP masking) plus the `max(timestamp)` Last Seen from #5824.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.thirdpartyapi import (
|
||||
domain_column_index,
|
||||
index_columns_by_query,
|
||||
make_third_party_apis_request,
|
||||
rows_by_domain,
|
||||
scalar_result,
|
||||
)
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
|
||||
# the placeholder the response layer writes where a query has no value for a row
|
||||
NO_VALUE = "n/a"
|
||||
|
||||
|
||||
# timestamp_attr seeds a numeric attribute named `timestamp`: resolution must keep the
|
||||
# intrinsic column, else the multiIf union fails with "no supertype for DateTime64, Float64".
|
||||
@pytest.mark.parametrize("noise", ["clean", "timestamp_attr"])
|
||||
def test_domain_list_last_seen_is_the_span_instant(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
noise: str,
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Two client spans to api.stripe.com, 3 and 1 minutes ago.
|
||||
|
||||
Tests:
|
||||
Last Seen resolves to the newer span's instant. A numeric coercion of the
|
||||
DateTime64 column would yield seconds since epoch (~1.7e9), which lands in
|
||||
January 1970 once read as milliseconds.
|
||||
"""
|
||||
extra_attrs = {"timestamp": 1.0} if noise == "timestamp_attr" else {}
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
newest = now - timedelta(minutes=1)
|
||||
domain = f"api.stripe-{noise}.com"
|
||||
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=ts,
|
||||
duration=timedelta(milliseconds=120),
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name="POST /v1/charges",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": "checkout"},
|
||||
attributes={
|
||||
"http.url": f"https://{domain}/v1/charges",
|
||||
"http.host": domain,
|
||||
**extra_attrs,
|
||||
},
|
||||
)
|
||||
for ts in (now - timedelta(minutes=3), newest)
|
||||
]
|
||||
)
|
||||
|
||||
response = make_third_party_apis_request(
|
||||
signoz,
|
||||
get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD),
|
||||
start_ms=int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
end_ms=int((now + timedelta(minutes=1)).timestamp() * 1000),
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
result = scalar_result(response)
|
||||
rows = rows_by_domain(result)
|
||||
assert domain in rows, result["data"]
|
||||
|
||||
raw_last_seen = rows[domain][index_columns_by_query(result)["lastseen"]]
|
||||
# a native DateTime64 serializes as an RFC 3339 instant; the span is seeded on a whole
|
||||
# second, so the instant is exact
|
||||
assert isinstance(raw_last_seen, str), f"Last Seen returned {raw_last_seen!r}, expected an RFC 3339 instant"
|
||||
last_seen = datetime.fromisoformat(raw_last_seen)
|
||||
|
||||
assert last_seen == newest, f"Last Seen {raw_last_seen!r} resolved to {last_seen.isoformat()}, expected the newer span at {newest.isoformat()}"
|
||||
|
||||
|
||||
def test_domain_list_groups_by_domain_and_counts_endpoints(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
api.github.com with 2 distinct URLs (one called twice); api.openai.com with 1.
|
||||
|
||||
Tests:
|
||||
One row per domain, endpoint counts are distinct-URL counts, and the columns
|
||||
used only to feed the error-rate formula are stripped from the response.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
specs = [
|
||||
("api.github.com", "https://api.github.com/repos"),
|
||||
("api.github.com", "https://api.github.com/repos"),
|
||||
("api.github.com", "https://api.github.com/issues"),
|
||||
("api.openai.com", "https://api.openai.com/v1/chat"),
|
||||
]
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=i + 1),
|
||||
duration=timedelta(milliseconds=200),
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name="client call",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": "gateway"},
|
||||
attributes={"http.url": url, "http.host": host},
|
||||
)
|
||||
for i, (host, url) in enumerate(specs)
|
||||
]
|
||||
)
|
||||
|
||||
response = make_third_party_apis_request(
|
||||
signoz,
|
||||
get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD),
|
||||
start_ms=int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
end_ms=int((now + timedelta(minutes=1)).timestamp() * 1000),
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
body = response.json()
|
||||
assert body["status"] == "success", body
|
||||
# the six queries are merged into exactly one table
|
||||
assert len(body["data"]["data"]["results"]) == 1, body["data"]["data"]["results"]
|
||||
|
||||
result = scalar_result(response)
|
||||
|
||||
by_query = index_columns_by_query(result)
|
||||
# exact set: the error/total_span intermediates only feed the error_rate formula and
|
||||
# must be stripped, and a new column should trip this rather than slip through
|
||||
assert by_query.keys() == {"endpoints", "lastseen", "rps", "p99", "error_rate"}, result["columns"]
|
||||
assert [col["name"] for col in result["columns"] if col["columnType"] == "group"] == ["http_host"], result["columns"]
|
||||
|
||||
width = len(result["columns"])
|
||||
assert all(len(row) == width for row in result["data"]), result["data"]
|
||||
|
||||
rows = rows_by_domain(result)
|
||||
endpoints = {domain: rows[domain][by_query["endpoints"]] for domain in ("api.github.com", "api.openai.com") if domain in rows}
|
||||
assert endpoints == {"api.github.com": 2, "api.openai.com": 1}, result["data"]
|
||||
|
||||
# every query groups by http_host, so a seeded domain has a value in every column
|
||||
for domain in ("api.github.com", "api.openai.com"):
|
||||
assert NO_VALUE not in rows[domain], (domain, result["data"])
|
||||
|
||||
|
||||
def test_domain_list_excludes_non_client_and_urlless_spans(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
A client span with an http.url, a server span with one, and a client span without.
|
||||
|
||||
Tests:
|
||||
The base filter (`http_url EXISTS AND kind_string = 'Client'`) keeps only the
|
||||
first domain; the other two never reach the response.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
specs = [
|
||||
("included.example.com", TracesKind.SPAN_KIND_CLIENT, "https://included.example.com/a"),
|
||||
(
|
||||
"server-kind.example.com",
|
||||
TracesKind.SPAN_KIND_SERVER,
|
||||
"https://server-kind.example.com/a",
|
||||
),
|
||||
("no-url.example.com", TracesKind.SPAN_KIND_CLIENT, None),
|
||||
]
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=i + 1),
|
||||
duration=timedelta(milliseconds=50),
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name="call",
|
||||
kind=kind,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": "gateway"},
|
||||
attributes=({"http.host": host} if url is None else {"http.url": url, "http.host": host}),
|
||||
)
|
||||
for i, (host, kind, url) in enumerate(specs)
|
||||
]
|
||||
)
|
||||
|
||||
response = make_third_party_apis_request(
|
||||
signoz,
|
||||
get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD),
|
||||
start_ms=int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
end_ms=int((now + timedelta(minutes=1)).timestamp() * 1000),
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
domains = rows_by_domain(scalar_result(response)).keys()
|
||||
|
||||
assert "included.example.com" in domains, domains
|
||||
assert "server-kind.example.com" not in domains, domains
|
||||
assert "no-url.example.com" not in domains, domains
|
||||
|
||||
|
||||
@pytest.mark.parametrize("show_ip", [False, True])
|
||||
def test_domain_list_show_ip_masks_ip_domains(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
show_ip: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
One client span each to a hostname, a bare IPv4 and a bare IPv6 address.
|
||||
|
||||
Tests:
|
||||
show_ip gates both IP forms (net.ParseIP accepts either); the hostname always shows.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
specs = [
|
||||
("named.example.com", "https://named.example.com/a"),
|
||||
("10.42.7.9", "http://10.42.7.9/metrics"),
|
||||
("2001:db8::42", "http://[2001:db8::42]/metrics"),
|
||||
]
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=i + 1),
|
||||
duration=timedelta(milliseconds=50),
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name="call",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": "gateway"},
|
||||
attributes={"http.url": url, "http.host": host},
|
||||
)
|
||||
for i, (host, url) in enumerate(specs)
|
||||
]
|
||||
)
|
||||
|
||||
response = make_third_party_apis_request(
|
||||
signoz,
|
||||
get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD),
|
||||
start_ms=int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
end_ms=int((now + timedelta(minutes=1)).timestamp() * 1000),
|
||||
show_ip=show_ip,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
domains = rows_by_domain(scalar_result(response)).keys()
|
||||
|
||||
assert "named.example.com" in domains, domains
|
||||
assert ("10.42.7.9" in domains) is show_ip, domains
|
||||
assert ("2001:db8::42" in domains) is show_ip, domains
|
||||
|
||||
|
||||
def test_domain_list_latency_and_rate_values(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
3 client spans of 250ms on one domain, 1 on another.
|
||||
|
||||
Tests:
|
||||
p99 is duration_nano in nanoseconds, and rps is rate() over the scalar window
|
||||
(count / (end - start) seconds), so the busier domain's rate is 3x the other's.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
busy, quiet = "busy.example.com", "quiet.example.com"
|
||||
duration = timedelta(milliseconds=250)
|
||||
specs = [(busy, 0), (busy, 1), (busy, 2), (quiet, 3)]
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=i + 1),
|
||||
duration=duration,
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name="call",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": "gateway"},
|
||||
attributes={"http.url": f"https://{host}/a", "http.host": host},
|
||||
)
|
||||
for host, i in specs
|
||||
]
|
||||
)
|
||||
|
||||
start_ms = int((now - timedelta(minutes=10)).timestamp() * 1000)
|
||||
end_ms = int((now + timedelta(minutes=1)).timestamp() * 1000)
|
||||
response = make_third_party_apis_request(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), start_ms, end_ms)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
result = scalar_result(response)
|
||||
by_query = index_columns_by_query(result)
|
||||
rows = rows_by_domain(result)
|
||||
|
||||
# every span has the same duration, so the percentile is that duration exactly
|
||||
expected_ns = duration / timedelta(microseconds=1) * 1000
|
||||
for domain in (busy, quiet):
|
||||
assert rows[domain][by_query["p99"]] == pytest.approx(expected_ns), (domain, result["data"])
|
||||
|
||||
window_seconds = (end_ms - start_ms) / 1000
|
||||
assert rows[busy][by_query["rps"]] == pytest.approx(3 / window_seconds, rel=0.05), result["data"]
|
||||
assert rows[quiet][by_query["rps"]] == pytest.approx(1 / window_seconds, rel=0.05), result["data"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"label,errors,total,expected",
|
||||
[("quarter", 1, 4, 25.0), ("all", 2, 2, 100.0), ("none", 0, 3, 0.0)],
|
||||
)
|
||||
def test_domain_list_error_rate_is_a_percentage_of_spans(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
label: str,
|
||||
errors: int,
|
||||
total: int,
|
||||
expected: float,
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
`total` client spans on one domain, `errors` of them failed.
|
||||
|
||||
Tests:
|
||||
error_rate is (error/total_span)*100 across the range, including the all-failed and
|
||||
none-failed edges.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
domain = f"errors-{label}.example.com"
|
||||
statuses = [TracesStatusCode.STATUS_CODE_ERROR] * errors + [TracesStatusCode.STATUS_CODE_OK] * (total - errors)
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=i + 1),
|
||||
duration=timedelta(milliseconds=100),
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name="call",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=status,
|
||||
resources={"service.name": "gateway"},
|
||||
attributes={"http.url": f"https://{domain}/a", "http.host": domain},
|
||||
)
|
||||
for i, status in enumerate(statuses)
|
||||
]
|
||||
)
|
||||
|
||||
response = make_third_party_apis_request(
|
||||
signoz,
|
||||
get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD),
|
||||
start_ms=int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
end_ms=int((now + timedelta(minutes=1)).timestamp() * 1000),
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
result = scalar_result(response)
|
||||
rows = rows_by_domain(result)
|
||||
assert domain in rows, result["data"]
|
||||
|
||||
error_rate = rows[domain][index_columns_by_query(result)["error_rate"]]
|
||||
assert error_rate == pytest.approx(expected), result["data"]
|
||||
|
||||
|
||||
def test_domain_list_user_filter_narrows_the_base_filter(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Two client spans to the same domain from different services.
|
||||
|
||||
Tests:
|
||||
A caller filter is ANDed onto the base filter, so only the named service's span
|
||||
contributes.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
domain = "filtered.example.com"
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=i + 1),
|
||||
duration=timedelta(milliseconds=100),
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name="call",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": service},
|
||||
attributes={"http.url": f"https://{domain}/{service}", "http.host": domain},
|
||||
)
|
||||
for i, service in enumerate(("keeper-svc", "dropped-svc"))
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms = int((now - timedelta(minutes=10)).timestamp() * 1000)
|
||||
end_ms = int((now + timedelta(minutes=1)).timestamp() * 1000)
|
||||
|
||||
unfiltered = make_third_party_apis_request(signoz, token, start_ms, end_ms)
|
||||
filtered = make_third_party_apis_request(signoz, token, start_ms, end_ms, filter_expression="service.name = 'keeper-svc'")
|
||||
|
||||
assert unfiltered.status_code == HTTPStatus.OK, unfiltered.text
|
||||
assert filtered.status_code == HTTPStatus.OK, filtered.text
|
||||
|
||||
unfiltered_result = scalar_result(unfiltered)
|
||||
filtered_result = scalar_result(filtered)
|
||||
endpoints_key = "endpoints"
|
||||
assert rows_by_domain(unfiltered_result)[domain][index_columns_by_query(unfiltered_result)[endpoints_key]] == 2
|
||||
assert rows_by_domain(filtered_result)[domain][index_columns_by_query(filtered_result)[endpoints_key]] == 1
|
||||
|
||||
|
||||
def test_domain_list_caller_group_by_adds_a_dimension(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Two client spans to one domain, from two services.
|
||||
|
||||
Tests:
|
||||
A caller groupBy is merged after the domain key, so the domain splits into one row
|
||||
per group value and the extra key gets its own column.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
domain = "grouped.example.com"
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=i + 1),
|
||||
duration=timedelta(milliseconds=100),
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name="call",
|
||||
kind=TracesKind.SPAN_KIND_CLIENT,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": service},
|
||||
attributes={"http.url": f"https://{domain}/a", "http.host": domain},
|
||||
)
|
||||
for i, service in enumerate(("svc-a", "svc-b"))
|
||||
]
|
||||
)
|
||||
|
||||
response = make_third_party_apis_request(
|
||||
signoz,
|
||||
get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD),
|
||||
start_ms=int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
end_ms=int((now + timedelta(minutes=1)).timestamp() * 1000),
|
||||
group_by=[
|
||||
{
|
||||
"name": "service.name",
|
||||
"fieldDataType": "string",
|
||||
"fieldContext": "resource",
|
||||
"signal": "traces",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
result = scalar_result(response)
|
||||
|
||||
assert "service.name" in {col["name"] for col in result["columns"]}, result["columns"]
|
||||
domain_idx = domain_column_index(result)
|
||||
service_idx = next(i for i, col in enumerate(result["columns"]) if col["name"] == "service.name")
|
||||
services = {row[service_idx] for row in result["data"] if row[domain_idx] == domain}
|
||||
assert services == {"svc-a", "svc-b"}, result["data"]
|
||||
|
||||
|
||||
def test_domain_list_is_empty_outside_the_seeded_window(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""
|
||||
Tests:
|
||||
A window with no matching spans answers 200 with no rows, not an error.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
long_ago = now - timedelta(days=400)
|
||||
response = make_third_party_apis_request(
|
||||
signoz,
|
||||
get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD),
|
||||
start_ms=int(long_ago.timestamp() * 1000),
|
||||
end_ms=int((long_ago + timedelta(minutes=5)).timestamp() * 1000),
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert scalar_result(response)["data"] == []
|
||||
|
||||
|
||||
def test_domain_list_requires_authentication(signoz: types.SigNoz) -> None:
|
||||
"""
|
||||
Tests:
|
||||
The route sits behind ViewAccess, so an unauthenticated call is rejected.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
response = make_third_party_apis_request(
|
||||
signoz,
|
||||
"",
|
||||
start_ms=int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.UNAUTHORIZED, response.text
|
||||
|
||||
|
||||
def test_domain_list_rejects_inverted_time_range(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""
|
||||
Tests:
|
||||
The request is validated before any query is built, so start >= end is a client
|
||||
error rather than an empty result.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
response = make_third_party_apis_request(
|
||||
signoz,
|
||||
get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD),
|
||||
start_ms=int(now.timestamp() * 1000),
|
||||
end_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
Reference in New Issue
Block a user