Compare commits

..

7 Commits

Author SHA1 Message Date
Gaurav Tewari
5d661217e9 test: merge feat/gcp-cloud-integration + fix/gcp-list-accounts for QA 2026-07-22 10:19:31 +05:30
Gaurav Tewari
4f3fe7ec6e fix(gcp): treat GCP as a one-click integration for connection status
isOneClickIntegration only listed AWS and Azure, so IntegrationDetailPage
treated GCP as a legacy integration and polled
GET /integrations/gcp/connection_status every 5s via useGetIntegrationStatus.
GCP is a one-click cloud integration like AWS/Azure and has no such legacy
status endpoint, so add it to the allowlist to disable the poll.
2026-07-22 09:53:35 +05:30
Gaurav Tewari
4f45b3a691 feat(gcp): add account settings (edit) drawer
Adds the GCP edit-account drawer for managing monitored project ids
and wires it into AccountActions so 'Edit Account' works for GCP.
2026-07-22 09:17:28 +05:30
Gaurav Tewari
dc4b4c3d03 feat(gcp): add cloud account setup (add-account) drawer
Adds the GCP add-account drawer (flow selector, connection secret
fields with shared CopyButton, validators) and wires it into
AccountActions so 'Integrate Now' / 'Add New Account' work for GCP.

Extends the shared periscope CopyButton with an optional onCopy
callback so callers can show a toast on copy.
2026-07-22 09:17:01 +05:30
Gaurav Tewari
c5f550d88f feat(gcp): add GCP integration config, types, and page wiring
Adds the GCP integration tile, config types, region constants, account
DTO mapping, and routes the 'gcp' integration id to the shared
CloudIntegration page. Accounts list, service details, and account
removal work for GCP; the add-account and edit-account drawers land in
follow-up commits (their buttons are no-ops for GCP in this commit).

Also narrows Azure config checks by 'resource_groups' instead of
'deployment_region', since the GCP config in the widened
CloudAccount.config union also has a deployment_region field.
2026-07-22 09:16:30 +05:30
swapnil-signoz
f5f8da4226 refactor: updating cloudintegration integration tests 2026-07-20 22:45:58 +05:30
swapnil-signoz
e8fae56354 fix: list gcp accounts was missing config 2026-07-20 20:07:21 +05:30
154 changed files with 2661 additions and 6087 deletions

View File

@@ -53,7 +53,6 @@ jobs:
- queriermetrics
- querierscalar
- queriercommon
- querierai
- rawexportdata
- querierauthz
- role

View File

@@ -9,11 +9,6 @@ global:
# the path component (e.g. /signoz in https://example.com/signoz) is used
# as the base path for all HTTP routes (both API and web frontend).
external_url: <unset>
# origins (scheme://host[:port], no path) allowed as login redirect targets. include
# the origin the signoz ui is served on. when not configured, redirect targets are
# not validated.
# allowed_origins:
# - https://signoz.example.com
# the url where the SigNoz backend receives telemetry data (traces, metrics, logs) from instrumented applications.
ingestion_url: <unset>
# the url of the SigNoz MCP server. when unset, the MCP settings page is hidden in the frontend.

View File

@@ -6992,7 +6992,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'
@@ -7001,7 +7000,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'
@@ -7016,15 +7014,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:
@@ -7138,7 +7127,6 @@ components:
Querybuildertypesv5QueryType:
enum:
- builder_query
- builder_ai_query
- builder_formula
- builder_trace_operator
- clickhouse_sql
@@ -8570,7 +8558,6 @@ components:
- resource
- attribute
- body
- ""
type: string
TelemetrytypesFieldDataType:
enum:
@@ -8579,7 +8566,6 @@ components:
- float64
- int64
- number
- ""
type: string
TelemetrytypesGettableFieldKeys:
properties:
@@ -8611,7 +8597,6 @@ components:
- traces
- logs
- metrics
- ""
type: string
TelemetrytypesSource:
enum:

View File

@@ -223,12 +223,17 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
return err
}
desiredTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, updatedRole.TransactionGroups)
existingGroups := authtypes.MustNewTransactionGroupsFromTuples(existingTuples)
additions, deletions := existingGroups.Diff(updatedRole.TransactionGroups)
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
if err != nil {
return err
}
additionTuples, deletionTuples := authtypes.DiffTuples(existingTuples, desiredTuples)
deletionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, deletions)
if err != nil {
return err
}
err = provider.Write(ctx, additionTuples, deletionTuples)
if err != nil {

View File

@@ -240,17 +240,17 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
mock := mockStore.Mock()
// Mock the fingerprint query (for Prometheus label matching)
// args: $1=metric_name (the __name__ matcher maps onto the column)
mock.ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(fingerprintRows)
// Mock the samples query (for Prometheus metric data)
// args: metric_name IN (discovered names), subquery metric_name, start, end
mock.ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs(
"test_metric",
"test_metric",
"__name__",
"test_metric",
queryStart,
queryEnd,
).

View File

@@ -3479,7 +3479,6 @@ export enum TelemetrytypesFieldContextDTO {
resource = 'resource',
attribute = 'attribute',
body = 'body',
'' = '',
}
export enum TelemetrytypesFieldDataTypeDTO {
string = 'string',
@@ -3487,13 +3486,11 @@ export enum TelemetrytypesFieldDataTypeDTO {
float64 = 'float64',
int64 = 'int64',
number = 'number',
'' = '',
}
export enum TelemetrytypesSignalDTO {
traces = 'traces',
logs = 'logs',
metrics = 'metrics',
'' = '',
}
export interface Querybuildertypesv5GroupByKeyDTO {
/**
@@ -4346,18 +4343,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
@@ -4541,7 +4526,6 @@ export interface Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO {
export type Querybuildertypesv5QueryEnvelopeDTO =
| Querybuildertypesv5QueryEnvelopeBuilderDTO
| Querybuildertypesv5QueryEnvelopeBuilderAIDTO
| Querybuildertypesv5QueryEnvelopeFormulaDTO
| Querybuildertypesv5QueryEnvelopeTraceOperatorDTO
| Querybuildertypesv5QueryEnvelopePromQLDTO
@@ -8386,7 +8370,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

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 128 128"><path fill="#ea4535" d="M80.6 40.3h.4l-.2-.2 14-14v-.3c-11.8-10.4-28.1-14-43.2-9.5C36.5 20.8 24.9 32.8 20.7 48c.2-.1.5-.2.8-.2 5.2-3.4 11.4-5.4 17.9-5.4 2.2 0 4.3.2 6.4.6.1-.1.2-.1.3-.1 9-9.9 24.2-11.1 34.6-2.6h-.1z"/><path fill="#557ebf" d="M108.1 47.8c-2.3-8.5-7.1-16.2-13.8-22.1L80 39.9c6 4.9 9.5 12.3 9.3 20v2.5c16.9 0 16.9 25.2 0 25.2H63.9v20h-.1l.1.2h25.4c14.6.1 27.5-9.3 31.8-23.1 4.3-13.8-1-28.8-13-36.9z"/><path fill="#36a852" d="M39 107.9h26.3V87.7H39c-1.9 0-3.7-.4-5.4-1.1l-15.2 14.6v.2c6 4.3 13.2 6.6 20.7 6.6z"/><path fill="#f9bc15" d="M40.2 41.9c-14.9.1-28.1 9.3-32.9 22.8-4.8 13.6 0 28.5 11.8 37.3l15.6-14.9c-8.6-3.7-10.6-14.5-4-20.8 6.6-6.4 17.8-4.4 21.7 3.8L68 55.2C61.4 46.9 51.1 42 40.2 42.1z"/></svg>

After

Width:  |  Height:  |  Size: 805 B

View File

@@ -20,7 +20,7 @@ import { Globe, Inbox, SquarePen } from '@signozhq/icons';
import AnnouncementsModal from './AnnouncementsModal';
import FeedbackModal from './FeedbackModal';
import ShareURLModal, { type ShareURLExtraOption } from './ShareURLModal';
import ShareURLModal from './ShareURLModal';
import './HeaderRightSection.styles.scss';
import { Typography } from '@signozhq/ui/typography';
@@ -29,15 +29,12 @@ interface HeaderRightSectionProps {
enableAnnouncements: boolean;
enableShare: boolean;
enableFeedback: boolean;
/** Optional page-specific toggle for the share dialog (e.g. "Include variables"). */
shareModalExtraOption?: ShareURLExtraOption;
}
function HeaderRightSection({
enableAnnouncements,
enableShare,
enableFeedback,
shareModalExtraOption,
}: HeaderRightSectionProps): JSX.Element | null {
const location = useLocation();
@@ -188,7 +185,7 @@ function HeaderRightSection({
rootClassName="header-section-popover-root"
className="shareable-link-popover"
placement="bottomRight"
content={<ShareURLModal extraOption={shareModalExtraOption} />}
content={<ShareURLModal />}
open={openShareURLModal}
destroyTooltipOnHide
arrow={false}

View File

@@ -24,22 +24,7 @@ const routesToBeSharedWithTime = [
ROUTES.METER_EXPLORER,
];
/**
* An optional, page-specific toggle in the share dialog (e.g. a dashboard's
* "Include variables"). When enabled, `apply` mutates the URL params that go into
* the shared link. Keeps this shared modal generic — the page owns what it adds.
*/
export interface ShareURLExtraOption {
label: string;
defaultEnabled?: boolean;
apply: (params: URLSearchParams) => void;
}
interface ShareURLModalProps {
extraOption?: ShareURLExtraOption;
}
function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
function ShareURLModal(): JSX.Element {
const urlQuery = useUrlQuery();
const location = useLocation();
const { selectedTime } = useSelector<AppState, GlobalReducer>(
@@ -49,9 +34,6 @@ function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
const [enableAbsoluteTime, setEnableAbsoluteTime] = useState(
selectedTime !== 'custom',
);
const [enableExtraOption, setEnableExtraOption] = useState(
extraOption?.defaultEnabled ?? false,
);
const startTime = urlQuery.get(QueryParams.startTime);
const endTime = urlQuery.get(QueryParams.endTime);
@@ -111,11 +93,6 @@ function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
}
}
if (extraOption && enableExtraOption) {
extraOption.apply(urlQuery);
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
}
return currentUrl;
};
@@ -166,20 +143,6 @@ function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
</>
)}
{extraOption && (
<div className="absolute-relative-time-toggler-container">
<Typography.Text className="absolute-relative-time-toggler-label">
{extraOption.label}
</Typography.Text>
<div className="absolute-relative-time-toggler">
<Switch
value={enableExtraOption}
onChange={(): void => setEnableExtraOption((prev) => !prev)}
/>
</div>
</div>
)}
<div className="share-link">
<div className="url-share-container">
<div className="url-share-container-header">

View File

@@ -17,9 +17,12 @@ import { ChevronDown, Dot, PencilLine, Plug, Plus } from '@signozhq/icons';
import AzureCloudAccountSetupModal from '../../AzureCloudServices/AddNewAccount/CloudAccountSetupModal';
import AzureAccountSettingsModal from '../../AzureCloudServices/EditAccount/AccountSettingsModal';
import GcpCloudAccountSetupDrawer from '../../GoogleCloudPlatform/AddNewAccount/CloudAccountSetupDrawer';
import GcpAccountSettingsDrawer from '../../GoogleCloudPlatform/EditAccount/AccountSettingsDrawer';
import {
mapAccountDtoToAwsCloudAccount,
mapAccountDtoToAzureCloudAccount,
mapAccountDtoToGcpCloudAccount,
} from '../../mapCloudAccountFromDto';
import AwsCloudAccountSetupModal from '../AddNewAccount/CloudAccountSetupModal';
import AwsAccountSettingsModal from '../EditAccount/AccountSettingsModal';
@@ -156,6 +159,18 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
});
}
if (type === IntegrationType.GCP_SERVICES) {
raw.forEach((account) => {
if (!account) {
return;
}
const mapped = mapAccountDtoToGcpCloudAccount(account);
if (mapped) {
mappedAccounts.push(mapped);
}
});
}
return mappedAccounts;
}, [listAccountsResponse, type]);
@@ -207,13 +222,23 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
// log telemetry event when an account is viewed.
useEffect(() => {
if (activeAccount) {
const { config } = activeAccount;
let enabledRegions: string[];
if ('regions' in config) {
// AWS
enabledRegions = config.regions;
} else if ('resource_groups' in config) {
// Azure
enabledRegions = config.resource_groups;
} else {
// GCP
enabledRegions = config.project_ids;
}
logEvent(`${type} Integration: Account viewed`, {
cloudAccountId: activeAccount?.cloud_account_id,
status: activeAccount?.status,
enabledRegions:
'regions' in activeAccount.config
? activeAccount.config.regions
: activeAccount.config.resource_groups,
enabledRegions,
});
}
}, [activeAccount, type]);
@@ -260,6 +285,11 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
onClose={(): void => setIsIntegrationModalOpen(false)}
/>
)}
{type === IntegrationType.GCP_SERVICES && (
<GcpCloudAccountSetupDrawer
onClose={(): void => setIsIntegrationModalOpen(false)}
/>
)}
</>
)}
@@ -281,6 +311,13 @@ function AccountActions({ type }: { type: IntegrationType }): JSX.Element {
setActiveAccount={setActiveAccount}
/>
)}
{type === IntegrationType.GCP_SERVICES && (
<GcpAccountSettingsDrawer
onClose={(): void => setIsAccountSettingsModalOpen(false)}
account={activeAccount}
setActiveAccount={setActiveAccount}
/>
)}
</>
)}
</div>

View File

@@ -46,14 +46,31 @@ const EMPTY_FORM_VALUES: ServiceConfigFormValues = {
s3BucketsByRegion: {},
};
function getIntegrationServiceConfig(
type: IntegrationType,
serviceDetailsData?: ServiceDetailsData,
):
| { logs?: { enabled?: boolean }; metrics?: { enabled?: boolean } }
| undefined {
const config = serviceDetailsData?.cloudIntegrationService?.config;
if (type === IntegrationType.AWS_SERVICES) {
return config?.aws;
}
if (type === IntegrationType.GCP_SERVICES) {
return config?.gcp;
}
return config?.azure;
}
function getInitialFormValues(
type: IntegrationType,
serviceDetailsData?: ServiceDetailsData,
): ServiceConfigFormValues {
const integrationConfig =
type === IntegrationType.AWS_SERVICES
? serviceDetailsData?.cloudIntegrationService?.config?.aws
: serviceDetailsData?.cloudIntegrationService?.config?.azure;
const integrationConfig = getIntegrationServiceConfig(
type,
serviceDetailsData,
);
return {
logsEnabled: integrationConfig?.logs?.enabled || false,
@@ -98,16 +115,21 @@ function getServiceConfigPayload({
};
}
return {
azure: {
logs: {
enabled: isLogsSupported ? logsEnabled : false,
},
metrics: {
enabled: isMetricsSupported ? metricsEnabled : false,
},
// Azure and GCP share the same simple logs/metrics enable-flag shape.
const signalConfig = {
logs: {
enabled: isLogsSupported ? logsEnabled : false,
},
metrics: {
enabled: isMetricsSupported ? metricsEnabled : false,
},
};
if (type === IntegrationType.GCP_SERVICES) {
return { gcp: signalConfig };
}
return { azure: signalConfig };
}
function ServiceDetails({
@@ -163,10 +185,10 @@ function ServiceDetails({
? isAccountServiceLoading
: isReadOnlyServiceLoading;
const integrationConfig =
type === IntegrationType.AWS_SERVICES
? serviceDetailsData?.cloudIntegrationService?.config?.aws
: serviceDetailsData?.cloudIntegrationService?.config?.azure;
const integrationConfig = getIntegrationServiceConfig(
type,
serviceDetailsData,
);
const isServiceEnabledInPersistedConfig =
Boolean(integrationConfig?.logs?.enabled) ||
Boolean(integrationConfig?.metrics?.enabled);

View File

@@ -36,8 +36,12 @@ function AccountSettingsModal({
const queryClient = useQueryClient();
// `account.config` is the shared per-provider union (Azure | AWS | GCP).
// Narrow to Azure by `resource_groups` (Azure-only) rather than
// `deployment_region`, which GCP also has — so it no longer identifies
// Azure uniquely.
const azureConfig = useMemo(
() => ('deployment_region' in account.config ? account.config : null),
() => ('resource_groups' in account.config ? account.config : null),
[account.config],
);

View File

@@ -0,0 +1,150 @@
.setupDrawer {
--dialog-header-padding: var(--spacing-10) var(--spacing-12);
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
// Fill every field with --l2-background (Input/ComboboxSimple default to transparent).
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--combobox-trigger-background-color: var(--l2-background);
// Text uses the brighter --l1-foreground, placeholders the duller --l3-foreground.
--input-foreground: var(--l1-foreground);
--input-placeholder-color: var(--l3-foreground);
--combobox-trigger-placeholder-color: var(--l3-foreground);
// Shared resting border (--l2-border) and text size; focus borders stay per-component.
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--l2-border);
--combobox-trigger-border-color: var(--l2-border);
--input-font-size: var(--periscope-font-size-base);
--combobox-trigger-font-size: var(--periscope-font-size-base);
--combobox-trigger-value-font-size: var(--periscope-font-size-base);
// Bounded flex column so the header/footer stay put and only the body
// scrolls when content overflows.
display: flex;
flex-direction: column;
overflow: hidden;
[data-slot='drawer-header'],
[data-slot='drawer-footer'] {
flex-shrink: 0;
}
// The drawer body renders inside [data-slot='drawer-description'] — this is
// the only region allowed to scroll.
[data-slot='drawer-description'] {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--spacing-10);
min-height: 0;
padding: var(--spacing-10) var(--spacing-12);
overflow-y: auto;
}
[data-slot='select-content'] {
width: var(--radix-select-trigger-width);
}
[data-slot='combobox-content'] {
z-index: 5;
background: var(--l1-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}
// Selected region value: bright, like every other field's text.
[data-slot='combobox-value'] {
color: var(--l1-foreground);
}
// Empty trigger (.regionEmpty, set from the RHF value): dull the placeholder text.
.regionEmpty [data-slot='combobox-value'] {
color: var(--l3-foreground);
}
}
.title {
h3 {
margin: 0;
font-size: var(--periscope-font-size-medium);
font-weight: var(--font-weight-semibold);
}
}
.footerContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0;
}
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.mono {
composes: mono from './shared.module.scss';
}
.fullWidth {
width: 100%;
}
.fieldError {
composes: fieldError from './shared.module.scss';
}
.projectIdsSelect {
:global(.ant-select-selector) {
min-height: 36px;
background: var(--l2-background);
border: 1px solid var(--l2-border) !important;
}
&:hover :global(.ant-select-selector),
&:global(.ant-select-focused) :global(.ant-select-selector) {
border-color: var(--l2-border);
box-shadow: none;
}
// antd defaults to 14px; pin to 13px to line up with the Input/Combobox fields.
:global(.ant-select-selection-placeholder),
:global(.ant-select-selection-search-input),
:global(.ant-select-selection-item) {
font-size: var(--periscope-font-size-base);
}
:global(.ant-select-selection-placeholder) {
color: var(--l3-foreground);
}
:global(.ant-select-selection-search-input) {
color: var(--l1-foreground);
}
:global(.ant-select-selection-item) {
color: var(--l1-foreground);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}
:global(.ant-select-selection-item-remove) {
color: var(--l3-foreground);
&:hover {
color: var(--l1-foreground);
}
}
}

View File

@@ -0,0 +1,286 @@
import { useEffect, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { ComboboxSimple } from '@signozhq/ui/combobox';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { Select } from 'antd';
import cx from 'classnames';
import { GCP_REGIONS } from 'container/Integrations/constants';
import { IntegrationModalProps } from 'container/Integrations/HeroSection/types';
import { useCloudAccountSetupDrawer } from 'hooks/integration/gcp/useCloudAccountSetupDrawer';
import { Controller, useForm } from 'react-hook-form';
import { popupContainer } from 'utils/selectPopupContainer';
import ConnectionSecretsFields from './ConnectionSecretsFields';
import FieldLabel from './FieldLabel';
import FlowSelector from './FlowSelector';
import { GcpSetupFormValues, SetupFlow } from './types';
import styles from './CloudAccountSetupDrawer.module.scss';
const REGION_ITEMS = GCP_REGIONS.map((region) => ({
value: region.value,
label: `${region.label} (${region.value})`,
}));
const DEFAULT_VALUES: GcpSetupFormValues = {
accountName: '',
deploymentProjectId: '',
deploymentRegion: '',
projectIds: [],
sigNozApiUrl: '',
sigNozApiKey: '',
ingestionUrl: '',
ingestionKey: '',
};
function CloudAccountSetupDrawer({
onClose,
}: IntegrationModalProps): JSX.Element {
const {
isLoading,
connectAccount,
handleClose,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
} = useCloudAccountSetupDrawer({ onClose });
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
defaultValues: DEFAULT_VALUES,
});
const [flow, setFlow] = useState<SetupFlow>('manual');
// Pre-fill the deployment/ingestion fields with the fetched credentials.
useEffect(() => {
if (!connectionParams) {
return;
}
setValue('sigNozApiUrl', connectionParams.sigNozApiUrl);
setValue('sigNozApiKey', connectionParams.sigNozApiKey);
setValue('ingestionUrl', connectionParams.ingestionUrl);
setValue('ingestionKey', connectionParams.ingestionKey);
}, [connectionParams, setValue]);
const footer = (
<div className={styles.footerContainer}>
{submitError && (
<Callout
type="error"
size="small"
showIcon
action="dismissible"
onClick={clearSubmitError}
title="Failed to connect GCP account"
testId="gcp-connect-error"
>
{submitError}
</Callout>
)}
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
onClick={handleClose}
testId="gcp-cancel-btn"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
onClick={handleSubmit(connectAccount)}
loading={isLoading}
disabled={isConnectionParamsLoading}
testId="gcp-connect-account-btn"
>
Connect Account
</Button>
</div>
</div>
);
return (
<DrawerWrapper
open={true}
className={styles.setupDrawer}
onOpenChange={(open): void => {
if (!open) {
handleClose();
}
}}
direction="right"
showCloseButton
title="Connect Google Cloud Platform"
width="base"
footer={footer}
drawerHeaderProps={{ className: styles.title }}
>
<FlowSelector value={flow} onChange={setFlow} />
<div className={styles.drawerSection}>
<FieldLabel
htmlFor="gcp-account-name-input"
label="Account Name"
tooltip="A label to identify this group of GCP projects (org ID, billing email, or any descriptive name)"
required
/>
<Controller
name="accountName"
control={control}
rules={{ required: 'Please enter an account name' }}
render={({ field, fieldState }): JSX.Element => (
<>
<Input
id="gcp-account-name-input"
className={styles.fullWidth}
placeholder="e.g. my-org or billing@company.com"
value={field.value}
onChange={(e): void => field.onChange(e.target.value)}
testId="gcp-account-name-input"
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<div className={styles.drawerSection}>
<FieldLabel
htmlFor="gcp-deployment-project-id-input"
label="Deployment Project ID"
tooltip="The GCP project that hosts your OTel Collector deployment — often separate from the projects you actually monitor"
required
/>
<Controller
name="deploymentProjectId"
control={control}
rules={{ required: 'Please enter the deployment project ID' }}
render={({ field, fieldState }): JSX.Element => (
<>
<Input
id="gcp-deployment-project-id-input"
className={cx(styles.fullWidth, styles.mono)}
placeholder="e.g. my-deployment-project-123"
value={field.value}
onChange={(e): void => field.onChange(e.target.value)}
testId="gcp-deployment-project-id-input"
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<div className={styles.drawerSection}>
<FieldLabel
htmlFor="gcp-deployment-region-select"
label="Deployment Region"
tooltip="The GCP region where your OTel Collector will be deployed"
required
/>
<Controller
name="deploymentRegion"
control={control}
rules={{ required: 'Please select a region' }}
render={({ field, fieldState }): JSX.Element => (
<>
<ComboboxSimple
id="gcp-deployment-region-select"
className={cx(styles.fullWidth, {
[styles.regionEmpty]: !field.value,
})}
items={REGION_ITEMS}
value={field.value}
onChange={(value): void => field.onChange(value as string)}
placeholder="Select a region..."
inputPlaceholder="Search regions…"
withPortal={false}
testId="gcp-deployment-region-select"
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<div className={styles.drawerSection}>
<FieldLabel
htmlFor="gcp-project-ids-select"
label="Projects to Monitor"
tooltip="Enter each GCP project ID then press Enter"
required
/>
<Controller
name="projectIds"
control={control}
rules={{
validate: (value): true | string =>
value.length > 0 || 'Please add at least one project ID',
}}
render={({ field, fieldState }): JSX.Element => (
<>
<Select
id="gcp-project-ids-select"
className={cx(styles.fullWidth, styles.projectIdsSelect)}
mode="tags"
value={field.value}
onChange={(value): void => field.onChange(value)}
placeholder="Add project IDs…"
tokenSeparators={[',', ' ']}
notFoundContent={null}
suffixIcon={null}
getPopupContainer={popupContainer}
data-testid="gcp-project-ids-select"
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<ConnectionSecretsFields
control={control}
isLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
/>
</DrawerWrapper>
);
}
export default CloudAccountSetupDrawer;

View File

@@ -0,0 +1,71 @@
.drawerSurface {
composes: drawerSurface from './shared.module.scss';
}
.drawerSurfaceHead {
composes: drawerSurfaceHead from './shared.module.scss';
}
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.headLabel {
display: flex;
justify-content: center;
align-items: center;
gap: var(--spacing-2);
}
.mono {
composes: mono from './shared.module.scss';
}
.fieldError {
composes: fieldError from './shared.module.scss';
}
.fullWidth {
width: 100%;
}
.secretsBody {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.skeletonLabel :global(.ant-skeleton-input) {
width: 120px;
min-width: 120px;
height: 14px;
}
.skeletonInput :global(.ant-skeleton-input) {
width: 100%;
min-width: 100%;
height: 36px;
}
.readonlyField {
display: flex;
gap: var(--spacing-2);
align-items: center;
height: 36px;
padding: 0 var(--spacing-2) 0 var(--spacing-4);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}
.readonlyValue {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
// Match the other fields' value text: 13px and the brighter --l1-foreground.
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
text-overflow: ellipsis;
white-space: nowrap;
cursor: not-allowed;
}

View File

@@ -0,0 +1,193 @@
import { Lock } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';
import { Typography } from '@signozhq/ui/typography';
import { Skeleton } from 'antd';
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { Control, Controller } from 'react-hook-form';
import FieldLabel from './FieldLabel';
import { GcpSetupFormValues } from './types';
import { SecretFieldType, validateSecretValue } from './validators';
import styles from './ConnectionSecretsFields.module.scss';
type CredentialField = keyof CloudintegrationtypesCredentialsDTO;
interface FieldConfig {
name: CredentialField;
label: string;
tooltip: string;
placeholder: string;
testId: string;
type: SecretFieldType;
}
const FIELDS: FieldConfig[] = [
{
name: 'sigNozApiUrl',
label: 'SigNoz API URL',
tooltip: 'Base URL of your SigNoz instance the collector reports to',
placeholder: 'https://<tenant>.signoz.cloud',
testId: 'gcp-signoz-api-url-input',
type: 'url',
},
{
name: 'sigNozApiKey',
label: 'SigNoz API Key',
tooltip: 'API key used to authenticate with your SigNoz instance',
placeholder: 'Enter SigNoz API key',
testId: 'gcp-signoz-api-key-input',
type: 'text',
},
{
name: 'ingestionUrl',
label: 'Ingestion URL',
tooltip: 'OTLP ingestion endpoint your OTel Collector sends telemetry to',
placeholder: 'https://ingest.<region>.signoz.cloud',
testId: 'gcp-ingestion-url-input',
type: 'url',
},
{
name: 'ingestionKey',
label: 'Ingestion Key',
tooltip: 'Ingestion key that authorizes telemetry sent to SigNoz',
placeholder: 'Enter ingestion key',
testId: 'gcp-ingestion-key-input',
type: 'text',
},
];
interface ConnectionSecretsFieldsProps {
control: Control<GcpSetupFormValues>;
isLoading: boolean;
connectionParams?: CloudintegrationtypesCredentialsDTO;
}
function ConnectionSecretsFields({
control,
isLoading,
connectionParams,
}: ConnectionSecretsFieldsProps): JSX.Element {
const hasMissingValue = FIELDS.some(
(field) => !connectionParams?.[field.name],
);
return (
<div className={styles.drawerSurface}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text weight="bold" size="base">
Deployment details &amp; ingestion secrets
</Typography.Text>
{!hasMissingValue && (
<div className={styles.headLabel}>
<Lock size={12} />
<Typography.Text as="span" size="small" className={styles.headLabel}>
Auto-filled by SigNoz
</Typography.Text>
</div>
)}
</div>
{isLoading ? (
<div className={styles.secretsBody} data-testid="gcp-secrets-skeleton">
{FIELDS.map((field) => (
<div key={field.name} className={styles.drawerSection}>
<Skeleton.Input active size="small" className={styles.skeletonLabel} />
<Skeleton.Input active block className={styles.skeletonInput} />
</div>
))}
</div>
) : (
<div className={styles.secretsBody}>
{FIELDS.map((field) => {
// Backend-provided values are read-only — the user can't edit them, so
// show a truncated value with a copy button. Missing values (enterprise)
// stay editable inputs with no copy button.
const providedValue = connectionParams?.[field.name];
if (providedValue) {
return (
<div key={field.name} className={styles.drawerSection}>
<FieldLabel
htmlFor={field.testId}
label={field.label}
tooltip={field.tooltip}
/>
<div className={styles.readonlyField}>
<Typography.Text
as="span"
id={field.testId}
className={cx(styles.readonlyValue, styles.mono)}
title={providedValue}
testId={field.testId}
>
{providedValue}
</Typography.Text>
<CopyButton
value={providedValue}
size={12}
ariaLabel={`Copy ${field.label}`}
testId={`${field.testId}-copy`}
onCopy={(): void => {
toast.success(`${field.label} copied to clipboard`, {
position: 'bottom-right',
});
}}
/>
</div>
</div>
);
}
return (
<div key={field.name} className={styles.drawerSection}>
<FieldLabel
htmlFor={field.testId}
label={field.label}
tooltip={field.tooltip}
/>
<Controller
name={field.name}
control={control}
rules={{
validate: (value): true | string =>
validateSecretValue(field.label, field.type, value),
}}
render={({ field: rhfField, fieldState }): JSX.Element => (
<>
<Input
id={field.testId}
className={cx(styles.fullWidth, styles.mono)}
placeholder={field.placeholder}
value={rhfField.value}
onChange={(e): void => rhfField.onChange(e.target.value)}
testId={field.testId}
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
);
})}
</div>
)}
</div>
);
}
ConnectionSecretsFields.defaultProps = {
connectionParams: undefined,
};
export default ConnectionSecretsFields;

View File

@@ -0,0 +1,22 @@
.fieldLabel {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
}
.required {
composes: required from './shared.module.scss';
}
.infoTrigger {
display: inline-flex;
align-items: center;
color: var(--l3-foreground);
cursor: help;
}
.tooltipContent {
max-width: 240px;
white-space: normal;
word-break: break-word;
}

View File

@@ -0,0 +1,49 @@
import { Info } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './FieldLabel.module.scss';
interface FieldLabelProps {
htmlFor: string;
label: string;
tooltip: string;
required?: boolean;
}
function FieldLabel({
htmlFor,
label,
tooltip,
required,
}: FieldLabelProps): JSX.Element {
return (
<label className={styles.fieldLabel} htmlFor={htmlFor}>
{label}
<TooltipSimple
title={tooltip}
side="top"
tooltipContentProps={{ className: styles.tooltipContent }}
>
<span
className={styles.infoTrigger}
aria-label={`${label} help`}
data-testid={`${htmlFor}-tooltip`}
>
<Info size={12} />
</span>
</TooltipSimple>
{required && (
<span className={styles.required} aria-hidden="true">
*
</span>
)}
</label>
);
}
FieldLabel.defaultProps = {
required: false,
};
export default FieldLabel;

View File

@@ -0,0 +1,84 @@
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.drawerSurface {
composes: drawerSurface from './shared.module.scss';
}
.drawerSurfaceHead {
composes: drawerSurfaceHead from './shared.module.scss';
}
.flowRadioGroup {
--radio-group-item-border-color: var(--l2-border);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
.flowRadio {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
gap: var(--spacing-5);
width: 100%;
padding: var(--spacing-5) var(--spacing-6);
margin: 0;
border: 1px solid transparent;
border-radius: var(--radius-2);
box-sizing: border-box;
cursor: pointer;
transition:
background-color 0.12s ease,
border-color 0.12s ease;
> button[role='radio'] {
flex: 0 0 16px;
width: 16px;
height: 16px;
margin-top: 3px;
}
> label {
flex: 1 1 auto;
min-width: 0;
display: block;
text-align: left;
cursor: pointer;
font-size: inherit;
font-weight: inherit;
color: inherit;
}
&.flowRadioManual:has(button[data-state='checked']) {
background: color-mix(in srgb, var(--accent-primary) 10%, transparent);
border-color: color-mix(in srgb, var(--accent-primary) 30%, transparent);
}
&:hover {
background: var(--l3-background-hover);
}
&:has(button[disabled]) {
cursor: not-allowed;
opacity: 0.55;
&:hover {
background: var(--l3-background);
}
}
}
}
.flowRadioTitle {
display: flex;
gap: var(--spacing-2);
align-items: center;
}
.flowRadioDesc {
margin-top: var(--spacing-2);
}

View File

@@ -0,0 +1,76 @@
import { Badge } from '@signozhq/ui/badge';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { SetupFlow } from './types';
import styles from './FlowSelector.module.scss';
interface FlowSelectorProps {
value: SetupFlow;
onChange: (flow: SetupFlow) => void;
}
function FlowSelector({ value, onChange }: FlowSelectorProps): JSX.Element {
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text weight="bold" size="base">
Connection method
</Typography.Text>
</div>
<RadioGroup
value={value}
onChange={(next): void => onChange(next as SetupFlow)}
className={styles.flowRadioGroup}
>
<RadioGroupItem
value="manual"
containerClassName={cx(styles.flowRadio, styles.flowRadioManual)}
testId="gcp-flow-manual"
>
<div className={styles.flowRadioTitle}>
<Typography.Text weight="semibold" size="base">
Connect Manually
</Typography.Text>
</div>
<Typography.Text
as="p"
size="small"
color="muted"
className={styles.flowRadioDesc}
>
Deploy your own OTel Collector and configure log sinks.
</Typography.Text>
</RadioGroupItem>
<RadioGroupItem
value="agent"
containerClassName={styles.flowRadio}
testId="gcp-flow-agent"
disabled
>
<div className={styles.flowRadioTitle}>
<Typography.Text weight="semibold" size="base">
Connect via Agent
</Typography.Text>
<Badge color="robin" variant="default">
Soon
</Badge>
</div>
<Typography.Text
as="p"
size="small"
color="muted"
className={styles.flowRadioDesc}
>
SigNoz deploys and manages the collector for you.
</Typography.Text>
</RadioGroupItem>
</RadioGroup>
</div>
);
}
export default FlowSelector;

View File

@@ -0,0 +1,36 @@
.drawerSection {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.drawerSection > label {
font-size: var(--periscope-font-size-normal);
color: var(--l2-foreground);
}
.required {
color: var(--accent-cherry);
}
.fieldError {
font-size: var(--periscope-font-size-small);
color: var(--accent-cherry);
}
.drawerSurface {
padding: var(--spacing-7);
border-radius: 6px;
border: 1px solid var(--l2-border);
}
.drawerSurfaceHead {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-5);
}
.mono {
font-family: var(--font-family-mono);
}

View File

@@ -0,0 +1,12 @@
export type SetupFlow = 'manual' | 'agent';
export interface GcpSetupFormValues {
accountName: string;
deploymentProjectId: string;
deploymentRegion: string;
projectIds: string[];
sigNozApiUrl: string;
sigNozApiKey: string;
ingestionUrl: string;
ingestionKey: string;
}

View File

@@ -0,0 +1,24 @@
export type SecretFieldType = 'url' | 'text';
export function isValidUrl(value: string): boolean {
try {
return Boolean(new URL(value));
} catch {
return false;
}
}
export function validateSecretValue(
label: string,
type: SecretFieldType,
value: string | undefined,
): true | string {
const trimmed = value?.trim();
if (!trimmed) {
return `Please enter the ${label}`;
}
if (type === 'url' && !isValidUrl(trimmed)) {
return `Please enter a valid URL for ${label}`;
}
return true;
}

View File

@@ -0,0 +1,165 @@
import { Dispatch, SetStateAction, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import { Save } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Form, Select } from 'antd';
import { invalidateListAccounts } from 'api/generated/services/cloudintegration';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { CloudAccount } from 'container/Integrations/types';
import { useAccountSettingsDrawer } from 'hooks/integration/gcp/useAccountSettingsDrawer';
import RemoveIntegrationAccount from '../../RemoveAccount/RemoveIntegrationAccount';
import '../../AmazonWebServices/EditAccount/AccountSettingsModal.style.scss';
interface AccountSettingsDrawerProps {
onClose: () => void;
account: CloudAccount;
setActiveAccount: Dispatch<SetStateAction<CloudAccount | null>>;
}
function AccountSettingsDrawer({
onClose,
account,
setActiveAccount,
}: AccountSettingsDrawerProps): JSX.Element {
const {
form,
isLoading,
projectIds,
isSaveDisabled,
setProjectIds,
handleSubmit,
handleClose,
} = useAccountSettingsDrawer({ onClose, account, setActiveAccount });
const queryClient = useQueryClient();
const gcpConfig = useMemo(
() => ('project_ids' in account.config ? account.config : null),
[account.config],
);
return (
<DrawerWrapper
open={true}
className="account-settings-modal"
title="Account Settings"
direction="right"
showCloseButton
onOpenChange={(open): void => {
if (!open) {
handleClose();
}
}}
width="wide"
footer={
<div className="account-settings-modal__footer">
<RemoveIntegrationAccount
accountId={account?.id}
onRemoveIntegrationAccountSuccess={(): void => {
void invalidateListAccounts(queryClient, {
cloudProvider: INTEGRATION_TYPES.GCP,
});
setActiveAccount(null);
handleClose();
}}
cloudProvider={INTEGRATION_TYPES.GCP}
/>
<Button
variant="solid"
color="secondary"
disabled={isSaveDisabled}
onClick={handleSubmit}
loading={isLoading}
prefix={<Save size={14} />}
data-testid="gcp-update-account-btn"
>
Update Changes
</Button>
</div>
}
>
<Form
form={form}
layout="vertical"
initialValues={{
projectIds: gcpConfig?.project_ids || [],
}}
>
<div className="account-settings-modal__body">
<div className="account-settings-modal__body-account-info">
<div className="account-settings-modal__body-account-info-connected-account-details">
<div className="account-settings-modal__body-account-info-connected-account-details-title">
Connected Account details
</div>
<div className="account-settings-modal__body-account-info-connected-account-details-account-id">
Account Name:{' '}
<span className="account-settings-modal__body-account-info-connected-account-details-account-id-account-id">
{account?.providerAccountId}
</span>
</div>
</div>
</div>
{gcpConfig?.deployment_project_id && (
<div className="account-settings-modal__body-region-selector">
<div className="account-settings-modal__body-region-selector-title">
Deployment project ID
</div>
<div className="account-settings-modal__body-region-selector-description">
{gcpConfig.deployment_project_id}
</div>
</div>
)}
{gcpConfig?.deployment_region && (
<div className="account-settings-modal__body-region-selector">
<div className="account-settings-modal__body-region-selector-title">
Deployment region
</div>
<div className="account-settings-modal__body-region-selector-description">
{gcpConfig.deployment_region}
</div>
</div>
)}
<div className="account-settings-modal__body-region-selector">
<div className="account-settings-modal__body-region-selector-title">
Projects to monitor
</div>
<div className="account-settings-modal__body-region-selector-description">
Update the GCP project IDs that should be monitored.
</div>
<Form.Item
name="projectIds"
rules={[
{
required: true,
type: 'array',
min: 1,
message: 'Please add at least one project ID',
},
]}
>
<Select
mode="tags"
value={projectIds}
tokenSeparators={[',']}
onChange={(values): void => {
setProjectIds(values);
form.setFieldValue('projectIds', values);
}}
data-testid="gcp-edit-project-ids-select"
/>
</Form.Item>
</div>
</div>
</Form>
</DrawerWrapper>
);
}
export default AccountSettingsDrawer;

View File

@@ -60,6 +60,44 @@ function RemoveIntegrationAccount({
setIsModalOpen(false);
};
let modalDescription: JSX.Element;
if (cloudProvider === INTEGRATION_TYPES.AWS) {
modalDescription = (
<>
Removing this account will remove all components created for sending
telemetry to SigNoz in your AWS account within the next ~15 minutes
(cloudformation stacks named signoz-integration-telemetry-collection in
enabled regions). <br />
<br />
After that, you can delete the cloudformation stack that was created
manually when connecting this account.
</>
);
} else if (cloudProvider === INTEGRATION_TYPES.GCP) {
modalDescription = (
<>
Removing this account will stop SigNoz from monitoring it. <br />
<br />
Since you manage the GCP resources yourself, remember to manually tear down
the OTel collector and Pub/Sub resources you created for this integration if
you no longer need them.
</>
);
} else {
modalDescription = (
<>
Removing this account will remove all components created for sending
telemetry to SigNoz in your Azure subscription within the next ~15 minutes
(deployment stack named signoz-integration-telemetry will be deleted
automatically). <br />
<br />
After that, you have to manually delete &apos;signoz-integration&apos;
deployment stack that was created while connecting this account (Takes ~20
minutes to delete).
</>
);
}
return (
<div className="remove-integration-account-container">
<Button
@@ -84,28 +122,7 @@ function RemoveIntegrationAccount({
loading: isRemoveIntegrationLoading,
}}
>
{cloudProvider === INTEGRATION_TYPES.AWS ? (
<>
Removing this account will remove all components created for sending
telemetry to SigNoz in your AWS account within the next ~15 minutes
(cloudformation stacks named signoz-integration-telemetry-collection in
enabled regions). <br />
<br />
After that, you can delete the cloudformation stack that was created
manually when connecting this account.
</>
) : (
<>
Removing this account will remove all components created for sending
telemetry to SigNoz in your Azure subscription within the next ~15 minutes
(deployment stack named signoz-integration-telemetry will be deleted
automatically). <br />
<br />
After that, you have to manually delete &apos;signoz-integration&apos;
deployment stack that was created while connecting this account (Takes ~20
minutes to delete).
</>
)}
{modalDescription}
</Modal>
</div>
);

View File

@@ -47,3 +47,27 @@ export function mapAccountDtoToAzureCloudAccount(
providerAccountId: account.providerAccountId,
};
}
export function mapAccountDtoToGcpCloudAccount(
account: CloudintegrationtypesAccountDTO,
): IntegrationCloudAccount | null {
if (!account.providerAccountId) {
return null;
}
return {
id: account.id,
cloud_account_id: account.id,
config: {
deployment_region: account.config?.gcp?.deploymentRegion ?? '',
deployment_project_id: account.config?.gcp?.deploymentProjectId ?? '',
project_ids: account.config?.gcp?.projectIds ?? [],
},
status: {
integration: {
last_heartbeat_ts_ms: account.agentReport?.timestampMillis ?? 0,
},
},
providerAccountId: account.providerAccountId,
};
}

View File

@@ -13,8 +13,8 @@ import { ArrowLeft, MoveUpRight, RotateCw } from '@signozhq/icons';
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
import CloudIntegration from '../CloudIntegration/CloudIntegration';
import { INTEGRATION_TYPES } from '../constants';
import { IntegrationType } from '../types';
import { INTEGRATION_TYPES } from '../constants';
import { handleContactSupport } from '../utils';
import IntegrationDetailContent from './IntegrationDetailContent';
import IntegrationDetailHeader from './IntegrationDetailHeader';
@@ -24,6 +24,12 @@ import { getConnectionStatesFromConnectionStatus } from './utils';
import './IntegrationDetailPage.styles.scss';
const cloudIntegrationTypeById: Record<string, IntegrationType> = {
[INTEGRATION_TYPES.AWS]: IntegrationType.AWS_SERVICES,
[INTEGRATION_TYPES.AZURE]: IntegrationType.AZURE_SERVICES,
[INTEGRATION_TYPES.GCP]: IntegrationType.GCP_SERVICES,
};
// eslint-disable-next-line sonarjs/cognitive-complexity
function IntegrationDetailPage(): JSX.Element {
const history = useHistory();
@@ -55,19 +61,8 @@ function IntegrationDetailPage(): JSX.Element {
),
);
if (
integrationId === INTEGRATION_TYPES.AWS ||
integrationId === INTEGRATION_TYPES.AZURE
) {
return (
<CloudIntegration
type={
integrationId === INTEGRATION_TYPES.AWS
? IntegrationType.AWS_SERVICES
: IntegrationType.AZURE_SERVICES
}
/>
);
if (integrationId && cloudIntegrationTypeById[integrationId]) {
return <CloudIntegration type={cloudIntegrationTypeById[integrationId]} />;
}
return (

View File

@@ -1,7 +1,8 @@
import awsDarkLogo from '@/assets/Logos/aws-dark.svg';
import azureOpenaiLogo from '@/assets/Logos/azure-openai.svg';
import gcpLogo from '@/assets/Logos/gcp.svg';
import { AzureRegion } from './types';
import { AzureRegion, GCPRegion } from './types';
export const INTEGRATION_TELEMETRY_EVENTS = {
INTEGRATIONS_LIST_VISITED: 'Integrations Page: Visited the list page',
@@ -21,6 +22,7 @@ export const INTEGRATION_TELEMETRY_EVENTS = {
export const INTEGRATION_TYPES = {
AWS: 'aws',
AZURE: 'azure',
GCP: 'gcp',
};
export const AWS_INTEGRATION = {
@@ -53,7 +55,26 @@ export const AZURE_INTEGRATION = {
is_new: true,
};
export const ONE_CLICK_INTEGRATIONS = [AWS_INTEGRATION, AZURE_INTEGRATION];
export const GCP_INTEGRATION = {
id: INTEGRATION_TYPES.GCP,
title: 'Google Cloud Platform',
description: 'Setup for GCP monitoring with SigNoz',
author: {
name: 'SigNoz',
email: 'integrations@signoz.io',
homepage: 'https://signoz.io',
},
icon: gcpLogo,
icon_alt: 'gcp-logo',
is_installed: false,
is_new: true,
};
export const ONE_CLICK_INTEGRATIONS = [
AWS_INTEGRATION,
AZURE_INTEGRATION,
GCP_INTEGRATION,
];
export const AZURE_REGIONS: AzureRegion[] = [
{
@@ -165,3 +186,66 @@ export const AZURE_REGIONS: AzureRegion[] = [
{ label: 'West US 2', value: 'westus2', geography: 'United States' },
{ label: 'West US 3', value: 'westus3', geography: 'United States' },
];
// Source of truth: pkg/types/cloudintegrationtypes/regions.go (GCP regions).
export const GCP_REGIONS: GCPRegion[] = [
{ label: 'Johannesburg', value: 'africa-south1', geography: 'Africa' },
{ label: 'Changhua County', value: 'asia-east1', geography: 'APAC' },
{ label: 'Hong Kong', value: 'asia-east2', geography: 'APAC' },
{ label: 'Tokyo', value: 'asia-northeast1', geography: 'APAC' },
{ label: 'Osaka', value: 'asia-northeast2', geography: 'APAC' },
{ label: 'Seoul', value: 'asia-northeast3', geography: 'APAC' },
{ label: 'Mumbai', value: 'asia-south1', geography: 'APAC' },
{ label: 'Delhi', value: 'asia-south2', geography: 'APAC' },
{ label: 'Singapore', value: 'asia-southeast1', geography: 'APAC' },
{ label: 'Jakarta', value: 'asia-southeast2', geography: 'APAC' },
{ label: 'Bangkok', value: 'asia-southeast3', geography: 'APAC' },
{ label: 'Sydney', value: 'australia-southeast1', geography: 'APAC' },
{ label: 'Melbourne', value: 'australia-southeast2', geography: 'APAC' },
{ label: 'Warsaw', value: 'europe-central2', geography: 'Europe' },
{ label: 'Hamina', value: 'europe-north1', geography: 'Europe' },
{ label: 'Stockholm', value: 'europe-north2', geography: 'Europe' },
{ label: 'Madrid', value: 'europe-southwest1', geography: 'Europe' },
{ label: 'St. Ghislain', value: 'europe-west1', geography: 'Europe' },
{ label: 'London', value: 'europe-west2', geography: 'Europe' },
{ label: 'Frankfurt', value: 'europe-west3', geography: 'Europe' },
{ label: 'Eemshaven', value: 'europe-west4', geography: 'Europe' },
{ label: 'Zurich', value: 'europe-west6', geography: 'Europe' },
{ label: 'Milan', value: 'europe-west8', geography: 'Europe' },
{ label: 'Paris', value: 'europe-west9', geography: 'Europe' },
{ label: 'Berlin', value: 'europe-west10', geography: 'Europe' },
{ label: 'Turin', value: 'europe-west12', geography: 'Europe' },
{ label: 'Doha', value: 'me-central1', geography: 'Middle East' },
{ label: 'Dammam', value: 'me-central2', geography: 'Middle East' },
{ label: 'Tel Aviv', value: 'me-west1', geography: 'Middle East' },
{
label: 'Montréal',
value: 'northamerica-northeast1',
geography: 'North America',
},
{
label: 'Toronto',
value: 'northamerica-northeast2',
geography: 'North America',
},
{
label: 'Querétaro',
value: 'northamerica-south1',
geography: 'North America',
},
{
label: 'São Paulo',
value: 'southamerica-east1',
geography: 'South America',
},
{ label: 'Santiago', value: 'southamerica-west1', geography: 'South America' },
{ label: 'Council Bluffs', value: 'us-central1', geography: 'North America' },
{ label: 'Moncks Corner', value: 'us-east1', geography: 'North America' },
{ label: 'Ashburn', value: 'us-east4', geography: 'North America' },
{ label: 'Columbus', value: 'us-east5', geography: 'North America' },
{ label: 'Dallas', value: 'us-south1', geography: 'North America' },
{ label: 'The Dalles', value: 'us-west1', geography: 'North America' },
{ label: 'Los Angeles', value: 'us-west2', geography: 'North America' },
{ label: 'Salt Lake City', value: 'us-west3', geography: 'North America' },
{ label: 'Las Vegas', value: 'us-west4', geography: 'North America' },
];

View File

@@ -6,6 +6,7 @@ import {
export enum IntegrationType {
AWS_SERVICES = 'aws',
AZURE_SERVICES = 'azure',
GCP_SERVICES = 'gcp',
}
interface LogField {
@@ -87,7 +88,10 @@ export interface ServiceData {
export interface CloudAccount {
id: string;
cloud_account_id: string;
config: AzureCloudAccountConfig | AWSCloudAccountConfig;
config:
| AzureCloudAccountConfig
| AWSCloudAccountConfig
| GCPCloudAccountConfig;
status: AccountStatus | IServiceStatus;
providerAccountId: string;
}
@@ -97,6 +101,12 @@ export interface AzureCloudAccountConfig {
resource_groups: string[];
}
export interface GCPCloudAccountConfig {
deployment_region: string;
deployment_project_id: string;
project_ids: string[];
}
export interface AccountStatus {
integration: IntegrationStatus;
}
@@ -111,6 +121,12 @@ export interface AzureRegion {
value: string;
}
export interface GCPRegion {
label: string;
geography: string;
value: string;
}
export interface UpdateServiceConfigPayload {
cloud_account_id: string;
config: AzureServicesConfig;

View File

@@ -5262,24 +5262,6 @@ const onboardingConfigWithLinks = [
],
},
},
{
dataSource: 'temporal-cloud-metrics',
label: 'Temporal Cloud Metrics',
imgUrl: temporalUrl,
tags: ['metrics'],
module: 'metrics',
relatedSearchKeywords: [
'metrics',
'integrations',
'temporal',
'temporal cloud',
'temporal cloud metrics',
'temporal metrics',
'openmetrics',
'prometheus',
],
link: '/docs/integrations/temporal-cloud-metrics/',
},
{
dataSource: 'temporal',
label: 'Temporal',
@@ -5291,6 +5273,9 @@ const onboardingConfigWithLinks = [
'application performance monitoring',
'integrations',
'temporal',
'temporal cloud',
'temporal logs',
'temporal metrics',
'temporal traces',
'traces',
'tracing',
@@ -5299,6 +5284,12 @@ const onboardingConfigWithLinks = [
desc: 'What are you using ?',
type: 'select',
options: [
{
key: 'temporal-cloud',
label: 'Cloud Metrics',
imgUrl: temporalUrl,
link: '/docs/integrations/temporal-cloud-metrics/',
},
{
key: 'temporal-golang',
label: 'Go',

View File

@@ -22,7 +22,7 @@ import {
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { ExtendedSelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
@@ -39,7 +39,6 @@ export const AggregatorFilter = memo(function AggregatorFilter({
signalSource,
setAttributeKeys,
}: AgregatorFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const queryClient = useQueryClient();
const [optionsData, setOptionsData] = useState<ExtendedSelectOption[]>([]);
@@ -290,7 +289,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
return (
<AutoComplete
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
placeholder={getPlaceholder()}
style={selectStyle}
filterOption={false}

View File

@@ -2,7 +2,7 @@ import { Select, SelectProps, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { getCategorySelectOptionByName } from 'container/NewWidget/RightContainer/alertFomatCategories';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { categoryToSupport } from './config';
import { selectStyles } from './styles';
@@ -13,7 +13,6 @@ function BuilderUnitsFilter({
onChange,
yAxisUnit,
}: IBuilderUnitsFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { currentQuery, handleOnUnitsChange } = useQueryBuilder();
const selectedValue = yAxisUnit || currentQuery?.unit;
@@ -37,7 +36,7 @@ function BuilderUnitsFilter({
Y-axis unit
</Typography.Text>
<Select
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
style={selectStyles}
onChange={onChangeHandler}
value={selectedValue}

View File

@@ -9,13 +9,12 @@ import {
} from 'lib/query/transformQueryBuilderData';
import { Having, HavingForm } from 'types/api/queryBuilder/queryBuilderData';
import { SelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { getHavingObject, isValidHavingValue } from '../../utils';
import { HavingFilterProps, HavingTagRenderProps } from './types';
function HavingFilter({ formula, onChange }: HavingFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { having } = formula;
const [searchText, setSearchText] = useState<string>('');
const [localValues, setLocalValues] = useState<string[]>([]);
@@ -172,7 +171,7 @@ function HavingFilter({ formula, onChange }: HavingFilterProps): JSX.Element {
return (
<Select
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
autoClearSearchValue={false}
mode="multiple"
onSearch={handleSearch}

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { Select, Spin } from 'antd';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { MetricAggregateOperator } from 'types/common/queryBuilder';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../../QueryBuilderSearch/config';
import { OrderByProps } from './types';
@@ -13,7 +13,6 @@ function OrderByFilter({
onChange,
query,
}: OrderByProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const {
debouncedSearchText,
createOptions,
@@ -65,7 +64,7 @@ function OrderByFilter({
return (
<Select
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
mode="tags"
style={selectStyle}
onSearch={handleSearchKeys}

View File

@@ -21,7 +21,7 @@ import { isEqual, uniqWith } from 'lodash-es';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
@@ -33,7 +33,6 @@ export const GroupByFilter = memo(function GroupByFilter({
disabled,
signalSource,
}: GroupByFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const queryClient = useQueryClient();
const [searchText, setSearchText] = useState<string>('');
const [optionsData, setOptionsData] = useState<
@@ -175,7 +174,7 @@ export const GroupByFilter = memo(function GroupByFilter({
return (
<Select
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
mode="tags"
style={selectStyle}
onSearch={handleSearchKeys}

View File

@@ -16,7 +16,7 @@ import {
import { Having, HavingForm } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { getHavingObject, isValidHavingValue } from '../utils';
// ** Types
@@ -27,7 +27,6 @@ export function HavingFilter({
query,
onChange,
}: HavingFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { having } = query;
const [searchText, setSearchText] = useState<string>('');
const [options, setOptions] = useState<SelectOption<string, string>[]>([]);
@@ -232,7 +231,7 @@ export function HavingFilter({
return (
<>
<Select
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
autoClearSearchValue={false}
mode="multiple"
onSearch={handleSearch}

View File

@@ -14,7 +14,7 @@ import {
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { ExtendedSelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
@@ -85,7 +85,6 @@ export const MetricNameSelector = memo(function MetricNameSelector({
signalSource,
'data-testid': dataTestId,
}: MetricNameSelectorProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const currentMetricName =
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
query.aggregateAttribute?.key ||
@@ -273,7 +272,7 @@ export const MetricNameSelector = memo(function MetricNameSelector({
return (
<AutoComplete
className="metric-name-selector"
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
style={selectStyle}
filterOption={false}
placeholder={placeholder}

View File

@@ -3,7 +3,7 @@ import { Select, Spin } from 'antd';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import { OrderByFilterProps } from './OrderByFilter.interfaces';
@@ -16,7 +16,6 @@ export function OrderByFilter({
entityVersion,
isNewQueryV2 = false,
}: OrderByFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const {
debouncedSearchText,
selectedValue,
@@ -79,7 +78,7 @@ export function OrderByFilter({
return (
<Select
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
mode="tags"
style={selectStyle}
onSearch={handleSearchKeys}

View File

@@ -50,7 +50,7 @@ import {
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../../../constants/features';
@@ -95,7 +95,6 @@ function QueryBuilderSearch({
disableNavigationShortcuts,
entity,
}: QueryBuilderSearchProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { pathname } = useLocation();
const isLogsExplorerPage = useMemo(
() => pathname === ROUTES.LOGS_EXPLORER,
@@ -398,7 +397,7 @@ function QueryBuilderSearch({
<Select
data-testid={'qb-search-select'}
ref={selectRef}
getPopupContainer={getPopupContainer}
getPopupContainer={popupContainer}
transitionName=""
choiceTransitionName=""
virtual={false}

View File

@@ -50,7 +50,7 @@ import {
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { popupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import { selectStyle } from '../QueryBuilderSearch/config';
@@ -157,8 +157,6 @@ function QueryBuilderSearchV2(
selectProps,
} = props;
const getPopupContainer = useSelectPopupContainer();
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
const { handleRunQuery, currentQuery } = useQueryBuilder();
@@ -991,7 +989,7 @@ function QueryBuilderSearchV2(
{...selectProps}
data-testid={'qb-search-select'}
ref={selectRef}
{...(hasPopupContainer ? { getPopupContainer } : {})}
{...(hasPopupContainer ? { getPopupContainer: popupContainer } : {})}
{...(maxTagCount ? { maxTagCount } : {})}
key={queryTags.join('.')}
virtual={false}

View File

@@ -10,7 +10,8 @@ import {
export function isOneClickIntegration(integrationId: string): boolean {
return (
integrationId === INTEGRATION_TYPES.AWS ||
integrationId === INTEGRATION_TYPES.AZURE
integrationId === INTEGRATION_TYPES.AZURE ||
integrationId === INTEGRATION_TYPES.GCP
);
}

View File

@@ -39,8 +39,12 @@ export function useAccountSettingsModal({
}: UseAccountSettingsModalProps): UseAccountSettingsModal {
const [form] = Form.useForm();
const { mutate: updateAccount, isLoading } = useUpdateAccount();
// `account.config` is the shared per-provider union (Azure | AWS | GCP).
// Narrow to Azure by `resource_groups` (Azure-only) rather than
// `deployment_region`, which GCP also has — so it no longer identifies
// Azure uniquely.
const accountConfig = useMemo(
() => ('deployment_region' in account.config ? account.config : null),
() => ('resource_groups' in account.config ? account.config : null),
[account.config],
);
const [resourceGroups, setResourceGroups] = useState<string[]>(

View File

@@ -0,0 +1,148 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { toast } from '@signozhq/ui/sonner';
import { Form } from 'antd';
import { FormInstance } from 'antd/lib';
import { useUpdateAccount } from 'api/generated/services/cloudintegration';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { CloudAccount } from 'container/Integrations/types';
import { isEqual } from 'lodash-es';
import logEvent from '../../../api/common/logEvent';
interface UseAccountSettingsDrawerProps {
onClose: () => void;
account: CloudAccount;
setActiveAccount: Dispatch<SetStateAction<CloudAccount | null>>;
}
interface UseAccountSettingsDrawer {
form: FormInstance;
isLoading: boolean;
projectIds: string[];
isSaveDisabled: boolean;
setProjectIds: Dispatch<SetStateAction<string[]>>;
handleSubmit: () => Promise<void>;
handleClose: () => void;
}
export function useAccountSettingsDrawer({
onClose,
account,
setActiveAccount,
}: UseAccountSettingsDrawerProps): UseAccountSettingsDrawer {
const [form] = Form.useForm();
const { mutate: updateAccount, isLoading } = useUpdateAccount();
const accountConfig = useMemo(
() => ('project_ids' in account.config ? account.config : null),
[account.config],
);
const [projectIds, setProjectIds] = useState<string[]>(
accountConfig?.project_ids || [],
);
useEffect(() => {
if (!accountConfig) {
return;
}
form.setFieldsValue({
projectIds: accountConfig.project_ids,
});
setProjectIds(accountConfig.project_ids);
}, [accountConfig, form]);
const handleSubmit = useCallback(async (): Promise<void> => {
try {
const values = await form.validateFields();
if (!accountConfig) {
return;
}
updateAccount(
{
pathParams: {
cloudProvider: INTEGRATION_TYPES.GCP,
id: account?.id || '',
},
data: {
config: {
gcp: {
// Deployment region & project ID are immutable in the UI, but the
// Updatable GCP DTO requires all three fields to be sent.
deploymentRegion: accountConfig.deployment_region,
deploymentProjectId: accountConfig.deployment_project_id,
projectIds: values.projectIds || [],
},
},
},
},
{
onSuccess: () => {
const nextConfig = {
deployment_region: accountConfig.deployment_region,
deployment_project_id: accountConfig.deployment_project_id,
project_ids: values.projectIds || [],
};
setActiveAccount({
...account,
config: nextConfig,
});
onClose();
toast.success('Account settings updated successfully', {
position: 'bottom-right',
});
void logEvent('GCP Integration: Account settings updated', {
cloudAccountId: account.cloud_account_id,
deploymentRegion: nextConfig.deployment_region,
projectIds: nextConfig.project_ids,
});
},
onError: (error) => {
toast.error('Failed to update account settings', {
description: error?.message,
position: 'bottom-right',
});
},
},
);
} catch (error) {
console.error('Form submission failed:', error);
}
}, [form, updateAccount, account, accountConfig, setActiveAccount, onClose]);
const isSaveDisabled = useMemo(() => {
if (!accountConfig) {
return true;
}
return isEqual(
[...(projectIds || [])].sort(),
[...accountConfig.project_ids].sort(),
);
}, [accountConfig, projectIds]);
const handleClose = useCallback(() => {
onClose();
}, [onClose]);
return {
form,
isLoading,
projectIds,
isSaveDisabled,
setProjectIds,
handleSubmit,
handleClose,
};
}

View File

@@ -0,0 +1,165 @@
import { useCallback, useState } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import {
CreateAccountMutationResult,
GetConnectionCredentialsQueryResult,
invalidateListAccounts,
useAgentCheckIn,
useCreateAccount,
useGetConnectionCredentials,
} from 'api/generated/services/cloudintegration';
import {
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesPostableAccountDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { GcpSetupFormValues } from 'container/Integrations/CloudIntegration/GoogleCloudPlatform/AddNewAccount/types';
import useAxiosError from 'hooks/useAxiosError';
import { toAPIError } from 'utils/errorUtils';
import logEvent from '../../../api/common/logEvent';
interface UseCloudAccountSetupDrawerProps {
onClose: () => void;
}
interface UseCloudAccountSetupDrawer {
isLoading: boolean;
connectAccount: (values: GcpSetupFormValues) => Promise<void>;
handleClose: () => void;
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
submitError: string | null;
clearSubmitError: () => void;
}
export function useCloudAccountSetupDrawer({
onClose,
}: UseCloudAccountSetupDrawerProps): UseCloudAccountSetupDrawer {
const queryClient = useQueryClient();
const [isLoading, setIsLoading] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const clearSubmitError = useCallback((): void => {
setSubmitError(null);
}, []);
const { mutateAsync: createAccount } = useCreateAccount();
const { mutateAsync: checkIn } = useAgentCheckIn();
const handleError = useAxiosError();
const { data: connectionParams, isLoading: isConnectionParamsLoading } =
useGetConnectionCredentials<GetConnectionCredentialsQueryResult>(
{
cloudProvider: INTEGRATION_TYPES.GCP,
},
{
query: {
onError: handleError,
},
},
);
const handleClose = useCallback((): void => {
onClose();
}, [onClose]);
const handleConnectionSuccess = useCallback(
(payload: {
cloudIntegrationId: string;
providerAccountId: string;
}): void => {
void logEvent('GCP Integration: Account connected', {
cloudIntegrationId: payload.cloudIntegrationId,
providerAccountId: payload.providerAccountId,
});
toast.success('GCP account connected successfully', {
position: 'bottom-right',
});
void invalidateListAccounts(queryClient, {
cloudProvider: INTEGRATION_TYPES.GCP,
});
handleClose();
},
[handleClose, queryClient],
);
const connectAccount = useCallback(
async (values: GcpSetupFormValues): Promise<void> => {
try {
setIsLoading(true);
setSubmitError(null);
const payload: CloudintegrationtypesPostableAccountDTO = {
config: {
gcp: {
deploymentRegion: values.deploymentRegion,
deploymentProjectId: values.deploymentProjectId,
projectIds: values.projectIds || [],
},
},
credentials: {
// Cloud users can't edit these — the backend-provided credentials are
// authoritative. Enterprise users have no backend defaults and enter
// their own (validated non-empty), so their form values are used.
ingestionUrl: connectionParams?.data?.ingestionUrl || values.ingestionUrl,
ingestionKey: connectionParams?.data?.ingestionKey || values.ingestionKey,
sigNozApiUrl: connectionParams?.data?.sigNozApiUrl || values.sigNozApiUrl,
sigNozApiKey: connectionParams?.data?.sigNozApiKey || values.sigNozApiKey,
},
};
// Step 1: create the integration account.
const createResponse: CreateAccountMutationResult = await createAccount({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: payload,
});
const cloudIntegrationId = createResponse.data.id;
const providerAccountId = values.accountName;
void logEvent('GCP Integration: Account created', {
id: cloudIntegrationId,
});
// Step 2: mimic the agent by checking in from the frontend (manual flow).
await checkIn({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: {
providerAccountId,
cloudIntegrationId,
data: {},
},
});
handleConnectionSuccess({ cloudIntegrationId, providerAccountId });
} catch (error) {
// Surface the backend's message inline in the drawer instead of a
// generic failure string.
const message = toAPIError(
error as ErrorType<RenderErrorResponseDTO>,
'Failed to connect GCP account',
).getErrorMessage();
setSubmitError(message);
} finally {
setIsLoading(false);
}
},
[connectionParams, createAccount, checkIn, handleConnectionSuccess],
);
return {
isLoading,
connectAccount,
handleClose,
connectionParams: connectionParams?.data as
| CloudintegrationtypesCredentialsDTO
| undefined,
isConnectionParamsLoading,
submitError,
clearSubmitError,
};
}

View File

@@ -126,15 +126,6 @@ export default function UPlotChart({
}
}, [isDataEmpty, destroyPlot]);
/**
* Destroy the plot on unmount. Without this, uPlot's window-level
* `dppxchange` listener keeps the instance (and its whole detached DOM
* subtree) alive after the component is gone.
*/
const destroyPlotRef = useRef(destroyPlot);
destroyPlotRef.current = destroyPlot;
useEffect(() => (): void => destroyPlotRef.current(), []);
/**
* Handle initialization and prop changes
*/

View File

@@ -327,32 +327,6 @@ describe('UPlotChart', () => {
expect(firstInstance.destroy).toHaveBeenCalled();
expect(instances).toHaveLength(2);
});
it('destroys the instance and notifies callbacks on unmount', () => {
const plotRef = jest.fn();
const onDestroy = jest.fn();
const { unmount } = render(
<UPlotChart
config={createMockConfig()}
data={validData}
width={600}
height={400}
plotRef={plotRef}
onDestroy={onDestroy}
/>,
{ wrapper: Wrapper },
);
const firstInstance = instances[0];
plotRef.mockClear();
unmount();
expect(onDestroy).toHaveBeenCalledWith(firstInstance);
expect(firstInstance.destroy).toHaveBeenCalledTimes(1);
expect(plotRef).toHaveBeenCalledWith(null);
});
});
describe('spanGaps data transformation', () => {

View File

@@ -16,13 +16,7 @@ import { sortBy } from 'lodash-es';
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
/** Telemetry signal — the generated enum (traces / logs / metrics). */
// A query/variable signal is only logs/traces/metrics. TelemetrytypesSignalDTO
// also carries the empty "any" value used on field keys, which is not a valid
// query/variable signal, so exclude it here.
export type TelemetrySignal =
| TelemetrytypesSignalDTO.logs
| TelemetrytypesSignalDTO.traces
| TelemetrytypesSignalDTO.metrics;
export type TelemetrySignal = TelemetrytypesSignalDTO;
/**
* Signal selected in the dynamic-variable editor. `'all'` is UI-only (the

View File

@@ -17,9 +17,6 @@ const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {
[TelemetrytypesSignalDTO.logs]: 'logs',
[TelemetrytypesSignalDTO.traces]: 'traces',
[TelemetrytypesSignalDTO.metrics]: 'metrics',
// The empty "any" signal only appears on field keys, never on a panel query;
// mapped for exhaustiveness.
[TelemetrytypesSignalDTO['']]: '',
};
/**

View File

@@ -1,6 +1,5 @@
import { ChevronDown } from '@signozhq/icons';
import { ColorPicker } from 'antd';
import { ThresholdColor } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/threshold';
import styles from './ThresholdsSection.module.scss';
@@ -12,11 +11,11 @@ interface ThresholdColorSelectProps {
// Named presets from the SigNoz palette (cherry / amber / forest / robin). They surface
// as quick swatches in the picker; the full picker below covers any custom color.
const PRESETS: { label: string; value: ThresholdColor }[] = [
{ label: 'Red', value: ThresholdColor.RED },
{ label: 'Orange', value: ThresholdColor.ORANGE },
{ label: 'Green', value: ThresholdColor.GREEN },
{ label: 'Blue', value: ThresholdColor.BLUE },
const PRESETS: { label: string; value: string }[] = [
{ label: 'Red', value: '#F1575F' },
{ label: 'Orange', value: '#F5B225' },
{ label: 'Green', value: '#2BB673' },
{ label: 'Blue', value: '#4E74F8' },
];
/**

View File

@@ -12,7 +12,6 @@ import {
AnyThreshold,
ThresholdVariant,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import { ThresholdColor } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/threshold';
import type { TableColumnOption } from '../../../hooks/useTableColumns';
import type { SectionEditorContext } from '../../sectionContext';
@@ -23,7 +22,7 @@ import TableThresholdRow from './rows/TableThresholdRow';
import styles from './ThresholdsSection.module.scss';
// New thresholds default to red (the first palette preset); the user recolors per rule.
const DEFAULT_THRESHOLD_COLOR = ThresholdColor.RED;
const DEFAULT_THRESHOLD_COLOR = '#F1575F';
// Add-button testId per variant — kept stable so existing E2E/unit selectors hold.
const ADD_TESTID: Record<ThresholdVariant, string> = {

View File

@@ -73,25 +73,6 @@ describe('usePanelEditorDraft', () => {
expect(result.current.isSpecDirty).toBe(false);
});
it('flags spec-dirty when the seed differs from the saved baseline (View handoff)', () => {
// The editor opens on a handed-off, already-edited spec (`seed`) but compares
// against the persisted panel (`saved`) — so it starts dirty, not clean.
const seed = panel('Memory', 'usage');
const saved = panel('CPU', 'usage');
const { result } = renderHook(() => usePanelEditorDraft(seed, saved));
expect(result.current.isSpecDirty).toBe(true);
});
it('is clean when the seed matches the saved baseline', () => {
const { result } = renderHook(() =>
usePanelEditorDraft(panel('CPU', 'usage'), panel('CPU', 'usage')),
);
expect(result.current.isSpecDirty).toBe(false);
});
it('reset restores the spec and clears dirty after an edit', () => {
const { result } = renderHook(() => usePanelEditorDraft(panel()));

View File

@@ -1,201 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react';
import type {
DashboardtypesPanelDTO,
DashboardtypesQueryDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { AllTheProviders } from 'tests/test-utils';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { usePanelEditorQuerySync } from '../usePanelEditorQuerySync';
// Exercises the REAL query builder provider (not mocks) so the dirty check is
// verified against the builder's actual re-serialization — the "always dirty"
// regression only reproduces with the real normalization in the loop.
const panelType = PANEL_TYPES.TIME_SERIES;
function makeSavedQueries(): DashboardtypesQueryDTO[] {
const base: Query = {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [
{
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
legend: 'cpu',
},
],
},
};
return toPerses(base, panelType);
}
function makePanel(queries: DashboardtypesQueryDTO[]): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries,
},
} as unknown as DashboardtypesPanelDTO;
}
describe('usePanelEditorQuerySync (real query builder)', () => {
it('an untouched existing panel is NOT query-dirty on mount', async () => {
const saved = makeSavedQueries();
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
draft: makePanel(saved),
panelType,
setSpec: jest.fn(),
refetch: jest.fn(),
savedQueries: saved,
}),
{ wrapper: AllTheProviders },
);
// The builder force-resets to the saved query asynchronously; once settled the
// live query must serialize back to the saved queries → clean.
await waitFor(() => expect(result.current.isQueryDirty).toBe(false));
// And stays clean (no late re-stage flips it dirty).
expect(result.current.isQueryDirty).toBe(false);
});
it('an untouched panel with a minimal/older stored query is NOT dirty (drift fix)', async () => {
// An older saved query carries only a few fields; the builder re-emits many more
// (source, stepInterval, filter, spaceAggregation, …). Comparing raw would read
// this as always-dirty; the round-tripped baseline normalizes both sides.
const minimalSaved: DashboardtypesQueryDTO[] = [
{
kind: 'time_series',
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: {
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'metrics',
aggregations: [
{ metricName: 'system_cpu_time', timeAggregation: 'avg' },
],
},
},
],
},
},
},
},
] as unknown as DashboardtypesQueryDTO[];
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
draft: makePanel(minimalSaved),
panelType,
setSpec: jest.fn(),
refetch: jest.fn(),
savedQueries: minimalSaved,
}),
{ wrapper: AllTheProviders },
);
await waitFor(() => expect(result.current.isQueryDirty).toBe(false));
expect(result.current.isQueryDirty).toBe(false);
});
it('retains an in-editor query edit carried in the URL across a refresh (and reads dirty)', async () => {
// Simulate a refresh mid-edit: the saved panel is unchanged, but the URL still
// carries the last-run (edited) query. The builder must hydrate from the URL —
// not discard it — so the edit survives, and it must read dirty against saved.
const saved = makeSavedQueries();
const editedInUrl: Query = {
...initialQueriesMap[DataSource.METRICS],
id: 'edited-in-url',
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [
{
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
legend: 'cpu-edited-in-url',
},
],
},
};
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(editedInUrl)),
);
const setSpec = jest.fn();
const wrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<AllTheProviders initialRoute={`/?${params.toString()}`}>
{children}
</AllTheProviders>
);
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
// The draft/preview open on the saved query…
draft: makePanel(saved),
panelType,
setSpec,
refetch: jest.fn(),
savedQueries: saved,
}),
{ wrapper },
);
// The URL edit is retained → dirty, and it's synced into the draft so the
// preview follows (setSpec called with the edited query).
await waitFor(() => expect(result.current.isQueryDirty).toBe(true));
expect(setSpec).toHaveBeenCalled();
});
it('is query-dirty when the draft carries an edit the saved panel does not (View handoff)', async () => {
const saved = makeSavedQueries();
const editedBase: Query = {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [
{
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
legend: 'cpu-edited',
},
],
},
};
const editedQueries = toPerses(editedBase, panelType);
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
// The builder seeds from the draft (the handed-off edit)…
draft: makePanel(editedQueries),
panelType,
setSpec: jest.fn(),
refetch: jest.fn(),
// …but the baseline is the persisted panel.
savedQueries: saved,
}),
{ wrapper: AllTheProviders },
);
await waitFor(() => expect(result.current.isQueryDirty).toBe(true));
});
});

View File

@@ -95,7 +95,6 @@ describe('usePanelEditorQuerySync', () => {
draft?: DashboardtypesPanelDTO;
setSpec?: jest.Mock;
refetch?: jest.Mock;
savedQueries?: DashboardtypesPanelSpecDTO['queries'];
} = {},
): {
result: {
@@ -120,22 +119,20 @@ describe('usePanelEditorQuerySync', () => {
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
refetch,
savedQueries: opts.savedQueries,
}),
);
return { result, setSpec, refetch, rerender };
}
it('seeds the builder from the draft queries on mount (URL query, when present, wins)', () => {
it('force-resets the builder to the saved queries on mount (discards stale URL)', () => {
setup();
expect(mockFromPerses).toHaveBeenCalledWith(
SAVED_QUERIES,
PANEL_TYPES.TIME_SERIES,
);
// No forceReset: useShareBuilderUrl resets to the seed only when the URL carries
// no query, so an in-editor edit in the URL survives a refresh.
expect(mockUseShareBuilderUrl).toHaveBeenCalledWith({
defaultValue: SEED_V1,
forceReset: true,
});
});
@@ -345,127 +342,44 @@ describe('usePanelEditorQuerySync', () => {
});
});
describe('staged-query re-sync (browser back/forward)', () => {
it('commits the staged query into the draft when it re-stages', () => {
const state = builderState();
mockUseQueryBuilder.mockImplementation(() => state);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Browser Back re-stages a different query via initQueryBuilderData; the
// preview must follow it instead of keeping the last Run's result.
mockGetIsQueryModified.mockReturnValue(true);
state.stagedQuery = {
id: 'restaged',
queryType: 'builder',
} as unknown as Query;
rerender();
expect(setSpec).toHaveBeenCalledWith({
...makeDraft().spec,
queries: CONVERTED_QUERIES,
});
});
it('does not commit when only the live query changes (no re-stage)', () => {
const state = builderState({
currentQuery: { id: 'a', queryType: 'builder' } as Query,
});
mockUseQueryBuilder.mockImplementation(() => state);
mockGetIsQueryModified.mockReturnValue(true);
const { setSpec, rerender } = setup();
setSpec.mockClear();
// Live edit: currentQuery changes, staged query + structure unchanged.
state.currentQuery = { id: 'b', queryType: 'builder' } as Query;
rerender();
expect(setSpec).not.toHaveBeenCalled();
});
});
describe('query dirty + save', () => {
// isQueryDirty compares the live query to the SAVED queries at the V5 envelope
// level (toQueryEnvelopes is mocked identity). Drive it via an input-sensitive
// toPerses so the envelope comparison — not getIsQueryModified — decides.
const SAVED_BASELINE = [{ id: 'saved-baseline' }] as unknown as NonNullable<
DashboardtypesPanelSpecDTO['queries']
>;
const EDITED_ENVELOPES = [
{ id: 'edited-envelopes' },
] as unknown as NonNullable<DashboardtypesPanelSpecDTO['queries']>;
const editedQuery = { id: 'edited', queryType: 'builder' } as Query;
const unchangedQuery = { id: 'unchanged', queryType: 'builder' } as Query;
it('compares the live query against the builder baseline (first staged query), not the raw seed', () => {
mockGetIsQueryModified.mockReturnValue(true);
const { result } = setup();
beforeEach(() => {
mockToPerses.mockImplementation((query: Query) =>
query?.id === 'edited' ? EDITED_ENVELOPES : SAVED_BASELINE,
// Baseline is the builder's own normalized staged query — immune to the
// raw-seed vs builder-normalized serialization drift.
expect(mockGetIsQueryModified).toHaveBeenCalledWith(
expect.anything(),
STAGED_V1,
);
});
it('is query-dirty when the live query no longer serializes to the saved queries', () => {
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: editedQuery }),
);
const { result } = setup({ savedQueries: SAVED_BASELINE });
expect(result.current.isQueryDirty).toBe(true);
});
it('is not query-dirty when the live query still serializes to the saved queries', () => {
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: unchangedQuery }),
);
const { result } = setup({ savedQueries: SAVED_BASELINE });
it('is not query-dirty when the live query matches the baseline', () => {
mockGetIsQueryModified.mockReturnValue(false);
const { result } = setup();
expect(result.current.isQueryDirty).toBe(false);
});
it('buildSaveSpec bakes the live query in when dirty', () => {
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: editedQuery }),
);
const { result } = setup({ savedQueries: SAVED_BASELINE });
mockGetIsQueryModified.mockReturnValue(true);
const { result } = setup();
const { spec } = makeDraft();
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
...spec,
queries: EDITED_ENVELOPES,
queries: CONVERTED_QUERIES,
});
});
it('buildSaveSpec returns the spec untouched when the query is unchanged', () => {
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: unchangedQuery }),
);
const { result } = setup({ savedQueries: SAVED_BASELINE });
mockGetIsQueryModified.mockReturnValue(false);
const { result } = setup();
const { spec } = makeDraft();
expect(result.current.buildSaveSpec(spec)).toBe(spec);
});
it('anchors the baseline to savedQueries, not the draft the builder seeds from (View handoff / refresh)', () => {
// The draft carries the View-mode edit (the builder seeds from it), but the
// baseline is the persisted panel: a live query equal to the edited draft
// still reads dirty against the saved queries.
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: editedQuery }),
);
const draft = makeDraft(EDITED_ENVELOPES);
const { result } = setup({ draft, savedQueries: SAVED_BASELINE });
expect(result.current.isQueryDirty).toBe(true);
});
it('falls back to the seed query as the baseline when there are no saved queries (new panel)', () => {
mockUseQueryBuilder.mockReturnValue(
builderState({ currentQuery: unchangedQuery }),
);
const { result } = setup();
expect(result.current.isQueryDirty).toBe(false);
});
});
});

View File

@@ -23,12 +23,6 @@ import { usePanelTypeSwitch } from './usePanelTypeSwitch';
interface UsePanelEditSessionArgs {
panel: DashboardtypesPanelDTO;
panelId: string;
/**
* The persisted panel the dirty check compares against. Distinct from `panel` (the
* seed), which may carry unsaved edits handed off from View mode. Omit for a new
* panel or the drilldown modal, where the seed is the baseline.
*/
savedPanel?: DashboardtypesPanelDTO;
/** Per-view time window (epoch ms); omit to follow the dashboard's global window. */
time?: PanelQueryTimeOverride;
/** Serialize the live builder query into the spec on save even if unchanged (new panels). */
@@ -73,15 +67,12 @@ export interface UsePanelEditSessionReturn {
export function usePanelEditSession({
panel,
panelId,
savedPanel,
time,
alwaysSerializeQuery = false,
seedQuerySignal = false,
}: UsePanelEditSessionArgs): UsePanelEditSessionReturn {
const { draft, spec, setSpec, isSpecDirty, reset } = usePanelEditorDraft(
panel,
savedPanel,
);
const { draft, spec, setSpec, isSpecDirty, reset } =
usePanelEditorDraft(panel);
const panelKind = draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
@@ -102,7 +93,6 @@ export function usePanelEditSession({
refetch: query.refetch,
alwaysSerializeQuery,
signal: seedQuerySignal ? defaultSignal : undefined,
savedQueries: savedPanel?.spec.queries,
});
const { onChangePanelKind } = usePanelTypeSwitch({

View File

@@ -13,14 +13,9 @@ import type { PanelEditorDraftApi } from '../types';
* preview renders it through the dashboard's renderer registry and the save hook
* patches it without conversion. Everything the config pane edits flows through the
* single `spec`/`setSpec` pair.
*
* `savedPanel` is the persisted panel the dirty check compares against — distinct from
* `initialPanel` (the seed), which may carry unsaved edits handed off from View mode.
* Defaults to the seed when there's no separate saved baseline (a new panel).
*/
export function usePanelEditorDraft(
initialPanel: DashboardtypesPanelDTO,
savedPanel: DashboardtypesPanelDTO = initialPanel,
): PanelEditorDraftApi {
const [draft, setDraft] = useState<DashboardtypesPanelDTO>(initialPanel);
@@ -40,9 +35,9 @@ export function usePanelEditorDraft(
() =>
!isEqual(
{ ...draft, spec: { ...draft.spec, queries: null } },
{ ...savedPanel, spec: { ...savedPanel.spec, queries: null } },
{ ...initialPanel, spec: { ...initialPanel.spec, queries: null } },
),
[draft, savedPanel],
[draft, initialPanel],
);
return {

View File

@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
@@ -28,12 +27,6 @@ interface UsePanelEditorQuerySyncArgs {
alwaysSerializeQuery?: boolean;
/** Signal to seed a new panel's builder with — the kind's first supported signal. */
signal?: TelemetrytypesSignalDTO;
/**
* The persisted panel's queries — the dirty baseline. Distinct from `draft.spec.queries`,
* which the builder seeds from and may carry unsaved edits handed off from View mode. Omit
* for a new panel, where the seed query is the baseline.
*/
savedQueries?: DashboardtypesQueryDTO[];
}
interface UsePanelEditorQuerySyncApi {
@@ -60,31 +53,43 @@ export function usePanelEditorQuerySync({
refetch,
alwaysSerializeQuery = false,
signal,
savedQueries,
}: UsePanelEditorQuerySyncArgs): UsePanelEditorQuerySyncApi {
const { currentQuery, stagedQuery, handleRunQuery } = useQueryBuilder();
const draftQueries = draft.spec.queries;
// Saved queries, captured once: seed the builder and serve as the restore target.
const savedQueries = draft.spec.queries;
// A new panel has no saved query: seed from the kind's first supported signal rather
// than `fromPerses`'s metrics default (which List doesn't support).
// A new panel has no saved query: seed from the kind's first supported signal
// instead of letting `fromPerses` fall back to the metrics default (which List
// doesn't support).
const seedQuery = useMemo(
() =>
draftQueries.length === 0 && signal
savedQueries.length === 0 && signal
? initialQueriesMap[signal]
: fromPerses(draftQueries, panelType),
[draftQueries, panelType, signal],
: fromPerses(savedQueries, panelType),
[savedQueries, panelType, signal],
);
// No forceReset: seed the builder only when the URL carries no query, so an
// in-editor edit in the URL survives a refresh / browser Back-Forward.
useShareBuilderUrl({ defaultValue: seedQuery });
// Force-reset the builder to the SAVED panel on first render only, discarding a
// stale URL query from a prior edit (else the QB/preview diverge and the dirty
// baseline is captured from the URL). After mount the URL syncs normally.
const isInitialRenderRef = useRef(true);
useShareBuilderUrl({
defaultValue: seedQuery,
forceReset: isInitialRenderRef.current,
});
useEffect(() => {
isInitialRenderRef.current = false;
}, []);
// Commit the live query into the draft (what the preview fetches).
// Commit the live query into the draft (what the preview fetches). The dirty
// check compares against the SAVED query (`seedQuery`), not the URL-synced
// staged query, which can carry stale state across a refresh and read a real
// switch as "unchanged". Returns whether the draft changed.
const commitQuery = useCallback(
(query: Query): boolean => {
const next = getIsQueryModified(query, seedQuery)
? toPerses(query, panelType)
: draftQueries;
: savedQueries;
// No-op guard at the V5 envelope level: equivalent wrappers (bare
// `signoz/BuilderQuery` vs `signoz/CompositeQuery`) unwrap to the same
// envelopes, so a structural compare would falsely dirty the draft.
@@ -95,7 +100,7 @@ export function usePanelEditorQuerySync({
setSpec({ ...draft.spec, queries: next });
return true;
},
[seedQuery, panelType, draftQueries, draft.spec, setSpec],
[seedQuery, panelType, savedQueries, draft.spec, setSpec],
);
// Latest query/commit, read by the structural-change effect without re-subscribing.
@@ -105,7 +110,7 @@ export function usePanelEditorQuerySync({
queryRef.current = currentQuery;
// Re-commit on a query-type/datasource switch so the preview refetches. Skip
// mount: the initial query is synced into the draft by the staged-query effect below.
// mount: the draft already holds the saved queries the builder is reset to.
const dataSources = useMemo(
() => (currentQuery.builder?.queryData ?? []).map((q) => q.dataSource),
[currentQuery.builder],
@@ -131,15 +136,6 @@ export function usePanelEditorQuerySync({
// eslint-disable-next-line react-hooks/exhaustive-deps -- structural change only
}, [currentQuery.queryType, dataSourceSignature]);
// Follow the staged (executed) query into the draft on a URL re-stage (mount
// hydration, browser Back/Forward) so the preview matches. Live edits touch only
// currentQuery, so they still wait for Run; commitQuery no-ops when unchanged.
useEffect(() => {
if (stagedQuery) {
commitRef.current(stagedQuery);
}
}, [stagedQuery]);
// Stage & Run / ⌘↵: stage, commit, and re-fetch when unchanged so it can be re-run.
const runQuery = useCallback((): void => {
handleRunQuery();
@@ -148,29 +144,20 @@ export function usePanelEditorQuerySync({
}
}, [handleRunQuery, commitQuery, currentQuery, refetch]);
// Dirty = the live query no longer serializes to the SAVED panel's query, compared at
// the V5 envelope level. Anchoring to `savedQueries` (not the builder-seed) keeps a
// handed-off / URL-restored edit reading as dirty; routing both sides through the same
// `fromPerses → toPerses` round-trip stops builder-added defaults (absent from an older
// stored query) reading an untouched panel as modified. New panel: fall back to seed.
const baselineEnvelopes = useMemo(
() =>
toQueryEnvelopes(
toPerses(
savedQueries ? fromPerses(savedQueries, panelType) : seedQuery,
panelType,
),
),
[savedQueries, seedQuery, panelType],
);
const isQueryDirty = useMemo(
() =>
!isEqual(
toQueryEnvelopes(toPerses(currentQuery, panelType)),
baselineEnvelopes,
),
[currentQuery, panelType, baselineEnvelopes],
);
// Dirty baseline: the builder's OWN normalized saved query (first non-null
// `stagedQuery` after the mount reset) — comparing builder-normalized to
// builder-normalized avoids serialization drift reading an untouched query as
// modified. In state (not a ref) so capture re-triggers `isQueryDirty`; captured
// once and never moved by Stage & Run, so it stays anchored to saved.
const [queryBaseline, setQueryBaseline] = useState<Query | null>(null);
useEffect(() => {
if (queryBaseline === null && stagedQuery) {
setQueryBaseline(stagedQuery);
}
}, [queryBaseline, stagedQuery]);
const isQueryDirty =
queryBaseline !== null && getIsQueryModified(currentQuery, queryBaseline);
const buildSaveSpec = useCallback(
(spec: DashboardtypesPanelSpecDTO): DashboardtypesPanelSpecDTO =>

View File

@@ -6,7 +6,6 @@ import {
useDefaultLayout,
} from '@signozhq/ui/resizable';
import { toast } from '@signozhq/ui/sonner';
import { ConfigProvider } from 'antd';
import {
type DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
@@ -42,22 +41,10 @@ import styles from './PanelEditor.module.scss';
import logEvent from '@/api/common/logEvent';
import { DashboardEvents } from '../../constants/events';
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
// popups (group-by, order-by, having, …) clip when they open into the short pane.
// Portal them to the document body; the query-builder filters honor this via
// `useSelectPopupContainer`. Scoped to the full-page editor — the View modal keeps
// its own `ConfigProvider` so popups stay inside the focus-trapped dialog.
const getBodyPopupContainer = (): HTMLElement => document.body;
interface PanelEditorContainerProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
/**
* The persisted panel the dirty check compares against. Distinct from `panel` (the
* seed), which may carry unsaved edits handed off from View mode. Omit for a new panel.
*/
savedPanel?: DashboardtypesPanelDTO;
/** Creating a new panel (seeded default) vs editing an existing one. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
@@ -81,7 +68,6 @@ function PanelEditorContainer({
dashboardId,
panelId,
panel,
savedPanel,
isNew = false,
layoutIndex,
isEditable,
@@ -105,7 +91,6 @@ function PanelEditorContainer({
} = usePanelEditSession({
panel,
panelId,
savedPanel,
alwaysSerializeQuery: isNew,
seedQuerySignal: true,
});
@@ -303,24 +288,22 @@ function PanelEditorContainer({
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
<ConfigProvider getPopupContainer={getBodyPopupContainer}>
<PanelEditorQueryBuilder
panelKind={panelKind}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
footer={
isListPanel ? (
<ListColumnsEditor
spec={spec}
onChangeSpec={setSpec}
signal={listSignal}
/>
) : undefined
}
/>
</ConfigProvider>
<PanelEditorQueryBuilder
panelKind={panelKind}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
footer={
isListPanel ? (
<ListColumnsEditor
spec={spec}
onChangeSpec={setSpec}
signal={listSignal}
/>
) : undefined
}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>

View File

@@ -22,22 +22,6 @@ export interface ComparisonThresholdShape {
format?: DashboardtypesThresholdFormatDTO;
}
/** SigNoz threshold palette; single source of truth for the hex values. */
export enum ThresholdColor {
RED = '#F1575F',
ORANGE = '#F5B225',
GREEN = '#2BB673',
BLUE = '#4E74F8',
}
/** Palette ordered most-dangerous first (preset order + alert-severity ranking). */
export const THRESHOLD_COLOR_DANGER_ORDER: ThresholdColor[] = [
ThresholdColor.RED,
ThresholdColor.ORANGE,
ThresholdColor.GREEN,
ThresholdColor.BLUE,
];
/** Comparison operators a threshold can use, as evaluable symbols. */
export type ThresholdComparisonOperator = '>' | '<' | '>=' | '<=' | '=' | '!=';

View File

@@ -104,9 +104,7 @@ function ViewPanelModalContent({
logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
panelId: panelId,
});
openPanelEditor(panelId, {
handoffState: { editSpec: buildSaveSpec(draft.spec) },
});
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) });
};
return (

View File

@@ -105,8 +105,7 @@ export function useDrilldownDashboardVariables({
type: 'DYNAMIC',
multiSelect: true,
dynamicAttribute: fieldName,
// `||` (not `??`): an empty "any" signal maps to All, same as unset.
dynamicSignal: signal || DYNAMIC_SIGNAL_ALL,
dynamicSignal: signal ?? DYNAMIC_SIGNAL_ALL,
};
try {
await patchAsync(

View File

@@ -5,13 +5,11 @@ import type {
DashboardtypesPanelDTO,
DashboardtypesPanelPluginDTO,
} from 'api/generated/services/sigNoz.schemas';
import { normalizeOperator } from 'container/CreateAlertV2/context/conditionNormalizers';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
Threshold,
} from 'container/CreateAlertV2/context/types';
import { THRESHOLD_COLOR_DANGER_ORDER } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/threshold';
import type { MetricAggregation } from 'types/api/v5/queryRange';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { ReduceOperators } from 'types/common/queryBuilder';
@@ -23,6 +21,14 @@ export interface PanelAlertPrefill {
threshold?: Threshold;
}
// Most-dangerous first, matching the panel editor palette; unknown colors sort last.
const THRESHOLD_COLOR_DANGER_ORDER = [
'#f1575f',
'#f5b225',
'#2bb673',
'#4e74f8',
];
interface NormalizedPanelThreshold {
color: string;
value: number;
@@ -87,12 +93,8 @@ function readPanelThresholds(
}
}
// Match case-insensitively (picker emits lowercase hex); unknown colors sort last.
function colorRank(color: string): number {
const target = color.toLowerCase();
const index = THRESHOLD_COLOR_DANGER_ORDER.findIndex(
(paletteColor) => paletteColor.toLowerCase() === target,
);
const index = THRESHOLD_COLOR_DANGER_ORDER.indexOf(color.toLowerCase());
return index === -1 ? THRESHOLD_COLOR_DANGER_ORDER.length : index;
}
@@ -104,17 +106,22 @@ function pickHighestDanger(
)[0];
}
// The alert UI has no inclusive operator; collapse "or equal" onto its strict variant.
function panelOperatorToAlertOperator(
operator: DashboardtypesComparisonOperatorDTO | undefined,
): AlertThresholdOperator | undefined {
switch (operator) {
case 'above':
case 'above_or_equal':
return normalizeOperator('above');
return AlertThresholdOperator.IS_ABOVE;
case 'below':
case 'below_or_equal':
return normalizeOperator('below');
return AlertThresholdOperator.IS_BELOW;
case 'equal':
return AlertThresholdOperator.IS_EQUAL_TO;
case 'not_equal':
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
default:
return normalizeOperator(operator);
return undefined;
}
}

View File

@@ -2,7 +2,6 @@ import { memo } from 'react';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import DashboardPageBreadcrumbs from './DashboardPageBreadcrumbs';
import { useShareVariablesOption } from './useShareVariablesOption';
import styles from './DashboardPageHeader.module.scss';
@@ -15,16 +14,10 @@ function DashboardPageHeader({
title,
image,
}: DashboardPageHeaderProps): JSX.Element {
const shareVariablesOption = useShareVariablesOption();
return (
<div className={styles.dashboardPageHeader}>
<DashboardPageBreadcrumbs title={title} image={image} />
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
shareModalExtraOption={shareVariablesOption}
/>
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
</div>
);
}

View File

@@ -1,41 +0,0 @@
import { useMemo } from 'react';
import type { ShareURLExtraOption } from 'components/HeaderRightSection/ShareURLModal';
import type { SelectedVariableValue } from '../../VariablesBar/selectionTypes';
import {
ALL_SELECTED,
variablesUrlParser,
} from '../../VariablesBar/utils/variablesUrlState';
import { selectVariableValues } from '../../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../../store/useDashboardStore';
/**
* The share-dialog "Include variables" option: serializes the current variable
* selection into the `?variables=` param (ALL encoded as the sentinel) so a shared
* link reproduces it for the recipient — who hydrates it into local storage on load,
* after which the param is cleared (see useSeedVariableSelection). Returns undefined
* when there is nothing selected to share.
*/
export function useShareVariablesOption(): ShareURLExtraOption | undefined {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const selections = useDashboardStore(selectVariableValues(dashboardId ?? ''));
return useMemo(() => {
const names = Object.keys(selections);
if (names.length === 0) {
return undefined;
}
const urlShape: Record<string, SelectedVariableValue> = {};
names.forEach((name) => {
const selection = selections[name];
urlShape[name] = selection.allSelected ? ALL_SELECTED : selection.value;
});
const serialized = variablesUrlParser.serialize(urlShape);
return {
label: 'Include variables',
apply: (params): void => {
params.set('variables', serialized);
},
};
}, [selections]);
}

View File

@@ -1,65 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { useCreatePanel } from '../useCreatePanel';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
let mockGlobalTime = {
selectedTime: '30m',
minTime: 0,
maxTime: 0,
};
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({ globalTime: mockGlobalTime }),
}));
jest.mock('../../store/useDashboardStore', () => ({
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1' }),
}));
describe('useCreatePanel', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGlobalTime = { selectedTime: '30m', minTime: 0, maxTime: 0 };
});
it('carries the relative time window onto the new-panel route', () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useCreatePanel());
act(() => {
result.current.createPanel('timeSeries' as never, 2);
});
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain('/dashboard/dash-1/panel/new');
expect(url).toContain('panelKind=timeSeries');
expect(url).toContain('layoutIndex=2');
expect(url).toContain('relativeTime=6h');
});
it('carries a custom absolute window and never a stray relativeTime', () => {
mockGlobalTime = {
selectedTime: 'custom',
minTime: 1000 * NANO_SECOND_MULTIPLIER,
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
};
const { result } = renderHook(() => useCreatePanel());
act(() => {
result.current.createPanel('timeSeries' as never, 2);
});
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain('startTime=1000');
expect(url).toContain('endTime=2000');
expect(url).not.toContain('relativeTime');
expect(url).not.toContain('&&');
});
});

View File

@@ -1,95 +0,0 @@
import { renderHook } from '@testing-library/react';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { useOpenPanelEditor } from '../useOpenPanelEditor';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
let mockGlobalTime = {
selectedTime: '30m',
minTime: 0,
maxTime: 0,
};
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({ globalTime: mockGlobalTime }),
}));
jest.mock('../../store/useDashboardStore', () => ({
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1' }),
}));
describe('useOpenPanelEditor', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGlobalTime = { selectedTime: '30m', minTime: 0, maxTime: 0 };
});
it('carries the relative time window into the editor route', () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useOpenPanelEditor());
result.current('panel-9');
expect(mockSafeNavigate).toHaveBeenCalledWith(
'/dashboard/dash-1/panel/panel-9?relativeTime=6h',
undefined,
);
});
it('carries a custom absolute window as a start/end ms pair', () => {
mockGlobalTime = {
selectedTime: 'custom',
minTime: 1000 * NANO_SECOND_MULTIPLIER,
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
};
const { result } = renderHook(() => useOpenPanelEditor());
result.current('panel-9');
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain('startTime=1000');
expect(url).toContain('endTime=2000');
// A custom range must not also carry relativeTime (it would win on the editor).
expect(url).not.toContain('relativeTime');
});
it('omits the query string for an uninitialized custom window', () => {
mockGlobalTime = { selectedTime: 'custom', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useOpenPanelEditor());
result.current('panel-9');
expect(mockSafeNavigate).toHaveBeenCalledWith(
'/dashboard/dash-1/panel/panel-9',
undefined,
);
});
it('forwards handoff state as router location state', () => {
mockGlobalTime = { selectedTime: '1h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useOpenPanelEditor());
const handoffState = { editSpec: { title: 'x' } } as never;
result.current('panel-9', { handoffState });
expect(mockSafeNavigate).toHaveBeenCalledWith(
'/dashboard/dash-1/panel/panel-9?relativeTime=1h',
{ state: handoffState },
);
});
it('merges search with the time window (leading ? tolerated)', () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useOpenPanelEditor());
result.current('new', { search: '?panelKind=timeSeries&layoutIndex=2' });
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain('/dashboard/dash-1/panel/new?');
expect(url).toContain('panelKind=timeSeries');
expect(url).toContain('layoutIndex=2');
expect(url).toContain('relativeTime=6h');
});
});

View File

@@ -1,43 +0,0 @@
import { renderHook } from '@testing-library/react';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { useTimeSearchParams } from '../useTimeSearchParams';
let mockGlobalTime = {
selectedTime: '30m',
minTime: 0,
maxTime: 0,
};
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({ globalTime: mockGlobalTime }),
}));
describe('useTimeSearchParams', () => {
it('returns a relativeTime query string for a relative selection', () => {
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useTimeSearchParams());
expect(result.current).toBe('relativeTime=6h');
});
it('returns an absolute ms pair for a custom selection', () => {
mockGlobalTime = {
selectedTime: 'custom',
minTime: 1000 * NANO_SECOND_MULTIPLIER,
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
};
const { result } = renderHook(() => useTimeSearchParams());
expect(result.current).toContain('startTime=1000');
expect(result.current).toContain('endTime=2000');
expect(result.current).not.toContain('relativeTime');
});
it('returns an empty string for an uninitialized custom window', () => {
mockGlobalTime = { selectedTime: 'custom', minTime: 0, maxTime: 0 };
const { result } = renderHook(() => useTimeSearchParams());
expect(result.current).toBe('');
});
});

View File

@@ -1,8 +1,11 @@
import { useCallback, useState } from 'react';
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { newPanelSearch, NEW_PANEL_ID } from '../PanelEditor/newPanelRoute';
import type { PanelKind } from '../Panels/types/panelKind';
import { useOpenPanelEditor } from './useOpenPanelEditor';
import { useDashboardStore } from '../store/useDashboardStore';
interface UseCreatePanelResult {
isPickerOpen: boolean;
@@ -21,7 +24,8 @@ interface UseCreatePanelResult {
* until save.
*/
export function useCreatePanel(): UseCreatePanelResult {
const openPanelEditor = useOpenPanelEditor();
const { safeNavigate } = useSafeNavigate();
const dashboardId = useDashboardStore((s) => s.dashboardId);
const [isPickerOpen, setIsPickerOpen] = useState(false);
// Captured on open, consumed on select.
@@ -39,12 +43,15 @@ export function useCreatePanel(): UseCreatePanelResult {
const createPanel = useCallback(
(panelKind: PanelKind, targetIndex?: number): void => {
setIsPickerOpen(false);
const target = targetIndex ?? layoutIndex;
openPanelEditor(NEW_PANEL_ID, {
search: newPanelSearch(panelKind, target),
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId: NEW_PANEL_ID,
});
const target = targetIndex ?? layoutIndex;
// Variable selection is read from the persisted store, not the URL.
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
},
[openPanelEditor, layoutIndex],
[safeNavigate, dashboardId, layoutIndex],
);
return {

View File

@@ -5,39 +5,30 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
import { useDashboardStore } from '../store/useDashboardStore';
import { useTimeSearchParams } from './useTimeSearchParams';
interface OpenPanelEditorOptions {
handoffState?: PanelEditorHandoffState;
/** Extra query merged into the editor URL (leading `?` optional). */
search?: string;
}
/** Opens the V2 panel editor, carrying the active time window in the URL. */
/**
* Returns a callback that opens the V2 panel editor by navigating to its full-page route
* (`/dashboard/:dashboardId/panel/:panelId`). The dashboard id comes from the store, so any
* caller can open the editor with just the panel id. Variable selection is read from the
* persisted store (localStorage), not carried in the URL. The optional `handoffState` is
* passed as router location state — the View modal uses it to hand its drilldown-edited spec
* off to the editor (view → edit) so the editor opens on those edits rather than the saved
* panel.
*/
export function useOpenPanelEditor(): (
panelId: string,
options?: OpenPanelEditorOptions,
handoffState?: PanelEditorHandoffState,
) => void {
const { safeNavigate } = useSafeNavigate();
const timeSearch = useTimeSearchParams();
const dashboardId = useDashboardStore((s) => s.dashboardId);
return useCallback(
(panelId: string, options?: OpenPanelEditorOptions): void => {
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId,
});
const params = new URLSearchParams(options?.search);
new URLSearchParams(timeSearch).forEach((value, key) => {
params.set(key, value);
});
const search = params.toString();
(panelId: string, handoffState?: PanelEditorHandoffState): void => {
safeNavigate(
search ? `${path}?${search}` : path,
options?.handoffState ? { state: options.handoffState } : undefined,
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
handoffState ? { state: handoffState } : undefined,
);
},
[safeNavigate, dashboardId, timeSearch],
[safeNavigate, dashboardId],
);
}

View File

@@ -1,20 +0,0 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import { timeParamsFromGlobalTime } from '../utils/timeUrlParams';
/** Active time window as a query string (no leading `?`), or `''` when unset. */
export function useTimeSearchParams(): string {
const { selectedTime, minTime, maxTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
return useMemo(
() => timeParamsFromGlobalTime({ selectedTime, minTime, maxTime }).toString(),
[selectedTime, minTime, maxTime],
);
}

View File

@@ -1,51 +0,0 @@
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import { timeParamsFromGlobalTime } from '../timeUrlParams';
describe('timeParamsFromGlobalTime', () => {
it('emits relativeTime for a relative selection', () => {
const params = timeParamsFromGlobalTime({
selectedTime: '6h',
minTime: 0,
maxTime: 0,
});
expect(params.get('relativeTime')).toBe('6h');
// Mutually exclusive: no absolute pair alongside a relative range.
expect(params.has('startTime')).toBe(false);
expect(params.has('endTime')).toBe(false);
});
it('emits an absolute ms pair for a custom selection (converting from ns)', () => {
const params = timeParamsFromGlobalTime({
selectedTime: 'custom',
minTime: 1000 * NANO_SECOND_MULTIPLIER,
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
});
expect(params.get('startTime')).toBe('1000');
expect(params.get('endTime')).toBe('2000');
// A custom range must not carry a relativeTime that would win on the editor.
expect(params.has('relativeTime')).toBe(false);
});
it('carries a custom shorthand relative selection verbatim', () => {
const params = timeParamsFromGlobalTime({
selectedTime: '13m',
minTime: 0,
maxTime: 0,
});
expect(params.get('relativeTime')).toBe('13m');
});
it('emits nothing for an uninitialized custom window', () => {
const params = timeParamsFromGlobalTime({
selectedTime: 'custom',
minTime: 0,
maxTime: 0,
});
expect(params.toString()).toBe('');
});
});

View File

@@ -1,39 +0,0 @@
import { QueryParams } from 'constants/query';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import type { GlobalReducer } from 'types/reducer/globalTime';
type GlobalTimeSelection = Pick<
GlobalReducer,
'selectedTime' | 'minTime' | 'maxTime'
>;
/**
* Time-window URL params for the active selection. Derived from Redux (what the picker and
* panel queries read), not the URL: the legacy react-router and newer nuqs time writers fall
* out of sync, leaving a stale `relativeTime` that `DateTimeSelectionV2` prefers over an
* absolute range. Redux keeps them mutually exclusive (custom → start/end ms; else relativeTime).
*/
export function timeParamsFromGlobalTime({
selectedTime,
minTime,
maxTime,
}: GlobalTimeSelection): URLSearchParams {
const params = new URLSearchParams();
if (selectedTime === 'custom') {
if (minTime > 0 && maxTime > 0) {
params.set(
QueryParams.startTime,
String(Math.floor(minTime / NANO_SECOND_MULTIPLIER)),
);
params.set(
QueryParams.endTime,
String(Math.floor(maxTime / NANO_SECOND_MULTIPLIER)),
);
}
} else {
params.set(QueryParams.relativeTime, selectedTime);
}
return params;
}

View File

@@ -21,7 +21,6 @@ import {
parseNewPanelLayoutIndex,
} from '../DashboardContainer/PanelEditor/newPanelRoute';
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
import { useTimeSearchParams } from '../DashboardContainer/hooks/useTimeSearchParams';
import { createDefaultPanel } from '../DashboardContainer/patchOps';
import { useDashboardStore } from '../DashboardContainer/store/useDashboardStore';
import { useSeedVariableSelection } from '../DashboardContainer/VariablesBar/hooks/useSeedVariableSelection';
@@ -39,7 +38,6 @@ function PanelEditorPage(): JSX.Element {
}>();
const { search, state } = useLocation();
const { safeNavigate } = useSafeNavigate();
const timeSearch = useTimeSearchParams();
// Edits handed off from the View modal's drilldown — open the editor on these
// instead of the saved panel. Lost on refresh/new-tab, which falls back to saved.
@@ -107,11 +105,10 @@ function PanelEditorPage(): JSX.Element {
const layoutIndex = parseNewPanelLayoutIndex(search);
const backToDashboard = useCallback((): void => {
// Drop editor-only URL state (variables come from the persisted store), but carry
// time so a custom range picked in the editor isn't reset to the dashboard default.
const path = generatePath(ROUTES.DASHBOARD, { dashboardId });
safeNavigate(timeSearch ? `${path}?${timeSearch}` : path);
}, [safeNavigate, dashboardId, timeSearch]);
// Drop editor-only URL state (chiefly `compositeQuery`); the dashboard reads its
// variable selection from the persisted store, and time lives in Redux.
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
}, [safeNavigate, dashboardId]);
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;
@@ -140,7 +137,6 @@ function PanelEditorPage(): JSX.Element {
dashboardId={dashboardId}
panelId={panelId}
panel={panel}
savedPanel={existingPanel}
isNew={!!newKind}
layoutIndex={layoutIndex}
isEditable={isEditable}

View File

@@ -16,6 +16,8 @@ export interface CopyButtonProps {
/** Extra class merged onto the button. */
className?: string;
testId?: string;
/** Called after the copy is triggered (e.g. to show a toast). */
onCopy?: () => void;
}
/**
@@ -29,12 +31,14 @@ function CopyButton({
ariaLabel = 'Copy',
className,
testId,
onCopy,
}: CopyButtonProps): JSX.Element {
const { copyToClipboard, isCopied } = useCopyToClipboard();
const handleClick = useCallback((): void => {
copyToClipboard(value);
}, [copyToClipboard, value]);
onCopy?.();
}, [copyToClipboard, value, onCopy]);
const stackStyle: CSSProperties = { width: size, height: size };
@@ -61,6 +65,7 @@ CopyButton.defaultProps = {
ariaLabel: 'Copy',
className: undefined,
testId: undefined,
onCopy: undefined,
};
export default CopyButton;

View File

@@ -1,20 +1,5 @@
import { ConfigProvider, SelectProps } from 'antd';
// eslint-disable-next-line no-restricted-imports
import { useContext } from 'react';
import { SelectProps } from 'antd';
export const popupContainer: SelectProps['getPopupContainer'] = (
trigger,
): HTMLElement => trigger.parentNode;
/**
* Popup container for query-builder Selects. Prefers a container supplied by an
* ancestor antd `ConfigProvider` (set by hosts that render the builder inside a
* clipped/portaled surface — e.g. the panel editor's `overflow:hidden` resizable
* pane, or the View modal's focus-trapped dialog) and otherwise falls back to
* `trigger.parentNode`, the app-wide default. No `ConfigProvider` container is set
* app-wide, so surfaces that don't opt in keep the legacy behavior unchanged.
*/
export function useSelectPopupContainer(): SelectProps['getPopupContainer'] {
const { getPopupContainer } = useContext(ConfigProvider.ConfigContext);
return getPopupContainer ?? popupContainer;
}

View File

@@ -12,15 +12,13 @@ import (
var (
ErrCodeInvalidGlobalConfig = errors.MustNewCode("invalid_global_config")
ErrCodeOriginNotAllowed = errors.MustNewCode("origin_not_allowed")
)
type Config struct {
ExternalURL *url.URL `mapstructure:"external_url"`
AllowedOrigins []*url.URL `mapstructure:"allowed_origins"`
IngestionURL *url.URL `mapstructure:"ingestion_url"`
MCPURL *url.URL `mapstructure:"mcp_url"`
AIAssistantURL *url.URL `mapstructure:"ai_assistant_url"`
ExternalURL *url.URL `mapstructure:"external_url"`
IngestionURL *url.URL `mapstructure:"ingestion_url"`
MCPURL *url.URL `mapstructure:"mcp_url"`
AIAssistantURL *url.URL `mapstructure:"ai_assistant_url"`
}
func NewConfigFactory() factory.ConfigFactory {
@@ -51,33 +49,9 @@ func (c Config) Validate() error {
}
}
for _, origin := range c.AllowedOrigins {
if origin == nil || origin.Scheme == "" || origin.Host == "" {
return errors.NewInvalidInputf(ErrCodeInvalidGlobalConfig, "global::allowed_origins entries must be of the form scheme://host[:port], got %q", origin)
}
if origin.Path != "" && origin.Path != "/" {
return errors.NewInvalidInputf(ErrCodeInvalidGlobalConfig, "global::allowed_origins entries must not contain a path, got %q", origin)
}
}
return nil
}
func (c Config) IsOriginAllowed(u *url.URL) bool {
if len(c.AllowedOrigins) == 0 {
return true
}
for _, origin := range c.AllowedOrigins {
if strings.EqualFold(origin.Scheme, u.Scheme) && strings.EqualFold(origin.Host, u.Host) {
return true
}
}
return false
}
func (c Config) ExternalPath() string {
if c.ExternalURL == nil || c.ExternalURL.Path == "" || c.ExternalURL.Path == "/" {
return ""

View File

@@ -123,26 +123,6 @@ func TestValidate(t *testing.T) {
config: Config{ExternalURL: &url.URL{Path: "signoz"}},
fail: true,
},
{
name: "ValidAllowedOrigin",
config: Config{AllowedOrigins: []*url.URL{{Scheme: "https", Host: "signoz.example.com"}}},
fail: false,
},
{
name: "AllowedOriginWithoutScheme",
config: Config{AllowedOrigins: []*url.URL{{Host: "signoz.example.com"}}},
fail: true,
},
{
name: "AllowedOriginWithoutHost",
config: Config{AllowedOrigins: []*url.URL{{Scheme: "https"}}},
fail: true,
},
{
name: "AllowedOriginWithPath",
config: Config{AllowedOrigins: []*url.URL{{Scheme: "https", Host: "signoz.example.com", Path: "/login"}}},
fail: true,
},
}
for _, tc := range testCases {
@@ -157,96 +137,3 @@ func TestValidate(t *testing.T) {
})
}
}
func TestIsOriginAllowedWhenUnconfigured(t *testing.T) {
testCases := []struct {
name string
config Config
}{
{
name: "Empty",
config: Config{},
},
{
name: "ExternalURLDoesNotActivateValidation",
config: Config{ExternalURL: &url.URL{Scheme: "https", Host: "signoz.example.com"}},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
u, err := url.Parse("https://anything.example.com/login")
assert.NoError(t, err)
assert.True(t, tc.config.IsOriginAllowed(u))
})
}
}
func TestIsOriginAllowed(t *testing.T) {
config := Config{
AllowedOrigins: []*url.URL{
{Scheme: "https", Host: "signoz.example.com"},
{Scheme: "http", Host: "localhost:3301"},
},
}
testCases := []struct {
name string
input string
expected bool
}{
{
name: "ConfiguredOrigin",
input: "https://signoz.example.com/login",
expected: true,
},
{
name: "ConfiguredOriginWithQuery",
input: "http://localhost:3301/login?next=/dashboards",
expected: true,
},
{
name: "CaseInsensitiveHost",
input: "https://SigNoz.Example.Com/login",
expected: true,
},
{
name: "UnknownHost",
input: "https://attacker.example.com/login",
expected: false,
},
{
name: "SchemeMismatch",
input: "http://signoz.example.com/login",
expected: false,
},
{
name: "PortMismatch",
input: "https://signoz.example.com:8443/login",
expected: false,
},
{
name: "SuffixConfusion",
input: "https://evilsignoz.example.com/login",
expected: false,
},
{
name: "UserInfoConfusion",
input: "https://signoz.example.com@attacker.example.com/login",
expected: false,
},
{
name: "SchemeRelative",
input: "//attacker.example.com/login",
expected: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
u, err := url.Parse(tc.input)
assert.NoError(t, err)
assert.Equal(t, tc.expected, config.IsOriginAllowed(u))
})
}
}

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

@@ -12,7 +12,6 @@ import (
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/session"
@@ -24,28 +23,26 @@ import (
)
type module struct {
settings factory.ScopedProviderSettings
authNs map[authtypes.AuthNProvider]authn.AuthN
userSetter user.Setter
userGetter user.Getter
authDomain authdomain.Module
tokenizer tokenizer.Tokenizer
orgGetter organization.Getter
authz authz.AuthZ
globalConfig global.Config
settings factory.ScopedProviderSettings
authNs map[authtypes.AuthNProvider]authn.AuthN
userSetter user.Setter
userGetter user.Getter
authDomain authdomain.Module
tokenizer tokenizer.Tokenizer
orgGetter organization.Getter
authz authz.AuthZ
}
func NewModule(providerSettings factory.ProviderSettings, authNs map[authtypes.AuthNProvider]authn.AuthN, userSetter user.Setter, userGetter user.Getter, authDomain authdomain.Module, tokenizer tokenizer.Tokenizer, orgGetter organization.Getter, authz authz.AuthZ, globalConfig global.Config) session.Module {
func NewModule(providerSettings factory.ProviderSettings, authNs map[authtypes.AuthNProvider]authn.AuthN, userSetter user.Setter, userGetter user.Getter, authDomain authdomain.Module, tokenizer tokenizer.Tokenizer, orgGetter organization.Getter, authz authz.AuthZ) session.Module {
return &module{
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/session/implsession"),
authNs: authNs,
userSetter: userSetter,
userGetter: userGetter,
authDomain: authDomain,
tokenizer: tokenizer,
orgGetter: orgGetter,
authz: authz,
globalConfig: globalConfig,
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/session/implsession"),
authNs: authNs,
userSetter: userSetter,
userGetter: userGetter,
authDomain: authDomain,
tokenizer: tokenizer,
orgGetter: orgGetter,
authz: authz,
}
}
@@ -143,10 +140,6 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
return "", err
}
if callbackIdentity.State.URL.Host != "" && !module.globalConfig.IsOriginAllowed(callbackIdentity.State.URL) {
return "", errors.Newf(errors.TypeForbidden, global.ErrCodeOriginNotAllowed, "state redirect %q is not an allowed origin", callbackIdentity.State.URL.String())
}
authDomain, err := module.authDomain.GetByOrgIDAndID(ctx, callbackIdentity.OrgID, callbackIdentity.State.DomainID)
if err != nil {
return "", err
@@ -224,10 +217,6 @@ func (module *module) getOrgSessionContext(ctx context.Context, org *types.Organ
return nil, err
}
if !module.globalConfig.IsOriginAllowed(siteURL) {
return nil, errors.Newf(errors.TypeInvalidInput, global.ErrCodeOriginNotAllowed, "ref %q is not an allowed origin", siteURL.String())
}
loginURL, err := provider.LoginURL(ctx, siteURL, authDomain)
if err != nil {
return nil, err

View File

@@ -56,21 +56,19 @@ func (c *captureClient) Read(ctx context.Context, query *prompb.Query, _ bool) (
}
}
// Without executing the series lookup, only an exact-name selector's
// metric name is known.
var metricNames []string
var metricName string
for _, matcher := range query.Matchers {
if matcher.Name == "__name__" && matcher.Type == prompb.LabelMatcher_EQ {
metricNames = []string{matcher.Value}
if matcher.Name == "__name__" {
metricName = matcher.Value
}
}
// Build the executing path's queries, but only record them.
sub, err := seriesLookupQuery(query, true)
subQuery, args, err := c.queryToClickhouseQuery(ctx, query, metricName, true)
if err != nil {
return nil, err
}
samplesQuery, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricNames, sub)
samplesQuery, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricName, subQuery, args)
c.recorder.record(samplesQuery, samplesArgs)
return storage.EmptySeriesSet(), nil

View File

@@ -4,7 +4,8 @@ import (
"context"
"fmt"
"math"
"sort"
"strconv"
"strings"
"sync"
"github.com/SigNoz/signoz/pkg/errors"
@@ -14,7 +15,6 @@ import (
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/cespare/xxhash/v2"
"github.com/huandu/go-sqlbuilder"
promValue "github.com/prometheus/prometheus/model/value"
"github.com/prometheus/prometheus/prompb"
"github.com/prometheus/prometheus/storage"
@@ -56,13 +56,19 @@ func (client *client) Read(ctx context.Context, query *prompb.Query, sortSeries
}
}
lookup, err := seriesLookupQuery(query, false)
var metricName string
for _, matcher := range query.Matchers {
if matcher.Name == "__name__" {
metricName = matcher.Value
}
}
clickhouseQuery, args, err := client.queryToClickhouseQuery(ctx, query, metricName, false)
if err != nil {
return nil, err
}
lookupSQL, lookupArgs := lookup.BuildWithFlavor(sqlbuilder.ClickHouse)
fingerprints, metricNames, err := client.getFingerprintsFromClickhouseQuery(ctx, lookupSQL, lookupArgs)
fingerprints, err := client.getFingerprintsFromClickhouseQuery(ctx, clickhouseQuery, args)
if err != nil {
return nil, err
}
@@ -70,14 +76,13 @@ func (client *client) Read(ctx context.Context, query *prompb.Query, sortSeries
return remote.FromQueryResult(sortSeries, new(prompb.QueryResult)), nil
}
sub, err := seriesLookupQuery(query, true)
clickhouseSubQuery, args, err := client.queryToClickhouseQuery(ctx, query, metricName, true)
if err != nil {
return nil, err
}
samplesSQL, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricNames, sub)
res := new(prompb.QueryResult)
timeseries, err := client.querySamples(ctx, samplesSQL, samplesArgs, fingerprints)
timeseries, err := client.querySamples(ctx, int64(query.StartTimestampMs), int64(query.EndTimestampMs), fingerprints, metricName, clickhouseSubQuery, args)
if err != nil {
return nil, err
}
@@ -121,115 +126,86 @@ func (c *client) ReadMultiple(ctx context.Context, queries []*prompb.Query, sort
return storage.NewMergeSeriesSet(sets, 0, storage.ChainedSeriesMerge), nil
}
// anchorRegex makes a pattern fully anchored, the way Prometheus compiles
// matcher regexes; ClickHouse's match() would otherwise substring-match.
func anchorRegex(pattern string) string {
return "^(?:" + pattern + ")$"
}
// seriesLookupQuery builds the time-series lookup. It returns a builder so
// the samples query can embed it as a subquery with the args merged in
// render order by the builder instead of hand-numbered placeholders.
func seriesLookupQuery(query *prompb.Query, subQuery bool) (*sqlbuilder.SelectBuilder, error) {
sb := sqlbuilder.NewSelectBuilder()
func (client *client) queryToClickhouseQuery(_ context.Context, query *prompb.Query, metricName string, subQuery bool) (string, []any, error) {
var clickHouseQuery string
var conditions []string
var argCount = 0
var selectString = "fingerprint, any(labels)"
if subQuery {
sb.Select("fingerprint")
} else {
sb.Select("fingerprint", "any(labels)")
argCount = 1
selectString = "fingerprint"
}
start, end, tableName := getStartAndEndAndTableName(query.StartTimestampMs, query.EndTimestampMs)
sb.From(databaseName + "." + tableName)
sb.Where("temporality IN ['Cumulative', 'Unspecified']")
var args []any
conditions = append(conditions, fmt.Sprintf("metric_name = $%d", argCount+1))
conditions = append(conditions, "temporality IN ['Cumulative', 'Unspecified']")
// Inclusive upper bound: registration rows are hour-floored by the
// exporter, so a series first registered in the hour starting exactly at
// `end` would otherwise be invisible while its samples (<= end) are in
// range.
sb.Where(fmt.Sprintf("unix_milli >= %d AND unix_milli <= %d", start, end))
conditions = append(conditions, fmt.Sprintf("unix_milli >= %d AND unix_milli <= %d", start, end))
args = append(args, metricName)
for _, m := range query.Matchers {
if m.Name == "__name__" {
// __name__ maps onto the metric_name column per matcher type;
// reducing regex/negated/absent name matchers to one equality
// made such selectors silently return empty.
switch m.Type {
case prompb.LabelMatcher_EQ:
sb.Where(sb.E("metric_name", m.Value))
case prompb.LabelMatcher_NEQ:
sb.Where(sb.NE("metric_name", m.Value))
case prompb.LabelMatcher_RE:
sb.Where(fmt.Sprintf("match(metric_name, %s)", sb.Var(anchorRegex(m.Value))))
case prompb.LabelMatcher_NRE:
sb.Where(fmt.Sprintf("not match(metric_name, %s)", sb.Var(anchorRegex(m.Value))))
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported or invalid matcher type: %s", m.Type.String())
}
continue
}
switch m.Type {
case prompb.LabelMatcher_EQ:
sb.Where(fmt.Sprintf("JSONExtractString(labels, %s) = %s", sb.Var(m.Name), sb.Var(m.Value)))
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, $%d) = $%d", argCount+2, argCount+3))
case prompb.LabelMatcher_NEQ:
sb.Where(fmt.Sprintf("JSONExtractString(labels, %s) != %s", sb.Var(m.Name), sb.Var(m.Value)))
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, $%d) != $%d", argCount+2, argCount+3))
case prompb.LabelMatcher_RE:
sb.Where(fmt.Sprintf("match(JSONExtractString(labels, %s), %s)", sb.Var(m.Name), sb.Var(anchorRegex(m.Value))))
conditions = append(conditions, fmt.Sprintf("match(JSONExtractString(labels, $%d), $%d)", argCount+2, argCount+3))
case prompb.LabelMatcher_NRE:
sb.Where(fmt.Sprintf("not match(JSONExtractString(labels, %s), %s)", sb.Var(m.Name), sb.Var(anchorRegex(m.Value))))
conditions = append(conditions, fmt.Sprintf("not match(JSONExtractString(labels, $%d), $%d)", argCount+2, argCount+3))
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported or invalid matcher type: %s", m.Type.String())
return "", nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported or invalid matcher type: %s", m.Type.String())
}
args = append(args, m.Name, m.Value)
argCount += 2
}
sb.GroupBy("fingerprint")
return sb, nil
whereClause := strings.Join(conditions, " AND ")
clickHouseQuery = fmt.Sprintf(`SELECT %s FROM %s.%s WHERE %s GROUP BY fingerprint`, selectString, databaseName, tableName, whereClause)
return clickHouseQuery, args, nil
}
func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, query string, args []any) (map[uint64][]prompb.Label, []string, error) {
func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, query string, args []any) (map[uint64][]prompb.Label, error) {
ctx = client.withClickhousePrometheusContext(ctx, "getFingerprintsFromClickhouseQuery")
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, nil, err
return nil, err
}
defer rows.Close()
fingerprints := make(map[uint64][]prompb.Label)
nameSet := make(map[string]struct{})
var fingerprint uint64
var labelString string
for rows.Next() {
if err = rows.Scan(&fingerprint, &labelString); err != nil {
return nil, nil, err
return nil, err
}
labels, metricName, err := unmarshalLabels(labelString)
labels, _, err := unmarshalLabels(labelString)
if err != nil {
return nil, nil, err
return nil, err
}
fingerprints[fingerprint] = labels
if metricName != "" {
nameSet[metricName] = struct{}{}
}
}
if err := rows.Err(); err != nil {
return nil, nil, err
return nil, err
}
metricNames := make([]string, 0, len(nameSet))
for name := range nameSet {
metricNames = append(metricNames, name)
}
sort.Strings(metricNames)
return fingerprints, metricNames, nil
return fingerprints, nil
}
// buildSamplesQuery renders the samples SQL for the series selected by
// subQuery. The metric_name condition exists only for primary-key pruning;
// the fingerprint filter already selects the right rows.
// buildSamplesQuery renders the samples SQL (and args) that fetches data
// points for the series selected by subQuery.
//
// Time bounds are inclusive on both ends because that is Prometheus's
// storage contract: Select(mint, maxt) returns [start, end] and the engine
@@ -239,29 +215,27 @@ func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, qu
// its own model — toStartOfInterval buckets covering [t, t+step), where a
// sample at `end` falls in an unrendered bucket and end-exclusive ranges
// tile exactly across cached time slices.
func buildSamplesQuery(start int64, end int64, metricNames []string, sub *sqlbuilder.SelectBuilder) (string, []any) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("metric_name", "fingerprint", "unix_milli", "value", "flags")
sb.From(databaseName + "." + distributedSamplesV4)
func buildSamplesQuery(start int64, end int64, metricName string, subQuery string, args []any) (string, []any) {
argCount := len(args)
if len(metricNames) > 0 {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb.Where(sb.In("metric_name", names...))
}
sb.Where(fmt.Sprintf("fingerprint GLOBAL IN (%s)", sb.Var(sub)))
sb.Where(sb.GTE("unix_milli", start), sb.LTE("unix_milli", end))
sb.OrderBy("fingerprint", "unix_milli")
query := fmt.Sprintf(`
SELECT metric_name, fingerprint, unix_milli, value, flags
FROM %s.%s
WHERE metric_name = $1 AND fingerprint GLOBAL IN (%s) AND unix_milli >= $%s AND unix_milli <= $%s ORDER BY fingerprint, unix_milli;`,
databaseName, distributedSamplesV4, subQuery, strconv.Itoa(argCount+2), strconv.Itoa(argCount+3))
query = strings.TrimSpace(query)
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
allArgs := append([]any{metricName}, args...)
allArgs = append(allArgs, start, end)
return query, allArgs
}
func (client *client) querySamples(ctx context.Context, query string, args []any, fingerprints map[uint64][]prompb.Label) ([]*prompb.TimeSeries, error) {
func (client *client) querySamples(ctx context.Context, start int64, end int64, fingerprints map[uint64][]prompb.Label, metricName string, subQuery string, args []any) ([]*prompb.TimeSeries, error) {
ctx = client.withClickhousePrometheusContext(ctx, "querySamples")
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
query, allArgs := buildSamplesQuery(start, end, metricName, subQuery, args)
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, allArgs...)
if err != nil {
return nil, err
}
@@ -269,7 +243,6 @@ func (client *client) querySamples(ctx context.Context, query string, args []any
var res []*prompb.TimeSeries
var ts *prompb.TimeSeries
var metricName string
var fingerprint, prevFingerprint uint64
var timestampMs, prevTimestamp int64
var value float64

View File

@@ -11,7 +11,6 @@ import (
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/huandu/go-sqlbuilder"
"github.com/prometheus/prometheus/prompb"
"github.com/stretchr/testify/assert"
)
@@ -30,7 +29,7 @@ func TestClient_QuerySamples(t *testing.T) {
start int64
end int64
fingerprints map[uint64][]prompb.Label
metricNames []string
metricName string
subQuery string
args []any
setupMock func(mock cmock.ClickConnMockCommon, args ...any)
@@ -53,7 +52,7 @@ func TestClient_QuerySamples(t *testing.T) {
{Name: "instance", Value: "localhost:9091"},
},
},
metricNames: []string{"cpu_usage"},
metricName: "cpu_usage",
subQuery: "SELECT metric_name, fingerprint, unix_milli, value, flags",
expectedTimeSeries: 2,
expectError: false,
@@ -98,10 +97,10 @@ func TestClient_QuerySamples(t *testing.T) {
telemetryStore := telemetrystoretest.New(telemetrystore.Config{Provider: "clickhouse"}, sqlmock.QueryMatcherRegexp)
readClient := client{telemetryStore: telemetryStore}
if tt.setupMock != nil {
tt.setupMock(telemetryStore.Mock(), "cpu_usage", tt.start, tt.end)
tt.setupMock(telemetryStore.Mock(), tt.metricName, tt.start, tt.end)
}
result, err := readClient.querySamples(ctx, tt.subQuery, []any{"cpu_usage", tt.start, tt.end}, tt.fingerprints)
result, err := readClient.querySamples(ctx, tt.start, tt.end, tt.fingerprints, tt.metricName, tt.subQuery, tt.args)
if tt.expectError {
assert.Error(t, err)
@@ -116,6 +115,101 @@ func TestClient_QuerySamples(t *testing.T) {
}
}
func TestClient_getFingerprintsFromClickhouseQuery(t *testing.T) {
cols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "labels", Type: "String"},
}
sortLabels := func(ls []prompb.Label) {
sort.Slice(ls, func(i, j int) bool {
if ls[i].Name == ls[j].Name {
return ls[i].Value < ls[j].Value
}
return ls[i].Name < ls[j].Name
})
}
tests := []struct {
name string
start, end int64
metricName string
subQuery string
args []any
setupMock func(m cmock.ClickConnMockCommon, args ...any)
want map[uint64][]prompb.Label
wantErr bool
}{
{
name: "happy-path - two fingerprints",
start: 1000,
end: 2000,
metricName: "cpu_usage",
subQuery: `SELECT fingerprint,labels`,
// args slice is empty here, but testcase still owns it
args: []any{},
setupMock: func(m cmock.ClickConnMockCommon, args ...any) {
rows := [][]any{
{uint64(123), `{"t1":"s1","t2":"s2"}`},
{uint64(234), `{"t1":"s1","t2":"s2","empty":""}`},
}
m.ExpectQuery(`SELECT fingerprint,labels`).WithArgs(args...).WillReturnRows(
cmock.NewRows(cols, rows),
)
},
// No synthetic fingerprint label (#8563), empty-valued labels
// dropped: both fingerprints present one labelset for
// querySamples to merge.
want: map[uint64][]prompb.Label{
123: {
{Name: "t1", Value: "s1"},
{Name: "t2", Value: "s2"},
},
234: {
{Name: "t1", Value: "s1"},
{Name: "t2", Value: "s2"},
},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
store := telemetrystoretest.New(
telemetrystore.Config{Provider: "clickhouse"},
sqlmock.QueryMatcherRegexp,
)
if tc.setupMock != nil {
tc.setupMock(store.Mock(), tc.args...)
}
c := client{telemetryStore: store}
got, err := c.getFingerprintsFromClickhouseQuery(ctx, tc.subQuery, tc.args)
if tc.wantErr {
require.Error(t, err)
require.Nil(t, got)
return
}
require.NoError(t, err)
require.Equal(t, len(tc.want), len(got), "fingerprint map length mismatch")
for fp, expLabels := range tc.want {
gotLabels, ok := got[fp]
require.Truef(t, ok, "missing fingerprint %d", fp)
sortLabels(expLabels)
sortLabels(gotLabels)
assert.Equalf(t, expLabels, gotLabels, "labels mismatch for fingerprint %d", fp)
}
})
}
}
// Regression for the duplicate-series class behind #8563: fingerprints
// sharing one labelset must come back as one merged series, the higher
// fingerprint winning equal timestamps.
@@ -157,7 +251,7 @@ func TestClient_QuerySamplesMergesIdenticalLabelSets(t *testing.T) {
WillReturnRows(cmock.NewRows(cols, values))
readClient := client{telemetryStore: telemetryStore}
result, err := readClient.querySamples(ctx, "SELECT metric_name, fingerprint, unix_milli, value, flags", []any{"requests", int64(1000), int64(3000)}, fingerprints)
result, err := readClient.querySamples(ctx, 1000, 3000, fingerprints, "requests", "SELECT metric_name, fingerprint, unix_milli, value, flags", nil)
require.NoError(t, err)
assert.Equal(t, []*prompb.TimeSeries{
@@ -178,188 +272,6 @@ func TestClient_QuerySamplesMergesIdenticalLabelSets(t *testing.T) {
}, result)
}
func TestClient_getFingerprintsFromClickhouseQuery(t *testing.T) {
cols := []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "labels", Type: "String"},
}
sortLabels := func(ls []prompb.Label) {
sort.Slice(ls, func(i, j int) bool {
if ls[i].Name == ls[j].Name {
return ls[i].Value < ls[j].Value
}
return ls[i].Name < ls[j].Name
})
}
tests := []struct {
name string
start, end int64
metricName string
subQuery string
args []any
setupMock func(m cmock.ClickConnMockCommon, args ...any)
want map[uint64][]prompb.Label
wantNames []string
wantErr bool
}{
{
name: "happy-path - two fingerprints",
start: 1000,
end: 2000,
metricName: "cpu_usage",
subQuery: `SELECT fingerprint,labels`,
// args slice is empty here, but testcase still owns it
args: []any{},
setupMock: func(m cmock.ClickConnMockCommon, args ...any) {
rows := [][]any{
{uint64(123), `{"__name__":"cpu_usage","t1":"s1","t2":"s2"}`},
{uint64(234), `{"__name__":"cpu_usage","t1":"s1","t2":"s2","empty":""}`},
}
m.ExpectQuery(`SELECT fingerprint,labels`).WithArgs(args...).WillReturnRows(
cmock.NewRows(cols, rows),
)
},
// No synthetic fingerprint label (#8563), empty-valued labels
// dropped: both fingerprints present one labelset for
// querySamples to merge.
want: map[uint64][]prompb.Label{
123: {
{Name: "__name__", Value: "cpu_usage"},
{Name: "t1", Value: "s1"},
{Name: "t2", Value: "s2"},
},
234: {
{Name: "__name__", Value: "cpu_usage"},
{Name: "t1", Value: "s1"},
{Name: "t2", Value: "s2"},
},
},
wantNames: []string{"cpu_usage"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
store := telemetrystoretest.New(
telemetrystore.Config{Provider: "clickhouse"},
sqlmock.QueryMatcherRegexp,
)
if tc.setupMock != nil {
tc.setupMock(store.Mock(), tc.args...)
}
c := client{telemetryStore: store}
got, gotNames, err := c.getFingerprintsFromClickhouseQuery(ctx, tc.subQuery, tc.args)
if tc.wantErr {
require.Error(t, err)
require.Nil(t, got)
return
}
require.NoError(t, err)
assert.Equal(t, tc.wantNames, gotNames, "discovered metric names mismatch")
require.Equal(t, len(tc.want), len(got), "fingerprint map length mismatch")
for fp, expLabels := range tc.want {
gotLabels, ok := got[fp]
require.Truef(t, ok, "missing fingerprint %d", fp)
sortLabels(expLabels)
sortLabels(gotLabels)
assert.Equalf(t, expLabels, gotLabels, "labels mismatch for fingerprint %d", fp)
}
})
}
}
// Regression for nameless/regex-name selectors silently returning empty:
// the old code reduced every __name__ matcher to `metric_name = <value>`
// (empty string when absent). Regexes must come out anchored — Prometheus
// matcher semantics, while ClickHouse match() substring-matches.
func TestQueryToClickhouseQueryNameMatchers(t *testing.T) {
query := func(matchers ...*prompb.LabelMatcher) *prompb.Query {
return &prompb.Query{StartTimestampMs: 0, EndTimestampMs: 1000, Matchers: matchers}
}
tests := []struct {
name string
query *prompb.Query
contains []string
absent []string
args []any
}{
{
name: "exact name",
query: query(&prompb.LabelMatcher{Type: prompb.LabelMatcher_EQ, Name: "__name__", Value: "cpu_usage"}),
contains: []string{"metric_name = ?"},
args: []any{"cpu_usage"},
},
{
name: "regex name is anchored",
query: query(&prompb.LabelMatcher{Type: prompb.LabelMatcher_RE, Name: "__name__", Value: ".+"}),
contains: []string{"match(metric_name, ?)"},
args: []any{"^(?:.+)$"},
},
{
name: "nameless selector has no metric_name condition",
query: query(
&prompb.LabelMatcher{Type: prompb.LabelMatcher_EQ, Name: "job", Value: "api"},
&prompb.LabelMatcher{Type: prompb.LabelMatcher_NRE, Name: "group", Value: "can.*"},
),
contains: []string{
"JSONExtractString(labels, ?) = ?",
"not match(JSONExtractString(labels, ?), ?)",
},
absent: []string{"metric_name"},
args: []any{"job", "api", "group", "^(?:can.*)$"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lookup, err := seriesLookupQuery(tt.query, false)
require.NoError(t, err)
sql, args := lookup.BuildWithFlavor(sqlbuilder.ClickHouse)
for _, want := range tt.contains {
assert.Contains(t, sql, want)
}
for _, notWant := range tt.absent {
assert.NotContains(t, sql, notWant)
}
assert.Equal(t, tt.args, args)
})
}
}
// The samples query narrows by the metric names the lookup discovered and
// embeds the series lookup as a subquery, the builder merging its args in
// render order.
func TestBuildSamplesQueryMetricNames(t *testing.T) {
sub := sqlbuilder.NewSelectBuilder()
sub.Select("fingerprint")
sub.From("t")
sub.Where(sub.E("k", "v"))
sql, args := buildSamplesQuery(5, 9, []string{"a_total", "b_total"}, sub)
assert.Contains(t, sql, "metric_name IN (?, ?)")
assert.Contains(t, sql, "fingerprint GLOBAL IN (SELECT fingerprint FROM t WHERE k = ?)")
assert.Contains(t, sql, "unix_milli >= ? AND unix_milli <= ?")
assert.Equal(t, []any{"a_total", "b_total", "v", int64(5), int64(9)}, args)
sub2 := sqlbuilder.NewSelectBuilder()
sub2.Select("fingerprint")
sub2.From("t")
sql, args = buildSamplesQuery(5, 9, nil, sub2)
assert.NotContains(t, sql, "metric_name IN")
assert.Equal(t, []any{int64(5), int64(9)}, args)
}
// Hash grouping must stay order-insensitive (stored JSON key order is not
// canonical across fingerprints), and a 64-bit hash collision between
// distinct labelsets must not merge them — splitByLabelSet is that guard.

View File

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

@@ -42,7 +42,6 @@ type querier struct {
metadataStore telemetrytypes.MetadataStore
promEngine 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]
@@ -62,7 +61,6 @@ func New(
metadataStore telemetrytypes.MetadataStore,
promEngine 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],
@@ -84,7 +82,6 @@ func New(
metadataStore: metadataStore,
promEngine: promEngine,
traceStmtBuilder: traceStmtBuilder,
aiTraceStmtBuilder: aiTraceStmtBuilder,
logStmtBuilder: logStmtBuilder,
auditStmtBuilder: auditStmtBuilder,
metricStmtBuilder: metricStmtBuilder,
@@ -235,16 +232,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]:
@@ -311,11 +298,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:
@@ -878,9 +860,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 statement builder the original query was created with, so an AI
// query keeps its AI builder without re-deriving it from the spec.
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()
@@ -1237,8 +1217,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

@@ -49,7 +49,6 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
metadataStore,
nil, // prometheus
nil, // traceStmtBuilder
nil, // aiTraceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder
@@ -122,7 +121,6 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
metadataStore,
nil, // prometheus
nil, // traceStmtBuilder
nil, // aiTraceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
&mockMetricStmtBuilder{}, // metricStmtBuilder

View File

@@ -9,7 +9,6 @@ import (
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryai"
"github.com/SigNoz/signoz/pkg/telemetryaudit"
"github.com/SigNoz/signoz/pkg/telemetrylogs"
"github.com/SigNoz/signoz/pkg/telemetrymetadata"
@@ -93,17 +92,6 @@ func newProvider(
cfg.SkipResourceFingerprint.Threshold,
)
// AI trace statement builder (builder_ai_query). The gen_ai gate/column keys are
// surfaced by the metadata store itself (enrichWithGenAIKeys), so queries work
// before any gen_ai metadata is ingested — no per-builder decoration needed.
// The standard trace builder doubles as the delegate for the span-list path.
aiTraceStmtBuilder := telemetryai.NewAITraceStatementBuilder(
settings,
telemetryMetadataStore,
traceStmtBuilder,
flagger,
)
// Create trace operator statement builder
traceOperatorStmtBuilder := telemetrytraces.NewTraceOperatorStatementBuilder(
settings,
@@ -193,7 +181,6 @@ func newProvider(
telemetryMetadataStore,
prometheus,
traceStmtBuilder,
aiTraceStmtBuilder,
logStmtBuilder,
auditStmtBuilder,
metricStmtBuilder,

View File

@@ -237,7 +237,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
// Mock the fingerprint query (for Prometheus label matching)
mock.ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(fingerprintRows)
// Mock the samples query (for Prometheus metric data)
@@ -245,6 +245,8 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
WithArgs(
"test_metric",
"test_metric",
"__name__",
"test_metric",
queryStart,
queryEnd,
).

View File

@@ -925,18 +925,20 @@ func TestPromRuleUnitCombinations(t *testing.T) {
}
samplesRows := cmock.NewRows(samplesCols, samplesData)
// args: $1=metric_name (the __name__ matcher maps onto the column)
// args: $1=metric_name, $2=label_name, $3=label_value
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(fingerprintRows)
// args: $1=metric_name IN (discovered names), $2=metric_name (subquery), $3=start, $4=end
// args: $1=metric_name (outer), $2=metric_name (subquery), $3=label_name, $4=label_value, $5=start, $6=end
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs(
"test_metric",
"test_metric",
"__name__",
"test_metric",
queryStart,
queryEnd,
).
@@ -1061,7 +1063,7 @@ func TestPromRuleNoData(t *testing.T) {
// no rows == no data
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(fingerprintRows)
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
@@ -1271,7 +1273,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(fingerprintRows)
telemetryStore.Mock().
@@ -1279,6 +1281,8 @@ func TestMultipleThresholdPromRule(t *testing.T) {
WithArgs(
"test_metric",
"test_metric",
"__name__",
"test_metric",
queryStart,
queryEnd,
).
@@ -1435,12 +1439,12 @@ func TestPromRule_NoData(t *testing.T) {
labelsJSON := `{"__name__":"test_metric"}`
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs("test_metric", "test_metric", queryStart, queryEnd).
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart, queryEnd).
WillReturnRows(cmock.NewRows(samplesCols, [][]any{}))
promProvider := prometheustest.New(
@@ -1571,11 +1575,11 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
queryStart1, queryEnd1 := calcQueryRange(t1)
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs("test_metric", "test_metric", queryStart1, queryEnd1).
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart1, queryEnd1).
WillReturnRows(cmock.NewRows(samplesCols, [][]any{
// Data points in the past relative to t1
{"test_metric", fingerprint, baseTime.UnixMilli(), 100.0, uint32(0)},
@@ -1587,11 +1591,11 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
queryStart2, queryEnd2 := calcQueryRange(t2)
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs("test_metric", "test_metric", queryStart2, queryEnd2).
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart2, queryEnd2).
WillReturnRows(cmock.NewRows(samplesCols, [][]any{})) // empty - no data
promProvider := prometheustest.New(
@@ -1748,11 +1752,11 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
telemetryStore.Mock().
ExpectQuery("SELECT fingerprint, any").
WithArgs("test_metric").
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(cmock.NewRows(fingerprintCols, fingerprintData))
telemetryStore.Mock().
ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
WithArgs("test_metric", "test_metric", queryStart, queryEnd).
WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart, queryEnd).
WillReturnRows(cmock.NewRows(samplesCols, samplesData))
promProvider := prometheustest.New(
context.Background(),

View File

@@ -48,7 +48,6 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
metadataStore,
nil, // prometheus
nil, // traceStmtBuilder
nil, // aiTraceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
metricStmtBuilder,
@@ -102,7 +101,6 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
metadataStore,
nil, // prometheus
nil, // traceStmtBuilder
nil, // aiTraceStmtBuilder
logStmtBuilder, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder
@@ -152,7 +150,6 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
metadataStore,
nil, // prometheus
traceStmtBuilder, // traceStmtBuilder
nil, // aiTraceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder

View File

@@ -181,15 +181,6 @@ func DataTypeCollisionHandledFieldName(key *telemetrytypes.TelemetryFieldKey, va
// dynamic array elements will be default casted to string
tblFieldName, value = castString(tblFieldName), toStrings(v)
}
case telemetrytypes.FieldDataTypeUnspecified:
if operator == qbtypes.FilterOperatorUnknown {
switch value.(type) {
case float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, json.Number:
tblFieldName = accurateCastFloat(tblFieldName)
case string:
tblFieldName = castString(tblFieldName)
}
}
}
return tblFieldName, value
}

View File

@@ -1,154 +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 single filter expression into a span-level
// part (a WHERE over spans) and a trace-level part (a HAVING over per-trace
// aggregates), splitting on the top-level AND.
//
// A key is trace-level when it carries the trace field context (`trace.completion_tokens`)
// or, with no context, its bare name is in aggregateNames. Any other explicit context
// (`span.`, `resource.`, …) is span-level. Trace-level and span-level keys may be
// AND-combined (they run at different query stages) but not OR-combined; an OR that
// mixes the two is an error.
//
// Syntax errors are ignored here — each part is re-parsed downstream (PrepareWhereClause
// for the span part, the HAVING rewriter for the trace part), which surface them.
func SplitFilterForAggregates(query string, aggregateNames map[string]struct{}) (spanExpr string, havingExpr string, err error) {
if strings.TrimSpace(query) == "" {
return "", "", nil
}
s := filterSplitter{query: []rune(query), aggregateNames: aggregateNames}
s.visit(parseFilterQuery(query))
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 {
lexer := grammar.NewFilterQueryLexer(antlr.NewInputStream(query))
lexer.RemoveErrorListeners()
parser := grammar.NewFilterQueryParser(antlr.NewCommonTokenStream(lexer, 0))
parser.RemoveErrorListeners()
return parser.Query()
}
// filterSplitter walks the parse tree once, flattening the top-level AND chain and
// routing each atom (a comparison, a NOT expression, or a whole multi-branch OR group)
// 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 single branch is just an AND chain; multiple branches are a real OR, 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)
// A multi-branch OR group's source slice excludes its enclosing parens (they belong
// to the parent Primary). Re-wrap it so rejoining a bucket with " AND " cannot invert
// OR/AND precedence, e.g. `a AND (b OR c)` must not flatten to `a AND b OR c`.
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.
// A key is trace-level when it carries the trace field context or, with no context,
// its name is a known aggregate; an unknown name under the trace context stays
// trace-level so the aggregate validation rejects it with a targeted error. Any other
// explicit context (`span.`, `resource.`, …) is span-level.
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 returns the original source substring for an atom, preserving
// whitespace. The token stream drops skipped whitespace, which would glue word
// operators (OR/AND/NOT) to their operands, so slice the input by token offsets.
// ANTLR offsets are rune indices (InputStream holds []rune), 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,167 +0,0 @@
package querybuilder
import (
"testing"
"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",
},
{
// an unknown name under the trace context still routes trace-level, so the
// aggregate validation rejects it with a targeted error instead of the span
// path failing on an unknown field.
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 (this used to truncate 1000 → 100).
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')",
},
// --- 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,
},
}
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)
require.Equal(t, c.span, span, "span part")
require.Equal(t, c.having, having, "having part")
})
}
}

View File

@@ -18,19 +18,6 @@ func NewHavingExpressionRewriter() *HavingExpressionRewriter {
}
}
// Rewrite rewrites and validates a HAVING expression against a caller-supplied
// column map (user-facing name -> SQL identifier/expression). Values are inlined, so
// the result is a bare SQL boolean expression with no bound args. Used by callers
// that project their own aggregate columns (e.g. the AI trace list) rather than the
// query's Aggregations.
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

@@ -3,6 +3,7 @@ package querybuilder
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
@@ -13,8 +14,38 @@ import (
"github.com/tidwall/gjson"
)
var telemetryGrantKeys = map[string]struct{}{
"service.name": {},
}
const telemetryValueSafeBytes = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-"
func EscapeTelemetryValue(value string) string {
var escaped strings.Builder
for _, character := range []byte(value) {
if strings.IndexByte(telemetryValueSafeBytes, character) >= 0 {
escaped.WriteByte(character)
continue
}
escaped.WriteString(fmt.Sprintf("%%%02X", character))
}
return escaped.String()
}
func TelemetrySelector(_ context.Context, resource coretypes.Resource, id string, _ valuer.UUID) ([]coretypes.Selector, error) {
values := telemetrytypes.NewTelemetryGrantSelectors(id)
values := []string{id}
segments := strings.Split(id, "/")
for level := len(segments) - 1; level >= 1; level-- {
value := strings.Join(segments[:level], "/") + "/" + coretypes.WildCardSelectorString
if value == id {
continue
}
values = append(values, value)
}
if id != coretypes.WildCardSelectorString {
values = append(values, coretypes.WildCardSelectorString)
}
selectors := make([]coretypes.Selector, 0, len(values))
for _, value := range values {
@@ -82,10 +113,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():
// An AI builder query is always a traces query; the signal is implied by the
// type (and may be absent from the payload), so pin the resource directly.
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():
@@ -107,10 +134,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
@@ -163,14 +187,14 @@ func builderQuerySelectors(queryType, expression string, variables map[string]qb
continue
}
key, ok := telemetrytypes.NewTelemetryGrantKey(condition.Key)
key, ok := canonicalTelemetryGrantKey(condition.Key)
if !ok {
continue
}
if condition.Operator == "=" || condition.Operator == "IN" {
for _, value := range condition.Values {
ids = append(ids, queryType+"/"+key+"/"+value)
ids = append(ids, queryType+"/"+key+"/"+EscapeTelemetryValue(value))
}
}
}
@@ -181,3 +205,16 @@ func builderQuerySelectors(queryType, expression string, variables map[string]qb
return ids, nil
}
func canonicalTelemetryGrantKey(keyText string) (string, bool) {
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(keyText)
if fieldKey.FieldContext != telemetrytypes.FieldContextUnspecified && fieldKey.FieldContext != telemetrytypes.FieldContextResource {
return "", false
}
if _, ok := telemetryGrantKeys[fieldKey.Name]; !ok {
return "", false
}
return fieldKey.Name, true
}

View File

@@ -2,6 +2,7 @@ package querybuilder
import (
"context"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/coretypes"
@@ -21,33 +22,33 @@ func TestQueryRangeResources(t *testing.T) {
expected []coretypes.ResourceWithID
}{
{
name: "top level key equality",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'checkout' AND status = 500"),
name: "top level service equality",
body: builderQueryBody("logs", "service.name = 'checkout' AND status = 500"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/checkout"},
},
},
{
name: "resource prefixed key",
body: builderQueryBody("traces", "resource.signoz.workspace.key.id = 'checkout'"),
name: "resource prefixed service key",
body: builderQueryBody("traces", "resource.service.name = 'checkout'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/service.name/checkout"},
},
},
{
name: "in atom requires every value",
body: builderQueryBody("logs", "signoz.workspace.key.id IN ('b', 'a')"),
body: builderQueryBody("logs", "service.name IN ('b', 'a')"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/b"},
},
},
{
name: "multiple equality atoms each require a grant",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'b' AND signoz.workspace.key.id = 'a'"),
body: builderQueryBody("logs", "service.name = 'b' AND service.name = 'a'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/b"},
},
},
{
@@ -58,52 +59,38 @@ func TestQueryRangeResources(t *testing.T) {
},
},
{
name: "key atom under or does not qualify",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'a' OR status = 500"),
name: "service atom under or does not qualify",
body: builderQueryBody("logs", "service.name = 'a' OR status = 500"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "negated key atom does not qualify",
body: builderQueryBody("logs", "NOT signoz.workspace.key.id = 'a'"),
name: "negated service atom does not qualify",
body: builderQueryBody("logs", "NOT service.name = 'a'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "key inequality does not qualify",
body: builderQueryBody("logs", "signoz.workspace.key.id != 'a'"),
name: "service inequality does not qualify",
body: builderQueryBody("logs", "service.name != 'a'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "value with spaces and slashes stays plaintext in the id",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'check out/2'"),
name: "unsafe value bytes are escaped",
body: builderQueryBody("logs", "service.name = 'check out/2'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/check out/2"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/check%20out%2F2"},
},
},
{
name: "audit source maps to audit logs resource",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit","filter":{"expression":"signoz.workspace.key.id = 'a'"}}}]}}`,
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit","filter":{"expression":"service.name = 'a'"}}}]}}`,
expected: []coretypes.ResourceWithID{
{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/*"},
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/service.name/a"},
},
},
{
@@ -130,23 +117,23 @@ func TestQueryRangeResources(t *testing.T) {
},
{
name: "trace operator rides on its referenced queries",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"name":"A","signal":"traces","disabled":true,"filter":{"expression":"signoz.workspace.key.id = 'checkout'"}}},{"type":"builder_query","spec":{"name":"B","signal":"traces","disabled":true,"filter":{"expression":"signoz.workspace.key.id = 'checkout' AND has_error = true"}}},{"type":"builder_trace_operator","spec":{"name":"T1","expression":"A => B","returnSpansFrom":"A"}}]}}`,
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"name":"A","signal":"traces","disabled":true,"filter":{"expression":"service.name = 'checkout'"}}},{"type":"builder_query","spec":{"name":"B","signal":"traces","disabled":true,"filter":{"expression":"service.name = 'checkout' AND has_error = true"}}},{"type":"builder_trace_operator","spec":{"name":"T1","expression":"A => B","returnSpansFrom":"A"}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/service.name/checkout"},
},
},
{
name: "variable substitution qualifies",
body: `{"variables":{"key":{"value":"checkout"}},"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = $key"}}}]}}`,
body: `{"variables":{"svc":{"value":"checkout"}},"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name = $svc"}}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/checkout"},
},
},
{
name: "duplicate queries dedupe",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = 'a'"}}},{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id='a'"}}}]}}`,
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name = 'a'"}}},{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name='a'"}}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
},
},
}
@@ -164,7 +151,7 @@ func TestQueryRangeResourcesErrors(t *testing.T) {
bodies := []string{
`{"compositeQuery":{"queries":[]}}`,
`{}`,
builderQueryBody("logs", "signoz.workspace.key.id = "),
builderQueryBody("logs", "service.name = "),
`{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"unknown"}}]}}`,
`{"compositeQuery":{"queries":[{"type":"unknown_type"}]}}`,
}
@@ -188,10 +175,10 @@ func TestTelemetrySelector(t *testing.T) {
return values
}
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a"))
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query"))
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql"))
assert.Equal(t, []string{"*"}, selectorValues("*"))
// a value containing "/" stays one logical segment (SplitN 3).
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a/b", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a/b"))
assert.Equal(t, []string{"builder_query/service.name/a", "builder_query/service.name/*", "builder_query/*", "*"}, selectorValues("builder_query/service.name/a"))
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query/*"))
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql/*"))
_, err := TelemetrySelector(context.Background(), coretypes.ResourceTelemetryResourceLogs, strings.Repeat("a", 256), orgID)
assert.Error(t, err)
}

View File

@@ -149,7 +149,7 @@ func NewModules(
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier),
AuthDomain: authDomainModule,
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz, config.Global),
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),

View File

@@ -1,100 +0,0 @@
package telemetryai
import (
"strings"
scopedtraces "github.com/SigNoz/signoz/pkg/telemetryscopedtraces"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// genAIBaseConditionProvider: an AI trace has >=1 gen_ai LLM, tool, or agent span.
type genAIBaseConditionProvider struct {
keys []string
}
var _ scopedtraces.BaseConditionProvider = (*genAIBaseConditionProvider)(nil)
func newGenAIBaseConditionProvider() scopedtraces.BaseConditionProvider {
return &genAIBaseConditionProvider{
keys: []string{telemetrytypes.GenAIRequestModel, telemetrytypes.GenAIToolName, telemetrytypes.GenAIAgentName},
}
}
func (p *genAIBaseConditionProvider) FilterExpression() string {
parts := make([]string, 0, len(p.keys))
for _, k := range p.keys {
parts = append(parts, k+" EXISTS")
}
return strings.Join(parts, " OR ")
}
func (p *genAIBaseConditionProvider) FieldKeys() []*telemetrytypes.TelemetryFieldKey {
keys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(p.keys))
for _, name := range p.keys {
keys = append(keys, &telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextAttribute,
})
}
return keys
}
// genAIColumnProvider adds AI/LLM per-trace metrics on top of the common columns.
type genAIColumnProvider struct{}
var _ scopedtraces.ColumnProvider = (*genAIColumnProvider)(nil)
func newGenAIColumnProvider() scopedtraces.ColumnProvider {
return &genAIColumnProvider{}
}
func (genAIColumnProvider) Columns() []scopedtraces.TraceColumn {
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
return 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_cost_usd", Orderable: true, Expr: scopedtraces.Reduce(scopedtraces.AggSum, &cost)},
// slowest single LLM call in the trace.
scopedtraces.TraceColumn{Alias: "max_llm_latency_ns", 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)},
)
}
func (genAIColumnProvider) DefaultOrderAlias() string { return "last_activity_time" }
func (p genAIColumnProvider) AggregateAliases() []string {
// Derived from Columns() so a new column can't be forgotten; SpanLevel columns
// are filtered span-level, so skip them.
cols := p.Columns()
aliases := make([]string, 0, len(cols))
for _, c := range cols {
if !c.SpanLevel {
aliases = append(aliases, c.Alias)
}
}
return aliases
}

View File

@@ -1,21 +0,0 @@
package telemetryai
import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
scopedtraces "github.com/SigNoz/signoz/pkg/telemetryscopedtraces"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// NewAITraceStatementBuilder wires the generic scoped-trace builder with the gen_ai
// gate and AI columns. This package holds only gen_ai domain knowledge; the query
// topology lives in telemetryscopedtraces.
func NewAITraceStatementBuilder(
settings factory.ProviderSettings,
metadataStore telemetrytypes.MetadataStore,
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
fl flagger.Flagger,
) qbtypes.StatementBuilder[qbtypes.TraceAggregation] {
return scopedtraces.NewScopedTraceStatementBuilder(settings, metadataStore, newGenAIBaseConditionProvider(), newGenAIColumnProvider(), traceStmtBuilder, fl)
}

View File

@@ -1,987 +0,0 @@
package telemetryai
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/querybuilder"
scopedtraces "github.com/SigNoz/signoz/pkg/telemetryscopedtraces"
"github.com/SigNoz/signoz/pkg/telemetrytraces"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
// otelKeysMap seeds the OpenTelemetry gen_ai semantic-convention keys the AI
// queries reference, so the metadata-backed field resolution succeeds in tests.
func otelKeysMap() map[string][]*telemetrytypes.TelemetryFieldKey {
strKey := func(name string) *telemetrytypes.TelemetryFieldKey {
return &telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
}
}
numKey := func(name string) *telemetrytypes.TelemetryFieldKey {
return &telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
}
}
m := make(map[string][]*telemetrytypes.TelemetryFieldKey)
// gen_ai semconv keys sourced from the single source of truth, mirroring what the
// production metadata store surfaces via enrichWithGenAIKeys.
for name, def := range telemetrytypes.GenAIFieldDefinitions {
keyCopy := def
m[name] = []*telemetrytypes.TelemetryFieldKey{&keyCopy}
}
// Extra keys these tests reference that aren't gen_ai semconv definitions.
m["gen_ai.user.id"] = []*telemetrytypes.TelemetryFieldKey{strKey("gen_ai.user.id")}
m["_signoz.gen_ai.total_cost"] = []*telemetrytypes.TelemetryFieldKey{numKey("_signoz.gen_ai.total_cost")}
m["gen_ai.usage.cached_input_tokens"] = []*telemetrytypes.TelemetryFieldKey{numKey("gen_ai.usage.cached_input_tokens")}
m["has_error"] = []*telemetrytypes.TelemetryFieldKey{{
Name: "has_error",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeBool,
}}
// service.name carries the resource-column evolutions like production metadata, so
// the rendered value expression prefers the JSON resource column over the legacy
// map (matching the standard traces builder tests).
m["service.name"] = []*telemetrytypes.TelemetryFieldKey{{
Name: "service.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: resourceEvolutions(),
}}
return m
}
// resourceEvolutions is the canonical resource-column timeline: the legacy
// resources_string map at epoch 0 and the JSON resource column released inside the
// test window (mirrors telemetrytraces' mockEvolutionData).
func resourceEvolutions() []*telemetrytypes.EvolutionEntry {
return []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalTraces,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalTraces,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC),
},
}
}
// standard test window (ms), matching the traces builder tests.
const (
testStartMs = uint64(1747947419000)
testEndMs = uint64(1747983448000)
)
func newTestBuilder(t *testing.T) qbtypes.StatementBuilder[qbtypes.TraceAggregation] {
return newTestBuilderWithKeys(t, otelKeysMap())
}
// newTestBuilderWithKeys mirrors the production wiring in signozquerier's provider.
// The gen_ai keys are seeded via keysMap here; in production the metadata store
// surfaces them itself (enrichWithGenAIKeys).
func newTestBuilderWithKeys(t *testing.T, keysMap map[string][]*telemetrytypes.TelemetryFieldKey) qbtypes.StatementBuilder[qbtypes.TraceAggregation] {
t.Helper()
settings := instrumentationtest.New().ToProviderSettings()
fm := telemetrytraces.NewFieldMapper()
cb := telemetrytraces.NewConditionBuilder(fm)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = keysMap
fl := flaggertest.New(t)
// In production the metadata store enriches gen_ai keys (enrichWithGenAIKeys);
// here the mock is seeded directly via keysMap.
metadataStore := telemetrytypes.MetadataStore(mockMetadataStore)
rewriter := querybuilder.NewAggExprRewriter(settings, nil, fm, cb, fl)
traceStmtBuilder := telemetrytraces.NewTraceQueryStatementBuilder(
settings,
metadataStore,
fm,
cb,
rewriter,
nil,
fl,
false,
100000,
)
return NewAITraceStatementBuilder(
settings,
metadataStore,
traceStmtBuilder,
fl,
)
}
// ---------------------------------------------------------------------------
// Full-query golden tests
//
// Each pins the WHOLE generated statement, with bound args inlined into the `?`
// placeholders, as ONE self-contained literal — so a failure diff shows the entire
// query and the expected SQL can be copied straight into a ClickHouse client. The
// `want` strings are formatted for readability; the comparison is whitespace- and
// backtick-insensitive (see normalizeSQL), so only the SQL tokens themselves matter.
//
// The four trace-list goldens cover the corners of how `matched` is assembled —
// {no span filter, span filter} × {no aggregate filter, aggregate filter} — plus a
// mixed filter + multi-key order, plus the delegated span list. Note `matched` selects
// only the aggregates ORDER BY / HAVING reference; the rest appear only in enrichment.
//
// Run `go test ./pkg/telemetryai/ -run TestBuild_FullSQL -v` to also print each query.
// ---------------------------------------------------------------------------
// renderSQL substitutes bound args into the `?` placeholders so the whole statement
// reads as one literal SQL string.
func renderSQL(t *testing.T, stmt *qbtypes.Statement) string {
t.Helper()
var b strings.Builder
argi := 0
for i := 0; i < len(stmt.Query); i++ {
if stmt.Query[i] == '?' {
require.Less(t, argi, len(stmt.Args), "more ? than args in query")
b.WriteString(formatArg(stmt.Args[argi]))
argi++
continue
}
b.WriteByte(stmt.Query[i])
}
require.Equal(t, len(stmt.Args), argi, "arg count does not match number of placeholders")
return b.String()
}
func formatArg(a any) string {
if s, ok := a.(string); ok {
return "'" + s + "'"
}
return fmt.Sprintf("%v", a)
}
// normalizeSQL makes the comparison insensitive to formatting: it drops identifier
// backticks, collapses whitespace runs to a single space, and removes spaces directly
// inside parentheses. This lets the golden strings be freely indented/wrapped (and
// written as Go raw literals, which cannot contain backticks) — only the SQL tokens
// and their order matter.
func normalizeSQL(s string) string {
s = strings.Join(strings.Fields(strings.ReplaceAll(s, "`", "")), " ")
s = strings.ReplaceAll(s, "( ", "(")
s = strings.ReplaceAll(s, " )", ")")
return s
}
func requireSQLEqual(t *testing.T, want string, stmt *qbtypes.Statement) {
t.Helper()
got := renderSQL(t, stmt)
t.Logf("\n%s", got)
require.Equal(t, normalizeSQL(want), normalizeSQL(got))
}
// No filter: matched selects only the default order key (last_activity_time), WHERE is
// just window + gate mask, no HAVING.
func TestBuild_FullSQL_TraceList_NoFilter(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces, Limit: 20,
}, nil)
require.NoError(t, err)
requireSQLEqual(t, `
WITH matched AS (
SELECT trace_id,
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) AS last_activity_time
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND ((mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name')))
GROUP BY trace_id
ORDER BY last_activity_time DESC, trace_id DESC
LIMIT 20
),
ranked AS (
SELECT trace_id, min(start) AS t_start, max(end) AS t_end
FROM signoz_traces.distributed_trace_summary
WHERE trace_id GLOBAL IN (SELECT trace_id FROM matched)
AND end >= fromUnixTimestamp64Nano(1747947419000000000)
AND start < fromUnixTimestamp64Nano(1747983448000000000)
GROUP BY trace_id
),
buckets AS (
SELECT DISTINCT b AS ts_bucket
FROM ranked
ARRAY JOIN range(toUInt64(intDiv(toUnixTimestamp(t_start), 1800) * 1800 - 1800), toUInt64(intDiv(toUnixTimestamp(t_end), 1800) * 1800 + 1800), 1800) AS b
)
SELECT trace_id,
min(timestamp) AS start_time,
max(timestamp) AS end_time,
(max(toUnixTimestamp64Nano(timestamp) + duration_nano) - min(toUnixTimestamp64Nano(timestamp))) AS trace_duration_nano,
count() AS span_count,
anyIf(name, parent_span_id = '') AS root_span_name,
any(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,
countIf(mapContains(attributes_string, 'gen_ai.request.model')) AS llm_call_count,
countIf(mapContains(attributes_string, 'gen_ai.tool.name')) AS tool_call_count,
uniqIf(multiIf(mapContains(attributes_string, 'gen_ai.tool.name'), attributes_string['gen_ai.tool.name'], NULL), mapContains(attributes_string, 'gen_ai.tool.name')) AS distinct_tool_count,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens'), toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)) AS input_tokens,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens'), toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)), 0) + coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)), 0) AS total_tokens,
sum(multiIf(mapContains(attributes_number, '_signoz.gen_ai.total_cost'), toFloat64(attributes_number['_signoz.gen_ai.total_cost']), NULL)) AS estimated_cost_usd,
maxIf(duration_nano, mapContains(attributes_string, 'gen_ai.request.model')) AS max_llm_latency_ns,
countIf(has_error = true) AS error_count,
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) AS last_activity_time,
argMinIf(multiIf(mapContains(attributes_string, 'gen_ai.input.messages'), attributes_string['gen_ai.input.messages'], NULL), timestamp, mapContains(attributes_string, 'gen_ai.input.messages')) AS input,
argMaxIf(multiIf(mapContains(attributes_string, 'gen_ai.output.messages'), attributes_string['gen_ai.output.messages'], NULL), timestamp, mapContains(attributes_string, 'gen_ai.output.messages')) AS output
FROM signoz_traces.distributed_signoz_index_v3
WHERE ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)
AND trace_id GLOBAL IN (SELECT trace_id FROM ranked)
GROUP BY trace_id
ORDER BY last_activity_time DESC, trace_id DESC
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
`, stmt)
}
// Promotion: a materialized gen_ai attribute must resolve to its materialized column
// everywhere it appears — gate mask, countIf/scoped existence, and value columns —
// while un-promoted attributes stay in the attributes map, so one query mixes both
// forms. Here gen_ai.request.model and gen_ai.usage.input_tokens are materialized:
// the gate/llm_call_count/max_llm_latency use `..._exists`, input_tokens/total_tokens
// use the materialized value column, and tool/output_tokens/cost/messages stay in the map.
func TestBuild_FullSQL_TraceList_MaterializedColumns(t *testing.T) {
keys := otelKeysMap()
for _, name := range []string{"gen_ai.request.model", "gen_ai.usage.input_tokens"} {
for _, k := range keys[name] {
k.Materialized = true
}
}
b := newTestBuilderWithKeys(t, keys)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces, Limit: 20,
}, nil)
require.NoError(t, err)
requireSQLEqual(t, `
WITH matched AS (
SELECT trace_id,
maxIf(timestamp, (attribute_string_gen_ai$$request$$model_exists OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) AS last_activity_time
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND ((attribute_string_gen_ai$$request$$model_exists OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name')))
GROUP BY trace_id
ORDER BY last_activity_time DESC, trace_id DESC
LIMIT 20
),
ranked AS (
SELECT trace_id, min(start) AS t_start, max(end) AS t_end
FROM signoz_traces.distributed_trace_summary
WHERE trace_id GLOBAL IN (SELECT trace_id FROM matched)
AND end >= fromUnixTimestamp64Nano(1747947419000000000)
AND start < fromUnixTimestamp64Nano(1747983448000000000)
GROUP BY trace_id
),
buckets AS (
SELECT DISTINCT b AS ts_bucket
FROM ranked
ARRAY JOIN range(toUInt64(intDiv(toUnixTimestamp(t_start), 1800) * 1800 - 1800), toUInt64(intDiv(toUnixTimestamp(t_end), 1800) * 1800 + 1800), 1800) AS b
)
SELECT trace_id,
min(timestamp) AS start_time,
max(timestamp) AS end_time,
(max(toUnixTimestamp64Nano(timestamp) + duration_nano) - min(toUnixTimestamp64Nano(timestamp))) AS trace_duration_nano,
count() AS span_count,
anyIf(name, parent_span_id = '') AS root_span_name,
any(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,
countIf(attribute_string_gen_ai$$request$$model_exists) AS llm_call_count,
countIf(mapContains(attributes_string, 'gen_ai.tool.name')) AS tool_call_count,
uniqIf(multiIf(mapContains(attributes_string, 'gen_ai.tool.name'), attributes_string['gen_ai.tool.name'], NULL), mapContains(attributes_string, 'gen_ai.tool.name')) AS distinct_tool_count,
sum(multiIf(attribute_number_gen_ai$$usage$$input_tokens_exists, toFloat64(attribute_number_gen_ai$$usage$$input_tokens), NULL)) AS input_tokens,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
coalesce(sum(multiIf(attribute_number_gen_ai$$usage$$input_tokens_exists, toFloat64(attribute_number_gen_ai$$usage$$input_tokens), NULL)), 0) + coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)), 0) AS total_tokens,
sum(multiIf(mapContains(attributes_number, '_signoz.gen_ai.total_cost'), toFloat64(attributes_number['_signoz.gen_ai.total_cost']), NULL)) AS estimated_cost_usd,
maxIf(duration_nano, attribute_string_gen_ai$$request$$model_exists) AS max_llm_latency_ns,
countIf(has_error = true) AS error_count,
maxIf(timestamp, (attribute_string_gen_ai$$request$$model_exists OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) AS last_activity_time,
argMinIf(multiIf(mapContains(attributes_string, 'gen_ai.input.messages'), attributes_string['gen_ai.input.messages'], NULL), timestamp, mapContains(attributes_string, 'gen_ai.input.messages')) AS input,
argMaxIf(multiIf(mapContains(attributes_string, 'gen_ai.output.messages'), attributes_string['gen_ai.output.messages'], NULL), timestamp, mapContains(attributes_string, 'gen_ai.output.messages')) AS output
FROM signoz_traces.distributed_signoz_index_v3
WHERE ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)
AND trace_id GLOBAL IN (SELECT trace_id FROM ranked)
GROUP BY trace_id
ORDER BY last_activity_time DESC, trace_id DESC
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
`, stmt)
}
// Span-level AND trace-level filter, order by the aggregate, pagination. matched selects
// only output_tokens (the sole aggregate referenced by both ORDER BY and HAVING) — not
// input_tokens/llm_call_count/last_activity_time. The span predicate widens the WHERE
// prune and becomes a countIf(...) > 0 existence check alongside the gate countIf.
func TestBuild_FullSQL_TraceList_SpanAndTraceFilter(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model = 'gpt-4o-mini' AND output_tokens > 1000"},
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "output_tokens"}}, Direction: qbtypes.OrderDirectionDesc}},
Limit: 10, Offset: 30,
}, nil)
require.NoError(t, err)
requireSQLEqual(t, `
WITH matched AS (
SELECT trace_id,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND ((mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
OR (attributes_string['gen_ai.request.model'] = 'gpt-4o-mini' AND mapContains(attributes_string, 'gen_ai.request.model')))
GROUP BY trace_id
HAVING countIf((mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) > 0
AND countIf((attributes_string['gen_ai.request.model'] = 'gpt-4o-mini' AND mapContains(attributes_string, 'gen_ai.request.model'))) > 0
AND output_tokens > 1000
ORDER BY output_tokens DESC, trace_id DESC
LIMIT 10 OFFSET 30
),
ranked AS (
SELECT trace_id, min(start) AS t_start, max(end) AS t_end
FROM signoz_traces.distributed_trace_summary
WHERE trace_id GLOBAL IN (SELECT trace_id FROM matched)
AND end >= fromUnixTimestamp64Nano(1747947419000000000)
AND start < fromUnixTimestamp64Nano(1747983448000000000)
GROUP BY trace_id
),
buckets AS (
SELECT DISTINCT b AS ts_bucket
FROM ranked
ARRAY JOIN range(toUInt64(intDiv(toUnixTimestamp(t_start), 1800) * 1800 - 1800), toUInt64(intDiv(toUnixTimestamp(t_end), 1800) * 1800 + 1800), 1800) AS b
)
SELECT trace_id,
min(timestamp) AS start_time,
max(timestamp) AS end_time,
(max(toUnixTimestamp64Nano(timestamp) + duration_nano) - min(toUnixTimestamp64Nano(timestamp))) AS trace_duration_nano,
count() AS span_count,
anyIf(name, parent_span_id = '') AS root_span_name,
any(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,
countIf(mapContains(attributes_string, 'gen_ai.request.model')) AS llm_call_count,
countIf(mapContains(attributes_string, 'gen_ai.tool.name')) AS tool_call_count,
uniqIf(multiIf(mapContains(attributes_string, 'gen_ai.tool.name'), attributes_string['gen_ai.tool.name'], NULL), mapContains(attributes_string, 'gen_ai.tool.name')) AS distinct_tool_count,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens'), toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)) AS input_tokens,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens'), toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)), 0) + coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)), 0) AS total_tokens,
sum(multiIf(mapContains(attributes_number, '_signoz.gen_ai.total_cost'), toFloat64(attributes_number['_signoz.gen_ai.total_cost']), NULL)) AS estimated_cost_usd,
maxIf(duration_nano, mapContains(attributes_string, 'gen_ai.request.model')) AS max_llm_latency_ns,
countIf(has_error = true) AS error_count,
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) AS last_activity_time,
argMinIf(multiIf(mapContains(attributes_string, 'gen_ai.input.messages'), attributes_string['gen_ai.input.messages'], NULL), timestamp, mapContains(attributes_string, 'gen_ai.input.messages')) AS input,
argMaxIf(multiIf(mapContains(attributes_string, 'gen_ai.output.messages'), attributes_string['gen_ai.output.messages'], NULL), timestamp, mapContains(attributes_string, 'gen_ai.output.messages')) AS output
FROM signoz_traces.distributed_signoz_index_v3
WHERE ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)
AND trace_id GLOBAL IN (SELECT trace_id FROM ranked)
GROUP BY trace_id
ORDER BY output_tokens DESC, trace_id DESC
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
`, stmt)
}
// Aggregate-only filter (no span filter). WHERE prune is NOT widened, there is no
// gate/span countIf, just the aggregate HAVING. `trace.output_tokens` rewrites to the
// output_tokens alias. matched selects output_tokens (HAVING) + last_activity_time (default order).
func TestBuild_FullSQL_TraceList_AggregateFilterOnly(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "trace.output_tokens > 1000"},
Limit: 20,
}, nil)
require.NoError(t, err)
requireSQLEqual(t, `
WITH matched AS (
SELECT trace_id,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) AS last_activity_time
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND ((mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name')))
GROUP BY trace_id
HAVING output_tokens > 1000
ORDER BY last_activity_time DESC, trace_id DESC
LIMIT 20
),
ranked AS (
SELECT trace_id, min(start) AS t_start, max(end) AS t_end
FROM signoz_traces.distributed_trace_summary
WHERE trace_id GLOBAL IN (SELECT trace_id FROM matched)
AND end >= fromUnixTimestamp64Nano(1747947419000000000)
AND start < fromUnixTimestamp64Nano(1747983448000000000)
GROUP BY trace_id
),
buckets AS (
SELECT DISTINCT b AS ts_bucket
FROM ranked
ARRAY JOIN range(toUInt64(intDiv(toUnixTimestamp(t_start), 1800) * 1800 - 1800), toUInt64(intDiv(toUnixTimestamp(t_end), 1800) * 1800 + 1800), 1800) AS b
)
SELECT trace_id,
min(timestamp) AS start_time,
max(timestamp) AS end_time,
(max(toUnixTimestamp64Nano(timestamp) + duration_nano) - min(toUnixTimestamp64Nano(timestamp))) AS trace_duration_nano,
count() AS span_count,
anyIf(name, parent_span_id = '') AS root_span_name,
any(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,
countIf(mapContains(attributes_string, 'gen_ai.request.model')) AS llm_call_count,
countIf(mapContains(attributes_string, 'gen_ai.tool.name')) AS tool_call_count,
uniqIf(multiIf(mapContains(attributes_string, 'gen_ai.tool.name'), attributes_string['gen_ai.tool.name'], NULL), mapContains(attributes_string, 'gen_ai.tool.name')) AS distinct_tool_count,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens'), toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)) AS input_tokens,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens'), toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)), 0) + coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)), 0) AS total_tokens,
sum(multiIf(mapContains(attributes_number, '_signoz.gen_ai.total_cost'), toFloat64(attributes_number['_signoz.gen_ai.total_cost']), NULL)) AS estimated_cost_usd,
maxIf(duration_nano, mapContains(attributes_string, 'gen_ai.request.model')) AS max_llm_latency_ns,
countIf(has_error = true) AS error_count,
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) AS last_activity_time,
argMinIf(multiIf(mapContains(attributes_string, 'gen_ai.input.messages'), attributes_string['gen_ai.input.messages'], NULL), timestamp, mapContains(attributes_string, 'gen_ai.input.messages')) AS input,
argMaxIf(multiIf(mapContains(attributes_string, 'gen_ai.output.messages'), attributes_string['gen_ai.output.messages'], NULL), timestamp, mapContains(attributes_string, 'gen_ai.output.messages')) AS output
FROM signoz_traces.distributed_signoz_index_v3
WHERE ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)
AND trace_id GLOBAL IN (SELECT trace_id FROM ranked)
GROUP BY trace_id
ORDER BY last_activity_time DESC, trace_id DESC
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
`, stmt)
}
// Span-only filter (no aggregate filter). WHERE is widened; HAVING has the gate + span
// countIf pair but no trailing aggregate. `has_error = true` resolves to a
// materialized-column predicate (not a map access). matched selects only the default order key.
func TestBuild_FullSQL_TraceList_SpanFilterOnly(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "has_error = true"},
Limit: 20,
}, nil)
require.NoError(t, err)
requireSQLEqual(t, `
WITH matched AS (
SELECT trace_id,
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) AS last_activity_time
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND ((mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
OR has_error = true)
GROUP BY trace_id
HAVING countIf((mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) > 0
AND countIf(has_error = true) > 0
ORDER BY last_activity_time DESC, trace_id DESC
LIMIT 20
),
ranked AS (
SELECT trace_id, min(start) AS t_start, max(end) AS t_end
FROM signoz_traces.distributed_trace_summary
WHERE trace_id GLOBAL IN (SELECT trace_id FROM matched)
AND end >= fromUnixTimestamp64Nano(1747947419000000000)
AND start < fromUnixTimestamp64Nano(1747983448000000000)
GROUP BY trace_id
),
buckets AS (
SELECT DISTINCT b AS ts_bucket
FROM ranked
ARRAY JOIN range(toUInt64(intDiv(toUnixTimestamp(t_start), 1800) * 1800 - 1800), toUInt64(intDiv(toUnixTimestamp(t_end), 1800) * 1800 + 1800), 1800) AS b
)
SELECT trace_id,
min(timestamp) AS start_time,
max(timestamp) AS end_time,
(max(toUnixTimestamp64Nano(timestamp) + duration_nano) - min(toUnixTimestamp64Nano(timestamp))) AS trace_duration_nano,
count() AS span_count,
anyIf(name, parent_span_id = '') AS root_span_name,
any(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,
countIf(mapContains(attributes_string, 'gen_ai.request.model')) AS llm_call_count,
countIf(mapContains(attributes_string, 'gen_ai.tool.name')) AS tool_call_count,
uniqIf(multiIf(mapContains(attributes_string, 'gen_ai.tool.name'), attributes_string['gen_ai.tool.name'], NULL), mapContains(attributes_string, 'gen_ai.tool.name')) AS distinct_tool_count,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens'), toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)) AS input_tokens,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens'), toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)), 0) + coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)), 0) AS total_tokens,
sum(multiIf(mapContains(attributes_number, '_signoz.gen_ai.total_cost'), toFloat64(attributes_number['_signoz.gen_ai.total_cost']), NULL)) AS estimated_cost_usd,
maxIf(duration_nano, mapContains(attributes_string, 'gen_ai.request.model')) AS max_llm_latency_ns,
countIf(has_error = true) AS error_count,
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) AS last_activity_time,
argMinIf(multiIf(mapContains(attributes_string, 'gen_ai.input.messages'), attributes_string['gen_ai.input.messages'], NULL), timestamp, mapContains(attributes_string, 'gen_ai.input.messages')) AS input,
argMaxIf(multiIf(mapContains(attributes_string, 'gen_ai.output.messages'), attributes_string['gen_ai.output.messages'], NULL), timestamp, mapContains(attributes_string, 'gen_ai.output.messages')) AS output
FROM signoz_traces.distributed_signoz_index_v3
WHERE ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)
AND trace_id GLOBAL IN (SELECT trace_id FROM ranked)
GROUP BY trace_id
ORDER BY last_activity_time DESC, trace_id DESC
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
`, stmt)
}
// Resource filter: a resource attribute in the filter is pulled into a __resource_filter
// CTE (fingerprints matching the resource condition), and the `matched` scan is narrowed
// by `resource_fingerprint GLOBAL IN (…)`. The resource key is dropped from the span
// predicate (skipResourceFilter), so here there is no span-level existence check — the
// prune stays the gate mask and the whole match is scoped to the resource fingerprints.
func TestBuild_FullSQL_TraceList_ResourceFilter(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "resource.service.name = 'checkout'"},
Limit: 20,
}, nil)
require.NoError(t, err)
requireSQLEqual(t, `
WITH __resource_filter AS (
SELECT fingerprint
FROM signoz_traces.distributed_traces_v3_resource
WHERE (simpleJSONExtractString(labels, 'service.name') = 'checkout' AND labels LIKE '%service.name%' AND labels LIKE '%service.name":"checkout%')
AND seen_at_ts_bucket_start >= 1747945619
AND seen_at_ts_bucket_start <= 1747983448
GROUP BY fingerprint
),
matched AS (
SELECT trace_id,
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) AS last_activity_time
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND ((mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name')))
AND resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)
GROUP BY trace_id
ORDER BY last_activity_time DESC, trace_id DESC
LIMIT 20
),
ranked AS (
SELECT trace_id, min(start) AS t_start, max(end) AS t_end
FROM signoz_traces.distributed_trace_summary
WHERE trace_id GLOBAL IN (SELECT trace_id FROM matched)
AND end >= fromUnixTimestamp64Nano(1747947419000000000)
AND start < fromUnixTimestamp64Nano(1747983448000000000)
GROUP BY trace_id
),
buckets AS (
SELECT DISTINCT b AS ts_bucket
FROM ranked
ARRAY JOIN range(toUInt64(intDiv(toUnixTimestamp(t_start), 1800) * 1800 - 1800), toUInt64(intDiv(toUnixTimestamp(t_end), 1800) * 1800 + 1800), 1800) AS b
)
SELECT trace_id,
min(timestamp) AS start_time,
max(timestamp) AS end_time,
(max(toUnixTimestamp64Nano(timestamp) + duration_nano) - min(toUnixTimestamp64Nano(timestamp))) AS trace_duration_nano,
count() AS span_count,
anyIf(name, parent_span_id = '') AS root_span_name,
any(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,
countIf(mapContains(attributes_string, 'gen_ai.request.model')) AS llm_call_count,
countIf(mapContains(attributes_string, 'gen_ai.tool.name')) AS tool_call_count,
uniqIf(multiIf(mapContains(attributes_string, 'gen_ai.tool.name'), attributes_string['gen_ai.tool.name'], NULL), mapContains(attributes_string, 'gen_ai.tool.name')) AS distinct_tool_count,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens'), toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)) AS input_tokens,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens'), toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)), 0) + coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)), 0) AS total_tokens,
sum(multiIf(mapContains(attributes_number, '_signoz.gen_ai.total_cost'), toFloat64(attributes_number['_signoz.gen_ai.total_cost']), NULL)) AS estimated_cost_usd,
maxIf(duration_nano, mapContains(attributes_string, 'gen_ai.request.model')) AS max_llm_latency_ns,
countIf(has_error = true) AS error_count,
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) AS last_activity_time,
argMinIf(multiIf(mapContains(attributes_string, 'gen_ai.input.messages'), attributes_string['gen_ai.input.messages'], NULL), timestamp, mapContains(attributes_string, 'gen_ai.input.messages')) AS input,
argMaxIf(multiIf(mapContains(attributes_string, 'gen_ai.output.messages'), attributes_string['gen_ai.output.messages'], NULL), timestamp, mapContains(attributes_string, 'gen_ai.output.messages')) AS output
FROM signoz_traces.distributed_signoz_index_v3
WHERE ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)
AND trace_id GLOBAL IN (SELECT trace_id FROM ranked)
GROUP BY trace_id
ORDER BY last_activity_time DESC, trace_id DESC
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
`, stmt)
}
// Mixed filter (two span predicates AND'd into one existence check + an aggregate) with
// a two-key order on different aggregates than the filter. matched selects input_tokens
// + last_activity_time (ORDER BY) and output_tokens (HAVING) — three of four; llm_call_count is not.
func TestBuild_FullSQL_TraceList_MixedFiltersMultiOrder(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model = 'gpt-4o' AND has_error = true AND output_tokens > 500"},
Order: []qbtypes.OrderBy{
{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "input_tokens"}}, Direction: qbtypes.OrderDirectionDesc},
{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "last_activity_time"}}, Direction: qbtypes.OrderDirectionAsc},
},
Limit: 15,
}, nil)
require.NoError(t, err)
requireSQLEqual(t, `
WITH matched AS (
SELECT trace_id,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens'), toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)) AS input_tokens,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) AS last_activity_time
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND ((mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
OR ((attributes_string['gen_ai.request.model'] = 'gpt-4o' AND mapContains(attributes_string, 'gen_ai.request.model')) AND has_error = true))
GROUP BY trace_id
HAVING countIf((mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) > 0
AND countIf(((attributes_string['gen_ai.request.model'] = 'gpt-4o' AND mapContains(attributes_string, 'gen_ai.request.model')) AND has_error = true)) > 0
AND output_tokens > 500
ORDER BY input_tokens DESC, last_activity_time ASC, trace_id DESC
LIMIT 15
),
ranked AS (
SELECT trace_id, min(start) AS t_start, max(end) AS t_end
FROM signoz_traces.distributed_trace_summary
WHERE trace_id GLOBAL IN (SELECT trace_id FROM matched)
AND end >= fromUnixTimestamp64Nano(1747947419000000000)
AND start < fromUnixTimestamp64Nano(1747983448000000000)
GROUP BY trace_id
),
buckets AS (
SELECT DISTINCT b AS ts_bucket
FROM ranked
ARRAY JOIN range(toUInt64(intDiv(toUnixTimestamp(t_start), 1800) * 1800 - 1800), toUInt64(intDiv(toUnixTimestamp(t_end), 1800) * 1800 + 1800), 1800) AS b
)
SELECT trace_id,
min(timestamp) AS start_time,
max(timestamp) AS end_time,
(max(toUnixTimestamp64Nano(timestamp) + duration_nano) - min(toUnixTimestamp64Nano(timestamp))) AS trace_duration_nano,
count() AS span_count,
anyIf(name, parent_span_id = '') AS root_span_name,
any(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,
countIf(mapContains(attributes_string, 'gen_ai.request.model')) AS llm_call_count,
countIf(mapContains(attributes_string, 'gen_ai.tool.name')) AS tool_call_count,
uniqIf(multiIf(mapContains(attributes_string, 'gen_ai.tool.name'), attributes_string['gen_ai.tool.name'], NULL), mapContains(attributes_string, 'gen_ai.tool.name')) AS distinct_tool_count,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens'), toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)) AS input_tokens,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens'), toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)), 0) + coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)), 0) AS total_tokens,
sum(multiIf(mapContains(attributes_number, '_signoz.gen_ai.total_cost'), toFloat64(attributes_number['_signoz.gen_ai.total_cost']), NULL)) AS estimated_cost_usd,
maxIf(duration_nano, mapContains(attributes_string, 'gen_ai.request.model')) AS max_llm_latency_ns,
countIf(has_error = true) AS error_count,
maxIf(timestamp, (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) AS last_activity_time,
argMinIf(multiIf(mapContains(attributes_string, 'gen_ai.input.messages'), attributes_string['gen_ai.input.messages'], NULL), timestamp, mapContains(attributes_string, 'gen_ai.input.messages')) AS input,
argMaxIf(multiIf(mapContains(attributes_string, 'gen_ai.output.messages'), attributes_string['gen_ai.output.messages'], NULL), timestamp, mapContains(attributes_string, 'gen_ai.output.messages')) AS output
FROM signoz_traces.distributed_signoz_index_v3
WHERE ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)
AND trace_id GLOBAL IN (SELECT trace_id FROM ranked)
GROUP BY trace_id
ORDER BY input_tokens DESC, last_activity_time ASC, trace_id DESC
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
`, stmt)
}
// Span list (requestType raw): delegated to the traces builder with the gate ANDed
// into the user filter, so only gen_ai spans matching the filter come back. Standard
// span columns, single SELECT (no CTE pipeline).
func TestBuild_FullSQL_SpanList_Raw(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeRaw,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model = 'gpt-4o-mini'"},
Limit: 10,
}, nil)
require.NoError(t, err)
requireSQLEqual(t, `
SELECT timestamp AS __SELECT_KEY_0_timestamp, trace_id AS __SELECT_KEY_1_trace_id, span_id AS __SELECT_KEY_2_span_id,
trace_state AS __SELECT_KEY_3_trace_state, parent_span_id AS __SELECT_KEY_4_parent_span_id, flags AS __SELECT_KEY_5_flags,
name AS __SELECT_KEY_6_name, kind AS __SELECT_KEY_7_kind, kind_string AS __SELECT_KEY_8_kind_string, duration_nano AS __SELECT_KEY_9_duration_nano,
status_code AS __SELECT_KEY_10_status_code, status_message AS __SELECT_KEY_11_status_message,
status_code_string AS __SELECT_KEY_12_status_code_string, events AS __SELECT_KEY_13_events, links AS __SELECT_KEY_14_links,
response_status_code AS __SELECT_KEY_15_response_status_code, external_http_url AS __SELECT_KEY_16_external_http_url,
http_url AS __SELECT_KEY_17_http_url, external_http_method AS __SELECT_KEY_18_external_http_method,
http_method AS __SELECT_KEY_19_http_method, http_host AS __SELECT_KEY_20_http_host, db_name AS __SELECT_KEY_21_db_name,
db_operation AS __SELECT_KEY_22_db_operation, has_error AS __SELECT_KEY_23_has_error, is_remote AS __SELECT_KEY_24_is_remote,
attributes_string, attributes_number, attributes_bool, resources_string
FROM signoz_traces.distributed_signoz_index_v3
WHERE (((mapContains(attributes_string, 'gen_ai.request.model')
OR mapContains(attributes_string, 'gen_ai.tool.name')
OR mapContains(attributes_string, 'gen_ai.agent.name')))
AND ((attributes_string['gen_ai.request.model'] = 'gpt-4o-mini'
AND mapContains(attributes_string, 'gen_ai.request.model'))))
AND timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
LIMIT 10
`, stmt)
}
// ---------------------------------------------------------------------------
// Behavior / branch tests not covered by the goldens above
// ---------------------------------------------------------------------------
// A filter mixing a resource attribute with a span-level and an aggregate condition:
// the resource key routes into __resource_filter (fingerprint prune), the span key stays
// as a countIf existence check, and the aggregate becomes a HAVING — all AND-combined.
// service.name (resource context) comes from otelKeysMap.
func TestBuild_TraceList_ResourcePlusSpanPlusAggregateFilter(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "resource.service.name = 'checkout' AND has_error = true AND output_tokens > 1000"},
Limit: 10,
}, nil)
require.NoError(t, err)
got := renderSQL(t, stmt)
// resource condition -> fingerprint CTE + prune, not filtered on the span index
// (the service.name output column still reads the resource map, hence the = form).
require.Contains(t, got, "__resource_filter AS (")
require.Contains(t, got, "resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
require.NotContains(t, got, "resources_string['service.name'] = 'checkout'")
// span condition -> existence check in matched HAVING.
require.Contains(t, got, "countIf(has_error = true) > 0")
// aggregate condition -> HAVING on the matched aggregate alias.
require.Contains(t, got, "output_tokens")
}
// The resolver-unset (nil) fallback is covered in pkg/telemetryscopedtraces, which
// can construct that builder state directly.
// Trace-level and span-level predicates may not be OR-combined.
func TestBuild_TraceList_TraceOrSpanMixRejected(t *testing.T) {
b := newTestBuilder(t)
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "trace.output_tokens > 1000 OR gen_ai.request.model = 'x'"},
Limit: 10,
}
_, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace, query, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "cannot be combined")
}
// An output-only aggregate (span_count / trace_duration_nano) can be displayed but not
// used in the aggregate filter or ORDER BY — it is not computable in the matched pass.
func TestBuild_TraceList_OutputOnlyAggregateRejected(t *testing.T) {
b := newTestBuilder(t)
// filter by span_count -> rejected
_, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "span_count > 3"},
}, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "span_count")
// order by trace_duration_nano -> rejected
_, err = b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "trace_duration_nano"}}, Direction: qbtypes.OrderDirectionDesc}},
}, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "unsupported order key")
}
// duration_nano no longer names an aggregate (the trace column is trace_duration_nano),
// so a bare filter on it is span-level like everywhere else in the product: the trace
// matches when any span exceeds the duration.
func TestBuild_TraceList_SpanDurationFilterIsSpanLevel(t *testing.T) {
keys := otelKeysMap()
keys["duration_nano"] = []*telemetrytypes.TelemetryFieldKey{{
Name: "duration_nano",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeNumber,
}}
b := newTestBuilderWithKeys(t, keys)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "duration_nano > 1000000"},
Limit: 10,
}, nil)
require.NoError(t, err)
got := renderSQL(t, stmt)
require.Contains(t, got, "countIf(duration_nano > 1000000) > 0")
require.NotContains(t, got, "HAVING trace_duration_nano")
}
// A HAVING referencing a non-aggregate column is rejected.
func TestBuild_TraceList_Having_UnknownColumn(t *testing.T) {
b := newTestBuilder(t)
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Having: &qbtypes.Having{Expression: "service.name > 1"}, // not an aggregate column
Limit: 10,
}
_, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace, query, nil)
require.Error(t, err)
}
// Ordering by an unknown key is rejected.
func TestBuild_TraceList_UnsupportedOrderKey(t *testing.T) {
b := newTestBuilder(t)
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Order: []qbtypes.OrderBy{
{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "http.request.method"}}, Direction: qbtypes.OrderDirectionDesc},
},
}
_, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace, query, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "unsupported order key")
}
// With no limit set, the builder applies the default of 100.
func TestBuild_TraceList_DefaultLimit(t *testing.T) {
b := newTestBuilder(t)
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
}
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace, query, nil)
require.NoError(t, err)
require.Contains(t, stmt.Query, "LIMIT ?")
require.Contains(t, stmt.Args, 100)
}
// Only trace list and span list (raw) are supported; distribution is not.
func TestBuild_UnsupportedRequestType(t *testing.T) {
b := newTestBuilder(t)
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{
{Expression: "count()"},
},
}
_, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeDistribution, query, nil)
require.ErrorIs(t, err, scopedtraces.ErrUnsupportedRequestType)
}
// A gate key ingested under several data types (e.g. string + number from a
// misbehaving SDK) contributes ALL variants to the mask, OR-combined — not just
// the first — matching the standard visitor's EXISTS handling.
func TestBuild_TraceList_MultiVariantGateKey(t *testing.T) {
keys := otelKeysMap()
keys[telemetrytypes.GenAIToolName] = append(keys[telemetrytypes.GenAIToolName], &telemetrytypes.TelemetryFieldKey{
Name: telemetrytypes.GenAIToolName,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
})
b := newTestBuilderWithKeys(t, keys)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces, Limit: 10,
}, nil)
require.NoError(t, err)
got := renderSQL(t, stmt)
require.Contains(t, got, "mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_number, 'gen_ai.tool.name')")
}
// `trace.` parses as the trace field context and marks a trace-level aggregate; the
// legacy `tracefield.` spelling is explicitly rejected (filter and having alike), and
// an output-only aggregate under the context gets the targeted rejection rather than
// an unknown-span-field failure.
func TestBuild_TraceList_TraceContextPrefix(t *testing.T) {
b := newTestBuilder(t)
build := func(q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) (*qbtypes.Statement, error) {
q.Signal, q.Limit = telemetrytypes.SignalTraces, 20
return b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace, q, nil)
}
_, err := build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Filter: &qbtypes.Filter{Expression: "trace.output_tokens > 1000"}})
require.NoError(t, err)
_, err = build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Filter: &qbtypes.Filter{Expression: "tracefield.output_tokens > 1000"}})
require.Error(t, err)
require.Contains(t, err.Error(), `use the "trace." prefix`)
_, err = build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Having: &qbtypes.Having{Expression: "tracefield.output_tokens > 1000"}})
require.Error(t, err)
require.Contains(t, err.Error(), `use the "trace." prefix`)
_, err = build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Filter: &qbtypes.Filter{Expression: "trace.span_count > 3"}})
require.Error(t, err)
require.Contains(t, err.Error(), "cannot be used")
}
// Query variables in a trace-level condition are substituted into the HAVING (the
// span path binds them via PrepareWhereClause; the HAVING is a text rewrite).
func TestBuild_TraceList_VariableInAggregateFilter(t *testing.T) {
b := newTestBuilder(t)
build := func(expr string, vars map[string]qbtypes.VariableItem) (*qbtypes.Statement, error) {
return b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: expr},
Limit: 20,
}, vars)
}
// scalar variable -> literal in HAVING
stmt, err := build("trace.output_tokens > $threshold",
map[string]qbtypes.VariableItem{"threshold": {Value: 700}})
require.NoError(t, err)
require.Contains(t, stmt.Query, "HAVING output_tokens > 700")
// list variable with IN
stmt, err = build("trace.llm_call_count IN $counts",
map[string]qbtypes.VariableItem{"counts": {Value: []any{1, 2}}})
require.NoError(t, err)
require.Contains(t, stmt.Query, "HAVING llm_call_count IN")
// dynamic __all__ -> condition dropped, no HAVING at all
stmt, err = build("trace.output_tokens > $threshold",
map[string]qbtypes.VariableItem{"threshold": {Type: qbtypes.DynamicVariableType, Value: "__all__"}})
require.NoError(t, err)
require.NotContains(t, stmt.Query, "HAVING")
// unresolved variable -> rejected, not compared as a literal
_, err = build("trace.output_tokens > $missing", map[string]qbtypes.VariableItem{"other": {Value: 1}})
require.Error(t, err)
}

View File

@@ -1188,27 +1188,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
@@ -1294,9 +1273,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
}
@@ -1375,9 +1351,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
}

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