Compare commits

..

2 Commits

Author SHA1 Message Date
Gaurav Tewari
89f3be486e chore(querybuilder): exercise builder_ai_query from the traces explorer
DEMO SCAFFOLD — remove before merge.

The AI o11y explorer page does not exist yet, so there is nowhere to run
the builder_ai_query plumbing end to end. This wires the traces explorer
to it behind `?aiDemo=1`, leaving the page byte-identical without the
param.

With the param set:
- all four views send builderQueryType: 'builder_ai_query', so
  compositeQuery.queries[].type changes on /query_range
- QuerySearch sends &type=builder_ai_query to /fields/keys
- the All/Root/Entrypoint span-scope select hides in List and Trace,
  matching the AI explorer's per-view policy

Each view's hand-built queryKey gains the flag as a discriminator.
Without it the two modes share a react-query cache entry, since all four
views override options.queryKey and none of the existing entries
distinguish them.

The real AI explorer page will pass these values directly, at which
point this commit and src/container/TracesExplorer/useIsAIQueryDemo.ts
can be dropped.
2026-08-06 17:12:36 +05:30
Gaurav Tewari
4a0c34c236 feat(querybuilder): support builder_ai_query envelope type
Adds the plumbing the AI o11y explorer needs from the query builder,
with every addition defaulted so existing behaviour is unchanged. No
caller sets the new options yet, so this is a no-op for all nine
current QueryBuilderV2 call sites.

Payload:
- add 'builder_ai_query' to the QueryType union, plus a narrowed
  BuilderQueryEnvelopeType alias. Narrow on purpose: the other members
  take a different spec shape and the backend decodes with
  DisallowUnknownFields, so passing one with a builder spec is a 400.
- convertBuilderQueriesToV5 takes the envelope type as a defaulted 4th
  param instead of hardcoding 'builder_query'.
- GetQueryResultsProps gains builderQueryType, threaded through
  prepareQueryRangePayloadV5 (which destructures explicitly, so it has
  to be named there — a spread does not reach it).

Response:
- mapQueryFromV5 is an if/else-if chain with no fallback, so an
  unrecognised envelope type was silently dropped rather than defaulted.
  builder_ai_query shares the builder-query spec shape and name
  namespace, so it now hydrates into queryData the same way.

Query builder props:
- showSpanScopeSelector (default true) lets a page hide the
  All/Root/Entrypoint select. Gated in QueryV2's memo so both render
  sites are covered. QueryProps already declared this prop and nothing
  read it; this gives that declaration meaning.
- fieldKeysQueryType is forwarded verbatim to /fields/keys as `type` so
  the backend can scope the suggested key set. Appended to the URL only
  when set, keeping every existing request byte-identical.

Deliberately not included: the AI explorer page itself, per-query AI
state, and 'trace' as a field context (the last needs a backend
decision — see frontend/docs/ai-explorer-qb-changeset.md).
2026-08-06 17:06:08 +05:30
121 changed files with 664 additions and 5770 deletions

View File

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

3
.gitignore vendored
View File

@@ -231,5 +231,4 @@ cython_debug/
# LSP config files
pyrightconfig.json
# agents
.claude/settings.local.json

View File

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

View File

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

View File

@@ -24,8 +24,6 @@
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
"tooltip_email_to": "Enter email addresses separated by commas.",
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",

View File

@@ -24,8 +24,6 @@
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
"tooltip_email_to": "Enter email addresses separated by commas.",
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",
"field_slack_description": "Description",

View File

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

View File

@@ -23,6 +23,7 @@ export const getKeySuggestions = (
fieldDataType = '',
signalSource = '',
metricNamespace = '',
type,
} = props;
const encodedSignal = encodeURIComponent(signal);
@@ -32,8 +33,11 @@ export const getKeySuggestions = (
const encodedFieldDataType = encodeURIComponent(fieldDataType);
const encodedSource = encodeURIComponent(signalSource);
const encodedMetricNamespace = encodeURIComponent(metricNamespace);
// Appended only when the caller sets `type`, unlike the params above which are
// always sent. Keeps every existing request URL byte-identical.
const typeParam = type ? `&type=${encodeURIComponent(type)}` : '';
return axios.get(
`/fields/keys?signal=${encodedSignal}&searchText=${encodedSearchText}&metricName=${encodedMetricName}&fieldContext=${encodedFieldContext}&fieldDataType=${encodedFieldDataType}&source=${encodedSource}&metricNamespace=${encodedMetricNamespace}`,
`/fields/keys?signal=${encodedSignal}&searchText=${encodedSearchText}&metricName=${encodedMetricName}&fieldContext=${encodedFieldContext}&fieldDataType=${encodedFieldDataType}&source=${encodedSource}&metricNamespace=${encodedMetricNamespace}${typeParam}`,
);
};

View File

@@ -13,6 +13,7 @@ import {
} from 'types/api/queryBuilder/queryBuilderData';
import {
BaseBuilderQuery,
BuilderQueryEnvelopeType,
FieldContext,
FieldDataType,
Filter,
@@ -322,6 +323,7 @@ export function convertBuilderQueriesToV5(
builderQueries: Record<string, any>,
requestType: RequestType,
panelType?: PANEL_TYPES,
builderQueryType: BuilderQueryEnvelopeType = 'builder_query',
): QueryEnvelope[] {
return Object.entries(builderQueries).map(
([queryName, queryData]): QueryEnvelope => {
@@ -363,10 +365,7 @@ export function convertBuilderQueriesToV5(
break;
}
return {
type: 'builder_query' as QueryType,
spec,
};
return { type: builderQueryType, spec };
},
);
}
@@ -558,6 +557,7 @@ export const prepareQueryRangePayloadV5 = ({
originalGraphType,
fillGaps,
dynamicVariables,
builderQueryType = 'builder_query',
}: GetQueryResultsProps): PrepareQueryRangePayloadV5Result => {
let legendMap: Record<string, string> = {};
const requestType = mapPanelTypeToRequestType(graphType);
@@ -594,6 +594,7 @@ export const prepareQueryRangePayloadV5 = ({
currentQueryData.data,
requestType,
graphType,
builderQueryType,
);
// Convert formulas as separate query type

View File

@@ -22,6 +22,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
isListViewPanel = false,
showOnlyWhereClause = false,
showTraceOperator = false,
showSpanScopeSelector = true,
fieldKeysQueryType,
version,
onSignalSourceChange,
signalSourceChangeEnabled = false,
@@ -209,6 +211,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}
isListViewPanel={isListViewPanel}
showSpanScopeSelector={showSpanScopeSelector}
fieldKeysQueryType={fieldKeysQueryType}
signalSource={currentQuery.builder.queryData[0].source as 'meter' | ''}
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
signalSourceChangeEnabled={signalSourceChangeEnabled}
@@ -232,6 +236,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}
isListViewPanel={isListViewPanel}
showSpanScopeSelector={showSpanScopeSelector}
fieldKeysQueryType={fieldKeysQueryType}
signalSource={query.source as 'meter' | ''}
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
signalSourceChangeEnabled={signalSourceChangeEnabled}

View File

@@ -47,7 +47,10 @@ import { validateQuery } from 'utils/queryValidationUtils';
import { unquote } from 'utils/stringUtils';
import { getRecentQueries } from 'lib/recentQueries/getRecentQueries';
import type { SignalType } from 'types/api/v5/queryRange';
import type {
BuilderQueryEnvelopeType,
SignalType,
} from 'types/api/v5/queryRange';
import {
queryExamples,
@@ -111,6 +114,12 @@ interface QuerySearchProps {
numberValues: number[];
complete: boolean;
}>;
/**
* Forwarded to `/fields/keys` as `type`, letting the backend scope the key set.
* Not named `type`: `QueryKeyDataSuggestionsProps.type` already means something
* else on the response side.
*/
fieldKeysQueryType?: BuilderQueryEnvelopeType;
}
function QuerySearch({
@@ -125,6 +134,7 @@ function QuerySearch({
initialExpression,
metricNamespace,
valueSuggestionsOverride,
fieldKeysQueryType,
}: QuerySearchProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
@@ -326,6 +336,7 @@ function QuerySearch({
metricName: debouncedMetricName ?? undefined,
signalSource: signalSource as 'meter' | '',
metricNamespace,
type: fieldKeysQueryType,
});
if (response.data.data) {
@@ -363,6 +374,7 @@ function QuerySearch({
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
metricNamespace,
fieldKeysQueryType,
],
);

View File

@@ -37,6 +37,9 @@ export const QueryV2 = forwardRef(function QueryV2(
hasTraceOperator = false,
version,
showOnlyWhereClause = false,
// Aliased: the local memo below is also called `showSpanScopeSelector`.
showSpanScopeSelector: allowSpanScopeSelector = true,
fieldKeysQueryType,
signalSource = '',
isMultiQueryAllowed = false,
onSignalSourceChange,
@@ -94,8 +97,8 @@ export const QueryV2 = forwardRef(function QueryV2(
);
const showSpanScopeSelector = useMemo(
() => dataSource === DataSource.TRACES,
[dataSource],
() => dataSource === DataSource.TRACES && allowSpanScopeSelector,
[dataSource, allowSpanScopeSelector],
);
const showInlineQuerySearch = useMemo(() => {
@@ -182,6 +185,7 @@ export const QueryV2 = forwardRef(function QueryV2(
queryData={query}
dataSource={dataSource}
signalSource={signalSource}
fieldKeysQueryType={fieldKeysQueryType}
/>
</div>
@@ -252,6 +256,7 @@ export const QueryV2 = forwardRef(function QueryV2(
queryData={query}
dataSource={dataSource}
signalSource={signalSource}
fieldKeysQueryType={fieldKeysQueryType}
/>
</div>

View File

@@ -9,6 +9,7 @@ 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',
ENABLE_AI_OBSERVABILITY = 'enable_ai_observability',
ENABLE_METRICS_REDUCTION = 'enable_metrics_reduction',
}

View File

@@ -1,28 +1,18 @@
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import { GoogleChatInitialConfig } from 'container/CreateAlertChannels/defaults';
import {
googleChatDescriptionDefaultValue,
googleChatTitleDefaultValue,
opsGenieDescriptionDefaultValue,
opsGenieMessageDefaultValue,
opsGeniePriorityDefaultValue,
pagerDutyAdditionalDetailsDefaultValue,
pagerDutyDescriptionDefaultValue,
pagerDutyDescriptionDefaultVaule,
pagerDutySeverityTextDefaultValue,
slackDescriptionDefaultValue,
slackTitleDefaultValue,
} from 'mocks-server/__mockdata__/alerts';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
act,
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { testLabelInputAndHelpValue } from './testUtils';
@@ -235,7 +225,7 @@ describe('Create Alert Channel', () => {
);
expect(descriptionTextArea).toHaveTextContent(
pagerDutyDescriptionDefaultValue,
pagerDutyDescriptionDefaultVaule,
);
});
it('Should check if Severity label, info (help_pager_severity), and textbox are displayed properly', () => {
@@ -429,150 +419,5 @@ describe('Create Alert Channel', () => {
expect(descriptionTextArea).toHaveTextContent(slackDescriptionDefaultValue);
});
});
describe('Google Chat', () => {
const validWebhookUrl =
'https://chat.googleapis.com/v1/spaces/AAAA/messages?key=dummy_key&token=dummy_token';
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.GoogleChat} />);
});
it('Should check if the selected item in the type dropdown has text "Google Chat"', () => {
expect(screen.getByText('Google Chat')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Title contains the google chat template', () => {
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
googleChatTitleDefaultValue,
);
});
it('Should check if Description contains the google chat template', () => {
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
googleChatDescriptionDefaultValue,
);
});
it('Should check if saving with a webhook url outside chat.googleapis.com displays error notification', async () => {
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'gchat-channel',
);
await user.type(
screen.getByTestId('webhook-url-textbox'),
'https://example.com/webhook',
);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'google_chat_webhook_url_invalid',
}),
);
});
it('Should check if saving sends a googlechat_configs payload', async () => {
let requestBody: unknown;
server.use(
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
requestBody = await req.json();
return res(
ctx.status(201),
ctx.json({ status: 'success', data: 'channel created' }),
);
}),
);
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'gchat-channel',
);
await user.type(screen.getByTestId('webhook-url-textbox'), validWebhookUrl);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_creation_done',
}),
);
expect(requestBody).toStrictEqual({
name: 'gchat-channel',
googlechat_configs: [
{
webhook_url: validWebhookUrl,
title: GoogleChatInitialConfig.title,
text: GoogleChatInitialConfig.text,
send_resolved: true,
},
],
});
});
});
describe('Changing the channel type', () => {
async function selectType(
user: ReturnType<typeof userEvent.setup>,
optionText: string,
): Promise<void> {
// the type dropdown opens on the inner search input of the antd select
await user.click(screen.getByRole('combobox'));
await user.click(await screen.findByTitle(optionText));
}
it('Should check if switching to Google Chat and back swaps the prefilled templates', async () => {
const user = userEvent.setup();
render(<CreateAlertChannels preType={ChannelType.Slack} />);
await selectType(user, 'Google Chat');
await waitFor(() =>
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
googleChatTitleDefaultValue,
),
);
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
googleChatDescriptionDefaultValue,
);
await selectType(user, 'Slack');
await waitFor(() =>
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
slackTitleDefaultValue,
),
);
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
slackDescriptionDefaultValue,
);
});
it('Should check if switching to Pagerduty prefills the pagerduty description and not the opsgenie one', async () => {
const user = userEvent.setup();
render(<CreateAlertChannels preType={ChannelType.Opsgenie} />);
await selectType(user, 'Pagerduty');
await waitFor(() =>
expect(screen.getByTestId('pager-description-textarea')).toHaveTextContent(
pagerDutyDescriptionDefaultValue,
),
);
});
});
});
});

View File

@@ -5,7 +5,7 @@ import {
opsGenieMessageDefaultValue,
opsGeniePriorityDefaultValue,
pagerDutyAdditionalDetailsDefaultValue,
pagerDutyDescriptionDefaultValue,
pagerDutyDescriptionDefaultVaule,
pagerDutySeverityTextDefaultValue,
slackDescriptionDefaultValue,
slackTitleDefaultValue,
@@ -150,7 +150,7 @@ describe('Create Alert Channel (Normal User)', () => {
);
expect(descriptionTextArea).toHaveTextContent(
pagerDutyDescriptionDefaultValue,
pagerDutyDescriptionDefaultVaule,
);
});
it('Should check if Severity label, info (help_pager_severity), and textbox are displayed properly', () => {

View File

@@ -104,7 +104,6 @@ export enum ChannelType {
Pagerduty = 'pagerduty',
Opsgenie = 'opsgenie',
MsTeams = 'msteams',
GoogleChat = 'googlechat',
}
// LabelFilterStatement will be used for preparing filter conditions / matchers
@@ -126,11 +125,3 @@ export interface MsTeamsChannel extends Channel {
title?: string;
text?: string;
}
export interface GoogleChatChannel extends Channel {
// incoming webhook url of the google chat space, must be an
// https url on chat.googleapis.com
webhook_url?: string;
title?: string;
text?: string;
}

View File

@@ -1,51 +1,4 @@
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
WebhookChannel,
} from './config';
// shared by slack and ms teams, both render the same title / description boxes
export const SlackInitialConfig: Partial<SlackChannel> = {
text: `{{ range .Alerts -}}
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
*Summary:* {{ .Annotations.summary }}
*Description:* {{ .Annotations.description }}
*RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}
*RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}
*Details:*
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
{{ end }}
{{ end }}`,
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
{{" "}}(
{{- with .CommonLabels.Remove .GroupLabels.Names }}
{{- range $index, $label := .SortedPairs -}}
{{ if $index }}, {{ end }}
{{- $label.Name }}="{{ $label.Value -}}"
{{- end }}
{{- end -}}
)
{{- end }}`,
};
// mirrors DefaultGoogleChatReceiverConfig in pkg/types/alertmanagertypes/googlechat.go,
// which the backend applies when title / text are left empty
export const GoogleChatInitialConfig: Partial<GoogleChatChannel> = {
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
text: `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}{{ if .Annotations.summary }}
**Summary:** {{ .Annotations.summary }}{{ end }}{{ if .Annotations.description }}
**Description:** {{ .Annotations.description }}{{ end }}
{{ end }}`,
};
import { EmailChannel, OpsgenieChannel, PagerChannel } from './config';
export const PagerInitialConfig: Partial<PagerChannel> = {
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
@@ -493,26 +446,3 @@ export const EmailInitialConfig: Partial<EmailChannel> = {
</body>
</html>`,
};
// prefilled values of every channel type, keyed by type so the form can apply
// exactly one set of defaults and swap it when the type changes
export const ChannelInitialConfig: Record<
ChannelType,
Partial<
SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel
>
> = {
[ChannelType.Slack]: SlackInitialConfig,
[ChannelType.MsTeams]: SlackInitialConfig,
[ChannelType.GoogleChat]: GoogleChatInitialConfig,
[ChannelType.Pagerduty]: PagerInitialConfig,
[ChannelType.Opsgenie]: OpsgenieInitialConfig,
[ChannelType.Email]: EmailInitialConfig,
[ChannelType.Webhook]: {},
};

View File

@@ -14,24 +14,16 @@ import testPagerApi from 'api/channels/testPager';
import testSlackApi from 'api/channels/testSlack';
import testWebhookApi from 'api/channels/testWebhook';
import logEvent from 'api/common/logEvent';
import {
useCreateChannel,
useTestChannel,
} from 'api/generated/services/channels';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import ROUTES from 'constants/routes';
import FormAlertChannels from 'container/FormAlertChannels';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
@@ -39,12 +31,12 @@ import {
ValidatePagerChannel,
WebhookChannel,
} from './config';
import { ChannelInitialConfig } from './defaults';
import {
isChannelType,
isValidGoogleChatWebhookURL,
prepareGoogleChatRequest,
} from './utils';
EmailInitialConfig,
OpsgenieInitialConfig,
PagerInitialConfig,
} from './defaults';
import { isChannelType } from './utils';
import './CreateAlertChannels.styles.scss';
@@ -68,38 +60,69 @@ function CreateAlertChannels({
PagerChannel &
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel
EmailChannel
>
>(() => ({
>({
send_resolved: true,
...ChannelInitialConfig[preType],
}));
text: `{{ range .Alerts -}}
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
*Summary:* {{ .Annotations.summary }}
*Description:* {{ .Annotations.description }}
*RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}
*RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}
*Details:*
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
{{ end }}
{{ end }}`,
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
{{" "}}(
{{- with .CommonLabels.Remove .GroupLabels.Names }}
{{- range $index, $label := .SortedPairs -}}
{{ if $index }}, {{ end }}
{{- $label.Name }}="{{ $label.Value -}}"
{{- end }}
{{- end -}}
)
{{- end }}`,
});
const [savingState, setSavingState] = useState<boolean>(false);
const [testingState, setTestingState] = useState<boolean>(false);
const { notifications } = useNotifications();
const { mutateAsync: createChannel } = useCreateChannel();
const { mutateAsync: testChannel } = useTestChannel();
const [type, setType] = useState<ChannelType>(preType);
const onTypeChangeHandler = useCallback(
(value: string) => {
const nextType = value as ChannelType;
if (nextType === type) {
return;
const currentType = type;
setType(value as ChannelType);
if (value === ChannelType.Pagerduty && currentType !== value) {
// reset config to pager defaults
setSelectedConfig({
name: selectedConfig?.name,
send_resolved: selectedConfig.send_resolved,
...PagerInitialConfig,
});
}
setType(nextType);
if (value === ChannelType.Opsgenie && currentType !== value) {
setSelectedConfig((selectedConfig) => ({
...selectedConfig,
...OpsgenieInitialConfig,
}));
}
// the fields the types share (title, text, description) keep the value of
// the type that was selected before, so the new type's defaults have to be
// written to both the config and the form
const defaults = ChannelInitialConfig[nextType];
setSelectedConfig((selectedConfig) => ({ ...selectedConfig, ...defaults }));
formInstance.setFieldsValue(defaults);
// reset config to email defaults
if (value === ChannelType.Email && currentType !== value) {
setSelectedConfig((selectedConfig) => ({
...selectedConfig,
...EmailInitialConfig,
}));
}
},
[type, formInstance],
[type, selectedConfig],
);
const prepareSlackRequest = useCallback(
@@ -384,56 +407,6 @@ function CreateAlertChannels({
showErrorModal,
]);
const validateGoogleChatConfig = useCallback((): boolean => {
if (!selectedConfig.webhook_url) {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
return false;
}
if (!isValidGoogleChatWebhookURL(selectedConfig.webhook_url)) {
notifications.error({
message: 'Error',
description: t('google_chat_webhook_url_invalid'),
});
return false;
}
return true;
}, [selectedConfig.webhook_url, notifications, t]);
const onGoogleChatHandler = useCallback(async () => {
if (!validateGoogleChatConfig()) {
return { status: 'failed', statusMessage: t('channel_creation_failed') };
}
setSavingState(true);
try {
await createChannel({ data: prepareGoogleChatRequest(selectedConfig) });
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
validateGoogleChatConfig,
createChannel,
selectedConfig,
notifications,
t,
showErrorModal,
]);
const onSaveHandler = useCallback(
async (value: ChannelType) => {
if (!selectedConfig.name) {
@@ -451,7 +424,6 @@ function CreateAlertChannels({
[ChannelType.Opsgenie]: onOpsgenieHandler,
[ChannelType.MsTeams]: onMsTeamsHandler,
[ChannelType.Email]: onEmailHandler,
[ChannelType.GoogleChat]: onGoogleChatHandler,
};
if (isChannelType(value)) {
@@ -483,7 +455,6 @@ function CreateAlertChannels({
onOpsgenieHandler,
onMsTeamsHandler,
onEmailHandler,
onGoogleChatHandler,
notifications,
t,
],
@@ -521,13 +492,6 @@ function CreateAlertChannels({
request = prepareEmailRequest();
await testEmail(request);
break;
case ChannelType.GoogleChat:
if (!validateGoogleChatConfig()) {
setTestingState(false);
return;
}
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
break;
default:
notifications.error({
message: 'Error',
@@ -549,11 +513,7 @@ function CreateAlertChannels({
status: 'Test success',
});
} catch (error) {
showErrorModal(
error instanceof APIError
? error
: toAPIError(error as ErrorType<RenderErrorResponseDTO>),
);
showErrorModal(error as APIError);
logEvent('Alert Channel: Test notification', {
type: channelType,
@@ -575,8 +535,6 @@ function CreateAlertChannels({
prepareSlackRequest,
prepareMsTeamsRequest,
prepareEmailRequest,
validateGoogleChatConfig,
testChannel,
notifications,
],
);
@@ -604,6 +562,9 @@ function CreateAlertChannels({
initialValue: {
type,
...selectedConfig,
...PagerInitialConfig,
...OpsgenieInitialConfig,
...EmailInitialConfig,
},
}}
/>

View File

@@ -1,39 +1,4 @@
import {
AlertmanagertypesPostableChannelDTO,
ConfigSecretURLDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ChannelType, GoogleChatChannel } from './config';
import { ChannelType } from './config';
export const isChannelType = (type: string): type is ChannelType =>
Object.values(ChannelType).includes(type as ChannelType);
const GOOGLE_CHAT_WEBHOOK_HOST = 'chat.googleapis.com';
// the backend enforces the same two rules, this is only for a nicer error experience
export const isValidGoogleChatWebhookURL = (url: string): boolean => {
try {
const { protocol, hostname } = new URL(url);
return (
protocol === 'https:' && hostname.toLowerCase() === GOOGLE_CHAT_WEBHOOK_HOST
);
} catch {
return false;
}
};
// create, update and test all send the same body shape
export const prepareGoogleChatRequest = (
config: Partial<GoogleChatChannel>,
): AlertmanagertypesPostableChannelDTO => ({
name: config.name || '',
googlechat_configs: [
{
// the generated type models go's config.SecretURL as an object, the api takes a string
webhook_url: (config.webhook_url || '') as unknown as ConfigSecretURLDTO,
title: config.title || '',
text: config.text || '',
send_resolved: config.send_resolved || false,
},
],
});

View File

@@ -14,17 +14,10 @@ import testPagerApi from 'api/channels/testPager';
import testSlackApi from 'api/channels/testSlack';
import testWebhookApi from 'api/channels/testWebhook';
import logEvent from 'api/common/logEvent';
import {
useTestChannel,
useUpdateChannelByID,
} from 'api/generated/services/channels';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import ROUTES from 'constants/routes';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
@@ -32,15 +25,10 @@ import {
ValidatePagerChannel,
WebhookChannel,
} from 'container/CreateAlertChannels/config';
import {
isValidGoogleChatWebhookURL,
prepareGoogleChatRequest,
} from 'container/CreateAlertChannels/utils';
import FormAlertChannels from 'container/FormAlertChannels';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
function EditAlertChannels({
initialValue,
@@ -57,8 +45,7 @@ function EditAlertChannels({
PagerChannel &
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel
EmailChannel
>
>({
...initialValue,
@@ -67,26 +54,6 @@ function EditAlertChannels({
const [testingState, setTestingState] = useState<boolean>(false);
const { notifications } = useNotifications();
const { mutateAsync: updateChannel } = useUpdateChannelByID();
const { mutateAsync: testChannel } = useTestChannel();
const notifyError = useCallback(
(error: unknown): APIError => {
const apiError =
error instanceof APIError
? error
: toAPIError(error as ErrorType<RenderErrorResponseDTO>);
notifications.error({
message: apiError.getErrorCode(),
description: apiError.getErrorMessage(),
});
return apiError;
},
[notifications],
);
const [type, setType] = useState<ChannelType>(
initialValue?.type ? (initialValue.type as ChannelType) : ChannelType.Slack,
);
@@ -397,61 +364,6 @@ function EditAlertChannels({
}
}, [prepareMsTeamsRequest, t, notifications, selectedConfig]);
const validateGoogleChatConfig = useCallback((): string => {
if (!selectedConfig?.webhook_url) {
return t('webhook_url_required');
}
if (!isValidGoogleChatWebhookURL(selectedConfig.webhook_url)) {
return t('google_chat_webhook_url_invalid');
}
return '';
}, [selectedConfig, t]);
const onGoogleChatEditHandler = useCallback(async () => {
const validationError = validateGoogleChatConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return { status: 'failed', statusMessage: validationError };
}
setSavingState(true);
try {
await updateChannel({
pathParams: { id },
data: prepareGoogleChatRequest(selectedConfig),
});
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
const apiError = notifyError(error);
return {
status: 'failed',
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [
validateGoogleChatConfig,
updateChannel,
id,
selectedConfig,
notifications,
notifyError,
t,
]);
const onSaveHandler = useCallback(
async (value: ChannelType) => {
let result;
@@ -467,8 +379,6 @@ function EditAlertChannels({
result = await onOpsgenieEditHandler();
} else if (value === ChannelType.Email) {
result = await onEmailEditHandler();
} else if (value === ChannelType.GoogleChat) {
result = await onGoogleChatEditHandler();
}
logEvent('Alert Channel: Save channel', {
type: value,
@@ -487,7 +397,6 @@ function EditAlertChannels({
onMsTeamsEditHandler,
onOpsgenieEditHandler,
onEmailEditHandler,
onGoogleChatEditHandler,
],
);
@@ -529,19 +438,6 @@ function EditAlertChannels({
await testEmail(request);
}
break;
case ChannelType.GoogleChat: {
const validationError = validateGoogleChatConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setTestingState(false);
return;
}
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
break;
}
default:
notifications.error({
message: 'Error',
@@ -563,7 +459,10 @@ function EditAlertChannels({
status: 'Test success',
});
} catch (error) {
notifyError(error);
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
logEvent('Alert Channel: Test notification', {
type: channelType,
sendResolvedAlert: selectedConfig?.send_resolved,
@@ -577,9 +476,6 @@ function EditAlertChannels({
// eslint-disable-next-line react-hooks/exhaustive-deps
[
t,
notifyError,
validateGoogleChatConfig,
testChannel,
prepareWebhookRequest,
preparePagerRequest,
prepareSlackRequest,

View File

@@ -1,82 +0,0 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { GoogleChatChannel } from '../../CreateAlertChannels/config';
import { isValidGoogleChatWebhookURL } from '../../CreateAlertChannels/utils';
function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
const { t } = useTranslation('channels');
return (
<>
<Form.Item
name="webhook_url"
label={t('field_webhook_url')}
required
rules={[
{
validator: (_, value: string): Promise<void> =>
!value || isValidGoogleChatWebhookURL(value)
? Promise.resolve()
: Promise.reject(new Error(t('google_chat_webhook_url_invalid'))),
},
]}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_google_chat_url')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
>
<Input
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
webhook_url: event.target.value,
}));
}}
data-testid="webhook-url-textbox"
/>
</Form.Item>
<Form.Item name="title" label={t('field_slack_title')}>
<Input.TextArea
rows={4}
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
title: event.target.value,
}))
}
data-testid="title-textarea"
/>
</Form.Item>
<Form.Item name="text" label={t('field_slack_description')}>
<Input.TextArea
rows={4}
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
text: event.target.value,
}))
}
data-testid="description-textarea"
placeholder={t('placeholder_slack_description')}
/>
</Form.Item>
</>
);
}
interface GoogleChatProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<GoogleChatChannel>>>;
}
export default GoogleChat;

View File

@@ -9,7 +9,6 @@ import ROUTES from 'constants/routes';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
@@ -18,7 +17,6 @@ import {
import history from 'lib/history';
import EmailSettings from './Settings/Email';
import GoogleChatSettings from './Settings/GoogleChat';
import MsTeamsSettings from './Settings/MsTeams';
import OpsgenieSettings from './Settings/Opsgenie';
import PagerSettings from './Settings/Pager';
@@ -51,8 +49,6 @@ function FormAlertChannels({
return <PagerSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.MsTeams:
return <MsTeamsSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.GoogleChat:
return <GoogleChatSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Opsgenie:
return <OpsgenieSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Email:
@@ -133,14 +129,6 @@ function FormAlertChannels({
<Select.Option value="msteams" key="msteams" data-testid="select-option">
Microsoft Teams
</Select.Option>
<Select.Option
value="googlechat"
key="googlechat"
data-testid="select-option"
>
Google Chat
</Select.Option>
</Select>
</Form.Item>
@@ -188,8 +176,7 @@ interface FormAlertChannelsProps {
WebhookChannel &
PagerChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel
EmailChannel
>
>
>;

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { Color } from '@signozhq/design-tokens';
import { Badge } from '@signozhq/ui/badge';
import { Progress } from '@signozhq/ui/progress';
import {
@@ -9,7 +10,6 @@ import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/K8
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { TextNoData } from 'container/InfraMonitoringK8sV2/components';
import { getStrokeColorForPercent } from 'container/InfraMonitoringK8sV2/components/EntityProgressBar.utils';
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
import {
getHostQueryPayload,
@@ -18,6 +18,26 @@ import {
import infraHostsStyles from './InfraMonitoringHosts.module.scss';
export function getProgressColor(percent: number): string {
if (percent >= 90) {
return Color.BG_SAKURA_500;
}
if (percent >= 60) {
return Color.BG_AMBER_500;
}
return Color.BG_FOREST_500;
}
export function getMemoryProgressColor(percent: number): string {
if (percent >= 90) {
return Color.BG_CHERRY_500;
}
if (percent >= 60) {
return Color.BG_AMBER_500;
}
return Color.BG_FOREST_500;
}
export type HostDetailMetadataConfigType =
K8sDetailsMetadataConfig<InframonitoringtypesHostRecordDTO>;
export const hostDetailsMetadataConfig: HostDetailMetadataConfigType[] = [
@@ -59,7 +79,7 @@ export const hostDetailsMetadataConfig: HostDetailMetadataConfigType[] = [
render: (value): React.ReactNode => (
<Progress
percent={Number(Number(value).toFixed(1))}
strokeColor={getStrokeColorForPercent('cpu', Number(value))}
strokeColor={getProgressColor(Number(value))}
showInfo
/>
),
@@ -70,7 +90,7 @@ export const hostDetailsMetadataConfig: HostDetailMetadataConfigType[] = [
render: (value): React.ReactNode => (
<Progress
percent={Number(Number(value).toFixed(1))}
strokeColor={getStrokeColorForPercent('memory', Number(value))}
strokeColor={getMemoryProgressColor(Number(value))}
showInfo
/>
),

View File

@@ -9,7 +9,6 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { getGroupByEl } from 'container/InfraMonitoringK8sV2/Base/utils';
import {
EntityProgressBar,
EntityProgressThresholds,
ExpandButtonWrapper,
GroupedStatusCounts,
ValidateColumnValueWrapper,
@@ -99,7 +98,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
),
},
{
id: INFRA_MONITORING_ATTR_KEYS.HOST_NAME,
id: 'hostName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Hostname"
@@ -109,7 +108,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
),
accessorFn: (row): string => row.hostName ?? '',
width: { min: 290 },
enableSort: true,
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -169,10 +168,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
{
id: 'cpu',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/host-monitoring#cpu-usage"
tooltip={<EntityProgressThresholds type="cpu" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/host-monitoring#cpu-usage">
CPU Usage
</ColumnHeader>
),
@@ -199,9 +195,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
id: 'memory',
header: (): React.ReactNode => (
<ColumnHeader
tooltip={
<EntityProgressThresholds type="memory" note="Excluding cache memory." />
}
tooltip="Excluding cache memory."
docPath="/infrastructure-monitoring/host-monitoring#memory-usage"
>
Memory Usage (WSS)
@@ -227,12 +221,9 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
},
},
{
id: 'disk_usage',
id: 'diskUsage',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/host-monitoring#disk-usage"
tooltip={<EntityProgressThresholds type="disk" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/host-monitoring#disk-usage">
Disk Usage
</ColumnHeader>
),

View File

@@ -3,14 +3,13 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './ColumnHeader.module.scss';
import cx from 'classnames';
import { MouseEventHandler } from 'react';
const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
interface ColumnHeaderProps {
children?: React.ReactNode;
docPath?: string;
tooltip?: React.ReactNode;
tooltip?: string;
className?: string;
}
@@ -20,9 +19,6 @@ function ColumnHeader({
tooltip,
className,
}: ColumnHeaderProps): JSX.Element {
const stopPropagationHandler: MouseEventHandler = (e): void =>
e.stopPropagation();
const renderContent = (): React.ReactNode => {
if (children) {
return children;
@@ -34,25 +30,21 @@ function ColumnHeader({
const renderInfoIcon = (): React.ReactNode => {
if (docPath) {
const tooltipTitle = tooltip || 'Not sure what this means?';
const isJustStringTitle = typeof tooltipTitle === 'string';
return (
<TooltipSimple
arrow
title={
<div onClick={stopPropagationHandler}>
<>
{tooltipTitle}{' '}
<a
href={`${DOCS_BASE_URL}${docPath}`}
target="_blank"
rel="noopener"
onClick={stopPropagationHandler}
onClick={(e): void => e.stopPropagation()}
>
{isJustStringTitle
? 'Learn more.'
: 'Check the documentation to learn more.'}
Learn more.
</a>
</div>
</>
}
>
<div className={styles.infoIcon}>
@@ -64,9 +56,7 @@ function ColumnHeader({
if (tooltip) {
return (
<TooltipSimple
title={<div onClick={stopPropagationHandler}>{tooltip}</div>}
>
<TooltipSimple title={tooltip}>
<div className={styles.infoIcon}>
<Info size="md" />
</div>

View File

@@ -128,8 +128,6 @@ export function K8sBaseList<
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
rowHeight: 42,
headerHeight: 58,
paginationHeight: 52,
});
const {
@@ -438,17 +436,16 @@ export function K8sBaseList<
isFetching={isFetching}
cancelQuery={cancelQuery}
/>
<K8sInstrumentationChecksCallout entity={entity} />
<K8sTableToolbar
entity={entity}
eventCategory={eventCategory}
leftFilters={leftFilters}
onOpenOptionsDrawer={handleOpenOptionsDrawer}
/>
<div ref={containerRef} className={styles.tableContainer}>
<K8sInstrumentationChecksCallout entity={entity} />
<K8sTableToolbar
entity={entity}
eventCategory={eventCategory}
leftFilters={leftFilters}
onOpenOptionsDrawer={handleOpenOptionsDrawer}
/>
{isError && (
<Typography>
{data?.error?.toString() || 'Something went wrong'}

View File

@@ -13,7 +13,7 @@
--tanstack-table-resize-handle-hover-bg: var(--l1-border);
--tanstack-table-row-height: 36px;
--tanstack-cell-padding-left-override: 26px;
--tanstack-cell-padding-left-override: 15px;
--tanstack-cell-padding-right-override: 15px;
& [data-hide-expanded='true'] {

View File

@@ -10,19 +10,18 @@ import TanStackTable, {
TableColumnDef,
TanStackTableStateProvider,
} from 'components/TanStackTableView';
import { QueryParams } from 'constants/query';
import { CornerDownRight } from '@signozhq/icons';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { v4 as uuid } from 'uuid';
import { useQueryState } from 'nuqs';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../constants';
import { InfraMonitoringEntity } from '../constants';
import {
SelectedItemParams,
useInfraMonitoringGroupBy,
@@ -37,8 +36,6 @@ import { useInfraMonitoringFontSize } from './useInfraMonitoringTablePreferences
import styles from './K8sExpandedRow.module.scss';
import { buildExpressionFromGroupMeta } from './utils';
import { logInfraColumnSortedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
import { getUnstableCurrentSearchParams } from 'container/TopNav/DateTimeSelectionV2/utils/getUnstableCurrentSearchParams';
import { QueryParams } from 'constants/query';
const EXPANDED_ROW_LIMIT = 10;
@@ -95,6 +92,7 @@ export function K8sExpandedRow<
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
const [, setMainOrderBy] = useInfraMonitoringOrderBy();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const location = useLocation();
const queryClient = useQueryClient();
@@ -260,26 +258,13 @@ export function K8sExpandedRow<
},
};
const searchParams = getUnstableCurrentSearchParams();
searchParams.set(
const newUrlQuery = new URLSearchParams(urlQuery.toString());
newUrlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(updatedQuery)),
);
searchParams.delete(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
searchParams.delete(INFRA_MONITORING_K8S_PARAMS_KEYS.EXPANDED);
searchParams.delete(orderByParamKey);
searchParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE, '1');
if (orderBy) {
searchParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY,
JSON.stringify(orderBy),
);
}
safeNavigate(`${location.pathname}?${searchParams.toString()}`);
safeNavigate(`${location.pathname}?${newUrlQuery.toString()}`);
};
const total = data?.total ?? 0;
@@ -291,7 +276,6 @@ export function K8sExpandedRow<
color="secondary"
variant="outlined"
className={styles.viewAllButton}
data-testid="expanded-row-view-all"
onClick={handleViewAllClick}
prefix={<CornerDownRight size={14} />}
>

View File

@@ -6,13 +6,9 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import { InfraMonitoringEvents } from 'constants/events';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
} from '../constants';
import { InfraMonitoringEntity } from '../constants';
import {
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
} from '../hooks';
import { useInfraMonitoringGroupByData } from './useInfraMonitoringGroupByData';
@@ -20,19 +16,6 @@ import { useInfraMonitoringGroupByData } from './useInfraMonitoringGroupByData';
import styles from './K8sTableToolbar.module.scss';
import { logInfraGroupByCustomizedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
const NAME_COLUMN_KEYS: Set<string> = new Set([
INFRA_MONITORING_ATTR_KEYS.HOST_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
]);
interface K8sTableToolbarProps {
entity: InfraMonitoringEntity;
eventCategory: InfraMonitoringEvents;
@@ -52,17 +35,11 @@ function K8sTableToolbar({
useInfraMonitoringGroupByData(entity);
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const handleGroupByChange = useCallback(
(value: string[]) => {
void setCurrentPage(1);
if (orderBy && NAME_COLUMN_KEYS.has(orderBy.columnName)) {
void setOrderBy(null);
}
void setGroupBy(value);
void logEvent(InfraMonitoringEvents.GroupByChanged, {
@@ -73,16 +50,15 @@ function K8sTableToolbar({
logInfraGroupByCustomizedEvent(entity, value);
},
[entity, eventCategory, orderBy, setCurrentPage, setOrderBy, setGroupBy],
[entity, eventCategory, setCurrentPage, setGroupBy],
);
return (
<div className={styles.toolbar}>
<div className={styles.groupByContainer} data-testid="k8s-table-group-by">
<div className={styles.groupByContainer}>
<div className={styles.groupByLabel}>Group by</div>
<Select
className={styles.groupBySelect}
data-testid="k8s-table-group-by-select"
loading={isLoadingGroupByFilters}
mode="multiple"
value={groupBy}

View File

@@ -1370,127 +1370,4 @@ describe('K8sBaseList', () => {
).resolves.toBeInTheDocument();
});
});
describe('groupBy change clears orderBy', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
const fetchListDataMock = jest.fn<
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
>();
beforeEach(() => {
onUrlUpdateMock.mockClear();
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [{ id: 'item-1' }],
total: 1,
error: null,
});
server.use(
rest.get('http://localhost/api/v2/infra_monitoring/checks', (_, res, ctx) =>
res(ctx.json({ status: 'success', data: { ready: true } })),
),
rest.get('http://localhost/api/v1/fields/keys', (_, res, ctx) =>
res(
ctx.json({
status: 'success',
data: {
keys: {
resource: [{ name: 'k8s.namespace.name' }],
},
},
}),
),
),
);
});
it('should clear orderBy for name columns when groupBy is changed', async () => {
const user = userEvent.setup();
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
// k8s.pod.name is a name column - should be cleared
orderBy: JSON.stringify({ columnName: 'k8s.pod.name', order: 'desc' }),
},
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
await waitFor(() => {
expect(screen.getByTestId('k8s-table-group-by')).toBeInTheDocument();
});
// Open group by dropdown using testId
const groupByContainer = screen.getByTestId('k8s-table-group-by-select');
const groupBySelect = groupByContainer.querySelector(
'.ant-select-selector',
) as Element;
await user.click(groupBySelect);
// Wait for options to load and click on the namespace option
const namespaceOption = await screen.findByTitle('k8s.namespace.name');
await user.click(namespaceOption);
// Verify orderBy was cleared (set to null) for name column
await waitFor(() => {
const orderByCalls = onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('orderBy'))
.filter((v) => v !== undefined);
const hasOrderByCleared = orderByCalls.some((v) => v === null);
expect(hasOrderByCleared).toBe(true);
});
});
it('should keep orderBy for non-name columns when groupBy is changed', async () => {
const user = userEvent.setup();
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
// cpu is NOT a name column - should be kept
orderBy: JSON.stringify({ columnName: 'cpu', order: 'desc' }),
},
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
await waitFor(() => {
expect(screen.getByTestId('k8s-table-group-by')).toBeInTheDocument();
});
// Open group by dropdown using testId
const groupByContainer = screen.getByTestId('k8s-table-group-by-select');
const groupBySelect = groupByContainer.querySelector(
'.ant-select-selector',
) as Element;
await user.click(groupBySelect);
// Wait for options to load and click on the namespace option
const namespaceOption = await screen.findByTitle('k8s.namespace.name');
await user.click(namespaceOption);
// Verify orderBy was NOT cleared for non-name column
await waitFor(() => {
const orderByCalls = onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('orderBy'))
.filter((v) => v !== undefined);
// orderBy should never be set to null
const hasOrderByCleared = orderByCalls.some((v) => v === null);
expect(hasOrderByCleared).toBe(false);
});
});
});
});

View File

@@ -60,7 +60,7 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
},
},
{
id: INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
id: 'clusterName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Cluster Name"
@@ -70,7 +70,7 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
),
accessorFn: (row): string => row.clusterName || '',
width: { min: 290 },
enableSort: true,
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',

View File

@@ -10,7 +10,6 @@ import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
@@ -70,7 +69,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
},
},
{
id: INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
id: 'daemonsetName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="DaemonSet Name"
@@ -81,7 +80,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] || '',
width: { min: 290 },
enableSort: true,
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -175,10 +174,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
{
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-req-usage-"
tooltip={<EntityProgressThresholds type="cpu-request" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-req-usage-">
CPU Request Usage (%)
</ColumnHeader>
),
@@ -196,7 +192,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="cpu-request" />
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
@@ -204,10 +200,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
{
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-limit-usage-"
tooltip={<EntityProgressThresholds type="cpu-limit" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-limit-usage-">
CPU Limit Usage (%)
</ColumnHeader>
),
@@ -224,7 +217,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
@@ -258,10 +251,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
{
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-req-usage-"
tooltip={<EntityProgressThresholds type="memory-request" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-req-usage-">
Memory Request Usage (%)
</ColumnHeader>
),
@@ -279,7 +269,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="memory-request" />
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
@@ -287,10 +277,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
{
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-limit-usage-"
tooltip={<EntityProgressThresholds type="memory-limit" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-limit-usage-">
Memory Limit Usage (%)
</ColumnHeader>
),
@@ -307,7 +294,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="memory-limit" />
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},

View File

@@ -10,7 +10,6 @@ import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
@@ -71,7 +70,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
},
},
{
id: INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
id: 'deploymentName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Deployment Name"
@@ -82,7 +81,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] || '',
width: { min: 290 },
enableSort: true,
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -163,10 +162,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
{
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-req-usage-"
tooltip={<EntityProgressThresholds type="cpu-request" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-req-usage-">
CPU Request Usage (%)
</ColumnHeader>
),
@@ -184,7 +180,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="cpu-request" />
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
@@ -192,10 +188,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
{
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-limit-usage-"
tooltip={<EntityProgressThresholds type="cpu-limit" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-limit-usage-">
CPU Limit Usage (%)
</ColumnHeader>
),
@@ -212,7 +205,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
@@ -245,10 +238,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
{
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/deployments#mem-req-usage-"
tooltip={<EntityProgressThresholds type="memory-request" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#mem-req-usage-">
Memory Request Usage (%)
</ColumnHeader>
),
@@ -266,7 +256,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="memory-request" />
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
@@ -274,10 +264,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
{
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/deployments#mem-limit-usage-"
tooltip={<EntityProgressThresholds type="memory-limit" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#mem-limit-usage-">
Memory Limit Usage (%)
</ColumnHeader>
),
@@ -294,7 +281,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="memory-limit" />
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},

View File

@@ -10,7 +10,6 @@ import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
@@ -64,7 +63,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
},
},
{
id: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
id: 'jobName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Job Name"
@@ -75,7 +74,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME] || '',
width: { min: 290 },
enableSort: true,
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -159,10 +158,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
{
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-req-usage-"
tooltip={<EntityProgressThresholds type="cpu-request" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-req-usage-">
CPU Request Usage (%)
</ColumnHeader>
),
@@ -180,7 +176,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
attribute="CPU Request"
rowId={rowId}
>
<EntityProgressBar value={cpuRequest} type="cpu-request" />
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
@@ -188,10 +184,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
{
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-limit-usage-"
tooltip={<EntityProgressThresholds type="cpu-limit" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-limit-usage-">
CPU Limit Usage (%)
</ColumnHeader>
),
@@ -208,7 +201,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
attribute="CPU Limit"
rowId={rowId}
>
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
@@ -241,10 +234,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
{
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/jobs#mem-req-usage-"
tooltip={<EntityProgressThresholds type="memory-request" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#mem-req-usage-">
Memory Request Usage (%)
</ColumnHeader>
),
@@ -262,7 +252,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
attribute="Memory Request"
rowId={rowId}
>
<EntityProgressBar value={memoryRequest} type="memory-request" />
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
@@ -270,10 +260,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
{
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/jobs#mem-limit-usage-"
tooltip={<EntityProgressThresholds type="memory-limit" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#mem-limit-usage-">
Memory Limit Usage (%)
</ColumnHeader>
),
@@ -290,7 +277,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
attribute="Memory Limit"
rowId={rowId}
>
<EntityProgressBar value={memoryLimit} type="memory-limit" />
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},

View File

@@ -66,7 +66,7 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
},
},
{
id: INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
id: 'namespaceName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Namespace Name"
@@ -76,7 +76,7 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
),
accessorFn: (row): string => row.namespaceName || '',
width: { min: 290 },
enableSort: true,
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',

View File

@@ -68,7 +68,7 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
},
},
{
id: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
id: 'nodeName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Node Name"
@@ -78,7 +78,7 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
),
accessorFn: (row): string => row.nodeName || '',
width: { min: 290 },
enableSort: true,
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',

View File

@@ -17,7 +17,6 @@ import {
} from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
@@ -69,7 +68,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
},
},
{
id: INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
id: 'podName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Pod Name"
@@ -80,7 +79,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
width: { min: 290 },
enableSort: true,
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -97,7 +96,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
</ColumnHeader>
),
accessorFn: (row): string => row.podStatus,
width: { min: 250 },
width: { min: 160 },
enableSort: false,
visibilityBehavior: 'hidden-on-expand',
cell: ({ row }): React.ReactNode => {
@@ -194,10 +193,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
{
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/pods#cpu-req-usage-"
tooltip={<EntityProgressThresholds type="cpu-request" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#cpu-req-usage-">
CPU Request Usage (%)
</ColumnHeader>
),
@@ -214,7 +210,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
entity={InfraMonitoringEntity.PODS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="cpu-request" />
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
@@ -222,10 +218,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
{
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/pods#cpu-limit-usage-"
tooltip={<EntityProgressThresholds type="cpu-limit" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#cpu-limit-usage-">
CPU Limit Usage (%)
</ColumnHeader>
),
@@ -241,7 +234,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
entity={InfraMonitoringEntity.PODS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
@@ -273,10 +266,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
{
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/pods#mem-req-usage-"
tooltip={<EntityProgressThresholds type="memory-request" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#mem-req-usage-">
Memory Request Usage (%)
</ColumnHeader>
),
@@ -293,7 +283,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
entity={InfraMonitoringEntity.PODS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="memory-request" />
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
@@ -301,10 +291,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
{
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/pods#mem-limit-usage-"
tooltip={<EntityProgressThresholds type="memory-limit" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#mem-limit-usage-">
Memory Limit Usage (%)
</ColumnHeader>
),
@@ -320,7 +307,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
entity={InfraMonitoringEntity.PODS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="memory-limit" />
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},

View File

@@ -10,7 +10,6 @@ import { SelectedItemParams } from '../hooks';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
@@ -71,7 +70,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
},
},
{
id: INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
id: 'statefulsetName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="StatefulSet Name"
@@ -82,7 +81,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME] || '',
width: { min: 290 },
enableSort: true,
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -166,10 +165,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
{
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-req-usage-"
tooltip={<EntityProgressThresholds type="cpu-request" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-req-usage-">
CPU Request Usage (%)
</ColumnHeader>
),
@@ -187,7 +183,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="cpu-request" />
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
@@ -195,10 +191,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
{
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-limit-usage-"
tooltip={<EntityProgressThresholds type="cpu-limit" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-limit-usage-">
CPU Limit Usage (%)
</ColumnHeader>
),
@@ -215,7 +208,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
@@ -249,10 +242,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
{
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-req-usage-"
tooltip={<EntityProgressThresholds type="memory-request" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-req-usage-">
Memory Request Usage (%)
</ColumnHeader>
),
@@ -270,7 +260,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="memory-request" />
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
@@ -278,10 +268,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
{
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-limit-usage-"
tooltip={<EntityProgressThresholds type="memory-limit" />}
>
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-limit-usage-">
Memory Limit Usage (%)
</ColumnHeader>
),
@@ -298,7 +285,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="memory-limit" />
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},

View File

@@ -64,7 +64,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
},
},
{
id: INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
id: 'pvcName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="PVC Name"
@@ -74,7 +74,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
),
accessorFn: (row): string => row.persistentVolumeClaimName || '',
width: { min: 290 },
enableSort: true,
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
@@ -195,7 +195,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
},
},
{
id: 'inodes_used',
id: 'inodesUsed',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/volumes#volume-inodes-used">
Inodes Used
@@ -219,7 +219,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
},
},
{
id: 'inodes_free',
id: 'inodesFree',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/volumes#volume-inodes-free">
Inodes Free

View File

@@ -26,6 +26,48 @@ export function formatBytes(bytes: number, decimals = 2): string {
return `${parseFloat((bytes / k ** i).toFixed(decimals))} ${sizes[i]}`;
}
/**
* Returns stroke color for request utilization parameters according to current value
*/
export function getStrokeColorForRequestUtilization(value: number): string {
const percent = Number((value * 100).toFixed(1));
// Orange
if (percent <= 50) {
return Color.BG_AMBER_500;
}
// Green
if (percent > 50 && percent <= 100) {
return Color.BG_FOREST_500;
}
// Regular Red
if (percent > 100 && percent <= 150) {
return Color.BG_SAKURA_500;
}
// Dark Red
return Color.BG_CHERRY_600;
}
/**
* Returns stroke color for limit utilization parameters according to current value
*/
export function getStrokeColorForLimitUtilization(value: number): string {
const percent = Number((value * 100).toFixed(1));
// Green
if (percent <= 60) {
return Color.BG_FOREST_500;
}
// Yellow
if (percent > 60 && percent <= 80) {
return Color.BG_AMBER_200;
}
// Orange
if (percent > 80 && percent <= 95) {
return Color.BG_AMBER_500;
}
// Red
return Color.BG_SAKURA_500;
}
export const POD_STATUS_COLORS: Record<
InframonitoringtypesPodStatusDTO,
BadgeColor

View File

@@ -1,11 +1,35 @@
import { Progress } from '@signozhq/ui/progress';
import TanStackTable from 'components/TanStackTableView';
import {
getMemoryProgressColor,
getProgressColor,
} from 'container/InfraMonitoringHostsV2/constants';
import {
getStrokeColorForLimitUtilization,
getStrokeColorForRequestUtilization,
} from '../commonUtils';
import styles from './EntityProgressBar.module.scss';
import {
EntityProgressBarType,
getStrokeColor,
} from './EntityProgressBar.utils';
type EntityProgressBarType = 'request' | 'limit' | 'cpu' | 'memory' | 'disk';
function getStrokeColor(type: EntityProgressBarType, value: number): string {
switch (type) {
case 'limit':
return getStrokeColorForLimitUtilization(value);
case 'request':
return getStrokeColorForRequestUtilization(value);
case 'cpu':
return getProgressColor(Number((value * 100).toFixed(1)));
case 'memory':
return getMemoryProgressColor(Number((value * 100).toFixed(1)));
case 'disk':
return getProgressColor(Number((value * 100).toFixed(1)));
default:
return getStrokeColorForRequestUtilization(value);
}
}
export function EntityProgressBar({
value,

View File

@@ -1,254 +0,0 @@
import { Color } from '@signozhq/design-tokens';
export type EntityProgressBarType =
| 'cpu-request'
| 'cpu-limit'
| 'memory-request'
| 'memory-limit'
| 'cpu'
| 'memory'
| 'disk';
export interface EntityProgressThreshold {
matches: (percent: number) => boolean;
color: string;
range: string;
label: string;
description: string;
}
const CPU_REQUEST_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent <= 50,
color: Color.BG_AMBER_500,
range: '≤ 50%',
label: 'Over-requested',
description:
'CPU usage is at most half of the request. The rest of the request stays reserved on the node.',
},
{
matches: (percent): boolean => percent <= 100,
color: Color.BG_FOREST_500,
range: '> 50% - 100%',
label: 'Right-sized',
description: 'CPU usage is close to the request and stays within it.',
},
{
matches: (percent): boolean => percent <= 150,
color: Color.BG_SAKURA_500,
range: '> 100% - 150%',
label: 'Over request',
description:
'CPU usage is above the request. The extra CPU is not guaranteed and depends on spare node capacity.',
},
{
matches: (): boolean => true,
color: Color.BG_CHERRY_600,
range: '> 150%',
label: 'Request badly undersized',
description:
'CPU usage is more than 1.5x the request, so most of the CPU in use is not guaranteed.',
},
];
const CPU_LIMIT_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent <= 60,
color: Color.BG_FOREST_500,
range: '≤ 60%',
label: 'Healthy',
description: 'CPU usage is well below the limit.',
},
{
matches: (percent): boolean => percent <= 80,
color: Color.BG_AMBER_200,
range: '> 60% - 80%',
label: 'Watch',
description: 'CPU usage is approaching the limit.',
},
{
matches: (percent): boolean => percent <= 95,
color: Color.BG_AMBER_500,
range: '> 80% - 95%',
label: 'Near limit',
description:
'CPU usage is close to the limit. Usage above the limit is throttled.',
},
{
matches: (): boolean => true,
color: Color.BG_SAKURA_500,
range: '> 95%',
label: 'At limit',
description:
'CPU usage is at the limit, so the container is likely being throttled.',
},
];
const MEMORY_REQUEST_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent <= 50,
color: Color.BG_AMBER_500,
range: '≤ 50%',
label: 'Over-requested',
description:
'Memory usage is at most half of the request. The rest of the request stays reserved on the node.',
},
{
matches: (percent): boolean => percent <= 100,
color: Color.BG_FOREST_500,
range: '> 50% - 100%',
label: 'Right-sized',
description: 'Memory usage is close to the request and stays within it.',
},
{
matches: (percent): boolean => percent <= 150,
color: Color.BG_SAKURA_500,
range: '> 100% - 150%',
label: 'Over request',
description:
'Memory usage is above the request. The extra memory is not guaranteed and is reclaimed first under node memory pressure.',
},
{
matches: (): boolean => true,
color: Color.BG_CHERRY_600,
range: '> 150%',
label: 'Request badly undersized',
description:
'Memory usage is more than 1.5x the request, so most of the memory in use is not guaranteed.',
},
];
const MEMORY_LIMIT_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent <= 60,
color: Color.BG_FOREST_500,
range: '≤ 60%',
label: 'Healthy',
description: 'Memory usage is well below the limit.',
},
{
matches: (percent): boolean => percent <= 80,
color: Color.BG_AMBER_200,
range: '> 60% - 80%',
label: 'Watch',
description: 'Memory usage is approaching the limit.',
},
{
matches: (percent): boolean => percent <= 95,
color: Color.BG_AMBER_500,
range: '> 80% - 95%',
label: 'Near limit',
description:
'Memory usage is close to the limit. Unlike CPU, memory is not throttled: reaching the limit ends in an OOM kill.',
},
{
matches: (): boolean => true,
color: Color.BG_SAKURA_500,
range: '> 95%',
label: 'At limit',
description:
'Memory usage is at the limit, so an OOM kill and container restart are likely.',
},
];
const CPU_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent < 60,
color: Color.BG_FOREST_500,
range: '< 60%',
label: 'Healthy',
description: 'CPU usage is well below the available capacity.',
},
{
matches: (percent): boolean => percent < 90,
color: Color.BG_AMBER_500,
range: '60% - 89.9%',
label: 'Elevated',
description: 'CPU usage is high relative to the available capacity.',
},
{
matches: (): boolean => true,
color: Color.BG_SAKURA_500,
range: '≥ 90%',
label: 'Critical',
description: 'CPU usage is close to the available capacity.',
},
];
const MEMORY_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent < 60,
color: Color.BG_FOREST_500,
range: '< 60%',
label: 'Healthy',
description: 'Memory usage is well below the available capacity.',
},
{
matches: (percent): boolean => percent < 90,
color: Color.BG_AMBER_500,
range: '60% - 89.9%',
label: 'Elevated',
description: 'Memory usage is high relative to the available capacity.',
},
{
matches: (): boolean => true,
color: Color.BG_CHERRY_500,
range: '≥ 90%',
label: 'Critical',
description:
'Memory usage is close to the available capacity. Unlike CPU, memory is not throttled: running out ends in an OOM kill.',
},
];
const DISK_THRESHOLDS: EntityProgressThreshold[] = [
{
matches: (percent): boolean => percent < 60,
color: Color.BG_FOREST_500,
range: '< 60%',
label: 'Healthy',
description: 'Most of the volume is still free.',
},
{
matches: (percent): boolean => percent < 90,
color: Color.BG_AMBER_500,
range: '60% - 89.9%',
label: 'Elevated',
description: 'Used space is high relative to the volume capacity.',
},
{
matches: (): boolean => true,
color: Color.BG_SAKURA_500,
range: '≥ 90%',
label: 'Critical',
description: 'The volume is nearly full. Writes fail once no space is left.',
},
];
export const THRESHOLDS_BY_TYPE: Record<
EntityProgressBarType,
EntityProgressThreshold[]
> = {
'cpu-request': CPU_REQUEST_THRESHOLDS,
'cpu-limit': CPU_LIMIT_THRESHOLDS,
'memory-request': MEMORY_REQUEST_THRESHOLDS,
'memory-limit': MEMORY_LIMIT_THRESHOLDS,
cpu: CPU_THRESHOLDS,
memory: MEMORY_THRESHOLDS,
disk: DISK_THRESHOLDS,
};
export function getStrokeColorForPercent(
type: EntityProgressBarType,
percent: number,
): string {
const thresholds = THRESHOLDS_BY_TYPE[type];
const match = thresholds.find((threshold) => threshold.matches(percent));
return (match ?? thresholds[thresholds.length - 1]).color;
}
export function getStrokeColor(
type: EntityProgressBarType,
value: number,
): string {
return getStrokeColorForPercent(type, Number((value * 100).toFixed(1)));
}

View File

@@ -1,39 +0,0 @@
.container {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
max-width: 320px;
text-align: left;
text-wrap: wrap;
margin-bottom: var(--spacing-1);
}
.threshold {
display: flex;
align-items: stretch;
gap: var(--spacing-4);
}
.swatch {
width: 3px;
border-radius: 1px;
flex-shrink: 0;
background-color: var(--ept-color);
}
.thresholdBody {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.thresholdHeading {
display: flex;
align-items: baseline;
gap: var(--spacing-2);
}
.range {
white-space: nowrap;
font-variant-numeric: tabular-nums;
}

View File

@@ -1,56 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import {
EntityProgressBarType,
THRESHOLDS_BY_TYPE,
} from './EntityProgressBar.utils';
import styles from './EntityProgressThresholds.module.scss';
interface EntityProgressThresholdsProps {
type: EntityProgressBarType;
note?: string;
}
export function EntityProgressThresholds({
type,
note,
}: EntityProgressThresholdsProps): JSX.Element {
return (
<div
className={styles.container}
data-testid={`entity-progress-thresholds-${type}`}
>
{note && (
<Typography.Text as="p" size="small">
{note}
</Typography.Text>
)}
{THRESHOLDS_BY_TYPE[type].map((threshold) => (
<div key={threshold.range} className={styles.threshold}>
<span
className={styles.swatch}
style={{ '--ept-color': threshold.color } as React.CSSProperties}
/>
<div className={styles.thresholdBody}>
<div className={styles.thresholdHeading}>
<Typography.Text as="span" size="small" weight="medium">
{threshold.label}
</Typography.Text>
<Typography.Text
as="span"
size="small"
color="muted"
className={styles.range}
>
{threshold.range}
</Typography.Text>
</div>
<Typography.Text as="p" size="small" color="muted">
{threshold.description}
</Typography.Text>
</div>
</div>
))}
</div>
);
}

View File

@@ -1,28 +0,0 @@
import { render, screen } from '@testing-library/react';
import { THRESHOLDS_BY_TYPE } from '../EntityProgressBar.utils';
import { EntityProgressThresholds } from '../EntityProgressThresholds';
describe('EntityProgressThresholds', () => {
it('renders every threshold band for the given type', () => {
render(<EntityProgressThresholds type="cpu-limit" />);
expect(
screen.getByTestId('entity-progress-thresholds-cpu-limit'),
).toBeInTheDocument();
THRESHOLDS_BY_TYPE['cpu-limit'].forEach((threshold) => {
expect(screen.getByText(threshold.label)).toBeInTheDocument();
expect(screen.getByText(threshold.range)).toBeInTheDocument();
expect(screen.getByText(threshold.description)).toBeInTheDocument();
});
});
it('renders the note above the threshold bands when provided', () => {
render(
<EntityProgressThresholds type="memory" note="Excluding cache memory." />,
);
expect(screen.getByText('Excluding cache memory.')).toBeInTheDocument();
});
});

View File

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

View File

@@ -2,6 +2,7 @@ import { ReactNode } from 'react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { BuilderQueryEnvelopeType } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { OrderByFilterProps } from './filters/OrderByFilter/OrderByFilter.interfaces';
@@ -33,6 +34,17 @@ export type QueryBuilderProps = {
showOnlyTraceOperator?: boolean;
showTraceViewSelector?: boolean;
showTraceOperator?: boolean;
/**
* Traces only. Default `true`. Set `false` to hide the
* All / Root / Entrypoint span-scope select — e.g. on views that render a
* single query as a bare filter, where a scope sub-filter is noise.
*/
showSpanScopeSelector?: boolean;
/**
* Forwarded verbatim to `/fields/keys` as `type` so the backend can scope the
* suggested key set. Never interpreted by the builder.
*/
fieldKeysQueryType?: BuilderQueryEnvelopeType;
version: string;
onChangeTraceView?: (view: TraceView) => void;
onSignalSourceChange?: (value: string) => void;

View File

@@ -1,6 +1,7 @@
import { IQueryBuilderState } from 'constants/queryBuilder';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { BuilderQueryEnvelopeType } from 'types/api/v5/queryRange';
export interface InitialStateI {
search: string;
@@ -30,6 +31,7 @@ export type QueryProps = {
showFunctions?: boolean;
version: string;
showSpanScopeSelector?: boolean;
fieldKeysQueryType?: BuilderQueryEnvelopeType;
showOnlyWhereClause?: boolean;
showTraceOperator?: boolean;
hasTraceOperator?: boolean;

View File

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

View File

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

View File

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

View File

@@ -2,15 +2,10 @@
* This was introduced to fix a sync bug between Nuqs and react-router-dom
*
* We are using the wrong adapter for nuqs because the correct one only supports v6/v7,
* and we are at version v5. Nuqs writes params straight to the History API, which
* react-router v5 never observes, so `useLocation().search` (and `useUrlQuery()`) can
* be several nuqs updates behind the real URL.
* and we are at version v5. This causes the nuqs/react-router-dom to be out of sync.
*
* Use this whenever you need to build a navigation target on top of the current
* params, otherwise stale values get republished and nuqs adopts them back on its
* next flush (it snapshots `window.location.search`).
*
* We can revert this once we migrate react-router-dom to v6.
* We can revert this commit once we migrate react-router-dom to v6, or once we migrate
* to DateTimeSelectionV3
*/
/**

View File

@@ -26,6 +26,7 @@ import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import { useIsAIQueryDemo } from 'container/TracesExplorer/useIsAIQueryDemo';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
@@ -61,6 +62,7 @@ function ListView({
}: ListViewProps): JSX.Element {
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
useQueryBuilder();
const isAIQueryDemo = useIsAIQueryDemo();
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
@@ -140,6 +142,7 @@ function ListView({
graphType: panelType,
selectedTime: 'GLOBAL_TIME' as const,
globalSelectedInterval: globalSelectedTime as CustomTimeType,
builderQueryType: isAIQueryDemo ? 'builder_ai_query' : undefined,
params: {
dataSource: 'traces',
},
@@ -151,7 +154,7 @@ function ListView({
// ENTITY_VERSION_V4,
ENTITY_VERSION_V5,
{
queryKey,
queryKey: [...queryKey, isAIQueryDemo],
enabled:
// don't make api call while the time range state in redux is loading
!timeRangeUpdateLoading &&

View File

@@ -4,11 +4,13 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import ExplorerOrderBy from 'container/ExplorerOrderBy';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useIsAIQueryDemo } from 'container/TracesExplorer/useIsAIQueryDemo';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { DataSource } from 'types/common/queryBuilder';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const isAIQueryDemo = useIsAIQueryDemo();
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
const isList = panelTypes === PANEL_TYPES.LIST;
@@ -50,6 +52,10 @@ function QuerySection(): JSX.Element {
queryComponents={queryComponents}
panelType={panelTypes}
filterConfigs={filterConfigs}
// DEMO: in AI mode, List/Trace hide the span-scope select and suggestions
// are scoped to the gen_ai key set. Both are no-ops without ?aiDemo=1.
showSpanScopeSelector={!isAIQueryDemo || !isListViewPanel}
fieldKeysQueryType={isAIQueryDemo ? 'builder_ai_query' : undefined}
showOnlyWhereClause={
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
}

View File

@@ -15,6 +15,7 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { QueryTable } from 'container/QueryTable';
import { useIsAIQueryDemo } from 'container/TracesExplorer/useIsAIQueryDemo';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { AppState } from 'store/reducers';
@@ -36,6 +37,7 @@ function TableView({
queryKeyRef?: MutableRefObject<any>;
}): JSX.Element {
const { stagedQuery, panelType } = useQueryBuilder();
const isAIQueryDemo = useIsAIQueryDemo();
const {
selectedTime: globalSelectedTime,
@@ -64,13 +66,14 @@ function TableView({
graphType: panelType || PANEL_TYPES.TABLE,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
builderQueryType: isAIQueryDemo ? 'builder_ai_query' : undefined,
params: {
dataSource: 'traces',
},
},
ENTITY_VERSION_V5,
{
queryKey,
queryKey: [...queryKey, isAIQueryDemo],
enabled: !!stagedQuery && panelType === PANEL_TYPES.TABLE,
},
);

View File

@@ -21,6 +21,7 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import NoLogs from 'container/NoLogs/NoLogs';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import { useIsAIQueryDemo } from 'container/TracesExplorer/useIsAIQueryDemo';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
@@ -51,6 +52,7 @@ function TracesView({
queryKeyRef,
}: TracesViewProps): JSX.Element {
const { stagedQuery, panelType } = useQueryBuilder();
const isAIQueryDemo = useIsAIQueryDemo();
const {
selectedTime: globalSelectedTime,
@@ -97,6 +99,7 @@ function TracesView({
graphType: panelType || PANEL_TYPES.TRACE,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
builderQueryType: isAIQueryDemo ? 'builder_ai_query' : undefined,
params: {
dataSource: 'traces',
},
@@ -106,7 +109,7 @@ function TracesView({
},
ENTITY_VERSION_V5,
{
queryKey,
queryKey: [...queryKey, isAIQueryDemo],
enabled: !!stagedQuery && panelType === PANEL_TYPES.TRACE,
},
);

View File

@@ -0,0 +1,24 @@
import useUrlQuery from 'hooks/useUrlQuery';
/**
* DEMO ONLY — delete this file before merging.
*
* The AI o11y explorer page does not exist yet, so there is nowhere to exercise
* the `builder_ai_query` plumbing end to end. This opts the Traces Explorer into
* AI-query behaviour when `?aiDemo=1` is present, leaving the page byte-identical
* otherwise:
*
* - `compositeQuery.queries[].type` becomes `builder_ai_query`
* - `/fields/keys` gains `&type=builder_ai_query`
* - the All/Root/Entrypoint span-scope select hides in List and Trace views
*
* Once the AI Explorer page lands, it passes those values unconditionally and
* this indirection goes away.
*/
export const AI_DEMO_QUERY_PARAM = 'aiDemo';
export function useIsAIQueryDemo(): boolean {
const urlQuery = useUrlQuery();
return urlQuery.get(AI_DEMO_QUERY_PARAM) === '1';
}

View File

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

View File

@@ -23,6 +23,7 @@ import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import {
BuilderQueryEnvelopeType,
ExecStats,
MetricRangePayloadV5,
QueryRangeResponseV5,
@@ -399,4 +400,11 @@ export interface GetQueryResultsProps {
step?: number;
originalGraphType?: PANEL_TYPES;
dynamicVariables?: IDashboardVariable[];
/**
* Envelope type stamped on every builder query in the composite query.
* Defaults to `builder_query`; the AI Explorer passes `builder_ai_query` so the
* backend routes to its gen_ai statement builder. A serialisation concern, not
* part of the query — same category as `graphType` and `formatForWeb`.
*/
builderQueryType?: BuilderQueryEnvelopeType;
}

View File

@@ -7,7 +7,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import getMinAgo from './getStartAndEndTime/getMinAgo';
const validCustomTimeRegex = /^(\d+)(months?|[mhdw])$/;
const validCustomTimeRegex = /^(\d+)([mhdw])$/;
export const isValidShortHandDateTimeFormat = (time: string): boolean =>
validCustomTimeRegex.test(time);

View File

@@ -30,19 +30,25 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
> = {};
const builderQueryTypes: Record<
string,
'builder_query' | 'builder_formula' | 'builder_trace_operator'
| 'builder_query'
| 'builder_ai_query'
| 'builder_formula'
| 'builder_trace_operator'
> = {};
const promQueries: IPromQLQuery[] = [];
const clickhouseQueries: IClickHouseQuery[] = [];
compositeQuery.queries?.forEach((q) => {
const spec = q.spec as BuilderQuery | PromQuery | ClickHouseQuery;
if (q.type === 'builder_query') {
// `builder_ai_query` shares the builder-query spec shape and name namespace,
// so it hydrates back into `queryData` exactly like `builder_query`. Without
// this branch the chain has no fallback and the envelope is silently dropped.
if (q.type === 'builder_query' || q.type === 'builder_ai_query') {
if (spec.name) {
builderQueries[spec.name] = convertBuilderQueryToIBuilderQuery(
spec as BuilderQuery,
);
builderQueryTypes[spec.name] = 'builder_query';
builderQueryTypes[spec.name] = q.type;
}
} else if (q.type === 'builder_formula') {
if (spec.name) {

View File

@@ -15,7 +15,10 @@ export const transformQueryBuilderDataModel = (
data: BuilderQueryDataResourse,
queryTypes?: Record<
string,
'builder_query' | 'builder_formula' | 'builder_trace_operator'
| 'builder_query'
| 'builder_ai_query'
| 'builder_formula'
| 'builder_trace_operator'
>,
): QueryBuilderData => {
const queryData: QueryBuilderData['queryData'] = [];

View File

@@ -27,13 +27,9 @@ export const slackTitleDefaultValue = `[{{ .Status | toUpper }}{{ if eq .Status
export const slackDescriptionDefaultValue = `{{ range .Alerts -}} *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }} *Summary:* {{ .Annotations.summary }} *Description:* {{ .Annotations.description }} *RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}} *RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}} *Details:* {{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }} {{ end }} {{ end }}`;
export const googleChatTitleDefaultValue = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`;
export const googleChatDescriptionDefaultValue = `{{ range .Alerts -}} **Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}{{ if .Annotations.summary }} **Summary:** {{ .Annotations.summary }}{{ end }}{{ if .Annotations.description }} **Description:** {{ .Annotations.description }}{{ end }} {{ end }}`;
export const editSlackDescriptionDefaultValue = `{{ range .Alerts -}} *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }} dummy_summary *Summary:* {{ .Annotations.summary }} *Description:* {{ .Annotations.description }} *Details:* {{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }} {{ end }} {{ end }}`;
export const pagerDutyDescriptionDefaultValue = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }} {{- if gt (len .CommonLabels) (len .GroupLabels) -}} {{" "}}( {{- with .CommonLabels.Remove .GroupLabels.Names }} {{- range $index, $label := .SortedPairs -}} {{ if $index }}, {{ end }} {{- $label.Name }}="{{ $label.Value -}}" {{- end }} {{- end -}} ) {{- end }}`;
export const pagerDutyDescriptionDefaultVaule = `{{ if gt (len .Alerts.Firing) 0 -}} Alerts Firing: {{ range .Alerts.Firing }} - Message: {{ .Annotations.description }} Labels: {{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }} Annotations: {{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }} Source: {{ .GeneratorURL }} {{ end }} {{- end }} {{ if gt (len .Alerts.Resolved) 0 -}} Alerts Resolved: {{ range .Alerts.Resolved }} - Message: {{ .Annotations.description }} Labels: {{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }} Annotations: {{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }} Source: {{ .GeneratorURL }} {{ end }} {{- end }}`;
export const pagerDutyAdditionalDetailsDefaultValue = JSON.stringify({
firing: `{{ .Alerts.Firing | toJson }}`,

View File

@@ -10,7 +10,6 @@ import Spinner from 'components/Spinner';
import ROUTES from 'constants/routes';
import {
ChannelType,
GoogleChatChannel,
MsTeamsChannel,
PagerChannel,
SlackChannel,
@@ -60,20 +59,11 @@ function ChannelsEdit(): JSX.Element {
const prepChannelConfig = (): {
type: string;
channel: SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
GoogleChatChannel;
channel: SlackChannel & WebhookChannel & PagerChannel & MsTeamsChannel;
} => {
let channel: SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
GoogleChatChannel = {
let channel: SlackChannel & WebhookChannel & PagerChannel & MsTeamsChannel = {
name: '',
};
if (value && 'slack_configs' in value) {
const slackConfig = value.slack_configs[0];
channel = slackConfig;
@@ -91,16 +81,6 @@ function ChannelsEdit(): JSX.Element {
channel,
};
}
if (value && 'googlechat_configs' in value) {
const [googleChatConfig] = value.googlechat_configs;
channel = googleChatConfig;
return {
type: ChannelType.GoogleChat,
channel,
};
}
if (value && 'pagerduty_configs' in value) {
const pagerConfig = value.pagerduty_configs[0];
channel = pagerConfig;

View File

@@ -12,6 +12,7 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { useIsAIQueryDemo } from 'container/TracesExplorer/useIsAIQueryDemo';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
@@ -31,6 +32,7 @@ function TimeSeriesViewContainer({
queryKeyRef,
}: TimeSeriesViewProps): JSX.Element {
const { stagedQuery, currentQuery, panelType } = useQueryBuilder();
const isAIQueryDemo = useIsAIQueryDemo();
const isValidToConvertToMs = useMemo(() => {
const isValid: boolean[] = [];
@@ -81,6 +83,7 @@ function TimeSeriesViewContainer({
graphType: panelType || PANEL_TYPES.TIME_SERIES,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
builderQueryType: isAIQueryDemo ? 'builder_ai_query' : undefined,
params: {
dataSource,
},
@@ -88,7 +91,7 @@ function TimeSeriesViewContainer({
// ENTITY_VERSION_V4,
ENTITY_VERSION_V5,
{
queryKey,
queryKey: [...queryKey, isAIQueryDemo],
enabled: !!stagedQuery && panelType === PANEL_TYPES.TIME_SERIES,
},
);

View File

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

View File

@@ -1,4 +1,7 @@
import { FieldDataType } from 'types/api/v5/queryRange';
import {
BuilderQueryEnvelopeType,
FieldDataType,
} from 'types/api/v5/queryRange';
export interface QueryKeyDataSuggestionsProps {
label: string;
@@ -35,6 +38,11 @@ export interface QueryKeyRequestProps {
metricName?: string;
metricNamespace?: string;
signalSource?: 'meter' | '';
/**
* Scopes the returned key set to a query-type sub-variant of the signal.
* `builder_ai_query` narrows `signal=traces` to the gen_ai attribute keys.
*/
type?: BuilderQueryEnvelopeType;
}
export interface QueryKeyValueSuggestionsProps {

View File

@@ -16,6 +16,7 @@ export type RequestType =
export type QueryType =
| 'builder_query'
| 'builder_ai_query'
| 'builder_trace_operator'
| 'builder_formula'
| 'builder_sub_query'
@@ -23,6 +24,17 @@ export type QueryType =
| 'clickhouse_sql'
| 'promql';
/**
* The envelope types `convertBuilderQueriesToV5` may emit. Deliberately narrower
* than `QueryType`: the remaining members take a different spec shape, and the
* backend decodes specs with `DisallowUnknownFields`, so sending one of them with
* a builder spec is a 400.
*/
export type BuilderQueryEnvelopeType = Extract<
QueryType,
'builder_query' | 'builder_ai_query'
>;
export type OrderDirection = 'asc' | 'desc';
export type JoinType = 'inner' | 'left' | 'right' | 'full' | 'cross';

View File

@@ -69,12 +69,7 @@ export const getMetricsExplorerUrl = ({
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
// `unit` must always be present: the query builder provider rewrites (and
// pushes a new history entry for) any compositeQuery missing a key of
// `initialQueriesMap`, which traps the browser back button.
// Since this is only being used by infra-monitoring, I will keep this fix one line
// instead of going and update each chart configuration.
encodeURIComponent(JSON.stringify({ unit: '', ...query })),
encodeURIComponent(JSON.stringify(query)),
);
if (relativeTime) {

View File

@@ -10,6 +10,7 @@ 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")
FeatureUsePrometheusClickhouseV2 = featuretypes.MustNewName("use_prometheus_clickhouse_v2")
@@ -73,6 +74,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,

View File

@@ -20,6 +20,7 @@ func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy stri
}
dashboard := postable.NewDashboardV2(orgID, createdBy, source)
var storableDashboard *dashboardtypes.StorableDashboard
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := m.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, dashboard.ID, postable.Tags)
@@ -32,13 +33,14 @@ func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy stri
if err != nil {
return err
}
storableDashboard = storable
return m.store.Create(ctx, storable)
})
if err != nil {
return nil, err
}
m.analytics.TrackUser(ctx, orgID.String(), creator.String(), "Dashboard Created", dashboardtypes.NewStatsFromPostableDashboardV2(postable))
m.analytics.TrackUser(ctx, orgID.String(), creator.String(), "Dashboard Created", dashboardtypes.NewStatsFromStorableDashboards([]*dashboardtypes.StorableDashboard{storableDashboard}))
return dashboard, nil
}

View File

@@ -213,18 +213,18 @@ func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", 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:

View File

@@ -250,7 +250,7 @@ func (handler *handler) ReplaceVariables(rw http.ResponseWriter, req *http.Reque
errs := []error{}
for idx, item := range queryRangeRequest.CompositeQuery.Queries {
if item.Type == qbtypes.QueryTypeBuilder || 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 != "" {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -56,17 +56,6 @@ func QueryStringToKeysSelectors(query string) []*telemetrytypes.FieldKeySelector
FieldDataType: key.FieldDataType,
})
}
// todo(tushar): consider reverting changes done to this method in below PR to avoid scope specific checks
// https://github.com/SigNoz/signoz/issues/11374
if key.FieldContext == telemetrytypes.FieldContextScope {
keys = append(keys, &telemetrytypes.FieldKeySelector{
Name: key.FieldContext.StringValue() + "." + key.Name,
Signal: key.Signal,
FieldContext: telemetrytypes.FieldContextUnspecified, // this allows 'scope.' prefix for keys with other context as well
FieldDataType: key.FieldDataType,
})
}
}
}

View File

@@ -72,23 +72,6 @@ func TestQueryToKeys(t *testing.T) {
},
},
},
{
query: `scope.version = '1.0.0'`,
expectedKeys: []telemetrytypes.FieldKeySelector{
{
Name: "version",
Signal: telemetrytypes.SignalUnspecified,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
{
Name: "scope.version",
Signal: telemetrytypes.SignalUnspecified,
FieldContext: telemetrytypes.FieldContextUnspecified,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
},
},
}
for _, testCase := range testCases {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -373,94 +373,6 @@ func TestStatementBuilder(t *testing.T) {
},
expectedErr: nil,
},
{
name: "scope.name filter and group by",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.TraceAggregation{
{
Expression: "count()",
},
},
Filter: &qbtypes.Filter{
Expression: "scope.name = 'opentelemetry-io'",
},
Limit: 10,
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "scope.name",
FieldContext: telemetrytypes.FieldContextScope,
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH __limit_cte AS (SELECT toString(multiIf(scope.name::String IS NOT NULL, scope.name::String, NULL)) AS `scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.name::String = ? AND scope.name::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `scope.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(scope.name::String IS NOT NULL, scope.name::String, NULL)) AS `scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.name::String = ? AND scope.name::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`scope.name`) GLOBAL IN (SELECT `scope.name` FROM __limit_cte) GROUP BY ts, `scope.name`",
Args: []any{"opentelemetry-io", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "opentelemetry-io", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
expectedErr: nil,
},
{
name: "scope.version filter with scope.name group by",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.TraceAggregation{
{
Expression: "count()",
},
},
Filter: &qbtypes.Filter{
Expression: "scope.version = '1.0.0'",
},
Limit: 10,
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "scope.name",
FieldContext: telemetrytypes.FieldContextScope,
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH __limit_cte AS (SELECT toString(multiIf(scope.name::String IS NOT NULL, scope.name::String, NULL)) AS `scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `scope.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(scope.name::String IS NOT NULL, scope.name::String, NULL)) AS `scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`scope.name`) GLOBAL IN (SELECT `scope.name` FROM __limit_cte) GROUP BY ts, `scope.name`",
Args: []any{"1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
expectedErr: nil,
},
{
name: "scope.version filter only (no scope field in group by)",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.TraceAggregation{
{
Expression: "count()",
},
},
Filter: &qbtypes.Filter{
Expression: "scope.version = '1.0.0'",
},
Limit: 10,
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
},
}
fl := flaggertest.New(t)
@@ -887,52 +799,6 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
},
expectedErr: nil,
},
{
name: "List query with scope filter only (no scope in select or group by)",
requestType: qbtypes.RequestTypeRaw,
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{
"scope.version": {
{
Name: "scope.version",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
},
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Filter: &qbtypes.Filter{
Expression: "scope.version = '1.0.0'",
},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, trace_state AS `trace_state`, parent_span_id AS `parent_span_id`, flags AS `flags`, name AS `name`, kind AS `kind`, kind_string AS `kind_string`, duration_nano AS `duration_nano`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String IS NOT NULL) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
},
{
// Regression test: scope.version in selectFields with no metadata (isColumn=true filters it out)
// must still produce scope.version::String, not scope.attributes.version::String
name: "scope.version in selectFields only, no metadata (intrinsic field fallback)",
requestType: qbtypes.RequestTypeRaw,
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{},
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Filter: &qbtypes.Filter{},
SelectFields: []telemetrytypes.TelemetryFieldKey{
{Name: "scope.version", FieldContext: telemetrytypes.FieldContextUnspecified},
},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, scope.version::String AS `scope.version` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
},
}
for _, c := range cases {

View File

@@ -180,7 +180,7 @@ func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelector
`CASE
// WHEN tagType = 'spanfield' THEN 1
WHEN tagType = 'resource' THEN 2
WHEN tagType = 'scope' THEN 3
// WHEN tagType = 'scope' THEN 3
WHEN tagType = 'tag' THEN 4
ELSE 5
END as priority`,
@@ -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
}

View File

@@ -121,20 +121,6 @@ var (
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
"scope.name": {
Name: "scope.name",
Description: "Instrumentation scope name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
"scope.version": {
Name: "scope.version",
Description: "Instrumentation scope version",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
}
IntrinsicFieldsDeprecated = map[string]telemetrytypes.TelemetryFieldKey{
"traceID": {

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