mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-13 03:41:20 +00:00
Compare commits
12 Commits
ns/githook
...
fix/safe-n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98a0d906ed | ||
|
|
20e6d44289 | ||
|
|
9fef8eea69 | ||
|
|
d00471c6b9 | ||
|
|
b91f7e9835 | ||
|
|
0b48326b10 | ||
|
|
95ee58eba5 | ||
|
|
9100d9aea6 | ||
|
|
3acfb0c8c0 | ||
|
|
75994b0d6f | ||
|
|
3bfb8708e9 | ||
|
|
876d84ac53 |
@@ -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. A secret; returned
|
||||
by create, update and fetch by ID, never by list.
|
||||
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.
|
||||
format: password
|
||||
type: string
|
||||
email:
|
||||
description: The Atlassian account email used for basic auth.
|
||||
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
|
||||
type: string
|
||||
required:
|
||||
- email
|
||||
- apiToken
|
||||
type: object
|
||||
AlertmanagertypesChannelJiraConfig:
|
||||
properties:
|
||||
@@ -642,16 +642,6 @@ 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
|
||||
@@ -11773,15 +11763,6 @@ 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
|
||||
@@ -11900,11 +11881,6 @@ 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'
|
||||
@@ -11915,10 +11891,6 @@ components:
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
version:
|
||||
deprecated: true
|
||||
description: The query payload version. Nothing reads it.
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- state
|
||||
@@ -25559,7 +25531,6 @@ 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
|
||||
@@ -25626,7 +25597,6 @@ 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
|
||||
@@ -25699,7 +25669,6 @@ 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
|
||||
@@ -25755,7 +25724,6 @@ 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.
|
||||
@@ -25847,7 +25815,6 @@ 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`.
|
||||
@@ -25916,7 +25883,6 @@ 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`
|
||||
@@ -26015,7 +25981,6 @@ 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
|
||||
@@ -26115,7 +26080,6 @@ 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
|
||||
@@ -26205,7 +26169,6 @@ 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
|
||||
@@ -26274,7 +26237,6 @@ paths:
|
||||
interval: 30m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRule'
|
||||
responses:
|
||||
@@ -26525,7 +26487,6 @@ 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
|
||||
@@ -26592,7 +26553,6 @@ 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
|
||||
@@ -26665,7 +26625,6 @@ 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
|
||||
@@ -26721,7 +26680,6 @@ 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.
|
||||
@@ -26813,7 +26771,6 @@ 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`.
|
||||
@@ -26882,7 +26839,6 @@ 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`
|
||||
@@ -26981,7 +26937,6 @@ 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
|
||||
@@ -27081,7 +27036,6 @@ 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
|
||||
@@ -27171,7 +27125,6 @@ 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
|
||||
@@ -27240,7 +27193,6 @@ paths:
|
||||
interval: 30m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRule'
|
||||
responses:
|
||||
@@ -27392,7 +27344,6 @@ 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
|
||||
@@ -27459,7 +27410,6 @@ 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
|
||||
@@ -27532,7 +27482,6 @@ 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
|
||||
@@ -27588,7 +27537,6 @@ 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.
|
||||
@@ -27680,7 +27628,6 @@ 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`.
|
||||
@@ -27749,7 +27696,6 @@ 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`
|
||||
@@ -27848,7 +27794,6 @@ 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
|
||||
@@ -27948,7 +27893,6 @@ 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
|
||||
@@ -28038,7 +27982,6 @@ 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
|
||||
@@ -28107,7 +28050,6 @@ paths:
|
||||
interval: 30m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRule'
|
||||
responses:
|
||||
@@ -29021,7 +28963,6 @@ 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
|
||||
@@ -29088,7 +29029,6 @@ 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
|
||||
@@ -29161,7 +29101,6 @@ 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
|
||||
@@ -29217,7 +29156,6 @@ 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.
|
||||
@@ -29309,7 +29247,6 @@ 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`.
|
||||
@@ -29378,7 +29315,6 @@ 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`
|
||||
@@ -29477,7 +29413,6 @@ 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
|
||||
@@ -29577,7 +29512,6 @@ 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
|
||||
@@ -29667,7 +29601,6 @@ 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
|
||||
@@ -29736,7 +29669,6 @@ paths:
|
||||
interval: 30m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRule'
|
||||
responses:
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { ErrorEvent, StackFrame } from '@sentry/react';
|
||||
import { AxiosError, AxiosHeaders } from 'axios';
|
||||
|
||||
import {
|
||||
hasNoAppFrames,
|
||||
isExpectedRequestFailure,
|
||||
isExternalIntegrationError,
|
||||
SENTRY_IGNORE_ERRORS,
|
||||
} from '../sentryFilters';
|
||||
@@ -138,3 +140,36 @@ describe('SENTRY_IGNORE_ERRORS', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExpectedRequestFailure', () => {
|
||||
function axiosErrorWithStatus(status: number): AxiosError {
|
||||
const config = { headers: new AxiosHeaders() };
|
||||
return new AxiosError(
|
||||
`Request failed with status code ${status}`,
|
||||
AxiosError.ERR_BAD_REQUEST,
|
||||
config,
|
||||
undefined,
|
||||
{ status, statusText: '', headers: {}, config, data: {} },
|
||||
);
|
||||
}
|
||||
|
||||
it.each([401, 403])('drops a %i response', (status) => {
|
||||
expect(isExpectedRequestFailure(axiosErrorWithStatus(status))).toBe(true);
|
||||
});
|
||||
|
||||
it('drops a network error', () => {
|
||||
expect(
|
||||
isExpectedRequestFailure(
|
||||
new AxiosError('Network Error', AxiosError.ERR_NETWORK),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([400, 404, 500, 503])('keeps a %i response', (status) => {
|
||||
expect(isExpectedRequestFailure(axiosErrorWithStatus(status))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an error that is not from axios', () => {
|
||||
expect(isExpectedRequestFailure(new Error('Network Error'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ import defaultRoutes, {
|
||||
} from './routes';
|
||||
import {
|
||||
hasNoAppFrames,
|
||||
isExpectedRequestFailure,
|
||||
isExternalIntegrationError,
|
||||
SENTRY_IGNORE_ERRORS,
|
||||
} from './sentryFilters';
|
||||
@@ -341,6 +342,9 @@ function App(): JSX.Element {
|
||||
if (error?.name === 'AbortError') {
|
||||
return null;
|
||||
}
|
||||
if (isExpectedRequestFailure(hint?.originalException)) {
|
||||
return null;
|
||||
}
|
||||
// Ignore benign Monaco cancellation errors (name 'Canceled').
|
||||
if (error?.name === 'Canceled') {
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ErrorEvent } from '@sentry/react';
|
||||
import { AxiosError, isAxiosError } from 'axios';
|
||||
|
||||
export const SENTRY_IGNORE_ERRORS = [
|
||||
// A tab that outlived a deploy requests hashed assets the new build no longer
|
||||
@@ -14,6 +15,18 @@ export const SENTRY_IGNORE_ERRORS = [
|
||||
/Called on script loaded before session recording is available/,
|
||||
];
|
||||
|
||||
// Not UI bugs: 401/403 come from the session or role, ERR_NETWORK from a
|
||||
// dropped connection.
|
||||
export function isExpectedRequestFailure(error: unknown): boolean {
|
||||
if (!isAxiosError(error)) {
|
||||
return false;
|
||||
}
|
||||
const status = error.response?.status;
|
||||
return (
|
||||
error.code === AxiosError.ERR_NETWORK || status === 401 || status === 403
|
||||
);
|
||||
}
|
||||
|
||||
const APP_ASSETS_PATH = '/assets/';
|
||||
|
||||
// Frames without a position are `[native code]`, which says nothing about the
|
||||
|
||||
@@ -829,33 +829,26 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
|
||||
jira = 'jira',
|
||||
}
|
||||
export interface AlertmanagertypesChannelJiraCloudAuthDTO {
|
||||
export interface AlertmanagertypesChannelJiraAuthDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
* @description The Atlassian API token paired with email. A secret; returned by create, update and fetch by ID, never by list.
|
||||
* @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.
|
||||
*/
|
||||
apiToken: string;
|
||||
apiToken?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The Atlassian account email used for basic auth.
|
||||
* @description The Atlassian account email used for basic auth. Required when deployment is cloud.
|
||||
*/
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelJiraSelfHostedAuthDTO {
|
||||
email?: string;
|
||||
/**
|
||||
* @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.
|
||||
* @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;
|
||||
pat?: string;
|
||||
}
|
||||
|
||||
export type AlertmanagertypesChannelJiraAuthDTO =
|
||||
| AlertmanagertypesChannelJiraCloudAuthDTO
|
||||
| AlertmanagertypesChannelJiraSelfHostedAuthDTO;
|
||||
|
||||
export enum AlertmanagertypesJiraDeploymentDTO {
|
||||
cloud = 'cloud',
|
||||
selfhosted = 'selfhosted',
|
||||
@@ -13048,16 +13041,6 @@ 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 {
|
||||
@@ -13150,11 +13133,6 @@ 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.
|
||||
*/
|
||||
@@ -13168,11 +13146,6 @@ export interface RuletypesRuleDTO {
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The query payload version. Nothing reads it.
|
||||
*/
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export enum RuletypesThresholdKindDTO {
|
||||
|
||||
@@ -11,6 +11,8 @@ 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;
|
||||
|
||||
1
frontend/src/assets/Logos/pixeltable.svg
Normal file
1
frontend/src/assets/Logos/pixeltable.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 726 B |
@@ -48,22 +48,59 @@ export function QuerySearchV2Provider({
|
||||
}, [initialExpression, store]);
|
||||
|
||||
const isInitialized = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!isInitialized.current && urlExpression) {
|
||||
const cleanedExpression = getUserExpressionFromCombined(
|
||||
initialExpression,
|
||||
urlExpression,
|
||||
);
|
||||
store.getState().initializeFromUrl(cleanedExpression);
|
||||
isInitialized.current = true;
|
||||
}
|
||||
}, [urlExpression, initialExpression, store]);
|
||||
|
||||
const lastCommittedRef = useRef(committedExpression);
|
||||
useEffect(() => {
|
||||
if (isInitialized.current || !urlExpression) {
|
||||
setUrlExpression(committedExpression || null);
|
||||
return;
|
||||
}
|
||||
}, [committedExpression, setUrlExpression, urlExpression]);
|
||||
isInitialized.current = true;
|
||||
store
|
||||
.getState()
|
||||
.initializeFromUrl(
|
||||
getUserExpressionFromCombined(initialExpression, urlExpression),
|
||||
);
|
||||
}, [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;
|
||||
}
|
||||
lastCommittedRef.current = committedExpression;
|
||||
isInitialized.current = true;
|
||||
const next = committedExpression || null;
|
||||
if (next === urlExpression) {
|
||||
return;
|
||||
}
|
||||
void setUrlExpression(next);
|
||||
}, [committedExpression, urlExpression, setUrlExpression]);
|
||||
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { act, renderHook } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
useExpression,
|
||||
useInitialExpression,
|
||||
useInputExpression,
|
||||
useQuerySearchInitialExpressionProp,
|
||||
useQuerySearchOnChange,
|
||||
useQuerySearchOnRun,
|
||||
@@ -34,10 +35,22 @@ 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;
|
||||
@@ -46,6 +59,7 @@ function useTestHooks(): {
|
||||
expression: useExpression(),
|
||||
userExpression: useUserExpression(),
|
||||
initialExpression: useInitialExpression(),
|
||||
inputExpression: useInputExpression(),
|
||||
querySearchInitialExpressionProp: useQuerySearchInitialExpressionProp(),
|
||||
onChange: useQuerySearchOnChange(),
|
||||
onRun: useQuerySearchOnRun(),
|
||||
@@ -56,6 +70,7 @@ describe('QuerySearchExpressionProvider', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUrlValue = null;
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
it('should provide initial context values', () => {
|
||||
@@ -110,6 +125,96 @@ 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());
|
||||
|
||||
@@ -15,11 +15,41 @@ $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;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { CircleCheck, Info, TriangleAlert, Filter } from '@signozhq/icons';
|
||||
import {
|
||||
CircleCheck,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
Filter,
|
||||
Play,
|
||||
} from '@signozhq/icons';
|
||||
import {
|
||||
autocompletion,
|
||||
closeCompletion,
|
||||
@@ -16,6 +22,7 @@ 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,
|
||||
@@ -59,6 +66,7 @@ import {
|
||||
SUGGESTION_FETCH_DEBOUNCE_MS,
|
||||
SUGGESTIONS_SECTION,
|
||||
} from './constants';
|
||||
import { ScopeChip } from './ScopeChip/ScopeChip';
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
dedupeOptionsByLabel,
|
||||
@@ -105,9 +113,14 @@ 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,
|
||||
@@ -125,10 +138,13 @@ function QuerySearch({
|
||||
queryData,
|
||||
dataSource,
|
||||
onRun,
|
||||
showRunButton,
|
||||
isLoadingQueries,
|
||||
signalSource,
|
||||
hardcodedAttributeKeys,
|
||||
showFilterSuggestionsWithoutMetric,
|
||||
initialExpression,
|
||||
showScopeChip: showScopeChipProp,
|
||||
metricNamespace,
|
||||
valueSuggestionsOverride,
|
||||
}: QuerySearchProps): JSX.Element {
|
||||
@@ -148,6 +164,7 @@ function QuerySearch({
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
|
||||
const isScopedFilter = initialExpression !== undefined;
|
||||
const showScopeChip = !!showScopeChipProp && !!initialExpression;
|
||||
|
||||
const validateExpressionForEditor = useCallback(
|
||||
(editorDoc: string): void => {
|
||||
@@ -173,6 +190,18 @@ 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;
|
||||
@@ -1557,169 +1586,182 @@ function QuerySearch({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="query-where-clause-editor-container">
|
||||
{isScopedFilter ? (
|
||||
<Tooltip title={initialExpression || ''} placement="left">
|
||||
<div className="query-search-initial-scope-label">
|
||||
<Filter
|
||||
<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
|
||||
size={14}
|
||||
style={{
|
||||
opacity: 0.9,
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</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,
|
||||
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', {
|
||||
<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 && !showScopeChip,
|
||||
})}
|
||||
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}
|
||||
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>
|
||||
</div>
|
||||
</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>
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -1813,12 +1855,15 @@ 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;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -224,6 +224,134 @@ 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'";
|
||||
|
||||
@@ -69,8 +69,6 @@ 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:
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import {
|
||||
getAllEndpointsWidgetData,
|
||||
getGroupByFiltersFromGroupByValues,
|
||||
getGroupByExpressionFromRowData,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import GridCard from 'container/WidgetCard/Card';
|
||||
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
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,
|
||||
@@ -23,8 +27,6 @@ function AllEndPoints({
|
||||
groupBy,
|
||||
setGroupBy,
|
||||
timeRange,
|
||||
initialFilters,
|
||||
setInitialFiltersEndPointStats,
|
||||
}: {
|
||||
domainName: string;
|
||||
setSelectedEndPointName: (name: string) => void;
|
||||
@@ -35,113 +37,50 @@ function AllEndPoints({
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
initialFilters: IBuilderQuery['filters'];
|
||||
setInitialFiltersEndPointStats: (filters: IBuilderQuery['filters']) => void;
|
||||
}): JSX.Element {
|
||||
const [params, setParams] = useApiMonitoringParams();
|
||||
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 }[]
|
||||
>([]);
|
||||
const [, setEndPointStatsExpression] = useQueryState(
|
||||
ENDPOINT_STATS_EXPRESSION_KEY,
|
||||
parseAsString,
|
||||
);
|
||||
|
||||
// --- FILTERS STATE SYNC ---
|
||||
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 };
|
||||
const {
|
||||
expression: filterExpression,
|
||||
initialExpression: scopeExpression,
|
||||
queryData,
|
||||
onDraftChange,
|
||||
onRun,
|
||||
onBlur: handleFilterBlur,
|
||||
} = useFilterExpression({
|
||||
groupBy,
|
||||
});
|
||||
|
||||
// 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, filters),
|
||||
[groupBy, domainName, filters],
|
||||
() => 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],
|
||||
);
|
||||
|
||||
// --- GROUP BY STATE SYNC (existing) ---
|
||||
const handleGroupByChange = useCallback(
|
||||
(value: IBuilderQuery['groupBy']) => {
|
||||
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('');
|
||||
setGroupBy(value);
|
||||
// Update params
|
||||
setParams({ groupBy: newGroupBy.map((g) => g.key) });
|
||||
setParams({ groupBy: value.map((g) => g.key) });
|
||||
},
|
||||
[groupByFiltersData, setGroupBy, allAvailableGroupByOptions, setParams],
|
||||
[setGroupBy, setParams],
|
||||
);
|
||||
|
||||
// Sync params to local groupBy state on param change
|
||||
@@ -154,77 +93,31 @@ function AllEndPoints({
|
||||
groupBy.map((g) => g.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, allAvailableGroupByOptions, groupBy, setGroupBy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (groupByFiltersData?.payload) {
|
||||
// Update dropdown options
|
||||
setGroupByOptions(
|
||||
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
|
||||
value: filter.key,
|
||||
label: filter.key,
|
||||
})) || [],
|
||||
setGroupBy(
|
||||
params.groupBy.map((key) => chooseAutocompleteFromCustomValue([], 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]);
|
||||
}, [params.groupBy, groupBy, setGroupBy]);
|
||||
|
||||
const onRowClick = useCallback(
|
||||
(props: any): void => {
|
||||
setSelectedEndPointName(props[SPAN_ATTRIBUTES.HTTP_URL] as string);
|
||||
setSelectedView(VIEWS.ENDPOINT_STATS);
|
||||
const initialItems = [
|
||||
...(filters?.items || []),
|
||||
...(getGroupByFiltersFromGroupByValues(props, groupBy)?.items || []),
|
||||
];
|
||||
setInitialFiltersEndPointStats({
|
||||
items: initialItems,
|
||||
op: 'AND',
|
||||
});
|
||||
const statsExpression = combineInitialAndUserExpression(
|
||||
getGroupByExpressionFromRowData(props),
|
||||
filterExpression,
|
||||
);
|
||||
void setEndPointStatsExpression(statsExpression || null);
|
||||
setParams({
|
||||
selectedEndPointName: props[SPAN_ATTRIBUTES.HTTP_URL] as string,
|
||||
selectedView: VIEWS.ENDPOINT_STATS,
|
||||
endPointDetailsLocalFilters: {
|
||||
items: initialItems,
|
||||
op: 'AND',
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
filters,
|
||||
setInitialFiltersEndPointStats,
|
||||
filterExpression,
|
||||
setEndPointStatsExpression,
|
||||
setSelectedEndPointName,
|
||||
setSelectedView,
|
||||
groupBy,
|
||||
setParams,
|
||||
],
|
||||
);
|
||||
@@ -232,28 +125,28 @@ function AllEndPoints({
|
||||
return (
|
||||
<div className="all-endpoints-container">
|
||||
<div className="all-endpoints-header">
|
||||
<div className="filter-container">
|
||||
<QueryBuilderSearchV2
|
||||
query={query}
|
||||
onChange={handleFilterChange}
|
||||
<div className="filter-container" onBlur={handleFilterBlur}>
|
||||
<QuerySearch
|
||||
queryData={queryData}
|
||||
onChange={onDraftChange}
|
||||
onRun={handleRun}
|
||||
showRunButton
|
||||
isLoadingQueries={isFetchingTable}
|
||||
initialExpression={scopeExpression}
|
||||
showScopeChip
|
||||
dataSource={DataSource.TRACES}
|
||||
placeholder="Search for filters..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group-by-container">
|
||||
<div className="group-by-label"> Group by </div>
|
||||
<Select
|
||||
className="group-by-select"
|
||||
loading={isLoadingGroupByFilters}
|
||||
mode="multiple"
|
||||
value={groupBy}
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
placeholder="Search for attribute"
|
||||
options={groupByOptions}
|
||||
<GroupByFilter
|
||||
query={queryData}
|
||||
onChange={handleGroupByChange}
|
||||
onSearch={(value: string): void => setGroupBySearchValue(value)}
|
||||
/>{' '}
|
||||
disabled={false}
|
||||
placeholder="Search for attribute"
|
||||
/>
|
||||
</div>
|
||||
<div className="endpoints-table-container">
|
||||
<GridCard
|
||||
|
||||
@@ -195,16 +195,6 @@
|
||||
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 {
|
||||
@@ -365,16 +355,9 @@
|
||||
.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 {
|
||||
@@ -416,29 +399,8 @@
|
||||
|
||||
.endpoint-details-filters-container {
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ant-select-item,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -17,7 +18,12 @@ 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';
|
||||
@@ -34,14 +40,14 @@ function DomainDetails({
|
||||
selectedDomainIndex,
|
||||
setSelectedDomainIndex,
|
||||
domainListLength,
|
||||
domainListFilters,
|
||||
domainListFilterExpression,
|
||||
}: {
|
||||
domainData: any;
|
||||
handleClose: () => void;
|
||||
selectedDomainIndex: number;
|
||||
setSelectedDomainIndex: (index: number) => void;
|
||||
domainListLength: number;
|
||||
domainListFilters: IBuilderQuery['filters'];
|
||||
domainListFilterExpression: string;
|
||||
}): JSX.Element {
|
||||
const [params, setParams] = useApiMonitoringParams();
|
||||
const [selectedView, setSelectedView] = useState<VIEWS>(
|
||||
@@ -53,8 +59,16 @@ function DomainDetails({
|
||||
const [endPointsGroupBy, setEndPointsGroupBy] = useState<
|
||||
IBuilderQuery['groupBy']
|
||||
>([]);
|
||||
const [initialFiltersEndPointStats, setInitialFiltersEndPointStats] =
|
||||
useState<IBuilderQuery['filters']>(domainListFilters);
|
||||
|
||||
const domainName = domainData?.domainName ?? '';
|
||||
const allEndpointsScope = useMemo(
|
||||
() => getDomainScopeExpression(domainName),
|
||||
[domainName],
|
||||
);
|
||||
const endPointStatsScope = useMemo(
|
||||
() => getDomainScopeExpression(domainName, selectedEndPointName),
|
||||
[domainName, selectedEndPointName],
|
||||
);
|
||||
|
||||
const handleTabChange = (value: string): void => {
|
||||
setSelectedView(value as VIEWS);
|
||||
@@ -222,7 +236,7 @@ function DomainDetails({
|
||||
<>
|
||||
<DomainMetrics
|
||||
domainName={domainData.domainName}
|
||||
domainListFilters={domainListFilters}
|
||||
domainListFilterExpression={domainListFilterExpression}
|
||||
timeRange={modalTimeRange}
|
||||
/>
|
||||
<div className="views-tabs-container">
|
||||
@@ -253,34 +267,43 @@ function DomainDetails({
|
||||
/>
|
||||
</div>
|
||||
{selectedView === VIEW_TYPES.ALL_ENDPOINTS && (
|
||||
<AllEndPoints
|
||||
domainName={domainData.domainName}
|
||||
setSelectedEndPointName={handleEndPointChange}
|
||||
setSelectedView={setSelectedView}
|
||||
groupBy={endPointsGroupBy}
|
||||
setGroupBy={setEndPointsGroupBy}
|
||||
timeRange={modalTimeRange}
|
||||
initialFilters={domainListFilters}
|
||||
setInitialFiltersEndPointStats={setInitialFiltersEndPointStats}
|
||||
/>
|
||||
<QuerySearchV2Provider
|
||||
queryParamKey={ALL_ENDPOINTS_EXPRESSION_KEY}
|
||||
initialExpression={allEndpointsScope}
|
||||
persistOnUnmount
|
||||
>
|
||||
<AllEndPoints
|
||||
domainName={domainData.domainName}
|
||||
setSelectedEndPointName={handleEndPointChange}
|
||||
setSelectedView={setSelectedView}
|
||||
groupBy={endPointsGroupBy}
|
||||
setGroupBy={setEndPointsGroupBy}
|
||||
timeRange={modalTimeRange}
|
||||
/>
|
||||
</QuerySearchV2Provider>
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.ENDPOINT_STATS && (
|
||||
<EndPointDetails
|
||||
domainName={domainData.domainName}
|
||||
endPointName={selectedEndPointName}
|
||||
setSelectedEndPointName={handleEndPointChange}
|
||||
initialFilters={initialFiltersEndPointStats}
|
||||
timeRange={modalTimeRange}
|
||||
handleTimeChange={handleTimeChange}
|
||||
/>
|
||||
<QuerySearchV2Provider
|
||||
queryParamKey={ENDPOINT_STATS_EXPRESSION_KEY}
|
||||
initialExpression={endPointStatsScope}
|
||||
persistOnUnmount
|
||||
>
|
||||
<EndPointDetails
|
||||
domainName={domainData.domainName}
|
||||
endPointName={selectedEndPointName}
|
||||
setSelectedEndPointName={handleEndPointChange}
|
||||
timeRange={modalTimeRange}
|
||||
handleTimeChange={handleTimeChange}
|
||||
/>
|
||||
</QuerySearchV2Provider>
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.TOP_ERRORS && (
|
||||
<TopErrors
|
||||
domainName={domainData.domainName}
|
||||
timeRange={modalTimeRange}
|
||||
initialFilters={domainListFilters}
|
||||
initialFilterExpression={domainListFilterExpression}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
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,36 +19,28 @@ 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 EndPointsDropDown from './components/EndPointsDropDown';
|
||||
import EndPointPicker from './components/EndPointPicker';
|
||||
import MetricOverTimeGraph from './components/MetricOverTimeGraph';
|
||||
import StatusCodeBarCharts from './components/StatusCodeBarCharts';
|
||||
import StatusCodeTable from './components/StatusCodeTable';
|
||||
import { SPAN_ATTRIBUTES } from './constants';
|
||||
|
||||
const httpUrlKey = {
|
||||
dataType: DataTypes.String,
|
||||
key: SPAN_ATTRIBUTES.HTTP_URL,
|
||||
type: 'tag',
|
||||
};
|
||||
import { useFilterExpression } from './hooks/useFilterExpression';
|
||||
import { useWidgetQueries } from './hooks/useWidgetQueries';
|
||||
|
||||
function EndPointDetails({
|
||||
domainName,
|
||||
endPointName,
|
||||
setSelectedEndPointName,
|
||||
initialFilters,
|
||||
timeRange,
|
||||
handleTimeChange,
|
||||
}: {
|
||||
domainName: string;
|
||||
endPointName: string;
|
||||
setSelectedEndPointName: (value: string) => void;
|
||||
initialFilters: IBuilderQuery['filters'];
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
@@ -59,159 +51,66 @@ function EndPointDetails({
|
||||
) => void;
|
||||
}): JSX.Element {
|
||||
const { startTime: minTime, endTime: maxTime } = timeRange;
|
||||
const [params, setParams] = useApiMonitoringParams();
|
||||
|
||||
const currentQuery = initialQueriesMap[DataSource.TRACES];
|
||||
// Search filters; the endpoint filter is combined in at query time
|
||||
const {
|
||||
expression: filterExpression,
|
||||
initialExpression: scopeExpression,
|
||||
queryData,
|
||||
onDraftChange,
|
||||
onRun,
|
||||
onBlur: handleFilterBlur,
|
||||
} = useFilterExpression();
|
||||
|
||||
// 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 httpUrlExpression = useMemo(
|
||||
() => getHttpUrlExpression(endPointName),
|
||||
[endPointName],
|
||||
);
|
||||
|
||||
const updatedCurrentQuery = useMemo(
|
||||
() => ({
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
dataSource: DataSource.TRACES,
|
||||
filters, // Use the local filters state
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
[filters, currentQuery],
|
||||
const scopedExpression = useMemo(
|
||||
() => combineInitialAndUserExpression(httpUrlExpression, filterExpression),
|
||||
[httpUrlExpression, filterExpression],
|
||||
);
|
||||
|
||||
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
|
||||
|
||||
const isServicesFilterApplied = useMemo(
|
||||
() => filters?.items?.some((item) => item.key?.key === 'service.name'),
|
||||
[filters],
|
||||
() =>
|
||||
extractQueryPairs(filterExpression).some(
|
||||
(pair) => pair.key === 'service.name',
|
||||
),
|
||||
[filterExpression],
|
||||
);
|
||||
|
||||
const endPointDetailsQueryPayload = useMemo(
|
||||
() => getEndPointDetailsQueryPayload(domainName, minTime, maxTime, filters),
|
||||
[domainName, filters, minTime, maxTime],
|
||||
() =>
|
||||
getEndPointDetailsQueryPayload(
|
||||
domainName,
|
||||
minTime,
|
||||
maxTime,
|
||||
scopedExpression,
|
||||
),
|
||||
[domainName, scopedExpression, 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) => {
|
||||
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,
|
||||
};
|
||||
}),
|
||||
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,
|
||||
})),
|
||||
);
|
||||
|
||||
const [
|
||||
endPointMetricsDataQuery,
|
||||
endPointStatusCodeDataQuery,
|
||||
endPointDropDownDataQuery,
|
||||
endPointDependentServicesDataQuery,
|
||||
endPointStatusCodeBarChartsDataQuery,
|
||||
endPointStatusCodeLatencyBarChartsDataQuery,
|
||||
@@ -222,7 +121,6 @@ function EndPointDetails({
|
||||
endPointDetailsDataQueries[2],
|
||||
endPointDetailsDataQueries[3],
|
||||
endPointDetailsDataQueries[4],
|
||||
endPointDetailsDataQueries[5],
|
||||
],
|
||||
[endPointDetailsDataQueries],
|
||||
);
|
||||
@@ -234,10 +132,32 @@ function EndPointDetails({
|
||||
|
||||
const [rateOverTimeWidget, latencyOverTimeWidget] = useMemo(
|
||||
() => [
|
||||
getRateOverTimeWidgetData(domainName, endPointName, filters),
|
||||
getLatencyOverTimeWidgetData(domainName, endPointName, filters),
|
||||
getRateOverTimeWidgetData(domainName, endPointName, scopedExpression),
|
||||
getLatencyOverTimeWidgetData(domainName, endPointName, scopedExpression),
|
||||
],
|
||||
[domainName, endPointName, filters], // Use combinedFilters
|
||||
[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],
|
||||
);
|
||||
|
||||
// // [TODO] Fix this later
|
||||
@@ -257,19 +177,23 @@ function EndPointDetails({
|
||||
return (
|
||||
<div className="endpoint-details-container">
|
||||
<div className="endpoint-details-filters-container">
|
||||
<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}
|
||||
<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}
|
||||
placeholder="Search for filters..."
|
||||
/>
|
||||
</div>
|
||||
@@ -299,7 +223,7 @@ function EndPointDetails({
|
||||
endPointStatusCodeLatencyBarChartsDataQuery
|
||||
}
|
||||
domainName={domainName}
|
||||
filters={filters}
|
||||
filterExpression={scopedExpression}
|
||||
timeRange={timeRange}
|
||||
onDragSelect={onDragSelect}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { QueryFunctionContext, useQueries, useQuery } from 'react-query';
|
||||
import { QueryFunctionContext, useQuery } from 'react-query';
|
||||
import { Spin, Table, Tooltip } from 'antd';
|
||||
import { Info, Loader } from '@signozhq/icons';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
@@ -7,41 +7,36 @@ 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,
|
||||
getEndPointDetailsQueryPayload,
|
||||
getHttpUrlExpression,
|
||||
getTopErrorsColumnsConfig,
|
||||
getTopErrorsCoRelationQueryFilters,
|
||||
getTopErrorsQueryPayload,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
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 { SuccessResponseV2 } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
|
||||
|
||||
import EndPointsDropDown from './components/EndPointsDropDown';
|
||||
import EndPointPicker from './components/EndPointPicker';
|
||||
import ErrorState from './components/ErrorState';
|
||||
import { SPAN_ATTRIBUTES } from './constants';
|
||||
|
||||
function TopErrors({
|
||||
domainName,
|
||||
timeRange,
|
||||
initialFilters,
|
||||
initialFilterExpression,
|
||||
}: {
|
||||
domainName: string;
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
initialFilters: IBuilderQuery['filters'];
|
||||
initialFilterExpression: string;
|
||||
}): JSX.Element {
|
||||
const { startTime: minTime, endTime: maxTime } = timeRange;
|
||||
|
||||
@@ -55,27 +50,10 @@ function TopErrors({
|
||||
domainName,
|
||||
minTime,
|
||||
maxTime,
|
||||
{
|
||||
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',
|
||||
},
|
||||
combineInitialAndUserExpression(
|
||||
getHttpUrlExpression(endPointName),
|
||||
initialFilterExpression,
|
||||
),
|
||||
showStatusCodeErrors,
|
||||
),
|
||||
[
|
||||
@@ -83,7 +61,7 @@ function TopErrors({
|
||||
endPointName,
|
||||
minTime,
|
||||
maxTime,
|
||||
initialFilters,
|
||||
initialFilterExpression,
|
||||
showStatusCodeErrors,
|
||||
],
|
||||
);
|
||||
@@ -120,35 +98,6 @@ 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) {
|
||||
@@ -162,14 +111,13 @@ function TopErrors({
|
||||
return (
|
||||
<div className="all-endpoints-container">
|
||||
<div className="top-errors-dropdown-container">
|
||||
<div className="endpoint-details-filters-container-dropdown">
|
||||
<EndPointsDropDown
|
||||
selectedEndPointName={endPointName}
|
||||
setSelectedEndPointName={setSelectedEndPointName}
|
||||
endPointDropDownDataQuery={endPointDropDownDataQuery}
|
||||
parentContainerDiv=".endpoint-details-filters-container"
|
||||
/>
|
||||
</div>
|
||||
<EndPointPicker
|
||||
domainName={domainName}
|
||||
timeRange={timeRange}
|
||||
filterExpression=""
|
||||
value={endPointName}
|
||||
onChange={setSelectedEndPointName}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Switch
|
||||
color="primary"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Skeleton, Table, TablePaginationConfig } from 'antd';
|
||||
import { Table, TablePaginationConfig } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
@@ -54,10 +54,6 @@ function DependentServices({
|
||||
[isExpanded, dependentServicesData.length],
|
||||
);
|
||||
|
||||
if (isLoading || isRefetching) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <ErrorState refetch={refetch} />;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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';
|
||||
@@ -34,10 +33,7 @@ describe('DomainMetrics - V5 Query Payload Tests', () => {
|
||||
startTime: 1758259531000,
|
||||
endTime: 1758261331000,
|
||||
},
|
||||
domainListFilters: {
|
||||
items: [],
|
||||
op: 'AND' as const,
|
||||
} as IBuilderQuery['filters'],
|
||||
domainListFilterExpression: '',
|
||||
};
|
||||
|
||||
const mockSuccessResponse = {
|
||||
@@ -174,35 +170,12 @@ describe('DomainMetrics - V5 Query Payload Tests', () => {
|
||||
it('includes custom filters in filter expressions', async () => {
|
||||
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
|
||||
|
||||
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,
|
||||
};
|
||||
const customFilterExpression =
|
||||
"service.name = 'my-service' AND deployment.environment = 'production'";
|
||||
|
||||
renderComponent({
|
||||
...mockProps,
|
||||
domainListFilters: customFilters,
|
||||
domainListFilterExpression: customFilterExpression,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -14,18 +14,17 @@ 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,
|
||||
domainListFilters,
|
||||
domainListFilterExpression,
|
||||
}: {
|
||||
domainName: string;
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
domainListFilters: IBuilderQuery['filters'];
|
||||
domainListFilterExpression: string;
|
||||
}): JSX.Element {
|
||||
const { startTime: minTime, endTime: maxTime } = timeRange;
|
||||
|
||||
@@ -35,9 +34,9 @@ function DomainMetrics({
|
||||
domainName,
|
||||
minTime,
|
||||
maxTime,
|
||||
domainListFilters,
|
||||
domainListFilterExpression,
|
||||
),
|
||||
[domainName, minTime, maxTime, domainListFilters],
|
||||
[domainName, minTime, maxTime, domainListFilterExpression],
|
||||
);
|
||||
|
||||
// Since only one query here
|
||||
|
||||
@@ -114,10 +114,7 @@ describe('EndPointMetrics - V5 Query Payload Tests', () => {
|
||||
const domainName = 'api.example.com';
|
||||
const startTime = 1758259531000;
|
||||
const endTime = 1758261331000;
|
||||
const filters = {
|
||||
items: [],
|
||||
op: 'AND' as const,
|
||||
};
|
||||
const filters = '';
|
||||
|
||||
// Get the actual payload that would be generated
|
||||
const payloads = getEndPointDetailsQueryPayload(
|
||||
@@ -237,31 +234,8 @@ describe('EndPointMetrics - V5 Query Payload Tests', () => {
|
||||
it('includes custom domainListFilters in all query expressions', async () => {
|
||||
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
|
||||
|
||||
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 customFilters =
|
||||
"service.name = 'payment-service' AND deployment.environment = 'staging'";
|
||||
|
||||
const payloads = getEndPointDetailsQueryPayload(
|
||||
'api.internal.com',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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;
|
||||
@@ -1,65 +0,0 @@
|
||||
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;
|
||||
@@ -24,7 +24,6 @@ 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 {
|
||||
@@ -37,7 +36,7 @@ function StatusCodeBarCharts({
|
||||
endPointStatusCodeBarChartsDataQuery,
|
||||
endPointStatusCodeLatencyBarChartsDataQuery,
|
||||
domainName,
|
||||
filters,
|
||||
filterExpression,
|
||||
timeRange,
|
||||
onDragSelect,
|
||||
}: {
|
||||
@@ -50,7 +49,7 @@ function StatusCodeBarCharts({
|
||||
unknown
|
||||
>;
|
||||
domainName: string;
|
||||
filters: IBuilderQuery['filters'];
|
||||
filterExpression: string;
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
@@ -130,12 +129,8 @@ function StatusCodeBarCharts({
|
||||
);
|
||||
|
||||
const widget = useMemo<Widgets>(
|
||||
() =>
|
||||
getStatusCodeBarChartWidgetData(domainName, {
|
||||
items: [...(filters?.items || [])],
|
||||
op: filters?.op || 'AND',
|
||||
}),
|
||||
[domainName, filters],
|
||||
() => getStatusCodeBarChartWidgetData(domainName, filterExpression),
|
||||
[domainName, filterExpression],
|
||||
);
|
||||
|
||||
const activeApiResponse = useMemo(
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
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'",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
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());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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 };
|
||||
}
|
||||
@@ -19,6 +19,7 @@ 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';
|
||||
@@ -27,16 +28,26 @@ 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,
|
||||
);
|
||||
@@ -55,6 +66,8 @@ function DomainList(): JSX.Element {
|
||||
});
|
||||
|
||||
const compositeData = useGetCompositeQueryParam();
|
||||
const listFilterExpression =
|
||||
compositeData?.builder?.queryData[0]?.filter?.expression ?? '';
|
||||
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
@@ -155,6 +168,7 @@ 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}
|
||||
/>
|
||||
@@ -225,6 +239,11 @@ 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', {});
|
||||
}
|
||||
},
|
||||
@@ -245,8 +264,12 @@ function DomainList(): JSX.Element {
|
||||
handleClose={(): void => {
|
||||
setSelectedDomainIndex(-1);
|
||||
setParams(DEFAULT_PARAMS);
|
||||
void setDrawerFilters({
|
||||
[ALL_ENDPOINTS_EXPRESSION_KEY]: null,
|
||||
[ENDPOINT_STATS_EXPRESSION_KEY]: null,
|
||||
});
|
||||
}}
|
||||
domainListFilters={query?.filters}
|
||||
domainListFilterExpression={listFilterExpression}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
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]);
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
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 {
|
||||
@@ -11,20 +9,14 @@ import {
|
||||
getFormattedEndPointDropDownData,
|
||||
getFormattedEndPointStatusCodeChartData,
|
||||
getFormattedEndPointStatusCodeData,
|
||||
getGroupByFiltersFromGroupByValues,
|
||||
getGroupByExpressionFromRowData,
|
||||
getHttpUrlExpression,
|
||||
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
|
||||
@@ -90,140 +82,39 @@ describe('API Monitoring Utils', () => {
|
||||
expect(result[0].lastUsed).toBe('-');
|
||||
});
|
||||
});
|
||||
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',
|
||||
};
|
||||
|
||||
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[],
|
||||
);
|
||||
|
||||
// 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
|
||||
describe('getGroupByExpressionFromRowData', () => {
|
||||
it('builds an AND expression from the row values, skipping the url and metric columns', () => {
|
||||
expect(
|
||||
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);
|
||||
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");
|
||||
});
|
||||
|
||||
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[],
|
||||
it('escapes quotes in values', () => {
|
||||
expect(getGroupByExpressionFromRowData({ 'user.name': "o'brien" })).toBe(
|
||||
"user.name = 'o\\'brien'",
|
||||
);
|
||||
|
||||
// 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[] = [];
|
||||
it('matches spans without the attribute when the group has no value', () => {
|
||||
expect(
|
||||
getGroupByExpressionFromRowData({
|
||||
'service.name': 'cart',
|
||||
'ai.embeddings': null,
|
||||
}),
|
||||
).toBe("service.name = 'cart' AND ai.embeddings NOT EXISTS");
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = getGroupByFiltersFromGroupByValues(rowData, groupBy);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.op).toBe('AND');
|
||||
expect(result?.items).toHaveLength(0);
|
||||
it('returns an empty expression for a row without group columns', () => {
|
||||
expect(getGroupByExpressionFromRowData({ http_url: '/x', A: 1 })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -703,13 +594,9 @@ 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,
|
||||
filters as IBuilderQuery['filters'],
|
||||
);
|
||||
const result = getStatusCodeBarChartWidgetData(domainName, '');
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
@@ -723,47 +610,29 @@ describe('API Monitoring Utils', () => {
|
||||
const queryData = result.query.builder.queryData[0];
|
||||
|
||||
// Should have domain filter
|
||||
const domainFilter = queryData.filters?.items?.find(
|
||||
(item) => item.key && item.key.key === SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
expect(queryData.filter?.expression).toBe(
|
||||
`${SPAN_ATTRIBUTES.SERVER_NAME} = '${domainName}'`,
|
||||
);
|
||||
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 customFilter = {
|
||||
id: 'custom-filter',
|
||||
key: {
|
||||
dataType: 'string',
|
||||
key: 'custom.key',
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: 'custom-value',
|
||||
};
|
||||
const filters = { items: [customFilter], op: 'AND' };
|
||||
const customFilterExpression = "custom.key = 'custom-value'";
|
||||
|
||||
// Act
|
||||
const result = getStatusCodeBarChartWidgetData(
|
||||
domainName,
|
||||
filters as IBuilderQuery['filters'],
|
||||
customFilterExpression,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const queryData = result.query.builder.queryData[0];
|
||||
|
||||
// Should include our custom filter
|
||||
const includedFilter = queryData.filters?.items?.find(
|
||||
(item) => item.id === 'custom-filter',
|
||||
// Should include our custom filter after the domain filter
|
||||
expect(queryData.filter?.expression).toBe(
|
||||
`${SPAN_ATTRIBUTES.SERVER_NAME} = '${domainName}' AND (${customFilterExpression})`,
|
||||
);
|
||||
expect(includedFilter).toBeDefined();
|
||||
if (includedFilter) {
|
||||
expect(includedFilter.value).toBe('custom-value');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -927,4 +796,17 @@ 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'",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import {
|
||||
getAllEndpointsWidgetData,
|
||||
getGroupByFiltersFromGroupByValues,
|
||||
getGroupByExpressionFromRowData,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
|
||||
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 AllEndPoints from '../Explorer/Domains/DomainDetails/AllEndPoints';
|
||||
import { FilterProviders } from './FilterProviders';
|
||||
import {
|
||||
SPAN_ATTRIBUTES,
|
||||
VIEWS,
|
||||
@@ -14,7 +20,7 @@ import {
|
||||
// Mock the dependencies
|
||||
jest.mock('container/ApiMonitoring/utils', () => ({
|
||||
getAllEndpointsWidgetData: jest.fn(),
|
||||
getGroupByFiltersFromGroupByValues: jest.fn(),
|
||||
getGroupByExpressionFromRowData: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('container/WidgetCard/Card', () => ({
|
||||
@@ -34,53 +40,47 @@ jest.mock('container/WidgetCard/Card', () => ({
|
||||
)),
|
||||
}));
|
||||
|
||||
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('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('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>
|
||||
)),
|
||||
};
|
||||
});
|
||||
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>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock useApiMonitoringParams hook
|
||||
jest.mock('container/ApiMonitoring/queryParams', () => ({
|
||||
...jest.requireActual('container/ApiMonitoring/queryParams'),
|
||||
useApiMonitoringParams: jest.fn().mockReturnValue([
|
||||
{
|
||||
showIP: true,
|
||||
@@ -88,8 +88,6 @@ jest.mock('container/ApiMonitoring/queryParams', () => ({
|
||||
selectedView: 'all_endpoints',
|
||||
selectedEndPointName: '',
|
||||
groupBy: [],
|
||||
allEndpointsLocalFilters: undefined,
|
||||
endPointDetailsLocalFilters: undefined,
|
||||
modalTimeRange: undefined,
|
||||
selectedInterval: undefined,
|
||||
},
|
||||
@@ -108,29 +106,21 @@ describe('AllEndPoints', () => {
|
||||
startTime: 1609459200000,
|
||||
endTime: 1609545600000,
|
||||
},
|
||||
initialFilters: { op: 'AND', items: [] },
|
||||
setInitialFiltersEndPointStats: jest.fn(),
|
||||
};
|
||||
|
||||
const renderAllEndPoints = (): void => {
|
||||
render(
|
||||
<FilterProviders queryParamKey={ALL_ENDPOINTS_EXPRESSION_KEY}>
|
||||
<AllEndPoints {...mockProps} />
|
||||
</FilterProviders>,
|
||||
);
|
||||
};
|
||||
|
||||
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',
|
||||
@@ -139,10 +129,9 @@ describe('AllEndPoints', () => {
|
||||
queryData: [],
|
||||
});
|
||||
|
||||
(getGroupByFiltersFromGroupByValues as jest.Mock).mockReturnValue({
|
||||
items: [{ id: 'group-filter', key: 'status', op: '=', value: '200' }],
|
||||
op: 'AND',
|
||||
});
|
||||
(getGroupByExpressionFromRowData as jest.Mock).mockReturnValue(
|
||||
"status = '200'",
|
||||
);
|
||||
});
|
||||
|
||||
// Add cleanup after each test
|
||||
@@ -151,7 +140,7 @@ describe('AllEndPoints', () => {
|
||||
});
|
||||
|
||||
it('renders component correctly', () => {
|
||||
render(<AllEndPoints {...mockProps} />);
|
||||
renderAllEndPoints();
|
||||
|
||||
// Verify basic component rendering
|
||||
expect(screen.getByText('Group by')).toBeInTheDocument();
|
||||
@@ -161,7 +150,7 @@ describe('AllEndPoints', () => {
|
||||
});
|
||||
|
||||
it('handles filter changes', () => {
|
||||
render(<AllEndPoints {...mockProps} />);
|
||||
renderAllEndPoints();
|
||||
|
||||
// Trigger filter change
|
||||
fireEvent.click(screen.getByTestId('filter-change-button'));
|
||||
@@ -170,25 +159,24 @@ describe('AllEndPoints', () => {
|
||||
expect(getAllEndpointsWidgetData).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'test-domain',
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([expect.objectContaining({ id: 'test' })]),
|
||||
op: 'AND',
|
||||
}),
|
||||
"test = 'test'",
|
||||
);
|
||||
});
|
||||
|
||||
it('handles group by changes', () => {
|
||||
render(<AllEndPoints {...mockProps} />);
|
||||
renderAllEndPoints();
|
||||
|
||||
// Trigger group by change
|
||||
fireEvent.click(screen.getByTestId('select-change-button'));
|
||||
|
||||
// Check if setGroupBy was called with updated group by value
|
||||
expect(mockProps.setGroupBy).toHaveBeenCalled();
|
||||
// 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' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles row click in grid card', async () => {
|
||||
render(<AllEndPoints {...mockProps} />);
|
||||
renderAllEndPoints();
|
||||
|
||||
// Trigger row click
|
||||
fireEvent.click(screen.getByTestId('row-click-button'));
|
||||
@@ -196,7 +184,11 @@ describe('AllEndPoints', () => {
|
||||
// Check if proper functions were called
|
||||
expect(mockProps.setSelectedEndPointName).toHaveBeenCalledWith('/api/test');
|
||||
expect(mockProps.setSelectedView).toHaveBeenCalledWith(VIEWS.ENDPOINT_STATS);
|
||||
expect(mockProps.setInitialFiltersEndPointStats).toHaveBeenCalled();
|
||||
expect(getGroupByFiltersFromGroupByValues).toHaveBeenCalled();
|
||||
expect(getGroupByExpressionFromRowData).toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(getCurrentNuqsSearchParams().get(ENDPOINT_STATS_EXPRESSION_KEY)).toBe(
|
||||
"status = '200'",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,16 +14,12 @@ 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: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
const emptyFilters = '';
|
||||
const emptyGroupBy: BaseAutocompleteData[] = [];
|
||||
|
||||
describe('1. V5 Format Migration - All Four Queries', () => {
|
||||
|
||||
@@ -121,8 +121,7 @@ describe('DependentServices', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders loading state correctly', () => {
|
||||
// Arrange
|
||||
it('keeps the table in place while loading', () => {
|
||||
const mockQuery: MockQueryResult = {
|
||||
isLoading: true,
|
||||
isRefetching: false,
|
||||
@@ -131,16 +130,15 @@ describe('DependentServices', () => {
|
||||
refetch: refetchFn,
|
||||
};
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
render(
|
||||
<DependentServices
|
||||
dependentServicesQuery={mockQuery as any}
|
||||
timeRange={mockTimeRange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dependent-services-table')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('loading-state')).toHaveTextContent('Loading');
|
||||
});
|
||||
|
||||
it('renders error state correctly', () => {
|
||||
@@ -225,8 +223,7 @@ describe('DependentServices', () => {
|
||||
expect(screen.getByTestId('page-size')).toHaveTextContent('5');
|
||||
});
|
||||
|
||||
it('handles refetching state correctly', () => {
|
||||
// Arrange
|
||||
it('shows the rows under the spinner while refetching', () => {
|
||||
const mockQuery: MockQueryResult = {
|
||||
isLoading: false,
|
||||
isRefetching: true,
|
||||
@@ -235,16 +232,16 @@ describe('DependentServices', () => {
|
||||
refetch: refetchFn,
|
||||
};
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
render(
|
||||
<DependentServices
|
||||
dependentServicesQuery={mockQuery as any}
|
||||
timeRange={mockTimeRange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('loading-state')).toHaveTextContent('Loading');
|
||||
expect(screen.getByTestId('row-count')).toHaveTextContent('2');
|
||||
expect(screen.getByTestId('table-row-0')).toHaveTextContent('auth-service');
|
||||
});
|
||||
|
||||
it('handles row click correctly', () => {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
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}"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useQueries } from 'react-query';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
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 {
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
|
||||
extractPortAndEndpoint,
|
||||
@@ -11,14 +13,13 @@ import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
TagFilter,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { getCurrentNuqsSearchParams, resetNuqsState } from 'tests/nuqs-helpers';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
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', () => ({
|
||||
@@ -26,72 +27,32 @@ 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: [
|
||||
'endPointMetricsData',
|
||||
'endPointStatusCodeData',
|
||||
'endPointDropDownData',
|
||||
'endPointDependentServicesData',
|
||||
'endPointStatusCodeBarChartsData',
|
||||
'endPointStatusCodeLatencyBarChartsData',
|
||||
],
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY: jest.requireActual(
|
||||
'container/ApiMonitoring/utils',
|
||||
).END_POINT_DETAILS_QUERY_KEYS_ARRAY,
|
||||
extractPortAndEndpoint: jest.fn(),
|
||||
getHttpUrlExpression: jest.requireActual('container/ApiMonitoring/utils')
|
||||
.getHttpUrlExpression,
|
||||
getEndPointDetailsQueryPayload: jest.fn(),
|
||||
getLatencyOverTimeWidgetData: jest.fn(),
|
||||
getRateOverTimeWidgetData: jest.fn(),
|
||||
}));
|
||||
|
||||
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>
|
||||
)),
|
||||
}),
|
||||
);
|
||||
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>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock all child components to simplify testing
|
||||
jest.mock(
|
||||
@@ -107,15 +68,15 @@ jest.mock(
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'../Explorer/Domains/DomainDetails/components/EndPointsDropDown',
|
||||
'../Explorer/Domains/DomainDetails/components/EndPointPicker',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(({ setSelectedEndPointName }) => (
|
||||
default: jest.fn().mockImplementation(({ onChange }) => (
|
||||
<div data-testid="endpoints-dropdown">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="select-endpoint-button"
|
||||
onClick={(): void => setSelectedEndPointName('/api/new-endpoint')}
|
||||
onClick={(): void => onChange('/api/new-endpoint')}
|
||||
>
|
||||
Select Endpoint
|
||||
</button>
|
||||
@@ -172,8 +133,23 @@ 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(6).fill({
|
||||
const mockQueryResults = Array(5).fill({
|
||||
data: { data: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -184,7 +160,6 @@ describe('EndPointDetails Component', () => {
|
||||
domainName: 'test-domain',
|
||||
endPointName: '/api/test',
|
||||
setSelectedEndPointName: jest.fn(),
|
||||
initialFilters: { items: [], op: 'AND' } as TagFilter,
|
||||
timeRange: {
|
||||
startTime: 1609459200000,
|
||||
endTime: 1609545600000,
|
||||
@@ -197,6 +172,7 @@ describe('EndPointDetails Component', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
resetNuqsState();
|
||||
|
||||
(extractPortAndEndpoint as jest.Mock).mockReturnValue({
|
||||
port: '8080',
|
||||
@@ -209,7 +185,6 @@ 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({
|
||||
@@ -226,7 +201,7 @@ describe('EndPointDetails Component', () => {
|
||||
});
|
||||
|
||||
it('renders the component correctly', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
// Check all major components are rendered
|
||||
expect(screen.getByTestId('query-builder-search')).toBeInTheDocument();
|
||||
@@ -246,44 +221,29 @@ describe('EndPointDetails Component', () => {
|
||||
});
|
||||
|
||||
it('calls getEndPointDetailsQueryPayload with correct parameters', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
expect(getEndPointDetailsQueryPayload).toHaveBeenCalledWith(
|
||||
'test-domain',
|
||||
mockProps.timeRange.startTime,
|
||||
mockProps.timeRange.endTime,
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
|
||||
value: '/api/test',
|
||||
}),
|
||||
]),
|
||||
op: 'AND',
|
||||
}),
|
||||
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('adds endpoint filter to initial filters', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
expect(getEndPointDetailsQueryPayload).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
|
||||
value: '/api/test',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
|
||||
);
|
||||
});
|
||||
|
||||
it('updates filters when QueryBuilderSearch changes', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
it('updates filters when the search expression is run', async () => {
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
// Trigger filter change
|
||||
fireEvent.click(screen.getByTestId('filter-change-button'));
|
||||
@@ -294,19 +254,37 @@ 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({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'test.key' }),
|
||||
value: 'test-value',
|
||||
}),
|
||||
]),
|
||||
queryData: expect.objectContaining({
|
||||
filter: { expression: "service.name = 'checkout'" },
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(EndPointPicker).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
filterExpression: "service.name = 'checkout'",
|
||||
value: '/api/test',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles endpoint dropdown selection', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
// Trigger endpoint selection
|
||||
fireEvent.click(screen.getByTestId('select-endpoint-button'));
|
||||
@@ -318,63 +296,50 @@ describe('EndPointDetails Component', () => {
|
||||
});
|
||||
|
||||
it('does not display dependent services when service filter is applied', () => {
|
||||
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} />);
|
||||
renderStats(
|
||||
<EndPointDetails {...mockProps} />,
|
||||
"service.name = 'test-service'",
|
||||
);
|
||||
|
||||
// Dependent services should not be displayed
|
||||
expect(screen.queryByTestId('dependent-services')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes the correct parameters to widget data generators', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
expect(getRateOverTimeWidgetData).toHaveBeenCalledWith(
|
||||
'test-domain',
|
||||
'/api/test',
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
|
||||
value: '/api/test',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
|
||||
);
|
||||
|
||||
expect(getLatencyOverTimeWidgetData).toHaveBeenCalledWith(
|
||||
'test-domain',
|
||||
'/api/test',
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
|
||||
value: '/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,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('generates correct query parameters for useQueries', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
// Check if useQueries was called with correct parameters
|
||||
expect(useQueries).toHaveBeenCalledWith(
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,210 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* V5 Migration Tests for Endpoint Dropdown Query
|
||||
*
|
||||
* These tests validate the migration from V4 to V5 format for the third payload
|
||||
* in getEndPointDetailsQueryPayload (endpoint dropdown data):
|
||||
* These tests validate the migration from V4 to V5 format for
|
||||
* getEndPointDropDownQueryPayload (endpoint picker options):
|
||||
* - Filter format change: filters.items[] → filter.expression
|
||||
* - Domain handling: http_host = '${domainName}'
|
||||
* - Kind filter: kind_string = 'Client'
|
||||
@@ -10,9 +10,8 @@
|
||||
* - Aggregation: count() expression
|
||||
* - GroupBy: http_url with type 'attribute'
|
||||
*/
|
||||
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
|
||||
import { getEndPointDropDownQueryPayload } 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';
|
||||
|
||||
@@ -20,22 +19,16 @@ describe('EndpointDropdown - V5 Migration Validation', () => {
|
||||
const mockDomainName = 'api.example.com';
|
||||
const mockStartTime = 1000;
|
||||
const mockEndTime = 2000;
|
||||
const emptyFilters: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
const emptyFilters = '';
|
||||
|
||||
describe('1. V5 Format Migration - Structure and Base Filters', () => {
|
||||
it('migrates to V5 format with correct filter expression structure, aggregations, and groupBy', () => {
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
const dropdownQuery = getEndPointDropDownQueryPayload(
|
||||
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)
|
||||
@@ -77,46 +70,21 @@ describe('EndpointDropdown - V5 Migration Validation', () => {
|
||||
|
||||
describe('2. Custom Filters Integration', () => {
|
||||
it('merges custom filters into filter expression with AND logic', () => {
|
||||
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 customFilters =
|
||||
"service.name = 'user-service' AND deployment.environment = 'production'";
|
||||
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
const dropdownQuery = getEndPointDropDownQueryPayload(
|
||||
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 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 AND (service.name = 'user-service' AND deployment.environment = 'production')`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,11 @@ 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: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
const emptyFilters = '';
|
||||
|
||||
describe('1. Rate Over Time - V5 Payload Structure', () => {
|
||||
it('generates V5 filter expression format (not V3 filters.items)', () => {
|
||||
@@ -51,31 +46,8 @@ describe('MetricOverTime - V5 Migration Validation', () => {
|
||||
});
|
||||
|
||||
it('merges custom filters into filter expression', () => {
|
||||
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 customFilters =
|
||||
"service.name = 'user-service' AND deployment.environment = 'production'";
|
||||
|
||||
const widget = getRateOverTimeWidgetData(
|
||||
mockDomainName,
|
||||
@@ -137,21 +109,8 @@ describe('MetricOverTime - V5 Migration Validation', () => {
|
||||
});
|
||||
|
||||
it('merges custom filters into filter expression', () => {
|
||||
const customFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'user-service',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
const customFilters =
|
||||
"service.name = 'user-service' AND deployment.environment = 'production'";
|
||||
|
||||
const widget = getLatencyOverTimeWidgetData(
|
||||
mockDomainName,
|
||||
@@ -163,7 +122,7 @@ describe('MetricOverTime - V5 Migration Validation', () => {
|
||||
|
||||
// Verify domain filter is present
|
||||
expect(queryData?.filter?.expression).toContain(
|
||||
`http_host = '${mockDomainName}' service.name = 'user-service'`,
|
||||
`http_host = '${mockDomainName}' AND (service.name = 'user-service'`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ 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';
|
||||
@@ -156,7 +155,6 @@ jest.mock('antd', () => {
|
||||
|
||||
describe('StatusCodeBarCharts', () => {
|
||||
// Default props for tests
|
||||
const mockFilters: IBuilderQuery['filters'] = { items: [], op: 'AND' };
|
||||
const mockTimeRange = {
|
||||
startTime: 1609459200000,
|
||||
endTime: 1609545600000,
|
||||
@@ -251,7 +249,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filters={mockFilters}
|
||||
filterExpression=""
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -286,7 +284,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filters={mockFilters}
|
||||
filterExpression=""
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -328,7 +326,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filters={mockFilters}
|
||||
filterExpression=""
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -372,7 +370,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filters={mockFilters}
|
||||
filterExpression=""
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -419,7 +417,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filters={mockFilters}
|
||||
filterExpression=""
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -433,26 +431,13 @@ 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 mockCustomFilters = {
|
||||
items: [
|
||||
{
|
||||
id: 'custom-filter',
|
||||
key: { key: 'test-key' },
|
||||
op: '=',
|
||||
value: 'test-value',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
const mockCustomFilterExpression = "test-key = 'test-value'";
|
||||
|
||||
const mockData = {
|
||||
payload: mockFormattedData,
|
||||
@@ -480,7 +465,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filters={mockCustomFilters as IBuilderQuery['filters']}
|
||||
filterExpression={mockCustomFilterExpression}
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -489,12 +474,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
// Assert widget creation was called with the correct parameters
|
||||
expect(getStatusCodeBarChartWidgetData).toHaveBeenCalledWith(
|
||||
mockDomainName,
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'custom-filter' }),
|
||||
]),
|
||||
op: 'AND',
|
||||
}),
|
||||
mockCustomFilterExpression,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (5th and 6th payloads):
|
||||
* in getEndPointDetailsQueryPayload (4th and 5th payloads):
|
||||
* - Number of Calls Chart (count aggregation)
|
||||
* - Latency Chart (p99 aggregation)
|
||||
*
|
||||
@@ -15,16 +15,12 @@
|
||||
*/
|
||||
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: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
const emptyFilters = '';
|
||||
|
||||
describe('1. Number of Calls Chart - V5 Payload Structure', () => {
|
||||
it('generates correct V5 payload for count aggregation grouped by status code', () => {
|
||||
@@ -35,8 +31,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
// 5th payload (index 4) is the number of calls bar chart
|
||||
const callsChartQuery = payload[4];
|
||||
// 4th payload (index 3) is the number of calls bar chart
|
||||
const callsChartQuery = payload[3];
|
||||
const queryA = callsChartQuery.query.builder.queryData[0];
|
||||
|
||||
// V5 format: filter.expression (not filters.items)
|
||||
@@ -84,8 +80,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
// 6th payload (index 5) is the latency bar chart
|
||||
const latencyChartQuery = payload[5];
|
||||
// 5th payload (index 4) is the latency bar chart
|
||||
const latencyChartQuery = payload[4];
|
||||
const queryA = latencyChartQuery.query.builder.queryData[0];
|
||||
|
||||
// V5 format: filter.expression (not filters.items)
|
||||
@@ -130,31 +126,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
|
||||
|
||||
describe('3. Custom Filters Integration', () => {
|
||||
it('merges custom filters into filter expression for both charts', () => {
|
||||
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 customFilters =
|
||||
"service.name = 'user-service' AND deployment.environment = 'production'";
|
||||
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
@@ -163,8 +136,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
|
||||
customFilters,
|
||||
);
|
||||
|
||||
const callsChartQuery = payload[4];
|
||||
const latencyChartQuery = payload[5];
|
||||
const callsChartQuery = payload[3];
|
||||
const latencyChartQuery = payload[4];
|
||||
|
||||
const callsExpression =
|
||||
callsChartQuery.query.builder.queryData[0].filter?.expression;
|
||||
|
||||
@@ -13,16 +13,12 @@
|
||||
*/
|
||||
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: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
const emptyFilters = '';
|
||||
|
||||
describe('1. V5 Format Migration with Base Filters', () => {
|
||||
it('migrates to V5 format with correct filter expression structure and base filters', () => {
|
||||
@@ -109,31 +105,8 @@ describe('StatusCodeTable - V5 Migration Validation', () => {
|
||||
|
||||
describe('3. Custom Filters Integration', () => {
|
||||
it('merges custom filters into filter expression with AND logic', () => {
|
||||
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 customFilters =
|
||||
"service.name = 'user-service' AND deployment.environment = 'production'";
|
||||
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
|
||||
@@ -1,31 +1,40 @@
|
||||
import { BuilderQuery } from 'api/v5/v5';
|
||||
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { fireEvent, render, screen, waitFor, within } from 'tests/test-utils';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
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';
|
||||
|
||||
// Mock the EndPointsDropDown component to avoid issues
|
||||
interface QueryRangeRequestBody {
|
||||
compositeQuery: { queries: { spec: { filter: { expression: string } } }[] };
|
||||
}
|
||||
|
||||
// Mock the EndPointPicker component to avoid issues
|
||||
jest.mock(
|
||||
'../Explorer/Domains/DomainDetails/components/EndPointsDropDown',
|
||||
'../Explorer/Domains/DomainDetails/components/EndPointPicker',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
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>
|
||||
)),
|
||||
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>
|
||||
)),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -43,10 +52,7 @@ describe('TopErrors', () => {
|
||||
startTime: 1000000000,
|
||||
endTime: 1000010000,
|
||||
},
|
||||
initialFilters: {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
},
|
||||
initialFilterExpression: '',
|
||||
};
|
||||
|
||||
// Helper function to wait for table data to load
|
||||
@@ -227,22 +233,30 @@ describe('TopErrors', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updates endpoint filter when dropdown value changes', async () => {
|
||||
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);
|
||||
}),
|
||||
);
|
||||
|
||||
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(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
expect(expressions[expressions.length - 1]).toContain(
|
||||
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/new-endpoint'`,
|
||||
);
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
@@ -306,7 +320,7 @@ describe('TopErrors', () => {
|
||||
'test-domain',
|
||||
mockProps.timeRange.startTime,
|
||||
mockProps.timeRange.endTime,
|
||||
{ items: [], op: 'AND' },
|
||||
'',
|
||||
false,
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ApiMonitoringParams,
|
||||
DEFAULT_PARAMS,
|
||||
getApiMonitoringParams,
|
||||
getLegacyFilterExpressions,
|
||||
setApiMonitoringParams,
|
||||
} from 'container/ApiMonitoring/queryParams';
|
||||
import { mockNavigate } from 'tests/router-mocks';
|
||||
@@ -52,6 +53,50 @@ 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', () => {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
5
frontend/src/container/ApiMonitoring/filterParamKeys.ts
Normal file
5
frontend/src/container/ApiMonitoring/filterParamKeys.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// 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';
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
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';
|
||||
|
||||
// --- Types for all API Monitoring query params ---
|
||||
export interface ApiMonitoringParams {
|
||||
@@ -9,8 +13,6 @@ 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
|
||||
@@ -22,8 +24,6 @@ export const DEFAULT_PARAMS: ApiMonitoringParams = {
|
||||
selectedView: 'all_endpoints',
|
||||
selectedEndPointName: '',
|
||||
groupBy: [],
|
||||
allEndpointsLocalFilters: undefined,
|
||||
endPointDetailsLocalFilters: undefined,
|
||||
modalTimeRange: undefined,
|
||||
selectedInterval: undefined,
|
||||
};
|
||||
@@ -35,15 +35,50 @@ function encodeParams(params: ApiMonitoringParams): string {
|
||||
return encodeURIComponent(JSON.stringify(params));
|
||||
}
|
||||
|
||||
function decodeParams(value: string | null): ApiMonitoringParams {
|
||||
// Older shared links carry the filters as TagFilter items.
|
||||
type LegacyApiMonitoringParams = ApiMonitoringParams & {
|
||||
allEndpointsLocalFilters?: TagFilter;
|
||||
endPointDetailsLocalFilters?: TagFilter;
|
||||
};
|
||||
|
||||
function parseParams(value: string | null): LegacyApiMonitoringParams | null {
|
||||
if (!value) {
|
||||
return DEFAULT_PARAMS;
|
||||
return null;
|
||||
}
|
||||
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 ---
|
||||
@@ -74,11 +109,18 @@ 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, location.search, replace);
|
||||
setApiMonitoringParams(
|
||||
newParams,
|
||||
`?${getUnstableCurrentSearchParams().toString()}`,
|
||||
replace,
|
||||
);
|
||||
},
|
||||
[location.search],
|
||||
[],
|
||||
);
|
||||
|
||||
return [params, setParams];
|
||||
|
||||
@@ -3,7 +3,8 @@ 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 { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
FiltersType,
|
||||
IQuickFiltersConfig,
|
||||
@@ -56,30 +57,28 @@ export const getDisplayValue = (value: unknown): string =>
|
||||
isEmptyFilterValue(value) ? '-' : String(value);
|
||||
|
||||
export const getDomainNameFilterExpression = (domainName: string): string =>
|
||||
`http_host = '${domainName}'`;
|
||||
`${SPAN_ATTRIBUTES.SERVER_NAME} = ${formatValueForExpression(domainName)}`;
|
||||
|
||||
export const clientKindExpression = `kind_string = 'Client'`;
|
||||
export const clientKindExpression = "kind_string = 'Client'";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
export const getHttpUrlExpression = (endPointName: string): string =>
|
||||
endPointName
|
||||
? `${SPAN_ATTRIBUTES.HTTP_URL} = ${formatValueForExpression(endPointName, '=')}`
|
||||
: '';
|
||||
|
||||
const { filter } = convertFiltersToExpressionWithExistingQuery(
|
||||
filters,
|
||||
baseExpression,
|
||||
);
|
||||
return filter.expression;
|
||||
};
|
||||
// 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 ');
|
||||
|
||||
export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
|
||||
{
|
||||
@@ -347,7 +346,7 @@ export const getDomainMetricsQueryPayload = (
|
||||
domainName: string,
|
||||
start: number,
|
||||
end: number,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
): GetQueryResultsProps[] => [
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
@@ -368,9 +367,9 @@ export const getDomainMetricsQueryPayload = (
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(domainName)} AND ${urlExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'A',
|
||||
@@ -396,9 +395,9 @@ export const getDomainMetricsQueryPayload = (
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(domainName)}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'B',
|
||||
@@ -424,9 +423,9 @@ export const getDomainMetricsQueryPayload = (
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(domainName)} AND has_error = true`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'C',
|
||||
@@ -452,9 +451,9 @@ export const getDomainMetricsQueryPayload = (
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(domainName)}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'D',
|
||||
@@ -824,7 +823,7 @@ export const getEndPointsQueryPayload = (
|
||||
|
||||
function buildFilterExpression(
|
||||
domainName: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
showStatusCodeErrors: boolean,
|
||||
): string {
|
||||
const baseFilterParts = [
|
||||
@@ -836,21 +835,22 @@ function buildFilterExpression(
|
||||
if (showStatusCodeErrors) {
|
||||
baseFilterParts.push('status_message EXISTS');
|
||||
}
|
||||
const filterExpression = baseFilterParts.join(' AND ');
|
||||
|
||||
return convertFiltersWithUrlHandling(filters, filterExpression);
|
||||
return combineInitialAndUserExpression(
|
||||
baseFilterParts.join(' AND '),
|
||||
filterExpression,
|
||||
);
|
||||
}
|
||||
|
||||
export const getTopErrorsQueryPayload = (
|
||||
domainName: string,
|
||||
start: number,
|
||||
end: number,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
showStatusCodeErrors = true,
|
||||
): QueryRangePayloadV5 => {
|
||||
const filterExpression = buildFilterExpression(
|
||||
const combinedExpression = buildFilterExpression(
|
||||
domainName,
|
||||
filters,
|
||||
filterExpression,
|
||||
showStatusCodeErrors,
|
||||
);
|
||||
|
||||
@@ -869,7 +869,7 @@ export const getTopErrorsQueryPayload = (
|
||||
stepInterval: 60,
|
||||
disabled: false,
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
filter: { expression: filterExpression },
|
||||
filter: { expression: combinedExpression },
|
||||
groupBy: [
|
||||
{
|
||||
name: SPAN_ATTRIBUTES.HTTP_URL,
|
||||
@@ -1367,15 +1367,14 @@ export const createFiltersForSelectedRowData = (
|
||||
|
||||
// First query payload for endpoint metrics
|
||||
// Second query payload for endpoint status code table
|
||||
// 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
|
||||
// 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
|
||||
export const getEndPointDetailsQueryPayload = (
|
||||
domainName: string,
|
||||
start: number,
|
||||
end: number,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
): GetQueryResultsProps[] => [
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
@@ -1394,11 +1393,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1424,11 +1423,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'B',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1454,11 +1453,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: true,
|
||||
expression: 'C',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND has_error = true`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1484,11 +1483,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'D',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1514,11 +1513,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: true,
|
||||
expression: 'E',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1586,11 +1585,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1622,11 +1621,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'B',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1660,11 +1659,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'C',
|
||||
@@ -1713,77 +1712,6 @@ 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,
|
||||
@@ -1801,11 +1729,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1838,11 +1766,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'B',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1875,11 +1803,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'C',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1912,11 +1840,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: true,
|
||||
expression: 'D',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND has_error = true`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1991,11 +1919,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -2062,11 +1990,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -2118,100 +2046,82 @@ export const getEndPointDetailsQueryPayload = (
|
||||
},
|
||||
];
|
||||
|
||||
export const getEndPointZeroStateQueryPayload = (
|
||||
export const getEndPointDropDownQueryPayload = (
|
||||
domainName: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): GetQueryResultsProps[] => [
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TABLE,
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.String,
|
||||
key: '',
|
||||
type: '',
|
||||
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: [],
|
||||
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,
|
||||
],
|
||||
timeAggregation: 'count',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [
|
||||
{
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: null,
|
||||
having: [],
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
groupBy: [
|
||||
{
|
||||
key: SPAN_ATTRIBUTES.HTTP_URL,
|
||||
dataType: DataTypes.String,
|
||||
type: 'attribute',
|
||||
},
|
||||
],
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
],
|
||||
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
|
||||
promql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
variables: {},
|
||||
formatForWeb: true,
|
||||
start,
|
||||
end,
|
||||
step: 60,
|
||||
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,
|
||||
});
|
||||
|
||||
interface EndPointMetricsResponseRow {
|
||||
data: {
|
||||
@@ -2369,8 +2279,8 @@ export const statusCodeWidgetInfo = [
|
||||
|
||||
interface EndPointDropDownResponseRow {
|
||||
data: {
|
||||
[SPAN_ATTRIBUTES.HTTP_URL]: string;
|
||||
A: number;
|
||||
[SPAN_ATTRIBUTES.HTTP_URL]?: string;
|
||||
A?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2712,7 +2622,7 @@ export const groupStatusCodes = (
|
||||
|
||||
export const getStatusCodeBarChartWidgetData = (
|
||||
domainName: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
): Widgets => ({
|
||||
query: {
|
||||
builder: {
|
||||
@@ -2728,22 +2638,13 @@ export const getStatusCodeBarChartWidgetData = (
|
||||
dataSource: DataSource.TRACES,
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
id: 'c6724407',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: domainName,
|
||||
},
|
||||
...(filters?.items || []),
|
||||
],
|
||||
op: 'AND',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
getDomainNameFilterExpression(domainName),
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
@@ -2824,18 +2725,15 @@ 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,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): Widgets => {
|
||||
const isGroupedByAttribute = groupBy.length > 0;
|
||||
@@ -2857,11 +2755,11 @@ export const getAllEndpointsWidgetData = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters,
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND http_url EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -2889,11 +2787,11 @@ export const getAllEndpointsWidgetData = (
|
||||
disabled: false,
|
||||
expression: 'B',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters,
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND http_url EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -2921,11 +2819,11 @@ export const getAllEndpointsWidgetData = (
|
||||
disabled: false,
|
||||
expression: 'C',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters,
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND http_url EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -2953,11 +2851,11 @@ export const getAllEndpointsWidgetData = (
|
||||
disabled: true,
|
||||
expression: 'D',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters,
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND has_error = true AND http_url EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -3075,38 +2973,26 @@ export const getAllEndpointsWidgetData = (
|
||||
return widget;
|
||||
};
|
||||
|
||||
const keysToRemove = [SPAN_ATTRIBUTES.HTTP_URL, 'A', 'B', 'C', 'F1'];
|
||||
const keysToRemove = [SPAN_ATTRIBUTES.HTTP_URL, 'key', 'A', 'B', 'C', 'F1'];
|
||||
|
||||
export const getGroupByFiltersFromGroupByValues = (
|
||||
rowData: any,
|
||||
groupBy: BaseAutocompleteData[],
|
||||
): IBuilderQuery['filters'] => {
|
||||
const items = Object.keys(rowData)
|
||||
// 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)
|
||||
.filter((key) => !keysToRemove.includes(key))
|
||||
.map((key) => {
|
||||
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',
|
||||
};
|
||||
};
|
||||
const value = rowData[key];
|
||||
return value === null || value === undefined
|
||||
? `${key} NOT EXISTS`
|
||||
: `${key} = ${formatValueForExpression(value)}`;
|
||||
})
|
||||
.join(' AND ');
|
||||
|
||||
export const getRateOverTimeWidgetData = (
|
||||
domainName: string,
|
||||
endPointName: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
): Widgets => {
|
||||
let legend = domainName;
|
||||
if (endPointName) {
|
||||
@@ -3130,9 +3016,9 @@ export const getRateOverTimeWidgetData = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`http_host = '${domainName}'`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -3156,7 +3042,7 @@ export const getRateOverTimeWidgetData = (
|
||||
export const getLatencyOverTimeWidgetData = (
|
||||
domainName: string,
|
||||
endPointName: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
): Widgets => {
|
||||
let legend = domainName;
|
||||
if (endPointName) {
|
||||
@@ -3180,9 +3066,9 @@ export const getLatencyOverTimeWidgetData = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`http_host = '${domainName}'`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
|
||||
@@ -174,7 +174,6 @@ function QuerySection(): JSX.Element {
|
||||
runQuery={runQueryHandler}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
alertDef={alertDef}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
isAnomalyDetection={isAnomalyDetection}
|
||||
key={currentQuery.queryType}
|
||||
|
||||
@@ -23,20 +23,11 @@ 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?: FlattenUnion<AlertmanagertypesChannelJiraAuthDTO>;
|
||||
auth?: AlertmanagertypesChannelJiraAuthDTO;
|
||||
};
|
||||
|
||||
/** Merged so switching kind keeps the fields the kinds share. */
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryPromQLData,
|
||||
@@ -28,7 +27,6 @@ const defaultAnnotations = {
|
||||
|
||||
export const alertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V5,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
builderQueries: {
|
||||
@@ -62,7 +60,6 @@ export const alertDefaults: AlertDef = {
|
||||
|
||||
export const anamolyAlertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V5,
|
||||
ruleType: RuletypesRuleTypeDTO.anomaly_rule,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
@@ -107,7 +104,6 @@ export const anamolyAlertDefaults: AlertDef = {
|
||||
|
||||
export const logAlertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.LOGS_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V5,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
builderQueries: {
|
||||
@@ -139,7 +135,6 @@ export const logAlertDefaults: AlertDef = {
|
||||
|
||||
export const traceAlertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.TRACES_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V5,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
builderQueries: {
|
||||
@@ -171,7 +166,6 @@ export const traceAlertDefaults: AlertDef = {
|
||||
|
||||
export const exceptionAlertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.EXCEPTIONS_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V5,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
builderQueries: {
|
||||
|
||||
@@ -176,7 +176,6 @@ function ChartPreview({
|
||||
},
|
||||
originalGraphType: graphType,
|
||||
},
|
||||
// alertDef?.version || DEFAULT_ENTITY_VERSION,
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey: [
|
||||
|
||||
@@ -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_V4 } from 'constants/app';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { QBShortcuts } from 'constants/shortcuts/QBShortcuts';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
@@ -15,7 +15,6 @@ 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';
|
||||
@@ -37,7 +36,6 @@ function QuerySection({
|
||||
runQuery,
|
||||
isLoadingQueries,
|
||||
handleCancelQuery,
|
||||
alertDef,
|
||||
panelType,
|
||||
ruleId,
|
||||
isAnomalyDetection,
|
||||
@@ -71,12 +69,8 @@ function QuerySection({
|
||||
signalSource: signalSource === 'meter' ? 'meter' : '',
|
||||
}}
|
||||
showTraceOperator={alertType === AlertTypes.TRACES_BASED_ALERT}
|
||||
showFunctions={
|
||||
(alertType === AlertTypes.METRICS_BASED_ALERT &&
|
||||
alertDef.version === ENTITY_VERSION_V4) ||
|
||||
alertType === AlertTypes.LOGS_BASED_ALERT
|
||||
}
|
||||
version={alertDef.version || 'v3'}
|
||||
showFunctions={alertType === AlertTypes.LOGS_BASED_ALERT}
|
||||
version={ENTITY_VERSION_V5}
|
||||
onSignalSourceChange={handleSignalSourceChange}
|
||||
signalSourceChangeEnabled
|
||||
/>
|
||||
@@ -301,7 +295,6 @@ interface QuerySectionProps {
|
||||
runQuery: VoidFunction;
|
||||
isLoadingQueries: boolean;
|
||||
handleCancelQuery: () => void;
|
||||
alertDef: AlertDef;
|
||||
panelType: PANEL_TYPES;
|
||||
ruleId: string;
|
||||
isAnomalyDetection?: boolean;
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
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;
|
||||
|
||||
@@ -23,18 +21,7 @@ interface HomeAlertRules {
|
||||
|
||||
export function useHomeAlertRules(): HomeAlertRules {
|
||||
const firing = useListRulesV3(FIRING_RULES_PARAMS, {
|
||||
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,
|
||||
}),
|
||||
},
|
||||
query: { cacheTime: 0 },
|
||||
});
|
||||
const firingRules = firing.data?.data.rules;
|
||||
const latest = useListRulesV3(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { QuickFiltersLayoutProps } from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -14,6 +15,19 @@ import { act, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import Explorer from '../Explorer';
|
||||
|
||||
jest.mock(
|
||||
'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: QuickFiltersLayoutProps): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
jest.mock('../QuerySection/QuerySection', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
jest.mock('../ListView/ListView', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="ai-list-view" />,
|
||||
@@ -74,9 +88,18 @@ describe('AI observability Explorer', () => {
|
||||
)}`,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('ai-trace-view')).toBeInTheDocument();
|
||||
expect(
|
||||
JSON.parse(urlParam(history, URL_OPTIONS) ?? '').selectColumns,
|
||||
).toStrictEqual(traceColumns);
|
||||
|
||||
await user.click(screen.getByTestId('search-view'));
|
||||
|
||||
expect(screen.getByTestId('ai-list-view')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('ai-trace-view')).not.toBeInTheDocument();
|
||||
expect(JSON.parse(urlParam(history, QueryParams.panelTypes) ?? '')).toBe(
|
||||
PANEL_TYPES.LIST,
|
||||
);
|
||||
expect(JSON.parse(urlParam(history, URL_OPTIONS) ?? '')).toStrictEqual({
|
||||
...defaultOptionsQuery,
|
||||
selectColumns: [],
|
||||
|
||||
@@ -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(--l2-background);
|
||||
--tanstack-table-cell-bg: var(--l1-background);
|
||||
--tanstack-table-cell-color: var(--l2-foreground);
|
||||
--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-row-hover-bg: var(--l1-background-hover);
|
||||
--tanstack-table-row-active-bg: var(--l1-background-active);
|
||||
--tanstack-table-resize-handle-bg: transparent;
|
||||
--tanstack-table-resize-handle-hover-bg: var(--l2-border);
|
||||
--tanstack-table-row-height: 42px;
|
||||
|
||||
@@ -48,16 +48,6 @@
|
||||
--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;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ 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'";
|
||||
|
||||
|
||||
@@ -240,7 +240,6 @@ function ListAlertRules(): JSX.Element {
|
||||
showTotalCount: true,
|
||||
}}
|
||||
paginationClassname={styles.paginationContainer}
|
||||
enableAlternatingRowColors
|
||||
plainTextCellLineClamp={2}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -107,9 +107,7 @@ export function getAlertRuleColumns(
|
||||
return <TanStackTable.Text>-</TanStackTable.Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<LabelColumn labels={tagKeys} value={labels} color="highlight-danger" />
|
||||
);
|
||||
return <LabelColumn labels={tagKeys} value={labels} color="secondary" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,13 +3,11 @@ 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';
|
||||
@@ -35,18 +33,7 @@ export function useAlertRulesData(
|
||||
const hasLoggedEvent = useRef(false);
|
||||
|
||||
const rulesResponse = useListRulesV3(params, {
|
||||
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,
|
||||
}),
|
||||
},
|
||||
query: { keepPreviousData: true },
|
||||
});
|
||||
|
||||
const rules = useMemo(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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';
|
||||
@@ -15,11 +17,7 @@ import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Tags } from 'hooks/useResourceAttribute/types';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
@@ -126,7 +124,7 @@ const generateAPIMonitoringPath = (
|
||||
domainName: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
): string => {
|
||||
const basePath = ROUTES.API_MONITORING;
|
||||
return `${basePath}?${createQueryParams({
|
||||
@@ -138,8 +136,8 @@ const generateAPIMonitoringPath = (
|
||||
endTime,
|
||||
},
|
||||
selectedInterval: 'custom',
|
||||
endPointDetailsLocalFilters: filters,
|
||||
}),
|
||||
[ENDPOINT_STATS_EXPRESSION_KEY]: filterExpression,
|
||||
})}`;
|
||||
};
|
||||
export function onViewAPIMonitoringPopupClick({
|
||||
@@ -153,41 +151,15 @@ export function onViewAPIMonitoringPopupClick({
|
||||
return (e?: React.MouseEvent): void => {
|
||||
const endTime = timestamp + (stepInterval || 60);
|
||||
const startTime = timestamp - (stepInterval || 60);
|
||||
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 filterExpression = [
|
||||
...(isError ? ["hasError IN ['true']"] : []),
|
||||
`service.name = ${formatValueForExpression(servicename)}`,
|
||||
].join(' AND ');
|
||||
const newPath = generateAPIMonitoringPath(
|
||||
domainName,
|
||||
startTime,
|
||||
endTime,
|
||||
filters,
|
||||
filterExpression,
|
||||
);
|
||||
|
||||
safeNavigate(newPath, { newTab: !!e && isModifierKeyPressed(e) });
|
||||
|
||||
@@ -153,6 +153,7 @@ 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';
|
||||
@@ -2579,7 +2580,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-ec2-metrics-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: ec2Url,
|
||||
link: '/integrations?integration=aws-integration&service=ec2',
|
||||
link: '/integrations/aws?service=ec2',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -2637,7 +2638,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-ecs-ec2-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: ecsUrl,
|
||||
link: '/integrations?integration=aws-integration&service=ecs',
|
||||
link: '/integrations/aws?service=ecs',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -2699,7 +2700,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-eks-ec2-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: eksUrl,
|
||||
link: '/integrations?integration=aws-integration&service=eks',
|
||||
link: '/integrations/aws?service=eks',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -2823,21 +2824,21 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-rds-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: rdsUrl,
|
||||
link: '/integrations?integration=aws-integration&service=rds',
|
||||
link: '/integrations/aws?service=rds',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
key: 'aws-rds-mysql-manual',
|
||||
label: 'MySQL Manual',
|
||||
imgUrl: rdsUrl,
|
||||
link: '/integrations?integration=builtin-aws_rds_mysql',
|
||||
link: '/integrations/builtin-aws_rds_mysql',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
key: 'aws-rds-postgresql-manual',
|
||||
label: 'PostgreSQL Manual',
|
||||
imgUrl: rdsUrl,
|
||||
link: '/integrations?integration=builtin-aws_rds_postgresql',
|
||||
link: '/integrations/builtin-aws_rds_postgresql',
|
||||
internalRedirect: true,
|
||||
},
|
||||
],
|
||||
@@ -2896,7 +2897,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-lambda-logs-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: lambdaUrl,
|
||||
link: '/integrations?integration=aws-integration&service=lambda',
|
||||
link: '/integrations/aws?service=lambda',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -2954,7 +2955,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-lambda-metrics-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: lambdaUrl,
|
||||
link: '/integrations?integration=aws-integration&service=lambda',
|
||||
link: '/integrations/aws?service=lambda',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -4710,6 +4711,7 @@ const onboardingConfigWithLinks = [
|
||||
'messaging',
|
||||
'messaging queues',
|
||||
'messaging-queues-celery',
|
||||
'python celery',
|
||||
'queues',
|
||||
'task queue tracing',
|
||||
],
|
||||
@@ -4930,7 +4932,7 @@ const onboardingConfigWithLinks = [
|
||||
'redis metrics',
|
||||
],
|
||||
imgUrl: redisUrl,
|
||||
link: '/integrations?integration=builtin-redis',
|
||||
link: '/integrations/builtin-redis',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -4946,7 +4948,7 @@ const onboardingConfigWithLinks = [
|
||||
'postgresql metrics',
|
||||
],
|
||||
imgUrl: postgresqlUrl,
|
||||
link: '/integrations?integration=builtin-postgres',
|
||||
link: '/integrations/builtin-postgres',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -4962,7 +4964,7 @@ const onboardingConfigWithLinks = [
|
||||
'proxy',
|
||||
],
|
||||
imgUrl: nginxUrl,
|
||||
link: '/integrations?integration=builtin-nginx',
|
||||
link: '/integrations/builtin-nginx',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -4983,7 +4985,7 @@ const onboardingConfigWithLinks = [
|
||||
'self',
|
||||
],
|
||||
imgUrl: mongoUrl,
|
||||
link: '/integrations?integration=builtin-mongo',
|
||||
link: '/integrations/builtin-mongo',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -5001,7 +5003,7 @@ const onboardingConfigWithLinks = [
|
||||
'olap database',
|
||||
],
|
||||
imgUrl: clickhouseUrl,
|
||||
link: '/integrations?integration=builtin-clickhouse',
|
||||
link: '/integrations/builtin-clickhouse',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -5056,7 +5058,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-alb-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: awsAlbUrl,
|
||||
link: '/integrations?integration=aws-integration&service=alb',
|
||||
link: '/integrations/aws?service=alb',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -5097,7 +5099,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-api-gateway-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: apiGatewayUrl,
|
||||
link: '/integrations?integration=aws-integration&service=api-gateway',
|
||||
link: '/integrations/aws?service=api-gateway',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -5142,7 +5144,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-dynamodb-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: dynamodbUrl,
|
||||
link: '/integrations?integration=aws-integration&service=dynamodb',
|
||||
link: '/integrations/aws?service=dynamodb',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -5189,14 +5191,14 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-elasticache-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: elasticacheUrl,
|
||||
link: '/integrations?integration=aws-integration&service=elasticache',
|
||||
link: '/integrations/aws?service=elasticache',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
key: 'aws-elasticache-redis-manual',
|
||||
label: 'Redis Manual',
|
||||
imgUrl: elasticacheUrl,
|
||||
link: '/integrations?integration=builtin-aws_elasticache_redis',
|
||||
link: '/integrations/builtin-aws_elasticache_redis',
|
||||
internalRedirect: true,
|
||||
},
|
||||
],
|
||||
@@ -5235,7 +5237,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-msk-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: amazonMskUrl,
|
||||
link: '/integrations?integration=aws-integration&service=msk',
|
||||
link: '/integrations/aws?service=msk',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -5278,7 +5280,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-s3-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: s3Url,
|
||||
link: '/integrations?integration=aws-integration&service=s3sync',
|
||||
link: '/integrations/aws?service=s3sync',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -5320,7 +5322,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-sns-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: snsUrl,
|
||||
link: '/integrations?integration=aws-integration&service=sns',
|
||||
link: '/integrations/aws?service=sns',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -5362,7 +5364,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-sqs-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: sqsUrl,
|
||||
link: '/integrations?integration=aws-integration&service=sqs',
|
||||
link: '/integrations/aws?service=sqs',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -7249,12 +7251,14 @@ 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',
|
||||
@@ -7263,6 +7267,8 @@ const onboardingConfigWithLinks = [
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'one click',
|
||||
'one click gcp integration',
|
||||
'opentelemetry collector gcp',
|
||||
'pub/sub',
|
||||
],
|
||||
@@ -8155,29 +8161,50 @@ const onboardingConfigWithLinks = [
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-pm2/',
|
||||
},
|
||||
{
|
||||
dataSource: 'gcp-one-click-integration',
|
||||
label: 'GCP One-Click Integration',
|
||||
imgUrl: gcpUrl,
|
||||
tags: ['GCP', 'metrics', 'logs'],
|
||||
module: 'integrations',
|
||||
dataSource: 'pixeltable',
|
||||
label: 'Pixeltable',
|
||||
imgUrl: pixeltableUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'gcp',
|
||||
'gcp agent',
|
||||
'gcp integration',
|
||||
'gcp logs',
|
||||
'gcp metrics',
|
||||
'gcp one click',
|
||||
'google cloud',
|
||||
'google cloud integration',
|
||||
'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',
|
||||
'logs',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'one click',
|
||||
'one click gcp integration',
|
||||
'otel claude cowork',
|
||||
'telemetry',
|
||||
],
|
||||
id: 'gcp-one-click-integration',
|
||||
link: '/docs/integrations/gcp/one-click-gcp-integrations/',
|
||||
id: 'claude-cowork',
|
||||
link: '/docs/claude-cowork-monitoring/',
|
||||
},
|
||||
];
|
||||
export default onboardingConfigWithLinks;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -140,7 +140,7 @@ export const useSafeNavigate = (
|
||||
|
||||
const navigationOptions = {
|
||||
...options,
|
||||
replace: isDefaultParamsNavigation || options?.replace,
|
||||
replace: options?.replace ?? isDefaultParamsNavigation,
|
||||
};
|
||||
|
||||
if (typeof to === 'string') {
|
||||
|
||||
@@ -40,7 +40,6 @@ const make = (
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
labels: { severity: 'info' },
|
||||
annotations: {},
|
||||
source: '',
|
||||
ruleType: RuletypesRuleTypeDTO.threshold_rule,
|
||||
schemaVersion: RuletypesRuleDTOSchemaVersion.v2alpha1,
|
||||
evaluation: {
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
}
|
||||
|
||||
.alert-details {
|
||||
margin-top: 10px;
|
||||
|
||||
&__divider {
|
||||
--divider-color: var(--l1-border);
|
||||
--divider-margin: 16px 0;
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
}
|
||||
|
||||
.alert-rules-container {
|
||||
margin-top: 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
@@ -133,10 +132,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
> .alert-rules-container {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,8 +379,6 @@ 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,
|
||||
|
||||
@@ -12,6 +12,7 @@ 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';
|
||||
@@ -123,17 +124,7 @@ const timeRange = (): Partial<AppState> => {
|
||||
};
|
||||
};
|
||||
|
||||
const serviceFilterItems = {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: 'storybook-service-filter',
|
||||
key: { key: 'service.name', dataType: 'string', type: 'resource' },
|
||||
op: '=',
|
||||
value: 'checkout',
|
||||
},
|
||||
],
|
||||
};
|
||||
const serviceFilterExpression = "service.name = 'checkout'";
|
||||
|
||||
interface RouteValues {
|
||||
drawer: DrawerState;
|
||||
@@ -160,12 +151,14 @@ 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. */
|
||||
|
||||
@@ -26,11 +26,9 @@ 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
2
go.mod
@@ -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.1
|
||||
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.2
|
||||
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
4
go.sum
@@ -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.1 h1:eKJ4vmSLYIfufgh+HaiJxXZts12Xz4jr81HMYYoQvMU=
|
||||
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.1/go.mod h1:RSsdmH4/6H+EHeNPyqJ59dGdpKS0hl2p3T5g1NbehOU=
|
||||
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/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=
|
||||
|
||||
@@ -33,7 +33,6 @@ 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{
|
||||
@@ -93,7 +92,6 @@ 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{
|
||||
@@ -176,7 +174,6 @@ 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{
|
||||
@@ -228,7 +225,6 @@ 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{
|
||||
@@ -293,7 +289,6 @@ 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{
|
||||
@@ -352,7 +347,6 @@ 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{
|
||||
@@ -429,7 +423,6 @@ 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{
|
||||
@@ -490,7 +483,6 @@ 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{
|
||||
@@ -573,7 +565,6 @@ 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{
|
||||
@@ -664,7 +655,6 @@ 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{
|
||||
|
||||
@@ -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 decodes flat too, keeping its attributes dotted;
|
||||
// every other JSON column decodes nested.
|
||||
// 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.
|
||||
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":
|
||||
case "attributes", "scope", "resource":
|
||||
return v.Flatten()
|
||||
}
|
||||
return v.Nest()
|
||||
|
||||
@@ -98,6 +98,10 @@ 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
|
||||
@@ -112,6 +116,7 @@ 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) {
|
||||
|
||||
@@ -49,7 +49,6 @@ func ThresholdRuleAtLeastOnceValueAbove(target float64, recovery *float64) rulet
|
||||
Annotations: map[string]string{
|
||||
"value": "{{$value}}",
|
||||
},
|
||||
Version: "v5",
|
||||
SchemaVersion: ruletypes.SchemaVersionV2Alpha1,
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "A",
|
||||
@@ -108,7 +107,6 @@ func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes
|
||||
Annotations: map[string]string{
|
||||
"value": "{{$value}}",
|
||||
},
|
||||
Version: "v5",
|
||||
SchemaVersion: ruletypes.SchemaVersionV2Alpha1,
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "A",
|
||||
|
||||
@@ -240,7 +240,6 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
|
||||
},
|
||||
SelectedQuery: "A",
|
||||
},
|
||||
Version: "v5",
|
||||
}
|
||||
|
||||
logger := instrumentationtest.New().Logger()
|
||||
@@ -303,7 +302,6 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
|
||||
},
|
||||
SelectedQuery: "A",
|
||||
},
|
||||
Version: "v5",
|
||||
}
|
||||
|
||||
logger := instrumentationtest.New().Logger()
|
||||
|
||||
@@ -251,15 +251,11 @@ 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.AttributesStringColumn)
|
||||
sb.SelectMore(audittelemetryschema.AttributesNumberColumn)
|
||||
sb.SelectMore(audittelemetryschema.AttributesBoolColumn)
|
||||
sb.SelectMore(audittelemetryschema.AttributesColumn)
|
||||
sb.SelectMore(audittelemetryschema.ResourceColumn)
|
||||
sb.SelectMore(audittelemetryschema.ScopeStringColumn)
|
||||
sb.SelectMore(audittelemetryschema.ScopeColumn)
|
||||
} else {
|
||||
for index := range query.SelectFields {
|
||||
if query.SelectFields[index].Name == audittelemetryschema.TimestampColumn || query.SelectFields[index].Name == audittelemetryschema.IDColumn {
|
||||
|
||||
@@ -18,13 +18,12 @@ import (
|
||||
)
|
||||
|
||||
func auditFieldKeyMap() map[string][]*telemetrytypes.TelemetryFieldKey {
|
||||
key := func(name string, ctx telemetrytypes.FieldContext, dt telemetrytypes.FieldDataType, materialized bool) *telemetrytypes.TelemetryFieldKey {
|
||||
key := func(name string, ctx telemetrytypes.FieldContext, dt telemetrytypes.FieldDataType) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: ctx,
|
||||
FieldDataType: dt,
|
||||
Materialized: materialized,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,26 +33,26 @@ func auditFieldKeyMap() map[string][]*telemetrytypes.TelemetryFieldKey {
|
||||
i64 := telemetrytypes.FieldDataTypeInt64
|
||||
|
||||
return map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"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)},
|
||||
"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)},
|
||||
}
|
||||
}
|
||||
|
||||
func newTestAuditStatementBuilder(t *testing.T) *auditQueryStatementBuilder {
|
||||
func newTestAuditStatementBuilder(t *testing.T, keysMap map[string][]*telemetrytypes.TelemetryFieldKey) *auditQueryStatementBuilder {
|
||||
t.Helper()
|
||||
fl := flaggertest.New(t)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = auditFieldKeyMap()
|
||||
mockMetadataStore.KeysMap = keysMap
|
||||
|
||||
storage := audittelemetryschema.NewStorage()
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
@@ -69,7 +68,7 @@ func newTestAuditStatementBuilder(t *testing.T) *auditQueryStatementBuilder {
|
||||
}
|
||||
|
||||
func TestStatementBuilder(t *testing.T) {
|
||||
statementBuilder := newTestAuditStatementBuilder(t)
|
||||
statementBuilder := newTestAuditStatementBuilder(t, auditFieldKeyMap())
|
||||
ctx := context.Background()
|
||||
|
||||
testCases := []struct {
|
||||
@@ -79,7 +78,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
expected qbtypes.Statement
|
||||
expectedErr error
|
||||
}{
|
||||
// List: all actions by a specific user (materialized principal.id filter)
|
||||
// List: all actions by a specific user
|
||||
{
|
||||
name: "ListByPrincipalID",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
@@ -92,11 +91,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, 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 ?",
|
||||
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 ?",
|
||||
Args: []any{"019a-1234-abcd-5678", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
|
||||
},
|
||||
},
|
||||
// List: all failed actions (materialized outcome filter)
|
||||
// List: all failed actions
|
||||
{
|
||||
name: "ListByOutcomeFailure",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
@@ -109,11 +108,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, 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 ?",
|
||||
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), 100},
|
||||
},
|
||||
},
|
||||
// List: change history of a specific dashboard (two materialized column AND)
|
||||
// List: change history of a specific dashboard (two resource keys AND)
|
||||
{
|
||||
name: "ListByResourceKindAndID",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
@@ -126,13 +125,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, 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 ?",
|
||||
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 ?",
|
||||
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 + action AND)
|
||||
// List: all dashboard deletions (compliance — resource.kind + verb AND)
|
||||
{
|
||||
name: "ListByResourceKindAndAction",
|
||||
name: "ListByResourceKindAndVerb",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
@@ -143,11 +142,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, 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 ?",
|
||||
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 ?",
|
||||
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 (materialized principal.type)
|
||||
// List: all actions by service accounts
|
||||
{
|
||||
name: "ListByPrincipalType",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
@@ -160,13 +159,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, 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 ?",
|
||||
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 ?",
|
||||
Args: []any{"service_account", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
|
||||
},
|
||||
},
|
||||
// Scalar: alert — count forbidden errors (outcome + action AND)
|
||||
// Scalar: alert — count forbidden errors (outcome + verb AND)
|
||||
{
|
||||
name: "ScalarCountByOutcomeAndAction",
|
||||
name: "ScalarCountByOutcomeAndVerb",
|
||||
requestType: qbtypes.RequestTypeScalar,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
@@ -180,7 +179,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
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",
|
||||
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",
|
||||
Args: []any{"failure", "update", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
@@ -204,7 +203,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
Limit: 5,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
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`",
|
||||
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`",
|
||||
Args: []any{"failure", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 5, "failure", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
@@ -225,8 +224,11 @@ func TestStatementBuilder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatementBuilderHeatmap(t *testing.T) {
|
||||
statementBuilder := newTestAuditStatementBuilder(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
|
||||
@@ -235,7 +237,206 @@ func TestStatementBuilderHeatmap(t *testing.T) {
|
||||
expected qbtypes.Statement
|
||||
}{
|
||||
{
|
||||
name: "AttributeField_GroupByMaterialized_LinearAxis_RequiresField",
|
||||
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())
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
expected qbtypes.Statement
|
||||
}{
|
||||
{
|
||||
name: "AttributeField_LinearAxis_RequiresField",
|
||||
requestType: qbtypes.RequestTypeHeatmap,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
@@ -247,7 +448,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(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",
|
||||
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",
|
||||
Args: []any{"dashboard", "%signoz.resource.kind%", "%signoz.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
@@ -264,7 +465,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(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",
|
||||
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",
|
||||
Args: []any{"dashboard", "%signoz.resource.kind%", "%signoz.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -691,6 +691,7 @@ 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(),
|
||||
@@ -717,6 +718,7 @@ 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(),
|
||||
@@ -884,18 +886,25 @@ func (t *telemetryMetaStore) getAuditKeys(ctx context.Context, fieldKeySelectors
|
||||
|
||||
complete := rowCount <= limit
|
||||
|
||||
// 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
|
||||
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)
|
||||
}
|
||||
if _, added := mapOfKeys[field.Name+";"+field.FieldContext.StringValue()+";"+field.FieldDataType.StringValue()]; added {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, &field)
|
||||
}
|
||||
|
||||
return keys, complete, nil
|
||||
return keys
|
||||
}
|
||||
|
||||
func getPriorityForContext(ctx telemetrytypes.FieldContext) int {
|
||||
@@ -1209,7 +1218,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, complete, err = t.getAuditKeys(ctx, selectors)
|
||||
keys = appendStaticAuditKeys(nil, map[string]*telemetrytypes.TelemetryFieldKey{}, selectors)
|
||||
} else {
|
||||
keys, complete, err = t.getLogsKeys(ctx, orgID, selectors)
|
||||
}
|
||||
@@ -1293,10 +1302,7 @@ func (t *telemetryMetaStore) GetKeysMulti(ctx context.Context, orgID valuer.UUID
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
auditKeys, auditComplete, err := t.getAuditKeys(ctx, auditSelectors)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
auditKeys := appendStaticAuditKeys(nil, map[string]*telemetrytypes.TelemetryFieldKey{}, auditSelectors)
|
||||
tracesKeys, tracesComplete, err := t.getTracesKeys(ctx, tracesSelectors)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
@@ -1311,7 +1317,7 @@ func (t *telemetryMetaStore) GetKeysMulti(ctx context.Context, orgID valuer.UUID
|
||||
return nil, false, err
|
||||
}
|
||||
// Complete only if all queries are complete
|
||||
complete := logsComplete && auditComplete && tracesComplete && metricsComplete
|
||||
complete := logsComplete && tracesComplete && metricsComplete
|
||||
|
||||
mapOfKeys := make(map[string][]*telemetrytypes.TelemetryFieldKey)
|
||||
for _, key := range logsKeys {
|
||||
@@ -1693,6 +1699,7 @@ 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(),
|
||||
@@ -2163,7 +2170,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, complete, err = t.getAuditFieldValues(ctx, fieldValueSelector)
|
||||
values = &telemetrytypes.TelemetryFieldValues{}
|
||||
} else {
|
||||
values, complete, err = t.getLogFieldValues(ctx, orgID, fieldValueSelector)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ 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 (
|
||||
@@ -22,15 +25,11 @@ const (
|
||||
TraceFlagsColumn = "trace_flags"
|
||||
SeverityTextColumn = "severity_text"
|
||||
SeverityNumberColumn = "severity_number"
|
||||
ScopeNameColumn = "scope_name"
|
||||
ScopeVersionColumn = "scope_version"
|
||||
|
||||
// Contextual Columns.
|
||||
AttributesStringColumn = "attributes_string"
|
||||
AttributesNumberColumn = "attributes_number"
|
||||
AttributesBoolColumn = "attributes_bool"
|
||||
ResourceColumn = "resource"
|
||||
ScopeStringColumn = "scope_string"
|
||||
AttributesColumn = "attributes"
|
||||
ResourceColumn = "resource"
|
||||
ScopeColumn = "scope"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -86,6 +85,15 @@ 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{
|
||||
@@ -117,15 +125,11 @@ 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.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}},
|
||||
"body": {Name: "body", Type: schema.JSONColumnType{}},
|
||||
"scope": {Name: "scope", Type: schema.JSONColumnType{}},
|
||||
"attributes": {Name: "attributes", Type: schema.JSONColumnType{}},
|
||||
"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 (
|
||||
@@ -138,3 +142,16 @@ 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
|
||||
}
|
||||
|
||||
@@ -24,24 +24,11 @@ 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["resource"]}, nil
|
||||
return []*schema.Column{auditLogColumns[ResourceColumn]}, nil
|
||||
case telemetrytypes.FieldContextScope:
|
||||
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
|
||||
return []*schema.Column{auditLogColumns[ScopeColumn]}, nil
|
||||
case telemetrytypes.FieldContextAttribute:
|
||||
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
|
||||
}
|
||||
return []*schema.Column{auditLogColumns[AttributesColumn]}, nil
|
||||
case telemetrytypes.FieldContextLog, telemetrytypes.FieldContextUnspecified:
|
||||
col, ok := auditLogColumns[key.Name]
|
||||
if !ok {
|
||||
@@ -53,46 +40,6 @@ 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) {
|
||||
@@ -100,46 +47,43 @@ func (m *storage) Read(ctx context.Context, q qbtypes.QueryInfo, key *telemetryt
|
||||
if err != nil {
|
||||
return qbtypes.Read{}, err
|
||||
}
|
||||
sql, err := m.read(ctx, q, key)
|
||||
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)
|
||||
if err != nil {
|
||||
return qbtypes.Read{}, err
|
||||
}
|
||||
presence, err := querybuilder.ExistsExpression(columns, key, q.StartNs, q.EndNs, sql, true)
|
||||
absence, err := querybuilder.ExistsExpression(columns, key, q.StartNs, q.EndNs, column.Name, false)
|
||||
if err != nil {
|
||||
return qbtypes.Read{}, err
|
||||
}
|
||||
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
|
||||
return qbtypes.Read{SQL: column.Name, Presence: presence, Absence: absence, WhenAbsent: qbtypes.AlwaysPresent}, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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)
|
||||
}
|
||||
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
|
||||
return querybuilder.WrapAsLogicalFields(key.Name, keys), nil
|
||||
}
|
||||
|
||||
func (m *storage) Traits() qbtypes.Traits {
|
||||
@@ -149,3 +93,81 @@ 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)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -12,7 +11,6 @@ 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)
|
||||
@@ -82,101 +80,60 @@ 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 {
|
||||
JiraAuth
|
||||
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."`
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
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 ChannelJiraCloudAuth) fillHTTPConfig(httpConfig *commoncfg.HTTPClientConfig) {
|
||||
func (a ChannelJiraAuth) fillHTTPConfig(deployment JiraDeployment, httpConfig *commoncfg.HTTPClientConfig) {
|
||||
if deployment == JiraDeploymentSelfHosted {
|
||||
httpConfig.Authorization = &commoncfg.Authorization{
|
||||
Type: jiraBearerAuthorizationType,
|
||||
Credentials: commoncfg.Secret(a.PAT),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
httpConfig.BasicAuth = &commoncfg.BasicAuth{
|
||||
Username: a.Email,
|
||||
Password: commoncfg.Secret(a.APIToken),
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
// UnmarshalJSON seeds send_resolved off, as JiraReceiverConfig does.
|
||||
func (c *ChannelJiraConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelJiraConfig
|
||||
envelope := struct {
|
||||
*alias
|
||||
Auth json.RawMessage `json:"auth"`
|
||||
}{alias: (*alias)(c)}
|
||||
if err := decodeStrict(data, &envelope); err != nil {
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -185,15 +142,6 @@ 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()
|
||||
}
|
||||
|
||||
@@ -215,13 +163,10 @@ func (c ChannelJiraConfig) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if c.Auth.JiraAuth == nil {
|
||||
if c.Auth == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.auth is required for a jira channel")
|
||||
}
|
||||
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 {
|
||||
if err := c.Auth.validate(c.Deployment); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -257,10 +202,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.JiraAuth == nil {
|
||||
if c.Auth == nil {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.auth is required for a jira channel")
|
||||
}
|
||||
c.Auth.fillHTTPConfig(&httpConfig)
|
||||
c.Auth.fillHTTPConfig(c.Deployment, &httpConfig)
|
||||
|
||||
jira := &JiraReceiverConfig{
|
||||
// JiraReceiverConfig seeds no send_resolved of its own, so unset means off.
|
||||
@@ -336,7 +281,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{JiraAuth: ChannelJiraSelfHostedAuth{PAT: string(authorization.Credentials)}}
|
||||
spec.Auth = &ChannelJiraAuth{PAT: string(authorization.Credentials)}
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
@@ -345,7 +290,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{JiraAuth: ChannelJiraCloudAuth{Email: jira.HTTPConfig.BasicAuth.Username, APIToken: string(jira.HTTPConfig.BasicAuth.Password)}}
|
||||
spec.Auth = &ChannelJiraAuth{Email: jira.HTTPConfig.BasicAuth.Username, APIToken: string(jira.HTTPConfig.BasicAuth.Password)}
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
@@ -463,26 +408,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 {
|
||||
@@ -177,9 +178,13 @@ 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"}}`, `unknown field "pat"`, 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},
|
||||
{"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"}}`, `unknown field "email"`, 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},
|
||||
{"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{}},
|
||||
}
|
||||
@@ -208,13 +213,13 @@ func TestChannelJiraConfigReceiverAuth(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "Cloud_BasicAuth",
|
||||
spec: ChannelJiraConfig{Deployment: JiraDeploymentCloud, Site: "https://acme.atlassian.net", Project: "OPS", IssueType: "Task", Auth: ChannelJiraAuth{JiraAuth: ChannelJiraCloudAuth{Email: "e@acme.com", APIToken: "t"}}},
|
||||
spec: ChannelJiraConfig{Deployment: JiraDeploymentCloud, Site: "https://acme.atlassian.net", Project: "OPS", IssueType: "Task", Auth: &ChannelJiraAuth{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{JiraAuth: ChannelJiraSelfHostedAuth{PAT: "p"}}},
|
||||
spec: ChannelJiraConfig{Deployment: JiraDeploymentSelfHosted, Site: "https://jira.acme.com", Project: "OPS", IssueType: "Task", Auth: &ChannelJiraAuth{PAT: "p"}},
|
||||
expectBearer: "p",
|
||||
expectJSONKey: `"deployment":"selfhosted"`,
|
||||
},
|
||||
@@ -243,3 +248,45 @@ 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{JiraAuth: ChannelJiraCloudAuth{Email: "oncall@acme.com", APIToken: "api-token"}},
|
||||
Auth: &ChannelJiraAuth{Email: "oncall@acme.com", APIToken: "api-token"},
|
||||
Summary: valuer.MustNewUnsetOrNonEmptyString("jira summary"), Description: valuer.MustNewUnsetOrNonEmptyString("jira description"), ReopenDuration: valuer.MustNewUnsetOrNonEmptyString("three days"),
|
||||
}},
|
||||
},
|
||||
|
||||
@@ -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{JiraAuth: ChannelJiraCloudAuth{Email: "oncall@acme.com", APIToken: "api-token"}},
|
||||
Auth: &ChannelJiraAuth{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{JiraAuth: ChannelJiraCloudAuth{Email: "oncall@acme.com", APIToken: "api-token"}},
|
||||
Auth: &ChannelJiraAuth{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{JiraAuth: ChannelJiraSelfHostedAuth{PAT: "personal-access-token"}},
|
||||
Auth: &ChannelJiraAuth{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{JiraAuth: ChannelJiraSelfHostedAuth{PAT: "personal-access-token"}},
|
||||
Auth: &ChannelJiraAuth{PAT: "personal-access-token"},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -46,6 +46,7 @@ type PrincipalAttributes struct {
|
||||
PrincipalID valuer.UUID
|
||||
PrincipalEmail valuer.Email
|
||||
PrincipalOrgID valuer.UUID
|
||||
IdentNProvider authtypes.IdentNProvider
|
||||
}
|
||||
|
||||
func NewPrincipalAttributesFromClaims(claims authtypes.Claims) PrincipalAttributes {
|
||||
@@ -61,6 +62,7 @@ func NewPrincipalAttributesFromClaims(claims authtypes.Claims) PrincipalAttribut
|
||||
PrincipalID: principalID,
|
||||
PrincipalEmail: principalEmail,
|
||||
PrincipalOrgID: principalOrgID,
|
||||
IdentNProvider: claims.IdentNProvider,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +73,7 @@ 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).
|
||||
@@ -178,7 +181,7 @@ func NewTransportAttributesFromHTTP(req *http.Request, route string, statusCode
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
clientAddress, clientPort := splitHostPort(req.RemoteAddr)
|
||||
clientAddress, clientPort := clientAddressFromHTTP(req)
|
||||
|
||||
return TransportAttributes{
|
||||
NetworkProtocolName: "http",
|
||||
@@ -220,6 +223,16 @@ 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)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package audittypes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
@@ -235,3 +237,48 @@ 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ func TestAuditEventToLogRecord(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "Success_User_HTTP11",
|
||||
claims: authtypes.Claims{UserID: "019a1234-abcd-7000-8000-567800000001", Principal: authtypes.PrincipalUser, Email: "alice@acme.com", OrgID: "019a1234-abcd-7000-8000-567800000002"},
|
||||
claims: authtypes.Claims{UserID: "019a1234-abcd-7000-8000-567800000001", Principal: authtypes.PrincipalUser, Email: "alice@acme.com", OrgID: "019a1234-abcd-7000-8000-567800000002", IdentNProvider: authtypes.IdentNProviderTokenizer},
|
||||
resourceAttributes: NewResourceAttributes(testDashboardResource, "d-001"),
|
||||
statusCode: http.StatusOK,
|
||||
remoteAddr: "10.0.0.1:52341",
|
||||
@@ -281,23 +281,24 @@ func TestAuditEventToLogRecord(t *testing.T) {
|
||||
protoMajor: 1,
|
||||
protoMinor: 1,
|
||||
expectedAttributes: map[string]any{
|
||||
"signoz.audit.verb": "create",
|
||||
"signoz.audit.action_category": "configuration_changes",
|
||||
"signoz.audit.outcome": "success",
|
||||
"signoz.principal.id": "019a1234-abcd-7000-8000-567800000001",
|
||||
"signoz.principal.type": "user",
|
||||
"signoz.principal.email": "alice@acme.com",
|
||||
"signoz.organization.id": "019a1234-abcd-7000-8000-567800000002",
|
||||
"network.protocol.version": "1.1",
|
||||
"client.address": "10.0.0.1",
|
||||
"client.port": int64(52341),
|
||||
"url.path": "/api/v1/resource",
|
||||
"signoz.audit.verb": "create",
|
||||
"signoz.audit.action_category": "configuration_changes",
|
||||
"signoz.audit.outcome": "success",
|
||||
"signoz.principal.id": "019a1234-abcd-7000-8000-567800000001",
|
||||
"signoz.principal.type": "user",
|
||||
"signoz.principal.email": "alice@acme.com",
|
||||
"signoz.organization.id": "019a1234-abcd-7000-8000-567800000002",
|
||||
"signoz.principal.identn.provider": "tokenizer",
|
||||
"network.protocol.version": "1.1",
|
||||
"client.address": "10.0.0.1",
|
||||
"client.port": int64(52341),
|
||||
"url.path": "/api/v1/resource",
|
||||
},
|
||||
absentAttributes: []string{"exception.type", "signoz.workspace.key.id", "signoz.resource.target.kind", "signoz.audit.identn_provider"},
|
||||
absentAttributes: []string{"exception.type", "signoz.workspace.key.id", "signoz.resource.target.kind"},
|
||||
},
|
||||
{
|
||||
name: "ServiceAccount_HTTP2",
|
||||
claims: authtypes.Claims{UserID: "00000000-0000-0000-0000-000000000000", ServiceAccountID: "019a1234-abcd-7000-8000-567800000003", Principal: authtypes.PrincipalServiceAccount, OrgID: "019a1234-abcd-7000-8000-567800000002"},
|
||||
claims: authtypes.Claims{UserID: "00000000-0000-0000-0000-000000000000", ServiceAccountID: "019a1234-abcd-7000-8000-567800000003", Principal: authtypes.PrincipalServiceAccount, OrgID: "019a1234-abcd-7000-8000-567800000002", IdentNProvider: authtypes.IdentNProviderAPIKey},
|
||||
resourceAttributes: NewResourceAttributes(testDashboardResource, "d-002"),
|
||||
statusCode: http.StatusOK,
|
||||
remoteAddr: "10.0.0.3:443",
|
||||
@@ -305,9 +306,10 @@ func TestAuditEventToLogRecord(t *testing.T) {
|
||||
protoMajor: 2,
|
||||
protoMinor: 0,
|
||||
expectedAttributes: map[string]any{
|
||||
"signoz.principal.id": "019a1234-abcd-7000-8000-567800000003",
|
||||
"signoz.principal.type": "service_account",
|
||||
"network.protocol.version": "2",
|
||||
"signoz.principal.id": "019a1234-abcd-7000-8000-567800000003",
|
||||
"signoz.principal.type": "service_account",
|
||||
"signoz.principal.identn.provider": "api_key",
|
||||
"network.protocol.version": "2",
|
||||
},
|
||||
absentAttributes: []string{"signoz.principal.email"},
|
||||
},
|
||||
@@ -326,7 +328,7 @@ func TestAuditEventToLogRecord(t *testing.T) {
|
||||
"exception.type": "forbidden",
|
||||
"client.address": "10.0.0.2",
|
||||
},
|
||||
absentAttributes: []string{"client.port", "signoz.principal.id", "signoz.principal.type", "signoz.principal.email", "signoz.workspace.key.id"},
|
||||
absentAttributes: []string{"client.port", "signoz.principal.id", "signoz.principal.type", "signoz.principal.email", "signoz.principal.identn.provider", "signoz.workspace.key.id"},
|
||||
},
|
||||
{
|
||||
name: "Attach_IngestionLimitToKey",
|
||||
|
||||
@@ -8,8 +8,7 @@ import (
|
||||
var (
|
||||
ActionCategoryAccessControl = ActionCategory{valuer.NewString(sigsemconv.SigNozAuditActionCategoryAccessControl.Value.AsString())}
|
||||
ActionCategoryConfigurationChange = ActionCategory{valuer.NewString(sigsemconv.SigNozAuditActionCategoryConfigurationChanges.Value.AsString())}
|
||||
ActionCategoryDataAccess = ActionCategory{valuer.NewString("data_access")}
|
||||
ActionCategorySystemEvent = ActionCategory{valuer.NewString("system_event")}
|
||||
ActionCategoryDataAccess = ActionCategory{valuer.NewString(sigsemconv.SigNozAuditActionCategoryDataAccess.Value.AsString())}
|
||||
)
|
||||
|
||||
// ActionCategory classifies an audited action per IEC 62443.
|
||||
@@ -21,6 +20,5 @@ func (ActionCategory) Enum() []any {
|
||||
ActionCategoryAccessControl,
|
||||
ActionCategoryConfigurationChange,
|
||||
ActionCategoryDataAccess,
|
||||
ActionCategorySystemEvent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ const (
|
||||
SchemaVersionV2Alpha1 = "v2alpha1"
|
||||
)
|
||||
|
||||
// legacyRuleKeys are accepted from older clients but kept out of the schema; nothing reads them.
|
||||
var legacyRuleKeys = []string{"source", "version"}
|
||||
|
||||
type RuleDataKind string
|
||||
|
||||
const (
|
||||
@@ -73,9 +76,9 @@ type PostableRule struct {
|
||||
|
||||
Disabled bool `json:"disabled" description:"Whether the rule is paused. A disabled rule is neither evaluated nor notifies. Defaults to false."`
|
||||
|
||||
Source string `json:"source,omitempty" description:"The URL of the page the rule was created from. Informational; it is not used for evaluation."`
|
||||
|
||||
Version string `json:"version" description:"The query payload version. Nothing reads it."`
|
||||
// legacyRuleKeys: stored and echoed as sent
|
||||
Source string `json:"source,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
|
||||
Evaluation *EvaluationEnvelope `json:"evaluation" required:"true" description:"The evaluation window, as a kind and spec."`
|
||||
SchemaVersion string `json:"schemaVersion" required:"true" enum:"v2alpha1" description:"The rule payload schema."`
|
||||
@@ -263,13 +266,9 @@ func (r *PostableRule) DecodeStrict(ruleStr string) error {
|
||||
|
||||
var _ jsonschema.Preparer = PostableRule{}
|
||||
|
||||
// PrepareJSONSchema marks version and source deprecated: nothing reads them,
|
||||
// they stay only so existing clients keep a stable schema.
|
||||
func (PostableRule) PrepareJSONSchema(schema *jsonschema.Schema) error {
|
||||
for _, name := range []string{"version", "source"} {
|
||||
if property, ok := schema.Properties[name]; ok && property.TypeObject != nil {
|
||||
property.TypeObject.WithDeprecated(true)
|
||||
}
|
||||
for _, key := range legacyRuleKeys {
|
||||
delete(schema.Properties, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ func TestParseIntoRule(t *testing.T) {
|
||||
"description": "Test description",
|
||||
"ruleType": "threshold_rule",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -68,7 +67,6 @@ func TestParseIntoRule(t *testing.T) {
|
||||
content: []byte(`{
|
||||
"alert": "PromQLRule",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "promql",
|
||||
@@ -138,7 +136,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
content: []byte(`{
|
||||
"alert": "V2Alpha1Test",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"ruleType": "threshold_rule",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
@@ -205,7 +202,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
content: []byte(`{
|
||||
"alert": "MixedFieldsTest",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"ruleType": "threshold_rule",
|
||||
"preferredChannels": ["slack"],
|
||||
"condition": {
|
||||
@@ -235,7 +231,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
content: []byte(`{
|
||||
"alert": "MissingEvalTest",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"ruleType": "threshold_rule",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
@@ -277,7 +272,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
content: []byte(`{
|
||||
"alert": "MissingNotifTest",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"ruleType": "threshold_rule",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
@@ -319,7 +313,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
content: []byte(`{
|
||||
"alert": "MissingThresholdsTest",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"ruleType": "threshold_rule",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
@@ -359,7 +352,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
content: []byte(`{
|
||||
"alert": "BadSchemaTest",
|
||||
"schemaVersion": "v3",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -386,7 +378,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "MissingSchemaTest",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -440,7 +431,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
func TestParseIntoRuleThresholdGeneration(t *testing.T) {
|
||||
content := []byte(`{
|
||||
"alert": "TestThresholds",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
@@ -514,7 +504,6 @@ func TestParseIntoRuleMultipleThresholds(t *testing.T) {
|
||||
"schemaVersion": "v2alpha1",
|
||||
"alert": "MultiThresholdAlert",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user