Compare commits

..

2 Commits

Author SHA1 Message Date
Jugal Kishore
53f7c3ae5d Merge branch 'main' into chore/openapi-deprecated-replacements 2026-11-12 13:24:35 +05:30
Jugal Kishore
34fe120a1f docs(openapi): name replacements for deprecated operations 2026-11-09 22:34:21 +05:30
148 changed files with 2917 additions and 3357 deletions

View File

@@ -553,23 +553,23 @@ components:
- apiKey
type: object
AlertmanagertypesChannelJiraAuth:
oneOf:
- $ref: '#/components/schemas/AlertmanagertypesChannelJiraCloudAuth'
- $ref: '#/components/schemas/AlertmanagertypesChannelJiraSelfHostedAuth'
type: object
AlertmanagertypesChannelJiraCloudAuth:
properties:
apiToken:
description: The Atlassian API token paired with email. Required when deployment
is cloud. A secret; returned by create, update and fetch by ID, never
by list.
description: The Atlassian API token paired with email. A secret; returned
by create, update and fetch by ID, never by list.
format: password
type: string
email:
description: The Atlassian account email used for basic auth. Required when
deployment is cloud.
type: string
pat:
description: The Jira personal access token, sent as a Bearer header. Required
when deployment is selfhosted. A secret; returned by create, update and
fetch by ID, never by list.
format: password
description: The Atlassian account email used for basic auth.
type: string
required:
- email
- apiToken
type: object
AlertmanagertypesChannelJiraConfig:
properties:
@@ -642,6 +642,16 @@ components:
- issueType
- auth
type: object
AlertmanagertypesChannelJiraSelfHostedAuth:
properties:
pat:
description: The Jira personal access token, sent as a Bearer header. A
secret; returned by create, update and fetch by ID, never by list.
format: password
type: string
required:
- pat
type: object
AlertmanagertypesChannelKind:
enum:
- slack
@@ -11763,6 +11773,15 @@ components:
enum:
- v2alpha1
type: string
source:
deprecated: true
description: The URL of the page the rule was created from. Informational;
it is not used for evaluation.
type: string
version:
deprecated: true
description: The query payload version. Nothing reads it.
type: string
required:
- alert
- alertType
@@ -11881,6 +11900,11 @@ components:
enum:
- v2alpha1
type: string
source:
deprecated: true
description: The URL of the page the rule was created from. Informational;
it is not used for evaluation.
type: string
state:
allOf:
- $ref: '#/components/schemas/RuletypesAlertState'
@@ -11891,6 +11915,10 @@ components:
type: string
updatedBy:
type: string
version:
deprecated: true
description: The query payload version. Nothing reads it.
type: string
required:
- id
- state
@@ -14195,8 +14223,9 @@ paths:
/api/v1/cloud-integrations/{cloud_provider}/agent-check-in:
post:
deprecated: true
description: '[Deprecated] This endpoint is called by the deployed agent to
check in'
description: 'This endpoint is called by the deployed agent to check in. Deprecated:
use POST /api/v1/cloud_integrations/{cloud_provider}/accounts/check_in instead.
Send cloudIntegrationId and providerAccountId instead of account_id and cloud_account_id.'
operationId: AgentCheckInDeprecated
parameters:
- in: path
@@ -18866,7 +18895,7 @@ paths:
/api/v1/testChannel:
post:
deprecated: true
description: 'Deprecated: use /api/v1/channels/test instead'
description: 'Deprecated: use POST /api/v1/channels/test instead.'
operationId: TestChannelDeprecated
requestBody:
content:
@@ -21353,7 +21382,8 @@ paths:
x-signoz-stability: alpha
post:
deprecated: true
description: This endpoint creates an ingestion key limit.
description: 'This endpoint creates an ingestion key limit. Deprecated: use
POST /api/v2/gateway/ingestion_limits instead. Send keyId in the request body.'
operationId: CreateIngestionKeyLimit
parameters:
- in: path
@@ -21413,7 +21443,8 @@ paths:
/api/v2/gateway/ingestion_keys/limits/{limitId}:
delete:
deprecated: true
description: This endpoint deletes an ingestion key limit
description: 'This endpoint deletes an ingestion key limit. Deprecated: use
DELETE /api/v2/gateway/ingestion_limits/{limitId} instead.'
operationId: DeleteIngestionKeyLimit
parameters:
- in: path
@@ -21455,7 +21486,8 @@ paths:
x-signoz-stability: alpha
patch:
deprecated: true
description: This endpoint updates an ingestion key limit.
description: 'This endpoint updates an ingestion key limit. Deprecated: use
PATCH /api/v2/gateway/ingestion_limits/{limitId} instead.'
operationId: UpdateIngestionKeyLimit
parameters:
- in: path
@@ -25531,6 +25563,7 @@ paths:
interval: 30m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
logs_threshold:
description: Counts matching log records (ERROR severity + body contains)
over a rolling window. Fires at least once per evaluation when the
@@ -25597,6 +25630,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
metric_anomaly:
description: Wraps a builder query in the `anomaly` function with
daily seasonality, so SigNoz compares each point against the forecast
@@ -25669,6 +25703,7 @@ paths:
interval: 4h
ruleType: anomaly_rule
schemaVersion: v2alpha1
version: v5
metric_promql:
description: PromQL expression instead of the builder. Dotted OTEL
resource attributes are quoted ("deployment.environment"). Useful
@@ -25724,6 +25759,7 @@ paths:
interval: 1h
ruleType: promql_rule
schemaVersion: v2alpha1
version: v5
metric_threshold_formula:
description: Computes disk utilization as (1 - available/capacity)
* 100 by combining two disabled base queries with a builder_formula.
@@ -25815,6 +25851,7 @@ paths:
interval: 2h
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
metric_threshold_single:
description: Fires when a pod consumes more than 80% of its requested
CPU for the whole evaluation window. Uses `k8s.pod.cpu_request_utilization`.
@@ -25883,6 +25920,7 @@ paths:
interval: 4h
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
notification_settings:
description: 'Demonstrates the full notificationSettings surface:
`groupBy` merges alerts across labels to cut noise, `newGroupEvalDelay`
@@ -25981,6 +26019,7 @@ paths:
usePolicy: false
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
tiered_thresholds:
description: Two tiers (warning and critical) in a single rule, each
with its own target, op, matchType, and channels so warnings and
@@ -26080,6 +26119,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
traces_error_rate_formula:
description: Two disabled trace count queries (A = error spans where
hasError=true, B = total spans) combined via a builder_formula into
@@ -26169,6 +26209,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
traces_threshold_latency:
description: Builder query against the traces signal with p99(duration_nano).
The series unit is ns (compositeQuery.unit), the target is in seconds
@@ -26237,6 +26278,7 @@ paths:
interval: 30m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
schema:
$ref: '#/components/schemas/RuletypesPostableRule'
responses:
@@ -26487,6 +26529,7 @@ paths:
interval: 30m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
logs_threshold:
description: Counts matching log records (ERROR severity + body contains)
over a rolling window. Fires at least once per evaluation when the
@@ -26553,6 +26596,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
metric_anomaly:
description: Wraps a builder query in the `anomaly` function with
daily seasonality, so SigNoz compares each point against the forecast
@@ -26625,6 +26669,7 @@ paths:
interval: 4h
ruleType: anomaly_rule
schemaVersion: v2alpha1
version: v5
metric_promql:
description: PromQL expression instead of the builder. Dotted OTEL
resource attributes are quoted ("deployment.environment"). Useful
@@ -26680,6 +26725,7 @@ paths:
interval: 1h
ruleType: promql_rule
schemaVersion: v2alpha1
version: v5
metric_threshold_formula:
description: Computes disk utilization as (1 - available/capacity)
* 100 by combining two disabled base queries with a builder_formula.
@@ -26771,6 +26817,7 @@ paths:
interval: 2h
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
metric_threshold_single:
description: Fires when a pod consumes more than 80% of its requested
CPU for the whole evaluation window. Uses `k8s.pod.cpu_request_utilization`.
@@ -26839,6 +26886,7 @@ paths:
interval: 4h
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
notification_settings:
description: 'Demonstrates the full notificationSettings surface:
`groupBy` merges alerts across labels to cut noise, `newGroupEvalDelay`
@@ -26937,6 +26985,7 @@ paths:
usePolicy: false
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
tiered_thresholds:
description: Two tiers (warning and critical) in a single rule, each
with its own target, op, matchType, and channels so warnings and
@@ -27036,6 +27085,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
traces_error_rate_formula:
description: Two disabled trace count queries (A = error spans where
hasError=true, B = total spans) combined via a builder_formula into
@@ -27125,6 +27175,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
traces_threshold_latency:
description: Builder query against the traces signal with p99(duration_nano).
The series unit is ns (compositeQuery.unit), the target is in seconds
@@ -27193,6 +27244,7 @@ paths:
interval: 30m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
schema:
$ref: '#/components/schemas/RuletypesPostableRule'
responses:
@@ -27344,6 +27396,7 @@ paths:
interval: 30m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
logs_threshold:
description: Counts matching log records (ERROR severity + body contains)
over a rolling window. Fires at least once per evaluation when the
@@ -27410,6 +27463,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
metric_anomaly:
description: Wraps a builder query in the `anomaly` function with
daily seasonality, so SigNoz compares each point against the forecast
@@ -27482,6 +27536,7 @@ paths:
interval: 4h
ruleType: anomaly_rule
schemaVersion: v2alpha1
version: v5
metric_promql:
description: PromQL expression instead of the builder. Dotted OTEL
resource attributes are quoted ("deployment.environment"). Useful
@@ -27537,6 +27592,7 @@ paths:
interval: 1h
ruleType: promql_rule
schemaVersion: v2alpha1
version: v5
metric_threshold_formula:
description: Computes disk utilization as (1 - available/capacity)
* 100 by combining two disabled base queries with a builder_formula.
@@ -27628,6 +27684,7 @@ paths:
interval: 2h
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
metric_threshold_single:
description: Fires when a pod consumes more than 80% of its requested
CPU for the whole evaluation window. Uses `k8s.pod.cpu_request_utilization`.
@@ -27696,6 +27753,7 @@ paths:
interval: 4h
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
notification_settings:
description: 'Demonstrates the full notificationSettings surface:
`groupBy` merges alerts across labels to cut noise, `newGroupEvalDelay`
@@ -27794,6 +27852,7 @@ paths:
usePolicy: false
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
tiered_thresholds:
description: Two tiers (warning and critical) in a single rule, each
with its own target, op, matchType, and channels so warnings and
@@ -27893,6 +27952,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
traces_error_rate_formula:
description: Two disabled trace count queries (A = error spans where
hasError=true, B = total spans) combined via a builder_formula into
@@ -27982,6 +28042,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
traces_threshold_latency:
description: Builder query against the traces signal with p99(duration_nano).
The series unit is ns (compositeQuery.unit), the target is in seconds
@@ -28050,6 +28111,7 @@ paths:
interval: 30m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
schema:
$ref: '#/components/schemas/RuletypesPostableRule'
responses:
@@ -28963,6 +29025,7 @@ paths:
interval: 30m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
logs_threshold:
description: Counts matching log records (ERROR severity + body contains)
over a rolling window. Fires at least once per evaluation when the
@@ -29029,6 +29092,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
metric_anomaly:
description: Wraps a builder query in the `anomaly` function with
daily seasonality, so SigNoz compares each point against the forecast
@@ -29101,6 +29165,7 @@ paths:
interval: 4h
ruleType: anomaly_rule
schemaVersion: v2alpha1
version: v5
metric_promql:
description: PromQL expression instead of the builder. Dotted OTEL
resource attributes are quoted ("deployment.environment"). Useful
@@ -29156,6 +29221,7 @@ paths:
interval: 1h
ruleType: promql_rule
schemaVersion: v2alpha1
version: v5
metric_threshold_formula:
description: Computes disk utilization as (1 - available/capacity)
* 100 by combining two disabled base queries with a builder_formula.
@@ -29247,6 +29313,7 @@ paths:
interval: 2h
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
metric_threshold_single:
description: Fires when a pod consumes more than 80% of its requested
CPU for the whole evaluation window. Uses `k8s.pod.cpu_request_utilization`.
@@ -29315,6 +29382,7 @@ paths:
interval: 4h
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
notification_settings:
description: 'Demonstrates the full notificationSettings surface:
`groupBy` merges alerts across labels to cut noise, `newGroupEvalDelay`
@@ -29413,6 +29481,7 @@ paths:
usePolicy: false
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
tiered_thresholds:
description: Two tiers (warning and critical) in a single rule, each
with its own target, op, matchType, and channels so warnings and
@@ -29512,6 +29581,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
traces_error_rate_formula:
description: Two disabled trace count queries (A = error spans where
hasError=true, B = total spans) combined via a builder_formula into
@@ -29601,6 +29671,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
traces_threshold_latency:
description: Builder query against the traces signal with p99(duration_nano).
The series unit is ns (compositeQuery.unit), the target is in seconds
@@ -29669,6 +29740,7 @@ paths:
interval: 30m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
schema:
$ref: '#/components/schemas/RuletypesPostableRule'
responses:
@@ -31533,8 +31605,9 @@ paths:
/api/v3/licenses:
post:
deprecated: true
description: This endpoint validates the license key with the upstream server
and activates the license for the organization.
description: 'This endpoint validates the license key with the upstream server
and activates the license for the organization. Deprecated: use POST /api/v4/licenses
instead. The v4 endpoint returns 201 with the license id.'
operationId: ActivateLicenseDeprecated
requestBody:
content:
@@ -31591,8 +31664,9 @@ paths:
x-signoz-stability: alpha
put:
deprecated: true
description: This endpoint refreshes the active license of the organization
from the upstream server.
description: 'This endpoint refreshes the active license of the organization
from the upstream server. Deprecated: use PUT /api/v4/licenses/{id} instead.
Pass the active license id in the path.'
operationId: RefreshLicenseDeprecated
responses:
"204":
@@ -32131,8 +32205,8 @@ paths:
x-signoz-stability: alpha
put:
deprecated: false
description: This endpoint refreshes the active license of the organization
from the upstream server.
description: This endpoint refreshes the license with the given id from the
upstream server. Only the active license can be refreshed.
operationId: RefreshLicense
parameters:
- in: path

View File

@@ -597,7 +597,7 @@ export const useTestChannel = <
return useMutation(getTestChannelMutationOptions(options));
};
/**
* Deprecated: use /api/v1/channels/test instead
* Deprecated: use POST /api/v1/channels/test instead.
* @deprecated
* @summary Test notification channel (deprecated)
*/

View File

@@ -72,7 +72,7 @@ const withQueryKey = <T extends object, K>(
};
/**
* [Deprecated] This endpoint is called by the deployed agent to check in
* This endpoint is called by the deployed agent to check in. Deprecated: use POST /api/v1/cloud_integrations/{cloud_provider}/accounts/check_in instead. Send cloudIntegrationId and providerAccountId instead of account_id and cloud_account_id.
* @deprecated
* @summary Agent check-in
*/

View File

@@ -634,7 +634,7 @@ export const invalidateGetIngestionKeyLimits = async (
};
/**
* This endpoint creates an ingestion key limit.
* This endpoint creates an ingestion key limit. Deprecated: use POST /api/v2/gateway/ingestion_limits instead. Send keyId in the request body.
* @deprecated
* @summary Create limit for the ingestion key
*/
@@ -736,7 +736,7 @@ export const useCreateIngestionKeyLimit = <
return useMutation(getCreateIngestionKeyLimitMutationOptions(options));
};
/**
* This endpoint deletes an ingestion key limit
* This endpoint deletes an ingestion key limit. Deprecated: use DELETE /api/v2/gateway/ingestion_limits/{limitId} instead.
* @deprecated
* @summary Delete limit for the ingestion key
*/
@@ -818,7 +818,7 @@ export const useDeleteIngestionKeyLimit = <
return useMutation(getDeleteIngestionKeyLimitMutationOptions(options));
};
/**
* This endpoint updates an ingestion key limit.
* This endpoint updates an ingestion key limit. Deprecated: use PATCH /api/v2/gateway/ingestion_limits/{limitId} instead.
* @deprecated
* @summary Update limit for the ingestion key
*/

View File

@@ -53,7 +53,7 @@ const withQueryKey = <T extends object, K>(
};
/**
* This endpoint validates the license key with the upstream server and activates the license for the organization.
* This endpoint validates the license key with the upstream server and activates the license for the organization. Deprecated: use POST /api/v4/licenses instead. The v4 endpoint returns 201 with the license id.
* @deprecated
* @summary Activate a license.
*/
@@ -139,7 +139,7 @@ export const useActivateLicenseDeprecated = <
return useMutation(getActivateLicenseDeprecatedMutationOptions(options));
};
/**
* This endpoint refreshes the active license of the organization from the upstream server.
* This endpoint refreshes the active license of the organization from the upstream server. Deprecated: use PUT /api/v4/licenses/{id} instead. Pass the active license id in the path.
* @deprecated
* @summary Refresh a license.
*/
@@ -559,7 +559,7 @@ export const invalidateGetLicense = async (
};
/**
* This endpoint refreshes the active license of the organization from the upstream server.
* This endpoint refreshes the license with the given id from the upstream server. Only the active license can be refreshed.
* @summary Refresh a license.
*/
export const refreshLicense = (

View File

@@ -829,26 +829,33 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
jira = 'jira',
}
export interface AlertmanagertypesChannelJiraAuthDTO {
export interface AlertmanagertypesChannelJiraCloudAuthDTO {
/**
* @type string
* @format password
* @description The Atlassian API token paired with email. Required when deployment is cloud. A secret; returned by create, update and fetch by ID, never by list.
* @description The Atlassian API token paired with email. A secret; returned by create, update and fetch by ID, never by list.
*/
apiToken?: string;
apiToken: string;
/**
* @type string
* @description The Atlassian account email used for basic auth. Required when deployment is cloud.
* @description The Atlassian account email used for basic auth.
*/
email?: string;
/**
* @type string
* @format password
* @description The Jira personal access token, sent as a Bearer header. Required when deployment is selfhosted. A secret; returned by create, update and fetch by ID, never by list.
*/
pat?: string;
email: string;
}
export interface AlertmanagertypesChannelJiraSelfHostedAuthDTO {
/**
* @type string
* @format password
* @description The Jira personal access token, sent as a Bearer header. A secret; returned by create, update and fetch by ID, never by list.
*/
pat: string;
}
export type AlertmanagertypesChannelJiraAuthDTO =
| AlertmanagertypesChannelJiraCloudAuthDTO
| AlertmanagertypesChannelJiraSelfHostedAuthDTO;
export enum AlertmanagertypesJiraDeploymentDTO {
cloud = 'cloud',
selfhosted = 'selfhosted',
@@ -13041,6 +13048,16 @@ export interface RuletypesPostableRuleDTO {
* @description The rule payload schema.
*/
schemaVersion: RuletypesPostableRuleDTOSchemaVersion;
/**
* @type string
* @description The URL of the page the rule was created from. Informational; it is not used for evaluation.
*/
source?: string;
/**
* @type string
* @description The query payload version. Nothing reads it.
*/
version?: string;
}
export interface RuletypesPostableRuleViewDTO {
@@ -13133,6 +13150,11 @@ export interface RuletypesRuleDTO {
* @description The rule payload schema.
*/
schemaVersion: RuletypesRuleDTOSchemaVersion;
/**
* @type string
* @description The URL of the page the rule was created from. Informational; it is not used for evaluation.
*/
source?: string;
/**
* @description The current evaluation state: inactive, pending, recovering, firing, nodata or disabled.
*/
@@ -13146,6 +13168,11 @@ export interface RuletypesRuleDTO {
* @type string
*/
updatedBy?: string;
/**
* @type string
* @description The query payload version. Nothing reads it.
*/
version?: string;
}
export enum RuletypesThresholdKindDTO {

View File

@@ -11,8 +11,6 @@ import axios, { AxiosError, AxiosRequestConfig } from 'axios';
// generated API Instance
const generatedAPIAxiosInstance = axios.create({
baseURL: ENVIRONMENT.baseURL,
// The backend binds repeated keys (`states=a&states=b`); axios' default sends `states[]=`.
paramsSerializer: { indexes: null },
});
let generatedAPIQueryKeyHeaderContext: Record<string, unknown> | undefined;

View File

@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect x="1.25" y="1.25" width="21.5" height="21.5" rx="3.5" fill="none" stroke="#7A8291" stroke-width="1.5"/><g fill="#7A8291"><circle cx="7" cy="4.5" r="1.05"/><circle cx="9.5" cy="4.5" r="1.05"/><circle cx="12" cy="4.5" r="1.05"/><circle cx="14.5" cy="4.5" r="1.05"/><circle cx="7" cy="7" r="1.05"/><circle cx="17" cy="7" r="1.05"/><circle cx="7" cy="9.5" r="1.05"/><circle cx="17" cy="9.5" r="1.05"/><circle cx="7" cy="12" r="1.05"/><circle cx="9.5" cy="12" r="1.05"/><circle cx="12" cy="12" r="1.05"/><circle cx="14.5" cy="12" r="1.05"/><circle cx="7" cy="14.5" r="1.05"/><circle cx="7" cy="17" r="1.05"/><circle cx="7" cy="19.5" r="1.05"/></g></svg>

Before

Width:  |  Height:  |  Size: 726 B

View File

@@ -48,59 +48,22 @@ export function QuerySearchV2Provider({
}, [initialExpression, store]);
const isInitialized = useRef(false);
const lastCommittedRef = useRef(committedExpression);
useEffect(() => {
if (isInitialized.current || !urlExpression) {
return;
}
isInitialized.current = true;
store
.getState()
.initializeFromUrl(
getUserExpressionFromCombined(initialExpression, urlExpression),
if (!isInitialized.current && urlExpression) {
const cleanedExpression = getUserExpressionFromCombined(
initialExpression,
urlExpression,
);
store.getState().initializeFromUrl(cleanedExpression);
isInitialized.current = true;
}
}, [urlExpression, initialExpression, store]);
// Back and Forward. Reads the browser's URL: after a write, nuqs can briefly
// report the value it replaced.
useEffect(() => {
const onPopState = (): void => {
isInitialized.current = true;
const urlValue = new URLSearchParams(window.location.search).get(
queryParamKey,
);
const expression = urlValue
? getUserExpressionFromCombined(initialExpression, urlValue)
: '';
// Another param changed; keep any text typed but not run.
if (expression === store.getState().committedExpression) {
return;
}
// Already in the URL, so the write below must not run for it.
lastCommittedRef.current = expression;
if (expression) {
store.getState().initializeFromUrl(expression);
} else {
store.getState().resetExpression();
}
};
window.addEventListener('popstate', onPopState);
return (): void => window.removeEventListener('popstate', onPopState);
}, [queryParamKey, initialExpression, store]);
// Write only when the committed filter changes, never in reply to a URL change.
useEffect(() => {
if (committedExpression === lastCommittedRef.current) {
return;
if (isInitialized.current || !urlExpression) {
setUrlExpression(committedExpression || null);
}
lastCommittedRef.current = committedExpression;
isInitialized.current = true;
const next = committedExpression || null;
if (next === urlExpression) {
return;
}
void setUrlExpression(next);
}, [committedExpression, urlExpression, setUrlExpression]);
}, [committedExpression, setUrlExpression, urlExpression]);
useEffect(() => {
return (): void => {

View File

@@ -1,10 +1,9 @@
import { ReactNode } from 'react';
import { act, renderHook } from 'tests/test-utils';
import { act, renderHook } from '@testing-library/react';
import {
useExpression,
useInitialExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
@@ -35,22 +34,10 @@ function createWrapper(
};
}
// Back or Forward. nuqs has not caught up yet, so its value is still the old one.
function goTo(expression: string | null): void {
act(() => {
const search = expression
? `?${new URLSearchParams({ testExpression: expression })}`
: '';
window.history.pushState(null, '', `/${search}`);
window.dispatchEvent(new PopStateEvent('popstate'));
});
}
function useTestHooks(): {
expression: string;
userExpression: string;
initialExpression: string;
inputExpression: string;
querySearchInitialExpressionProp: string | undefined;
onChange: (expr: string) => void;
onRun: (expr: string) => void;
@@ -59,7 +46,6 @@ function useTestHooks(): {
expression: useExpression(),
userExpression: useUserExpression(),
initialExpression: useInitialExpression(),
inputExpression: useInputExpression(),
querySearchInitialExpressionProp: useQuerySearchInitialExpressionProp(),
onChange: useQuerySearchOnChange(),
onRun: useQuerySearchOnRun(),
@@ -70,7 +56,6 @@ describe('QuerySearchExpressionProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUrlValue = null;
window.history.replaceState(null, '', '/');
});
it('should provide initial context values', () => {
@@ -125,96 +110,6 @@ describe('QuerySearchExpressionProvider', () => {
expect(result.current.expression).toBe('status = 500');
});
it('follows Back and Forward from the browser URL', () => {
mockUrlValue = 'status = 500';
const { result } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
goTo('status = 404');
expect(result.current.userExpression).toBe('status = 404');
expect(mockSetQueryState).not.toHaveBeenCalled();
});
it('clears the filter on Back to a URL without the param', () => {
mockUrlValue = 'status = 500';
const { result } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
goTo(null);
expect(result.current.userExpression).toBe('');
expect(mockSetQueryState).not.toHaveBeenCalled();
});
it('keeps text typed but not run when Back changes another param', () => {
mockUrlValue = 'status = 500';
const { result } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
act(() => {
result.current.onChange('status = 404');
});
goTo('status = 500');
expect(result.current.inputExpression).toBe('status = 404');
});
it('writes a run filter to the URL once and ignores it coming back', () => {
const { result, rerender } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
act(() => {
result.current.onRun('status = 500');
});
expect(mockSetQueryState).toHaveBeenCalledTimes(1);
expect(mockSetQueryState).toHaveBeenLastCalledWith('status = 500');
mockUrlValue = 'status = 500';
rerender();
expect(result.current.userExpression).toBe('status = 500');
expect(mockSetQueryState).toHaveBeenCalledTimes(1);
});
it('keeps a run filter when the URL briefly reports the value it replaced', () => {
mockUrlValue = 'status = 500';
const { result, rerender } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
act(() => {
result.current.onRun('status = 404');
});
mockUrlValue = 'status = 404';
rerender();
mockUrlValue = 'status = 500';
rerender();
expect(result.current.userExpression).toBe('status = 404');
});
it('keeps a newer run when a late URL value arrives after an empty mount', () => {
const { result, rerender } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
act(() => {
result.current.onRun('status = 500');
});
act(() => {
result.current.onRun('status = 404');
});
mockUrlValue = 'status = 500';
rerender();
expect(result.current.userExpression).toBe('status = 404');
});
it('should throw error when used outside provider', () => {
expect(() => {
renderHook(() => useExpression());

View File

@@ -15,41 +15,11 @@ $max-recents-shown: 5;
Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', sans-serif;
.query-where-clause-row {
display: flex;
align-items: flex-start;
gap: 8px;
// single-line editor: 36px .cm-line + 1px .cm-content border on each side
--button-height: 38px;
.query-where-clause-editor-container {
flex: 1;
min-width: 0;
}
}
.query-where-clause-editor-container {
position: relative;
display: flex;
flex-direction: row;
// The field draws one outline around the scope chip and the editor, so
// the editor inside draws none
&.hasScopeChip {
align-items: center;
gap: var(--spacing-3);
padding-left: var(--spacing-3);
border: 1px solid var(--l2-border);
border-radius: 2px;
background: var(--l1-background);
--query-builder-v2-border-color: transparent;
--query-builder-v2-background-color: transparent;
.query-where-clause-editor {
min-width: 0;
}
}
.query-search-initial-scope-label {
position: absolute;
left: 8px;

View File

@@ -1,12 +1,6 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
CircleCheck,
Info,
TriangleAlert,
Filter,
Play,
} from '@signozhq/icons';
import { CircleCheck, Info, TriangleAlert, Filter } from '@signozhq/icons';
import {
autocompletion,
closeCompletion,
@@ -22,7 +16,6 @@ import { githubLight } from '@uiw/codemirror-theme-github';
import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
import { Button, Card, Collapse, Popover, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Button as SignozButton } from '@signozhq/ui/button';
import cx from 'classnames';
import {
negationQueryOperatorSuggestions,
@@ -66,7 +59,6 @@ import {
SUGGESTION_FETCH_DEBOUNCE_MS,
SUGGESTIONS_SECTION,
} from './constants';
import { ScopeChip } from './ScopeChip/ScopeChip';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
@@ -113,14 +105,9 @@ interface QuerySearchProps {
signalSource?: string;
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
onRun?: (query: string) => void;
showRunButton?: boolean;
/** Shows the Run button busy while the page's queries fetch. */
isLoadingQueries?: boolean;
showFilterSuggestionsWithoutMetric?: boolean;
/** When set, the editor shows only the user expression; API/filter uses `initial AND (user)`. */
initialExpression?: string;
/** Shows `initialExpression` as a locked chip in the field instead of the scope icon. */
showScopeChip?: boolean;
/** When set, replaces the generic value-suggestion API with a custom fetcher. */
valueSuggestionsOverride?: (
key: string,
@@ -138,13 +125,10 @@ function QuerySearch({
queryData,
dataSource,
onRun,
showRunButton,
isLoadingQueries,
signalSource,
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
initialExpression,
showScopeChip: showScopeChipProp,
metricNamespace,
valueSuggestionsOverride,
}: QuerySearchProps): JSX.Element {
@@ -164,7 +148,6 @@ function QuerySearch({
const editorRef = useRef<EditorView | null>(null);
const isScopedFilter = initialExpression !== undefined;
const showScopeChip = !!showScopeChipProp && !!initialExpression;
const validateExpressionForEditor = useCallback(
(editorDoc: string): void => {
@@ -190,18 +173,6 @@ function QuerySearch({
[],
);
const handleRun = useCallback((): void => {
if (!onRun) {
return;
}
const user = getCurrentExpression();
onRun(
isScopedFilter
? combineInitialAndUserExpression(initialExpression ?? '', user)
: user,
);
}, [onRun, getCurrentExpression, isScopedFilter, initialExpression]);
const updateEditorValue = useCallback(
(value: string, options: { skipOnChange?: boolean } = {}): void => {
const view = editorRef.current;
@@ -1586,182 +1557,169 @@ function QuerySearch({
</div>
)}
<div className="query-where-clause-row">
<div
className={cx('query-where-clause-editor-container', {
hasScopeChip: showScopeChip,
})}
>
{showScopeChip && <ScopeChip expression={initialExpression ?? ''} />}
{isScopedFilter && !showScopeChip ? (
<Tooltip title={initialExpression || ''} placement="left">
<div className="query-search-initial-scope-label">
<Filter
size={14}
style={{
opacity: 0.9,
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
</div>
</Tooltip>
) : null}
<Tooltip
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
placement="left"
>
<a
href="https://signoz.io/docs/userguide/search-syntax/"
target="_blank"
rel="noopener noreferrer"
style={{
position: 'absolute',
top: 8,
right: validation.isValid === false && getCurrentExpression() ? 40 : 8, // Move left when error shown
cursor: 'help',
zIndex: 10,
transition: 'right 0.2s ease',
display: 'inline-flex',
alignItems: 'center',
color: '#8c8c8c',
}}
onClick={(e): void => e.stopPropagation()}
>
<Info
<div className="query-where-clause-editor-container">
{isScopedFilter ? (
<Tooltip title={initialExpression || ''} placement="left">
<div className="query-search-initial-scope-label">
<Filter
size={14}
style={{
opacity: 0.9,
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
</a>
</div>
</Tooltip>
) : null}
<Tooltip
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
placement="left"
>
<a
href="https://signoz.io/docs/userguide/search-syntax/"
target="_blank"
rel="noopener noreferrer"
style={{
position: 'absolute',
top: 8,
right: validation.isValid === false && getCurrentExpression() ? 40 : 8, // Move left when error shown
cursor: 'help',
zIndex: 10,
transition: 'right 0.2s ease',
display: 'inline-flex',
alignItems: 'center',
color: '#8c8c8c',
}}
onClick={(e): void => e.stopPropagation()}
>
<Info
size={14}
style={{
opacity: 0.9,
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
</a>
</Tooltip>
<CodeMirror
theme={isDarkMode ? copilot : githubLight}
onChange={handleChange}
onUpdate={handleUpdate}
onCreateEditor={handleEditorCreate}
data-testid="query-where-clause-editor"
className={cx('query-where-clause-editor', {
isValid: validation.isValid === true,
<CodeMirror
theme={isDarkMode ? copilot : githubLight}
onChange={handleChange}
onUpdate={handleUpdate}
onCreateEditor={handleEditorCreate}
data-testid="query-where-clause-editor"
className={cx('query-where-clause-editor', {
isValid: validation.isValid === true,
hasErrors: validation.errors.length > 0,
hasInitialExpression: isScopedFilter,
})}
extensions={[
autocompletion({
override: [combinedSuggestions],
defaultKeymap: true,
closeOnBlur: true,
activateOnTyping: true,
maxRenderedOptions: 50,
addToOptions: [{ render: renderRecentDeleteButton, position: 100 }],
}),
javascript({ jsx: false, typescript: false }),
EditorView.lineWrapping,
stopEventsExtension,
Prec.highest(
keymap.of([
...completionKeymap,
{
key: 'Escape',
run: closeCompletion,
},
{
key: 'Enter',
preventDefault: true,
// Prevent default behavior of Enter to add new line
// and instead run a custom action
run: (): boolean => true,
},
{
key: 'Mod-Enter',
preventDefault: true,
// Prevent default behavior of Mod-Enter to add new line
// and instead run a custom action
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => {
if (onRun && typeof onRun === 'function') {
const user = getCurrentExpression();
onRun(
isScopedFilter
? combineInitialAndUserExpression(initialExpression ?? '', user)
: user,
);
}
return true;
},
},
{
key: 'Shift-Enter',
preventDefault: true,
// Prevent default behavior of Shift-Enter to add new line
run: (): boolean => true,
},
]),
),
]}
placeholder={placeholder}
basicSetup={{
lineNumbers: false,
}}
onFocus={(): void => {
setIsFocused(true);
}}
onBlur={handleBlur}
/>
{getCurrentExpression() && validation.isValid === false && !isFocused && (
<div
className={cx('query-status-container', {
hasErrors: validation.errors.length > 0,
hasInitialExpression: isScopedFilter && !showScopeChip,
})}
extensions={[
autocompletion({
override: [combinedSuggestions],
defaultKeymap: true,
closeOnBlur: true,
activateOnTyping: true,
maxRenderedOptions: 50,
addToOptions: [{ render: renderRecentDeleteButton, position: 100 }],
}),
javascript({ jsx: false, typescript: false }),
EditorView.lineWrapping,
stopEventsExtension,
Prec.highest(
keymap.of([
...completionKeymap,
{
key: 'Escape',
run: closeCompletion,
},
{
key: 'Enter',
preventDefault: true,
// Prevent default behavior of Enter to add new line
// and instead run a custom action
run: (): boolean => true,
},
{
key: 'Mod-Enter',
preventDefault: true,
// Prevent default behavior of Mod-Enter to add new line
// and instead run a custom action
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => {
handleRun();
return true;
},
},
{
key: 'Shift-Enter',
preventDefault: true,
// Prevent default behavior of Shift-Enter to add new line
run: (): boolean => true,
},
]),
),
]}
placeholder={placeholder}
basicSetup={{
lineNumbers: false,
}}
onFocus={(): void => {
setIsFocused(true);
}}
onBlur={handleBlur}
/>
{getCurrentExpression() && validation.isValid === false && !isFocused && (
<div
className={cx('query-status-container', {
hasErrors: validation.errors.length > 0,
})}
data-testid="query-validation-status"
>
<Popover
placement="bottomRight"
showArrow={false}
content={
<div className="query-status-content">
<div className="query-status-content-header">
<div className="query-validation">
<div className="query-validation-errors">
{validation.errors.map((error) => (
<div key={error.message} className="query-validation-error">
<div className="query-validation-error">
{error.line}:{error.column} - {error.message}
</div>
data-testid="query-validation-status"
>
<Popover
placement="bottomRight"
showArrow={false}
content={
<div className="query-status-content">
<div className="query-status-content-header">
<div className="query-validation">
<div className="query-validation-errors">
{validation.errors.map((error) => (
<div key={error.message} className="query-validation-error">
<div className="query-validation-error">
{error.line}:{error.column} - {error.message}
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
}
overlayClassName="query-status-popover"
>
{validation.isValid ? (
<Button
type="text"
icon={<CircleCheck size="md" />}
className="periscope-btn ghost"
/>
) : (
<Button
type="text"
icon={<TriangleAlert size={14} color={Color.BG_CHERRY_500} />}
className="periscope-btn ghost"
/>
)}
</Popover>
</div>
)}
</div>
{showRunButton && (
<SignozButton
variant="solid"
color="secondary"
size="md"
prefix={<Play size={14} />}
loading={isLoadingQueries}
onClick={handleRun}
testId="query-search-run-button"
>
Run
</SignozButton>
</div>
}
overlayClassName="query-status-popover"
>
{validation.isValid ? (
<Button
type="text"
icon={<CircleCheck size="md" />}
className="periscope-btn ghost"
/>
) : (
<Button
type="text"
icon={<TriangleAlert size={14} color={Color.BG_CHERRY_500} />}
className="periscope-btn ghost"
/>
)}
</Popover>
</div>
)}
</div>
@@ -1855,15 +1813,12 @@ function QuerySearch({
QuerySearch.defaultProps = {
onRun: undefined,
showRunButton: false,
isLoadingQueries: false,
signalSource: '',
hardcodedAttributeKeys: undefined,
placeholder:
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
showFilterSuggestionsWithoutMetric: false,
initialExpression: undefined,
showScopeChip: false,
};
export default QuerySearch;

View File

@@ -1,57 +0,0 @@
.scopeChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
flex: 0 1 auto;
min-width: 0;
max-width: 420px;
padding: var(--spacing-1) var(--spacing-3);
border-radius: 2px;
background: var(--l3-background);
color: var(--l2-foreground);
font-size: 12px;
line-height: 20px;
cursor: default;
}
.scopeIcon {
flex: none;
}
.scopeMore {
flex: none;
color: var(--l3-foreground);
}
// The editor beside it is CodeMirror on the copilot theme in dark and
// githubLight in light; the chip borrows those token colours so one filter
// does not read as two
.scopeKey {
color: #939da5;
}
.scopeOperator {
color: #939da5;
}
.scopeValue {
color: #5bec95;
}
:global(.lightMode) {
.scopeKey,
.scopeOperator {
color: #005cc5;
}
.scopeValue {
color: #032f62;
}
}
.scopeExpression {
font-family: var(--font-mono, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -1,41 +0,0 @@
import { useMemo } from 'react';
import { Lock } from '@signozhq/icons';
import { Tooltip } from '@signozhq/ui/tooltip';
import { extractQueryPairs } from 'utils/queryContextUtils';
import styles from './ScopeChip.module.scss';
interface ScopeChipProps {
expression: string;
}
/**
* The locked half of the filter. It reads like the editor beside it: the last
* clause carries the relation, the ones scoping it are counted instead.
*/
export function ScopeChip({ expression }: ScopeChipProps): JSX.Element {
const clauses = useMemo(() => extractQueryPairs(expression), [expression]);
const relation = clauses[clauses.length - 1];
return (
<Tooltip title={expression} side="top">
<span className={styles.scopeChip} data-testid="query-search-scope-chip">
<Lock size={12} className={styles.scopeIcon} />
<span className={styles.scopeExpression}>
{relation ? (
<>
<span className={styles.scopeKey}>{relation.key}</span>
<span className={styles.scopeOperator}>{` ${relation.operator} `}</span>
<span className={styles.scopeValue}>{relation.value}</span>
</>
) : (
expression
)}
</span>
{clauses.length > 1 && (
<span className={styles.scopeMore}>{`+${clauses.length - 1}`}</span>
)}
</span>
</Tooltip>
);
}

View File

@@ -224,134 +224,6 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
await waitFor(() => expect(onRun).toHaveBeenCalled(), { timeout: 2000 });
});
it('does not render the run button by default', () => {
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
onRun={jest.fn()}
/>,
);
expect(
screen.queryByTestId('query-search-run-button'),
).not.toBeInTheDocument();
});
it('calls onRun with the editor text when the run button is clicked', async () => {
const onRun = jest.fn() as jest.MockedFunction<(q: string) => void>;
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={{
...initialQueriesMap.logs.builder.queryData[0],
filter: { expression: SAMPLE_STATUS_QUERY },
}}
dataSource={DataSource.LOGS}
onRun={onRun}
showRunButton
/>,
);
await waitFor(() =>
expect(document.querySelector(CM_EDITOR_SELECTOR)?.textContent).toBe(
SAMPLE_STATUS_QUERY,
),
);
await userEvent.click(screen.getByTestId('query-search-run-button'));
expect(onRun).toHaveBeenCalledWith(SAMPLE_STATUS_QUERY);
});
it('shows the locked scope as a chip in place of the scope icon', () => {
const scope = "kind_string = 'Client' AND http_host = 'api.example.com'";
const { container } = render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.traces.builder.queryData[0]}
dataSource={DataSource.TRACES}
initialExpression={scope}
showScopeChip
/>,
);
const chip = screen.getByTestId('query-search-scope-chip');
expect(chip).toHaveTextContent("http_host = 'api.example.com'");
expect(chip).toHaveTextContent('+1');
expect(chip).not.toHaveTextContent('kind_string');
expect(
container.querySelector('.query-search-initial-scope-label'),
).not.toBeInTheDocument();
});
it('keeps the scope icon unless the chip is asked for', () => {
const { container } = render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.traces.builder.queryData[0]}
dataSource={DataSource.TRACES}
initialExpression="kind_string = 'Client'"
/>,
);
expect(
screen.queryByTestId('query-search-scope-chip'),
).not.toBeInTheDocument();
expect(
container.querySelector('.query-search-initial-scope-label'),
).toBeInTheDocument();
});
it('shows the run button busy while the queries load', () => {
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
onRun={jest.fn()}
showRunButton
isLoadingQueries
/>,
);
expect(screen.getByTestId('query-search-run-button')).toHaveAttribute(
'aria-busy',
'true',
);
});
it('runs the locked scope combined with the editor text', async () => {
const onRun = jest.fn() as jest.MockedFunction<(q: string) => void>;
const scope = "kind_string = 'Client'";
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={{
...initialQueriesMap.traces.builder.queryData[0],
filter: { expression: SAMPLE_STATUS_QUERY },
}}
dataSource={DataSource.TRACES}
onRun={onRun}
initialExpression={scope}
showRunButton
/>,
);
await waitFor(() =>
expect(document.querySelector(CM_EDITOR_SELECTOR)?.textContent).toBe(
SAMPLE_STATUS_QUERY,
),
);
await userEvent.click(screen.getByTestId('query-search-run-button'));
expect(onRun).toHaveBeenCalledWith(`${scope} AND (${SAMPLE_STATUS_QUERY})`);
});
it('initializes CodeMirror with expression from queryData.filter.expression on mount', async () => {
const testExpression =
"http.status_code >= 500 AND service.name = 'frontend'";

View File

@@ -69,6 +69,8 @@ export const REACT_QUERY_KEY = {
GET_NESTED_ENDPOINTS_LIST: 'GET_NESTED_ENDPOINTS_LIST',
GET_ENDPOINT_METRICS_DATA: 'GET_ENDPOINT_METRICS_DATA',
GET_ENDPOINT_STATUS_CODE_DATA: 'GET_ENDPOINT_STATUS_CODE_DATA',
GET_ENDPOINT_RATE_OVER_TIME_DATA: 'GET_ENDPOINT_RATE_OVER_TIME_DATA',
GET_ENDPOINT_LATENCY_OVER_TIME_DATA: 'GET_ENDPOINT_LATENCY_OVER_TIME_DATA',
GET_ENDPOINT_DROPDOWN_DATA: 'GET_ENDPOINT_DROPDOWN_DATA',
GET_ENDPOINT_DEPENDENT_SERVICES_DATA: 'GET_ENDPOINT_DEPENDENT_SERVICES_DATA',
GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA:

View File

@@ -1,24 +1,20 @@
import { useCallback, useEffect, useMemo } from 'react';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Select } from 'antd';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap } from 'constants/queryBuilder';
import {
getAllEndpointsWidgetData,
getGroupByExpressionFromRowData,
getGroupByFiltersFromGroupByValues,
} from 'container/ApiMonitoring/utils';
import GridCard from 'container/WidgetCard/Card';
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { isEqual } from 'lodash-es';
import { parseAsString, useQueryState } from 'nuqs';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { ENDPOINT_STATS_EXPRESSION_KEY } from '../../../filterParamKeys';
import { useApiMonitoringParams } from '../../../queryParams';
import { SPAN_ATTRIBUTES, VIEWS } from './constants';
import { useFilterExpression } from './hooks/useFilterExpression';
import { useWidgetQueries } from './hooks/useWidgetQueries';
function AllEndPoints({
domainName,
@@ -27,6 +23,8 @@ function AllEndPoints({
groupBy,
setGroupBy,
timeRange,
initialFilters,
setInitialFiltersEndPointStats,
}: {
domainName: string;
setSelectedEndPointName: (name: string) => void;
@@ -37,50 +35,113 @@ function AllEndPoints({
startTime: number;
endTime: number;
};
initialFilters: IBuilderQuery['filters'];
setInitialFiltersEndPointStats: (filters: IBuilderQuery['filters']) => void;
}): JSX.Element {
const [params, setParams] = useApiMonitoringParams();
const [, setEndPointStatsExpression] = useQueryState(
ENDPOINT_STATS_EXPRESSION_KEY,
parseAsString,
);
const [groupBySearchValue, setGroupBySearchValue] = useState<string>('');
const [allAvailableGroupByOptions, setAllAvailableGroupByOptions] = useState<{
[key: string]: any;
}>({});
const { data: groupByFiltersData, isLoading: isLoadingGroupByFilters } =
useGetAggregateKeys({
dataSource: DataSource.TRACES,
aggregateAttribute: '',
aggregateOperator: 'noop',
searchText: groupBySearchValue,
tagType: '',
});
const [groupByOptions, setGroupByOptions] = useState<
{ value: string; label: string }[]
>([]);
// --- FILTERS STATE SYNC ---
const {
expression: filterExpression,
initialExpression: scopeExpression,
queryData,
onDraftChange,
onRun,
onBlur: handleFilterBlur,
} = useFilterExpression({
groupBy,
const [filters, setFilters] = useState<IBuilderQuery['filters']>(() => {
if (params.allEndpointsLocalFilters) {
return params.allEndpointsLocalFilters;
}
// Initialize filters based on the initial endPointName prop
const initialItems = [...(initialFilters?.items || [])];
return { op: 'AND', items: initialItems };
});
// Sync params to local filters state on param change
useEffect(() => {
if (
params.allEndpointsLocalFilters &&
!isEqual(params.allEndpointsLocalFilters, filters)
) {
setFilters(params.allEndpointsLocalFilters);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.allEndpointsLocalFilters]);
// Handler for changes from the QueryBuilderSearchV2 component
const handleFilterChange = useCallback(
(newFilters: IBuilderQuery['filters']): void => {
setFilters(newFilters);
setParams({ allEndpointsLocalFilters: newFilters });
},
[setParams],
);
const currentQuery = initialQueriesMap[DataSource.TRACES];
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.TRACES,
filters, // Use the local filters state
},
],
},
}),
[filters, currentQuery],
);
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
const allEndpointsWidgetData = useMemo(
() => getAllEndpointsWidgetData(groupBy, domainName, filterExpression),
[groupBy, domainName, filterExpression],
);
const tableWidgets = useMemo(
() => [allEndpointsWidgetData],
[allEndpointsWidgetData],
);
const { isFetching: isFetchingTable, refetch: refetchTable } =
useWidgetQueries(tableWidgets);
const handleRun = useCallback(
(value: string): void => onRun(value, refetchTable),
[onRun, refetchTable],
() => getAllEndpointsWidgetData(groupBy, domainName, filters),
[groupBy, domainName, filters],
);
// --- GROUP BY STATE SYNC (existing) ---
const handleGroupByChange = useCallback(
(value: IBuilderQuery['groupBy']) => {
setGroupBy(value);
const newGroupBy = [];
for (let index = 0; index < value.length; index++) {
const element = value[index] as unknown as string;
// Check if the key exists in our cached options first
if (allAvailableGroupByOptions[element]) {
newGroupBy.push(allAvailableGroupByOptions[element]);
} else {
// If not found in cache, check the current filtered results
const key = groupByFiltersData?.payload?.attributeKeys?.find(
(key) => key.key === element,
);
if (key) {
newGroupBy.push(key);
}
}
}
setGroupBy(newGroupBy);
setGroupBySearchValue('');
// Update params
setParams({ groupBy: value.map((g) => g.key) });
setParams({ groupBy: newGroupBy.map((g) => g.key) });
},
[setGroupBy, setParams],
[groupByFiltersData, setGroupBy, allAvailableGroupByOptions, setParams],
);
// Sync params to local groupBy state on param change
@@ -93,31 +154,77 @@ function AllEndPoints({
groupBy.map((g) => g.key),
)
) {
setGroupBy(
params.groupBy.map((key) => chooseAutocompleteFromCustomValue([], key)),
);
// Only update if different
const newGroupBy = params.groupBy
.map((key) => allAvailableGroupByOptions[key])
.filter(Boolean);
if (newGroupBy.length === params.groupBy.length) {
setGroupBy(newGroupBy);
}
}
}, [params.groupBy, groupBy, setGroupBy]);
}, [params.groupBy, allAvailableGroupByOptions, groupBy, setGroupBy]);
useEffect(() => {
if (groupByFiltersData?.payload) {
// Update dropdown options
setGroupByOptions(
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
value: filter.key,
label: filter.key,
})) || [],
);
// Cache all available options to preserve selected values using functional update
setAllAvailableGroupByOptions((prevOptions) => {
const newOptions = { ...prevOptions };
groupByFiltersData?.payload?.attributeKeys?.forEach((filter) => {
newOptions[filter.key] = filter;
});
return newOptions;
});
}
}, [groupByFiltersData]); // Only depends on groupByFiltersData now
// Cache existing selected options on component mount
useEffect(() => {
if (groupBy && groupBy.length > 0) {
setAllAvailableGroupByOptions((prevOptions) => {
const newOptions = { ...prevOptions };
groupBy.forEach((option) => {
newOptions[option.key] = option;
});
return newOptions;
});
}
}, [groupBy]);
const onRowClick = useCallback(
(props: any): void => {
setSelectedEndPointName(props[SPAN_ATTRIBUTES.HTTP_URL] as string);
setSelectedView(VIEWS.ENDPOINT_STATS);
const statsExpression = combineInitialAndUserExpression(
getGroupByExpressionFromRowData(props),
filterExpression,
);
void setEndPointStatsExpression(statsExpression || null);
const initialItems = [
...(filters?.items || []),
...(getGroupByFiltersFromGroupByValues(props, groupBy)?.items || []),
];
setInitialFiltersEndPointStats({
items: initialItems,
op: 'AND',
});
setParams({
selectedEndPointName: props[SPAN_ATTRIBUTES.HTTP_URL] as string,
selectedView: VIEWS.ENDPOINT_STATS,
endPointDetailsLocalFilters: {
items: initialItems,
op: 'AND',
},
});
},
[
filterExpression,
setEndPointStatsExpression,
filters,
setInitialFiltersEndPointStats,
setSelectedEndPointName,
setSelectedView,
groupBy,
setParams,
],
);
@@ -125,28 +232,28 @@ function AllEndPoints({
return (
<div className="all-endpoints-container">
<div className="all-endpoints-header">
<div className="filter-container" onBlur={handleFilterBlur}>
<QuerySearch
queryData={queryData}
onChange={onDraftChange}
onRun={handleRun}
showRunButton
isLoadingQueries={isFetchingTable}
initialExpression={scopeExpression}
showScopeChip
dataSource={DataSource.TRACES}
<div className="filter-container">
<QueryBuilderSearchV2
query={query}
onChange={handleFilterChange}
placeholder="Search for filters..."
/>
</div>
</div>
<div className="group-by-container">
<div className="group-by-label"> Group by </div>
<GroupByFilter
query={queryData}
onChange={handleGroupByChange}
disabled={false}
<Select
className="group-by-select"
loading={isLoadingGroupByFilters}
mode="multiple"
value={groupBy}
allowClear
maxTagCount="responsive"
placeholder="Search for attribute"
/>
options={groupByOptions}
onChange={handleGroupByChange}
onSearch={(value: string): void => setGroupBySearchValue(value)}
/>{' '}
</div>
<div className="endpoints-table-container">
<GridCard

View File

@@ -195,6 +195,16 @@
line-height: 18px;
letter-spacing: -0.07px;
}
.group-by-select {
width: 100%;
.ant-select-selector {
font-size: 14px;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
}
// Add border-bottom to table cells when pagination is not present
.ant-spin-container:not(:has(.ant-pagination)) .ant-table-cell {
@@ -355,9 +365,16 @@
.top-errors-dropdown-container {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 10px;
align-items: center;
.endpoint-details-filters-container-dropdown {
width: 100%;
}
.endpoint-details-filters-container-search {
flex: 1;
}
}
.endpoint-details-container {
@@ -399,8 +416,29 @@
.endpoint-details-filters-container {
display: flex;
flex-direction: column;
gap: 8px;
flex-direction: row;
align-items: center;
border: 1px solid var(--l1-border);
height: 36px;
box-sizing: content-box;
.ant-select-selector {
border: none !important;
}
.endpoint-details-filters-container-dropdown {
width: 120px;
border-right: 1px solid var(--l1-border);
height: 36px;
display: flex;
align-items: center;
.ant-select-single {
height: 32px;
}
}
.endpoint-details-filters-container-search {
flex: 1;
}
}
.ant-select-item,

View File

@@ -6,7 +6,6 @@ import { Button, Drawer } from 'antd';
import { ToggleGroup } from '@signozhq/ui/toggle-group';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import { QuerySearchV2Provider } from 'components/QueryBuilderV2';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
@@ -18,12 +17,7 @@ import { AppState } from 'store/reducers';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
import {
ALL_ENDPOINTS_EXPRESSION_KEY,
ENDPOINT_STATS_EXPRESSION_KEY,
} from '../../../filterParamKeys';
import { useApiMonitoringParams } from '../../../queryParams';
import { getDomainScopeExpression } from '../../../utils';
import AllEndPoints from './AllEndPoints';
import DomainMetrics from './components/DomainMetrics';
import { VIEW_TYPES, VIEWS } from './constants';
@@ -40,14 +34,14 @@ function DomainDetails({
selectedDomainIndex,
setSelectedDomainIndex,
domainListLength,
domainListFilterExpression,
domainListFilters,
}: {
domainData: any;
handleClose: () => void;
selectedDomainIndex: number;
setSelectedDomainIndex: (index: number) => void;
domainListLength: number;
domainListFilterExpression: string;
domainListFilters: IBuilderQuery['filters'];
}): JSX.Element {
const [params, setParams] = useApiMonitoringParams();
const [selectedView, setSelectedView] = useState<VIEWS>(
@@ -59,16 +53,8 @@ function DomainDetails({
const [endPointsGroupBy, setEndPointsGroupBy] = useState<
IBuilderQuery['groupBy']
>([]);
const domainName = domainData?.domainName ?? '';
const allEndpointsScope = useMemo(
() => getDomainScopeExpression(domainName),
[domainName],
);
const endPointStatsScope = useMemo(
() => getDomainScopeExpression(domainName, selectedEndPointName),
[domainName, selectedEndPointName],
);
const [initialFiltersEndPointStats, setInitialFiltersEndPointStats] =
useState<IBuilderQuery['filters']>(domainListFilters);
const handleTabChange = (value: string): void => {
setSelectedView(value as VIEWS);
@@ -236,7 +222,7 @@ function DomainDetails({
<>
<DomainMetrics
domainName={domainData.domainName}
domainListFilterExpression={domainListFilterExpression}
domainListFilters={domainListFilters}
timeRange={modalTimeRange}
/>
<div className="views-tabs-container">
@@ -267,43 +253,34 @@ function DomainDetails({
/>
</div>
{selectedView === VIEW_TYPES.ALL_ENDPOINTS && (
<QuerySearchV2Provider
queryParamKey={ALL_ENDPOINTS_EXPRESSION_KEY}
initialExpression={allEndpointsScope}
persistOnUnmount
>
<AllEndPoints
domainName={domainData.domainName}
setSelectedEndPointName={handleEndPointChange}
setSelectedView={setSelectedView}
groupBy={endPointsGroupBy}
setGroupBy={setEndPointsGroupBy}
timeRange={modalTimeRange}
/>
</QuerySearchV2Provider>
<AllEndPoints
domainName={domainData.domainName}
setSelectedEndPointName={handleEndPointChange}
setSelectedView={setSelectedView}
groupBy={endPointsGroupBy}
setGroupBy={setEndPointsGroupBy}
timeRange={modalTimeRange}
initialFilters={domainListFilters}
setInitialFiltersEndPointStats={setInitialFiltersEndPointStats}
/>
)}
{selectedView === VIEW_TYPES.ENDPOINT_STATS && (
<QuerySearchV2Provider
queryParamKey={ENDPOINT_STATS_EXPRESSION_KEY}
initialExpression={endPointStatsScope}
persistOnUnmount
>
<EndPointDetails
domainName={domainData.domainName}
endPointName={selectedEndPointName}
setSelectedEndPointName={handleEndPointChange}
timeRange={modalTimeRange}
handleTimeChange={handleTimeChange}
/>
</QuerySearchV2Provider>
<EndPointDetails
domainName={domainData.domainName}
endPointName={selectedEndPointName}
setSelectedEndPointName={handleEndPointChange}
initialFilters={initialFiltersEndPointStats}
timeRange={modalTimeRange}
handleTimeChange={handleTimeChange}
/>
)}
{selectedView === VIEW_TYPES.TOP_ERRORS && (
<TopErrors
domainName={domainData.domainName}
timeRange={modalTimeRange}
initialFilterExpression={domainListFilterExpression}
initialFilters={domainListFilters}
/>
)}
</>

View File

@@ -1,17 +1,17 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueries } from 'react-query';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
import {
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
extractPortAndEndpoint,
getEndPointDetailsQueryPayload,
getHttpUrlExpression,
getLatencyOverTimeWidgetData,
getRateOverTimeWidgetData,
} from 'container/ApiMonitoring/utils';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import {
CustomTimeType,
Time,
@@ -19,28 +19,36 @@ import {
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { extractQueryPairs } from 'utils/queryContextUtils';
import DependentServices from './components/DependentServices';
import EndPointMetrics from './components/EndPointMetrics';
import EndPointPicker from './components/EndPointPicker';
import EndPointsDropDown from './components/EndPointsDropDown';
import MetricOverTimeGraph from './components/MetricOverTimeGraph';
import StatusCodeBarCharts from './components/StatusCodeBarCharts';
import StatusCodeTable from './components/StatusCodeTable';
import { useFilterExpression } from './hooks/useFilterExpression';
import { useWidgetQueries } from './hooks/useWidgetQueries';
import { SPAN_ATTRIBUTES } from './constants';
const httpUrlKey = {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.HTTP_URL,
type: 'tag',
};
function EndPointDetails({
domainName,
endPointName,
setSelectedEndPointName,
initialFilters,
timeRange,
handleTimeChange,
}: {
domainName: string;
endPointName: string;
setSelectedEndPointName: (value: string) => void;
initialFilters: IBuilderQuery['filters'];
timeRange: {
startTime: number;
endTime: number;
@@ -51,66 +59,159 @@ function EndPointDetails({
) => void;
}): JSX.Element {
const { startTime: minTime, endTime: maxTime } = timeRange;
const [params, setParams] = useApiMonitoringParams();
// Search filters; the endpoint filter is combined in at query time
const {
expression: filterExpression,
initialExpression: scopeExpression,
queryData,
onDraftChange,
onRun,
onBlur: handleFilterBlur,
} = useFilterExpression();
const currentQuery = initialQueriesMap[DataSource.TRACES];
const httpUrlExpression = useMemo(
() => getHttpUrlExpression(endPointName),
[endPointName],
// Local state for filters, combining endpoint filter and search filters
const [filters, setFilters] = useState<IBuilderQuery['filters']>(() => {
// Initialize filters based on the initial endPointName prop
const initialItems = params.endPointDetailsLocalFilters
? [...(params.endPointDetailsLocalFilters?.items || [])]
: [...(initialFilters?.items || [])];
if (endPointName) {
initialItems.push({
id: '92b8a1c1',
key: httpUrlKey,
op: '=',
value: endPointName,
});
}
return { op: 'AND', items: initialItems };
});
// Effect to synchronize local filters when the endPointName prop changes (e.g., from dropdown)
useEffect(() => {
setFilters((currentFilters) => {
const existingHttpUrlFilter = currentFilters?.items?.find(
(item) => item.key?.key === httpUrlKey.key,
);
const existingHttpUrlValue = (existingHttpUrlFilter?.value as string) || '';
// Only update filters if the prop value is different from what's already in filters
if (endPointName === existingHttpUrlValue) {
return currentFilters; // No change needed, prevents loop
}
// Rebuild filters: Keep non-http_url filters and add/update http_url filter based on prop
const otherFilters = currentFilters?.items?.filter(
(item) => item.key?.key !== httpUrlKey.key,
);
const newItems = [...(otherFilters || [])];
if (endPointName) {
newItems.push({
id: '92b8a1c1',
key: httpUrlKey,
op: '=',
value: endPointName,
});
}
return { op: 'AND', items: newItems };
});
}, [endPointName]);
// Separate effect to update params when filters change
useEffect(() => {
const filtersWithoutHttpUrl = {
op: 'AND',
items:
filters?.items?.filter((item) => item.key?.key !== httpUrlKey.key) || [],
};
setParams({ endPointDetailsLocalFilters: filtersWithoutHttpUrl });
}, [filters, setParams]);
// Handler for changes from the QueryBuilderSearchV2 component
const handleFilterChange = useCallback(
(newFilters: IBuilderQuery['filters']): void => {
// 1. Update local filters state immediately
setFilters(newFilters);
// Filter out http_url filter before saving to params
const filteredNewFilters = {
op: 'AND',
items:
newFilters?.items?.filter((item) => item.key?.key !== httpUrlKey.key) ||
[],
};
setParams({ endPointDetailsLocalFilters: filteredNewFilters });
// 2. Derive the endpoint name from the *new* filters state
const httpUrlFilter = newFilters?.items?.find(
(item) => item.key?.key === httpUrlKey.key,
);
const derivedEndPointName = (httpUrlFilter?.value as string) || '';
// 3. If the derived endpoint name is different from the current prop,
// it means the search change modified the effective endpoint.
// Notify the parent component.
if (derivedEndPointName !== endPointName) {
setSelectedEndPointName(derivedEndPointName);
}
},
[endPointName, setSelectedEndPointName, setParams], // Dependencies for the callback
);
const scopedExpression = useMemo(
() => combineInitialAndUserExpression(httpUrlExpression, filterExpression),
[httpUrlExpression, filterExpression],
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.TRACES,
filters, // Use the local filters state
},
],
},
}),
[filters, currentQuery],
);
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
const isServicesFilterApplied = useMemo(
() =>
extractQueryPairs(filterExpression).some(
(pair) => pair.key === 'service.name',
),
[filterExpression],
() => filters?.items?.some((item) => item.key?.key === 'service.name'),
[filters],
);
const endPointDetailsQueryPayload = useMemo(
() =>
getEndPointDetailsQueryPayload(
domainName,
minTime,
maxTime,
scopedExpression,
),
[domainName, scopedExpression, minTime, maxTime],
() => getEndPointDetailsQueryPayload(domainName, minTime, maxTime, filters),
[domainName, filters, minTime, maxTime],
);
const V5_QUERIES = [
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_METRICS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA,
] as const;
const endPointDetailsDataQueries = useQueries(
endPointDetailsQueryPayload.map((payload, index) => ({
queryKey: [
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index],
payload,
ENTITY_VERSION_V5,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V5),
enabled: !!payload,
// Keeps the table's rows, and so its height, while a new filter loads
keepPreviousData:
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index] ===
REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
})),
endPointDetailsQueryPayload.map((payload, index) => {
const queryKey = END_POINT_DETAILS_QUERY_KEYS_ARRAY[index];
const version = (V5_QUERIES as readonly string[]).includes(queryKey)
? ENTITY_VERSION_V5
: ENTITY_VERSION_V4;
return {
queryKey: [
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index],
payload,
...(filters?.items?.length ? filters.items : []), // Include filters.items in queryKey for better caching
version,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, version),
enabled: !!payload,
};
}),
);
const [
endPointMetricsDataQuery,
endPointStatusCodeDataQuery,
endPointDropDownDataQuery,
endPointDependentServicesDataQuery,
endPointStatusCodeBarChartsDataQuery,
endPointStatusCodeLatencyBarChartsDataQuery,
@@ -121,6 +222,7 @@ function EndPointDetails({
endPointDetailsDataQueries[2],
endPointDetailsDataQueries[3],
endPointDetailsDataQueries[4],
endPointDetailsDataQueries[5],
],
[endPointDetailsDataQueries],
);
@@ -132,32 +234,10 @@ function EndPointDetails({
const [rateOverTimeWidget, latencyOverTimeWidget] = useMemo(
() => [
getRateOverTimeWidgetData(domainName, endPointName, scopedExpression),
getLatencyOverTimeWidgetData(domainName, endPointName, scopedExpression),
getRateOverTimeWidgetData(domainName, endPointName, filters),
getLatencyOverTimeWidgetData(domainName, endPointName, filters),
],
[domainName, endPointName, scopedExpression],
);
const graphWidgets = useMemo(
() => [rateOverTimeWidget, latencyOverTimeWidget],
[rateOverTimeWidget, latencyOverTimeWidget],
);
const { isFetching: isFetchingGraphs, refetch: refetchGraphs } =
useWidgetQueries(graphWidgets);
const isFetchingQueries =
isFetchingGraphs ||
endPointDetailsDataQueries.some((query) => query.isFetching);
const refetchQueries = useCallback((): void => {
refetchGraphs();
endPointDetailsDataQueries.forEach((query) => {
void query.refetch();
});
}, [refetchGraphs, endPointDetailsDataQueries]);
const handleRun = useCallback(
(value: string): void => onRun(value, refetchQueries),
[onRun, refetchQueries],
[domainName, endPointName, filters], // Use combinedFilters
);
// // [TODO] Fix this later
@@ -177,23 +257,19 @@ function EndPointDetails({
return (
<div className="endpoint-details-container">
<div className="endpoint-details-filters-container">
<EndPointPicker
domainName={domainName}
timeRange={timeRange}
filterExpression={filterExpression}
value={endPointName}
onChange={setSelectedEndPointName}
/>
<div onBlur={handleFilterBlur}>
<QuerySearch
queryData={queryData}
onChange={onDraftChange}
onRun={handleRun}
showRunButton
isLoadingQueries={isFetchingQueries}
initialExpression={scopeExpression}
showScopeChip
dataSource={DataSource.TRACES}
<div className="endpoint-details-filters-container-dropdown">
<EndPointsDropDown
selectedEndPointName={endPointName}
setSelectedEndPointName={setSelectedEndPointName}
endPointDropDownDataQuery={endPointDropDownDataQuery}
parentContainerDiv=".endpoint-details-filters-container"
dropdownStyle={{ width: 'calc(100% - 36px)' }}
/>
</div>
<div className="endpoint-details-filters-container-search">
<QueryBuilderSearchV2
query={query}
onChange={handleFilterChange}
placeholder="Search for filters..."
/>
</div>
@@ -223,7 +299,7 @@ function EndPointDetails({
endPointStatusCodeLatencyBarChartsDataQuery
}
domainName={domainName}
filterExpression={scopedExpression}
filters={filters}
timeRange={timeRange}
onDragSelect={onDragSelect}
/>

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { QueryFunctionContext, useQuery } from 'react-query';
import { QueryFunctionContext, useQueries, useQuery } from 'react-query';
import { Spin, Table, Tooltip } from 'antd';
import { Info, Loader } from '@signozhq/icons';
import { Switch } from '@signozhq/ui/switch';
@@ -7,36 +7,41 @@ import { Typography } from '@signozhq/ui/typography';
import { getQueryRangeV5 } from 'api/v5/queryRange/getQueryRange';
import { MetricRangePayloadV5, ScalarData } from 'api/v5/v5';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { withErrorBoundary } from 'components/ErrorBoundaryHOC';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
formatTopErrorsDataForTable,
getHttpUrlExpression,
getEndPointDetailsQueryPayload,
getTopErrorsColumnsConfig,
getTopErrorsCoRelationQueryFilters,
getTopErrorsQueryPayload,
} from 'container/ApiMonitoring/utils';
import { SuccessResponseV2 } from 'types/api';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { SuccessResponse, SuccessResponseV2 } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
import EndPointPicker from './components/EndPointPicker';
import EndPointsDropDown from './components/EndPointsDropDown';
import ErrorState from './components/ErrorState';
import { SPAN_ATTRIBUTES } from './constants';
function TopErrors({
domainName,
timeRange,
initialFilterExpression,
initialFilters,
}: {
domainName: string;
timeRange: {
startTime: number;
endTime: number;
};
initialFilterExpression: string;
initialFilters: IBuilderQuery['filters'];
}): JSX.Element {
const { startTime: minTime, endTime: maxTime } = timeRange;
@@ -50,10 +55,27 @@ function TopErrors({
domainName,
minTime,
maxTime,
combineInitialAndUserExpression(
getHttpUrlExpression(endPointName),
initialFilterExpression,
),
{
items: endPointName
? [
// Remove any existing http_url filters from initialFilters to avoid duplicates
...(initialFilters?.items?.filter(
(item) => item.key?.key !== SPAN_ATTRIBUTES.HTTP_URL,
) || []),
{
id: '92b8a1c1',
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.HTTP_URL,
type: 'tag',
},
op: '=',
value: endPointName,
},
]
: [...(initialFilters?.items || [])],
op: 'AND',
},
showStatusCodeErrors,
),
[
@@ -61,7 +83,7 @@ function TopErrors({
endPointName,
minTime,
maxTime,
initialFilterExpression,
initialFilters,
showStatusCodeErrors,
],
);
@@ -98,6 +120,35 @@ function TopErrors({
[topErrorsData],
);
const endPointDropDownQueryPayload = useMemo(
() => [
getEndPointDetailsQueryPayload(domainName, minTime, maxTime, {
items: [],
op: 'AND',
})[2],
],
[domainName, minTime, maxTime],
);
const endPointDropDownDataQueries = useQueries(
endPointDropDownQueryPayload.map((payload) => ({
queryKey: [
END_POINT_DETAILS_QUERY_KEYS_ARRAY[2],
payload,
ENTITY_VERSION_V5,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V5),
enabled: !!payload,
staleTime: 60 * 1000,
})),
);
const [endPointDropDownDataQuery] = useMemo(
() => [endPointDropDownDataQueries[0]],
[endPointDropDownDataQueries],
);
const navigateToExplorer = useNavigateToExplorer();
if (isError) {
@@ -111,13 +162,14 @@ function TopErrors({
return (
<div className="all-endpoints-container">
<div className="top-errors-dropdown-container">
<EndPointPicker
domainName={domainName}
timeRange={timeRange}
filterExpression=""
value={endPointName}
onChange={setSelectedEndPointName}
/>
<div className="endpoint-details-filters-container-dropdown">
<EndPointsDropDown
selectedEndPointName={endPointName}
setSelectedEndPointName={setSelectedEndPointName}
endPointDropDownDataQuery={endPointDropDownDataQuery}
parentContainerDiv=".endpoint-details-filters-container"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Switch
color="primary"

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { UseQueryResult } from 'react-query';
import { Table, TablePaginationConfig } from 'antd';
import { Skeleton, Table, TablePaginationConfig } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { QueryParams } from 'constants/query';
import {
@@ -54,6 +54,10 @@ function DependentServices({
[isExpanded, dependentServicesData.length],
);
if (isLoading || isRefetching) {
return <Skeleton />;
}
if (isError) {
return <ErrorState refetch={refetch} />;
}

View File

@@ -3,6 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import { TraceAggregation } from 'api/v5/v5';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { SPAN_ATTRIBUTES } from '../constants';
import DomainMetrics from './DomainMetrics';
@@ -33,7 +34,10 @@ describe('DomainMetrics - V5 Query Payload Tests', () => {
startTime: 1758259531000,
endTime: 1758261331000,
},
domainListFilterExpression: '',
domainListFilters: {
items: [],
op: 'AND' as const,
} as IBuilderQuery['filters'],
};
const mockSuccessResponse = {
@@ -170,12 +174,35 @@ describe('DomainMetrics - V5 Query Payload Tests', () => {
it('includes custom filters in filter expressions', async () => {
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
const customFilterExpression =
"service.name = 'my-service' AND deployment.environment = 'production'";
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'my-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND' as const,
};
renderComponent({
...mockProps,
domainListFilterExpression: customFilterExpression,
domainListFilters: customFilters,
});
await waitFor(() => {

View File

@@ -14,17 +14,18 @@ import {
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import ErrorState from './ErrorState';
function DomainMetrics({
domainName,
timeRange,
domainListFilterExpression,
domainListFilters,
}: {
domainName: string;
timeRange: { startTime: number; endTime: number };
domainListFilterExpression: string;
domainListFilters: IBuilderQuery['filters'];
}): JSX.Element {
const { startTime: minTime, endTime: maxTime } = timeRange;
@@ -34,9 +35,9 @@ function DomainMetrics({
domainName,
minTime,
maxTime,
domainListFilterExpression,
domainListFilters,
),
[domainName, minTime, maxTime, domainListFilterExpression],
[domainName, minTime, maxTime, domainListFilters],
);
// Since only one query here

View File

@@ -114,7 +114,10 @@ describe('EndPointMetrics - V5 Query Payload Tests', () => {
const domainName = 'api.example.com';
const startTime = 1758259531000;
const endTime = 1758261331000;
const filters = '';
const filters = {
items: [],
op: 'AND' as const,
};
// Get the actual payload that would be generated
const payloads = getEndPointDetailsQueryPayload(
@@ -234,8 +237,31 @@ describe('EndPointMetrics - V5 Query Payload Tests', () => {
it('includes custom domainListFilters in all query expressions', async () => {
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
const customFilters =
"service.name = 'payment-service' AND deployment.environment = 'staging'";
const customFilters = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'payment-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'staging',
},
],
op: 'AND' as const,
};
const payloads = getEndPointDetailsQueryPayload(
'api.internal.com',

View File

@@ -1,18 +0,0 @@
.picker {
width: 460px;
max-width: 100%;
// Popover fits its longest option by default; match the trigger so long URLs truncate.
[data-slot='combobox-content'] {
--popover-width: var(--radix-popover-trigger-width);
}
}
.option {
display: block;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -1,92 +0,0 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
import { ComboboxSimple, ComboboxSimpleItem } from '@signozhq/ui/combobox';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
getEndPointDropDownQueryPayload,
getFormattedEndPointDropDownData,
} from 'container/ApiMonitoring/utils';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import styles from './EndPointPicker.module.scss';
const VIRTUALIZE_AFTER = 100;
const ALL_ENDPOINTS = '__all_endpoints__';
interface EndPointPickerProps {
domainName: string;
timeRange: {
startTime: number;
endTime: number;
};
filterExpression: string;
value: string;
onChange: (endPointName: string) => void;
}
function EndPointPicker({
domainName,
timeRange,
filterExpression,
value,
onChange,
}: EndPointPickerProps): JSX.Element {
const payload = useMemo(
() =>
getEndPointDropDownQueryPayload(
domainName,
timeRange.startTime,
timeRange.endTime,
filterExpression,
),
[domainName, timeRange.startTime, timeRange.endTime, filterExpression],
);
const { data, isLoading, isFetching } = useQuery({
queryKey: [
REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA,
payload,
ENTITY_VERSION_V5,
],
queryFn: () => GetMetricQueryRange(payload, ENTITY_VERSION_V5),
staleTime: 60 * 1000,
});
const items = useMemo(
(): ComboboxSimpleItem[] => [
{ value: ALL_ENDPOINTS, label: 'All endpoints' },
...getFormattedEndPointDropDownData(
data?.payload?.data?.result?.[0]?.table?.rows ?? [],
).map(({ value: url }) => ({
value: url,
label: (
<span className={styles.option} title={url}>
{url}
</span>
),
})),
],
[data],
);
return (
<div className={styles.picker}>
<ComboboxSimple
items={items}
value={value || ALL_ENDPOINTS}
onChange={(next): void =>
onChange(!next || next === ALL_ENDPOINTS ? '' : (next as string))
}
inputPlaceholder="Search endpoints"
emptyPlaceholder="No endpoints found"
loading={isLoading || isFetching}
virtualized={items.length > VIRTUALIZE_AFTER}
withPortal={false}
testId="endpoint-picker"
/>
</div>
);
}
export default EndPointPicker;

View File

@@ -0,0 +1,65 @@
import { useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { Select } from 'antd';
import { getFormattedEndPointDropDownData } from 'container/ApiMonitoring/utils';
import { SuccessResponse } from 'types/api';
interface EndPointsDropDownProps {
selectedEndPointName?: string;
setSelectedEndPointName: (value: string) => void;
endPointDropDownDataQuery: UseQueryResult<SuccessResponse<any>, unknown>;
parentContainerDiv?: string;
dropdownStyle?: React.CSSProperties;
}
const defaultProps = {
selectedEndPointName: '',
parentContainerDiv: '',
dropdownStyle: {},
};
function EndPointsDropDown({
selectedEndPointName,
setSelectedEndPointName,
endPointDropDownDataQuery,
parentContainerDiv,
dropdownStyle,
}: EndPointsDropDownProps): JSX.Element {
const { data, isLoading, isFetching } = endPointDropDownDataQuery;
const handleChange = (value: string): void => {
setSelectedEndPointName(value);
};
const formattedData = useMemo(
() =>
getFormattedEndPointDropDownData(data?.payload.data.result[0].table.rows),
[data?.payload.data.result],
);
return (
<Select
value={selectedEndPointName || undefined}
placeholder="Select endpoint"
loading={isLoading || isFetching}
style={{ width: '100%' }}
onChange={handleChange}
options={formattedData}
getPopupContainer={
parentContainerDiv
? (): HTMLElement =>
document.querySelector(parentContainerDiv) as HTMLElement
: (triggerNode): HTMLElement => triggerNode.parentNode as HTMLElement
}
dropdownStyle={dropdownStyle}
allowClear
onClear={(): void => {
setSelectedEndPointName('');
}}
/>
);
}
EndPointsDropDown.defaultProps = defaultProps;
export default EndPointsDropDown;

View File

@@ -24,6 +24,7 @@ import { LegendPosition } from 'lib/uPlotV2/components/types';
import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/widgets/widget';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import ErrorState from './ErrorState';
import {
@@ -36,7 +37,7 @@ function StatusCodeBarCharts({
endPointStatusCodeBarChartsDataQuery,
endPointStatusCodeLatencyBarChartsDataQuery,
domainName,
filterExpression,
filters,
timeRange,
onDragSelect,
}: {
@@ -49,7 +50,7 @@ function StatusCodeBarCharts({
unknown
>;
domainName: string;
filterExpression: string;
filters: IBuilderQuery['filters'];
timeRange: {
startTime: number;
endTime: number;
@@ -129,8 +130,12 @@ function StatusCodeBarCharts({
);
const widget = useMemo<Widgets>(
() => getStatusCodeBarChartWidgetData(domainName, filterExpression),
[domainName, filterExpression],
() =>
getStatusCodeBarChartWidgetData(domainName, {
items: [...(filters?.items || [])],
op: filters?.op || 'AND',
}),
[domainName, filters],
);
const activeApiResponse = useMemo(

View File

@@ -1,157 +0,0 @@
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { FilterProviders } from 'container/ApiMonitoring/__tests__/FilterProviders';
import { getCurrentNuqsSearchParams, resetNuqsState } from 'tests/nuqs-helpers';
import {
act,
render,
renderHook,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { useFilterExpression } from '../useFilterExpression';
const PARAM_KEY = 'testExpression';
const linkExpression = "service.name = 'frontend'";
const linkSearch = `${PARAM_KEY}=${encodeURIComponent(linkExpression)}`;
function renderFilterHook(
searchParams = '',
): ReturnType<
typeof renderHook<ReturnType<typeof useFilterExpression>, unknown>
> {
return renderHook(() => useFilterExpression(), {
wrapper: ({ children }) => (
<FilterProviders queryParamKey={PARAM_KEY} searchParams={searchParams}>
{children}
</FilterProviders>
),
});
}
function FilterBox(): JSX.Element {
const { expression, onDraftChange, onBlur } = useFilterExpression();
return (
<div onBlur={onBlur}>
<span data-testid="applied">{expression}</span>
<input
data-testid="filter-input"
onChange={(e): void => onDraftChange(e.target.value)}
/>
<button type="button" data-testid="inside-button">
Run
</button>
</div>
);
}
describe('useFilterExpression', () => {
beforeEach(() => {
resetNuqsState();
});
it('reads the filter from the link', async () => {
const { result } = renderFilterHook(linkSearch);
await waitFor(() => expect(result.current.expression).toBe(linkExpression));
expect(result.current.queryData.dataSource).toBe('traces');
expect(result.current.queryData.filter?.expression).toBe(linkExpression);
});
it('applies a valid expression on run and ignores an invalid one', async () => {
const { result } = renderFilterHook(linkSearch);
await waitFor(() => expect(result.current.expression).toBe(linkExpression));
act(() => {
result.current.onRun('service.name =');
});
expect(result.current.expression).toBe(linkExpression);
act(() => {
result.current.onRun('http.status_code = 500');
});
expect(result.current.expression).toBe('http.status_code = 500');
await waitFor(() =>
expect(getCurrentNuqsSearchParams().get(PARAM_KEY)).toBe(
'http.status_code = 500',
),
);
});
it('runs again instead of applying when the filter is already applied', async () => {
const scope = "http_host = 'api.example.com'";
const { result } = renderHook(() => useFilterExpression(), {
wrapper: ({ children }) => (
<FilterProviders
queryParamKey={PARAM_KEY}
initialExpression={scope}
searchParams={linkSearch}
>
{children}
</FilterProviders>
),
});
await waitFor(() => expect(result.current.expression).toBe(linkExpression));
const onUnchanged = jest.fn() as jest.MockedFunction<() => void>;
act(() => {
result.current.onRun(
combineInitialAndUserExpression(scope, linkExpression),
onUnchanged,
);
});
expect(onUnchanged).toHaveBeenCalledTimes(1);
act(() => {
result.current.onRun(
combineInitialAndUserExpression(scope, 'http.status_code = 500'),
onUnchanged,
);
});
expect(onUnchanged).toHaveBeenCalledTimes(1);
expect(result.current.expression).toBe('http.status_code = 500');
});
it('applies the draft on blur only when focus leaves the container', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<FilterProviders queryParamKey={PARAM_KEY}>
<FilterBox />
</FilterProviders>,
);
await user.type(screen.getByTestId('filter-input'), "service.name = 'cart'");
await user.click(screen.getByTestId('inside-button'));
expect(screen.getByTestId('applied')).toHaveTextContent('');
await user.click(document.body);
expect(screen.getByTestId('applied')).toHaveTextContent(
"service.name = 'cart'",
);
});
it('keeps only the user text when the scope is set', async () => {
const scope = "http_host = 'api.example.com'";
const { result } = renderHook(() => useFilterExpression(), {
wrapper: ({ children }) => (
<FilterProviders queryParamKey={PARAM_KEY} initialExpression={scope}>
{children}
</FilterProviders>
),
});
await waitFor(() => expect(result.current.initialExpression).toBe(scope));
act(() => {
result.current.onRun(`${scope} AND (service.name = 'cart')`);
});
expect(result.current.expression).toBe("service.name = 'cart'");
await waitFor(() =>
expect(getCurrentNuqsSearchParams().get(PARAM_KEY)).toBe(
"service.name = 'cart'",
),
);
});
});

View File

@@ -1,82 +0,0 @@
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider, QueryObserver } from 'react-query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
import { act, renderHook, waitFor } from 'tests/test-utils';
import { Widgets } from 'types/api/widgets/widget';
import { useWidgetQueries } from '../useWidgetQueries';
const widgetWithQueryId = (id: string): Widgets =>
getWidgetQueryBuilder({
query: { ...initialQueriesMap.traces, id },
panelTypes: PANEL_TYPES.TABLE,
});
const gridCardKey = (queryId: string): unknown[] => [
REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE,
0,
0,
{ id: queryId },
];
function startGridCardFetch(client: QueryClient, queryId: string): void {
void client.prefetchQuery(gridCardKey(queryId), () => new Promise(() => {}));
}
function renderWidgetQueries(
client: QueryClient,
widgets: Widgets[],
): ReturnType<typeof renderHook<ReturnType<typeof useWidgetQueries>, unknown>> {
return renderHook(() => useWidgetQueries(widgets), {
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
),
});
}
describe('useWidgetQueries', () => {
it('counts only the given widgets’ grid card queries', async () => {
const client = new QueryClient();
const { result } = renderWidgetQueries(client, [widgetWithQueryId('table')]);
await act(async () => {
startGridCardFetch(client, 'other-tab-chart');
await new Promise((resolve) => setTimeout(resolve, 50));
});
expect(client.isFetching()).toBe(1);
expect(result.current.isFetching).toBe(false);
act(() => startGridCardFetch(client, 'table'));
await waitFor(() => expect(result.current.isFetching).toBe(true));
});
it('refetches only the given widgets’ grid card queries', async () => {
const client = new QueryClient();
const fetchTable = jest.fn(() =>
Promise.resolve('table'),
) as jest.MockedFunction<() => Promise<string>>;
const fetchOther = jest.fn(() =>
Promise.resolve('other'),
) as jest.MockedFunction<() => Promise<string>>;
const unsubscribers = [
new QueryObserver(client, {
queryKey: gridCardKey('table'),
queryFn: fetchTable,
}).subscribe(() => {}),
new QueryObserver(client, {
queryKey: gridCardKey('other-tab-chart'),
queryFn: fetchOther,
}).subscribe(() => {}),
];
await waitFor(() => expect(client.isFetching()).toBe(0));
const { result } = renderWidgetQueries(client, [widgetWithQueryId('table')]);
act(() => result.current.refetch());
await waitFor(() => expect(fetchTable).toHaveBeenCalledTimes(2));
expect(fetchOther).toHaveBeenCalledTimes(1);
unsubscribers.forEach((unsubscribe) => unsubscribe());
});
});

View File

@@ -1,93 +0,0 @@
import { FocusEvent, useCallback, useEffect, useMemo, useRef } from 'react';
import {
useInitialExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnRun,
useUserExpression,
} from 'components/QueryBuilderV2';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { initialQueriesMap } from 'constants/queryBuilder';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { validateQuery } from 'utils/queryValidationUtils';
interface UseFilterExpressionParams {
groupBy?: IBuilderQuery['groupBy'];
}
interface UseFilterExpressionReturn {
expression: string;
/** The provider's locked scope, for `QuerySearch`'s `initialExpression` */
initialExpression: string | undefined;
queryData: IBuilderQuery;
onDraftChange: (value: string) => void;
/** `onUnchanged` runs instead when the filter is already applied. */
onRun: (value: string, onUnchanged?: () => void) => void;
onBlur: (event: FocusEvent<HTMLDivElement>) => void;
}
/** Must be used inside a `QuerySearchV2Provider`. */
export function useFilterExpression({
groupBy,
}: UseFilterExpressionParams = {}): UseFilterExpressionReturn {
const expression = useUserExpression();
const scope = useInitialExpression();
const initialExpression = useQuerySearchInitialExpressionProp();
const querySearchOnRun = useQuerySearchOnRun();
const queryData = useMemo(
(): IBuilderQuery => ({
...initialQueriesMap[DataSource.TRACES].builder.queryData[0],
dataSource: DataSource.TRACES,
filter: { expression },
...(groupBy ? { groupBy } : {}),
}),
[expression, groupBy],
);
const onRun = useCallback(
(value: string, onUnchanged?: () => void): void => {
if (!validateQuery(value).isValid) {
return;
}
if (getUserExpressionFromCombined(scope, value) === expression) {
onUnchanged?.();
return;
}
querySearchOnRun(value);
},
[querySearchOnRun, scope, expression],
);
// A ref, not the provider's draft, so typing does not re-render the page.
const draftRef = useRef(expression);
useEffect(() => {
draftRef.current = expression;
}, [expression]);
const onDraftChange = useCallback((value: string): void => {
draftRef.current = value;
}, []);
const onBlur = useCallback(
(event: FocusEvent<HTMLDivElement>): void => {
if (event.currentTarget.contains(event.relatedTarget as Node | null)) {
return;
}
onRun(combineInitialAndUserExpression(scope, draftRef.current));
},
[onRun, scope],
);
return {
expression,
initialExpression,
queryData,
onDraftChange,
onRun,
onBlur,
};
}

View File

@@ -1,45 +0,0 @@
import { useCallback, useMemo } from 'react';
import {
Query as ReactQuery,
useIsFetching,
useQueryClient,
} from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { Widgets } from 'types/api/widgets/widget';
interface UseWidgetQueriesReturn {
isFetching: boolean;
refetch: () => void;
}
/**
* These widgets' `GridCard` queries. Matches on the widget's query id, so
* another tab's cards are neither counted nor refetched.
*/
export function useWidgetQueries(widgets: Widgets[]): UseWidgetQueriesReturn {
const queryClient = useQueryClient();
const predicate = useMemo(() => {
const queryIds = new Set(
widgets
.map((widget) => widget.query?.id)
.filter((id): id is string => Boolean(id)),
);
return ({ queryKey }: ReactQuery): boolean =>
Array.isArray(queryKey) &&
queryKey[0] === REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE &&
queryKey.some((part) => {
const id = (part as Query | undefined)?.id;
return !!id && queryIds.has(id);
});
}, [widgets]);
const isFetching = useIsFetching({ predicate }) > 0;
const refetch = useCallback((): void => {
void queryClient.refetchQueries({ predicate, active: true });
}, [queryClient, predicate]);
return { isFetching, refetch };
}

View File

@@ -19,7 +19,6 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useListOverview } from 'hooks/thirdPartyApis/useListOverview';
import { get } from 'lodash-es';
import { parseAsString, useQueryStates } from 'nuqs';
import { AppState } from 'store/reducers';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
@@ -28,26 +27,16 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
import { ApiMonitoringHardcodedAttributeKeys } from '../../constants';
import {
ALL_ENDPOINTS_EXPRESSION_KEY,
ENDPOINT_STATS_EXPRESSION_KEY,
} from '../../filterParamKeys';
import { DEFAULT_PARAMS, useApiMonitoringParams } from '../../queryParams';
import { columnsConfig, formatDataForTable } from '../../utils';
import DomainDetails from './DomainDetails/DomainDetails';
import { useConvertLegacyFilterLinks } from './hooks/useConvertLegacyFilterLinks';
import '../Explorer.styles.scss';
function DomainList(): JSX.Element {
const [params, setParams] = useApiMonitoringParams();
const { showIP, selectedDomain } = params;
const [, setDrawerFilters] = useQueryStates({
[ALL_ENDPOINTS_EXPRESSION_KEY]: parseAsString,
[ENDPOINT_STATS_EXPRESSION_KEY]: parseAsString,
});
const [selectedDomainIndex, setSelectedDomainIndex] = useState<number>(-1);
useConvertLegacyFilterLinks();
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
@@ -66,8 +55,6 @@ function DomainList(): JSX.Element {
});
const compositeData = useGetCompositeQueryParam();
const listFilterExpression =
compositeData?.builder?.queryData[0]?.filter?.expression ?? '';
const [isCancelled, setIsCancelled] = useState(false);
@@ -168,7 +155,6 @@ function DomainList(): JSX.Element {
dataSource={DataSource.TRACES}
queryData={query}
onChange={handleSearchChange}
onRun={handleStageAndRunQuery}
placeholder="Enter your filter query (e.g., deployment.environment = 'otel-demo' AND service.name = 'frontend')"
hardcodedAttributeKeys={ApiMonitoringHardcodedAttributeKeys}
/>
@@ -239,11 +225,6 @@ function DomainList(): JSX.Element {
);
setSelectedDomainIndex(dataIndex);
setParams({ selectedDomain: record.domainName });
// The drawer tabs start from the list's filter
void setDrawerFilters({
[ALL_ENDPOINTS_EXPRESSION_KEY]: listFilterExpression || null,
[ENDPOINT_STATS_EXPRESSION_KEY]: listFilterExpression || null,
});
logEvent('API Monitoring: Domain name row clicked', {});
}
},
@@ -264,12 +245,8 @@ function DomainList(): JSX.Element {
handleClose={(): void => {
setSelectedDomainIndex(-1);
setParams(DEFAULT_PARAMS);
void setDrawerFilters({
[ALL_ENDPOINTS_EXPRESSION_KEY]: null,
[ENDPOINT_STATS_EXPRESSION_KEY]: null,
});
}}
domainListFilterExpression={listFilterExpression}
domainListFilters={query?.filters}
/>
)}
</section>

View File

@@ -1,122 +0,0 @@
import { ReactNode, useEffect, useState } from 'react';
import {
ALL_ENDPOINTS_EXPRESSION_KEY,
ENDPOINT_STATS_EXPRESSION_KEY,
} from 'container/ApiMonitoring/filterParamKeys';
import { getApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import {
getCurrentNuqsSearchParams,
onNuqsUrlUpdate,
resetNuqsState,
} from 'tests/nuqs-helpers';
import { mockAppLocation, mockNavigate } from 'tests/router-mocks';
import { renderHook, waitFor } from 'tests/test-utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
import { useConvertLegacyFilterLinks } from '../useConvertLegacyFilterLinks';
const legacyFilter = (key: string, value: string): TagFilter => ({
op: 'AND',
items: [
{
id: key,
key: { key, dataType: DataTypes.String, type: 'tag' },
op: '=',
value,
},
],
});
// NuqsTestingAdapter resets its update queue on every render, not only on mount,
// dropping writes queued just before it re-renders.
function UrlAdapter({
searchParams,
children,
}: {
searchParams: string;
children: ReactNode;
}): JSX.Element {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<NuqsTestingAdapter
searchParams={searchParams}
onUrlUpdate={onNuqsUrlUpdate}
rateLimitFactor={0}
resetUrlUpdateQueueOnMount={!mounted}
hasMemory
>
{children}
</NuqsTestingAdapter>
);
}
function renderWithLink(linkParams: object): jest.SpyInstance {
const search = `?apiMonitoringParams=${encodeURIComponent(
encodeURIComponent(JSON.stringify(linkParams)),
)}`;
__setSearchParamsGetterForTest(() => new URLSearchParams(search));
mockAppLocation({ search });
const navigateSpy = mockNavigate();
renderHook(() => useConvertLegacyFilterLinks(), {
wrapper: ({ children }: { children: ReactNode }) => (
<UrlAdapter searchParams={search}>{children}</UrlAdapter>
),
});
return navigateSpy;
}
describe('useConvertLegacyFilterLinks', () => {
beforeEach(() => {
resetNuqsState();
});
afterEach(() => {
__resetSearchParamsGetter();
});
it('moves an older link’s filters into the drawer params once', async () => {
const navigateSpy = renderWithLink({
selectedDomain: 'api.example.com',
allEndpointsLocalFilters: legacyFilter('service.name', 'cart'),
endPointDetailsLocalFilters: legacyFilter('http.status_code', '500'),
});
await waitFor(() => {
const params = getCurrentNuqsSearchParams();
expect(params.get(ALL_ENDPOINTS_EXPRESSION_KEY)).toBe(
"service.name = 'cart'",
);
expect(params.get(ENDPOINT_STATS_EXPRESSION_KEY)).toBe(
"http.status_code = '500'",
);
});
const [to, options] = navigateSpy.mock.calls[0];
const rewritten = getApiMonitoringParams(
typeof to === 'string' ? to : (to.search ?? ''),
);
expect(rewritten.selectedDomain).toBe('api.example.com');
expect(rewritten).not.toHaveProperty('allEndpointsLocalFilters');
expect(options).toStrictEqual({ replace: true });
});
it('leaves a link without legacy filters alone', () => {
const navigateSpy = renderWithLink({ selectedDomain: 'api.example.com' });
expect(navigateSpy).not.toHaveBeenCalledWith(
expect.objectContaining({ search: expect.any(String) }),
expect.anything(),
);
expect(getCurrentNuqsSearchParams().has(ALL_ENDPOINTS_EXPRESSION_KEY)).toBe(
false,
);
});
});

View File

@@ -1,42 +0,0 @@
import { useEffect } from 'react';
import { parseAsString, useQueryStates } from 'nuqs';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import {
ALL_ENDPOINTS_EXPRESSION_KEY,
ENDPOINT_STATS_EXPRESSION_KEY,
} from '../../../filterParamKeys';
import {
getLegacyFilterExpressions,
useApiMonitoringParams,
} from '../../../queryParams';
/**
* Moves the filters an older link keeps inside `apiMonitoringParams` into the
* drawer's own params, then rewrites `apiMonitoringParams` without them, so it
* happens once.
*/
export function useConvertLegacyFilterLinks(): void {
const [, setParams] = useApiMonitoringParams();
const [, setDrawerFilters] = useQueryStates({
[ALL_ENDPOINTS_EXPRESSION_KEY]: parseAsString,
[ENDPOINT_STATS_EXPRESSION_KEY]: parseAsString,
});
useEffect(() => {
const legacy = getLegacyFilterExpressions(
`?${getUnstableCurrentSearchParams().toString()}`,
);
if (
legacy.allEndpoints === undefined &&
legacy.endPointDetails === undefined
) {
return;
}
void setDrawerFilters({
[ALL_ENDPOINTS_EXPRESSION_KEY]: legacy.allEndpoints || null,
[ENDPOINT_STATS_EXPRESSION_KEY]: legacy.endPointDetails || null,
});
setParams({}, true);
}, [setParams, setDrawerFilters]);
}

View File

@@ -1,4 +1,6 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
import {
@@ -9,14 +11,20 @@ import {
getFormattedEndPointDropDownData,
getFormattedEndPointStatusCodeChartData,
getFormattedEndPointStatusCodeData,
getGroupByExpressionFromRowData,
getHttpUrlExpression,
getGroupByFiltersFromGroupByValues,
getStatusCodeBarChartWidgetData,
getTopErrorsColumnsConfig,
getTopErrorsCoRelationQueryFilters,
} from '../utils';
import { APIMonitoringColumnsMock } from './mock';
// Mock or define DataTypes since it seems to be missing from imports
const DataTypes = {
String: 'string',
Float64: 'float64',
bool: 'bool',
};
// Mock the external utils dependencies that are used within our tested functions
jest.mock('../utils', () => {
// Import the actual module to partial mock
@@ -82,39 +90,140 @@ describe('API Monitoring Utils', () => {
expect(result[0].lastUsed).toBe('-');
});
});
describe('getGroupByExpressionFromRowData', () => {
it('builds an AND expression from the row values, skipping the url and metric columns', () => {
expect(
getGroupByExpressionFromRowData({
http_url: '/api/test',
key: '3f2a-row-uuid',
A: 10,
B: 20,
C: 1,
F1: 5,
'service.name': 'api-service',
'http.status_code': 200,
}),
).toBe("service.name = 'api-service' AND http.status_code = 200");
});
describe('getGroupByFiltersFromGroupByValues', () => {
it('should convert row data to filters correctly', () => {
// Arrange
const rowData = {
'http.method': 'GET',
'http.status_code': '200',
'service.name': 'api-service',
// Fields that should be filtered out
data: 'someValue',
key: 'someKey',
};
it('escapes quotes in values', () => {
expect(getGroupByExpressionFromRowData({ 'user.name': "o'brien" })).toBe(
"user.name = 'o\\'brien'",
const groupBy = [
{
id: 'group-by-1',
key: 'http.method',
dataType: DataTypes.String,
type: '',
},
{
id: 'group-by-2',
key: 'http.status_code',
dataType: DataTypes.String,
type: '',
},
{
id: 'group-by-3',
key: 'service.name',
dataType: DataTypes.String,
type: 'tag',
},
];
// Act
const result = getGroupByFiltersFromGroupByValues(
rowData,
groupBy as BaseAutocompleteData[],
);
});
it('matches spans without the attribute when the group has no value', () => {
// Assert
expect(result).toBeDefined();
expect(result?.op).toBe('AND');
// The implementation includes all keys from rowData, not just those in groupBy
expect(result?.items?.length).toBeGreaterThanOrEqual(3);
// Verify each filter matches the corresponding groupBy
expect(
getGroupByExpressionFromRowData({
'service.name': 'cart',
'ai.embeddings': null,
}),
).toBe("service.name = 'cart' AND ai.embeddings NOT EXISTS");
result?.items?.some(
(item) =>
item.key &&
item.key.key === 'http.method' &&
item.value === 'GET' &&
item.op === '=',
),
).toBe(true);
expect(
result?.items?.some(
(item) =>
item.key &&
item.key.key === 'http.status_code' &&
item.value === '200' &&
item.op === '=',
),
).toBe(true);
expect(
result?.items?.some(
(item) =>
item.key &&
item.key.key === 'service.name' &&
item.value === 'api-service' &&
item.op === '=',
),
).toBe(true);
});
it('returns an empty expression for a row without group columns', () => {
expect(getGroupByExpressionFromRowData({ http_url: '/x', A: 1 })).toBe('');
it('should handle fields not in groupBy', () => {
// Arrange
const rowData = {
'http.method': 'GET',
'unknown.field': 'someValue',
};
const groupBy = [
{
id: 'group-by-1',
key: 'http.method',
dataType: DataTypes.String,
type: '',
},
];
// Act
const result = getGroupByFiltersFromGroupByValues(
rowData,
groupBy as BaseAutocompleteData[],
);
// Assert
expect(result).toBeDefined();
// The implementation includes all keys from rowData, not just those in groupBy
expect(result?.items?.length).toBeGreaterThanOrEqual(1);
// Should include the known field with the proper dataType from groupBy
const knownField = result?.items?.find(
(item) => item.key && item.key.key === 'http.method',
);
expect(knownField).toBeDefined();
if (knownField && knownField.key) {
expect(knownField.key.dataType).toBe(DataTypes.String);
}
// Should include the unknown field
const unknownField = result?.items?.find(
(item) => item.key && item.key.key === 'unknown.field',
);
expect(unknownField).toBeDefined();
if (unknownField && unknownField.key) {
expect(unknownField.key.dataType).toBe(DataTypes.String); // Default
}
});
it('should handle empty input', () => {
// Arrange
const rowData = {};
const groupBy: any[] = [];
// Act
const result = getGroupByFiltersFromGroupByValues(rowData, groupBy);
// Assert
expect(result).toBeDefined();
expect(result?.op).toBe('AND');
expect(result?.items).toHaveLength(0);
});
});
@@ -594,9 +703,13 @@ describe('API Monitoring Utils', () => {
it('should generate widget configuration for status code bar chart', () => {
// Arrange
const domainName = 'test-domain';
const filters = { items: [], op: 'AND' };
// Act
const result = getStatusCodeBarChartWidgetData(domainName, '');
const result = getStatusCodeBarChartWidgetData(
domainName,
filters as IBuilderQuery['filters'],
);
// Assert
expect(result).toBeDefined();
@@ -610,29 +723,47 @@ describe('API Monitoring Utils', () => {
const queryData = result.query.builder.queryData[0];
// Should have domain filter
expect(queryData.filter?.expression).toBe(
`${SPAN_ATTRIBUTES.SERVER_NAME} = '${domainName}'`,
const domainFilter = queryData.filters?.items?.find(
(item) => item.key && item.key.key === SPAN_ATTRIBUTES.SERVER_NAME,
);
expect(domainFilter).toBeDefined();
if (domainFilter) {
expect(domainFilter.value).toBe(domainName);
}
});
it('should include custom filters in the widget configuration', () => {
// Arrange
const domainName = 'test-domain';
const customFilterExpression = "custom.key = 'custom-value'";
const customFilter = {
id: 'custom-filter',
key: {
dataType: 'string',
key: 'custom.key',
type: '',
},
op: '=',
value: 'custom-value',
};
const filters = { items: [customFilter], op: 'AND' };
// Act
const result = getStatusCodeBarChartWidgetData(
domainName,
customFilterExpression,
filters as IBuilderQuery['filters'],
);
// Assert
const queryData = result.query.builder.queryData[0];
// Should include our custom filter after the domain filter
expect(queryData.filter?.expression).toBe(
`${SPAN_ATTRIBUTES.SERVER_NAME} = '${domainName}' AND (${customFilterExpression})`,
// Should include our custom filter
const includedFilter = queryData.filters?.items?.find(
(item) => item.id === 'custom-filter',
);
expect(includedFilter).toBeDefined();
if (includedFilter) {
expect(includedFilter.value).toBe('custom-value');
}
});
});
@@ -796,17 +927,4 @@ describe('API Monitoring Utils', () => {
expect(latencyColumn?.title).toBeDefined();
});
});
describe('getHttpUrlExpression', () => {
it('returns no clause when no endpoint is picked', () => {
expect(getHttpUrlExpression('')).toBe('');
});
it('quotes the endpoint and escapes apostrophes', () => {
expect(getHttpUrlExpression('/x')).toBe("http_url = '/x'");
expect(getHttpUrlExpression("/users/o'brien")).toBe(
"http_url = '/users/o\\'brien'",
);
});
});
});

View File

@@ -1,17 +1,11 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import {
getAllEndpointsWidgetData,
getGroupByExpressionFromRowData,
getGroupByFiltersFromGroupByValues,
} from 'container/ApiMonitoring/utils';
import {
ALL_ENDPOINTS_EXPRESSION_KEY,
ENDPOINT_STATS_EXPRESSION_KEY,
} from 'container/ApiMonitoring/filterParamKeys';
import { getCurrentNuqsSearchParams, resetNuqsState } from 'tests/nuqs-helpers';
import { cleanup, fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import AllEndPoints from '../Explorer/Domains/DomainDetails/AllEndPoints';
import { FilterProviders } from './FilterProviders';
import {
SPAN_ATTRIBUTES,
VIEWS,
@@ -20,7 +14,7 @@ import {
// Mock the dependencies
jest.mock('container/ApiMonitoring/utils', () => ({
getAllEndpointsWidgetData: jest.fn(),
getGroupByExpressionFromRowData: jest.fn(),
getGroupByFiltersFromGroupByValues: jest.fn(),
}));
jest.mock('container/WidgetCard/Card', () => ({
@@ -40,47 +34,53 @@ jest.mock('container/WidgetCard/Card', () => ({
)),
}));
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onRun }) => (
<div data-testid="query-builder-mock">
<button
type="button"
data-testid="filter-change-button"
onClick={(): void => onRun("test = 'test'")}
>
Change Filter
</button>
</div>
)),
jest.mock(
'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2',
() => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onChange }) => (
<div data-testid="query-builder-mock">
<button
type="button"
data-testid="filter-change-button"
onClick={(): void =>
onChange({
items: [{ id: 'test', key: 'test', op: '=', value: 'test' }],
op: 'AND',
})
}
>
Change Filter
</button>
</div>
)),
}),
);
jest.mock('hooks/queryBuilder/useGetAggregateKeys', () => ({
useGetAggregateKeys: jest.fn(),
}));
jest.mock('container/QueryBuilder/filters/GroupByFilter/GroupByFilter', () => ({
GroupByFilter: jest.fn().mockImplementation(({ onChange }) => (
<div data-testid="select-mock">
<button
data-testid="select-change-button"
type="button"
onClick={(): void =>
onChange([
{
id: 'http.status_code--string--attribute',
key: 'http.status_code',
dataType: 'string',
type: 'attribute',
},
])
}
>
Change GroupBy
</button>
</div>
)),
}));
jest.mock('antd', () => {
const originalModule = jest.requireActual('antd');
return {
...originalModule,
Select: jest.fn().mockImplementation(({ onChange }) => (
<div data-testid="select-mock">
<button
data-testid="select-change-button"
type="button"
onClick={(): void => onChange(['http.status_code'])}
>
Change GroupBy
</button>
</div>
)),
};
});
// Mock useApiMonitoringParams hook
jest.mock('container/ApiMonitoring/queryParams', () => ({
...jest.requireActual('container/ApiMonitoring/queryParams'),
useApiMonitoringParams: jest.fn().mockReturnValue([
{
showIP: true,
@@ -88,6 +88,8 @@ jest.mock('container/ApiMonitoring/queryParams', () => ({
selectedView: 'all_endpoints',
selectedEndPointName: '',
groupBy: [],
allEndpointsLocalFilters: undefined,
endPointDetailsLocalFilters: undefined,
modalTimeRange: undefined,
selectedInterval: undefined,
},
@@ -106,21 +108,29 @@ describe('AllEndPoints', () => {
startTime: 1609459200000,
endTime: 1609545600000,
},
};
const renderAllEndPoints = (): void => {
render(
<FilterProviders queryParamKey={ALL_ENDPOINTS_EXPRESSION_KEY}>
<AllEndPoints {...mockProps} />
</FilterProviders>,
);
initialFilters: { op: 'AND', items: [] },
setInitialFiltersEndPointStats: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
resetNuqsState();
// Setup mock implementations
(useGetAggregateKeys as jest.Mock).mockReturnValue({
data: {
payload: {
attributeKeys: [
{
key: 'http.status_code',
dataType: 'string',
type: '',
},
],
},
},
isLoading: false,
});
(getAllEndpointsWidgetData as jest.Mock).mockReturnValue({
id: 'test-widget',
title: 'Endpoint Overview',
@@ -129,9 +139,10 @@ describe('AllEndPoints', () => {
queryData: [],
});
(getGroupByExpressionFromRowData as jest.Mock).mockReturnValue(
"status = '200'",
);
(getGroupByFiltersFromGroupByValues as jest.Mock).mockReturnValue({
items: [{ id: 'group-filter', key: 'status', op: '=', value: '200' }],
op: 'AND',
});
});
// Add cleanup after each test
@@ -140,7 +151,7 @@ describe('AllEndPoints', () => {
});
it('renders component correctly', () => {
renderAllEndPoints();
render(<AllEndPoints {...mockProps} />);
// Verify basic component rendering
expect(screen.getByText('Group by')).toBeInTheDocument();
@@ -150,7 +161,7 @@ describe('AllEndPoints', () => {
});
it('handles filter changes', () => {
renderAllEndPoints();
render(<AllEndPoints {...mockProps} />);
// Trigger filter change
fireEvent.click(screen.getByTestId('filter-change-button'));
@@ -159,24 +170,25 @@ describe('AllEndPoints', () => {
expect(getAllEndpointsWidgetData).toHaveBeenCalledWith(
expect.anything(),
'test-domain',
"test = 'test'",
expect.objectContaining({
items: expect.arrayContaining([expect.objectContaining({ id: 'test' })]),
op: 'AND',
}),
);
});
it('handles group by changes', () => {
renderAllEndPoints();
render(<AllEndPoints {...mockProps} />);
// Trigger group by change
fireEvent.click(screen.getByTestId('select-change-button'));
// The picked keys are stored as given and written to the URL as bare names
expect(mockProps.setGroupBy).toHaveBeenCalledWith([
expect.objectContaining({ key: 'http.status_code', type: 'attribute' }),
]);
// Check if setGroupBy was called with updated group by value
expect(mockProps.setGroupBy).toHaveBeenCalled();
});
it('handles row click in grid card', async () => {
renderAllEndPoints();
render(<AllEndPoints {...mockProps} />);
// Trigger row click
fireEvent.click(screen.getByTestId('row-click-button'));
@@ -184,11 +196,7 @@ describe('AllEndPoints', () => {
// Check if proper functions were called
expect(mockProps.setSelectedEndPointName).toHaveBeenCalledWith('/api/test');
expect(mockProps.setSelectedView).toHaveBeenCalledWith(VIEWS.ENDPOINT_STATS);
expect(getGroupByExpressionFromRowData).toHaveBeenCalled();
await waitFor(() =>
expect(getCurrentNuqsSearchParams().get(ENDPOINT_STATS_EXPRESSION_KEY)).toBe(
"status = '200'",
),
);
expect(mockProps.setInitialFiltersEndPointStats).toHaveBeenCalled();
expect(getGroupByFiltersFromGroupByValues).toHaveBeenCalled();
});
});

View File

@@ -14,12 +14,16 @@ import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
describe('AllEndpointsWidget - V5 Migration Validation', () => {
const mockDomainName = 'api.example.com';
const emptyFilters = '';
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
const emptyGroupBy: BaseAutocompleteData[] = [];
describe('1. V5 Format Migration - All Four Queries', () => {

View File

@@ -121,7 +121,8 @@ describe('DependentServices', () => {
);
});
it('keeps the table in place while loading', () => {
it('renders loading state correctly', () => {
// Arrange
const mockQuery: MockQueryResult = {
isLoading: true,
isRefetching: false,
@@ -130,15 +131,16 @@ describe('DependentServices', () => {
refetch: refetchFn,
};
render(
// Act
const { container } = render(
<DependentServices
dependentServicesQuery={mockQuery as any}
timeRange={mockTimeRange}
/>,
);
expect(screen.getByTestId('dependent-services-table')).toBeInTheDocument();
expect(screen.getByTestId('loading-state')).toHaveTextContent('Loading');
// Assert
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('renders error state correctly', () => {
@@ -223,7 +225,8 @@ describe('DependentServices', () => {
expect(screen.getByTestId('page-size')).toHaveTextContent('5');
});
it('shows the rows under the spinner while refetching', () => {
it('handles refetching state correctly', () => {
// Arrange
const mockQuery: MockQueryResult = {
isLoading: false,
isRefetching: true,
@@ -232,16 +235,16 @@ describe('DependentServices', () => {
refetch: refetchFn,
};
render(
// Act
const { container } = render(
<DependentServices
dependentServicesQuery={mockQuery as any}
timeRange={mockTimeRange}
/>,
);
expect(screen.getByTestId('loading-state')).toHaveTextContent('Loading');
expect(screen.getByTestId('row-count')).toHaveTextContent('2');
expect(screen.getByTestId('table-row-0')).toHaveTextContent('auth-service');
// Assert
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('handles row click correctly', () => {

View File

@@ -1,60 +0,0 @@
import { useApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
import { render, screen } from 'tests/test-utils';
import { VIEWS } from '../Explorer/Domains/DomainDetails/constants';
import DomainDetails from '../Explorer/Domains/DomainDetails/DomainDetails';
jest.mock('container/ApiMonitoring/queryParams', () => ({
...jest.requireActual('container/ApiMonitoring/queryParams'),
useApiMonitoringParams: jest.fn(),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('../Explorer/Domains/DomainDetails/components/DomainMetrics', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('../Explorer/Domains/DomainDetails/EndPointDetails', () => {
const { useInitialExpression } = jest.requireActual(
'components/QueryBuilderV2',
);
return {
__esModule: true,
default: function MockEndPointDetails(): JSX.Element {
return (
<span data-testid="endpoint-stats-scope">{useInitialExpression()}</span>
);
},
};
});
describe('DomainDetails', () => {
it('scopes Endpoint Stats to the domain and the picked endpoint', () => {
jest
.mocked(useApiMonitoringParams)
.mockReturnValue([
{ selectedView: VIEWS.ENDPOINT_STATS, selectedEndPointName: '/v1/charges' },
jest.fn(),
]);
render(
<DomainDetails
domainData={{ domainName: 'api.example.com' }}
handleClose={jest.fn()}
selectedDomainIndex={0}
setSelectedDomainIndex={jest.fn()}
domainListLength={1}
domainListFilterExpression=""
/>,
);
expect(screen.getByTestId('endpoint-stats-scope')).toHaveTextContent(
"kind_string = 'Client' AND http_host = 'api.example.com' AND http_url = '/v1/charges'",
);
});
});

View File

@@ -1,84 +0,0 @@
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import DomainList from '../Explorer/Domains/DomainList';
const FILTER = "http_host = 'api.example.com'";
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(
({
onChange,
onRun,
}: {
onChange: (query: string) => void;
onRun?: (query: string) => void;
}) => (
<>
<button
type="button"
data-testid="query-search-type"
onClick={(): void => onChange("http_host = 'api.example.com'")}
>
Type
</button>
<button
type="button"
data-testid="query-search-run"
onClick={(): void => onRun?.("http_host = 'api.example.com'")}
>
Run
</button>
</>
),
),
}));
jest.mock('container/Toolbar/Toolbar', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('../Explorer/Domains/DomainDetails/DomainDetails', () => ({
__esModule: true,
default: (): null => null,
}));
// Under the test router it keeps rewriting the URL, which re-renders the page
// without end.
jest.mock('hooks/queryBuilder/useShareBuilderUrl', () => ({
useShareBuilderUrl: jest.fn(),
}));
describe('DomainList', () => {
it('runs the typed filter from the filter box', async () => {
let listRequests = 0;
server.use(
rest.post('*/third-party-apis/overview/list', (_req, res, ctx) => {
listRequests += 1;
return res(ctx.status(200), ctx.json({ data: { data: { results: [] } } }));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<DomainList />, undefined, {
initialRoute: '/api-monitoring/explorer',
});
await waitFor(() => expect(listRequests).toBe(1));
await user.click(screen.getByTestId('query-search-type'));
await user.click(screen.getByTestId('query-search-run'));
await waitFor(() => expect(listRequests).toBe(2));
const [url] = safeNavigateMock.mock.calls[0];
const compositeQuery = new URLSearchParams(String(url).split('?')[1]).get(
'compositeQuery',
);
expect(decodeURIComponent(compositeQuery ?? '')).toContain(
`"expression":"${FILTER}"`,
);
});
});

View File

@@ -1,7 +1,5 @@
import { useQueries } from 'react-query';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ENDPOINT_STATS_EXPRESSION_KEY } from 'container/ApiMonitoring/filterParamKeys';
import { fireEvent, render, screen } from '@testing-library/react';
import {
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
extractPortAndEndpoint,
@@ -13,13 +11,14 @@ import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { getCurrentNuqsSearchParams, resetNuqsState } from 'tests/nuqs-helpers';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
TagFilter,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
import EndPointPicker from '../Explorer/Domains/DomainDetails/components/EndPointPicker';
import EndPointDetails from '../Explorer/Domains/DomainDetails/EndPointDetails';
import { FilterProviders } from './FilterProviders';
// Mock dependencies
jest.mock('react-query', () => ({
@@ -27,32 +26,72 @@ jest.mock('react-query', () => ({
useQueries: jest.fn(),
}));
// Mock useApiMonitoringParams hook
jest.mock('container/ApiMonitoring/queryParams', () => ({
useApiMonitoringParams: jest.fn().mockReturnValue([
{
showIP: true,
selectedDomain: '',
selectedView: 'all_endpoints',
selectedEndPointName: '',
groupBy: [],
allEndpointsLocalFilters: undefined,
endPointDetailsLocalFilters: undefined,
modalTimeRange: undefined,
selectedInterval: undefined,
},
jest.fn(),
]),
}));
jest.mock('container/ApiMonitoring/utils', () => ({
END_POINT_DETAILS_QUERY_KEYS_ARRAY: jest.requireActual(
'container/ApiMonitoring/utils',
).END_POINT_DETAILS_QUERY_KEYS_ARRAY,
END_POINT_DETAILS_QUERY_KEYS_ARRAY: [
'endPointMetricsData',
'endPointStatusCodeData',
'endPointDropDownData',
'endPointDependentServicesData',
'endPointStatusCodeBarChartsData',
'endPointStatusCodeLatencyBarChartsData',
],
extractPortAndEndpoint: jest.fn(),
getHttpUrlExpression: jest.requireActual('container/ApiMonitoring/utils')
.getHttpUrlExpression,
getEndPointDetailsQueryPayload: jest.fn(),
getLatencyOverTimeWidgetData: jest.fn(),
getRateOverTimeWidgetData: jest.fn(),
}));
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onRun }) => (
<div data-testid="query-builder-search">
<button
type="button"
data-testid="filter-change-button"
onClick={(): void => onRun("test.key = 'test-value'")}
>
Change Filter
</button>
</div>
)),
}));
jest.mock(
'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2',
() => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onChange }) => (
<div data-testid="query-builder-search">
<button
type="button"
data-testid="filter-change-button"
onClick={(): void =>
onChange({
items: [
{
id: 'test-filter',
key: {
key: 'test.key',
dataType: DataTypes.String,
type: 'tag',
},
op: '=',
value: 'test-value',
},
],
op: 'AND',
})
}
>
Change Filter
</button>
</div>
)),
}),
);
// Mock all child components to simplify testing
jest.mock(
@@ -68,15 +107,15 @@ jest.mock(
);
jest.mock(
'../Explorer/Domains/DomainDetails/components/EndPointPicker',
'../Explorer/Domains/DomainDetails/components/EndPointsDropDown',
() => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onChange }) => (
default: jest.fn().mockImplementation(({ setSelectedEndPointName }) => (
<div data-testid="endpoints-dropdown">
<button
type="button"
data-testid="select-endpoint-button"
onClick={(): void => onChange('/api/new-endpoint')}
onClick={(): void => setSelectedEndPointName('/api/new-endpoint')}
>
Select Endpoint
</button>
@@ -133,23 +172,8 @@ jest.mock(
}),
);
function renderStats(ui: JSX.Element, linkFilter = ''): void {
render(
<FilterProviders
queryParamKey={ENDPOINT_STATS_EXPRESSION_KEY}
searchParams={
linkFilter
? `${ENDPOINT_STATS_EXPRESSION_KEY}=${encodeURIComponent(linkFilter)}`
: ''
}
>
{ui}
</FilterProviders>,
);
}
describe('EndPointDetails Component', () => {
const mockQueryResults = Array(5).fill({
const mockQueryResults = Array(6).fill({
data: { data: [] },
isLoading: false,
isError: false,
@@ -160,6 +184,7 @@ describe('EndPointDetails Component', () => {
domainName: 'test-domain',
endPointName: '/api/test',
setSelectedEndPointName: jest.fn(),
initialFilters: { items: [], op: 'AND' } as TagFilter,
timeRange: {
startTime: 1609459200000,
endTime: 1609545600000,
@@ -172,7 +197,6 @@ describe('EndPointDetails Component', () => {
beforeEach(() => {
jest.clearAllMocks();
resetNuqsState();
(extractPortAndEndpoint as jest.Mock).mockReturnValue({
port: '8080',
@@ -185,6 +209,7 @@ describe('EndPointDetails Component', () => {
{ id: 'query3', label: 'Query 3' },
{ id: 'query4', label: 'Query 4' },
{ id: 'query5', label: 'Query 5' },
{ id: 'query6', label: 'Query 6' },
]);
(getRateOverTimeWidgetData as jest.Mock).mockReturnValue({
@@ -201,7 +226,7 @@ describe('EndPointDetails Component', () => {
});
it('renders the component correctly', () => {
renderStats(<EndPointDetails {...mockProps} />);
render(<EndPointDetails {...mockProps} />);
// Check all major components are rendered
expect(screen.getByTestId('query-builder-search')).toBeInTheDocument();
@@ -221,29 +246,44 @@ describe('EndPointDetails Component', () => {
});
it('calls getEndPointDetailsQueryPayload with correct parameters', () => {
renderStats(<EndPointDetails {...mockProps} />);
render(<EndPointDetails {...mockProps} />);
expect(getEndPointDetailsQueryPayload).toHaveBeenCalledWith(
'test-domain',
mockProps.timeRange.startTime,
mockProps.timeRange.endTime,
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`,
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
value: '/api/test',
}),
]),
op: 'AND',
}),
);
});
it('adds endpoint filter to initial filters', () => {
renderStats(<EndPointDetails {...mockProps} />);
render(<EndPointDetails {...mockProps} />);
expect(getEndPointDetailsQueryPayload).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
value: '/api/test',
}),
]),
}),
);
});
it('updates filters when the search expression is run', async () => {
renderStats(<EndPointDetails {...mockProps} />);
it('updates filters when QueryBuilderSearch changes', () => {
render(<EndPointDetails {...mockProps} />);
// Trigger filter change
fireEvent.click(screen.getByTestId('filter-change-button'));
@@ -254,37 +294,19 @@ describe('EndPointDetails Component', () => {
expect.anything(),
expect.anything(),
expect.anything(),
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test' AND (test.key = 'test-value')`,
);
await waitFor(() =>
expect(getCurrentNuqsSearchParams().get(ENDPOINT_STATS_EXPRESSION_KEY)).toBe(
"test.key = 'test-value'",
),
);
});
it('keeps the endpoint out of the editor and the picker options', () => {
renderStats(<EndPointDetails {...mockProps} />, "service.name = 'checkout'");
expect(QuerySearch).toHaveBeenLastCalledWith(
expect.objectContaining({
queryData: expect.objectContaining({
filter: { expression: "service.name = 'checkout'" },
}),
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'test.key' }),
value: 'test-value',
}),
]),
}),
expect.anything(),
);
expect(EndPointPicker).toHaveBeenLastCalledWith(
expect.objectContaining({
filterExpression: "service.name = 'checkout'",
value: '/api/test',
}),
expect.anything(),
);
});
it('handles endpoint dropdown selection', () => {
renderStats(<EndPointDetails {...mockProps} />);
render(<EndPointDetails {...mockProps} />);
// Trigger endpoint selection
fireEvent.click(screen.getByTestId('select-endpoint-button'));
@@ -296,50 +318,63 @@ describe('EndPointDetails Component', () => {
});
it('does not display dependent services when service filter is applied', () => {
renderStats(
<EndPointDetails {...mockProps} />,
"service.name = 'test-service'",
);
const propsWithServiceFilter = {
...mockProps,
initialFilters: {
items: [
{
id: 'service-filter',
key: {
key: 'service.name',
dataType: DataTypes.String,
type: 'tag',
},
op: '=',
value: 'test-service',
},
] as TagFilterItem[],
op: 'AND',
} as TagFilter,
};
render(<EndPointDetails {...propsWithServiceFilter} />);
// Dependent services should not be displayed
expect(screen.queryByTestId('dependent-services')).not.toBeInTheDocument();
});
it('passes the correct parameters to widget data generators', () => {
renderStats(<EndPointDetails {...mockProps} />);
render(<EndPointDetails {...mockProps} />);
expect(getRateOverTimeWidgetData).toHaveBeenCalledWith(
'test-domain',
'/api/test',
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
value: '/api/test',
}),
]),
}),
);
expect(getLatencyOverTimeWidgetData).toHaveBeenCalledWith(
'test-domain',
'/api/test',
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
);
});
it('keeps the previous dependent services while a new filter loads', () => {
renderStats(<EndPointDetails {...mockProps} />);
const queries = jest.mocked(useQueries).mock.calls[0][0];
expect(
queries.map(({ queryKey, keepPreviousData }) => [
(queryKey as unknown[])[0],
keepPreviousData,
]),
).toStrictEqual(
END_POINT_DETAILS_QUERY_KEYS_ARRAY.map((key) => [
key,
key === REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
]),
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
value: '/api/test',
}),
]),
}),
);
});
it('generates correct query parameters for useQueries', () => {
renderStats(<EndPointDetails {...mockProps} />);
render(<EndPointDetails {...mockProps} />);
// Check if useQueries was called with correct parameters
expect(useQueries).toHaveBeenCalledWith(

View File

@@ -1,115 +0,0 @@
import {
GetMetricQueryRange,
GetQueryResultsProps,
} from 'lib/dashboard/getQueryResults';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import EndPointPicker from '../Explorer/Domains/DomainDetails/components/EndPointPicker';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
jest.mock('lib/dashboard/getQueryResults', () => ({
GetMetricQueryRange: jest.fn(),
}));
const mockGetMetricQueryRange = jest.mocked(GetMetricQueryRange);
const rowsResponse = (urls: string[]): MetricQueryRangeSuccessResponse => ({
statusCode: 200,
message: 'success',
error: null,
payload: {
data: {
resultType: '',
newResult: { data: { result: [], resultType: '' } },
result: [
{
metric: {},
queryName: 'A',
values: [],
table: {
columns: [],
rows: urls.map((url) => ({
data: { [SPAN_ATTRIBUTES.HTTP_URL]: url, A: 1 },
})),
},
},
],
},
},
});
const baseProps = {
domainName: 'api.example.com',
timeRange: { startTime: 1000, endTime: 2000 },
filterExpression: "service.name = 'checkout'",
value: '',
};
describe('EndPointPicker', () => {
beforeEach(() => {
mockGetMetricQueryRange.mockResolvedValue(
rowsResponse(['/v1/charges', '/v1/refunds']),
);
});
it('loads options for the domain scoped by the user filter', async () => {
render(<EndPointPicker {...baseProps} onChange={jest.fn()} />);
await waitFor(() => expect(mockGetMetricQueryRange).toHaveBeenCalled());
const payload: GetQueryResultsProps =
mockGetMetricQueryRange.mock.calls[0][0];
expect(payload.query.builder.queryData[0].filter?.expression).toBe(
`http_host = 'api.example.com' AND kind_string = 'Client' AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS AND (service.name = 'checkout')`,
);
});
it('reports the picked endpoint', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const onChange = jest.fn() as jest.MockedFunction<
(endPointName: string) => void
>;
render(<EndPointPicker {...baseProps} onChange={onChange} />);
await waitFor(() => expect(mockGetMetricQueryRange).toHaveBeenCalled());
await user.click(screen.getByTestId('endpoint-picker'));
await user.click(await screen.findByText('/v1/refunds'));
expect(onChange).toHaveBeenCalledWith('/v1/refunds');
});
it('shows All endpoints when none is picked and clears through it', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const onChange = jest.fn() as jest.MockedFunction<
(endPointName: string) => void
>;
const { rerender } = render(
<EndPointPicker {...baseProps} onChange={onChange} />,
);
expect(screen.getByTestId('endpoint-picker')).toHaveTextContent(
'All endpoints',
);
rerender(
<EndPointPicker {...baseProps} value="/v1/charges" onChange={onChange} />,
);
await waitFor(() => expect(mockGetMetricQueryRange).toHaveBeenCalled());
await user.click(screen.getByTestId('endpoint-picker'));
await user.click(
await screen.findByRole('option', { name: 'All endpoints' }),
);
expect(onChange).toHaveBeenCalledWith('');
});
it('shows the selected endpoint', async () => {
render(
<EndPointPicker {...baseProps} value="/v1/charges" onChange={jest.fn()} />,
);
await waitFor(() =>
expect(screen.getByText('/v1/charges')).toBeInTheDocument(),
);
});
});

View File

@@ -0,0 +1,210 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { getFormattedEndPointDropDownData } from 'container/ApiMonitoring/utils';
import EndPointsDropDown from '../Explorer/Domains/DomainDetails/components/EndPointsDropDown';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
// Mock the Select component from antd
jest.mock('antd', () => {
const originalModule = jest.requireActual('antd');
return {
...originalModule,
Select: jest
.fn()
.mockImplementation(({ value, loading, onChange, options, onClear }) => (
<div data-testid="mock-select">
<div data-testid="select-value">{value}</div>
<div data-testid="select-loading">
{loading ? 'loading' : 'not-loading'}
</div>
<select
data-testid="select-element"
value={value || ''}
onChange={(e): void => onChange(e.target.value)}
>
<option value="">Select...</option>
{options?.map((option: { value: string; label: string; key: string }) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button data-testid="select-clear-button" type="button" onClick={onClear}>
Clear
</button>
</div>
)),
};
});
// Mock the utilities
jest.mock('container/ApiMonitoring/utils', () => ({
getFormattedEndPointDropDownData: jest.fn(),
}));
describe('EndPointsDropDown Component', () => {
const mockEndPoints = [
{ key: '1', value: '/api/endpoint1', label: '/api/endpoint1' },
{ key: '2', value: '/api/endpoint2', label: '/api/endpoint2' },
];
const mockSetSelectedEndPointName = jest.fn();
// Create a mock that satisfies the UseQueryResult interface
const createMockQueryResult = (overrides: any = {}): any => ({
data: {
payload: {
data: {
result: [
{
table: {
rows: [],
},
},
],
},
},
},
dataUpdatedAt: 0,
error: null,
errorUpdatedAt: 0,
failureCount: 0,
isError: false,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isIdle: false,
isLoading: false,
isLoadingError: false,
isPlaceholderData: false,
isPreviousData: false,
isRefetchError: false,
isRefetching: false,
isStale: false,
isSuccess: true,
refetch: jest.fn(),
remove: jest.fn(),
status: 'success',
...overrides,
});
const defaultProps = {
selectedEndPointName: '',
setSelectedEndPointName: mockSetSelectedEndPointName,
endPointDropDownDataQuery: createMockQueryResult(),
};
beforeEach(() => {
jest.clearAllMocks();
(getFormattedEndPointDropDownData as jest.Mock).mockReturnValue(
mockEndPoints,
);
});
it('renders the component correctly', () => {
render(<EndPointsDropDown {...defaultProps} />);
expect(screen.getByTestId('mock-select')).toBeInTheDocument();
expect(screen.getByTestId('select-loading')).toHaveTextContent('not-loading');
});
it('shows loading state when data is loading', () => {
const loadingProps = {
...defaultProps,
endPointDropDownDataQuery: createMockQueryResult({
isLoading: true,
}),
};
render(<EndPointsDropDown {...loadingProps} />);
expect(screen.getByTestId('select-loading')).toHaveTextContent('loading');
});
it('shows loading state when data is fetching', () => {
const fetchingProps = {
...defaultProps,
endPointDropDownDataQuery: createMockQueryResult({
isFetching: true,
}),
};
render(<EndPointsDropDown {...fetchingProps} />);
expect(screen.getByTestId('select-loading')).toHaveTextContent('loading');
});
it('displays the selected endpoint', () => {
const selectedProps = {
...defaultProps,
selectedEndPointName: '/api/endpoint1',
};
render(<EndPointsDropDown {...selectedProps} />);
expect(screen.getByTestId('select-value')).toHaveTextContent(
'/api/endpoint1',
);
});
it('calls setSelectedEndPointName when an option is selected', () => {
render(<EndPointsDropDown {...defaultProps} />);
// Get the select element and change its value
const selectElement = screen.getByTestId('select-element');
fireEvent.change(selectElement, { target: { value: '/api/endpoint2' } });
expect(mockSetSelectedEndPointName).toHaveBeenCalledWith('/api/endpoint2');
});
it('calls setSelectedEndPointName with empty string when cleared', () => {
render(<EndPointsDropDown {...defaultProps} />);
// Click the clear button
const clearButton = screen.getByTestId('select-clear-button');
fireEvent.click(clearButton);
expect(mockSetSelectedEndPointName).toHaveBeenCalledWith('');
});
it('passes dropdown style prop correctly', () => {
const styleProps = {
...defaultProps,
dropdownStyle: { maxHeight: '200px' },
};
render(<EndPointsDropDown {...styleProps} />);
// We can't easily test style props in our mock, but at least ensure the component rendered
expect(screen.getByTestId('mock-select')).toBeInTheDocument();
});
it('formats data using the utility function', () => {
const mockRows = [
{ data: { [SPAN_ATTRIBUTES.HTTP_URL]: '/api/test', A: 10 } },
];
const dataProps = {
...defaultProps,
endPointDropDownDataQuery: createMockQueryResult({
data: {
payload: {
data: {
result: [
{
table: {
rows: mockRows,
},
},
],
},
},
},
}),
};
render(<EndPointsDropDown {...dataProps} />);
expect(getFormattedEndPointDropDownData).toHaveBeenCalledWith(mockRows);
});
});

View File

@@ -1,8 +1,8 @@
/**
* V5 Migration Tests for Endpoint Dropdown Query
*
* These tests validate the migration from V4 to V5 format for
* getEndPointDropDownQueryPayload (endpoint picker options):
* These tests validate the migration from V4 to V5 format for the third payload
* in getEndPointDetailsQueryPayload (endpoint dropdown data):
* - Filter format change: filters.items[] → filter.expression
* - Domain handling: http_host = '${domainName}'
* - Kind filter: kind_string = 'Client'
@@ -10,8 +10,9 @@
* - Aggregation: count() expression
* - GroupBy: http_url with type 'attribute'
*/
import { getEndPointDropDownQueryPayload } from 'container/ApiMonitoring/utils';
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
@@ -19,16 +20,22 @@ describe('EndpointDropdown - V5 Migration Validation', () => {
const mockDomainName = 'api.example.com';
const mockStartTime = 1000;
const mockEndTime = 2000;
const emptyFilters = '';
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
describe('1. V5 Format Migration - Structure and Base Filters', () => {
it('migrates to V5 format with correct filter expression structure, aggregations, and groupBy', () => {
const dropdownQuery = getEndPointDropDownQueryPayload(
const payload = getEndPointDetailsQueryPayload(
mockDomainName,
mockStartTime,
mockEndTime,
emptyFilters,
);
// Third payload is the endpoint dropdown query (index 2)
const dropdownQuery = payload[2];
const queryA = dropdownQuery.query.builder.queryData[0];
// CRITICAL V5 MIGRATION: filter.expression (not filters.items)
@@ -70,21 +77,46 @@ describe('EndpointDropdown - V5 Migration Validation', () => {
describe('2. Custom Filters Integration', () => {
it('merges custom filters into filter expression with AND logic', () => {
const customFilters =
"service.name = 'user-service' AND deployment.environment = 'production'";
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'user-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND',
};
const dropdownQuery = getEndPointDropDownQueryPayload(
const payload = getEndPointDetailsQueryPayload(
mockDomainName,
mockStartTime,
mockEndTime,
customFilters,
);
const dropdownQuery = payload[2];
const expression =
dropdownQuery.query.builder.queryData[0].filter?.expression;
// Exact filter expression with custom filters merged
expect(expression).toBe(
`${SPAN_ATTRIBUTES.SERVER_NAME} = 'api.example.com' AND kind_string = 'Client' AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS AND (service.name = 'user-service' AND deployment.environment = 'production')`,
`${SPAN_ATTRIBUTES.SERVER_NAME} = 'api.example.com' AND kind_string = 'Client' AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS service.name = 'user-service' AND deployment.environment = 'production'`,
);
});
});

View File

@@ -1,34 +0,0 @@
import { ReactNode } from 'react';
import { QuerySearchV2Provider } from 'components/QueryBuilderV2';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { onNuqsUrlUpdate } from 'tests/nuqs-helpers';
/** Mounts a drawer tab's filter provider over an in-memory URL. */
export function FilterProviders({
queryParamKey,
initialExpression,
searchParams = '',
children,
}: {
queryParamKey: string;
initialExpression?: string;
searchParams?: string;
children: ReactNode;
}): JSX.Element {
return (
<NuqsTestingAdapter
searchParams={searchParams}
onUrlUpdate={onNuqsUrlUpdate}
rateLimitFactor={0}
hasMemory
>
<QuerySearchV2Provider
queryParamKey={queryParamKey}
initialExpression={initialExpression}
persistOnUnmount
>
{children}
</QuerySearchV2Provider>
</NuqsTestingAdapter>
);
}

View File

@@ -2,11 +2,16 @@ import {
getLatencyOverTimeWidgetData,
getRateOverTimeWidgetData,
} from 'container/ApiMonitoring/utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
describe('MetricOverTime - V5 Migration Validation', () => {
const mockDomainName = 'api.example.com';
const mockEndpointName = '/api/users';
const emptyFilters = '';
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
describe('1. Rate Over Time - V5 Payload Structure', () => {
it('generates V5 filter expression format (not V3 filters.items)', () => {
@@ -46,8 +51,31 @@ describe('MetricOverTime - V5 Migration Validation', () => {
});
it('merges custom filters into filter expression', () => {
const customFilters =
"service.name = 'user-service' AND deployment.environment = 'production'";
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
},
op: '=',
value: 'user-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: DataTypes.String,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND',
};
const widget = getRateOverTimeWidgetData(
mockDomainName,
@@ -109,8 +137,21 @@ describe('MetricOverTime - V5 Migration Validation', () => {
});
it('merges custom filters into filter expression', () => {
const customFilters =
"service.name = 'user-service' AND deployment.environment = 'production'";
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
},
op: '=',
value: 'user-service',
},
],
op: 'AND',
};
const widget = getLatencyOverTimeWidgetData(
mockDomainName,
@@ -122,7 +163,7 @@ describe('MetricOverTime - V5 Migration Validation', () => {
// Verify domain filter is present
expect(queryData?.filter?.expression).toContain(
`http_host = '${mockDomainName}' AND (service.name = 'user-service'`,
`http_host = '${mockDomainName}' service.name = 'user-service'`,
);
});
});

View File

@@ -5,6 +5,7 @@ import {
getStatusCodeBarChartWidgetData,
} from 'container/ApiMonitoring/utils';
import { SuccessResponse } from 'types/api';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import ErrorState from '../Explorer/Domains/DomainDetails/components/ErrorState';
import StatusCodeBarCharts from '../Explorer/Domains/DomainDetails/components/StatusCodeBarCharts';
@@ -155,6 +156,7 @@ jest.mock('antd', () => {
describe('StatusCodeBarCharts', () => {
// Default props for tests
const mockFilters: IBuilderQuery['filters'] = { items: [], op: 'AND' };
const mockTimeRange = {
startTime: 1609459200000,
endTime: 1609545600000,
@@ -249,7 +251,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filterExpression=""
filters={mockFilters}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -284,7 +286,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filterExpression=""
filters={mockFilters}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -326,7 +328,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filterExpression=""
filters={mockFilters}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -370,7 +372,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filterExpression=""
filters={mockFilters}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -417,7 +419,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filterExpression=""
filters={mockFilters}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -431,13 +433,26 @@ describe('StatusCodeBarCharts', () => {
// but we've confirmed the function is mocked and ready to be tested
expect(getStatusCodeBarChartWidgetData).toHaveBeenCalledWith(
mockDomainName,
'',
expect.objectContaining({
items: [],
op: 'AND',
}),
);
});
it('handles widget generation with current filters', () => {
// Arrange
const mockCustomFilterExpression = "test-key = 'test-value'";
const mockCustomFilters = {
items: [
{
id: 'custom-filter',
key: { key: 'test-key' },
op: '=',
value: 'test-value',
},
],
op: 'AND',
};
const mockData = {
payload: mockFormattedData,
@@ -465,7 +480,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filterExpression={mockCustomFilterExpression}
filters={mockCustomFilters as IBuilderQuery['filters']}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -474,7 +489,12 @@ describe('StatusCodeBarCharts', () => {
// Assert widget creation was called with the correct parameters
expect(getStatusCodeBarChartWidgetData).toHaveBeenCalledWith(
mockDomainName,
mockCustomFilterExpression,
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({ id: 'custom-filter' }),
]),
op: 'AND',
}),
);
});
});

View File

@@ -2,7 +2,7 @@
* V5 Migration Tests for Status Code Bar Chart Queries
*
* These tests validate the migration to V5 format for the bar chart payloads
* in getEndPointDetailsQueryPayload (4th and 5th payloads):
* in getEndPointDetailsQueryPayload (5th and 6th payloads):
* - Number of Calls Chart (count aggregation)
* - Latency Chart (p99 aggregation)
*
@@ -15,12 +15,16 @@
*/
import { TraceAggregation } from 'api/v5/v5';
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
describe('StatusCodeBarCharts - V5 Migration Validation', () => {
const mockDomainName = '0.0.0.0';
const mockStartTime = 1762573673000;
const mockEndTime = 1762832873000;
const emptyFilters = '';
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
describe('1. Number of Calls Chart - V5 Payload Structure', () => {
it('generates correct V5 payload for count aggregation grouped by status code', () => {
@@ -31,8 +35,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
emptyFilters,
);
// 4th payload (index 3) is the number of calls bar chart
const callsChartQuery = payload[3];
// 5th payload (index 4) is the number of calls bar chart
const callsChartQuery = payload[4];
const queryA = callsChartQuery.query.builder.queryData[0];
// V5 format: filter.expression (not filters.items)
@@ -80,8 +84,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
emptyFilters,
);
// 5th payload (index 4) is the latency bar chart
const latencyChartQuery = payload[4];
// 6th payload (index 5) is the latency bar chart
const latencyChartQuery = payload[5];
const queryA = latencyChartQuery.query.builder.queryData[0];
// V5 format: filter.expression (not filters.items)
@@ -126,8 +130,31 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
describe('3. Custom Filters Integration', () => {
it('merges custom filters into filter expression for both charts', () => {
const customFilters =
"service.name = 'user-service' AND deployment.environment = 'production'";
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'user-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND',
};
const payload = getEndPointDetailsQueryPayload(
mockDomainName,
@@ -136,8 +163,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
customFilters,
);
const callsChartQuery = payload[3];
const latencyChartQuery = payload[4];
const callsChartQuery = payload[4];
const latencyChartQuery = payload[5];
const callsExpression =
callsChartQuery.query.builder.queryData[0].filter?.expression;

View File

@@ -13,12 +13,16 @@
*/
import { TraceAggregation } from 'api/v5/v5';
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
describe('StatusCodeTable - V5 Migration Validation', () => {
const mockDomainName = 'api.example.com';
const mockStartTime = 1000;
const mockEndTime = 2000;
const emptyFilters = '';
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
describe('1. V5 Format Migration with Base Filters', () => {
it('migrates to V5 format with correct filter expression structure and base filters', () => {
@@ -105,8 +109,31 @@ describe('StatusCodeTable - V5 Migration Validation', () => {
describe('3. Custom Filters Integration', () => {
it('merges custom filters into filter expression with AND logic', () => {
const customFilters =
"service.name = 'user-service' AND deployment.environment = 'production'";
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'user-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND',
};
const payload = getEndPointDetailsQueryPayload(
mockDomainName,

View File

@@ -1,40 +1,31 @@
import { BuilderQuery } from 'api/v5/v5';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { rest, server } from 'mocks-server/server';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
within,
} from 'tests/test-utils';
import { fireEvent, render, screen, waitFor, within } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
import TopErrors from '../Explorer/Domains/DomainDetails/TopErrors';
import { getTopErrorsQueryPayload } from '../utils';
interface QueryRangeRequestBody {
compositeQuery: { queries: { spec: { filter: { expression: string } } }[] };
}
// Mock the EndPointPicker component to avoid issues
// Mock the EndPointsDropDown component to avoid issues
jest.mock(
'../Explorer/Domains/DomainDetails/components/EndPointPicker',
'../Explorer/Domains/DomainDetails/components/EndPointsDropDown',
() => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onChange }): JSX.Element => (
<div data-testid="endpoints-dropdown-mock">
<select
data-testid="endpoints-select"
onChange={(e): void => onChange(e.target.value)}
>
<option value="/api/test">/api/test</option>
<option value="/api/new-endpoint">/api/new-endpoint</option>
</select>
</div>
)),
default: jest
.fn()
.mockImplementation(({ setSelectedEndPointName }): JSX.Element => (
<div data-testid="endpoints-dropdown-mock">
<select
data-testid="endpoints-select"
onChange={(e): void => setSelectedEndPointName(e.target.value)}
>
<option value="/api/test">/api/test</option>
<option value="/api/new-endpoint">/api/new-endpoint</option>
</select>
</div>
)),
}),
);
@@ -52,7 +43,10 @@ describe('TopErrors', () => {
startTime: 1000000000,
endTime: 1000010000,
},
initialFilterExpression: '',
initialFilters: {
items: [],
op: 'AND',
},
};
// Helper function to wait for table data to load
@@ -233,30 +227,22 @@ describe('TopErrors', () => {
});
});
it('scopes the query to the endpoint picked in the picker', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const expressions: string[] = [];
server.use(
rest.post(V5_QUERY_RANGE_API_PATH, async (req) => {
const body: QueryRangeRequestBody = await req.json();
expressions.push(body.compositeQuery.queries[0].spec.filter.expression);
}),
);
it('updates endpoint filter when dropdown value changes', async () => {
render(<TopErrors {...mockProps} />);
await waitFor(() => expect(expressions).toHaveLength(1));
expect(expressions[0]).not.toContain(`${SPAN_ATTRIBUTES.HTTP_URL} =`);
await user.selectOptions(
screen.getByTestId('endpoints-select'),
'/api/new-endpoint',
);
// Wait for initial load
await waitFor(() => {
expect(expressions[expressions.length - 1]).toContain(
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/new-endpoint'`,
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
// Find the dropdown
const dropdown = screen.getByRole('combobox');
// Mock the change
fireEvent.change(dropdown, { target: { value: '/api/new-endpoint' } });
// Component should re-render with new filter
expect(dropdown).toBeInTheDocument();
});
it('handles status message toggle correctly', async () => {
@@ -320,7 +306,7 @@ describe('TopErrors', () => {
'test-domain',
mockProps.timeRange.startTime,
mockProps.timeRange.endTime,
'',
{ items: [], op: 'AND' },
false,
);

View File

@@ -2,7 +2,6 @@ import {
ApiMonitoringParams,
DEFAULT_PARAMS,
getApiMonitoringParams,
getLegacyFilterExpressions,
setApiMonitoringParams,
} from 'container/ApiMonitoring/queryParams';
import { mockNavigate } from 'tests/router-mocks';
@@ -53,50 +52,6 @@ describe('API Monitoring Query Params', () => {
expect(getApiMonitoringParams(search)).toStrictEqual(DEFAULT_PARAMS);
});
it('converts filter items saved by older links into an expression', () => {
const legacyParams = {
selectedDomain: 'test-domain',
allEndpointsLocalFilters: {
op: 'AND',
items: [
{
id: 'a',
key: { key: 'service.name', dataType: 'string', type: 'resource' },
op: '=',
value: 'frontend',
},
],
},
endPointDetailsLocalFilters: {
op: 'AND',
items: [
{
id: 'b',
key: { key: 'http.status_code', dataType: 'string', type: '' },
op: '=',
value: '500',
},
],
},
};
const urlParams = new URLSearchParams();
urlParams.set(
'apiMonitoringParams',
encodeURIComponent(JSON.stringify(legacyParams)),
);
const search = `?${urlParams.toString()}`;
expect(getLegacyFilterExpressions(search)).toStrictEqual({
allEndpoints: "service.name = 'frontend'",
endPointDetails: "http.status_code = '500'",
});
const result = getApiMonitoringParams(search);
expect(result.selectedDomain).toBe('test-domain');
expect(result).not.toHaveProperty('allEndpointsLocalFilters');
expect(result).not.toHaveProperty('endPointDetailsLocalFilters');
});
});
describe('setApiMonitoringParams', () => {

View File

@@ -1,65 +0,0 @@
import { ALL_ENDPOINTS_EXPRESSION_KEY } from 'container/ApiMonitoring/filterParamKeys';
import {
getApiMonitoringParams,
useApiMonitoringParams,
} from 'container/ApiMonitoring/queryParams';
import { mockAppLocation, mockNavigate } from 'tests/router-mocks';
import { act, renderHook } from 'tests/test-utils';
// The stub writes to the browser URL, which the hook reads back; the router's
// own location (`useAppLocation`) stays at its first value.
function renderParamsHook(): ReturnType<
typeof renderHook<ReturnType<typeof useApiMonitoringParams>, unknown>
> {
mockNavigate().mockImplementation((to) => {
const url = typeof to === 'string' ? to : `/${to.search ?? ''}`;
window.history.pushState(null, '', url);
});
mockAppLocation();
return renderHook(() => useApiMonitoringParams());
}
describe('useApiMonitoringParams', () => {
beforeEach(() => {
window.history.replaceState(null, '', '/');
});
it('keeps every write made before the next render', () => {
const { result } = renderParamsHook();
act(() => {
const [, setParams] = result.current;
setParams({ selectedView: 'endpoint_stats' });
setParams({ selectedInterval: '15m' });
});
expect(getApiMonitoringParams(window.location.search)).toMatchObject({
selectedView: 'endpoint_stats',
selectedInterval: '15m',
});
});
it('keeps a drawer filter written outside the router', () => {
window.history.replaceState(
null,
'',
`/?${ALL_ENDPOINTS_EXPRESSION_KEY}=${encodeURIComponent(
"service.name = 'cart'",
)}`,
);
const { result } = renderParamsHook();
act(() => {
const [, setParams] = result.current;
setParams({ selectedView: 'endpoint_stats' });
});
const search = new URLSearchParams(window.location.search);
expect(search.get(ALL_ENDPOINTS_EXPRESSION_KEY)).toBe(
"service.name = 'cart'",
);
expect(getApiMonitoringParams(window.location.search).selectedView).toBe(
'endpoint_stats',
);
});
});

View File

@@ -1,5 +0,0 @@
// The drawer filters live in their own URL params, owned by `QuerySearchV2Provider`.
export const ALL_ENDPOINTS_EXPRESSION_KEY =
'apiMonitoringAllEndpointsExpression';
export const ENDPOINT_STATS_EXPRESSION_KEY =
'apiMonitoringEndpointStatsExpression';

View File

@@ -1,10 +1,6 @@
import { useCallback } from 'react';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { navigate } from 'lib/router/navigation';
import { useAppLocation } from 'lib/router/useAppLocation';
import { omit } from 'lodash-es';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { navigate } from 'lib/router/navigation';
// --- Types for all API Monitoring query params ---
export interface ApiMonitoringParams {
@@ -13,6 +9,8 @@ export interface ApiMonitoringParams {
selectedView?: string;
selectedEndPointName?: string;
groupBy?: string[];
allEndpointsLocalFilters?: any;
endPointDetailsLocalFilters?: any;
modalTimeRange?: { startTime: number; endTime: number };
selectedInterval?: string;
// Add more params as needed
@@ -24,6 +22,8 @@ export const DEFAULT_PARAMS: ApiMonitoringParams = {
selectedView: 'all_endpoints',
selectedEndPointName: '',
groupBy: [],
allEndpointsLocalFilters: undefined,
endPointDetailsLocalFilters: undefined,
modalTimeRange: undefined,
selectedInterval: undefined,
};
@@ -35,50 +35,15 @@ function encodeParams(params: ApiMonitoringParams): string {
return encodeURIComponent(JSON.stringify(params));
}
// Older shared links carry the filters as TagFilter items.
type LegacyApiMonitoringParams = ApiMonitoringParams & {
allEndpointsLocalFilters?: TagFilter;
endPointDetailsLocalFilters?: TagFilter;
};
function parseParams(value: string | null): LegacyApiMonitoringParams | null {
function decodeParams(value: string | null): ApiMonitoringParams {
if (!value) {
return null;
return DEFAULT_PARAMS;
}
try {
return JSON.parse(decodeURIComponent(value));
} catch {
return null;
}
}
// Drops the legacy filters, so the next write leaves them out of the URL.
function decodeParams(value: string | null): ApiMonitoringParams {
const parsed = parseParams(value);
if (!parsed) {
return DEFAULT_PARAMS;
}
return omit(parsed, [
'allEndpointsLocalFilters',
'endPointDetailsLocalFilters',
]);
}
export interface LegacyFilterExpressions {
allEndpoints?: string;
endPointDetails?: string;
}
export function getLegacyFilterExpressions(
search: string,
): LegacyFilterExpressions {
const parsed = parseParams(new URLSearchParams(search).get(PARAM_KEY));
const toExpression = (filters?: TagFilter): string | undefined =>
filters ? convertFiltersToExpression(filters).expression : undefined;
return {
allEndpoints: toExpression(parsed?.allEndpointsLocalFilters),
endPointDetails: toExpression(parsed?.endPointDetailsLocalFilters),
};
}
// --- Read query params from URL ---
@@ -109,18 +74,11 @@ export function useApiMonitoringParams(): [
const location = useAppLocation();
const params = getApiMonitoringParams(location.search);
// Read the browser's URL, not the router's: several writes can land before
// the next render, and the drawer filters are written by nuqs, which the
// router's location does not see.
const setParams = useCallback(
(newParams: Partial<ApiMonitoringParams>, replace = false) => {
setApiMonitoringParams(
newParams,
`?${getUnstableCurrentSearchParams().toString()}`,
replace,
);
setApiMonitoringParams(newParams, location.search, replace);
},
[],
[location.search],
);
return [params, setParams];

View File

@@ -3,8 +3,7 @@ import { Color } from '@signozhq/design-tokens';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Progress } from '@signozhq/ui/progress';
import { Badge } from '@signozhq/ui/badge';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
import {
FiltersType,
IQuickFiltersConfig,
@@ -57,28 +56,30 @@ export const getDisplayValue = (value: unknown): string =>
isEmptyFilterValue(value) ? '-' : String(value);
export const getDomainNameFilterExpression = (domainName: string): string =>
`${SPAN_ATTRIBUTES.SERVER_NAME} = ${formatValueForExpression(domainName)}`;
`http_host = '${domainName}'`;
export const clientKindExpression = "kind_string = 'Client'";
export const clientKindExpression = `kind_string = 'Client'`;
export const getHttpUrlExpression = (endPointName: string): string =>
endPointName
? `${SPAN_ATTRIBUTES.HTTP_URL} = ${formatValueForExpression(endPointName, '=')}`
: '';
/**
* Converts filters to expression
* @param filters Filters to convert
* @param baseExpression Base expression to combine with filters
* @returns Filter expression string
*/
export const convertFiltersWithUrlHandling = (
filters: IBuilderQuery['filters'],
baseExpression: string,
): string => {
if (!filters) {
return baseExpression;
}
// The domain (and endpoint) the drawer is locked to, built from the same
// expressions as the requests' filters so the chip shows what runs.
export const getDomainScopeExpression = (
domainName: string,
endPointName = '',
): string =>
[
clientKindExpression,
getDomainNameFilterExpression(domainName),
getHttpUrlExpression(endPointName),
]
.filter(Boolean)
.join(' AND ');
const { filter } = convertFiltersToExpressionWithExistingQuery(
filters,
baseExpression,
);
return filter.expression;
};
export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
{
@@ -346,7 +347,7 @@ export const getDomainMetricsQueryPayload = (
domainName: string,
start: number,
end: number,
filterExpression: string,
filters: IBuilderQuery['filters'],
): GetQueryResultsProps[] => [
{
selectedTime: 'GLOBAL_TIME',
@@ -367,9 +368,9 @@ export const getDomainMetricsQueryPayload = (
spaceAggregation: 'sum',
functions: [],
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(domainName)} AND ${urlExpression}`,
filterExpression,
),
},
expression: 'A',
@@ -395,9 +396,9 @@ export const getDomainMetricsQueryPayload = (
spaceAggregation: 'sum',
functions: [],
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(domainName)}`,
filterExpression,
),
},
expression: 'B',
@@ -423,9 +424,9 @@ export const getDomainMetricsQueryPayload = (
spaceAggregation: 'sum',
functions: [],
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(domainName)} AND has_error = true`,
filterExpression,
),
},
expression: 'C',
@@ -451,9 +452,9 @@ export const getDomainMetricsQueryPayload = (
spaceAggregation: 'sum',
functions: [],
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(domainName)}`,
filterExpression,
),
},
expression: 'D',
@@ -823,7 +824,7 @@ export const getEndPointsQueryPayload = (
function buildFilterExpression(
domainName: string,
filterExpression: string,
filters: IBuilderQuery['filters'],
showStatusCodeErrors: boolean,
): string {
const baseFilterParts = [
@@ -835,22 +836,21 @@ function buildFilterExpression(
if (showStatusCodeErrors) {
baseFilterParts.push('status_message EXISTS');
}
return combineInitialAndUserExpression(
baseFilterParts.join(' AND '),
filterExpression,
);
const filterExpression = baseFilterParts.join(' AND ');
return convertFiltersWithUrlHandling(filters, filterExpression);
}
export const getTopErrorsQueryPayload = (
domainName: string,
start: number,
end: number,
filterExpression: string,
filters: IBuilderQuery['filters'],
showStatusCodeErrors = true,
): QueryRangePayloadV5 => {
const combinedExpression = buildFilterExpression(
const filterExpression = buildFilterExpression(
domainName,
filterExpression,
filters,
showStatusCodeErrors,
);
@@ -869,7 +869,7 @@ export const getTopErrorsQueryPayload = (
stepInterval: 60,
disabled: false,
aggregations: [{ expression: 'count()' }],
filter: { expression: combinedExpression },
filter: { expression: filterExpression },
groupBy: [
{
name: SPAN_ATTRIBUTES.HTTP_URL,
@@ -1367,14 +1367,15 @@ export const createFiltersForSelectedRowData = (
// First query payload for endpoint metrics
// Second query payload for endpoint status code table
// Third query payload for endpoint dependant services
// Fourth query payload for endpoint response status count bar chart
// Fifth query payload for endpoint response status code latency bar chart
// Third query payload for endpoint dropdown selection
// Fourth query payload for endpoint dependant services
// Fifth query payload for endpoint response status count bar chart
// Sixth query payload for endpoint response status code latency bar chart
export const getEndPointDetailsQueryPayload = (
domainName: string,
start: number,
end: number,
filterExpression: string,
filters: IBuilderQuery['filters'],
): GetQueryResultsProps[] => [
{
selectedTime: 'GLOBAL_TIME',
@@ -1393,11 +1394,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1423,11 +1424,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'B',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1453,11 +1454,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: true,
expression: 'C',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND has_error = true`,
filterExpression,
),
},
functions: [],
@@ -1483,11 +1484,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'D',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1513,11 +1514,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: true,
expression: 'E',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1585,11 +1586,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
filterExpression,
),
},
functions: [],
@@ -1621,11 +1622,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'B',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
filterExpression,
),
},
functions: [],
@@ -1659,11 +1660,11 @@ export const getEndPointDetailsQueryPayload = (
spaceAggregation: 'sum',
functions: [],
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
filterExpression,
),
},
expression: 'C',
@@ -1712,6 +1713,77 @@ export const getEndPointDetailsQueryPayload = (
end,
step: 60,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TABLE,
query: {
builder: {
queryData: [
{
dataSource: DataSource.TRACES,
queryName: 'A',
aggregateOperator: 'count',
aggregations: [
{
expression: 'count()',
},
],
timeAggregation: 'count',
spaceAggregation: 'sum',
functions: [],
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS`,
),
},
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
legend: '',
limit: null,
orderBy: [],
groupBy: [
{
key: SPAN_ATTRIBUTES.HTTP_URL,
dataType: DataTypes.String,
type: 'attribute',
},
],
reduceTo: ReduceOperators.AVG,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: true,
start,
end,
step: 60,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TABLE,
@@ -1729,11 +1801,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1766,11 +1838,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'B',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1803,11 +1875,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'C',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1840,11 +1912,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: true,
expression: 'D',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND has_error = true`,
filterExpression,
),
},
functions: [],
@@ -1919,11 +1991,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1990,11 +2062,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -2046,82 +2118,100 @@ export const getEndPointDetailsQueryPayload = (
},
];
export const getEndPointDropDownQueryPayload = (
export const getEndPointZeroStateQueryPayload = (
domainName: string,
start: number,
end: number,
filterExpression: string,
): GetQueryResultsProps => ({
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TABLE,
query: {
builder: {
queryData: [
{
dataSource: DataSource.TRACES,
queryName: 'A',
aggregateOperator: 'count',
aggregations: [
{
expression: 'count()',
},
],
timeAggregation: 'count',
spaceAggregation: 'sum',
functions: [],
filter: {
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS`,
filterExpression,
),
},
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
legend: '',
limit: null,
orderBy: [],
groupBy: [
{
key: SPAN_ATTRIBUTES.HTTP_URL,
): GetQueryResultsProps[] => [
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TABLE,
query: {
builder: {
queryData: [
{
dataSource: DataSource.TRACES,
queryName: 'A',
aggregateOperator: 'count',
aggregateAttribute: {
dataType: DataTypes.String,
type: 'attribute',
key: '',
type: '',
},
],
reduceTo: ReduceOperators.AVG,
timeAggregation: 'count',
spaceAggregation: 'sum',
functions: [],
filters: {
items: [
{
id: '3db61dd6',
key: {
key: SPAN_ATTRIBUTES.SERVER_NAME,
dataType: DataTypes.String,
type: '',
},
op: '=',
value: domainName,
},
{
id: '212678b9',
key: {
key: 'kind_string',
dataType: DataTypes.String,
type: '',
},
op: '=',
value: 'Client',
},
],
op: 'AND',
},
expression: 'A',
disabled: false,
stepInterval: 60,
having: [],
legend: '',
limit: null,
orderBy: [],
groupBy: [
{
key: SPAN_ATTRIBUTES.HTTP_URL,
dataType: DataTypes.String,
type: 'tag',
},
],
reduceTo: ReduceOperators.AVG,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryFormulas: [],
queryTraceOperator: [],
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
variables: {},
formatForWeb: true,
start,
end,
step: 60,
},
variables: {},
formatForWeb: true,
start,
end,
step: 60,
});
];
interface EndPointMetricsResponseRow {
data: {
@@ -2279,8 +2369,8 @@ export const statusCodeWidgetInfo = [
interface EndPointDropDownResponseRow {
data: {
[SPAN_ATTRIBUTES.HTTP_URL]?: string;
A?: number;
[SPAN_ATTRIBUTES.HTTP_URL]: string;
A: number;
};
}
@@ -2622,7 +2712,7 @@ export const groupStatusCodes = (
export const getStatusCodeBarChartWidgetData = (
domainName: string,
filterExpression: string,
filters: IBuilderQuery['filters'],
): Widgets => ({
query: {
builder: {
@@ -2638,13 +2728,22 @@ export const getStatusCodeBarChartWidgetData = (
dataSource: DataSource.TRACES,
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
getDomainNameFilterExpression(domainName),
filterExpression,
),
filters: {
items: [
{
id: 'c6724407',
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.SERVER_NAME,
type: '',
},
op: '=',
value: domainName,
},
...(filters?.items || []),
],
op: 'AND',
},
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
@@ -2725,15 +2824,18 @@ export const getFormattedEndPointStatusCodeChartData = (
export const END_POINT_DETAILS_QUERY_KEYS_ARRAY = [
REACT_QUERY_KEY.GET_ENDPOINT_METRICS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_RATE_OVER_TIME_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_LATENCY_OVER_TIME_DATA,
];
export const getAllEndpointsWidgetData = (
groupBy: BaseAutocompleteData[],
domainName: string,
filterExpression: string,
filters: IBuilderQuery['filters'],
// eslint-disable-next-line sonarjs/cognitive-complexity
): Widgets => {
const isGroupedByAttribute = groupBy.length > 0;
@@ -2755,11 +2857,11 @@ export const getAllEndpointsWidgetData = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters,
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND http_url EXISTS`,
filterExpression,
),
},
functions: [],
@@ -2787,11 +2889,11 @@ export const getAllEndpointsWidgetData = (
disabled: false,
expression: 'B',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters,
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND http_url EXISTS`,
filterExpression,
),
},
functions: [],
@@ -2819,11 +2921,11 @@ export const getAllEndpointsWidgetData = (
disabled: false,
expression: 'C',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters,
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND http_url EXISTS`,
filterExpression,
),
},
functions: [],
@@ -2851,11 +2953,11 @@ export const getAllEndpointsWidgetData = (
disabled: true,
expression: 'D',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters,
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND has_error = true AND http_url EXISTS`,
filterExpression,
),
},
functions: [],
@@ -2973,26 +3075,38 @@ export const getAllEndpointsWidgetData = (
return widget;
};
const keysToRemove = [SPAN_ATTRIBUTES.HTTP_URL, 'key', 'A', 'B', 'C', 'F1'];
const keysToRemove = [SPAN_ATTRIBUTES.HTTP_URL, 'A', 'B', 'C', 'F1'];
// A group without a value for a key is the set of spans missing that attribute.
export const getGroupByExpressionFromRowData = (
rowData: Record<string, string | number | boolean | null | undefined>,
): string =>
Object.keys(rowData)
export const getGroupByFiltersFromGroupByValues = (
rowData: any,
groupBy: BaseAutocompleteData[],
): IBuilderQuery['filters'] => {
const items = Object.keys(rowData)
.filter((key) => !keysToRemove.includes(key))
.map((key) => {
const value = rowData[key];
return value === null || value === undefined
? `${key} NOT EXISTS`
: `${key} = ${formatValueForExpression(value)}`;
})
.join(' AND ');
const groupByAttribute = groupBy.find((gb) => gb.key === key);
return {
id: groupByAttribute?.id || v4(),
key: {
dataType: groupByAttribute?.dataType || DataTypes.String,
key: groupByAttribute?.key || key,
type: groupByAttribute?.type || '',
},
op: '=', // operator for every attribute -> discuss
value: rowData[key],
};
});
return {
items,
op: 'AND',
};
};
export const getRateOverTimeWidgetData = (
domainName: string,
endPointName: string,
filterExpression: string,
filters: IBuilderQuery['filters'],
): Widgets => {
let legend = domainName;
if (endPointName) {
@@ -3016,9 +3130,9 @@ export const getRateOverTimeWidgetData = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`http_host = '${domainName}'`,
filterExpression,
),
},
functions: [],
@@ -3042,7 +3156,7 @@ export const getRateOverTimeWidgetData = (
export const getLatencyOverTimeWidgetData = (
domainName: string,
endPointName: string,
filterExpression: string,
filters: IBuilderQuery['filters'],
): Widgets => {
let legend = domainName;
if (endPointName) {
@@ -3066,9 +3180,9 @@ export const getLatencyOverTimeWidgetData = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`http_host = '${domainName}'`,
filterExpression,
),
},
functions: [],

View File

@@ -174,6 +174,7 @@ function QuerySection(): JSX.Element {
runQuery={runQueryHandler}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
alertDef={alertDef}
panelType={PANEL_TYPES.TIME_SERIES}
isAnomalyDetection={isAnomalyDetection}
key={currentQuery.queryType}

View File

@@ -23,11 +23,20 @@ export type JiraDeployment = AlertmanagertypesJiraDeploymentDTO;
export type KeysOfUnion<T> = T extends unknown ? keyof T : never;
/** Every key of every member, optional, so a form can hold any member's fields. */
export type FlattenUnion<T> = {
[K in KeysOfUnion<T>]?: T extends unknown
? K extends keyof T
? T[K]
: never
: never;
};
export type JiraFormSpec = Omit<
AlertmanagertypesChannelJiraConfigDTO,
'auth'
> & {
auth?: AlertmanagertypesChannelJiraAuthDTO;
auth?: FlattenUnion<AlertmanagertypesChannelJiraAuthDTO>;
};
/** Merged so switching kind keeps the fields the kinds share. */

View File

@@ -1,4 +1,5 @@
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { ENTITY_VERSION_V5 } from 'constants/app';
import {
initialQueryBuilderFormValuesMap,
initialQueryPromQLData,
@@ -27,6 +28,7 @@ const defaultAnnotations = {
export const alertDefaults: AlertDef = {
alertType: AlertTypes.METRICS_BASED_ALERT,
version: ENTITY_VERSION_V5,
condition: {
compositeQuery: {
builderQueries: {
@@ -60,6 +62,7 @@ export const alertDefaults: AlertDef = {
export const anamolyAlertDefaults: AlertDef = {
alertType: AlertTypes.METRICS_BASED_ALERT,
version: ENTITY_VERSION_V5,
ruleType: RuletypesRuleTypeDTO.anomaly_rule,
condition: {
compositeQuery: {
@@ -104,6 +107,7 @@ export const anamolyAlertDefaults: AlertDef = {
export const logAlertDefaults: AlertDef = {
alertType: AlertTypes.LOGS_BASED_ALERT,
version: ENTITY_VERSION_V5,
condition: {
compositeQuery: {
builderQueries: {
@@ -135,6 +139,7 @@ export const logAlertDefaults: AlertDef = {
export const traceAlertDefaults: AlertDef = {
alertType: AlertTypes.TRACES_BASED_ALERT,
version: ENTITY_VERSION_V5,
condition: {
compositeQuery: {
builderQueries: {
@@ -166,6 +171,7 @@ export const traceAlertDefaults: AlertDef = {
export const exceptionAlertDefaults: AlertDef = {
alertType: AlertTypes.EXCEPTIONS_BASED_ALERT,
version: ENTITY_VERSION_V5,
condition: {
compositeQuery: {
builderQueries: {

View File

@@ -176,6 +176,7 @@ function ChartPreview({
},
originalGraphType: graphType,
},
// alertDef?.version || DEFAULT_ENTITY_VERSION,
ENTITY_VERSION_V5,
{
queryKey: [

View File

@@ -6,7 +6,7 @@ import logEvent from 'api/common/logEvent';
import PromQLIcon from 'assets/Dashboard/PromQl';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { QBShortcuts } from 'constants/shortcuts/QBShortcuts';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
@@ -15,6 +15,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { isEmpty } from 'lodash-es';
import { Atom, Terminal } from '@signozhq/icons';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef } from 'types/api/alerts/def';
import { EQueryType } from 'types/common/dashboard';
import ChQuerySection from './ChQuerySection';
@@ -36,6 +37,7 @@ function QuerySection({
runQuery,
isLoadingQueries,
handleCancelQuery,
alertDef,
panelType,
ruleId,
isAnomalyDetection,
@@ -69,8 +71,12 @@ function QuerySection({
signalSource: signalSource === 'meter' ? 'meter' : '',
}}
showTraceOperator={alertType === AlertTypes.TRACES_BASED_ALERT}
showFunctions={alertType === AlertTypes.LOGS_BASED_ALERT}
version={ENTITY_VERSION_V5}
showFunctions={
(alertType === AlertTypes.METRICS_BASED_ALERT &&
alertDef.version === ENTITY_VERSION_V4) ||
alertType === AlertTypes.LOGS_BASED_ALERT
}
version={alertDef.version || 'v3'}
onSignalSourceChange={handleSignalSourceChange}
signalSourceChangeEnabled
/>
@@ -295,6 +301,7 @@ interface QuerySectionProps {
runQuery: VoidFunction;
isLoadingQueries: boolean;
handleCancelQuery: () => void;
alertDef: AlertDef;
panelType: PANEL_TYPES;
ruleId: string;
isAnomalyDetection?: boolean;

View File

@@ -1,10 +1,12 @@
import { useMemo } from 'react';
import { useListRulesV3 } from 'api/generated/services/rules';
import {
type ListRulesV3200,
type ListRulesV3Params,
RuletypesAlertStateDTO,
type RuletypesListableRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
const HOME_ALERT_RULES_LIMIT = 5;
@@ -21,7 +23,18 @@ interface HomeAlertRules {
export function useHomeAlertRules(): HomeAlertRules {
const firing = useListRulesV3(FIRING_RULES_PARAMS, {
query: { cacheTime: 0 },
query: {
cacheTime: 0,
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
queryFn: ({ signal }) =>
GeneratedAPIInstance<ListRulesV3200>({
url: '/api/v3/rules',
method: 'GET',
params: FIRING_RULES_PARAMS,
paramsSerializer: { indexes: null },
signal,
}),
},
});
const firingRules = firing.data?.data.rules;
const latest = useListRulesV3(

View File

@@ -35,11 +35,11 @@
--tanstack-table-header-cell-bg: var(--l2-background);
--tanstack-table-header-cell-color: var(--l2-foreground);
--tanstack-table-cell-bg: var(--l1-background);
--tanstack-table-cell-bg: var(--l2-background);
--tanstack-table-cell-color: var(--l2-foreground);
--tanstack-table-row-hover-bg: var(--l1-background-hover);
--tanstack-table-row-active-bg: var(--l1-background-active);
--tanstack-table-resize-handle-bg: transparent;
--tanstack-table-row-hover-bg: var(--l2-background-hover);
--tanstack-table-row-active-bg: var(--l2-background-active);
--tanstack-table-resize-handle-bg: var(--l2-background);
--tanstack-table-resize-handle-hover-bg: var(--l2-border);
--tanstack-table-row-height: 42px;
@@ -48,6 +48,16 @@
--tanstack-cell-padding-left-override: 16px;
--tanstack-cell-padding-right-override: 16px;
--tanstack-table-row-odd-bg: color-mix(
in srgb,
var(--l1-foreground) 2%,
transparent
);
--tanstack-table-row-even-bg: color-mix(
in srgb,
var(--l1-foreground) 1%,
transparent
);
--badge-cursor: pointer;
}

View File

@@ -31,8 +31,6 @@ jest.mock('@floating-ui/dom', () => ({
Promise.resolve({ x: 0, y: 0, placement, strategy, middlewareData: {} }),
}));
jest.setTimeout(15_000);
const VIEW_1_ORDER_BY = JSON.stringify({ columnName: 'name', order: 'asc' });
const VIEW_1_QUERY = "severity = 'critical'";

View File

@@ -240,6 +240,7 @@ function ListAlertRules(): JSX.Element {
showTotalCount: true,
}}
paginationClassname={styles.paginationContainer}
enableAlternatingRowColors
plainTextCellLineClamp={2}
/>
)}

View File

@@ -107,7 +107,9 @@ export function getAlertRuleColumns(
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return <LabelColumn labels={tagKeys} value={labels} color="secondary" />;
return (
<LabelColumn labels={tagKeys} value={labels} color="highlight-danger" />
);
},
},
{

View File

@@ -3,11 +3,13 @@ import logEvent from 'api/common/logEvent';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useListRulesV3 } from 'api/generated/services/rules';
import type {
ListRulesV3200,
ListRulesV3Params,
RenderErrorResponseDTO,
RuletypesLabelPairDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
import { isUndefined } from 'lodash-es';
import type { AlertRule } from './types';
@@ -33,7 +35,18 @@ export function useAlertRulesData(
const hasLoggedEvent = useRef(false);
const rulesResponse = useListRulesV3(params, {
query: { keepPreviousData: true },
query: {
keepPreviousData: true,
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
queryFn: ({ signal }) =>
GeneratedAPIInstance<ListRulesV3200>({
url: '/api/v3/rules',
method: 'GET',
params,
paramsSerializer: { indexes: null },
signal,
}),
},
});
const rules = useMemo(

View File

@@ -1,9 +1,7 @@
import React, { Dispatch, SetStateAction, useMemo, useRef } from 'react';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { ENDPOINT_STATS_EXPRESSION_KEY } from 'container/ApiMonitoring/filterParamKeys';
import { routeConfig } from 'container/SideNav/config';
import { getQueryString } from 'container/SideNav/helper';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -17,7 +15,11 @@ import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import {
IBuilderQuery,
Query,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { Tags } from 'hooks/useResourceAttribute/types';
import { isModifierKeyPressed } from 'utils/app';
@@ -124,7 +126,7 @@ const generateAPIMonitoringPath = (
domainName: string,
startTime: number,
endTime: number,
filterExpression: string,
filters: IBuilderQuery['filters'],
): string => {
const basePath = ROUTES.API_MONITORING;
return `${basePath}?${createQueryParams({
@@ -136,8 +138,8 @@ const generateAPIMonitoringPath = (
endTime,
},
selectedInterval: 'custom',
endPointDetailsLocalFilters: filters,
}),
[ENDPOINT_STATS_EXPRESSION_KEY]: filterExpression,
})}`;
};
export function onViewAPIMonitoringPopupClick({
@@ -151,15 +153,41 @@ export function onViewAPIMonitoringPopupClick({
return (e?: React.MouseEvent): void => {
const endTime = timestamp + (stepInterval || 60);
const startTime = timestamp - (stepInterval || 60);
const filterExpression = [
...(isError ? ["hasError IN ['true']"] : []),
`service.name = ${formatValueForExpression(servicename)}`,
].join(' AND ');
const filters = {
items: [
...(isError
? [
{
id: uuid().slice(0, 8),
key: {
key: 'hasError',
dataType: DataTypes.bool,
type: 'tag',
id: 'hasError--bool--tag--true',
},
op: 'in',
value: ['true'],
},
]
: []),
{
id: uuid().slice(0, 8),
key: {
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
},
op: '=',
value: servicename,
},
],
op: 'AND',
};
const newPath = generateAPIMonitoringPath(
domainName,
startTime,
endTime,
filterExpression,
filters,
);
safeNavigate(newPath, { newTab: !!e && isModifierKeyPressed(e) });

View File

@@ -153,7 +153,6 @@ import opentelemetryUrl from '@/assets/Logos/opentelemetry.svg';
import phpUrl from '@/assets/Logos/php.svg';
import pinoUrl from '@/assets/Logos/pino.svg';
import pipecatUrl from '@/assets/Logos/pipecat.svg';
import pixeltableUrl from '@/assets/Logos/pixeltable.svg';
import planetscaleUrl from '@/assets/Logos/planetscale.svg';
import pm2Url from '@/assets/Logos/pm2.svg';
import podmanUrl from '@/assets/Logos/podman.svg';
@@ -2580,7 +2579,7 @@ const onboardingConfigWithLinks = [
key: 'aws-ec2-metrics-one-click',
label: 'One Click AWS',
imgUrl: ec2Url,
link: '/integrations/aws?service=ec2',
link: '/integrations?integration=aws-integration&service=ec2',
internalRedirect: true,
},
{
@@ -2638,7 +2637,7 @@ const onboardingConfigWithLinks = [
key: 'aws-ecs-ec2-one-click',
label: 'One Click AWS',
imgUrl: ecsUrl,
link: '/integrations/aws?service=ecs',
link: '/integrations?integration=aws-integration&service=ecs',
internalRedirect: true,
},
{
@@ -2700,7 +2699,7 @@ const onboardingConfigWithLinks = [
key: 'aws-eks-ec2-one-click',
label: 'One Click AWS',
imgUrl: eksUrl,
link: '/integrations/aws?service=eks',
link: '/integrations?integration=aws-integration&service=eks',
internalRedirect: true,
},
{
@@ -2824,21 +2823,21 @@ const onboardingConfigWithLinks = [
key: 'aws-rds-one-click',
label: 'One Click AWS',
imgUrl: rdsUrl,
link: '/integrations/aws?service=rds',
link: '/integrations?integration=aws-integration&service=rds',
internalRedirect: true,
},
{
key: 'aws-rds-mysql-manual',
label: 'MySQL Manual',
imgUrl: rdsUrl,
link: '/integrations/builtin-aws_rds_mysql',
link: '/integrations?integration=builtin-aws_rds_mysql',
internalRedirect: true,
},
{
key: 'aws-rds-postgresql-manual',
label: 'PostgreSQL Manual',
imgUrl: rdsUrl,
link: '/integrations/builtin-aws_rds_postgresql',
link: '/integrations?integration=builtin-aws_rds_postgresql',
internalRedirect: true,
},
],
@@ -2897,7 +2896,7 @@ const onboardingConfigWithLinks = [
key: 'aws-lambda-logs-one-click',
label: 'One Click AWS',
imgUrl: lambdaUrl,
link: '/integrations/aws?service=lambda',
link: '/integrations?integration=aws-integration&service=lambda',
internalRedirect: true,
},
{
@@ -2955,7 +2954,7 @@ const onboardingConfigWithLinks = [
key: 'aws-lambda-metrics-one-click',
label: 'One Click AWS',
imgUrl: lambdaUrl,
link: '/integrations/aws?service=lambda',
link: '/integrations?integration=aws-integration&service=lambda',
internalRedirect: true,
},
{
@@ -4711,7 +4710,6 @@ const onboardingConfigWithLinks = [
'messaging',
'messaging queues',
'messaging-queues-celery',
'python celery',
'queues',
'task queue tracing',
],
@@ -4932,7 +4930,7 @@ const onboardingConfigWithLinks = [
'redis metrics',
],
imgUrl: redisUrl,
link: '/integrations/builtin-redis',
link: '/integrations?integration=builtin-redis',
internalRedirect: true,
},
{
@@ -4948,7 +4946,7 @@ const onboardingConfigWithLinks = [
'postgresql metrics',
],
imgUrl: postgresqlUrl,
link: '/integrations/builtin-postgres',
link: '/integrations?integration=builtin-postgres',
internalRedirect: true,
},
{
@@ -4964,7 +4962,7 @@ const onboardingConfigWithLinks = [
'proxy',
],
imgUrl: nginxUrl,
link: '/integrations/builtin-nginx',
link: '/integrations?integration=builtin-nginx',
internalRedirect: true,
},
{
@@ -4985,7 +4983,7 @@ const onboardingConfigWithLinks = [
'self',
],
imgUrl: mongoUrl,
link: '/integrations/builtin-mongo',
link: '/integrations?integration=builtin-mongo',
internalRedirect: true,
},
{
@@ -5003,7 +5001,7 @@ const onboardingConfigWithLinks = [
'olap database',
],
imgUrl: clickhouseUrl,
link: '/integrations/builtin-clickhouse',
link: '/integrations?integration=builtin-clickhouse',
internalRedirect: true,
},
{
@@ -5058,7 +5056,7 @@ const onboardingConfigWithLinks = [
key: 'aws-alb-one-click',
label: 'One Click AWS',
imgUrl: awsAlbUrl,
link: '/integrations/aws?service=alb',
link: '/integrations?integration=aws-integration&service=alb',
internalRedirect: true,
},
{
@@ -5099,7 +5097,7 @@ const onboardingConfigWithLinks = [
key: 'aws-api-gateway-one-click',
label: 'One Click AWS',
imgUrl: apiGatewayUrl,
link: '/integrations/aws?service=api-gateway',
link: '/integrations?integration=aws-integration&service=api-gateway',
internalRedirect: true,
},
{
@@ -5144,7 +5142,7 @@ const onboardingConfigWithLinks = [
key: 'aws-dynamodb-one-click',
label: 'One Click AWS',
imgUrl: dynamodbUrl,
link: '/integrations/aws?service=dynamodb',
link: '/integrations?integration=aws-integration&service=dynamodb',
internalRedirect: true,
},
{
@@ -5191,14 +5189,14 @@ const onboardingConfigWithLinks = [
key: 'aws-elasticache-one-click',
label: 'One Click AWS',
imgUrl: elasticacheUrl,
link: '/integrations/aws?service=elasticache',
link: '/integrations?integration=aws-integration&service=elasticache',
internalRedirect: true,
},
{
key: 'aws-elasticache-redis-manual',
label: 'Redis Manual',
imgUrl: elasticacheUrl,
link: '/integrations/builtin-aws_elasticache_redis',
link: '/integrations?integration=builtin-aws_elasticache_redis',
internalRedirect: true,
},
],
@@ -5237,7 +5235,7 @@ const onboardingConfigWithLinks = [
key: 'aws-msk-one-click',
label: 'One Click AWS',
imgUrl: amazonMskUrl,
link: '/integrations/aws?service=msk',
link: '/integrations?integration=aws-integration&service=msk',
internalRedirect: true,
},
{
@@ -5280,7 +5278,7 @@ const onboardingConfigWithLinks = [
key: 'aws-s3-one-click',
label: 'One Click AWS',
imgUrl: s3Url,
link: '/integrations/aws?service=s3sync',
link: '/integrations?integration=aws-integration&service=s3sync',
internalRedirect: true,
},
{
@@ -5322,7 +5320,7 @@ const onboardingConfigWithLinks = [
key: 'aws-sns-one-click',
label: 'One Click AWS',
imgUrl: snsUrl,
link: '/integrations/aws?service=sns',
link: '/integrations?integration=aws-integration&service=sns',
internalRedirect: true,
},
{
@@ -5364,7 +5362,7 @@ const onboardingConfigWithLinks = [
key: 'aws-sqs-one-click',
label: 'One Click AWS',
imgUrl: sqsUrl,
link: '/integrations/aws?service=sqs',
link: '/integrations?integration=aws-integration&service=sqs',
internalRedirect: true,
},
{
@@ -7251,14 +7249,12 @@ const onboardingConfigWithLinks = [
'cloud monitoring metrics',
'connect gcp account',
'gcp',
'gcp agent',
'gcp integration',
'gcp logs',
'gcp manual setup',
'gcp metrics',
'gcp monitoring',
'gcp observability',
'gcp one click',
'gcp service account',
'google cloud',
'google cloud integration',
@@ -7267,8 +7263,6 @@ const onboardingConfigWithLinks = [
'metrics',
'monitoring',
'observability',
'one click',
'one click gcp integration',
'opentelemetry collector gcp',
'pub/sub',
],
@@ -8161,50 +8155,29 @@ const onboardingConfigWithLinks = [
link: '/docs/instrumentation/javascript/opentelemetry-pm2/',
},
{
dataSource: 'pixeltable',
label: 'Pixeltable',
imgUrl: pixeltableUrl,
tags: ['LLM Monitoring'],
module: 'apm',
dataSource: 'gcp-one-click-integration',
label: 'GCP One-Click Integration',
imgUrl: gcpUrl,
tags: ['GCP', 'metrics', 'logs'],
module: 'integrations',
relatedSearchKeywords: [
'apm',
'llm',
'llm monitoring',
'multimodal ai',
'observability',
'opentelemetry pixeltable',
'pixeltable',
'pixeltable monitoring',
'pixeltable observability',
'pixeltable traces',
'traces',
'udf',
],
id: 'pixeltable',
link: '/docs/pixeltable-observability/',
},
{
dataSource: 'claude-cowork',
label: 'Claude Cowork',
imgUrl: claudeCodeUrl,
tags: ['LLM Monitoring', 'logs'],
module: 'logs',
relatedSearchKeywords: [
'claude',
'claude cowork',
'claude cowork monitoring',
'claude cowork observability',
'cowork',
'llm',
'llm monitoring',
'gcp',
'gcp agent',
'gcp integration',
'gcp logs',
'gcp metrics',
'gcp one click',
'google cloud',
'google cloud integration',
'logs',
'metrics',
'monitoring',
'observability',
'otel claude cowork',
'telemetry',
'one click',
'one click gcp integration',
],
id: 'claude-cowork',
link: '/docs/claude-cowork-monitoring/',
id: 'gcp-one-click-integration',
link: '/docs/integrations/gcp/one-click-gcp-integrations/',
},
];
export default onboardingConfigWithLinks;

View File

@@ -1,32 +0,0 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { back, getCurrentLocation } from 'lib/router/navigation';
import { act, AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
// jest.config.ts maps `hooks/useSafeNavigate` to a mock; the `.ts` path loads the real hook.
const { useSafeNavigate } = jest.requireActual<
typeof import('hooks/useSafeNavigate')
>('hooks/useSafeNavigate.ts');
describe('useSafeNavigate', () => {
it('adds a history entry for replace: false even when the target adds params', async () => {
const initialSearch = `?${QueryParams.relativeTime}=15m`;
const { result } = renderHook(() => useSafeNavigate(), {
wrapper: ({ children }): JSX.Element => (
<AllTheProviders initialRoute={`${ROUTES.LOGS_EXPLORER}${initialSearch}`}>
{children}
</AllTheProviders>
),
});
act(() => {
result.current.safeNavigate(
`${ROUTES.LOGS_EXPLORER}?${QueryParams.startTime}=1000`,
{ replace: false },
);
});
act(() => back());
await waitFor(() => expect(getCurrentLocation().search).toBe(initialSearch));
});
});

View File

@@ -140,7 +140,7 @@ export const useSafeNavigate = (
const navigationOptions = {
...options,
replace: options?.replace ?? isDefaultParamsNavigation,
replace: isDefaultParamsNavigation || options?.replace,
};
if (typeof to === 'string') {

View File

@@ -40,6 +40,7 @@ const make = (
state: RuletypesAlertStateDTO.inactive,
labels: { severity: 'info' },
annotations: {},
source: '',
ruleType: RuletypesRuleTypeDTO.threshold_rule,
schemaVersion: RuletypesRuleDTOSchemaVersion.v2alpha1,
evaluation: {

View File

@@ -41,8 +41,6 @@
}
.alert-details {
margin-top: 10px;
&__divider {
--divider-color: var(--l1-border);
--divider-margin: 16px 0;

View File

@@ -56,6 +56,7 @@
}
.alert-rules-container {
margin-top: 10px;
flex: 1;
min-height: 0;
display: flex;
@@ -132,6 +133,10 @@
display: flex;
flex-direction: column;
overflow: hidden;
> .alert-rules-container {
margin-top: 0;
}
}
}
}

View File

@@ -379,6 +379,8 @@ const buildRule = (index: number, shape: RuleShape): RuletypesRuleDTO => {
createdBy: 'ada@signoz.io',
updatedBy: 'grace@signoz.io',
schemaVersion: RuletypesRuleDTOSchemaVersion.v2alpha1,
version: 'v5',
source: 'http://localhost/alerts',
notificationSettings: {
groupBy: ['alertname'],
usePolicy: false,

View File

@@ -12,7 +12,6 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { VIEWS } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
import { ENDPOINT_STATS_EXPRESSION_KEY } from 'container/ApiMonitoring/filterParamKeys';
import { DEFAULT_PARAMS } from 'container/ApiMonitoring/queryParams';
import type { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { rest } from 'msw';
@@ -124,7 +123,17 @@ const timeRange = (): Partial<AppState> => {
};
};
const serviceFilterExpression = "service.name = 'checkout'";
const serviceFilterItems = {
op: 'AND',
items: [
{
id: 'storybook-service-filter',
key: { key: 'service.name', dataType: 'string', type: 'resource' },
op: '=',
value: 'checkout',
},
],
};
interface RouteValues {
drawer: DrawerState;
@@ -151,14 +160,12 @@ const apiMonitoringRoute = ({
selectedView: VIEW_OF[drawer],
selectedEndPointName:
drawer === 'endpoint-stats' ? endpointUrl(domainName, 0) : '',
...(serviceFilter ? { endPointDetailsLocalFilters: serviceFilterItems } : {}),
};
const filterParam = serviceFilter
? `&${ENDPOINT_STATS_EXPRESSION_KEY}=${encodeURIComponent(serviceFilterExpression)}`
: '';
return `${ROUTES.API_MONITORING}?apiMonitoringParams=${encodeURIComponent(
JSON.stringify(params),
)}${filterParam}`;
)}`;
};
/** The parts of a query spec the handler tells the page's requests apart by. */

View File

@@ -26,9 +26,11 @@ export interface AlertDef {
labels?: Labels;
annotations?: Labels;
evalWindow?: string;
source?: string;
disabled?: boolean;
preferredChannels?: string[];
broadcastToAll?: boolean;
version?: string;
}
export interface RuleCondition {

2
go.mod
View File

@@ -10,7 +10,7 @@ require (
github.com/SigNoz/clickhouse-go-mock v0.14.0
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd
github.com/SigNoz/signoz-otel-collector v0.144.6
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.2
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.1
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/cespare/xxhash/v2 v2.3.0

4
go.sum
View File

@@ -102,8 +102,8 @@ github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd h1:Bk43AsDYe0fhkb
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd/go.mod h1:nxRcH/OEdM8QxzH37xkGzomr1O0JpYBRS6pwjsWW6Pc=
github.com/SigNoz/signoz-otel-collector v0.144.6 h1:AgblCIDlLKGtssS4ywvyEByH9fbJ7hFqZvPvUVJKwp8=
github.com/SigNoz/signoz-otel-collector v0.144.6/go.mod h1:YRQw6ETaq7fm8ts4Itgb6wC0bVhc0+nC6PFI0MaiH9k=
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.2 h1:b0YEbA8sjW73bcDl9PhYrftdZ2uScWMcIu6zKwh4/9I=
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.2/go.mod h1:RSsdmH4/6H+EHeNPyqJ59dGdpKS0hl2p3T5g1NbehOU=
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.1 h1:eKJ4vmSLYIfufgh+HaiJxXZts12Xz4jr81HMYYoQvMU=
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.1/go.mod h1:RSsdmH4/6H+EHeNPyqJ59dGdpKS0hl2p3T5g1NbehOU=
github.com/Yiling-J/theine-go v0.6.2 h1:1GeoXeQ0O0AUkiwj2S9Jc0Mzx+hpqzmqsJ4kIC4M9AY=
github.com/Yiling-J/theine-go v0.6.2/go.mod h1:08QpMa5JZ2pKN+UJCRrCasWYO1IKCdl54Xa836rpmDU=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=

View File

@@ -119,7 +119,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
ID: "TestChannelDeprecated",
Tags: []string{"channels"},
Summary: "Test notification channel (deprecated)",
Description: "Deprecated: use /api/v1/channels/test instead",
Description: "Deprecated: use POST /api/v1/channels/test instead.",
Request: new(alertmanagertypes.Receiver),
RequestContentType: "application/json",
Response: nil,

View File

@@ -336,7 +336,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
ID: "AgentCheckInDeprecated",
Tags: []string{"cloudintegration"},
Summary: "Agent check-in",
Description: "[Deprecated] This endpoint is called by the deployed agent to check in",
Description: "This endpoint is called by the deployed agent to check in. Deprecated: use POST /api/v1/cloud_integrations/{cloud_provider}/accounts/check_in instead. Send cloudIntegrationId and providerAccountId instead of account_id and cloud_account_id.",
Request: new(citypes.PostableAgentCheckIn),
RequestContentType: "application/json",
Response: new(citypes.GettableAgentCheckIn),

View File

@@ -207,7 +207,7 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
ID: "CreateIngestionKeyLimit",
Tags: []string{"gateway"},
Summary: "Create limit for the ingestion key",
Description: "This endpoint creates an ingestion key limit.",
Description: "This endpoint creates an ingestion key limit. Deprecated: use POST /api/v2/gateway/ingestion_limits instead. Send keyId in the request body.",
Request: new(gatewaytypes.DeprecatedPostableIngestionKeyLimit),
RequestContentType: "application/json",
Response: new(gatewaytypes.GettableCreatedIngestionKeyLimit),
@@ -245,7 +245,7 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
ID: "UpdateIngestionKeyLimit",
Tags: []string{"gateway"},
Summary: "Update limit for the ingestion key",
Description: "This endpoint updates an ingestion key limit.",
Description: "This endpoint updates an ingestion key limit. Deprecated: use PATCH /api/v2/gateway/ingestion_limits/{limitId} instead.",
Request: new(gatewaytypes.UpdatableIngestionKeyLimit),
RequestContentType: "application/json",
Response: nil,
@@ -272,7 +272,7 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
ID: "DeleteIngestionKeyLimit",
Tags: []string{"gateway"},
Summary: "Delete limit for the ingestion key",
Description: "This endpoint deletes an ingestion key limit",
Description: "This endpoint deletes an ingestion key limit. Deprecated: use DELETE /api/v2/gateway/ingestion_limits/{limitId} instead.",
Request: nil,
RequestContentType: "application/json",
Response: nil,

View File

@@ -45,7 +45,7 @@ func (provider *provider) addLicensingRoutes(router *mux.Router) error {
ID: "ActivateLicenseDeprecated",
Tags: []string{"licenses"},
Summary: "Activate a license.",
Description: "This endpoint validates the license key with the upstream server and activates the license for the organization.",
Description: "This endpoint validates the license key with the upstream server and activates the license for the organization. Deprecated: use POST /api/v4/licenses instead. The v4 endpoint returns 201 with the license id.",
Request: new(licensetypes.PostableLicense),
RequestContentType: "application/json",
Response: nil,
@@ -71,7 +71,7 @@ func (provider *provider) addLicensingRoutes(router *mux.Router) error {
ID: "RefreshLicenseDeprecated",
Tags: []string{"licenses"},
Summary: "Refresh a license.",
Description: "This endpoint refreshes the active license of the organization from the upstream server.",
Description: "This endpoint refreshes the active license of the organization from the upstream server. Deprecated: use PUT /api/v4/licenses/{id} instead. Pass the active license id in the path.",
Request: nil,
RequestContentType: "",
Response: nil,
@@ -167,7 +167,7 @@ func (provider *provider) addLicensingRoutes(router *mux.Router) error {
ID: "RefreshLicense",
Tags: []string{"licenses"},
Summary: "Refresh a license.",
Description: "This endpoint refreshes the active license of the organization from the upstream server.",
Description: "This endpoint refreshes the license with the given id from the upstream server. Only the active license can be refreshed.",
Request: nil,
RequestContentType: "",
Response: nil,

View File

@@ -33,6 +33,7 @@ func postableRuleExamples() []handler.OpenAPIExample {
"alertType": "METRIC_BASED_ALERT",
"description": "CPU usage for api-service pods exceeds 80% of the requested CPU",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
@@ -92,6 +93,7 @@ func postableRuleExamples() []handler.OpenAPIExample {
"alertType": "METRIC_BASED_ALERT",
"description": "Disk utilization for a persistent volume is above 80%",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
@@ -174,6 +176,7 @@ func postableRuleExamples() []handler.OpenAPIExample {
"alertType": "METRIC_BASED_ALERT",
"description": "Consumer group lag computed via PromQL",
"ruleType": "promql_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
@@ -225,6 +228,7 @@ func postableRuleExamples() []handler.OpenAPIExample {
"alertType": "METRIC_BASED_ALERT",
"description": "Detect an abrupt drop in span ingestion using a z-score anomaly function",
"ruleType": "anomaly_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
@@ -289,6 +293,7 @@ func postableRuleExamples() []handler.OpenAPIExample {
"alertType": "LOGS_BASED_ALERT",
"description": "Any panic log line emitted by the payments service",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
@@ -347,6 +352,7 @@ func postableRuleExamples() []handler.OpenAPIExample {
"alertType": "LOGS_BASED_ALERT",
"description": "Error log ratio as a percentage of total logs for payments-api",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
@@ -423,6 +429,7 @@ func postableRuleExamples() []handler.OpenAPIExample {
"alertType": "TRACES_BASED_ALERT",
"description": "p99 duration of the search endpoint exceeds 5s",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
@@ -483,6 +490,7 @@ func postableRuleExamples() []handler.OpenAPIExample {
"alertType": "TRACES_BASED_ALERT",
"description": "Request error rate for search-api, grouped by route",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
@@ -565,6 +573,7 @@ func postableRuleExamples() []handler.OpenAPIExample {
"alertType": "METRIC_BASED_ALERT",
"description": "Warn at lag ≥ 50 and page at ≥ 200, tiered via thresholds.spec.",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
@@ -655,6 +664,7 @@ func postableRuleExamples() []handler.OpenAPIExample {
"alertType": "TRACES_BASED_ALERT",
"description": "Noise-controlled 5xx error rate alert with renotify on gaps",
"ruleType": "threshold_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{

View File

@@ -43,15 +43,15 @@ func stripKeyAlias(name string) string {
// unwrapVariant decodes a scan envelope: the chcol.Variant the driver scans a Dynamic column (a JSON
// path such as body_v2.level) into, and the JSONValue a whole JSON column scans into. The attributes
// bag decodes flat so its dotted keys merge with the legacy attribute maps and a key stored at both a
// scalar and an object path stays two keys; the scope and resource decode flat too, keeping their
// attributes dotted; every other JSON column decodes nested.
// scalar and an object path stays two keys; the scope decodes flat too, keeping its attributes dotted;
// every other JSON column decodes nested.
func unwrapVariant(name string, val any) any {
switch v := val.(type) {
case chcol.Variant:
return v.Any()
case telemetrystoretypes.JSONValue:
switch name {
case "attributes", "scope", "resource":
case "attributes", "scope":
return v.Flatten()
}
return v.Nest()

View File

@@ -98,10 +98,6 @@ func TestUnwrapVariant(t *testing.T) {
scopeAttrs := chcol.NewJSON()
scopeAttrs.SetValueAtPath("lib.lang", "go")
scopeCol := jsonColumn(map[string]any{"name": "io.opentelemetry.http", "version": "1.2.0", "attributes": scopeAttrs})
resourceAttrs := chcol.NewJSON()
resourceAttrs.SetValueAtPath("service.name", "signoz")
resourceAttrs.SetValueAtPath("signoz.resource.kind", "role")
resourceCol := jsonColumn(map[string]any{"attributes": resourceAttrs})
testCases := []struct {
name string
@@ -116,7 +112,6 @@ func TestUnwrapVariant(t *testing.T) {
{name: "AttributesColumnFlat", column: "attributes", input: jsonCol, want: map[string]any{"level": "error", "attrs.code": float64(500)}},
{name: "AttributesColumnDropsNullPaths", column: "attributes", input: nullCol, want: map[string]any{"level": "error"}},
{name: "ScopeColumnFlat", column: "scope", input: scopeCol, want: map[string]any{"name": "io.opentelemetry.http", "version": "1.2.0", "attributes": map[string]any{"lib.lang": "go"}}},
{name: "ResourceColumnFlat", column: "resource", input: resourceCol, want: map[string]any{"attributes": map[string]any{"service.name": "signoz", "signoz.resource.kind": "role"}}},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {

View File

@@ -49,6 +49,7 @@ func ThresholdRuleAtLeastOnceValueAbove(target float64, recovery *float64) rulet
Annotations: map[string]string{
"value": "{{$value}}",
},
Version: "v5",
SchemaVersion: ruletypes.SchemaVersionV2Alpha1,
RuleCondition: &ruletypes.RuleCondition{
SelectedQuery: "A",
@@ -107,6 +108,7 @@ func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes
Annotations: map[string]string{
"value": "{{$value}}",
},
Version: "v5",
SchemaVersion: ruletypes.SchemaVersionV2Alpha1,
RuleCondition: &ruletypes.RuleCondition{
SelectedQuery: "A",

View File

@@ -240,6 +240,7 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
},
SelectedQuery: "A",
},
Version: "v5",
}
logger := instrumentationtest.New().Logger()
@@ -302,6 +303,7 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
},
SelectedQuery: "A",
},
Version: "v5",
}
logger := instrumentationtest.New().Logger()

View File

@@ -251,11 +251,15 @@ func (b *auditQueryStatementBuilder) buildListQuery(
sb.SelectMore(audittelemetryschema.TraceFlagsColumn)
sb.SelectMore(audittelemetryschema.SeverityTextColumn)
sb.SelectMore(audittelemetryschema.SeverityNumberColumn)
sb.SelectMore(audittelemetryschema.ScopeNameColumn)
sb.SelectMore(audittelemetryschema.ScopeVersionColumn)
sb.SelectMore(audittelemetryschema.BodyColumn)
sb.SelectMore(audittelemetryschema.EventNameColumn)
sb.SelectMore(audittelemetryschema.AttributesColumn)
sb.SelectMore(audittelemetryschema.AttributesStringColumn)
sb.SelectMore(audittelemetryschema.AttributesNumberColumn)
sb.SelectMore(audittelemetryschema.AttributesBoolColumn)
sb.SelectMore(audittelemetryschema.ResourceColumn)
sb.SelectMore(audittelemetryschema.ScopeColumn)
sb.SelectMore(audittelemetryschema.ScopeStringColumn)
} else {
for index := range query.SelectFields {
if query.SelectFields[index].Name == audittelemetryschema.TimestampColumn || query.SelectFields[index].Name == audittelemetryschema.IDColumn {

View File

@@ -18,12 +18,13 @@ import (
)
func auditFieldKeyMap() map[string][]*telemetrytypes.TelemetryFieldKey {
key := func(name string, ctx telemetrytypes.FieldContext, dt telemetrytypes.FieldDataType) *telemetrytypes.TelemetryFieldKey {
key := func(name string, ctx telemetrytypes.FieldContext, dt telemetrytypes.FieldDataType, materialized bool) *telemetrytypes.TelemetryFieldKey {
return &telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: telemetrytypes.SignalLogs,
FieldContext: ctx,
FieldDataType: dt,
Materialized: materialized,
}
}
@@ -33,26 +34,26 @@ func auditFieldKeyMap() map[string][]*telemetrytypes.TelemetryFieldKey {
i64 := telemetrytypes.FieldDataTypeInt64
return map[string][]*telemetrytypes.TelemetryFieldKey{
"service.name": {key("service.name", res, str)},
"signoz.audit.verb": {key("signoz.audit.verb", attr, str)},
"signoz.audit.outcome": {key("signoz.audit.outcome", attr, str)},
"signoz.principal.email": {key("signoz.principal.email", attr, str)},
"signoz.principal.id": {key("signoz.principal.id", attr, str)},
"signoz.principal.type": {key("signoz.principal.type", attr, str)},
"signoz.resource.kind": {key("signoz.resource.kind", res, str)},
"signoz.resource.id": {key("signoz.resource.id", res, str)},
"signoz.audit.action_category": {key("signoz.audit.action_category", attr, str)},
"exception.type": {key("exception.type", attr, str)},
"http.request.method": {key("http.request.method", attr, str)},
"http.response.status_code": {key("http.response.status_code", attr, i64)},
"service.name": {key("service.name", res, str, false)},
"signoz.audit.verb": {key("signoz.audit.verb", attr, str, true)},
"signoz.audit.outcome": {key("signoz.audit.outcome", attr, str, true)},
"signoz.principal.email": {key("signoz.principal.email", attr, str, true)},
"signoz.principal.id": {key("signoz.principal.id", attr, str, true)},
"signoz.principal.type": {key("signoz.principal.type", attr, str, true)},
"signoz.resource.kind": {key("signoz.resource.kind", res, str, false)},
"signoz.resource.id": {key("signoz.resource.id", res, str, false)},
"signoz.audit.action_category": {key("signoz.audit.action_category", attr, str, false)},
"exception.type": {key("exception.type", attr, str, false)},
"http.request.method": {key("http.request.method", attr, str, false)},
"http.response.status_code": {key("http.response.status_code", attr, i64, false)},
}
}
func newTestAuditStatementBuilder(t *testing.T, keysMap map[string][]*telemetrytypes.TelemetryFieldKey) *auditQueryStatementBuilder {
func newTestAuditStatementBuilder(t *testing.T) *auditQueryStatementBuilder {
t.Helper()
fl := flaggertest.New(t)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = keysMap
mockMetadataStore.KeysMap = auditFieldKeyMap()
storage := audittelemetryschema.NewStorage()
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
@@ -68,7 +69,7 @@ func newTestAuditStatementBuilder(t *testing.T, keysMap map[string][]*telemetryt
}
func TestStatementBuilder(t *testing.T) {
statementBuilder := newTestAuditStatementBuilder(t, auditFieldKeyMap())
statementBuilder := newTestAuditStatementBuilder(t)
ctx := context.Background()
testCases := []struct {
@@ -78,7 +79,7 @@ func TestStatementBuilder(t *testing.T) {
expected qbtypes.Statement
expectedErr error
}{
// List: all actions by a specific user
// List: all actions by a specific user (materialized principal.id filter)
{
name: "ListByPrincipalID",
requestType: qbtypes.RequestTypeRaw,
@@ -91,11 +92,11 @@ func TestStatementBuilder(t *testing.T) {
Limit: 100,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, body, event_name, attributes, resource, scope FROM signoz_audit.distributed_logs WHERE (attributes.`signoz.principal.id`::String = ? AND attributes.`signoz.principal.id` IS NOT NULL) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$principal$$id` = ? AND `attribute_string_signoz$$principal$$id_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"019a-1234-abcd-5678", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
},
},
// List: all failed actions
// List: all failed actions (materialized outcome filter)
{
name: "ListByOutcomeFailure",
requestType: qbtypes.RequestTypeRaw,
@@ -108,11 +109,11 @@ func TestStatementBuilder(t *testing.T) {
Limit: 100,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, body, event_name, attributes, resource, scope FROM signoz_audit.distributed_logs WHERE (attributes.`signoz.audit.outcome`::String = ? AND attributes.`signoz.audit.outcome` IS NOT NULL) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"failure", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
},
},
// List: change history of a specific dashboard (two resource keys AND)
// List: change history of a specific dashboard (two materialized column AND)
{
name: "ListByResourceKindAndID",
requestType: qbtypes.RequestTypeRaw,
@@ -125,13 +126,13 @@ func TestStatementBuilder(t *testing.T) {
Limit: 100,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND (simpleJSONExtractString(labels, 'signoz.resource.id') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, body, event_name, attributes, resource, scope FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND (simpleJSONExtractString(labels, 'signoz.resource.id') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"dashboard", "%signoz.resource.kind%", "%signoz.resource.kind\":\"dashboard%", "019b-5678-efgh-9012", "%signoz.resource.id%", "%signoz.resource.id\":\"019b-5678-efgh-9012%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
},
},
// List: all dashboard deletions (compliance — resource.kind + verb AND)
// List: all dashboard deletions (compliance — resource.kind + action AND)
{
name: "ListByResourceKindAndVerb",
name: "ListByResourceKindAndAction",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
@@ -142,11 +143,11 @@ func TestStatementBuilder(t *testing.T) {
Limit: 100,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE (simpleJSONExtractString(labels, 'signoz.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, body, event_name, attributes, resource, scope FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (attributes.`signoz.audit.verb`::String = ? AND attributes.`signoz.audit.verb` IS NOT NULL) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE (simpleJSONExtractString(labels, 'signoz.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (`attribute_string_signoz$$audit$$verb` = ? AND `attribute_string_signoz$$audit$$verb_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"dashboard", "%signoz.resource.kind%", "%signoz.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "delete", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
},
},
// List: all actions by service accounts
// List: all actions by service accounts (materialized principal.type)
{
name: "ListByPrincipalType",
requestType: qbtypes.RequestTypeRaw,
@@ -159,13 +160,13 @@ func TestStatementBuilder(t *testing.T) {
Limit: 100,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, body, event_name, attributes, resource, scope FROM signoz_audit.distributed_logs WHERE (attributes.`signoz.principal.type`::String = ? AND attributes.`signoz.principal.type` IS NOT NULL) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$principal$$type` = ? AND `attribute_string_signoz$$principal$$type_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"service_account", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
},
},
// Scalar: alert — count forbidden errors (outcome + verb AND)
// Scalar: alert — count forbidden errors (outcome + action AND)
{
name: "ScalarCountByOutcomeAndVerb",
name: "ScalarCountByOutcomeAndAction",
requestType: qbtypes.RequestTypeScalar,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
@@ -179,7 +180,7 @@ func TestStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "SELECT count() AS __result_0 FROM signoz_audit.distributed_logs WHERE ((attributes.`signoz.audit.outcome`::String = ? AND attributes.`signoz.audit.outcome` IS NOT NULL) AND (attributes.`signoz.audit.verb`::String = ? AND attributes.`signoz.audit.verb` IS NOT NULL)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC",
Query: "SELECT count() AS __result_0 FROM signoz_audit.distributed_logs WHERE ((`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = true) AND (`attribute_string_signoz$$audit$$verb` = ? AND `attribute_string_signoz$$audit$$verb_exists` = true)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC",
Args: []any{"failure", "update", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
},
@@ -203,7 +204,7 @@ func TestStatementBuilder(t *testing.T) {
Limit: 5,
},
expected: qbtypes.Statement{
Query: "WITH __limit_cte AS (SELECT toString(multiIf(attributes.`signoz.principal.email` IS NOT NULL, attributes.`signoz.principal.email`::String, NULL)) AS `signoz.principal.email`, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE (attributes.`signoz.audit.outcome`::String = ? AND attributes.`signoz.audit.outcome` IS NOT NULL) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `signoz.principal.email` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(attributes.`signoz.principal.email` IS NOT NULL, attributes.`signoz.principal.email`::String, NULL)) AS `signoz.principal.email`, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE (attributes.`signoz.audit.outcome`::String = ? AND attributes.`signoz.audit.outcome` IS NOT NULL) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`signoz.principal.email`) GLOBAL IN (SELECT `signoz.principal.email` FROM __limit_cte) GROUP BY ts, `signoz.principal.email`",
Query: "WITH __limit_cte AS (SELECT toString(multiIf(`attribute_string_signoz$$principal$$email_exists` = true, `attribute_string_signoz$$principal$$email`, NULL)) AS `signoz.principal.email`, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `signoz.principal.email` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(`attribute_string_signoz$$principal$$email_exists` = true, `attribute_string_signoz$$principal$$email`, NULL)) AS `signoz.principal.email`, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`signoz.principal.email`) GLOBAL IN (SELECT `signoz.principal.email` FROM __limit_cte) GROUP BY ts, `signoz.principal.email`",
Args: []any{"failure", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 5, "failure", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
},
@@ -224,210 +225,8 @@ func TestStatementBuilder(t *testing.T) {
}
}
// Audit has no keys tables yet, so every key outside the intrinsic fields
// resolves through the storage fallback.
func TestStatementBuilder_WithoutMetadata(t *testing.T) {
statementBuilder := newTestAuditStatementBuilder(t, map[string][]*telemetrytypes.TelemetryFieldKey{})
ctx := context.Background()
testCases := []struct {
name string
requestType qbtypes.RequestType
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
expected qbtypes.Statement
}{
{
name: "ListByAttribute",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
Filter: &qbtypes.Filter{Expression: "signoz.audit.outcome = 'failure'"},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, body, event_name, attributes, resource, scope FROM signoz_audit.distributed_logs WHERE (attributes.`signoz.audit.outcome`::String = ? AND attributes.`signoz.audit.outcome` IS NOT NULL) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"failure", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
{
name: "ListByResourceAttribute",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
Filter: &qbtypes.Filter{Expression: "resource.signoz.resource.kind = 'dashboard'"},
Limit: 20,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, body, event_name, attributes, resource, scope FROM signoz_audit.distributed_logs WHERE (resource.attributes.`signoz.resource.kind`::String = ? AND resource.attributes.`signoz.resource.kind` IS NOT NULL) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"dashboard", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 20},
},
},
{
name: "ListByScopeNameAndBody",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
Filter: &qbtypes.Filter{Expression: "scope.name = 'signoz.audit' AND body CONTAINS 'deleted'"},
Limit: 30,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, body, event_name, attributes, resource, scope FROM signoz_audit.distributed_logs WHERE ((scope.name = ? AND scope.name <> '') AND LOWER(body.message) LIKE LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"signoz.audit", "%deleted%", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 30},
},
},
{
name: "ListByFullText",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
Filter: &qbtypes.Filter{Expression: "'dashboard.deleted'"},
Limit: 40,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, body, event_name, attributes, resource, scope FROM signoz_audit.distributed_logs WHERE match(body.message, ?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"dashboard.deleted", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 40},
},
},
{
name: "ListByNumericAttribute",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
Filter: &qbtypes.Filter{Expression: "http.response.status_code >= 400"},
Limit: 50,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, body, event_name, attributes, resource, scope FROM signoz_audit.distributed_logs WHERE toFloat64OrNull(attributes.`http.response.status_code`::String) >= ? AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{float64(400), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 50},
},
},
{
name: "ListByAttributeExists_OrderByTimestamp",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
Filter: &qbtypes.Filter{Expression: "exception.type EXISTS"},
Order: []qbtypes.OrderBy{
{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "timestamp"}}, Direction: qbtypes.OrderDirectionDesc},
},
Limit: 60,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, body, event_name, attributes, resource, scope FROM signoz_audit.distributed_logs WHERE attributes.`exception.type` IS NOT NULL AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY timestamp desc LIMIT ?",
Args: []any{"1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 60},
},
},
{
name: "ListSelectFields",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
SelectFields: []telemetrytypes.TelemetryFieldKey{
{Name: "signoz.principal.email", FieldContext: telemetrytypes.FieldContextAttribute},
{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource},
{Name: "body"},
},
Limit: 70,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, multiIf(attributes.`signoz.principal.email` IS NOT NULL, attributes.`signoz.principal.email`::String, NULL) AS `signoz.principal.email`, multiIf(resource.attributes.`service.name` IS NOT NULL, resource.attributes.`service.name`::String, NULL) AS `service.name`, body.message AS `body` FROM signoz_audit.distributed_logs WHERE timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 70},
},
},
{
name: "ScalarCountGroupedByAttribute",
requestType: qbtypes.RequestTypeScalar,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "signoz.audit.verb"}},
},
},
expected: qbtypes.Statement{
Query: "SELECT toString(multiIf(attributes.`signoz.audit.verb` IS NOT NULL, attributes.`signoz.audit.verb`::String, NULL)) AS `signoz.audit.verb`, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `signoz.audit.verb` ORDER BY __result_0 DESC",
Args: []any{"1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
q, err := statementBuilder.Build(ctx, valuer.UUID{}, 1747947419000, 1747983448000, testCase.requestType, testCase.query, nil)
require.NoError(t, err)
assert.Equal(t, testCase.expected.Query, q.Query)
assert.Equal(t, testCase.expected.Args, q.Args)
})
}
}
func TestStatementBuilder_KnownResourceFields(t *testing.T) {
keysMap := map[string][]*telemetrytypes.TelemetryFieldKey{}
for name, field := range audittelemetryschema.ResourceFields {
keysMap[name] = []*telemetrytypes.TelemetryFieldKey{&field}
}
statementBuilder := newTestAuditStatementBuilder(t, keysMap)
ctx := context.Background()
testCases := []struct {
name string
requestType qbtypes.RequestType
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
expected qbtypes.Statement
}{
{
name: "ListByBareResourceKey",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
Filter: &qbtypes.Filter{Expression: "signoz.resource.kind = 'role'"},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE (simpleJSONExtractString(labels, 'signoz.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, body, event_name, attributes, resource, scope FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"role", "%signoz.resource.kind%", "%signoz.resource.kind\":\"role%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
{
name: "ScalarCountGroupedByBareResourceKey",
requestType: qbtypes.RequestTypeScalar,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Source: telemetrytypes.SourceAudit,
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "signoz.resource.kind"}},
},
},
expected: qbtypes.Statement{
Query: "SELECT toString(multiIf(resource.attributes.`signoz.resource.kind` IS NOT NULL, resource.attributes.`signoz.resource.kind`::String, NULL)) AS `signoz.resource.kind`, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `signoz.resource.kind` ORDER BY __result_0 DESC",
Args: []any{"1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
q, err := statementBuilder.Build(ctx, valuer.UUID{}, 1747947419000, 1747983448000, testCase.requestType, testCase.query, nil)
require.NoError(t, err)
assert.Equal(t, testCase.expected.Query, q.Query)
assert.Equal(t, testCase.expected.Args, q.Args)
assert.Empty(t, q.Warnings)
})
}
}
func TestStatementBuilderHeatmap(t *testing.T) {
statementBuilder := newTestAuditStatementBuilder(t, auditFieldKeyMap())
statementBuilder := newTestAuditStatementBuilder(t)
testCases := []struct {
name string
@@ -436,7 +235,7 @@ func TestStatementBuilderHeatmap(t *testing.T) {
expected qbtypes.Statement
}{
{
name: "AttributeField_LinearAxis_RequiresField",
name: "AttributeField_GroupByMaterialized_LinearAxis_RequiresField",
requestType: qbtypes.RequestTypeHeatmap,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
@@ -448,7 +247,7 @@ func TestStatementBuilderHeatmap(t *testing.T) {
BucketOptions: &qbtypes.BucketOptions{Kind: qbtypes.BucketsKindLinear, Spec: qbtypes.LinearBucketsSpec{MaxValue: 500, NumBuckets: 5}},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(toFloat64OrNull(attributes.`http.response.status_code`::String), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(attributes.`signoz.audit.verb` IS NOT NULL, attributes.`signoz.audit.verb`::String, NULL)) AS `signoz.audit.verb`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value > 500, toFloat64(500), (least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) - 1) * 500 / 5) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value > 500, toFloat64('+Inf'), least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) * 500 / 5) AS __bucket_max, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND toFloat64OrNull(attributes.`http.response.status_code`::String) IS NOT NULL AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, `signoz.audit.verb`, __bucket_min, __bucket_max",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(multiIf(mapContains(attributes_number, 'http.response.status_code'), toFloat64(attributes_number['http.response.status_code']), NULL), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(`attribute_string_signoz$$audit$$verb_exists` = true, `attribute_string_signoz$$audit$$verb`, NULL)) AS `signoz.audit.verb`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value > 500, toFloat64(500), (least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) - 1) * 500 / 5) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value > 500, toFloat64('+Inf'), least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) * 500 / 5) AS __bucket_max, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND mapContains(attributes_number, 'http.response.status_code') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, `signoz.audit.verb`, __bucket_min, __bucket_max",
Args: []any{"dashboard", "%signoz.resource.kind%", "%signoz.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
},
@@ -465,7 +264,7 @@ func TestStatementBuilderHeatmap(t *testing.T) {
BucketOptions: &qbtypes.BucketOptions{Kind: qbtypes.BucketsKindLinear, Spec: qbtypes.LinearBucketsSpec{MaxValue: 500, NumBuckets: 5}},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(toFloat64OrNull(attributes.`http.response.status_code`::String), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(attributes.`signoz.audit.verb` IS NOT NULL, attributes.`signoz.audit.verb`::String, NULL)) AS `signoz.audit.verb`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value > 500, toFloat64(500), (least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) - 1) * 500 / 5) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value > 500, toFloat64('+Inf'), least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) * 500 / 5) AS __bucket_max, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND toFloat64OrNull(attributes.`http.response.status_code`::String) IS NOT NULL AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, `signoz.audit.verb`, __bucket_min, __bucket_max",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE ((simpleJSONExtractString(labels, 'signoz.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), ifNull(multiIf(mapContains(attributes_number, 'http.response.status_code'), toFloat64(attributes_number['http.response.status_code']), NULL), 0) AS __bucket_value SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(`attribute_string_signoz$$audit$$verb_exists` = true, `attribute_string_signoz$$audit$$verb`, NULL)) AS `signoz.audit.verb`, multiIf(__bucket_value <= 0, toFloat64('-Inf'), __bucket_value > 500, toFloat64(500), (least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) - 1) * 500 / 5) AS __bucket_min, multiIf(__bucket_value <= 0, toFloat64(0), __bucket_value > 500, toFloat64('+Inf'), least(greatest(ceil(__bucket_value * 5 / 500), 1), 5) * 500 / 5) AS __bucket_max, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND mapContains(attributes_number, 'http.response.status_code') AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts, `signoz.audit.verb`, __bucket_min, __bucket_max",
Args: []any{"dashboard", "%signoz.resource.kind%", "%signoz.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
},

View File

@@ -691,7 +691,6 @@ func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, orgID valuer.UUID,
return keys, complete, nil
}
//nolint:unused // not called until audit keys are recorded in signoz_metadata
func (t *telemetryMetaStore) auditTblStatementToFieldKeys(ctx context.Context) ([]*telemetrytypes.TelemetryFieldKey, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
@@ -718,7 +717,6 @@ func (t *telemetryMetaStore) auditTblStatementToFieldKeys(ctx context.Context) (
return materialisedKeys, nil
}
//nolint:unused // not called until audit keys are recorded in signoz_metadata
func (t *telemetryMetaStore) getAuditKeys(ctx context.Context, fieldKeySelectors []*telemetrytypes.FieldKeySelector) ([]*telemetrytypes.TelemetryFieldKey, bool, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
@@ -886,25 +884,18 @@ func (t *telemetryMetaStore) getAuditKeys(ctx context.Context, fieldKeySelectors
complete := rowCount <= limit
return appendStaticAuditKeys(keys, mapOfKeys, fieldKeySelectors), complete, nil
}
// appendStaticAuditKeys adds the matching intrinsic audit fields (same as logs intrinsics: body, severity_text, etc.)
// and the known resource fields.
func appendStaticAuditKeys(keys []*telemetrytypes.TelemetryFieldKey, mapOfKeys map[string]*telemetrytypes.TelemetryFieldKey, fieldKeySelectors []*telemetrytypes.FieldKeySelector) []*telemetrytypes.TelemetryFieldKey {
for _, fields := range []map[string]telemetrytypes.TelemetryFieldKey{audittelemetryschema.IntrinsicFields, audittelemetryschema.ResourceFields} {
for _, field := range fields {
if !staticFieldMatchesAny(field, fieldKeySelectors) {
continue
}
if _, added := mapOfKeys[field.Name+";"+field.FieldContext.StringValue()+";"+field.FieldDataType.StringValue()]; added {
continue
}
keys = append(keys, &field)
// Add the matching intrinsic audit fields (same as logs intrinsics: body, severity_text, etc.)
for _, field := range maps.Values(audittelemetryschema.IntrinsicFields) {
if !staticFieldMatchesAny(field, fieldKeySelectors) {
continue
}
if _, added := mapOfKeys[field.Name+";"+field.FieldContext.StringValue()+";"+field.FieldDataType.StringValue()]; added {
continue
}
keys = append(keys, &field)
}
return keys
return keys, complete, nil
}
func getPriorityForContext(ctx telemetrytypes.FieldContext) int {
@@ -1218,7 +1209,7 @@ func (t *telemetryMetaStore) GetKeys(ctx context.Context, orgID valuer.UUID, fie
keys, complete, err = t.getTracesKeys(ctx, selectors)
case telemetrytypes.SignalLogs:
if fieldKeySelector.Source == telemetrytypes.SourceAudit {
keys = appendStaticAuditKeys(nil, map[string]*telemetrytypes.TelemetryFieldKey{}, selectors)
keys, complete, err = t.getAuditKeys(ctx, selectors)
} else {
keys, complete, err = t.getLogsKeys(ctx, orgID, selectors)
}
@@ -1302,7 +1293,10 @@ func (t *telemetryMetaStore) GetKeysMulti(ctx context.Context, orgID valuer.UUID
if err != nil {
return nil, false, err
}
auditKeys := appendStaticAuditKeys(nil, map[string]*telemetrytypes.TelemetryFieldKey{}, auditSelectors)
auditKeys, auditComplete, err := t.getAuditKeys(ctx, auditSelectors)
if err != nil {
return nil, false, err
}
tracesKeys, tracesComplete, err := t.getTracesKeys(ctx, tracesSelectors)
if err != nil {
return nil, false, err
@@ -1317,7 +1311,7 @@ func (t *telemetryMetaStore) GetKeysMulti(ctx context.Context, orgID valuer.UUID
return nil, false, err
}
// Complete only if all queries are complete
complete := logsComplete && tracesComplete && metricsComplete
complete := logsComplete && auditComplete && tracesComplete && metricsComplete
mapOfKeys := make(map[string][]*telemetrytypes.TelemetryFieldKey)
for _, key := range logsKeys {
@@ -1699,7 +1693,6 @@ func (t *telemetryMetaStore) getTagTableValues(ctx context.Context, table string
return values, complete, nil
}
//nolint:unused // not called until audit values are recorded in signoz_metadata
func (t *telemetryMetaStore) getAuditFieldValues(ctx context.Context, fieldValueSelector *telemetrytypes.FieldValueSelector) (*telemetrytypes.TelemetryFieldValues, bool, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
@@ -2170,7 +2163,7 @@ func (t *telemetryMetaStore) GetAllValues(ctx context.Context, orgID valuer.UUID
values, complete, err = t.getSpanFieldValues(ctx, orgID, fieldValueSelector)
case telemetrytypes.SignalLogs:
if fieldValueSelector.Source == telemetrytypes.SourceAudit {
values = &telemetrytypes.TelemetryFieldValues{}
values, complete, err = t.getAuditFieldValues(ctx, fieldValueSelector)
} else {
values, complete, err = t.getLogFieldValues(ctx, orgID, fieldValueSelector)
}

View File

@@ -2,11 +2,8 @@ package audittelemetryschema
import (
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz-semantic-conventions/gen/go/sigsemconv"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"go.opentelemetry.io/otel/attribute"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
const (
@@ -25,11 +22,15 @@ const (
TraceFlagsColumn = "trace_flags"
SeverityTextColumn = "severity_text"
SeverityNumberColumn = "severity_number"
ScopeNameColumn = "scope_name"
ScopeVersionColumn = "scope_version"
// Contextual Columns.
AttributesColumn = "attributes"
ResourceColumn = "resource"
ScopeColumn = "scope"
AttributesStringColumn = "attributes_string"
AttributesNumberColumn = "attributes_number"
AttributesBoolColumn = "attributes_bool"
ResourceColumn = "resource"
ScopeStringColumn = "scope_string"
)
var (
@@ -85,15 +86,6 @@ var (
},
}
// ResourceFields are the resource keys the audit writer emits, so a bare name resolves to resource.
ResourceFields = newResourceFields(
semconv.ServiceNameKey,
semconv.ServiceVersionKey,
sigsemconv.SigNozResourceKindKey,
sigsemconv.SigNozResourceIDKey,
sigsemconv.SigNozResourceObjectKey,
)
DefaultSortingOrder = []qbtypes.OrderBy{
{
Key: qbtypes.OrderByKey{
@@ -125,11 +117,15 @@ var auditLogColumns = map[string]*schema.Column{
"trace_flags": {Name: "trace_flags", Type: schema.ColumnTypeUInt32},
"severity_text": {Name: "severity_text", Type: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}},
"severity_number": {Name: "severity_number", Type: schema.ColumnTypeUInt8},
"body": {Name: "body", Type: schema.JSONColumnType{}},
"scope": {Name: "scope", Type: schema.JSONColumnType{}},
"attributes": {Name: "attributes", Type: schema.JSONColumnType{}},
"body": {Name: "body", Type: schema.ColumnTypeString},
"attributes_string": {Name: "attributes_string", Type: schema.MapColumnType{KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}, ValueType: schema.ColumnTypeString}},
"attributes_number": {Name: "attributes_number", Type: schema.MapColumnType{KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}, ValueType: schema.ColumnTypeFloat64}},
"attributes_bool": {Name: "attributes_bool", Type: schema.MapColumnType{KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}, ValueType: schema.ColumnTypeBool}},
"resource": {Name: "resource", Type: schema.JSONColumnType{}},
"event_name": {Name: "event_name", Type: schema.ColumnTypeString},
"scope_name": {Name: "scope_name", Type: schema.ColumnTypeString},
"scope_version": {Name: "scope_version", Type: schema.ColumnTypeString},
"scope_string": {Name: "scope_string", Type: schema.MapColumnType{KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}, ValueType: schema.ColumnTypeString}},
}
const (
@@ -142,16 +138,3 @@ const (
LogResourceKeysTblName = "distributed_logs_resource_keys"
LogsResourceTableName = "distributed_logs_resource"
)
func newResourceFields(keys ...attribute.Key) map[string]telemetrytypes.TelemetryFieldKey {
fields := make(map[string]telemetrytypes.TelemetryFieldKey, len(keys))
for _, key := range keys {
fields[string(key)] = telemetrytypes.TelemetryFieldKey{
Name: string(key),
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}
}
return fields
}

View File

@@ -24,11 +24,24 @@ func NewStorage() qbtypes.Storage {
func (m *storage) getColumn(_ context.Context, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
switch key.FieldContext {
case telemetrytypes.FieldContextResource:
return []*schema.Column{auditLogColumns[ResourceColumn]}, nil
return []*schema.Column{auditLogColumns["resource"]}, nil
case telemetrytypes.FieldContextScope:
return []*schema.Column{auditLogColumns[ScopeColumn]}, nil
switch key.Name {
case "name", "scope.name", "scope_name":
return []*schema.Column{auditLogColumns["scope_name"]}, nil
case "version", "scope.version", "scope_version":
return []*schema.Column{auditLogColumns["scope_version"]}, nil
}
return []*schema.Column{auditLogColumns["scope_string"]}, nil
case telemetrytypes.FieldContextAttribute:
return []*schema.Column{auditLogColumns[AttributesColumn]}, nil
switch key.FieldDataType {
case telemetrytypes.FieldDataTypeString:
return []*schema.Column{auditLogColumns["attributes_string"]}, nil
case telemetrytypes.FieldDataTypeInt64, telemetrytypes.FieldDataTypeFloat64, telemetrytypes.FieldDataTypeNumber:
return []*schema.Column{auditLogColumns["attributes_number"]}, nil
case telemetrytypes.FieldDataTypeBool:
return []*schema.Column{auditLogColumns["attributes_bool"]}, nil
}
case telemetrytypes.FieldContextLog, telemetrytypes.FieldContextUnspecified:
col, ok := auditLogColumns[key.Name]
if !ok {
@@ -40,6 +53,46 @@ func (m *storage) getColumn(_ context.Context, key *telemetrytypes.TelemetryFiel
return nil, qbtypes.ErrColumnNotFound
}
func (m *storage) read(ctx context.Context, _ qbtypes.QueryInfo, key *telemetrytypes.TelemetryFieldKey) (string, error) {
columns, err := m.getColumn(ctx, key)
if err != nil {
return "", err
}
if len(columns) != 1 {
return "", errors.NewInternalf(errors.CodeInternal, "expected exactly 1 column, got %d", len(columns))
}
column := columns[0]
switch column.Type.GetType() {
case schema.ColumnTypeEnumJSON:
if key.FieldContext != telemetrytypes.FieldContextResource {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "only resource context fields are supported for json columns in audit, got %s", key.FieldContext.String)
}
return fmt.Sprintf("%s.%s::String", column.Name, clickhousesql.Identifier(key.Name)), nil
case schema.ColumnTypeEnumLowCardinality:
return column.Name, nil
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumUInt64, schema.ColumnTypeEnumUInt32, schema.ColumnTypeEnumUInt8:
return column.Name, nil
case schema.ColumnTypeEnumMap:
keyType := column.Type.(schema.MapColumnType).KeyType
if _, ok := keyType.(schema.LowCardinalityColumnType); !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "key type %s is not supported for map column type %s", keyType, column.Type)
}
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumBool, schema.ColumnTypeEnumFloat64:
if key.Materialized {
return telemetrytypes.FieldKeyToMaterializedColumnName(key), nil
}
return fmt.Sprintf("%s[%s]", column.Name, clickhousesql.StringLiteral(key.Name)), nil
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported map value type %s", valueType)
}
}
return column.Name, nil
}
// Read composes the bare read of one key with its membership test and what
// an absent row reads.
func (m *storage) Read(ctx context.Context, q qbtypes.QueryInfo, key *telemetrytypes.TelemetryFieldKey) (qbtypes.Read, error) {
@@ -47,43 +100,46 @@ func (m *storage) Read(ctx context.Context, q qbtypes.QueryInfo, key *telemetryt
if err != nil {
return qbtypes.Read{}, err
}
if len(columns) != 1 {
return qbtypes.Read{}, errors.NewInternalf(errors.CodeInternal, "expected exactly 1 column, got %d", len(columns))
}
column := columns[0]
if column.Type.GetType() == schema.ColumnTypeEnumJSON {
return jsonRead(column, key)
}
presence, err := querybuilder.ExistsExpression(columns, key, q.StartNs, q.EndNs, column.Name, true)
sql, err := m.read(ctx, q, key)
if err != nil {
return qbtypes.Read{}, err
}
absence, err := querybuilder.ExistsExpression(columns, key, q.StartNs, q.EndNs, column.Name, false)
presence, err := querybuilder.ExistsExpression(columns, key, q.StartNs, q.EndNs, sql, true)
if err != nil {
return qbtypes.Read{}, err
}
return qbtypes.Read{SQL: column.Name, Presence: presence, Absence: absence, WhenAbsent: qbtypes.AlwaysPresent}, nil
absence, err := querybuilder.ExistsExpression(columns, key, q.StartNs, q.EndNs, sql, false)
if err != nil {
return qbtypes.Read{}, err
}
return qbtypes.Read{SQL: sql, Presence: presence, Absence: absence, WhenAbsent: absentReads(columns[0])}, nil
}
// Fallback answers a column name with the column, and synthesizes any other
// key from the operand, since audit has no keys tables to report it.
func (m *storage) Fallback(_ context.Context, _ qbtypes.QueryInfo, key *telemetrytypes.TelemetryFieldKey, _ qbtypes.FilterOperator, value any) ([]*telemetrytypes.LogicalField, error) {
var keys []*telemetrytypes.TelemetryFieldKey
switch key.FieldContext {
case telemetrytypes.FieldContextUnspecified, telemetrytypes.FieldContextLog:
if column, ok := auditLogColumns[key.Name]; ok {
keys = []*telemetrytypes.TelemetryFieldKey{telemetrytypes.NewTelemetryFieldKey(key.Name, telemetrytypes.FieldContextLog, columnDataType(key, column))}
break
}
if key.FieldContext == telemetrytypes.FieldContextUnspecified {
keys = synthesizeKeys(key, value)
}
case telemetrytypes.FieldContextAttribute, telemetrytypes.FieldContextResource, telemetrytypes.FieldContextScope:
keys = synthesizeKeys(key, value)
// absentReads tells what a row without the key reads from the column. A
// map reads its empty value. A JSON path reads the empty string, because
// its ::String cast folds NULL. Every other column reads a real value.
func absentReads(column *schema.Column) qbtypes.Absent {
switch column.Type.GetType() {
case schema.ColumnTypeEnumMap, schema.ColumnTypeEnumJSON:
return qbtypes.AbsentIsSentinel
}
return querybuilder.WrapAsLogicalFields(key.Name, keys), nil
return qbtypes.AlwaysPresent
}
// Fallback answers a column name with the column. Audit has no attribute
// synthesis, so any other unknown key stays unresolved and the caller
// errors.
func (m *storage) Fallback(_ context.Context, _ qbtypes.QueryInfo, key *telemetrytypes.TelemetryFieldKey, _ qbtypes.FilterOperator, _ any) ([]*telemetrytypes.LogicalField, error) {
schemaColumn, ok := auditLogColumns[key.Name]
if !ok {
return nil, nil
}
dataType := key.FieldDataType
if dataType == telemetrytypes.FieldDataTypeUnspecified {
dataType = querybuilder.ColumnDataType(schemaColumn)
}
column := telemetrytypes.NewTelemetryFieldKey(key.Name, telemetrytypes.FieldContextLog, dataType)
return querybuilder.WrapAsLogicalFields(key.Name, []*telemetrytypes.TelemetryFieldKey{column}), nil
}
func (m *storage) Traits() qbtypes.Traits {
@@ -93,81 +149,3 @@ func (m *storage) Traits() qbtypes.Traits {
func (m *storage) Compile(ctx context.Context, q qbtypes.QueryInfo, logical *telemetrytypes.LogicalField, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (qbtypes.Compiled, error) {
return querybuilder.SharedCondition(ctx, q, m, logical, operator, value, sb)
}
// jsonRead reads a key from one of the JSON columns. The body text and the
// scope name and version are typed paths, so they read without a cast.
// Resource and scope attributes sit under an `attributes` path.
func jsonRead(column *schema.Column, key *telemetrytypes.TelemetryFieldKey) (qbtypes.Read, error) {
switch column.Name {
case BodyColumn:
if key.FieldContext != telemetrytypes.FieldContextLog && key.FieldContext != telemetrytypes.FieldContextUnspecified {
break
}
path := BodyColumn + ".message"
return qbtypes.Read{SQL: path, Presence: path + " <> ''", Absence: path + " = ''", WhenAbsent: qbtypes.AlwaysPresent}, nil
case ScopeColumn:
switch key.Name {
case "name", "scope.name", "scope_name":
return declaredStringRead(ScopeColumn + ".name"), nil
case "version", "scope.version", "scope_version":
return declaredStringRead(ScopeColumn + ".version"), nil
}
return stringPathRead(fmt.Sprintf("%s.attributes.%s", ScopeColumn, clickhousesql.Identifier(key.Name))), nil
case ResourceColumn:
if key.FieldContext != telemetrytypes.FieldContextResource {
break
}
return stringPathRead(fmt.Sprintf("%s.attributes.%s", ResourceColumn, clickhousesql.Identifier(key.Name))), nil
case AttributesColumn:
if key.FieldContext != telemetrytypes.FieldContextAttribute {
break
}
path := fmt.Sprintf("%s.%s", AttributesColumn, clickhousesql.Identifier(key.Name))
switch key.FieldDataType {
case telemetrytypes.FieldDataTypeInt64, telemetrytypes.FieldDataTypeFloat64, telemetrytypes.FieldDataTypeNumber:
return typedPathRead(fmt.Sprintf("toFloat64OrNull(%s::String)", path)), nil
case telemetrytypes.FieldDataTypeBool:
return typedPathRead(fmt.Sprintf("if(dynamicType(%s) = 'Bool', accurateCastOrNull(%s, 'Bool'), NULL)", path, path)), nil
}
return stringPathRead(path), nil
}
return qbtypes.Read{}, errors.NewInvalidInputf(errors.CodeInvalidInput, "field %s in context %s cannot be read from the json column %s", key.Name, key.FieldContext.StringValue(), column.Name)
}
// declaredStringRead reads a typed String path, which reads an empty string when absent.
func declaredStringRead(path string) qbtypes.Read {
return qbtypes.Read{SQL: path, Presence: path + " <> ''", Absence: path + " = ''", WhenAbsent: qbtypes.AbsentIsSentinel}
}
// stringPathRead reads a dynamic path as String. The cast folds NULL to an
// empty string, so presence tests the raw path.
func stringPathRead(path string) qbtypes.Read {
return qbtypes.Read{SQL: path + "::String", Presence: path + " IS NOT NULL", Absence: path + " IS NULL", WhenAbsent: qbtypes.AbsentIsSentinel}
}
// typedPathRead reads a number or bool path through a cast that is NULL when
// the key is absent or stored as another type.
// TODO: share with tracestelemetryschema.AttributeJSONValueExpr from a common package.
func typedPathRead(expr string) qbtypes.Read {
return qbtypes.Read{SQL: expr, Presence: expr + " IS NOT NULL", Absence: expr + " IS NULL", WhenAbsent: qbtypes.AbsentIsNull}
}
// synthesizeKeys reads an untyped key with no operand as String only. A JSON
// path casts every stored type to String, so the other types add nothing.
func synthesizeKeys(key *telemetrytypes.TelemetryFieldKey, value any) []*telemetrytypes.TelemetryFieldKey {
if value == nil && key.FieldDataType == telemetrytypes.FieldDataTypeUnspecified {
key = telemetrytypes.NewTelemetryFieldKey(key.Name, key.FieldContext, telemetrytypes.FieldDataTypeString)
}
return querybuilder.SynthesizeKeys(key, value)
}
func columnDataType(key *telemetrytypes.TelemetryFieldKey, column *schema.Column) telemetrytypes.FieldDataType {
if key.FieldDataType != telemetrytypes.FieldDataTypeUnspecified {
return key.FieldDataType
}
if field, ok := IntrinsicFields[key.Name]; ok {
return field.FieldDataType
}
return querybuilder.ColumnDataType(column)
}

View File

@@ -1,6 +1,7 @@
package alertmanagertypes
import (
"encoding/json"
"fmt"
"slices"
"strings"
@@ -11,6 +12,7 @@ import (
"github.com/prometheus/alertmanager/config"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/swaggest/jsonschema-go"
)
const defaultJiraReopenDuration = model.Duration(3 * 24 * time.Hour)
@@ -80,60 +82,101 @@ type ChannelJiraConfig struct {
WontFixResolution string `json:"wontFixResolution" description:"The resolution name that stops an issue from being reopened."`
CustomFields map[string]any `json:"customFields,omitzero" description:"Extra issue fields keyed by Jira field ID. Stored, but not currently included in the issue Jira receives."`
Auth *ChannelJiraAuth `json:"auth" required:"true" description:"The credentials: email and apiToken when deployment is cloud, pat when deployment is selfhosted."`
Auth ChannelJiraAuth `json:"auth" required:"true" description:"The credentials: email and apiToken when deployment is cloud, pat when deployment is selfhosted."`
}
type JiraAuth interface {
deployment() JiraDeployment
validate() error
fillHTTPConfig(httpConfig *commoncfg.HTTPClientConfig)
}
// ChannelJiraAuth wraps the JiraAuth value because the OpenAPI reflector
// renders an interface-typed field as an empty component.
type ChannelJiraAuth struct {
Email string `json:"email,omitempty" description:"The Atlassian account email used for basic auth. Required when deployment is cloud."`
APIToken string `json:"apiToken,omitempty" format:"password" description:"The Atlassian API token paired with email. Required when deployment is cloud. A secret; returned by create, update and fetch by ID, never by list."`
PAT string `json:"pat,omitempty" format:"password" description:"The Jira personal access token, sent as a Bearer header. Required when deployment is selfhosted. A secret; returned by create, update and fetch by ID, never by list."`
JiraAuth
}
func (a ChannelJiraAuth) validate(deployment JiraDeployment) error {
if deployment == JiraDeploymentSelfHosted {
if a.PAT == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.auth.pat is required for a self-hosted jira channel")
}
if a.Email != "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.auth.email is not supported for a self-hosted jira channel")
}
if a.APIToken != "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.auth.apiToken is not supported for a self-hosted jira channel")
}
return nil
}
var (
_ jsonschema.OneOfExposer = ChannelJiraAuth{}
_ jsonschema.Preparer = ChannelJiraAuth{}
_ json.Marshaler = ChannelJiraAuth{}
)
func (ChannelJiraAuth) JSONSchemaOneOf() []any {
return []any{ChannelJiraCloudAuth{}, ChannelJiraSelfHostedAuth{}}
}
func (ChannelJiraAuth) PrepareJSONSchema(schema *jsonschema.Schema) error {
schema.Properties = nil
schema.Required = nil
return nil
}
func (a ChannelJiraAuth) MarshalJSON() ([]byte, error) {
if a.JiraAuth == nil {
return []byte("null"), nil
}
return json.Marshal(a.JiraAuth)
}
type ChannelJiraCloudAuth struct {
Email string `json:"email" required:"true" description:"The Atlassian account email used for basic auth."`
APIToken string `json:"apiToken" required:"true" format:"password" description:"The Atlassian API token paired with email. A secret; returned by create, update and fetch by ID, never by list."`
}
func (ChannelJiraCloudAuth) deployment() JiraDeployment {
return JiraDeploymentCloud
}
func (a ChannelJiraCloudAuth) validate() error {
if a.Email == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.auth.email is required for a jira cloud channel")
}
if a.APIToken == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.auth.apiToken is required for a jira cloud channel")
}
if a.PAT != "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.auth.pat is not supported for a jira cloud channel")
}
return nil
}
func (a ChannelJiraAuth) fillHTTPConfig(deployment JiraDeployment, httpConfig *commoncfg.HTTPClientConfig) {
if deployment == JiraDeploymentSelfHosted {
httpConfig.Authorization = &commoncfg.Authorization{
Type: jiraBearerAuthorizationType,
Credentials: commoncfg.Secret(a.PAT),
}
return
}
func (a ChannelJiraCloudAuth) fillHTTPConfig(httpConfig *commoncfg.HTTPClientConfig) {
httpConfig.BasicAuth = &commoncfg.BasicAuth{
Username: a.Email,
Password: commoncfg.Secret(a.APIToken),
}
}
// UnmarshalJSON seeds send_resolved off, as JiraReceiverConfig does.
type ChannelJiraSelfHostedAuth struct {
PAT string `json:"pat" required:"true" format:"password" description:"The Jira personal access token, sent as a Bearer header. A secret; returned by create, update and fetch by ID, never by list."`
}
func (ChannelJiraSelfHostedAuth) deployment() JiraDeployment {
return JiraDeploymentSelfHosted
}
func (a ChannelJiraSelfHostedAuth) validate() error {
if a.PAT == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.auth.pat is required for a self-hosted jira channel")
}
return nil
}
func (a ChannelJiraSelfHostedAuth) fillHTTPConfig(httpConfig *commoncfg.HTTPClientConfig) {
httpConfig.Authorization = &commoncfg.Authorization{
Type: jiraBearerAuthorizationType,
Credentials: commoncfg.Secret(a.PAT),
}
}
// UnmarshalJSON seeds send_resolved off, as JiraReceiverConfig does. The auth
// bytes are held back until deployment is known, which picks their type.
func (c *ChannelJiraConfig) UnmarshalJSON(data []byte) error {
type alias ChannelJiraConfig
if err := decodeStrict(data, (*alias)(c)); err != nil {
envelope := struct {
*alias
Auth json.RawMessage `json:"auth"`
}{alias: (*alias)(c)}
if err := decodeStrict(data, &envelope); err != nil {
return err
}
@@ -142,6 +185,15 @@ func (c *ChannelJiraConfig) UnmarshalJSON(data []byte) error {
c.Description.SetIfUnset(DefaultJiraDescriptionTemplate)
c.ReopenDuration.SetIfUnset(defaultJiraReopenDuration.String())
if err := c.validateDeployment(); err != nil {
return err
}
auth, err := decodeJiraAuth(c.Deployment, envelope.Auth)
if err != nil {
return err
}
c.Auth = ChannelJiraAuth{JiraAuth: auth}
return c.Validate()
}
@@ -163,10 +215,13 @@ func (c ChannelJiraConfig) Validate() error {
}
}
if c.Auth == nil {
if c.Auth.JiraAuth == nil {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.auth is required for a jira channel")
}
if err := c.Auth.validate(c.Deployment); err != nil {
if c.Auth.deployment() != c.Deployment {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.auth does not match deployment %q", c.Deployment.StringValue())
}
if err := c.Auth.validate(); err != nil {
return err
}
@@ -202,10 +257,10 @@ func (c ChannelJiraConfig) toUndefaultedReceiver(displayName string) (*Receiver,
// and EnableHTTP2 marshal unconditionally, so a zero value would persist them
// as false and read back as a config ChannelJiraConfig cannot represent.
httpConfig := commoncfg.DefaultHTTPClientConfig
if c.Auth == nil {
if c.Auth.JiraAuth == nil {
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.auth is required for a jira channel")
}
c.Auth.fillHTTPConfig(c.Deployment, &httpConfig)
c.Auth.fillHTTPConfig(&httpConfig)
jira := &JiraReceiverConfig{
// JiraReceiverConfig seeds no send_resolved of its own, so unset means off.
@@ -281,7 +336,7 @@ func newChannelJiraConfigFromReceiver(name string, receiver *Receiver) (ChannelS
if !strings.EqualFold(authorization.Type, jiraBearerAuthorizationType) || authorization.CredentialsFile != "" || authorization.CredentialsRef != "" {
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q must set http_config.authorization with type Bearer and inline credentials", name)
}
spec.Auth = &ChannelJiraAuth{PAT: string(authorization.Credentials)}
spec.Auth = ChannelJiraAuth{JiraAuth: ChannelJiraSelfHostedAuth{PAT: string(authorization.Credentials)}}
}
return spec, nil
}
@@ -290,7 +345,7 @@ func newChannelJiraConfigFromReceiver(name string, receiver *Receiver) (ChannelS
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization, which is not supported", name)
}
if jira.HTTPConfig.BasicAuth != nil {
spec.Auth = &ChannelJiraAuth{Email: jira.HTTPConfig.BasicAuth.Username, APIToken: string(jira.HTTPConfig.BasicAuth.Password)}
spec.Auth = ChannelJiraAuth{JiraAuth: ChannelJiraCloudAuth{Email: jira.HTTPConfig.BasicAuth.Username, APIToken: string(jira.HTTPConfig.BasicAuth.Password)}}
}
return spec, nil
@@ -408,3 +463,26 @@ func (c *JiraReceiverConfig) SearchPath() string {
}
return "/search/jql"
}
func decodeJiraAuth(deployment JiraDeployment, data json.RawMessage) (JiraAuth, error) {
if len(data) == 0 || string(data) == "null" {
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.auth is required for a jira channel")
}
var auth JiraAuth
var err error
switch deployment {
case JiraDeploymentSelfHosted:
var selfHosted ChannelJiraSelfHostedAuth
err = decodeStrict(data, &selfHosted)
auth = selfHosted
default:
var cloud ChannelJiraCloudAuth
err = decodeStrict(data, &cloud)
auth = cloud
}
if err != nil {
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.auth for a %s jira channel: %s", deployment.StringValue(), err.Error())
}
return auth, nil
}

View File

@@ -10,7 +10,6 @@ import (
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/swaggest/jsonschema-go"
)
func jiraReceiverJSON(project, issueType string, withAuth bool) string {
@@ -178,13 +177,9 @@ func TestChannelJiraConfigUnmarshal(t *testing.T) {
{"RootCredentials_Rejected", `{"deployment":"cloud","site":"https://acme.atlassian.net","project":"OPS","issueType":"Task","email":"e","apiToken":"t"}`, `unknown field "email"`, JiraDeploymentCloud},
{"Cloud_MissingEmail", `{"deployment":"cloud","site":"https://acme.atlassian.net","project":"OPS","issueType":"Task","auth":{"apiToken":"t"}}`, "config.spec.auth.email is required for a jira cloud channel", JiraDeploymentCloud},
{"Cloud_MissingAPIToken", `{"deployment":"cloud","site":"https://acme.atlassian.net","project":"OPS","issueType":"Task","auth":{"email":"e"}}`, "config.spec.auth.apiToken is required for a jira cloud channel", JiraDeploymentCloud},
{"Cloud_WithPAT_Rejected", `{"deployment":"cloud","site":"https://acme.atlassian.net","project":"OPS","issueType":"Task","auth":{"email":"e","apiToken":"t","pat":"p"}}`, "config.spec.auth.pat is not supported for a jira cloud channel", JiraDeploymentCloud},
{"Cloud_WithBlankPAT", `{"deployment":"cloud","site":"https://acme.atlassian.net","project":"OPS","issueType":"Task","auth":{"email":"e","apiToken":"t","pat":""}}`, "", JiraDeploymentCloud},
{"Cloud_WithPAT_Rejected", `{"deployment":"cloud","site":"https://acme.atlassian.net","project":"OPS","issueType":"Task","auth":{"email":"e","apiToken":"t","pat":"p"}}`, `unknown field "pat"`, JiraDeploymentCloud},
{"SelfHosted_MissingPAT", `{"deployment":"selfhosted","site":"https://jira.acme.com","project":"OPS","issueType":"Task","auth":{}}`, "config.spec.auth.pat is required for a self-hosted jira channel", JiraDeploymentSelfHosted},
{"SelfHosted_WithEmail_Rejected", `{"deployment":"selfhosted","site":"https://jira.acme.com","project":"OPS","issueType":"Task","auth":{"pat":"p","email":"e"}}`, "config.spec.auth.email is not supported for a self-hosted jira channel", JiraDeploymentSelfHosted},
{"SelfHosted_WithAPIToken_Rejected", `{"deployment":"selfhosted","site":"https://jira.acme.com","project":"OPS","issueType":"Task","auth":{"pat":"p","apiToken":"t"}}`, "config.spec.auth.apiToken is not supported for a self-hosted jira channel", JiraDeploymentSelfHosted},
{"SelfHosted_WithBlankEmail", `{"deployment":"selfhosted","site":"https://jira.acme.com","project":"OPS","issueType":"Task","auth":{"pat":"p","email":""}}`, "", JiraDeploymentSelfHosted},
{"NullAuth_Rejected", `{"deployment":"cloud","site":"https://acme.atlassian.net","project":"OPS","issueType":"Task","auth":null}`, "config.spec.auth is required for a jira channel", JiraDeploymentCloud},
{"SelfHosted_WithEmail_Rejected", `{"deployment":"selfhosted","site":"https://jira.acme.com","project":"OPS","issueType":"Task","auth":{"pat":"p","email":"e"}}`, `unknown field "email"`, JiraDeploymentSelfHosted},
{"UnknownDeployment", `{"deployment":"onprem","site":"https://jira.acme.com","project":"OPS","issueType":"Task","auth":{"pat":"p"}}`, `config.spec.deployment "onprem" is not supported`, JiraDeployment{}},
{"EmptyDeployment_Rejected", `{"deployment":"","site":"https://jira.acme.com","project":"OPS","issueType":"Task","auth":{"pat":"p"}}`, "string must not be empty", JiraDeployment{}},
}
@@ -213,13 +208,13 @@ func TestChannelJiraConfigReceiverAuth(t *testing.T) {
}{
{
name: "Cloud_BasicAuth",
spec: ChannelJiraConfig{Deployment: JiraDeploymentCloud, Site: "https://acme.atlassian.net", Project: "OPS", IssueType: "Task", Auth: &ChannelJiraAuth{Email: "e@acme.com", APIToken: "t"}},
spec: ChannelJiraConfig{Deployment: JiraDeploymentCloud, Site: "https://acme.atlassian.net", Project: "OPS", IssueType: "Task", Auth: ChannelJiraAuth{JiraAuth: ChannelJiraCloudAuth{Email: "e@acme.com", APIToken: "t"}}},
expectBasic: &commoncfg.BasicAuth{Username: "e@acme.com", Password: "t"},
expectJSONKey: `"basic_auth"`,
},
{
name: "SelfHosted_BearerAuthorization",
spec: ChannelJiraConfig{Deployment: JiraDeploymentSelfHosted, Site: "https://jira.acme.com", Project: "OPS", IssueType: "Task", Auth: &ChannelJiraAuth{PAT: "p"}},
spec: ChannelJiraConfig{Deployment: JiraDeploymentSelfHosted, Site: "https://jira.acme.com", Project: "OPS", IssueType: "Task", Auth: ChannelJiraAuth{JiraAuth: ChannelJiraSelfHostedAuth{PAT: "p"}}},
expectBearer: "p",
expectJSONKey: `"deployment":"selfhosted"`,
},
@@ -248,45 +243,3 @@ func TestChannelJiraConfigReceiverAuth(t *testing.T) {
})
}
}
func TestChannelJiraAuthJSONSchema(t *testing.T) {
reflector := jsonschema.Reflector{}
schema, err := reflector.Reflect(ChannelJiraAuth{})
require.NoError(t, err)
assert.Empty(t, schema.OneOf)
assert.Empty(t, schema.Required)
require.Len(t, schema.Properties, 3)
testCases := []struct {
name string
property string
format string
}{
{name: "Email_Optional_PlainString", property: "email"},
{name: "APIToken_Optional_Password", property: "apiToken", format: "password"},
{name: "PAT_Optional_Password", property: "pat", format: "password"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
property, ok := schema.Properties[testCase.property]
require.True(t, ok)
require.NotNil(t, property.TypeObject)
raw, err := json.Marshal(property.TypeObject)
require.NoError(t, err)
var got struct {
Type string `json:"type"`
Format string `json:"format"`
Description string `json:"description"`
}
require.NoError(t, json.Unmarshal(raw, &got))
assert.Equal(t, "string", got.Type)
assert.Equal(t, testCase.format, got.Format)
assert.NotEmpty(t, got.Description)
})
}
}

View File

@@ -75,7 +75,7 @@ func TestPostableChannelValidate(t *testing.T) {
DisplayName: "jira",
Config: ChannelConfig{Kind: ChannelKindJira, Spec: &ChannelJiraConfig{
Deployment: JiraDeploymentCloud, Site: "https://acme.atlassian.net", Project: "OPS", IssueType: "Bug",
Auth: &ChannelJiraAuth{Email: "oncall@acme.com", APIToken: "api-token"},
Auth: ChannelJiraAuth{JiraAuth: ChannelJiraCloudAuth{Email: "oncall@acme.com", APIToken: "api-token"}},
Summary: valuer.MustNewUnsetOrNonEmptyString("jira summary"), Description: valuer.MustNewUnsetOrNonEmptyString("jira description"), ReopenDuration: valuer.MustNewUnsetOrNonEmptyString("three days"),
}},
},

View File

@@ -198,7 +198,7 @@ func TestDeriveChannelConfigRoundTripsEveryFieldOfEveryKind(t *testing.T) {
ReopenDuration: valuer.MustNewUnsetOrNonEmptyString("3d"),
WontFixResolution: "Won't Do",
CustomFields: map[string]any{"customfield_10010": "Ops"},
Auth: &ChannelJiraAuth{Email: "oncall@acme.com", APIToken: "api-token"},
Auth: ChannelJiraAuth{JiraAuth: ChannelJiraCloudAuth{Email: "oncall@acme.com", APIToken: "api-token"}},
},
expectedRoundTrip: &ChannelJiraConfig{
SendResolved: &sendResolved,
@@ -215,7 +215,7 @@ func TestDeriveChannelConfigRoundTripsEveryFieldOfEveryKind(t *testing.T) {
ReopenDuration: valuer.MustNewUnsetOrNonEmptyString("3d"),
WontFixResolution: "Won't Do",
CustomFields: map[string]any{"customfield_10010": "Ops"},
Auth: &ChannelJiraAuth{Email: "oncall@acme.com", APIToken: "api-token"},
Auth: ChannelJiraAuth{JiraAuth: ChannelJiraCloudAuth{Email: "oncall@acme.com", APIToken: "api-token"}},
},
},
{
@@ -230,7 +230,7 @@ func TestDeriveChannelConfigRoundTripsEveryFieldOfEveryKind(t *testing.T) {
Summary: valuer.MustNewUnsetOrNonEmptyString("jira summary"),
Description: valuer.MustNewUnsetOrNonEmptyString("jira description"),
ReopenDuration: valuer.MustNewUnsetOrNonEmptyString("3d"),
Auth: &ChannelJiraAuth{PAT: "personal-access-token"},
Auth: ChannelJiraAuth{JiraAuth: ChannelJiraSelfHostedAuth{PAT: "personal-access-token"}},
},
expectedRoundTrip: &ChannelJiraConfig{
SendResolved: &sendResolved,
@@ -241,7 +241,7 @@ func TestDeriveChannelConfigRoundTripsEveryFieldOfEveryKind(t *testing.T) {
Summary: valuer.MustNewUnsetOrNonEmptyString("jira summary"),
Description: valuer.MustNewUnsetOrNonEmptyString("jira description"),
ReopenDuration: valuer.MustNewUnsetOrNonEmptyString("3d"),
Auth: &ChannelJiraAuth{PAT: "personal-access-token"},
Auth: ChannelJiraAuth{JiraAuth: ChannelJiraSelfHostedAuth{PAT: "personal-access-token"}},
},
},
{

View File

@@ -46,7 +46,6 @@ type PrincipalAttributes struct {
PrincipalID valuer.UUID
PrincipalEmail valuer.Email
PrincipalOrgID valuer.UUID
IdentNProvider authtypes.IdentNProvider
}
func NewPrincipalAttributesFromClaims(claims authtypes.Claims) PrincipalAttributes {
@@ -62,7 +61,6 @@ func NewPrincipalAttributesFromClaims(claims authtypes.Claims) PrincipalAttribut
PrincipalID: principalID,
PrincipalEmail: principalEmail,
PrincipalOrgID: principalOrgID,
IdentNProvider: claims.IdentNProvider,
}
}
@@ -73,7 +71,6 @@ func (attributes PrincipalAttributes) Put(dest pcommon.Map) {
}
putStrIfNotEmpty(dest, string(sigsemconv.SigNozPrincipalEmailKey), attributes.PrincipalEmail.String())
dest.PutStr(string(sigsemconv.SigNozOrganizationIDKey), attributes.PrincipalOrgID.StringValue())
putStrIfNotEmpty(dest, string(sigsemconv.SigNozPrincipalIdentnProviderKey), attributes.IdentNProvider.StringValue())
}
// Audit attributes — Resource (On What).
@@ -181,7 +178,7 @@ func NewTransportAttributesFromHTTP(req *http.Request, route string, statusCode
scheme = "https"
}
clientAddress, clientPort := clientAddressFromHTTP(req)
clientAddress, clientPort := splitHostPort(req.RemoteAddr)
return TransportAttributes{
NetworkProtocolName: "http",
@@ -223,16 +220,6 @@ func networkProtocolVersion(req *http.Request) string {
return strconv.Itoa(req.ProtoMajor) + "." + strconv.Itoa(req.ProtoMinor)
}
// clientAddressFromHTTP prefers the first X-Forwarded-For entry, which carries no port, over the peer address.
func clientAddressFromHTTP(req *http.Request) (string, int) {
forwardedFor, _, _ := strings.Cut(req.Header.Get("X-Forwarded-For"), ",")
if forwardedFor = strings.TrimSpace(forwardedFor); forwardedFor != "" {
return forwardedFor, 0
}
return splitHostPort(req.RemoteAddr)
}
// splitHostPort returns addr unchanged with port 0 when it has no valid port.
func splitHostPort(addr string) (string, int) {
host, portStr, err := net.SplitHostPort(addr)

View File

@@ -1,8 +1,6 @@
package audittypes
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/SigNoz/signoz/pkg/types/coretypes"
@@ -237,48 +235,3 @@ func TestNewBody(t *testing.T) {
})
}
}
func TestNewTransportAttributesFromHTTP_ClientAddress(t *testing.T) {
testCases := []struct {
name string
forwardedFor string
expectedAddress string
expectedPort int
}{
{
name: "NoHeader_RemoteAddr",
expectedAddress: "10.0.0.1",
expectedPort: 52341,
},
{
name: "SingleEntry",
forwardedFor: "203.0.113.7",
expectedAddress: "203.0.113.7",
},
{
name: "MultipleEntries_FirstWins",
forwardedFor: " 203.0.113.7 , 10.0.0.5",
expectedAddress: "203.0.113.7",
},
{
name: "BlankFirstEntry_RemoteAddr",
forwardedFor: " , 10.0.0.5",
expectedAddress: "10.0.0.1",
expectedPort: 52341,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/resource", nil)
req.RemoteAddr = "10.0.0.1:52341"
if testCase.forwardedFor != "" {
req.Header.Set("X-Forwarded-For", testCase.forwardedFor)
}
attrs := NewTransportAttributesFromHTTP(req, "/api/v1/resource", http.StatusOK)
assert.Equal(t, testCase.expectedAddress, attrs.ClientAddress)
assert.Equal(t, testCase.expectedPort, attrs.ClientPort)
})
}
}

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