Compare commits

..

22 Commits

Author SHA1 Message Date
Naman Verma
b4191b9e10 fix: repair v2 rules in another migration 2026-10-26 18:27:41 +05:30
Naman Verma
7e043b1221 Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-10-26 16:58:06 +05:30
Naman Verma
55e76a182d Merge branch 'main' into nv/alerts-migration 2026-10-26 16:57:45 +05:30
Swapnil Nakade
aaca29d26d feat: adding gcp cloud integration agent mode changes (#13103)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Adding gcp cloud integration agent mode support
  - Type changes
  - Module changes
  - Generated openapi specs
- Added a new key ConnectionMethod for GCP provider to differentiate
between Agent and Manual flow
- Added integration tests for agent mode
- Frontend changes will be addressed in different PR

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/keystone-pod/issues/148

---------

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2026-10-26 09:39:25 +00:00
Naman Verma
9135807532 chore: reduce diff 2026-10-26 11:22:55 +05:30
Naman Verma
a0d671e0e8 Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-10-26 11:02:46 +05:30
Naman Verma
a5f5dc4fb6 Merge branch 'main' into nv/alerts-migration 2026-10-26 11:02:32 +05:30
Naman Verma
b18258e76f Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-10-26 05:46:28 +05:30
Naman Verma
872b4172d3 fix: migrate broken v1 rules as disabled rules 2026-10-26 05:44:15 +05:30
Naman Verma
08f4c099f6 fix: add more validations on v2 schema rules 2026-10-26 04:17:37 +05:30
Naman Verma
87b1eed20a chore: remove extra v2 validations 2026-10-26 04:14:03 +05:30
Naman Verma
f6cfcdfc78 chore: more rule validations 2026-10-26 03:52:16 +05:30
Naman Verma
37cf5ae407 fix: fix all non-query related post-migration errors 2026-10-25 23:59:21 +05:30
Naman Verma
9a8f66be63 Merge branch 'main' into nv/alerts-migration 2026-10-25 22:06:04 +05:30
Naman Verma
3a5078e29b Merge branch 'main' into nv/alerts-migration 2026-10-22 02:05:57 +05:30
Naman Verma
7ebb1910b9 fix: remove nilnil return 2026-10-21 13:50:18 +05:30
Naman Verma
1e1d6f8d24 Merge branch 'main' into nv/alerts-migration 2026-10-21 13:39:57 +05:30
Naman Verma
6f6eea7187 fix: 400 on malformed JSON, missing alertType, unknown enums 2026-10-19 12:00:16 +05:30
Naman Verma
0bec6c5494 Merge branch 'main' into nv/alerts-migration 2026-10-18 09:41:38 +05:30
Naman Verma
d6744c1d8e chore: remove v1 rule apis 2026-10-16 15:20:38 +05:30
Naman Verma
6476be26bb Merge branch 'main' into nv/alerts-migration
# Conflicts:
#	pkg/query-service/app/http_handler.go
2026-10-16 14:56:00 +05:30
Naman Verma
06c54c6483 feat: migrate all rules to v2 schema 2026-10-16 11:17:10 +05:30
112 changed files with 6058 additions and 3860 deletions

View File

@@ -2239,6 +2239,8 @@ components:
type: object
CloudintegrationtypesGCPAccountConfig:
properties:
connectionMethod:
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionMethod'
deploymentProjectId:
type: string
deploymentRegion:
@@ -2248,13 +2250,79 @@ components:
type: string
type: array
required:
- connectionMethod
- deploymentProjectId
- deploymentRegion
- projectIds
type: object
CloudintegrationtypesGCPConnectionArtifact:
properties:
cliCommand:
type: string
required:
- cliCommand
type: object
CloudintegrationtypesGCPConnectionMethod:
enum:
- manual
- agent
type: string
CloudintegrationtypesGCPIntegrationConfig:
properties:
connectionMethod:
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionMethod'
deploymentProjectId:
type: string
deploymentRegion:
type: string
projectIds:
items:
type: string
type: array
telemetryCollectionStrategy:
$ref: '#/components/schemas/CloudintegrationtypesGCPTelemetryCollectionStrategy'
required:
- connectionMethod
- deploymentProjectId
- deploymentRegion
- projectIds
- telemetryCollectionStrategy
type: object
CloudintegrationtypesGCPLogsCollectionStrategy:
nullable: true
properties:
sinkFilter:
type: string
required:
- sinkFilter
type: object
CloudintegrationtypesGCPMetricsCollectionStrategy:
nullable: true
properties:
metricNames:
items:
type: string
type: array
required:
- metricNames
type: object
CloudintegrationtypesGCPPostableAccountConfig:
properties:
connectionMethod:
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionMethod'
deploymentProjectId:
type: string
deploymentRegion:
type: string
projectIds:
items:
type: string
type: array
required:
- connectionMethod
- deploymentProjectId
- deploymentRegion
- projectIds
type: object
CloudintegrationtypesGCPServiceConfig:
properties:
@@ -2277,6 +2345,16 @@ components:
required:
- enabled
type: object
CloudintegrationtypesGCPTelemetryCollectionStrategy:
properties:
logs:
$ref: '#/components/schemas/CloudintegrationtypesGCPLogsCollectionStrategy'
metrics:
$ref: '#/components/schemas/CloudintegrationtypesGCPMetricsCollectionStrategy'
required:
- metrics
- logs
type: object
CloudintegrationtypesGettableAccountWithConnectionArtifact:
properties:
connectionArtifact:
@@ -2413,7 +2491,7 @@ components:
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
$ref: '#/components/schemas/CloudintegrationtypesGCPPostableAccountConfig'
type: object
CloudintegrationtypesPostableAgentCheckIn:
properties:
@@ -2648,7 +2726,6 @@ components:
projectIds:
items:
type: string
nullable: true
type: array
required:
- deploymentProjectId
@@ -9656,35 +9733,34 @@ components:
type: string
disabled:
type: boolean
evalWindow:
type: string
evaluation:
$ref: '#/components/schemas/RuletypesEvaluationEnvelope'
frequency:
type: string
labels:
additionalProperties:
type: string
type: object
notificationSettings:
$ref: '#/components/schemas/RuletypesNotificationSettings'
preferredChannels:
items:
type: string
type: array
ruleType:
$ref: '#/components/schemas/RuletypesRuleType'
schemaVersion:
enum:
- v2alpha1
type: string
source:
deprecated: true
type: string
version:
deprecated: true
type: string
required:
- alert
- alertType
- ruleType
- condition
- evaluation
- schemaVersion
- notificationSettings
type: object
RuletypesPostableRuleView:
properties:
@@ -9745,12 +9821,8 @@ components:
type: string
disabled:
type: boolean
evalWindow:
type: string
evaluation:
$ref: '#/components/schemas/RuletypesEvaluationEnvelope'
frequency:
type: string
id:
type: string
labels:
@@ -9759,15 +9831,14 @@ components:
type: object
notificationSettings:
$ref: '#/components/schemas/RuletypesNotificationSettings'
preferredChannels:
items:
type: string
type: array
ruleType:
$ref: '#/components/schemas/RuletypesRuleType'
schemaVersion:
enum:
- v2alpha1
type: string
source:
deprecated: true
type: string
state:
$ref: '#/components/schemas/RuletypesAlertState'
@@ -9777,6 +9848,7 @@ components:
updatedBy:
type: string
version:
deprecated: true
type: string
required:
- id
@@ -9785,6 +9857,9 @@ components:
- alertType
- ruleType
- condition
- evaluation
- schemaVersion
- notificationSettings
type: object
RuletypesRuleCondition:
properties:
@@ -9793,14 +9868,8 @@ components:
type: integer
alertOnAbsent:
type: boolean
algorithm:
type: string
compositeQuery:
$ref: '#/components/schemas/RuletypesAlertCompositeQuery'
matchType:
$ref: '#/components/schemas/RuletypesMatchType'
op:
$ref: '#/components/schemas/RuletypesCompareOperator'
requireMinPoints:
type: boolean
requiredNumPoints:
@@ -9809,16 +9878,11 @@ components:
$ref: '#/components/schemas/RuletypesSeasonality'
selectedQueryName:
type: string
target:
format: double
nullable: true
type: number
targetUnit:
type: string
thresholds:
$ref: '#/components/schemas/RuletypesRuleThresholdData'
required:
- compositeQuery
- thresholds
type: object
RuletypesRuleThresholdData:
discriminator:
@@ -22443,56 +22507,6 @@ paths:
- rules
x-signoz-stability: alpha
/api/v2/rules:
get:
deprecated: true
description: 'This endpoint lists all alert rules with their current evaluation
state. Deprecated: use ListRulesV3, which supports filtering, sorting and
pagination.'
operationId: ListRules
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/RuletypesRule'
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List alert rules
tags:
- rules
x-signoz-stability: alpha
post:
deprecated: false
description: This endpoint creates a new alert rule
@@ -22651,13 +22665,13 @@ paths:
schemaVersion: v2alpha1
version: v5
metric_anomaly:
description: Anomaly rules are not yet supported under schemaVersion
v2alpha1, so this example uses the v1 shape. Wraps a builder query
in the `anomaly` function with daily seasonality SigNoz compares
each point against the forecast for that time of day. Fires when
the anomaly score stays below the threshold for the entire window;
`requireMinPoints` guards against noisy intervals.
summary: Metric anomaly rule (v1 only)
description: Wraps a builder query in the `anomaly` function with
daily seasonality, so SigNoz compares each point against the forecast
for that time of day. The threshold is compared against the signed
anomaly score, so `below` with a target of -2 fires when an abrupt
drop holds for the entire window; `requireMinPoints` guards against
noisy intervals.
summary: Metric anomaly rule
value:
alert: Anomalous drop in ingested spans
alertType: METRIC_BASED_ALERT
@@ -22692,22 +22706,36 @@ paths:
stepInterval: 21600
type: builder_query
queryType: builder
matchType: all_the_times
op: below
requireMinPoints: true
requiredNumPoints: 3
seasonality: daily
selectedQueryName: A
target: 2
thresholds:
kind: basic
spec:
- channels:
- slack-ingestion
matchType: all_the_times
name: warning
op: below
target: -2
description: Detect an abrupt drop in span ingestion using a z-score
anomaly function
evalWindow: 24h
frequency: 3h
evaluation:
kind: rolling
spec:
evalWindow: 24h
frequency: 3h
labels:
severity: warning
preferredChannels:
- slack-ingestion
notificationSettings:
renotify:
alertStates:
- firing
enabled: true
interval: 4h
ruleType: anomaly_rule
schemaVersion: v2alpha1
version: v5
metric_promql:
description: PromQL expression instead of the builder. Dotted OTEL
@@ -23603,13 +23631,13 @@ paths:
schemaVersion: v2alpha1
version: v5
metric_anomaly:
description: Anomaly rules are not yet supported under schemaVersion
v2alpha1, so this example uses the v1 shape. Wraps a builder query
in the `anomaly` function with daily seasonality SigNoz compares
each point against the forecast for that time of day. Fires when
the anomaly score stays below the threshold for the entire window;
`requireMinPoints` guards against noisy intervals.
summary: Metric anomaly rule (v1 only)
description: Wraps a builder query in the `anomaly` function with
daily seasonality, so SigNoz compares each point against the forecast
for that time of day. The threshold is compared against the signed
anomaly score, so `below` with a target of -2 fires when an abrupt
drop holds for the entire window; `requireMinPoints` guards against
noisy intervals.
summary: Metric anomaly rule
value:
alert: Anomalous drop in ingested spans
alertType: METRIC_BASED_ALERT
@@ -23644,22 +23672,36 @@ paths:
stepInterval: 21600
type: builder_query
queryType: builder
matchType: all_the_times
op: below
requireMinPoints: true
requiredNumPoints: 3
seasonality: daily
selectedQueryName: A
target: 2
thresholds:
kind: basic
spec:
- channels:
- slack-ingestion
matchType: all_the_times
name: warning
op: below
target: -2
description: Detect an abrupt drop in span ingestion using a z-score
anomaly function
evalWindow: 24h
frequency: 3h
evaluation:
kind: rolling
spec:
evalWindow: 24h
frequency: 3h
labels:
severity: warning
preferredChannels:
- slack-ingestion
notificationSettings:
renotify:
alertStates:
- firing
enabled: true
interval: 4h
ruleType: anomaly_rule
schemaVersion: v2alpha1
version: v5
metric_promql:
description: PromQL expression instead of the builder. Dotted OTEL
@@ -24456,13 +24498,13 @@ paths:
schemaVersion: v2alpha1
version: v5
metric_anomaly:
description: Anomaly rules are not yet supported under schemaVersion
v2alpha1, so this example uses the v1 shape. Wraps a builder query
in the `anomaly` function with daily seasonality SigNoz compares
each point against the forecast for that time of day. Fires when
the anomaly score stays below the threshold for the entire window;
`requireMinPoints` guards against noisy intervals.
summary: Metric anomaly rule (v1 only)
description: Wraps a builder query in the `anomaly` function with
daily seasonality, so SigNoz compares each point against the forecast
for that time of day. The threshold is compared against the signed
anomaly score, so `below` with a target of -2 fires when an abrupt
drop holds for the entire window; `requireMinPoints` guards against
noisy intervals.
summary: Metric anomaly rule
value:
alert: Anomalous drop in ingested spans
alertType: METRIC_BASED_ALERT
@@ -24497,22 +24539,36 @@ paths:
stepInterval: 21600
type: builder_query
queryType: builder
matchType: all_the_times
op: below
requireMinPoints: true
requiredNumPoints: 3
seasonality: daily
selectedQueryName: A
target: 2
thresholds:
kind: basic
spec:
- channels:
- slack-ingestion
matchType: all_the_times
name: warning
op: below
target: -2
description: Detect an abrupt drop in span ingestion using a z-score
anomaly function
evalWindow: 24h
frequency: 3h
evaluation:
kind: rolling
spec:
evalWindow: 24h
frequency: 3h
labels:
severity: warning
preferredChannels:
- slack-ingestion
notificationSettings:
renotify:
alertStates:
- firing
enabled: true
interval: 4h
ruleType: anomaly_rule
schemaVersion: v2alpha1
version: v5
metric_promql:
description: PromQL expression instead of the builder. Dotted OTEL
@@ -25887,13 +25943,13 @@ paths:
schemaVersion: v2alpha1
version: v5
metric_anomaly:
description: Anomaly rules are not yet supported under schemaVersion
v2alpha1, so this example uses the v1 shape. Wraps a builder query
in the `anomaly` function with daily seasonality SigNoz compares
each point against the forecast for that time of day. Fires when
the anomaly score stays below the threshold for the entire window;
`requireMinPoints` guards against noisy intervals.
summary: Metric anomaly rule (v1 only)
description: Wraps a builder query in the `anomaly` function with
daily seasonality, so SigNoz compares each point against the forecast
for that time of day. The threshold is compared against the signed
anomaly score, so `below` with a target of -2 fires when an abrupt
drop holds for the entire window; `requireMinPoints` guards against
noisy intervals.
summary: Metric anomaly rule
value:
alert: Anomalous drop in ingested spans
alertType: METRIC_BASED_ALERT
@@ -25928,22 +25984,36 @@ paths:
stepInterval: 21600
type: builder_query
queryType: builder
matchType: all_the_times
op: below
requireMinPoints: true
requiredNumPoints: 3
seasonality: daily
selectedQueryName: A
target: 2
thresholds:
kind: basic
spec:
- channels:
- slack-ingestion
matchType: all_the_times
name: warning
op: below
target: -2
description: Detect an abrupt drop in span ingestion using a z-score
anomaly function
evalWindow: 24h
frequency: 3h
evaluation:
kind: rolling
spec:
evalWindow: 24h
frequency: 3h
labels:
severity: warning
preferredChannels:
- slack-ingestion
notificationSettings:
renotify:
alertStates:
- firing
enabled: true
interval: 4h
ruleType: anomaly_rule
schemaVersion: v2alpha1
version: v5
metric_promql:
description: PromQL expression instead of the builder. Dotted OTEL

View File

@@ -7,24 +7,66 @@ import (
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
)
type gcpcloudprovider struct {
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
}
func NewGCPCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore) cloudintegration.CloudProviderModule {
return &gcpcloudprovider{
serviceDefinitions: defStore,
}
}
func (g *gcpcloudprovider) BuildIntegrationConfig(ctx context.Context, account *cloudintegrationtypes.Account, services []*cloudintegrationtypes.StorableCloudIntegrationService) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
// for manual flow we don't have any integration config to return, so returning empty config for now.
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
func (g *gcpcloudprovider) BuildIntegrationConfig(
ctx context.Context,
account *cloudintegrationtypes.Account,
services []*cloudintegrationtypes.StorableCloudIntegrationService,
) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
if account.Config.GCP.ConnectionMethod != cloudintegrationtypes.GCPConnectionMethodAgent {
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
}
var metricNames []string
var sinkFilters []string
for _, storedSvc := range services {
svcCfg, err := cloudintegrationtypes.NewServiceConfigFromJSON(cloudintegrationtypes.CloudProviderTypeGCP, storedSvc.Config)
if err != nil {
return nil, err
}
svcDef, err := g.GetServiceDefinition(ctx, storedSvc.Type)
if err != nil {
return nil, err
}
if svcDef.TelemetryCollectionStrategy.GCP == nil {
continue
}
strategy := svcDef.TelemetryCollectionStrategy.GCP
if svcCfg.IsMetricsEnabled(cloudintegrationtypes.CloudProviderTypeGCP) && strategy.Metrics != nil {
metricNames = append(metricNames, strategy.Metrics.MetricNames...)
}
if svcCfg.IsLogsEnabled(cloudintegrationtypes.CloudProviderTypeGCP) && strategy.Logs != nil {
sinkFilters = append(sinkFilters, strategy.Logs.SinkFilter)
}
}
return &cloudintegrationtypes.ProviderIntegrationConfig{
GCP: cloudintegrationtypes.NewGCPIntegrationConfig(account.Config.GCP, metricNames, sinkFilters),
}, nil
}
func (g *gcpcloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
// for manual flow we don't have any connection artifact to return, so returning empty artifact for now.
return &cloudintegrationtypes.ConnectionArtifact{}, nil
if account.Config.GCP.ConnectionMethod != cloudintegrationtypes.GCPConnectionMethodAgent {
return &cloudintegrationtypes.ConnectionArtifact{}, nil
}
connectionArtifact, err := cloudintegrationtypes.NewGCPConnectionArtifact(account.ID, req.Config.AgentVersion, req.Credentials, account.Config.GCP)
if err != nil {
return nil, err
}
return &cloudintegrationtypes.ConnectionArtifact{
GCP: connectionArtifact,
}, nil
}
func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
@@ -34,3 +76,7 @@ func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID c
func (g *gcpcloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
return g.serviceDefinitions.List(ctx, cloudintegrationtypes.CloudProviderTypeGCP)
}
type gcpcloudprovider struct {
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
}

View File

@@ -2,7 +2,6 @@ package implcloudintegration
import (
"context"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -171,18 +170,6 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
connectedAccount, err := module.store.GetConnectedAccountByProviderAccountID(ctx, orgID, req.ProviderAccountID, provider)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
// If a different integration is already connected to this provider account ID, reject the check-in.
// Allow re-check-in from the same integration (e.g. agent restarting).
if connectedAccount != nil && connectedAccount.ID != req.CloudIntegrationID {
errMessage := fmt.Sprintf("provider account id %s is already connected to cloud integration id %s", req.ProviderAccountID, connectedAccount.ID)
return nil, errors.New(errors.TypeAlreadyExists, cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected, errMessage)
}
storableAccount, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
if err != nil {
return nil, err
@@ -195,41 +182,36 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
syncState := account.NextSyncState(req.SyncedVersion)
// If account has been removed (disconnected), return a minimal response with empty integration config.
if account.RemovedAt != nil {
// Heartbeat stays frozen after removal, only the sync state is updated.
if account.AgentReport != nil && syncState != nil {
account.UpdateSyncState(syncState)
return module.checkInRemovedAccount(ctx, account, req.ProviderAccountID, syncState)
}
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return nil, err
}
if err := account.ErrIfInvalidProviderAccountID(req.ProviderAccountID); err != nil {
return nil, err
}
err = module.store.UpdateAgentReport(ctx, storableAccount)
if err != nil {
return nil, err
}
}
connectedAccount, err := module.store.GetConnectedAccountByProviderAccountID(ctx, orgID, req.ProviderAccountID, provider)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
return cloudintegrationtypes.NewAgentCheckInResponse(
req.ProviderAccountID,
account.ID.StringValue(),
new(cloudintegrationtypes.ProviderIntegrationConfig),
account.RemovedAt,
syncState,
), nil
// If a different integration is already connected to this provider account ID, reject the check-in.
// Allow re-check-in from the same integration (e.g. agent restarting).
if connectedAccount != nil && connectedAccount.ID != req.CloudIntegrationID {
return nil, errors.Newf(
errors.TypeAlreadyExists,
cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected,
"provider account id %s is already connected to cloud integration id %s", req.ProviderAccountID, connectedAccount.ID,
)
}
account.UpdateAgentReport(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data, syncState))
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return nil, err
}
err = module.store.UpdateAgentReport(ctx, storableAccount)
if err != nil {
if err := module.store.UpdateAgentReport(ctx, storableAccount); err != nil {
return nil, err
}
@@ -498,11 +480,45 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
stats["cloudintegration.azure.connectedaccounts.count"] = azureAccountsCount
}
// NOTE: not adding stats for services for now.
gcpAccountsCount, err := module.store.CountConnectedAccounts(ctx, orgID, cloudintegrationtypes.CloudProviderTypeGCP)
if err == nil {
stats["cloudintegration.gcp.connectedaccounts.count"] = gcpAccountsCount
}
return stats, nil
}
// checkInRemovedAccount returns an empty integration config and saves cleanup acknowledgments without refreshing the heartbeat.
func (module *module) checkInRemovedAccount(ctx context.Context, account *cloudintegrationtypes.Account, providerAccountID string, syncState *cloudintegrationtypes.SyncState) (*cloudintegrationtypes.AgentCheckInResponse, error) {
response := cloudintegrationtypes.NewAgentCheckInResponse(
providerAccountID,
account.ID.StringValue(),
new(cloudintegrationtypes.ProviderIntegrationConfig),
account.RemovedAt,
syncState,
)
if account.AgentReport == nil {
return response, nil
}
if syncState == nil {
return response, nil
}
account.UpdateSyncState(syncState)
storableAccount, err := cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return nil, err
}
if err := module.store.UpdateAgentReport(ctx, storableAccount); err != nil {
return nil, err
}
return response, nil
}
func (module *module) getCloudProvider(provider cloudintegrationtypes.CloudProviderType) (cloudintegration.CloudProviderModule, error) {
if cloudProviderModule, ok := module.cloudProvidersMap[provider]; ok {
return cloudProviderModule, nil

View File

@@ -105,15 +105,15 @@ func (r *AnomalyRule) Type() ruletypes.RuleType {
}
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbtypes.QueryRangeRequest {
r.logger.InfoContext(
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
slog.Int64("eval.window_ms", r.EvalWindow().Milliseconds()),
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
)
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
r.logger.InfoContext(
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
slog.Int64("eval.window_ms", end-start),
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
)
req := &qbtypes.QueryRangeRequest{
Start: uint64(start),
End: uint64(end),
@@ -420,12 +420,10 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
func (r *AnomalyRule) String() string {
ar := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName(r.Name()),
RuleCondition: r.Condition(),
EvalWindow: r.EvalWindow(),
Labels: r.Labels().Map(),
Annotations: r.Annotations().Map(),
PreferredChannels: r.PreferredChannels(),
AlertName: valuer.NewDisplayName(r.Name()),
RuleCondition: r.Condition(),
Labels: r.Labels().Map(),
Annotations: r.Annotations().Map(),
}
byt, err := json.Marshal(ar)

View File

@@ -54,9 +54,6 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
@@ -157,10 +154,7 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
AlertOnAbsent: true,
Target: &target,
AlertOnAbsent: true,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{

View File

@@ -3792,7 +3792,12 @@ export interface CloudintegrationtypesAzureAccountConfigDTO {
resourceGroups: string[];
}
export enum CloudintegrationtypesGCPConnectionMethodDTO {
manual = 'manual',
agent = 'agent',
}
export interface CloudintegrationtypesGCPAccountConfigDTO {
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
/**
* @type string
*/
@@ -4039,7 +4044,10 @@ export interface CloudintegrationtypesCollectedMetricDTO {
}
export interface CloudintegrationtypesGCPConnectionArtifactDTO {
[key: string]: unknown;
/**
* @type string
*/
cliCommand: string;
}
export interface CloudintegrationtypesConnectionArtifactDTO {
@@ -4078,8 +4086,62 @@ export interface CloudintegrationtypesDataCollectedDTO {
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
}
/**
* @nullable
*/
export type CloudintegrationtypesGCPLogsCollectionStrategyDTO = {
/**
* @type string
*/
sinkFilter: string;
} | null;
/**
* @nullable
*/
export type CloudintegrationtypesGCPMetricsCollectionStrategyDTO = {
/**
* @type array
*/
metricNames: string[];
} | null;
export interface CloudintegrationtypesGCPTelemetryCollectionStrategyDTO {
logs: CloudintegrationtypesGCPLogsCollectionStrategyDTO | null;
metrics: CloudintegrationtypesGCPMetricsCollectionStrategyDTO | null;
}
export interface CloudintegrationtypesGCPIntegrationConfigDTO {
[key: string]: unknown;
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
/**
* @type string
*/
deploymentProjectId: string;
/**
* @type string
*/
deploymentRegion: string;
/**
* @type array
*/
projectIds: string[];
telemetryCollectionStrategy: CloudintegrationtypesGCPTelemetryCollectionStrategyDTO;
}
export interface CloudintegrationtypesGCPPostableAccountConfigDTO {
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
/**
* @type string
*/
deploymentProjectId: string;
/**
* @type string
*/
deploymentRegion: string;
/**
* @type array
*/
projectIds: string[];
}
export interface CloudintegrationtypesGettableAccountWithConnectionArtifactDTO {
@@ -4234,7 +4296,7 @@ export interface CloudintegrationtypesGettableServicesMetadataDTO {
export interface CloudintegrationtypesPostableAccountConfigDTO {
aws?: CloudintegrationtypesAWSPostableAccountConfigDTO;
azure?: CloudintegrationtypesAzureAccountConfigDTO;
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
gcp?: CloudintegrationtypesGCPPostableAccountConfigDTO;
}
export interface CloudintegrationtypesPostableAccountDTO {
@@ -4377,9 +4439,9 @@ export interface CloudintegrationtypesUpdatableGCPAccountConfigDTO {
*/
deploymentRegion: string;
/**
* @type array,null
* @type array
*/
projectIds: string[] | null;
projectIds: string[];
}
export interface CloudintegrationtypesUpdatableAccountConfigDTO {

View File

@@ -0,0 +1,5 @@
.waitingMessage {
display: flex;
align-items: center;
gap: var(--spacing-4);
}

View File

@@ -0,0 +1,147 @@
import { LoaderCircle } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
import { useAgentAccountSetup } from 'hooks/integration/gcp/useAgentAccountSetup';
import { useForm } from 'react-hook-form';
import ConnectionMethodSelector from '../ConnectionMethodSelector';
import ConnectionSecretsFields from '../ConnectionSecretsFields';
import { AGENT_SETUP_GUIDE, DEFAULT_SETUP_FORM_VALUES } from '../constants';
import DeploymentProjectIdField from '../fields/DeploymentProjectIdField';
import DeploymentRegionField from '../fields/DeploymentRegionField';
import SetupDrawerFooter from '../SetupDrawerFooter';
import SetupGuideCallout from '../SetupGuideCallout';
import { GcpSetupFormValues } from '../types';
import { usePrefillCredentials } from '../usePrefillCredentials';
import AgentSetupCommand from './AgentSetupCommand';
import styles from './AgentSetup.module.scss';
interface AgentSetupProps {
footerSlot: HTMLDivElement | null;
onConnectionMethodChange: (
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
) => void;
onCommandGenerated: () => void;
/** Asks to leave the setup; the parent may confirm before closing. */
onCancel: () => void;
onClose: () => void;
}
function AgentSetup({
footerSlot,
onConnectionMethodChange,
onCommandGenerated,
onCancel,
onClose,
}: AgentSetupProps): JSX.Element {
const {
isLoading,
generateCommand,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
connectionCommand,
commandProjectId,
modalState,
checkConnectionAgain,
} = useAgentAccountSetup({ onClose, onCommandGenerated });
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
defaultValues: DEFAULT_SETUP_FORM_VALUES,
});
usePrefillCredentials(connectionParams, setValue);
return (
<>
<ConnectionMethodSelector
value={CloudintegrationtypesGCPConnectionMethodDTO.agent}
onChange={onConnectionMethodChange}
disabled={isLoading || Boolean(connectionCommand)}
/>
<SetupGuideCallout {...AGENT_SETUP_GUIDE} />
{connectionCommand ? (
<>
<AgentSetupCommand
deploymentProjectId={commandProjectId}
command={connectionCommand}
/>
{modalState === ModalStateEnum.WAITING && (
<Callout
type="info"
showIcon={false}
title={
<div className={styles.waitingMessage}>
<LoaderCircle size={14} className="animate-spin" />
Waiting for GCP account connection, retrying in 10 secs...
</div>
}
testId="gcp-agent-waiting"
/>
)}
{modalState === ModalStateEnum.ERROR && (
<Callout
type="error"
showIcon
title="We couldn't establish a connection to your GCP account. Please try again"
testId="gcp-agent-connection-error"
/>
)}
</>
) : (
<>
<DeploymentProjectIdField
control={control}
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.agent}
/>
<DeploymentRegionField
control={control}
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.agent}
/>
<ConnectionSecretsFields
control={control}
isLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
/>
</>
)}
<SetupDrawerFooter
slot={footerSlot}
errorTitle="Failed to generate the GCP setup command"
error={submitError}
onDismissError={clearSubmitError}
onCancel={onCancel}
>
{!connectionCommand && (
<Button
size="md"
variant="solid"
color="primary"
onClick={handleSubmit(generateCommand)}
loading={isLoading || isConnectionParamsLoading}
testId="gcp-generate-command-btn"
>
Generate Command
</Button>
)}
{modalState === ModalStateEnum.ERROR && (
<Button
size="md"
variant="solid"
color="primary"
onClick={checkConnectionAgain}
testId="gcp-check-connection-btn"
>
Check Again
</Button>
)}
</SetupDrawerFooter>
</>
);
}
export default AgentSetup;

View File

@@ -0,0 +1,31 @@
.commands {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.commandsHead {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.mono {
composes: mono from '../shared.module.scss';
}
// CodeBlock renders the copy button's wrapper first and the highlighted code last;
// the code theme sets the block's margin and padding inline, hence !important.
.command {
> div:first-child {
top: var(--spacing-6);
right: var(--spacing-6);
}
> div:last-child {
// The theme's top margin pushes the bordered block under the copy button.
margin: 0 !important;
// Keeps long lines from running underneath the copy button.
padding-right: var(--spacing-24) !important;
}
}

View File

@@ -0,0 +1,32 @@
import { Typography } from '@signozhq/ui/typography';
import CodeBlock from 'components/CodeBlock/CodeBlock';
import styles from './AgentSetupCommand.module.scss';
interface AgentSetupCommandProps {
deploymentProjectId: string;
command: string;
}
function AgentSetupCommand({
deploymentProjectId,
command,
}: AgentSetupCommandProps): JSX.Element {
return (
<div className={styles.commands} data-testid="gcp-agent-command">
<div className={styles.commandsHead}>
<Typography.Text weight="bold" size="base">
Deploy the SigNoz agent
</Typography.Text>
<Typography.Text as="p" size="small" color="muted">
Run this command in Google Cloud Shell, or in a terminal where gcloud is
signed in to <span className={styles.mono}>{deploymentProjectId}</span>. It
contains your SigNoz keys, so do not share it.
</Typography.Text>
</div>
<CodeBlock language="bash" code={command} className={styles.command} />
</div>
);
}
export default AgentSetupCommand;

View File

@@ -0,0 +1,183 @@
import { screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import {
DEPLOYMENT_PROJECT_ID,
fillAgentForm,
onClose,
onCommandGenerated,
renderAgentSetup,
mockAgentSetupApi,
AgentSetupApiMock,
} from './agentSetupTestHelpers';
import {
connectionCredentials,
createAgentAccountResponse,
GCP_ACCOUNTS_URL,
gcpConnectionCommand,
} from '../../../__tests__/mockData';
// `useAgentAccountSetup` imports logEvent by relative path, which the
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
// not intercept, so mock the resolved module directly.
jest.mock('../../../../../../../api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
describe('GCP AgentSetup form', () => {
let api: AgentSetupApiMock;
beforeEach(() => {
jest.clearAllMocks();
api = mockAgentSetupApi();
});
it('asks only for the deployment project, region and credentials', async () => {
renderAgentSetup();
await waitFor(() => {
expect(screen.getByTestId('gcp-signoz-api-url-input')).toHaveTextContent(
connectionCredentials.sigNozApiUrl,
);
});
expect(
screen.getByTestId('gcp-deployment-project-id-input'),
).toBeInTheDocument();
expect(
screen.getByTestId('gcp-deployment-region-select'),
).toBeInTheDocument();
expect(
screen.queryByTestId('gcp-account-name-input'),
).not.toBeInTheDocument();
expect(document.querySelector('#gcp-project-ids-select')).toBeNull();
expect(screen.getByTestId('gcp-setup-guide-callout')).toHaveTextContent(
'SigNoz integration sets up everything needed',
);
});
it('blocks submission and surfaces validation errors when the form is empty', async () => {
const user = userEvent.setup();
renderAgentSetup();
await waitFor(() => {
expect(screen.getByTestId('gcp-generate-command-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await waitFor(() => {
expect(
screen.getByText('Please enter the deployment project ID'),
).toBeInTheDocument();
});
expect(screen.getByText('Please select a region')).toBeInTheDocument();
expect(api.createAccountPayload).toBeNull();
});
it('creates an agent account and shows its setup command', async () => {
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await waitFor(() => {
expect(screen.getByTestId('gcp-agent-command')).toBeInTheDocument();
});
expect(api.createAccountPayload).toStrictEqual({
config: {
gcp: {
connectionMethod: 'agent',
deploymentRegion: 'asia-south1',
deploymentProjectId: DEPLOYMENT_PROJECT_ID,
projectIds: [DEPLOYMENT_PROJECT_ID],
},
},
credentials: connectionCredentials,
});
expect(screen.getByTestId('gcp-agent-command')).toHaveTextContent(
gcpConnectionCommand.cliCommand,
);
expect(screen.getByTestId('gcp-agent-command')).toHaveTextContent(
DEPLOYMENT_PROJECT_ID,
);
expect(
screen.queryByTestId('gcp-generate-command-btn'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('gcp-deployment-project-id-input'),
).not.toBeInTheDocument();
expect(screen.getByRole('radiogroup')).toHaveAttribute(
'aria-disabled',
'true',
);
expect(onCommandGenerated).toHaveBeenCalledTimes(1);
expect(api.checkInCalled).toBe(false);
expect(onClose).not.toHaveBeenCalled();
});
it('shows the backend error inline and keeps the form when creation fails', async () => {
server.use(
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
res(
ctx.status(400),
ctx.json({
status: 'error',
error: {
message:
'projectIds must contain only deploymentProjectId for GCP agent setup',
},
}),
),
),
);
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await waitFor(() => {
expect(screen.getByTestId('gcp-connect-error')).toHaveTextContent(
'projectIds must contain only deploymentProjectId for GCP agent setup',
);
});
expect(screen.queryByTestId('gcp-agent-command')).not.toBeInTheDocument();
expect(screen.getByTestId('gcp-generate-command-btn')).toBeInTheDocument();
});
it('locks the connection method and keeps the submitted project while the command is generating', async () => {
server.use(
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
res(ctx.delay(300), ctx.status(201), ctx.json(createAgentAccountResponse)),
),
);
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await waitFor(() => {
expect(screen.getByRole('radiogroup')).toHaveAttribute(
'aria-disabled',
'true',
);
});
await user.type(
screen.getByTestId('gcp-deployment-project-id-input'),
'-edited',
);
const commands = await screen.findByTestId('gcp-agent-command');
expect(commands).toHaveTextContent(`signed in to ${DEPLOYMENT_PROJECT_ID}.`);
expect(commands).not.toHaveTextContent(`${DEPLOYMENT_PROJECT_ID}-edited`);
});
});

View File

@@ -0,0 +1,118 @@
import { screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import {
fillAgentForm,
onClose,
renderAgentSetup,
mockAgentSetupApi,
} from './agentSetupTestHelpers';
import {
connectedAgentAccountResponse,
GCP_AGENT_ACCOUNT_URL,
pendingAgentAccountResponse,
} from '../../../__tests__/mockData';
// `useAgentAccountSetup` imports logEvent by relative path, which the
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
// not intercept, so mock the resolved module directly.
jest.mock('../../../../../../../api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
describe('GCP AgentSetup connection polling', () => {
beforeEach(() => {
jest.clearAllMocks();
mockAgentSetupApi();
});
it('waits for the agent after showing the command', async () => {
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await screen.findByTestId('gcp-agent-waiting');
expect(
screen.queryByTestId('gcp-check-connection-btn'),
).not.toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
it('closes the drawer once the agent checks in', async () => {
server.use(
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(connectedAgentAccountResponse)),
),
);
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
});
it('reports a timeout and lets the user check again', async () => {
const realNow = Date.now.bind(Date);
let clockOffset = 0;
const nowSpy = jest
.spyOn(Date, 'now')
.mockImplementation(() => realNow() + clockOffset);
let pollCount = 0;
server.use(
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) => {
pollCount += 1;
if (pollCount === 1) {
// Polling started before this response, so jumping the clock
// past the 10 minute window makes this first poll time out.
clockOffset += 11 * 60 * 1000;
return res(ctx.status(200), ctx.json(pendingAgentAccountResponse));
}
return res(ctx.status(200), ctx.json(connectedAgentAccountResponse));
}),
);
try {
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await screen.findByTestId('gcp-agent-connection-error');
expect(onClose).not.toHaveBeenCalled();
await user.click(screen.getByTestId('gcp-check-connection-btn'));
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
} finally {
nowSpy.mockRestore();
}
});
it('shows a retryable error when the connection status cannot be fetched', async () => {
server.use(
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
const user = userEvent.setup();
renderAgentSetup();
await fillAgentForm(user);
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await screen.findByTestId('gcp-agent-connection-error');
expect(screen.getByTestId('gcp-check-connection-btn')).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,102 @@
import { ComponentProps } from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import { RestRequest } from 'msw';
import AgentSetup from '../AgentSetup';
import SetupDrawerLayout from '../../SetupDrawerLayout';
import {
connectionCredentialsResponse,
createAgentAccountResponse,
GCP_ACCOUNTS_URL,
GCP_AGENT_ACCOUNT_URL,
GCP_CHECK_IN_URL,
GCP_CREDENTIALS_URL,
pendingAgentAccountResponse,
} from '../../../__tests__/mockData';
export const DEPLOYMENT_PROJECT_ID = 'my-deployment-project-123';
type AgentSetupProps = ComponentProps<typeof AgentSetup>;
export const onClose = jest.fn() as jest.MockedFunction<
AgentSetupProps['onClose']
>;
export const onConnectionMethodChange = jest.fn() as jest.MockedFunction<
AgentSetupProps['onConnectionMethodChange']
>;
export const onCommandGenerated = jest.fn() as jest.MockedFunction<
AgentSetupProps['onCommandGenerated']
>;
export const onCancel = jest.fn() as jest.MockedFunction<
AgentSetupProps['onCancel']
>;
export const renderAgentSetup = (): void => {
render(
<SetupDrawerLayout onClose={onClose}>
{(footerSlot): JSX.Element => (
<AgentSetup
footerSlot={footerSlot}
onConnectionMethodChange={onConnectionMethodChange}
onCommandGenerated={onCommandGenerated}
onCancel={onCancel}
onClose={onClose}
/>
)}
</SetupDrawerLayout>,
);
};
export const fillAgentForm = async (
user: ReturnType<typeof userEvent.setup>,
): Promise<void> => {
await waitFor(() => {
expect(screen.getByTestId('gcp-generate-command-btn')).toBeEnabled();
});
await user.type(
screen.getByTestId('gcp-deployment-project-id-input'),
DEPLOYMENT_PROJECT_ID,
);
await user.click(screen.getByTestId('gcp-deployment-region-select'));
await user.click(await screen.findByText('Mumbai (asia-south1)'));
};
export interface AgentSetupApiMock {
readonly createAccountPayload: Record<string, unknown> | null;
readonly checkInCalled: boolean;
}
/** Registers the agent setup endpoints; the polled account stays pending. */
export const mockAgentSetupApi = (): AgentSetupApiMock => {
let createAccountPayload: Record<string, unknown> | null = null;
let checkInCalled = false;
server.use(
rest.get(GCP_CREDENTIALS_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(connectionCredentialsResponse)),
),
// check_in is registered first, it is a more specific path than
// /accounts and msw matches handlers in registration order.
rest.post(GCP_CHECK_IN_URL, (_req, res, ctx) => {
checkInCalled = true;
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
rest.post(GCP_ACCOUNTS_URL, async (req: RestRequest, res, ctx) => {
createAccountPayload = await req.json();
return res(ctx.status(201), ctx.json(createAgentAccountResponse));
}),
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(pendingAgentAccountResponse)),
),
);
return {
get createAccountPayload(): Record<string, unknown> | null {
return createAccountPayload;
},
get checkInCalled(): boolean {
return checkInCalled;
},
};
};

View File

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

View File

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

View File

@@ -10,12 +10,6 @@
composes: drawerSurfaceHead from './shared.module.scss';
}
.flowRadioTitle {
display: flex;
gap: var(--spacing-2);
align-items: center;
}
.flowRadioDesc {
.methodRadioDesc {
margin-top: var(--spacing-2);
}

View File

@@ -0,0 +1,89 @@
import { RadioGroup } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import styles from './ConnectionMethodSelector.module.scss';
interface ConnectionMethodSelectorProps {
value: CloudintegrationtypesGCPConnectionMethodDTO;
onChange: (
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
) => void;
disabled?: boolean;
}
function ConnectionMethodSelector({
value,
onChange,
disabled,
}: ConnectionMethodSelectorProps): JSX.Element {
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<Typography.Text
weight="bold"
size="base"
className={styles.drawerSurfaceHead}
>
Connection method
</Typography.Text>
<RadioGroup
color="primary"
textOverflow="wrap"
value={value}
onChange={(next): void =>
onChange(next as CloudintegrationtypesGCPConnectionMethodDTO)
}
disabled={disabled}
disabledTooltip="The connection method can't be changed once setup has started"
items={[
{
value: CloudintegrationtypesGCPConnectionMethodDTO.manual,
testId: 'gcp-connection-method-manual',
label: (
<>
<Typography.Text weight="semibold" size="base" display="block">
Connect Manually
</Typography.Text>
<Typography.Text
as="p"
size="small"
color="muted"
className={styles.methodRadioDesc}
>
Deploy your own OTel Collector.
</Typography.Text>
</>
),
},
{
value: CloudintegrationtypesGCPConnectionMethodDTO.agent,
testId: 'gcp-connection-method-agent',
label: (
<>
<Typography.Text weight="semibold" size="base" display="block">
Connect via Agent
</Typography.Text>
<Typography.Text
as="p"
size="small"
color="muted"
className={styles.methodRadioDesc}
>
SigNoz deploys and manages the collector for you.
</Typography.Text>
</>
),
},
]}
/>
</div>
);
}
ConnectionMethodSelector.defaultProps = {
disabled: false,
};
export default ConnectionMethodSelector;

View File

@@ -21,10 +21,6 @@
composes: mono from './shared.module.scss';
}
.fieldError {
composes: fieldError from './shared.module.scss';
}
.fullWidth {
width: 100%;
}

View File

@@ -9,6 +9,7 @@ import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { Control, Controller } from 'react-hook-form';
import FieldLabel from './FieldLabel';
import FormField from './FormField';
import { GcpSetupFormValues } from './types';
import { SecretFieldType, validateSecretValue } from './validators';
import styles from './ConnectionSecretsFields.module.scss';
@@ -141,43 +142,32 @@ function ConnectionSecretsFields({
}
return (
<div key={field.name} className={styles.drawerSection}>
<FieldLabel
htmlFor={field.testId}
label={field.label}
tooltip={field.tooltip}
/>
<Controller
name={field.name}
control={control}
rules={{
validate: (value): true | string =>
validateSecretValue(field.label, field.type, value),
}}
render={({ field: rhfField, fieldState }): JSX.Element => (
<>
<Input
id={field.testId}
className={cx(styles.fullWidth, styles.mono)}
placeholder={field.placeholder}
value={rhfField.value}
onChange={(e): void => rhfField.onChange(e.target.value)}
testId={field.testId}
/>
{fieldState.error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<Controller
key={field.name}
name={field.name}
control={control}
rules={{
validate: (value): true | string =>
validateSecretValue(field.label, field.type, value),
}}
render={({ field: rhfField, fieldState }): JSX.Element => (
<FormField
htmlFor={field.testId}
label={field.label}
tooltip={field.tooltip}
error={fieldState.error}
>
<Input
id={field.testId}
className={cx(styles.fullWidth, styles.mono)}
placeholder={field.placeholder}
value={rhfField.value}
onChange={(e): void => rhfField.onChange(e.target.value)}
testId={field.testId}
/>
</FormField>
)}
/>
);
})}
</div>

View File

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

View File

@@ -0,0 +1,7 @@
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.fieldError {
composes: fieldError from './shared.module.scss';
}

View File

@@ -0,0 +1,55 @@
import { ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { FieldError } from 'react-hook-form';
import FieldLabel from './FieldLabel';
import styles from './FormField.module.scss';
interface FormFieldProps {
htmlFor: string;
label: string;
tooltip: string;
required?: boolean;
error?: FieldError;
children: ReactNode;
}
/** A labelled setup drawer field with its validation message below the input. */
function FormField({
htmlFor,
label,
tooltip,
required,
error,
children,
}: FormFieldProps): JSX.Element {
return (
<div className={styles.drawerSection}>
<FieldLabel
htmlFor={htmlFor}
label={label}
tooltip={tooltip}
required={required}
/>
{children}
{error && (
<Typography.Text
as="span"
size="small"
role="alert"
className={styles.fieldError}
>
{error.message}
</Typography.Text>
)}
</div>
);
}
FormField.defaultProps = {
required: false,
error: undefined,
};
export default FormField;

View File

@@ -0,0 +1,139 @@
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import { useManualAccountSetup } from 'hooks/integration/gcp/useManualAccountSetup';
import { Controller, useForm } from 'react-hook-form';
import ProjectIdsInput from '../../ProjectIdsInput';
import ConnectionSecretsFields from '../ConnectionSecretsFields';
import FormField from '../FormField';
import DeploymentProjectIdField from '../fields/DeploymentProjectIdField';
import DeploymentRegionField from '../fields/DeploymentRegionField';
import ConnectionMethodSelector from '../ConnectionMethodSelector';
import SetupDrawerFooter from '../SetupDrawerFooter';
import SetupGuideCallout from '../SetupGuideCallout';
import { MANUAL_SETUP_GUIDE, DEFAULT_SETUP_FORM_VALUES } from '../constants';
import { GcpSetupFormValues } from '../types';
import { usePrefillCredentials } from '../usePrefillCredentials';
import styles from './ManualSetup.module.scss';
interface ManualSetupProps {
footerSlot: HTMLDivElement | null;
onConnectionMethodChange: (
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
) => void;
onClose: () => void;
}
function ManualSetup({
footerSlot,
onConnectionMethodChange,
onClose,
}: ManualSetupProps): JSX.Element {
const {
isLoading,
connectAccount,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
} = useManualAccountSetup({ onClose });
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
defaultValues: DEFAULT_SETUP_FORM_VALUES,
});
usePrefillCredentials(connectionParams, setValue);
return (
<>
<ConnectionMethodSelector
value={CloudintegrationtypesGCPConnectionMethodDTO.manual}
onChange={onConnectionMethodChange}
disabled={isLoading}
/>
<SetupGuideCallout {...MANUAL_SETUP_GUIDE} />
<Controller
name="accountName"
control={control}
rules={{ required: 'Please enter an account name' }}
render={({ field, fieldState }): JSX.Element => (
<FormField
htmlFor="gcp-account-name-input"
label="Account Name"
tooltip="A label to identify this group of GCP projects (org ID, billing email, or any descriptive name)"
required
error={fieldState.error}
>
<Input
id="gcp-account-name-input"
className={styles.fullWidth}
placeholder="e.g. my-org or billing@company.com"
value={field.value}
onChange={(e): void => field.onChange(e.target.value)}
testId="gcp-account-name-input"
/>
</FormField>
)}
/>
<DeploymentProjectIdField
control={control}
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.manual}
/>
<DeploymentRegionField
control={control}
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.manual}
/>
<Controller
name="projectIds"
control={control}
rules={{
validate: (value): true | string =>
value.length > 0 || 'Please add at least one project ID',
}}
render={({ field, fieldState }): JSX.Element => (
<FormField
htmlFor="gcp-project-ids-select"
label="Projects to Monitor"
tooltip="Enter each GCP project ID then press Enter"
required
error={fieldState.error}
>
<ProjectIdsInput
id="gcp-project-ids-select"
testId="gcp-project-ids-select"
value={field.value}
onChange={field.onChange}
/>
</FormField>
)}
/>
<ConnectionSecretsFields
control={control}
isLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
/>
<SetupDrawerFooter
slot={footerSlot}
errorTitle="Failed to connect GCP account"
error={submitError}
onDismissError={clearSubmitError}
onCancel={onClose}
>
<Button
size="md"
variant="solid"
color="primary"
onClick={handleSubmit(connectAccount)}
loading={isLoading || isConnectionParamsLoading}
testId="gcp-connect-account-btn"
>
Connect Account
</Button>
</SetupDrawerFooter>
</>
);
}
export default ManualSetup;

View File

@@ -0,0 +1,14 @@
.footerContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0;
}

View File

@@ -0,0 +1,65 @@
import { ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import styles from './SetupDrawerFooter.module.scss';
interface SetupDrawerFooterProps {
slot: HTMLDivElement | null;
errorTitle: string;
error: string | null;
onDismissError: () => void;
onCancel: () => void;
children?: ReactNode;
}
function SetupDrawerFooter({
slot,
errorTitle,
error,
onDismissError,
onCancel,
children,
}: SetupDrawerFooterProps): JSX.Element | null {
if (!slot) {
return null;
}
return createPortal(
<div className={styles.footerContainer}>
{error && (
<Callout
type="error"
size="small"
showIcon
action="dismissible"
onClick={onDismissError}
title={errorTitle}
testId="gcp-connect-error"
>
{error}
</Callout>
)}
<div className={styles.footer}>
<Button
size="md"
variant="outlined"
color="secondary"
onClick={onCancel}
testId="gcp-cancel-btn"
>
Cancel
</Button>
{children}
</div>
</div>,
slot,
);
}
SetupDrawerFooter.defaultProps = {
children: undefined,
};
export default SetupDrawerFooter;

View File

@@ -0,0 +1,36 @@
.setupDrawer {
// Bounded flex column so the header/footer stay put and only the body
// scrolls when content overflows.
display: flex;
flex-direction: column;
overflow: hidden;
[data-slot='drawer-header'],
[data-slot='drawer-footer'] {
flex-shrink: 0;
}
// The drawer body renders inside [data-slot='drawer-description'] — this is
// the only region allowed to scroll.
[data-slot='drawer-description'] {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--spacing-10);
min-height: 0;
overflow-y: auto;
}
// The region menu renders in place (no portal), so it needs its own surface
// to sit above the fields below it.
[data-slot='combobox-content'] {
z-index: 5;
background: var(--l1-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}
}
.footerSlot {
width: 100%;
}

View File

@@ -0,0 +1,40 @@
import { ReactNode, useState } from 'react';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import styles from './SetupDrawerLayout.module.scss';
interface SetupDrawerLayoutProps {
onClose: () => void;
/** Receives the footer slot element that SetupDrawerFooter portals into. */
children: (footerSlot: HTMLDivElement | null) => ReactNode;
}
/** Stays mounted across connection method switches so the drawer does not replay its open animation. */
function SetupDrawerLayout({
onClose,
children,
}: SetupDrawerLayoutProps): JSX.Element {
const [footerSlot, setFooterSlot] = useState<HTMLDivElement | null>(null);
return (
<DrawerWrapper
open={true}
className={styles.setupDrawer}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
showCloseButton
title="Connect Google Cloud Platform"
width="extra-wide"
footer={<div ref={setFooterSlot} className={styles.footerSlot} />}
testId="gcp-setup-drawer"
>
{children(footerSlot)}
</DrawerWrapper>
);
}
export default SetupDrawerLayout;

View File

@@ -4,24 +4,30 @@ import { Typography } from '@signozhq/ui/typography';
import styles from './SetupGuideCallout.module.scss';
const GCP_INTEGRATION_DOCS_URL =
'https://signoz.io/docs/integrations/gcp/gcp-integration/';
interface SetupGuideCalloutProps {
description: string;
docsUrl: string;
linkLabel: string;
}
function SetupGuideCallout(): JSX.Element {
function SetupGuideCallout({
description,
docsUrl,
linkLabel,
}: SetupGuideCalloutProps): JSX.Element {
return (
<Callout icon={<KeyRound />} testId="gcp-setup-guide-callout">
<Typography.Text as="span" size="base">
Please go through our GCP integration guide, which covers all prerequisites
— service account, IAM roles, and resource setup.
{description}
</Typography.Text>
<a
className={styles.guideLink}
href={GCP_INTEGRATION_DOCS_URL}
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
data-testid="gcp-setup-guide-link"
>
GCP integration guide
{linkLabel}
<ArrowUpRight size={12} />
</a>
</Callout>

View File

@@ -0,0 +1,27 @@
import { GcpSetupFormValues } from './types';
export const DEFAULT_SETUP_FORM_VALUES: GcpSetupFormValues = {
accountName: '',
deploymentProjectId: '',
deploymentRegion: '',
projectIds: [],
sigNozApiUrl: '',
sigNozApiKey: '',
ingestionUrl: '',
ingestionKey: '',
};
export const MANUAL_SETUP_GUIDE = {
description:
'Please follow our setup guide to create the resources needed to complete the integration.',
docsUrl: 'https://signoz.io/docs/integrations/gcp/gcp-integration/',
linkLabel: 'View setup guide',
};
export const AGENT_SETUP_GUIDE = {
description:
'SigNoz integration sets up everything needed to collect telemetry from your project.',
// TODO: point to the agent setup docs once they are published.
docsUrl: 'https://signoz.io/docs/integrations/gcp/gcp-integration/',
linkLabel: 'Learn more',
};

View File

@@ -0,0 +1,54 @@
import { Input } from '@signozhq/ui/input';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { Control, Controller } from 'react-hook-form';
import FormField from '../FormField';
import { GcpSetupFormValues } from '../types';
import styles from './Fields.module.scss';
const TOOLTIPS: Record<CloudintegrationtypesGCPConnectionMethodDTO, string> = {
[CloudintegrationtypesGCPConnectionMethodDTO.manual]:
'The GCP project that hosts your OTel Collector deployment, often separate from the projects you actually monitor',
[CloudintegrationtypesGCPConnectionMethodDTO.agent]:
'The GCP project where SigNoz deploys the agent. The agent monitors this project.',
};
interface DeploymentProjectIdFieldProps {
control: Control<GcpSetupFormValues>;
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
}
function DeploymentProjectIdField({
control,
connectionMethod,
}: DeploymentProjectIdFieldProps): JSX.Element {
return (
<Controller
name="deploymentProjectId"
control={control}
rules={{ required: 'Please enter the deployment project ID' }}
render={({ field, fieldState }): JSX.Element => (
<FormField
htmlFor="gcp-deployment-project-id-input"
label="Deployment Project ID"
tooltip={TOOLTIPS[connectionMethod]}
required
error={fieldState.error}
>
<Input
id="gcp-deployment-project-id-input"
className={cx(styles.fullWidth, styles.mono)}
placeholder="e.g. my-deployment-project-123"
value={field.value}
onChange={(e): void => field.onChange(e.target.value)}
testId="gcp-deployment-project-id-input"
/>
</FormField>
)}
/>
);
}
export default DeploymentProjectIdField;

View File

@@ -0,0 +1,65 @@
import { ComboboxSimple } from '@signozhq/ui/combobox';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { GCP_REGIONS } from 'container/Integrations/constants';
import { Control, Controller } from 'react-hook-form';
import FormField from '../FormField';
import { GcpSetupFormValues } from '../types';
import styles from './Fields.module.scss';
const TOOLTIPS: Record<CloudintegrationtypesGCPConnectionMethodDTO, string> = {
[CloudintegrationtypesGCPConnectionMethodDTO.manual]:
'The GCP region where your OTel Collector will be deployed',
[CloudintegrationtypesGCPConnectionMethodDTO.agent]:
'The GCP region where the agent resources are deployed',
};
const REGION_ITEMS = GCP_REGIONS.map((region) => ({
value: region.value,
label: `${region.label} (${region.value})`,
}));
interface DeploymentRegionFieldProps {
control: Control<GcpSetupFormValues>;
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
}
function DeploymentRegionField({
control,
connectionMethod,
}: DeploymentRegionFieldProps): JSX.Element {
return (
<Controller
name="deploymentRegion"
control={control}
rules={{ required: 'Please select a region' }}
render={({ field, fieldState }): JSX.Element => (
<FormField
htmlFor="gcp-deployment-region-select"
label="Deployment Region"
tooltip={TOOLTIPS[connectionMethod]}
required
error={fieldState.error}
>
<ComboboxSimple
id="gcp-deployment-region-select"
className={cx(styles.fullWidth, styles.regionSelect, {
[styles.regionEmpty]: !field.value,
})}
items={REGION_ITEMS}
value={field.value}
onChange={(value): void => field.onChange(value as string)}
placeholder="Select a region..."
inputPlaceholder="Search regions…"
withPortal={false}
testId="gcp-deployment-region-select"
/>
</FormField>
)}
/>
);
}
export default DeploymentRegionField;

View File

@@ -0,0 +1,19 @@
.mono {
composes: mono from '../shared.module.scss';
}
.fullWidth {
width: 100%;
}
.regionSelect {
// Selected region value: bright, like every other field's text.
[data-slot='combobox-value'] {
color: var(--l1-foreground);
}
// Empty trigger (.regionEmpty, set from the RHF value): dull the placeholder text.
&.regionEmpty [data-slot='combobox-value'] {
color: var(--l3-foreground);
}
}

View File

@@ -1,5 +1,4 @@
export type SetupFlow = 'manual' | 'agent';
/** Shared by both connection methods; agent setup leaves accountName and projectIds unused. */
export interface GcpSetupFormValues {
accountName: string;
deploymentProjectId: string;

View File

@@ -0,0 +1,21 @@
import { useEffect } from 'react';
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
import { UseFormSetValue } from 'react-hook-form';
import { GcpSetupFormValues } from './types';
// Pre-fill the deployment/ingestion fields with the fetched credentials.
export function usePrefillCredentials(
connectionParams: CloudintegrationtypesCredentialsDTO | undefined,
setValue: UseFormSetValue<GcpSetupFormValues>,
): void {
useEffect(() => {
if (!connectionParams) {
return;
}
setValue('sigNozApiUrl', connectionParams.sigNozApiUrl);
setValue('sigNozApiKey', connectionParams.sigNozApiKey);
setValue('ingestionUrl', connectionParams.ingestionUrl);
setValue('ingestionKey', connectionParams.ingestionKey);
}, [connectionParams, setValue]);
}

View File

@@ -3,13 +3,15 @@ import { useQueryClient } from 'react-query';
import { Save } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Form, Select } from 'antd';
import { Form } from 'antd';
import { invalidateListAccounts } from 'api/generated/services/cloudintegration';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { CloudAccount } from 'container/Integrations/types';
import { useAccountSettingsDrawer } from 'hooks/integration/gcp/useAccountSettingsDrawer';
import RemoveIntegrationAccount from '../../RemoveAccount/RemoveIntegrationAccount';
import ProjectIdsInput from '../ProjectIdsInput';
import styles from './AccountSettingsDrawer.module.scss';
@@ -40,6 +42,10 @@ function AccountSettingsDrawer({
() => ('project_ids' in account.config ? account.config : null),
[account.config],
);
// The backend rejects updates to agent accounts, so their settings are read-only.
const isAgentAccount =
gcpConfig?.connection_method ===
CloudintegrationtypesGCPConnectionMethodDTO.agent;
return (
<DrawerWrapper
@@ -65,20 +71,23 @@ function AccountSettingsDrawer({
handleClose();
}}
cloudProvider={INTEGRATION_TYPES.GCP}
gcpConnectionMethod={gcpConfig?.connection_method}
/>
<Button
disabledTooltip="Change the project IDs first"
size="md"
variant="solid"
color="secondary"
disabled={isSaveDisabled}
onClick={handleSubmit}
loading={isLoading}
prefix={<Save size={14} />}
testId="gcp-update-account-btn"
>
Update Changes
</Button>
{!isAgentAccount && (
<Button
disabledTooltip="Change the project IDs first"
size="md"
variant="solid"
color="secondary"
disabled={isSaveDisabled}
onClick={handleSubmit}
loading={isLoading}
prefix={<Save size={14} />}
testId="gcp-update-account-btn"
>
Update Changes
</Button>
)}
</div>
}
>
@@ -122,32 +131,42 @@ function AccountSettingsDrawer({
<div className={styles.regionSelector}>
<div className={styles.regionSelectorTitle}>Projects to monitor</div>
<div className={styles.regionSelectorDescription}>
Update the GCP project IDs that should be monitored.
</div>
{isAgentAccount ? (
<div
className={styles.regionSelectorDescription}
data-testid="gcp-agent-monitored-projects"
>
{gcpConfig?.project_ids.join(', ')}
</div>
) : (
<>
<div className={styles.regionSelectorDescription}>
Update the GCP project IDs that should be monitored.
</div>
<Form.Item
name="projectIds"
rules={[
{
required: true,
type: 'array',
min: 1,
message: 'Please add at least one project ID',
},
]}
>
<Select
mode="tags"
value={projectIds}
tokenSeparators={[',']}
onChange={(values): void => {
setProjectIds(values);
form.setFieldValue('projectIds', values);
}}
data-testid="gcp-edit-project-ids-select"
/>
</Form.Item>
<Form.Item
name="projectIds"
rules={[
{
required: true,
type: 'array',
min: 1,
message: 'Please add at least one project ID',
},
]}
>
<ProjectIdsInput
id="gcp-edit-project-ids-select"
testId="gcp-edit-project-ids-select"
value={projectIds}
onChange={(values): void => {
setProjectIds(values);
form.setFieldValue('projectIds', values);
}}
/>
</Form.Item>
</>
)}
</div>
</div>
</Form>

View File

@@ -0,0 +1,5 @@
.projectIds {
// The multi-value trigger is content-box, so full width needs its padding counted in.
box-sizing: border-box;
width: 100%;
}

View File

@@ -0,0 +1,43 @@
import { ComboboxSimple } from '@signozhq/ui/combobox';
import cx from 'classnames';
import styles from './ProjectIdsInput.module.scss';
interface ProjectIdsInputProps {
id: string;
testId: string;
value: string[];
onChange: (projectIds: string[]) => void;
className?: string;
}
/** Free-form list of GCP project IDs: type an ID and press Enter to add it. */
function ProjectIdsInput({
id,
testId,
value,
onChange,
className,
}: ProjectIdsInputProps): JSX.Element {
return (
<ComboboxSimple
id={id}
testId={testId}
className={cx(styles.projectIds, className)}
multiple
allowCreate
value={value}
onChange={(next): void => onChange(Array.isArray(next) ? next : [])}
placeholder="Add project IDs…"
inputPlaceholder="Type a project ID and press Enter"
emptyPlaceholder="Type a project ID to add it"
withPortal={false}
/>
);
}
ProjectIdsInput.defaultProps = {
className: undefined,
};
export default ProjectIdsInput;

View File

@@ -1,19 +1,18 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ComponentProps } from 'react';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { toast } from '@signozhq/ui/sonner';
import { server } from 'mocks-server/server';
import { rest, RestRequest } from 'msw';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { rest, server } from 'mocks-server/server';
import { RestRequest } from 'msw';
import AccountSettingsDrawer from '../EditAccount/AccountSettingsDrawer';
import {
GCP_ACCOUNT_ID,
GCP_ACCOUNT_URL,
GCP_ACCOUNTS_URL,
gcpAccount,
gcpAccountConfig,
listAccountsResponse,
} from './mockData';
import { addProjectIds } from './projectIdsTestUtils';
// `useAccountSettingsDrawer` imports logEvent by relative path, which the
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
@@ -31,31 +30,29 @@ jest.mock('@signozhq/ui/sonner', () => ({
},
}));
const onClose = jest.fn();
const setActiveAccount = jest.fn();
type AccountSettingsDrawerProps = ComponentProps<typeof AccountSettingsDrawer>;
const onClose = jest.fn() as jest.MockedFunction<
AccountSettingsDrawerProps['onClose']
>;
const setActiveAccount = jest.fn() as jest.MockedFunction<
AccountSettingsDrawerProps['setActiveAccount']
>;
const renderDrawer = (): void => {
render(
<MockQueryClientProvider>
<AccountSettingsDrawer
onClose={onClose}
account={gcpAccount}
setActiveAccount={setActiveAccount}
/>
</MockQueryClientProvider>,
<AccountSettingsDrawer
onClose={onClose}
account={gcpAccount}
setActiveAccount={setActiveAccount}
/>,
);
};
/** The antd tags Select renders its text input inside the testId wrapper. */
const getProjectIdsInput = (): HTMLElement =>
within(screen.getByTestId('gcp-edit-project-ids-select')).getByRole(
'combobox',
);
/** Each selected tag carries an antd "close" icon that removes it. */
const getProjectIdTagRemoveButtons = (): HTMLElement[] =>
/** Each selected project ID renders as a pill with its own remove button. */
const getProjectIdRemoveButtons = (): HTMLElement[] =>
within(screen.getByTestId('gcp-edit-project-ids-select')).queryAllByLabelText(
'close',
/^Remove /,
);
describe('GCP AccountSettingsDrawer', () => {
@@ -86,8 +83,9 @@ describe('GCP AccountSettingsDrawer', () => {
screen.getByText(gcpAccountConfig.deployment_region),
).toBeInTheDocument();
const projectIdsInput = screen.getByTestId('gcp-edit-project-ids-select');
gcpAccountConfig.project_ids.forEach((projectId) => {
expect(screen.getByTitle(projectId)).toBeInTheDocument();
expect(projectIdsInput).toHaveTextContent(projectId);
});
});
@@ -100,7 +98,7 @@ describe('GCP AccountSettingsDrawer', () => {
'true',
);
await user.type(getProjectIdsInput(), 'project-c,');
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
await waitFor(() => {
expect(screen.getByTestId('gcp-update-account-btn')).not.toHaveAttribute(
@@ -114,7 +112,7 @@ describe('GCP AccountSettingsDrawer', () => {
const user = userEvent.setup();
renderDrawer();
await user.type(getProjectIdsInput(), 'project-c,');
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
await waitFor(() => {
expect(screen.getByTestId('gcp-update-account-btn')).not.toHaveAttribute(
'aria-disabled',
@@ -141,8 +139,7 @@ describe('GCP AccountSettingsDrawer', () => {
expect(setActiveAccount).toHaveBeenCalledWith({
...gcpAccount,
config: {
deployment_region: gcpAccountConfig.deployment_region,
deployment_project_id: gcpAccountConfig.deployment_project_id,
...gcpAccountConfig,
project_ids: ['project-a', 'project-b', 'project-c'],
},
});
@@ -159,9 +156,9 @@ describe('GCP AccountSettingsDrawer', () => {
renderDrawer();
// Strip every tag via its remove icon; the list shrinks as we go.
while (getProjectIdTagRemoveButtons().length > 0) {
while (getProjectIdRemoveButtons().length > 0) {
// eslint-disable-next-line no-await-in-loop
await user.click(getProjectIdTagRemoveButtons()[0]);
await user.click(getProjectIdRemoveButtons()[0]);
}
await waitFor(() => {
@@ -194,7 +191,7 @@ describe('GCP AccountSettingsDrawer', () => {
const user = userEvent.setup();
renderDrawer();
await user.type(getProjectIdsInput(), 'project-c,');
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
await waitFor(() => {
expect(screen.getByTestId('gcp-update-account-btn')).not.toHaveAttribute(
'aria-disabled',
@@ -212,31 +209,4 @@ describe('GCP AccountSettingsDrawer', () => {
expect(setActiveAccount).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});
it('disconnects the account with the GCP-specific confirmation copy', async () => {
let disconnectedId: string | null = null;
server.use(
rest.delete(`${GCP_ACCOUNTS_URL}/:id`, (req, res, ctx) => {
disconnectedId = req.params.id as string;
return res(ctx.status(204));
}),
);
const user = userEvent.setup();
renderDrawer();
await user.click(screen.getByRole('button', { name: /disconnect/i }));
await expect(
screen.findByText(/manually tear down/i),
).resolves.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /remove account/i }));
await waitFor(() => {
expect(disconnectedId).toBe(GCP_ACCOUNT_ID);
});
expect(setActiveAccount).toHaveBeenCalledWith(null);
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,104 @@
import { ComponentProps } from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import { CloudAccount } from 'container/Integrations/types';
import AccountSettingsDrawer from '../EditAccount/AccountSettingsDrawer';
import {
GCP_ACCOUNT_ID,
GCP_ACCOUNTS_URL,
gcpAccount,
gcpAgentAccount,
listAccountsResponse,
} from './mockData';
// `useAccountSettingsDrawer` imports logEvent by relative path, which the
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
// not intercept, so mock the resolved module directly.
jest.mock('../../../../../api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
type AccountSettingsDrawerProps = ComponentProps<typeof AccountSettingsDrawer>;
const onClose = jest.fn() as jest.MockedFunction<
AccountSettingsDrawerProps['onClose']
>;
const setActiveAccount = jest.fn() as jest.MockedFunction<
AccountSettingsDrawerProps['setActiveAccount']
>;
const renderDrawer = (account: CloudAccount = gcpAccount): void => {
render(
<AccountSettingsDrawer
onClose={onClose}
account={account}
setActiveAccount={setActiveAccount}
/>,
);
};
describe('GCP AccountSettingsDrawer by connection method', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listAccountsResponse)),
),
);
});
it('disconnects the account with the GCP-specific confirmation copy', async () => {
let disconnectedId: string | null = null;
server.use(
rest.delete(`${GCP_ACCOUNTS_URL}/:id`, (req, res, ctx) => {
disconnectedId = req.params.id as string;
return res(ctx.status(204));
}),
);
const user = userEvent.setup();
renderDrawer();
await user.click(screen.getByRole('button', { name: /disconnect/i }));
await expect(
screen.findByText(/manually tear down/i),
).resolves.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /remove account/i }));
await waitFor(() => {
expect(disconnectedId).toBe(GCP_ACCOUNT_ID);
});
expect(setActiveAccount).toHaveBeenCalledWith(null);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('shows agent accounts read-only, since the backend rejects updates to them', () => {
renderDrawer(gcpAgentAccount);
expect(screen.getByTestId('gcp-agent-monitored-projects')).toHaveTextContent(
'my-agent-project',
);
expect(
screen.queryByTestId('gcp-edit-project-ids-select'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('gcp-update-account-btn'),
).not.toBeInTheDocument();
});
it('explains the agent-specific cleanup when disconnecting an agent account', async () => {
const user = userEvent.setup();
renderDrawer(gcpAgentAccount);
await user.click(screen.getByRole('button', { name: /disconnect/i }));
await expect(
screen.findByText(/signoz-integration-bootstrap Infrastructure/i),
).resolves.toBeInTheDocument();
expect(screen.queryByText(/manually tear down/i)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,128 @@
import { ComponentProps } from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import CloudAccountSetupDrawer from '../AddNewAccount/CloudAccountSetupDrawer';
import {
connectedAgentAccountResponse,
connectionCredentialsResponse,
createAgentAccountResponse,
GCP_ACCOUNTS_URL,
GCP_AGENT_ACCOUNT_URL,
GCP_CREDENTIALS_URL,
pendingAgentAccountResponse,
} from './mockData';
// The setup hooks import logEvent by relative path, which the jest.config
// moduleNameMapper (keyed on the `api/common/logEvent` alias) does not
// intercept, so mock the resolved module directly.
jest.mock('../../../../../api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const onClose = jest.fn() as jest.MockedFunction<
ComponentProps<typeof CloudAccountSetupDrawer>['onClose']
>;
const renderDrawer = (): void => {
render(<CloudAccountSetupDrawer onClose={onClose} />);
};
const generateAgentCommand = async (
user: ReturnType<typeof userEvent.setup>,
): Promise<void> => {
await user.click(screen.getByTestId('gcp-connection-method-agent'));
await waitFor(() => {
expect(screen.getByTestId('gcp-generate-command-btn')).toBeEnabled();
});
await user.type(
screen.getByTestId('gcp-deployment-project-id-input'),
'my-deployment-project-123',
);
await user.click(screen.getByTestId('gcp-deployment-region-select'));
await user.click(await screen.findByText('Mumbai (asia-south1)'));
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await screen.findByTestId('gcp-agent-command');
};
describe('GCP setup drawer close confirmation', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(GCP_CREDENTIALS_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(connectionCredentialsResponse)),
),
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
res(ctx.status(201), ctx.json(createAgentAccountResponse)),
),
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(pendingAgentAccountResponse)),
),
);
});
it('closes right away when no setup command has been generated', async () => {
const user = userEvent.setup();
renderDrawer();
await user.click(screen.getByTestId('gcp-connection-method-agent'));
await user.click(screen.getByTestId('gcp-cancel-btn'));
expect(onClose).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId('gcp-close-setup-btn')).not.toBeInTheDocument();
});
it('asks before cancelling a pending agent setup and can continue it', async () => {
const user = userEvent.setup();
renderDrawer();
await generateAgentCommand(user);
await user.click(screen.getByTestId('gcp-cancel-btn'));
await screen.findByText(/start the installation process again/i);
expect(onClose).not.toHaveBeenCalled();
await user.click(screen.getByTestId('gcp-continue-setup-btn'));
await waitFor(() => {
expect(
screen.queryByText(/start the installation process again/i),
).not.toBeInTheDocument();
});
expect(screen.getByTestId('gcp-agent-command')).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
it('closes the drawer once the user confirms', async () => {
const user = userEvent.setup();
renderDrawer();
await generateAgentCommand(user);
await user.click(screen.getByRole('button', { name: 'Close' }));
await user.click(await screen.findByTestId('gcp-close-setup-btn'));
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
});
it('closes without asking once the agent connects', async () => {
server.use(
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(connectedAgentAccountResponse)),
),
);
const user = userEvent.setup();
renderDrawer();
await generateAgentCommand(user);
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
expect(
screen.queryByText(/start the installation process again/i),
).not.toBeInTheDocument();
});
});

View File

@@ -1,8 +1,7 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { server } from 'mocks-server/server';
import { rest, RestRequest } from 'msw';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { ComponentProps } from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import { RestRequest } from 'msw';
import CloudAccountSetupDrawer from '../AddNewAccount/CloudAccountSetupDrawer';
import {
@@ -15,8 +14,9 @@ import {
GCP_CHECK_IN_URL,
GCP_CREDENTIALS_URL,
} from './mockData';
import { addProjectIds } from './projectIdsTestUtils';
// `useCloudAccountSetupDrawer` imports logEvent by relative path, which the
// `useManualAccountSetup` imports logEvent by relative path, which the
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
// not intercept — so mock the resolved module directly.
jest.mock('../../../../../api/common/logEvent', () => ({
@@ -24,14 +24,12 @@ jest.mock('../../../../../api/common/logEvent', () => ({
default: jest.fn(),
}));
const onClose = jest.fn();
const onClose = jest.fn() as jest.MockedFunction<
ComponentProps<typeof CloudAccountSetupDrawer>['onClose']
>;
const renderDrawer = (): void => {
render(
<MockQueryClientProvider>
<CloudAccountSetupDrawer onClose={onClose} />
</MockQueryClientProvider>,
);
render(<CloudAccountSetupDrawer onClose={onClose} />);
};
describe('GCP CloudAccountSetupDrawer', () => {
@@ -109,6 +107,13 @@ describe('GCP CloudAccountSetupDrawer', () => {
});
it('creates the account, checks the agent in, and closes the drawer', async () => {
// A slow check-in keeps the connection in flight long enough to observe it.
server.use(
rest.post(GCP_CHECK_IN_URL, async (req: RestRequest, res, ctx) => {
checkInPayload = await req.json();
return res(ctx.delay(300), ctx.status(200), ctx.json(checkInResponse));
}),
);
const user = userEvent.setup();
renderDrawer();
@@ -127,13 +132,21 @@ describe('GCP CloudAccountSetupDrawer', () => {
await user.click(screen.getByTestId('gcp-deployment-region-select'));
await user.click(await screen.findByText('Mumbai (asia-south1)'));
const projectIdsInput = document.querySelector(
'#gcp-project-ids-select',
) as HTMLInputElement;
await user.type(projectIdsInput, 'project-a,project-b,');
await addProjectIds(user, 'gcp-project-ids-select', [
'project-a',
'project-b',
]);
await user.click(screen.getByTestId('gcp-connect-account-btn'));
// Switching away mid-request would let its success close the other form.
await waitFor(() => {
expect(screen.getByRole('radiogroup')).toHaveAttribute(
'aria-disabled',
'true',
);
});
await waitFor(() => {
expect(createAccountPayload).not.toBeNull();
});
@@ -141,6 +154,7 @@ describe('GCP CloudAccountSetupDrawer', () => {
expect(createAccountPayload).toStrictEqual({
config: {
gcp: {
connectionMethod: 'manual',
deploymentRegion: 'asia-south1',
deploymentProjectId: 'my-deployment-project-123',
projectIds: ['project-a', 'project-b'],
@@ -193,10 +207,7 @@ describe('GCP CloudAccountSetupDrawer', () => {
await user.click(screen.getByTestId('gcp-deployment-region-select'));
await user.click(await screen.findByText('Mumbai (asia-south1)'));
const projectIdsInput = document.querySelector(
'#gcp-project-ids-select',
) as HTMLInputElement;
await user.type(projectIdsInput, 'project-a,');
await addProjectIds(user, 'gcp-project-ids-select', ['project-a']);
await user.click(screen.getByTestId('gcp-connect-account-btn'));

View File

@@ -0,0 +1,101 @@
import { ComponentProps } from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import CloudAccountSetupDrawer from '../AddNewAccount/CloudAccountSetupDrawer';
import {
connectionCredentialsResponse,
createAgentAccountResponse,
GCP_ACCOUNTS_URL,
GCP_AGENT_ACCOUNT_URL,
GCP_CREDENTIALS_URL,
pendingAgentAccountResponse,
} from './mockData';
// The setup hooks import logEvent by relative path, which the jest.config
// moduleNameMapper (keyed on the `api/common/logEvent` alias) does not
// intercept, so mock the resolved module directly.
jest.mock('../../../../../api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const onClose = jest.fn() as jest.MockedFunction<
ComponentProps<typeof CloudAccountSetupDrawer>['onClose']
>;
const renderDrawer = (): void => {
render(<CloudAccountSetupDrawer onClose={onClose} />);
};
describe('GCP setup drawer connection method switch', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(GCP_CREDENTIALS_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(connectionCredentialsResponse)),
),
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
res(ctx.status(201), ctx.json(createAgentAccountResponse)),
),
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(pendingAgentAccountResponse)),
),
);
});
it('switches between the manual and agent connection methods inside the same drawer', async () => {
const user = userEvent.setup();
renderDrawer();
const drawer = screen.getByTestId('gcp-setup-drawer');
expect(screen.getByTestId('gcp-account-name-input')).toBeInTheDocument();
expect(screen.getByTestId('gcp-connect-account-btn')).toBeInTheDocument();
await user.click(screen.getByTestId('gcp-connection-method-agent'));
await screen.findByTestId('gcp-generate-command-btn');
expect(
screen.queryByTestId('gcp-account-name-input'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('gcp-connect-account-btn'),
).not.toBeInTheDocument();
await user.click(screen.getByTestId('gcp-connection-method-manual'));
await screen.findByTestId('gcp-account-name-input');
expect(
screen.queryByTestId('gcp-generate-command-btn'),
).not.toBeInTheDocument();
// The drawer itself stays mounted, so it does not replay its open animation.
expect(screen.getByTestId('gcp-setup-drawer')).toBe(drawer);
});
it('keeps the drawer width the same across connection methods and setup steps', async () => {
const user = userEvent.setup();
renderDrawer();
const drawer = screen.getByTestId('gcp-setup-drawer');
expect(drawer).toHaveAttribute('data-width', 'extra-wide');
await user.click(screen.getByTestId('gcp-connection-method-agent'));
await waitFor(() => {
expect(screen.getByTestId('gcp-generate-command-btn')).toBeEnabled();
});
expect(drawer).toHaveAttribute('data-width', 'extra-wide');
await user.type(
screen.getByTestId('gcp-deployment-project-id-input'),
'my-deployment-project-123',
);
await user.click(screen.getByTestId('gcp-deployment-region-select'));
await user.click(await screen.findByText('Mumbai (asia-south1)'));
await user.click(screen.getByTestId('gcp-generate-command-btn'));
await screen.findByTestId('gcp-agent-command');
expect(screen.getByTestId('gcp-setup-drawer')).toBe(drawer);
expect(drawer).toHaveAttribute('data-width', 'extra-wide');
});
});

View File

@@ -1,4 +1,7 @@
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
import {
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesGCPConnectionMethodDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
CloudAccount,
GCPCloudAccountConfig,
@@ -17,6 +20,7 @@ export const GCP_ACCOUNT_ID = 'acc-gcp-1';
export const GCP_ACCOUNT_URL = `${GCP_ACCOUNTS_URL}/${GCP_ACCOUNT_ID}`;
export const gcpAccountConfig: GCPCloudAccountConfig = {
connection_method: CloudintegrationtypesGCPConnectionMethodDTO.manual,
deployment_region: 'asia-south1',
deployment_project_id: 'my-deployment-project-123',
project_ids: ['project-a', 'project-b'],
@@ -30,6 +34,19 @@ export const gcpAccount: CloudAccount = {
status: { integration: { last_heartbeat_ts_ms: 1_700_000_000_000 } },
};
export const gcpAgentAccount: CloudAccount = {
id: 'acc-gcp-agent-1',
cloud_account_id: 'acc-gcp-agent-1',
providerAccountId: 'my-agent-project',
config: {
connection_method: CloudintegrationtypesGCPConnectionMethodDTO.agent,
deployment_region: 'us-central1',
deployment_project_id: 'my-agent-project',
project_ids: ['my-agent-project'],
},
status: { integration: { last_heartbeat_ts_ms: 1_700_000_000_000 } },
};
export const listAccountsResponse = {
status: 'success',
data: { accounts: [] },
@@ -63,3 +80,53 @@ export const checkInResponse = {
status: 'success',
data: {},
};
export const gcpConnectionCommand = {
cliCommand:
"curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration gcp terraform --gcp-project 'my-deployment-project-123'",
};
export const createAgentAccountResponse = {
status: 'success',
data: {
id: CLOUD_INTEGRATION_ID,
connectionArtifact: { gcp: gcpConnectionCommand },
},
};
export const GCP_AGENT_ACCOUNT_URL = `${GCP_ACCOUNTS_URL}/${CLOUD_INTEGRATION_ID}`;
const pendingAgentAccount = {
id: CLOUD_INTEGRATION_ID,
orgId: 'org-1',
provider: 'gcp',
providerAccountId: null,
removedAt: null,
agentReport: null,
config: {
gcp: {
connectionMethod: 'agent',
deploymentProjectId: 'my-deployment-project-123',
deploymentRegion: 'asia-south1',
projectIds: ['my-deployment-project-123'],
},
},
};
export const pendingAgentAccountResponse = {
status: 'success',
data: pendingAgentAccount,
};
export const connectedAgentAccountResponse = {
status: 'success',
data: {
...pendingAgentAccount,
providerAccountId: 'my-deployment-project-123',
agentReport: {
timestampMillis: 1_700_000_000_000,
data: {},
syncState: null,
},
},
};

View File

@@ -0,0 +1,17 @@
import { screen, userEvent } from 'tests/test-utils';
/** Opens the project IDs input and adds each ID the way a user does: type, then Enter. */
export const addProjectIds = async (
user: ReturnType<typeof userEvent.setup>,
testId: string,
projectIds: string[],
): Promise<void> => {
await user.click(screen.getByTestId(testId));
const input = await screen.findByPlaceholderText(
'Type a project ID and press Enter',
);
for (const projectId of projectIds) {
// eslint-disable-next-line no-await-in-loop
await user.type(input, `${projectId}{Enter}`);
}
};

View File

@@ -3,6 +3,7 @@ import { Button } from '@signozhq/ui/button';
import { Modal } from 'antd/lib';
import logEvent from 'api/common/logEvent';
import { useDisconnectAccount } from 'api/generated/services/cloudintegration';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import {
INTEGRATION_TELEMETRY_EVENTS,
@@ -17,10 +18,12 @@ function RemoveIntegrationAccount({
cloudProvider,
accountId,
onRemoveIntegrationAccountSuccess,
gcpConnectionMethod,
}: {
cloudProvider: string;
accountId: string;
onRemoveIntegrationAccountSuccess: () => void;
gcpConnectionMethod?: CloudintegrationtypesGCPConnectionMethodDTO;
}): JSX.Element {
const { notifications } = useNotifications();
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -44,7 +47,7 @@ function RemoveIntegrationAccount({
},
});
const handleOk = (): void => {
logEvent(INTEGRATION_TELEMETRY_EVENTS.INTEGRATION_ACCOUNT_REMOVED, {
void logEvent(INTEGRATION_TELEMETRY_EVENTS.INTEGRATION_ACCOUNT_REMOVED, {
accountId,
integration: cloudProvider,
});
@@ -73,6 +76,20 @@ function RemoveIntegrationAccount({
manually when connecting this account.
</>
);
} else if (
cloudProvider === INTEGRATION_TYPES.GCP &&
gcpConnectionMethod === CloudintegrationtypesGCPConnectionMethodDTO.agent
) {
modalDescription = (
<>
Removing this account will stop SigNoz from monitoring it, and the agent
will disable the telemetry resources it created in your GCP project. <br />
<br />
After that, you can delete the signoz-integration-bootstrap Infrastructure
Manager deployment, the signoz-integration-bootstrap service account and the
signoz-integration secrets that were created while connecting this account.
</>
);
} else if (cloudProvider === INTEGRATION_TYPES.GCP) {
modalDescription = (
<>
@@ -129,4 +146,8 @@ function RemoveIntegrationAccount({
);
}
RemoveIntegrationAccount.defaultProps = {
gcpConnectionMethod: undefined,
};
export default RemoveIntegrationAccount;

View File

@@ -0,0 +1,51 @@
import {
CloudintegrationtypesAccountDTO,
CloudintegrationtypesGCPConnectionMethodDTO,
} from 'api/generated/services/sigNoz.schemas';
import { mapAccountDtoToGcpCloudAccount } from '../mapCloudAccountFromDto';
const gcpAccountDto: CloudintegrationtypesAccountDTO = {
id: 'acc-gcp-1',
orgId: 'org-1',
provider: 'gcp',
providerAccountId: 'my-project',
removedAt: null,
agentReport: { timestampMillis: 1_700_000_000_000, data: {}, syncState: null },
config: {
gcp: {
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
deploymentProjectId: 'my-project',
deploymentRegion: 'us-central1',
projectIds: ['my-project'],
},
},
};
describe('mapAccountDtoToGcpCloudAccount', () => {
it('maps the GCP config, including its connection method', () => {
const account = mapAccountDtoToGcpCloudAccount(gcpAccountDto);
expect(account?.config).toStrictEqual({
connection_method: CloudintegrationtypesGCPConnectionMethodDTO.agent,
deployment_region: 'us-central1',
deployment_project_id: 'my-project',
project_ids: ['my-project'],
});
});
it('skips an account that has no GCP config', () => {
expect(
mapAccountDtoToGcpCloudAccount({ ...gcpAccountDto, config: {} }),
).toBeNull();
});
it('skips an account the agent has not checked in for yet', () => {
expect(
mapAccountDtoToGcpCloudAccount({
...gcpAccountDto,
providerAccountId: null,
}),
).toBeNull();
});
});

View File

@@ -51,7 +51,8 @@ export function mapAccountDtoToAzureCloudAccount(
export function mapAccountDtoToGcpCloudAccount(
account: CloudintegrationtypesAccountDTO,
): IntegrationCloudAccount | null {
if (!account.providerAccountId) {
const gcpConfig = account.config.gcp;
if (!account.providerAccountId || !gcpConfig) {
return null;
}
@@ -59,9 +60,10 @@ export function mapAccountDtoToGcpCloudAccount(
id: account.id,
cloud_account_id: account.id,
config: {
deployment_region: account.config?.gcp?.deploymentRegion ?? '',
deployment_project_id: account.config?.gcp?.deploymentProjectId ?? '',
project_ids: account.config?.gcp?.projectIds ?? [],
connection_method: gcpConfig.connectionMethod,
deployment_region: gcpConfig.deploymentRegion,
deployment_project_id: gcpConfig.deploymentProjectId,
project_ids: gcpConfig.projectIds,
},
status: {
integration: {

View File

@@ -1,3 +1,4 @@
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import {
AWSCloudAccountConfig,
AWSServiceConfig,
@@ -102,6 +103,7 @@ export interface AzureCloudAccountConfig {
}
export interface GCPCloudAccountConfig {
connection_method: CloudintegrationtypesGCPConnectionMethodDTO;
deployment_region: string;
deployment_project_id: string;
project_ids: string[];

View File

@@ -0,0 +1,67 @@
import {
CloudintegrationtypesAccountDTO,
CloudintegrationtypesGCPConnectionMethodDTO,
} from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { useAgentConnectionPolling } from '../useAgentConnectionPolling';
type PollingProps = Parameters<typeof useAgentConnectionPolling>[0];
export const onConnected = jest.fn() as jest.MockedFunction<
PollingProps['onConnected']
>;
export const onTimeout = jest.fn() as jest.MockedFunction<
PollingProps['onTimeout']
>;
export const onError = jest.fn() as jest.MockedFunction<
PollingProps['onError']
>;
export const callbacks = { onConnected, onTimeout, onError };
export const ACCOUNT_ID = 'acc-gcp-1';
export const ACCOUNT_URL = `http://localhost/api/v1/cloud_integrations/gcp/accounts/${ACCOUNT_ID}`;
const agentReport = {
timestampMillis: 1_700_000_000_000,
data: {},
syncState: null,
};
export const pendingAccount: CloudintegrationtypesAccountDTO = {
id: ACCOUNT_ID,
orgId: 'org-1',
provider: 'gcp',
providerAccountId: null,
removedAt: null,
agentReport: null,
config: {
gcp: {
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
deploymentProjectId: 'my-deployment-project-123',
deploymentRegion: 'us-central1',
projectIds: ['my-deployment-project-123'],
},
},
};
export const connectedAccount: CloudintegrationtypesAccountDTO = {
...pendingAccount,
providerAccountId: 'my-deployment-project-123',
agentReport,
};
export const mockAccountResponses = (
...accounts: CloudintegrationtypesAccountDTO[]
): { requestCount: () => number } => {
let count = 0;
server.use(
rest.get(ACCOUNT_URL, (_req, res, ctx) => {
const account = accounts[Math.min(count, accounts.length - 1)];
count += 1;
return res(ctx.status(200), ctx.json({ status: 'success', data: account }));
}),
);
return { requestCount: (): number => count };
};

View File

@@ -0,0 +1,94 @@
import { act, AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import {
CONNECTION_TIMEOUT_MS,
POLL_INTERVAL_MS,
useAgentConnectionPolling,
} from '../useAgentConnectionPolling';
import {
ACCOUNT_ID,
ACCOUNT_URL,
callbacks,
connectedAccount,
mockAccountResponses,
onConnected,
onError,
onTimeout,
pendingAccount,
} from './pollingTestHelpers';
const renderPolling = (): void => {
renderHook(
() =>
useAgentConnectionPolling({
accountId: ACCOUNT_ID,
enabled: true,
...callbacks,
}),
{ wrapper: AllTheProviders },
);
};
describe('useAgentConnectionPolling outcomes', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('keeps polling until the agent checks in, then reports the connection', async () => {
const { requestCount } = mockAccountResponses(
pendingAccount,
pendingAccount,
connectedAccount,
);
renderPolling();
await waitFor(() => expect(requestCount()).toBe(1));
await act(async () => {
jest.advanceTimersByTime(POLL_INTERVAL_MS);
});
await waitFor(() => expect(requestCount()).toBe(2));
await act(async () => {
jest.advanceTimersByTime(POLL_INTERVAL_MS);
});
await waitFor(() =>
expect(onConnected).toHaveBeenCalledWith(connectedAccount),
);
expect(onTimeout).not.toHaveBeenCalled();
expect(onError).not.toHaveBeenCalled();
});
it('does not treat a removed account as connected', async () => {
const { requestCount } = mockAccountResponses({
...connectedAccount,
removedAt: '2026-01-01T00:00:00Z',
});
renderPolling();
await waitFor(() => expect(requestCount()).toBe(1));
await act(async () => {
jest.advanceTimersByTime(CONNECTION_TIMEOUT_MS);
});
await waitFor(() => expect(onTimeout).toHaveBeenCalled());
expect(onConnected).not.toHaveBeenCalled();
});
it('reports an error when the account request fails', async () => {
server.use(
rest.get(ACCOUNT_URL, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
renderPolling();
await waitFor(() => expect(onError).toHaveBeenCalled());
expect(onConnected).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,83 @@
import { act, AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
import {
CONNECTION_TIMEOUT_MS,
POLL_INTERVAL_MS,
useAgentConnectionPolling,
} from '../useAgentConnectionPolling';
import {
ACCOUNT_ID,
callbacks,
connectedAccount,
mockAccountResponses,
onConnected,
onTimeout,
pendingAccount,
} from './pollingTestHelpers';
const renderPolling = (
initialEnabled: boolean,
): ((enabled: boolean) => void) => {
const { rerender } = renderHook(
({ enabled }: { enabled: boolean }) =>
useAgentConnectionPolling({
accountId: ACCOUNT_ID,
enabled,
...callbacks,
}),
{ wrapper: AllTheProviders, initialProps: { enabled: initialEnabled } },
);
return (enabled: boolean): void => rerender({ enabled });
};
describe('useAgentConnectionPolling polling window', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('does not poll while disabled', async () => {
const { requestCount } = mockAccountResponses(connectedAccount);
renderPolling(false);
await act(async () => {
jest.advanceTimersByTime(POLL_INTERVAL_MS * 3);
});
expect(requestCount()).toBe(0);
expect(onConnected).not.toHaveBeenCalled();
});
it('reports a timeout once the deadline passes without a check-in', async () => {
const { requestCount } = mockAccountResponses(pendingAccount);
renderPolling(true);
await waitFor(() => expect(requestCount()).toBe(1));
expect(onTimeout).not.toHaveBeenCalled();
await act(async () => {
jest.advanceTimersByTime(CONNECTION_TIMEOUT_MS);
});
await waitFor(() => expect(onTimeout).toHaveBeenCalled());
expect(onConnected).not.toHaveBeenCalled();
});
it('starts the timeout clock when polling is enabled, not on mount', async () => {
const { requestCount } = mockAccountResponses(pendingAccount);
const setEnabled = renderPolling(false);
// Longer than the whole window passes before polling is turned on.
await act(async () => {
jest.advanceTimersByTime(CONNECTION_TIMEOUT_MS + POLL_INTERVAL_MS);
});
setEnabled(true);
await waitFor(() => expect(requestCount()).toBe(1));
expect(onTimeout).not.toHaveBeenCalled();
});
});

View File

@@ -87,8 +87,7 @@ export function useAccountSettingsDrawer({
{
onSuccess: () => {
const nextConfig = {
deployment_region: accountConfig.deployment_region,
deployment_project_id: accountConfig.deployment_project_id,
...accountConfig,
project_ids: values.projectIds || [],
};

View File

@@ -0,0 +1,138 @@
import { useCallback, useState } from 'react';
import {
CloudintegrationtypesAccountDTO,
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesGCPConnectionMethodDTO,
} from 'api/generated/services/sigNoz.schemas';
import { GcpSetupFormValues } from 'container/Integrations/CloudIntegration/GoogleCloudPlatform/AddNewAccount/types';
import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
import logEvent from '../../../api/common/logEvent';
import { useAgentConnectionPolling } from './useAgentConnectionPolling';
import { useGcpAccountSetup } from './useGcpAccountSetup';
const GENERATE_COMMAND_ERROR = 'Failed to generate the GCP setup command';
interface UseAgentAccountSetupProps {
onClose: () => void;
onCommandGenerated: () => void;
}
interface UseAgentAccountSetup {
isLoading: boolean;
generateCommand: (values: GcpSetupFormValues) => Promise<void>;
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
submitError: string | null;
clearSubmitError: () => void;
connectionCommand: string | null;
/** The project the generated command targets, as submitted. */
commandProjectId: string;
modalState: ModalStateEnum;
checkConnectionAgain: () => void;
}
export function useAgentAccountSetup({
onClose,
onCommandGenerated,
}: UseAgentAccountSetupProps): UseAgentAccountSetup {
const {
connectionParams,
isConnectionParamsLoading,
isLoading,
submitError,
setSubmitError,
clearSubmitError,
createGcpAccount,
runSubmit,
handleConnected,
} = useGcpAccountSetup({ onClose });
const [connectionCommand, setConnectionCommand] = useState<string | null>(
null,
);
const [commandProjectId, setCommandProjectId] = useState('');
const [accountId, setAccountId] = useState<string | null>(null);
const [modalState, setModalState] = useState<ModalStateEnum>(
ModalStateEnum.FORM,
);
const generateCommand = useCallback(
(values: GcpSetupFormValues): Promise<void> =>
runSubmit(async () => {
const account = await createGcpAccount(
{
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
deploymentRegion: values.deploymentRegion,
deploymentProjectId: values.deploymentProjectId,
// The agent monitors only the project it is deployed in.
projectIds: [values.deploymentProjectId],
},
values,
);
const command = account.connectionArtifact.gcp?.cliCommand;
if (!command) {
setSubmitError(GENERATE_COMMAND_ERROR);
return;
}
setAccountId(account.id);
setCommandProjectId(values.deploymentProjectId);
setConnectionCommand(command);
setModalState(ModalStateEnum.WAITING);
onCommandGenerated();
void logEvent('GCP Integration: Account connection command generated', {
id: account.id,
});
}, GENERATE_COMMAND_ERROR),
[createGcpAccount, onCommandGenerated, runSubmit, setSubmitError],
);
const handleAgentConnected = useCallback(
(account: CloudintegrationtypesAccountDTO): void => {
handleConnected({
cloudIntegrationId: account.id,
providerAccountId: account.providerAccountId,
});
},
[handleConnected],
);
const handleTimeout = useCallback((): void => {
setModalState(ModalStateEnum.ERROR);
void logEvent('GCP Integration: Account connection attempt timed out', {
id: accountId,
});
}, [accountId]);
const handlePollingError = useCallback((): void => {
setModalState(ModalStateEnum.ERROR);
}, []);
const checkConnectionAgain = useCallback((): void => {
setModalState(ModalStateEnum.WAITING);
}, []);
useAgentConnectionPolling({
accountId: accountId ?? '',
enabled: modalState === ModalStateEnum.WAITING && accountId !== null,
onConnected: handleAgentConnected,
onTimeout: handleTimeout,
onError: handlePollingError,
});
return {
isLoading,
generateCommand,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
connectionCommand,
commandProjectId,
modalState,
checkConnectionAgain,
};
}

View File

@@ -0,0 +1,62 @@
import { useEffect, useRef } from 'react';
import { useGetAccount } from 'api/generated/services/cloudintegration';
import { CloudintegrationtypesAccountDTO } from 'api/generated/services/sigNoz.schemas';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
export const POLL_INTERVAL_MS = 10 * 1000;
export const CONNECTION_TIMEOUT_MS = 10 * 60 * 1000;
interface UseAgentConnectionPollingProps {
accountId: string;
/** Polls exactly while true; the caller decides when there is an account to poll. */
enabled: boolean;
onConnected: (account: CloudintegrationtypesAccountDTO) => void;
onTimeout: () => void;
onError: () => void;
}
/**
* Polls a GCP agent account until the agent checks in. The timeout is measured
* from when polling gets enabled, so re-enabling it starts a fresh window.
*/
export function useAgentConnectionPolling({
accountId,
enabled,
onConnected,
onTimeout,
onError,
}: UseAgentConnectionPollingProps): void {
const startTimeRef = useRef<number | null>(null);
useEffect(() => {
startTimeRef.current = enabled ? Date.now() : null;
}, [enabled]);
useGetAccount(
{ cloudProvider: INTEGRATION_TYPES.GCP, id: accountId },
{
query: {
enabled,
refetchInterval: POLL_INTERVAL_MS,
select: (response): CloudintegrationtypesAccountDTO => response.data,
onSuccess: (account) => {
const isConnected =
Boolean(account.providerAccountId) && account.removedAt === null;
if (isConnected) {
onConnected(account);
return;
}
const startTime = startTimeRef.current ?? Date.now();
if (Date.now() - startTime >= CONNECTION_TIMEOUT_MS) {
onTimeout();
}
},
onError: () => {
onError();
},
},
},
);
}

View File

@@ -2,16 +2,15 @@ import { useCallback, useState } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import {
CreateAccountMutationResult,
GetConnectionCredentialsQueryResult,
invalidateListAccounts,
useAgentCheckIn,
useCreateAccount,
useGetConnectionCredentials,
} from 'api/generated/services/cloudintegration';
import {
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesPostableAccountDTO,
CloudintegrationtypesGCPPostableAccountConfigDTO,
CloudintegrationtypesGettableAccountWithConnectionArtifactDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
@@ -22,23 +21,36 @@ import { toAPIError } from 'utils/errorUtils';
import logEvent from '../../../api/common/logEvent';
interface UseCloudAccountSetupDrawerProps {
interface UseGcpAccountSetupProps {
onClose: () => void;
}
interface UseCloudAccountSetupDrawer {
isLoading: boolean;
connectAccount: (values: GcpSetupFormValues) => Promise<void>;
handleClose: () => void;
interface UseGcpAccountSetup {
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
isLoading: boolean;
submitError: string | null;
setSubmitError: (message: string | null) => void;
clearSubmitError: () => void;
createGcpAccount: (
gcpConfig: CloudintegrationtypesGCPPostableAccountConfigDTO,
values: GcpSetupFormValues,
) => Promise<CloudintegrationtypesGettableAccountWithConnectionArtifactDTO>;
/** Runs a submit, showing the backend's message inline (or `fallbackMessage`) when it fails. */
runSubmit: (
work: () => Promise<void>,
fallbackMessage: string,
) => Promise<void>;
handleConnected: (payload: {
cloudIntegrationId: string;
providerAccountId: string | null;
}) => void;
}
export function useCloudAccountSetupDrawer({
/** What both GCP connection methods share: credentials, account creation, submit state and the connected toast. */
export function useGcpAccountSetup({
onClose,
}: UseCloudAccountSetupDrawerProps): UseCloudAccountSetupDrawer {
}: UseGcpAccountSetupProps): UseGcpAccountSetup {
const queryClient = useQueryClient();
const [isLoading, setIsLoading] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
@@ -48,7 +60,6 @@ export function useCloudAccountSetupDrawer({
}, []);
const { mutateAsync: createAccount } = useCreateAccount();
const { mutateAsync: checkIn } = useAgentCheckIn();
const handleError = useAxiosError();
const { data: connectionParams, isLoading: isConnectionParamsLoading } =
@@ -63,44 +74,15 @@ export function useCloudAccountSetupDrawer({
},
);
const handleClose = useCallback((): void => {
onClose();
}, [onClose]);
const handleConnectionSuccess = useCallback(
(payload: {
cloudIntegrationId: string;
providerAccountId: string;
}): void => {
void logEvent('GCP Integration: Account connected', {
cloudIntegrationId: payload.cloudIntegrationId,
providerAccountId: payload.providerAccountId,
});
toast.success('GCP account connected successfully', {
position: 'bottom-right',
});
void invalidateListAccounts(queryClient, {
cloudProvider: INTEGRATION_TYPES.GCP,
});
handleClose();
},
[handleClose, queryClient],
);
const connectAccount = useCallback(
async (values: GcpSetupFormValues): Promise<void> => {
try {
setIsLoading(true);
setSubmitError(null);
const payload: CloudintegrationtypesPostableAccountDTO = {
config: {
gcp: {
deploymentRegion: values.deploymentRegion,
deploymentProjectId: values.deploymentProjectId,
projectIds: values.projectIds || [],
},
},
const createGcpAccount = useCallback(
async (
gcpConfig: CloudintegrationtypesGCPPostableAccountConfigDTO,
values: GcpSetupFormValues,
): Promise<CloudintegrationtypesGettableAccountWithConnectionArtifactDTO> => {
const response = await createAccount({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: {
config: { gcp: gcpConfig },
credentials: {
// Cloud users can't edit these — the backend-provided credentials are
// authoritative. Enterprise users have no backend defaults and enter
@@ -110,56 +92,69 @@ export function useCloudAccountSetupDrawer({
sigNozApiUrl: connectionParams?.data?.sigNozApiUrl || values.sigNozApiUrl,
sigNozApiKey: connectionParams?.data?.sigNozApiKey || values.sigNozApiKey,
},
};
},
});
// Step 1: create the integration account.
const createResponse: CreateAccountMutationResult = await createAccount({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: payload,
});
return response.data;
},
[connectionParams, createAccount],
);
const cloudIntegrationId = createResponse.data.id;
const providerAccountId = values.accountName;
void logEvent('GCP Integration: Account created', {
id: cloudIntegrationId,
});
// Step 2: mimic the agent by checking in from the frontend (manual flow).
await checkIn({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: {
providerAccountId,
cloudIntegrationId,
data: {},
},
});
handleConnectionSuccess({ cloudIntegrationId, providerAccountId });
const runSubmit = useCallback(
async (work: () => Promise<void>, fallbackMessage: string): Promise<void> => {
try {
setIsLoading(true);
setSubmitError(null);
await work();
} catch (error) {
// Surface the backend's message inline in the drawer instead of a
// generic failure string.
const message = toAPIError(
error as ErrorType<RenderErrorResponseDTO>,
'Failed to connect GCP account',
fallbackMessage,
).getErrorMessage();
setSubmitError(message);
} finally {
setIsLoading(false);
}
},
[connectionParams, createAccount, checkIn, handleConnectionSuccess],
[],
);
const handleConnected = useCallback(
({
cloudIntegrationId,
providerAccountId,
}: {
cloudIntegrationId: string;
providerAccountId: string | null;
}): void => {
void logEvent('GCP Integration: Account connected', {
cloudIntegrationId,
providerAccountId,
});
toast.success('GCP account connected successfully', {
position: 'bottom-right',
});
void invalidateListAccounts(queryClient, {
cloudProvider: INTEGRATION_TYPES.GCP,
});
onClose();
},
[onClose, queryClient],
);
return {
isLoading,
connectAccount,
handleClose,
connectionParams: connectionParams?.data as
| CloudintegrationtypesCredentialsDTO
| undefined,
isConnectionParamsLoading,
isLoading,
submitError,
setSubmitError,
clearSubmitError,
createGcpAccount,
runSubmit,
handleConnected,
};
}

View File

@@ -0,0 +1,86 @@
import { useCallback } from 'react';
import { useAgentCheckIn } from 'api/generated/services/cloudintegration';
import {
CloudintegrationtypesCredentialsDTO,
CloudintegrationtypesGCPConnectionMethodDTO,
} from 'api/generated/services/sigNoz.schemas';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { GcpSetupFormValues } from 'container/Integrations/CloudIntegration/GoogleCloudPlatform/AddNewAccount/types';
import logEvent from '../../../api/common/logEvent';
import { useGcpAccountSetup } from './useGcpAccountSetup';
interface UseManualAccountSetupProps {
onClose: () => void;
}
interface UseManualAccountSetup {
isLoading: boolean;
connectAccount: (values: GcpSetupFormValues) => Promise<void>;
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
submitError: string | null;
clearSubmitError: () => void;
}
export function useManualAccountSetup({
onClose,
}: UseManualAccountSetupProps): UseManualAccountSetup {
const {
connectionParams,
isConnectionParamsLoading,
isLoading,
submitError,
clearSubmitError,
createGcpAccount,
runSubmit,
handleConnected,
} = useGcpAccountSetup({ onClose });
const { mutateAsync: checkIn } = useAgentCheckIn();
const connectAccount = useCallback(
(values: GcpSetupFormValues): Promise<void> =>
runSubmit(async () => {
// Step 1: create the integration account.
const account = await createGcpAccount(
{
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.manual,
deploymentRegion: values.deploymentRegion,
deploymentProjectId: values.deploymentProjectId,
projectIds: values.projectIds || [],
},
values,
);
const cloudIntegrationId = account.id;
const providerAccountId = values.accountName;
void logEvent('GCP Integration: Account created', {
id: cloudIntegrationId,
});
// Step 2: mimic the agent by checking in from the frontend (manual flow).
await checkIn({
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
data: {
providerAccountId,
cloudIntegrationId,
data: {},
},
});
handleConnected({ cloudIntegrationId, providerAccountId });
}, 'Failed to connect GCP account'),
[checkIn, createGcpAccount, handleConnected, runSubmit],
);
return {
isLoading,
connectAccount,
connectionParams,
isConnectionParamsLoading,
submitError,
clearSubmitError,
};
}

View File

@@ -1,134 +0,0 @@
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, initialQueryState } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { AllTheProviders, renderHook } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
const UNCONSTRAINED_ROUTE = '/unconstrained';
const renderOn = (
compositeQuery: string,
route: string = UNCONSTRAINED_ROUTE,
): Query | null => {
const search = new URLSearchParams();
search.set('compositeQuery', compositeQuery);
const { result } = renderHook(() => useGetCompositeQueryParam(), {
wrapper: ({ children }): JSX.Element => (
<AllTheProviders initialRoute={`${route}?${search}`}>
{children}
</AllTheProviders>
),
});
return result.current;
};
const logsWith = (item: object): string =>
JSON.stringify({
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: [{ ...initialQueriesMap.logs.builder.queryData[0], ...item }],
},
});
const withHavingConverted = (queryData: object[]): object[] =>
queryData.map((item) => ({ ...item, having: { expression: '' } }));
describe('useGetCompositeQueryParam', () => {
it('treats a non-object or broken compositeQuery as no query', () => {
['[]', '"x"', '5', 'null', '{not json'].forEach((raw) =>
expect(renderOn(raw)).toBeNull(),
);
});
it('repairs the builder shape and converts the legacy having', () => {
const defaultQueryData = withHavingConverted(
initialQueryState.builder.queryData,
);
[{ a: 1 }, [null, initialQueriesMap.logs.builder.queryData[0]]].forEach(
(queryData) =>
expect(
renderOn(
JSON.stringify({
...initialQueriesMap.logs,
builder: { ...initialQueriesMap.logs.builder, queryData },
}),
)?.builder.queryData,
).toStrictEqual(defaultQueryData),
);
expect(
renderOn(logsWith({ having: [null] }))?.builder.queryData[0].having,
).toStrictEqual({ expression: '' });
expect(renderOn(JSON.stringify(initialQueriesMap.logs))).toStrictEqual({
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: withHavingConverted(initialQueriesMap.logs.builder.queryData),
},
});
});
it('gives an id-less query with a filter the same id on every parse', () => {
const { id: _, ...withoutId } = JSON.parse(
logsWith({ filter: { expression: "service.name='x'" } }),
);
const first = renderOn(JSON.stringify(withoutId));
const again = renderOn(JSON.stringify(withoutId));
const other = renderOn(
JSON.stringify({ ...withoutId, queryType: EQueryType.PROM }),
);
expect(first?.id).toHaveLength(36);
expect(again?.id).toBe(first?.id);
expect(other?.id).not.toBe(first?.id);
});
it('applies the page constraints on every constrained route', () => {
const clickhouseWithOperator = JSON.stringify({
...initialQueriesMap.logs,
queryType: EQueryType.CLICKHOUSE,
builder: {
...initialQueriesMap.logs.builder,
queryData: [
{
...initialQueriesMap.metrics.builder.queryData[0],
source: TelemetrytypesSourceDTO.meter,
},
],
queryTraceOperator: [initialQueriesMap.traces.builder.queryData[0]],
},
});
[
[ROUTES.LOGS_EXPLORER, DataSource.LOGS, ''],
[ROUTES.AI_OBSERVABILITY_EXPLORER, DataSource.TRACES, ''],
[ROUTES.METRICS_EXPLORER_EXPLORER, DataSource.METRICS, ''],
[ROUTES.METER_EXPLORER, DataSource.METRICS, TelemetrytypesSourceDTO.meter],
].forEach(([route, dataSource, source]) => {
const query = renderOn(clickhouseWithOperator, route);
expect(query?.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(query?.builder.queryData[0].dataSource).toBe(dataSource);
expect(query?.builder.queryData[0].source ?? '').toBe(source);
expect(query?.builder.queryTraceOperator).toStrictEqual([]);
});
});
it('keeps the query type on a route without constraints', () => {
const query = renderOn(
JSON.stringify({
...initialQueriesMap.metrics,
queryType: EQueryType.CLICKHOUSE,
}),
);
expect(query?.queryType).toBe(EQueryType.CLICKHOUSE);
expect(query?.builder.queryData[0].dataSource).toBe(DataSource.METRICS);
});
});

View File

@@ -1,297 +0,0 @@
import {
initialQueriesMap,
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import {
Querybuildertypesv5QueryTypeDTO,
TelemetrytypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
IBuilderQuery,
QueryMode,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import {
buildPageQuerySchema,
normalizeCompositeQuery,
} from './compositeQuerySchema';
const logsSchema = buildPageQuerySchema({
queryTypes: [EQueryType.QUERY_BUILDER],
dataSource: DataSource.LOGS,
});
const tracesSchema = buildPageQuerySchema({
queryTypes: [EQueryType.QUERY_BUILDER],
dataSource: DataSource.TRACES,
traceOperator: true,
});
const meterSchema = buildPageQuerySchema({
queryTypes: [EQueryType.QUERY_BUILDER],
dataSource: DataSource.METRICS,
source: TelemetrytypesSourceDTO.meter,
});
const aiSchema = buildPageQuerySchema({
queryTypes: [EQueryType.QUERY_BUILDER],
dataSource: DataSource.TRACES,
builderQueryType: QueryMode.AI_QUERY_BUILDER,
});
const builderlessUrlQuery = {
queryType: 'clickhouse_sql',
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
id: 'b8084e0c-c13d-4172-a775-f61aedadb79e',
unit: '',
};
const logsItem = (overrides: object = {}): object => ({
...initialQueryBuilderFormValuesMap.logs,
...overrides,
});
const queryOf = (
base: typeof initialQueriesMap.logs,
queryData: unknown[],
builder: object = {},
): object => ({
...base,
builder: { ...base.builder, queryData, ...builder },
});
const logsQuery = (queryData: unknown[], builder?: object): object =>
queryOf(initialQueriesMap.logs, queryData, builder);
const without = (item: object, key: keyof IBuilderQuery): object => {
const { [key]: _, ...rest } = item as IBuilderQuery;
return rest;
};
describe('normalizeCompositeQuery', () => {
it('passes valid queries through unchanged', () => {
const withoutSource = logsQuery([
without(initialQueriesMap.logs.builder.queryData[0], 'source'),
]);
const clickhouse = {
...initialQueriesMap.logs,
queryType: EQueryType.CLICKHOUSE,
clickhouse_sql: [
{ name: 'A', legend: '', disabled: false, query: 'SELECT 1' },
],
};
const unmodeled = normalizeCompositeQuery({
...logsQuery([logsItem({ builderQueryType: QueryMode.AI_QUERY_BUILDER })]),
extra: 'kept',
}).query;
expect(normalizeCompositeQuery(initialQueriesMap.logs)).toStrictEqual({
query: initialQueriesMap.logs,
changed: false,
});
expect(
normalizeCompositeQuery(initialQueriesMap.logs, logsSchema),
).toStrictEqual({ query: initialQueriesMap.logs, changed: false });
expect(normalizeCompositeQuery(withoutSource, logsSchema).changed).toBe(
false,
);
expect(
normalizeCompositeQuery(
clickhouse,
buildPageQuerySchema({ dataSource: DataSource.LOGS }),
),
).toStrictEqual({ query: clickhouse, changed: false });
expect(unmodeled.builder.queryData[0].builderQueryType).toBe(
QueryMode.AI_QUERY_BUILDER,
);
expect((unmodeled as { extra?: string }).extra).toBe('kept');
});
it('fills missing blocks with defaults and a stable id', () => {
const sharedBefore = JSON.stringify(initialQueryState.builder);
const builderless = normalizeCompositeQuery(builderlessUrlQuery);
builderless.query.builder.queryFormulas.push(
{} as (typeof builderless.query.builder.queryFormulas)[number],
);
const noArrays = normalizeCompositeQuery({
...initialQueriesMap.logs,
builder: { queryData: initialQueriesMap.logs.builder.queryData },
});
const omittedBlocks = normalizeCompositeQuery(
{
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [logsItem()], queryFormulas: [] },
},
logsSchema,
);
const { id: _, ...withoutId } = initialQueriesMap.logs;
const id = normalizeCompositeQuery(withoutId).query.id;
expect(builderless.query.queryType).toBe(EQueryType.CLICKHOUSE);
expect(builderless.query.id).toBe(builderlessUrlQuery.id);
expect(builderless.changed).toBe(true);
expect(JSON.stringify(initialQueryState.builder)).toBe(sharedBefore);
expect(noArrays.query.builder.queryFormulas).toStrictEqual([]);
expect(noArrays.query.builder.queryTraceOperator).toStrictEqual([]);
expect(noArrays.changed).toBe(true);
[[], [5, logsItem({ queryName: 'B' })]].forEach((queryData) =>
expect(
normalizeCompositeQuery(logsQuery(queryData)).query.builder.queryData,
).toStrictEqual(initialQueryState.builder.queryData),
);
expect(omittedBlocks.query.promql).toStrictEqual(initialQueryState.promql);
expect(omittedBlocks.query.clickhouse_sql).toStrictEqual(
initialQueryState.clickhouse_sql,
);
expect(omittedBlocks.query.builder.queryData[0].dataSource).toBe(
DataSource.LOGS,
);
expect(id).toHaveLength(36);
expect(normalizeCompositeQuery(withoutId).query.id).toBe(id);
expect(
normalizeCompositeQuery({ ...withoutId, queryType: EQueryType.PROM }).query
.id,
).not.toBe(id);
});
it('resets query types and text the page cannot run', () => {
const builderless = normalizeCompositeQuery(builderlessUrlQuery, logsSchema);
const withText = normalizeCompositeQuery(
{
...initialQueriesMap.logs,
clickhouse_sql: [
{ name: 'A', legend: '', disabled: false, query: 'SELECT 1' },
],
promql: [{ name: 'A', legend: '', disabled: false, query: 'up' }],
},
logsSchema,
);
expect(builderless.query.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(builderless.query.builder.queryData).toStrictEqual([
{ ...initialQueryBuilderFormValuesMap.logs, source: '' },
]);
expect(withText.query.clickhouse_sql).toStrictEqual(
initialQueryState.clickhouse_sql,
);
expect(withText.query.promql).toStrictEqual(initialQueryState.promql);
expect(withText.changed).toBe(true);
[EQueryType.CLICKHOUSE, EQueryType.PROM].forEach((queryType) =>
expect(
normalizeCompositeQuery(
{ ...initialQueriesMap.traces, queryType },
tracesSchema,
).query.queryType,
).toBe(EQueryType.QUERY_BUILDER),
);
});
it('resets items that do not match the page and keeps their names', () => {
const mixed = normalizeCompositeQuery(
logsQuery([
logsItem({
dataSource: DataSource.TRACES,
queryName: 'B',
expression: 'B',
filter: { expression: "name='GET /api'" },
}),
without(
logsItem({ filter: { expression: "severity='error'" } }),
'dataSource',
),
]),
logsSchema,
).query.builder.queryData;
const meterFilled = normalizeCompositeQuery(
queryOf(initialQueriesMap.metrics, [
{
...without(initialQueriesMap.metrics.builder.queryData[0], 'source'),
filter: { expression: "x='y'" },
},
]),
meterSchema,
).query.builder.queryData[0];
const metricsOnMeter = normalizeCompositeQuery(
initialQueriesMap.metrics,
meterSchema,
);
const tracesItem = {
...initialQueryBuilderFormValuesMap.traces,
filter: { expression: "name='llm'" },
};
const [aiMissing, aiWrong, aiLogs] = [
[tracesItem],
[
{
...tracesItem,
queryName: 'B',
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
},
],
[logsItem({ queryName: 'C' })],
].map(
(queryData) =>
normalizeCompositeQuery(
queryOf(initialQueriesMap.traces, queryData),
aiSchema,
).query.builder.queryData[0],
);
expect(mixed[0]).toStrictEqual({
...initialQueryBuilderFormValuesMap.logs,
source: '',
queryName: 'B',
expression: 'B',
});
expect(mixed[1]).toMatchObject({
dataSource: DataSource.LOGS,
filter: { expression: "severity='error'" },
});
expect(meterFilled).toMatchObject({
source: TelemetrytypesSourceDTO.meter,
filter: { expression: "x='y'" },
});
expect(metricsOnMeter.query.builder.queryData[0].source).toBe(
TelemetrytypesSourceDTO.meter,
);
expect(metricsOnMeter.changed).toBe(true);
expect(aiMissing).toMatchObject({
builderQueryType: QueryMode.AI_QUERY_BUILDER,
filter: { expression: "name='llm'" },
});
expect(aiWrong).toMatchObject({
builderQueryType: QueryMode.AI_QUERY_BUILDER,
queryName: 'B',
filter: { expression: '' },
});
expect(aiLogs).toMatchObject({
builderQueryType: QueryMode.AI_QUERY_BUILDER,
dataSource: DataSource.TRACES,
queryName: 'C',
});
});
it('clears a trace operator unless the page allows it', () => {
const operator = {
queryTraceOperator: [initialQueryBuilderFormValuesMap.traces],
};
const onLogs = normalizeCompositeQuery(
logsQuery([logsItem()], operator),
logsSchema,
);
const onTraces = normalizeCompositeQuery(
queryOf(
initialQueriesMap.traces,
initialQueriesMap.traces.builder.queryData,
operator,
),
tracesSchema,
);
expect(onLogs.query.builder.queryTraceOperator).toStrictEqual([]);
expect(onLogs.changed).toBe(true);
expect(onTraces.query.builder.queryTraceOperator).toHaveLength(1);
expect(onTraces.changed).toBe(false);
});
});

View File

@@ -1,134 +0,0 @@
import {
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import { cloneDeep, isEqual, omitBy } from 'lodash-es';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { BuilderQueryType } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { BuilderQuerySource, DataSource } from 'types/common/queryBuilder';
import { v5 as uuidv5 } from 'uuid';
import { z } from 'zod';
const COMPOSITE_QUERY_ID_NAMESPACE = '2f1c6a4e-9b3d-4c57-8e21-7a0d5f6b9c13';
export type QueryConstraints = {
dataSource: DataSource;
source?: BuilderQuerySource;
builderQueryType?: BuilderQueryType;
traceOperator?: boolean;
queryTypes?: EQueryType[];
};
export type NormalizedQuery = {
query: Query;
changed: boolean;
};
const fallback = (value: unknown) => (): never => cloneDeep(value) as never;
const builderItemSchema = z
.looseObject({
dataSource: z.enum(DataSource).optional().catch(undefined),
source: z.string().optional().catch(undefined),
})
.transform((item) => omitBy(item, (value) => value === undefined));
const textItemSchema = z.looseObject({ query: z.string().catch('') });
const builderSchema = z.looseObject({
queryData: z
.array(builderItemSchema)
.min(1)
.catch(fallback(initialQueryState.builder.queryData)),
queryFormulas: z.unknown().transform((value) => value ?? []),
queryTraceOperator: z.unknown().transform((value) => value ?? []),
});
const compositeQuerySchema = z.looseObject({
queryType: z.enum(EQueryType).catch(fallback(EQueryType.QUERY_BUILDER)),
builder: builderSchema.catch(fallback(initialQueryState.builder)),
promql: z.array(textItemSchema).catch(fallback(initialQueryState.promql)),
clickhouse_sql: z
.array(textItemSchema)
.catch(fallback(initialQueryState.clickhouse_sql)),
});
const mustBeEmpty = (value: unknown): z.ZodType =>
z.array(z.looseObject({ query: z.literal('') })).catch(fallback(value));
export function buildPageQuerySchema(
constraints: QueryConstraints,
): z.ZodType<unknown> {
const { queryTypes } = constraints;
const itemDefault: IBuilderQuery = {
...initialQueryBuilderFormValuesMap[constraints.dataSource],
source: constraints.source ?? '',
...(constraints.builderQueryType && {
builderQueryType: constraints.builderQueryType,
}),
};
const allows = (type: EQueryType): boolean =>
!queryTypes || queryTypes.includes(type);
const pageItemSchema = builderItemSchema
.transform((item) => ({
...item,
dataSource: item.dataSource ?? constraints.dataSource,
...(constraints.source && { source: item.source ?? constraints.source }),
...(constraints.builderQueryType && {
builderQueryType: item.builderQueryType ?? constraints.builderQueryType,
}),
}))
.refine(
(item) =>
item.dataSource === constraints.dataSource &&
(item.source ?? '') === (constraints.source ?? '') &&
(!constraints.builderQueryType ||
item.builderQueryType === constraints.builderQueryType),
)
.catch(({ input }): never => {
const { queryName, expression } = input as Partial<IBuilderQuery>;
return {
...cloneDeep(itemDefault),
queryName: queryName ?? itemDefault.queryName,
expression: expression ?? itemDefault.expression,
} as never;
});
return compositeQuerySchema.extend({
queryType: queryTypes
? z
.enum(EQueryType)
.refine((type) => queryTypes.includes(type))
.catch(fallback(queryTypes[0]))
: compositeQuerySchema.shape.queryType,
promql: allows(EQueryType.PROM)
? compositeQuerySchema.shape.promql
: mustBeEmpty(initialQueryState.promql),
clickhouse_sql: allows(EQueryType.CLICKHOUSE)
? compositeQuerySchema.shape.clickhouse_sql
: mustBeEmpty(initialQueryState.clickhouse_sql),
builder: builderSchema
.extend({
queryData: z
.array(pageItemSchema)
.min(1)
.catch(fallback([itemDefault])),
queryTraceOperator: constraints.traceOperator
? builderSchema.shape.queryTraceOperator
: z.array(z.never()).catch(fallback([])),
})
.catch(fallback({ ...initialQueryState.builder, queryData: [itemDefault] })),
});
}
export function normalizeCompositeQuery(
raw: unknown,
pageSchema?: z.ZodType<unknown>,
): NormalizedQuery {
const base = compositeQuerySchema.parse(raw) as unknown as Query;
base.id ??= uuidv5(JSON.stringify(raw), COMPOSITE_QUERY_ID_NAMESPACE);
const query = (pageSchema ? pageSchema.parse(base) : base) as Query;
return { query, changed: !isEqual(query, raw) };
}

View File

@@ -1,45 +0,0 @@
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { z } from 'zod';
import { buildPageQuerySchema, QueryConstraints } from './compositeQuerySchema';
const PAGE_QUERY_CONSTRAINTS: Partial<Record<string, QueryConstraints>> = {
[ROUTES.LOGS_EXPLORER]: {
queryTypes: [EQueryType.QUERY_BUILDER],
dataSource: DataSource.LOGS,
},
[ROUTES.TRACES_EXPLORER]: {
queryTypes: [EQueryType.QUERY_BUILDER],
dataSource: DataSource.TRACES,
traceOperator: true,
},
[ROUTES.AI_OBSERVABILITY_EXPLORER]: {
queryTypes: [EQueryType.QUERY_BUILDER],
dataSource: DataSource.TRACES,
builderQueryType: QueryMode.AI_QUERY_BUILDER,
},
[ROUTES.METRICS_EXPLORER_EXPLORER]: {
queryTypes: [EQueryType.QUERY_BUILDER],
dataSource: DataSource.METRICS,
},
[ROUTES.METER_EXPLORER]: {
queryTypes: [EQueryType.QUERY_BUILDER],
dataSource: DataSource.METRICS,
source: TelemetrytypesSourceDTO.meter,
},
};
const pageSchemas = new Map<string, z.ZodType<unknown>>(
Object.entries(PAGE_QUERY_CONSTRAINTS).map(([route, constraints]) => [
route,
buildPageQuerySchema(constraints as QueryConstraints),
]),
);
export const pageQuerySchemaFor = (
pathname: string,
): z.ZodType<unknown> | undefined => pageSchemas.get(pathname);

View File

@@ -1,5 +1,4 @@
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
@@ -7,24 +6,15 @@ import {
} from 'components/QueryBuilderV2/utils';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { isPlainObject } from 'lodash-es';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
NormalizedQuery,
normalizeCompositeQuery,
} from './compositeQuerySchema';
import { pageQuerySchemaFor } from './pageQueryConstraints';
export const useNormalizedCompositeQueryParam = (): NormalizedQuery | null => {
export const useGetCompositeQueryParam = (): Query | null => {
const urlQuery = useUrlQuery();
const { pathname } = useLocation();
return useMemo(() => {
const compositeQuery = urlQuery.get(QueryParams.compositeQuery);
let parsedCompositeQuery: Query | null = null;
let changed = false;
try {
if (!compositeQuery) {
@@ -37,10 +27,13 @@ export const useNormalizedCompositeQueryParam = (): NormalizedQuery | null => {
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
);
({ query: parsedCompositeQuery, changed } = normalizeCompositeQuery(
parsedCompositeQuery,
pageQuerySchemaFor(pathname),
));
// Add default values for optional fields if empty
if (parsedCompositeQuery?.builder) {
parsedCompositeQuery.builder.queryFormulas =
parsedCompositeQuery.builder.queryFormulas ?? [];
parsedCompositeQuery.builder.queryTraceOperator =
parsedCompositeQuery.builder.queryTraceOperator ?? [];
}
// Convert old format to new format for each query in builder.queryData
if (parsedCompositeQuery?.builder?.queryData) {
@@ -58,9 +51,7 @@ export const useNormalizedCompositeQueryParam = (): NormalizedQuery | null => {
// Convert having if needed
if (Array.isArray(query.having)) {
const convertedHaving = convertHavingToExpression(
query.having.filter(isPlainObject),
);
const convertedHaving = convertHavingToExpression(query.having);
convertedQuery.having = convertedHaving;
}
@@ -84,9 +75,6 @@ export const useNormalizedCompositeQueryParam = (): NormalizedQuery | null => {
parsedCompositeQuery = null;
}
return parsedCompositeQuery ? { query: parsedCompositeQuery, changed } : null;
}, [urlQuery, pathname]);
return parsedCompositeQuery;
}, [urlQuery]);
};
export const useGetCompositeQueryParam = (): Query | null =>
useNormalizedCompositeQueryParam()?.query ?? null;

View File

@@ -0,0 +1,28 @@
export function replaceIncorrectObjectFields<
// eslint-disable-next-line @typescript-eslint/ban-types
TargetValue extends object,
// eslint-disable-next-line @typescript-eslint/ban-types
ResultValue extends object,
>(
targetObject: TargetValue,
defaultObject: ResultValue,
): { isValid: boolean; validData: ResultValue } {
const targetObjectKeys = Object.keys(targetObject);
const defaultObjectKeys = Object.keys(defaultObject);
let isValid = true;
const result: ResultValue = { ...defaultObject };
defaultObjectKeys.forEach((key) => {
if (targetObjectKeys.includes(key)) {
result[key as keyof ResultValue] = targetObject[
key as keyof TargetValue
] as unknown as ResultValue[keyof ResultValue];
} else {
isValid = false;
}
});
return { isValid, validData: result };
}

View File

@@ -4,7 +4,11 @@
*/
import { rest } from 'msw';
import type { CloudintegrationtypesUpdatableServiceDTO } from 'api/generated/services/sigNoz.schemas';
import {
CloudintegrationtypesGCPConnectionMethodDTO,
type CloudintegrationtypesPostableAccountDTO,
type CloudintegrationtypesUpdatableServiceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { choiceControl, countControl } from '@/storybook/controls/controls';
@@ -18,9 +22,14 @@ import {
CLOUD_PROVIDERS,
CLOUD_SERVICE_CAP,
type CloudProvider,
GCP_CONNECTION_METHODS,
MUTATION_STATES,
type MutationState,
NEW_ACCOUNT_CHECK_INS,
type NewAccountCheckIn,
createdAccountResponse,
newAccountId,
newAccountResponse,
BUILT_IN_INTEGRATION_NOT_FOUND,
credentialsResponse,
seedServiceSignals,
@@ -36,6 +45,11 @@ import {
const builtInIntegrationNotFound: MockResolver = (_req, res, ctx) =>
res(ctx.status(404), ctx.json(BUILT_IN_INTEGRATION_NOT_FOUND));
const FAILED_REQUEST = {
status: 'error',
error: { code: 'internal', message: 'Request failed' },
};
/**
* Answers a mutation by the control's state: never, with a failure, or with the
* success body the caller builds.
@@ -48,16 +62,33 @@ const mutationResolver =
}
return state === 'error'
? res(
ctx.status(500),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'Request failed' },
}),
)
? res(ctx.status(500), ctx.json(FAILED_REQUEST))
: ok(req, res, ctx, ...rest);
};
/**
* The account the connect flow just created answers by the check-in control;
* every other account is the page's data.
*/
const accountResolver =
(
provider: CloudProvider,
checkIn: NewAccountCheckIn,
existing: MockResolver,
): MockResolver =>
(req, res, ctx, ...rest) => {
if (String(req.params.id) !== newAccountId(provider)) {
return existing(req, res, ctx, ...rest);
}
return checkIn === 'error'
? res(ctx.status(500), ctx.json(FAILED_REQUEST))
: res(
ctx.status(200),
ctx.json(newAccountResponse(provider, checkIn === 'checked-in')),
);
};
const PROVIDER = 'Cloud integration · provider';
const MUTATIONS = 'Cloud integration · mutations';
const SERVICES = 'Cloud integration · services';
@@ -71,6 +102,17 @@ export const cloudIntegrationMocks = defineStoryMocks({
options: CLOUD_PROVIDERS,
value: 'aws',
}),
gcpConnectionMethod:
choiceControl<CloudintegrationtypesGCPConnectionMethodDTO>(
'GCP connection method',
{
group: PROVIDER,
description:
'How the connected GCP accounts were set up. `agent` makes the account settings drawer read-only and switches the Disconnect copy to the agent cleanup.',
options: GCP_CONNECTION_METHODS,
value: CloudintegrationtypesGCPConnectionMethodDTO.manual,
},
),
accounts: countControl('Connected accounts', {
group: PROVIDER,
description:
@@ -99,6 +141,13 @@ export const cloudIntegrationMocks = defineStoryMocks({
options: MUTATION_STATES,
value: 'success',
}),
newAccountCheckIn: choiceControl<NewAccountCheckIn>('New account check-in', {
group: MUTATIONS,
description:
'How the poll for the account the connect flow just created answers. `checked-in` connects it on the first poll and the drawer closes with the success toast, `pending` keeps the drawer waiting on the agent (the GCP agent setup command stays up), `error` fails the poll so the drawer reports it could not connect.',
options: NEW_ACCOUNT_CHECK_INS,
value: 'checked-in',
}),
accountUpdate: choiceControl<MutationState>('Account settings update', {
group: MUTATIONS,
description:
@@ -172,9 +221,19 @@ export const cloudIntegrationMocks = defineStoryMocks({
rest.post(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts',
mutationResolver(values.accountCreate, (_req, res, ctx) =>
res(ctx.status(201), ctx.json(createdAccountResponse(values.provider))),
),
mutationResolver(values.accountCreate, async (req, res, ctx) => {
const body = (await req.json()) as CloudintegrationtypesPostableAccountDTO;
return res(
ctx.status(201),
ctx.json(
createdAccountResponse(
values.provider,
body.config?.gcp?.connectionMethod,
),
),
);
}),
),
rest.put(
@@ -182,21 +241,41 @@ export const cloudIntegrationMocks = defineStoryMocks({
mutationResolver(values.accountUpdate, (req, res, ctx) =>
res(
ctx.status(200),
ctx.json(accountResponse(values.provider, String(req.params.id))),
ctx.json(
accountResponse(
values.provider,
String(req.params.id),
values.gcpConnectionMethod,
),
),
),
),
),
rest.get(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts/:id',
response.json((req) =>
accountResponse(values.provider, String(req.params.id)),
accountResolver(
values.provider,
values.newAccountCheckIn,
response.json((req) =>
accountResponse(
values.provider,
String(req.params.id),
values.gcpConnectionMethod,
),
),
),
),
rest.get(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts',
response.json(() => accountsResponse(values.provider, values.accounts)),
response.json(() =>
accountsResponse(
values.provider,
values.accounts,
values.gcpConnectionMethod,
),
),
),
],
config: (values) => ({
@@ -264,6 +343,17 @@ export const connectAzureAccount = async (
);
};
const addGcpProjectId = async (
testId: string,
projectId: string,
): Promise<void> => {
await userEvent.click(screen.getByTestId(testId));
await userEvent.type(
await screen.findByPlaceholderText('Type a project ID and press Enter'),
`${projectId}{Enter}`,
);
};
export const connectGcpAccount = async (
canvasElement: HTMLElement,
): Promise<void> => {
@@ -278,14 +368,40 @@ export const connectGcpAccount = async (
);
await userEvent.click(screen.getByTestId('gcp-deployment-region-select'));
await userEvent.click(await screen.findByText('Mumbai (asia-south1)'));
await userEvent.type(
document.querySelector<HTMLElement>('#gcp-project-ids-select') as HTMLElement,
'project-a,',
);
await addGcpProjectId('gcp-project-ids-select', 'project-a');
await userEvent.click(screen.getByTestId('gcp-connect-account-btn'));
};
const openEditAccount = async (canvasElement: HTMLElement): Promise<void> => {
export const openGcpAgentSetup = async (
canvasElement: HTMLElement,
): Promise<void> => {
await openConnectFlow(canvasElement);
await userEvent.click(
await screen.findByTestId(
'gcp-connection-method-agent',
undefined,
untilLoaded,
),
);
await screen.findByTestId('gcp-generate-command-btn', undefined, untilLoaded);
};
export const generateGcpAgentCommand = async (
canvasElement: HTMLElement,
): Promise<void> => {
await openGcpAgentSetup(canvasElement);
await userEvent.type(
screen.getByTestId('gcp-deployment-project-id-input'),
'signoz-prod-4471',
);
await userEvent.click(screen.getByTestId('gcp-deployment-region-select'));
await userEvent.click(await screen.findByText('Council Bluffs (us-central1)'));
await userEvent.click(screen.getByTestId('gcp-generate-command-btn'));
};
export const openEditAccount = async (
canvasElement: HTMLElement,
): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/edit account/i,
@@ -308,6 +424,17 @@ export const updateAwsAccount = async (
);
};
export const updateGcpProjects = async (
canvasElement: HTMLElement,
projectId: string,
): Promise<void> => {
await openEditAccount(canvasElement);
await addGcpProjectId('gcp-edit-project-ids-select', projectId);
await userEvent.click(
await screen.findByRole('button', { name: /update changes/i }),
);
};
export const updateTaggedAccount = async (
canvasElement: HTMLElement,
tag: string,

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Route } from 'react-router-dom';
import { screen, userEvent, within } from 'storybook/test';
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
@@ -12,9 +13,13 @@ import {
connectAzureAccount,
connectGcpAccount,
expectToast,
generateGcpAgentCommand,
openConnectFlow,
openEditAccount,
openGcpAgentSetup,
untilLoaded,
updateAwsAccount,
updateGcpProjects,
updateTaggedAccount,
} from './CloudIntegration.stories.mocks';
@@ -251,6 +256,111 @@ export const GcpRegionOpen: Story = {
},
};
/**
* The GCP connect drawer on its agent connection method: only the deployment
* project, region and credentials, since the agent monitors the project it is
* deployed in.
*/
export const GcpAgentSetup: Story = {
args: { provider: 'gcp', accounts: 0 },
play: async ({ canvasElement }): Promise<void> => {
await openGcpAgentSetup(canvasElement);
},
};
/**
* The setup command the agent connection generates, with the drawer waiting for
* the agent to check in. Set New account check-in to
* `checked-in` or `error` to see it connect or fail.
*/
export const GcpAgentCommand: Story = {
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'pending' },
play: async ({ canvasElement }): Promise<void> => {
await generateGcpAgentCommand(canvasElement);
await screen.findByTestId('gcp-agent-command', undefined, untilLoaded);
},
};
/** The agent checks in on the first poll, so the drawer closes with a toast. */
export const GcpAgentConnectedToast: Story = {
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'checked-in' },
play: async ({ canvasElement, args }): Promise<void> => {
await generateGcpAgentCommand(canvasElement);
if (args.newAccountCheckIn === 'checked-in') {
await expectToast(/gcp account connected successfully/i);
}
},
};
/**
* The connection poll fails, so the drawer reports it could not connect and
* offers Check Again.
*/
export const GcpAgentConnectionError: Story = {
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'error' },
play: async ({ canvasElement }): Promise<void> => {
await generateGcpAgentCommand(canvasElement);
await screen.findByTestId(
'gcp-agent-connection-error',
undefined,
untilLoaded,
);
},
};
/**
* Leaving the drawer while the agent has not connected yet, which asks first
* because closing means starting the installation again.
*/
export const GcpAgentCloseConfirm: Story = {
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'pending' },
play: async ({ canvasElement }): Promise<void> => {
await generateGcpAgentCommand(canvasElement);
await userEvent.click(
await screen.findByTestId('gcp-cancel-btn', undefined, untilLoaded),
);
await screen.findByTestId('gcp-close-setup-btn', undefined, untilLoaded);
},
};
/**
* The settings of a GCP account connected through the agent: read-only, since
* the backend rejects updates to agent accounts.
*/
export const GcpAgentAccountSettings: Story = {
args: {
provider: 'gcp',
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
},
play: async ({ canvasElement }): Promise<void> => {
await openEditAccount(canvasElement);
await screen.findByTestId(
'gcp-agent-monitored-projects',
undefined,
untilLoaded,
);
},
};
/** Disconnecting an agent account, which names what is left to clean up. */
export const GcpAgentDisconnect: Story = {
args: {
provider: 'gcp',
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
},
play: async ({ canvasElement }): Promise<void> => {
await openEditAccount(canvasElement);
await userEvent.click(
await screen.findByRole('button', { name: /disconnect/i }, untilLoaded),
);
await screen.findByText(
/signoz-integration-bootstrap Infrastructure/i,
undefined,
untilLoaded,
);
},
};
/**
* Saving the regions of a connected AWS account. Set Account settings update
* to `error` for the failure toast.
@@ -301,7 +411,7 @@ export const AzureAccountSettingsUpdateErrorToast: Story = {
export const GcpAccountSettingsUpdatedToast: Story = {
args: { provider: 'gcp' },
play: async ({ canvasElement, args }): Promise<void> => {
await updateTaggedAccount(canvasElement, 'extra-project');
await updateGcpProjects(canvasElement, 'extra-project');
if (args.accountUpdate === 'success') {
await expectToast(/account settings updated successfully/i);
}
@@ -312,7 +422,7 @@ export const GcpAccountSettingsUpdatedToast: Story = {
export const GcpAccountSettingsUpdateErrorToast: Story = {
args: { provider: 'gcp', accountUpdate: 'error' },
play: async ({ canvasElement, args }): Promise<void> => {
await updateTaggedAccount(canvasElement, 'extra-project');
await updateGcpProjects(canvasElement, 'extra-project');
if (args.accountUpdate === 'error') {
await expectToast(/failed to update account settings/i);
}

View File

@@ -3,6 +3,7 @@
* Do not hand-edit: regenerate instead.
*/
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
import type {
CloudintegrationtypesAccountConfigDTO,
CloudintegrationtypesAccountDTO,
@@ -23,6 +24,19 @@ export type CloudProvider = (typeof CLOUD_PROVIDERS)[number];
export const MUTATION_STATES = ['success', 'loading', 'error'] as const;
export type MutationState = (typeof MUTATION_STATES)[number];
export const GCP_CONNECTION_METHODS = [
CloudintegrationtypesGCPConnectionMethodDTO.manual,
CloudintegrationtypesGCPConnectionMethodDTO.agent,
] as const;
/** How the poll for the account the connect flow just created answers. */
export const NEW_ACCOUNT_CHECK_INS = [
'checked-in',
'pending',
'error',
] as const;
export type NewAccountCheckIn = (typeof NEW_ACCOUNT_CHECK_INS)[number];
interface CloudServiceSeed {
id: string;
title: string;
@@ -266,6 +280,7 @@ const ACCOUNTS: Record<CloudProvider, readonly string[]> = {
const accountConfig = (
provider: CloudProvider,
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
): CloudintegrationtypesAccountConfigDTO => {
if (provider === 'aws') {
return { aws: { regions: ['us-east-1', 'eu-west-1', 'ap-south-1'] } };
@@ -280,8 +295,23 @@ const accountConfig = (
};
}
// The backend only accepts the deployment project as an agent account's scope.
if (
gcpConnectionMethod === CloudintegrationtypesGCPConnectionMethodDTO.agent
) {
return {
gcp: {
connectionMethod: gcpConnectionMethod,
deploymentRegion: 'us-central1',
deploymentProjectId: 'signoz-prod-4471',
projectIds: ['signoz-prod-4471'],
},
};
}
return {
gcp: {
connectionMethod: gcpConnectionMethod,
deploymentRegion: 'us-central1',
deploymentProjectId: 'signoz-prod-4471',
projectIds: ['signoz-prod-4471', 'signoz-analytics-8820'],
@@ -292,12 +322,13 @@ const accountConfig = (
const account = (
provider: CloudProvider,
index: number,
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
): CloudintegrationtypesAccountDTO => ({
id: `${provider}-account-${index + 1}`,
orgId: 'story-org',
provider,
providerAccountId: ACCOUNTS[provider][index],
config: accountConfig(provider),
config: accountConfig(provider, gcpConnectionMethod),
agentReport: {
timestampMillis: Date.now() - 45 * 1000,
data: null,
@@ -311,11 +342,12 @@ const account = (
export const accountsResponse = (
provider: CloudProvider,
count: number,
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
): ListAccounts200 => ({
status: 'success',
data: {
accounts: Array.from({ length: count }, (_, index) =>
account(provider, index),
account(provider, index, gcpConnectionMethod),
),
},
});
@@ -323,11 +355,52 @@ export const accountsResponse = (
export const accountResponse = (
provider: CloudProvider,
id: string,
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
): GetAccount200 => {
const parsed = Number.parseInt(id.replace(`${provider}-account-`, ''), 10);
const index = Number.isNaN(parsed) ? 0 : Math.max(0, parsed - 1);
return { status: 'success', data: account(provider, index) };
return {
status: 'success',
data: account(provider, index, gcpConnectionMethod),
};
};
/** The backend's command template, filled in for the story's project and region. */
const GCP_AGENT_COMMAND = {
cliCommand: `curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration gcp terraform \\
--region 'us-central1' \\
--gcp-project 'signoz-prod-4471' \\
--integration-account-id 'gcp-account-new' \\
--signoz-api-url 'https://story.us.signoz.cloud' \\
--signoz-api-key 'story-api-key-8b7d02' \\
--ingestion-url 'https://ingest.us.signoz.cloud:443' \\
--ingestion-key 'story-ingestion-key-2f9c41' \\
--agent-version 'v0.0.8'`,
};
export const newAccountId = (provider: CloudProvider): string =>
`${provider}-account-new`;
/**
* The account the connect flow just created, as its connection poll sees it. Only
* GCP's agent connection polls, so a GCP account here is an agent one.
*/
export const newAccountResponse = (
provider: CloudProvider,
isCheckedIn: boolean,
): GetAccount200 => {
const created = {
...account(provider, 0, CloudintegrationtypesGCPConnectionMethodDTO.agent),
id: newAccountId(provider),
};
return {
status: 'success',
data: isCheckedIn
? created
: { ...created, providerAccountId: null, agentReport: null },
};
};
/**
@@ -541,14 +614,16 @@ export const credentialsResponse = (): GetConnectionCredentials200 => ({
/**
* The connection URL is what the setup flow opens in a new tab, which the story
* reports through the navigation overlay rather than following.
* reports through the navigation overlay rather than following. GCP returns its
* setup command only for an agent connection; a manual one gets none.
*/
export const createdAccountResponse = (
provider: CloudProvider,
gcpConnectionMethod?: CloudintegrationtypesGCPConnectionMethodDTO,
): CreateAccount201 => ({
status: 'success',
data: {
id: `${provider}-account-new`,
id: newAccountId(provider),
connectionArtifact: {
aws: {
connectionUrl: `https://console.aws.amazon.com/cloudformation/home#/stacks/create?templateURL=https://signoz.io/${provider}.yaml`,
@@ -559,6 +634,10 @@ export const createdAccountResponse = (
cloudPowerShellCommand:
'New-AzSubscriptionDeployment -TemplateUri https://signoz.io/azure.json',
},
...(gcpConnectionMethod ===
CloudintegrationtypesGCPConnectionMethodDTO.agent && {
gcp: GCP_AGENT_COMMAND,
}),
},
},
});

View File

@@ -34,13 +34,14 @@ import {
PartialPanelTypes,
} from 'lib/query/panelTypeDataSourceFormValuesMap';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { useNormalizedCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
import { saveRecentQuery } from 'lib/recentQueries/saveRecentQuery';
import { replaceIncorrectObjectFields } from 'lib/replaceIncorrectObjectFields';
import { cloneDeep, get, isEqual, set } from 'lodash-es';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
// ** Types
@@ -113,8 +114,7 @@ export function QueryBuilderProvider({
const [calledFromHandleRunQuery, setCalledFromHandleRunQuery] =
useState<boolean>(false);
const normalizedCompositeQuery = useNormalizedCompositeQueryParam();
const compositeQueryParam = normalizedCompositeQuery?.query ?? null;
const compositeQueryParam = useGetCompositeQueryParam();
const { queryType: queryTypeParam, ...queryState } =
compositeQueryParam || initialQueriesMap.metrics;
@@ -1115,22 +1115,20 @@ export function QueryBuilderProvider({
return;
}
if (normalizedCompositeQuery?.changed) {
const urlQuery = getUnstableCurrentSearchParams();
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQueryParam)),
);
safeNavigate(`${location.pathname}?${urlQuery}`, { replace: true });
return;
}
const { isValid, validData } = replaceIncorrectObjectFields(
compositeQueryParam,
initialQueriesMap.metrics,
);
initQueryBuilderData(compositeQueryParam);
if (!isValid) {
redirectWithQueryBuilderData(validData);
} else {
initQueryBuilderData(compositeQueryParam);
}
}
}, [
initQueryBuilderData,
safeNavigate,
normalizedCompositeQuery,
redirectWithQueryBuilderData,
compositeQueryParam,
stagedQuery,
location.pathname,

View File

@@ -1,165 +0,0 @@
import {
initialQueriesMap,
initialQueryBuilderFormValuesMap,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
act,
AllTheProviders,
renderHook,
RenderHookResult,
waitFor,
} from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
jest.mock('hooks/useSafeNavigate', () =>
jest
.requireActual('tests/browser-history-safe-navigate')
.createBrowserHistorySafeNavigateMock(),
);
const builderlessUrlQuery = {
queryType: 'clickhouse_sql',
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
id: 'b8084e0c-c13d-4172-a775-f61aedadb79e',
unit: '',
};
const clickhouseUrlQuery = {
...builderlessUrlQuery,
clickhouse_sql: [
{ name: 'A', legend: '', disabled: false, query: 'SELECT 1' },
],
};
const tracesWithOperator = {
...initialQueriesMap.traces,
builder: {
...initialQueriesMap.traces.builder,
queryTraceOperator: [initialQueriesMap.traces.builder.queryData[0]],
},
};
const urlQuery = (): Query =>
JSON.parse(
decodeURIComponent(
new URLSearchParams(window.location.search).get('compositeQuery') ?? '',
),
);
const activeLogIdParam = (): string | null =>
new URLSearchParams(window.location.search).get('activeLogId');
const renderOn = (
route: string,
compositeQuery: object,
params: Record<string, string> = {},
): RenderHookResult<QueryBuilderContextType, unknown> => {
const search = new URLSearchParams(params);
search.set('compositeQuery', JSON.stringify(compositeQuery));
const url = `${route}?${search}`;
window.history.replaceState(null, '', url);
return renderHook(() => useQueryBuilder(), {
wrapper: ({ children }): JSX.Element => (
<AllTheProviders initialRoute={url}>{children}</AllTheProviders>
),
});
};
describe('QueryBuilderProvider on a url compositeQuery without builder', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('seeds supersetQuery with a builder so the first edit does not throw', () => {
const { result } = renderOn(ROUTES.LOGS_EXPLORER, builderlessUrlQuery);
expect(result.current.supersetQuery.builder).toBeDefined();
act(() => {
result.current.handleSetQueryData(0, {
...initialQueryBuilderFormValuesMap.logs,
limit: 10,
});
});
expect(result.current.supersetQuery.builder.queryData[0].limit).toBe(10);
expect(result.current.currentQuery.builder.queryData[0].limit).toBe(10);
});
});
describe('QueryBuilderProvider with page constraints', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('seeds and stages the logs query and rewrites only compositeQuery', async () => {
const replaceState = jest.spyOn(window.history, 'replaceState');
const pushState = jest.spyOn(window.history, 'pushState');
const { result } = renderOn(ROUTES.LOGS_EXPLORER, clickhouseUrlQuery, {
activeLogId: '"log-1"',
});
expect(result.current.currentQuery.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.current.supersetQuery.builder.queryData[0].dataSource).toBe(
DataSource.LOGS,
);
await waitFor(() =>
expect(result.current.stagedQuery?.queryType).toBe(EQueryType.QUERY_BUILDER),
);
expect(result.current.stagedQuery?.clickhouse_sql[0].query).toBe('');
expect(urlQuery().queryType).toBe(EQueryType.QUERY_BUILDER);
expect(urlQuery().builder.queryData[0].dataSource).toBe(DataSource.LOGS);
expect(urlQuery().clickhouse_sql[0].query).toBe('');
expect(activeLogIdParam()).toBe('"log-1"');
expect(replaceState).toHaveBeenCalled();
expect(pushState).not.toHaveBeenCalled();
});
it('keeps a trace operator on the traces explorer', async () => {
const { result } = renderOn(ROUTES.TRACES_EXPLORER, tracesWithOperator);
await waitFor(() =>
expect(result.current.stagedQuery?.builder.queryTraceOperator).toHaveLength(
1,
),
);
});
it('leaves a valid url without source untouched and keeps activeLogId', async () => {
const { source: _, ...item } = initialQueriesMap.logs.builder.queryData[0];
const query = {
...initialQueriesMap.logs,
builder: { ...initialQueriesMap.logs.builder, queryData: [item] },
};
const { result } = renderOn(ROUTES.LOGS_EXPLORER, query, {
activeLogId: '"log-1"',
});
await waitFor(() =>
expect(result.current.stagedQuery?.id).toBe(initialQueriesMap.logs.id),
);
expect(
new URLSearchParams(window.location.search).get('compositeQuery'),
).toBe(JSON.stringify(query));
expect(activeLogIdParam()).toBe('"log-1"');
});
it('leaves a clickhouse query alone on a route without constraints', async () => {
const { result } = renderOn('/unconstrained', clickhouseUrlQuery);
expect(result.current.currentQuery.queryType).toBe(EQueryType.CLICKHOUSE);
await waitFor(() =>
expect(result.current.stagedQuery?.queryType).toBe(EQueryType.CLICKHOUSE),
);
expect(urlQuery().clickhouse_sql[0].query).toBe('SELECT 1');
});
});

View File

@@ -9,8 +9,6 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/tidwall/gjson"
"github.com/uptrace/bun"
)
type config struct {
@@ -239,37 +237,6 @@ func (store *config) ListAllChannels(ctx context.Context) ([]*alertmanagertypes.
return channels, nil
}
func (store *config) GetMatchers(ctx context.Context, orgID string) (map[string][]string, error) {
type matcher struct {
bun.BaseModel `bun:"table:rule"`
ID valuer.UUID `bun:"id,pk"`
Data string `bun:"data"`
}
matchers := []matcher{}
err := store.
sqlstore.
BunDB().
NewSelect().
Column("id", "data").
Model(&matchers).
Scan(ctx)
if err != nil {
return nil, err
}
matchersMap := make(map[string][]string)
for _, matcher := range matchers {
receivers := gjson.Get(matcher.Data, "preferredChannels").Array()
for _, receiver := range receivers {
matchersMap[matcher.ID.StringValue()] = append(matchersMap[matcher.ID.StringValue()], receiver.String())
}
}
return matchersMap, nil
}
func (store *config) wrap(ctx context.Context, fn func(ctx context.Context) error, opts ...alertmanagertypes.StoreOption) error {
storeOpts := alertmanagertypes.NewStoreOptions(opts...)

View File

@@ -245,25 +245,13 @@ func (service *Service) compareAndSelectConfig(ctx context.Context, incomingConf
return nil, err
}
matchers, err := service.configStore.GetMatchers(ctx, incomingConfig.StoreableConfig().OrgID)
if err != nil {
return nil, err
}
config, err := alertmanagertypes.NewConfigFromChannels(service.config.Global, service.config.Route, channels, incomingConfig.StoreableConfig().OrgID)
if err != nil {
return nil, err
}
for ruleID, receivers := range matchers {
err = config.CreateRuleIDMatcher(ruleID, receivers)
if err != nil {
return nil, err
}
}
if incomingConfig.StoreableConfig().Hash != config.StoreableConfig().Hash {
service.settings.Logger().InfoContext(ctx, "mismatch found, updating config to match channels and matchers")
service.settings.Logger().InfoContext(ctx, "mismatch found, updating config to match channels")
return config, nil
}

View File

@@ -11,20 +11,6 @@ import (
)
func (provider *provider) addRulerRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRules), handler.OpenAPIDef{
ID: "ListRules",
Tags: []string{"rules"},
Summary: "List alert rules",
Description: "This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.",
Response: make([]*ruletypes.Rule, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
Deprecated: true,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v3/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRulesV3), handler.OpenAPIDef{
ID: "ListRulesV3",
Tags: []string{"rules"},

View File

@@ -221,16 +221,15 @@ func postableRuleExamples() []handler.OpenAPIExample {
},
{
Name: "metric_anomaly",
Summary: "Metric anomaly rule (v1 only)",
Description: "Anomaly rules are not yet supported under schemaVersion v2alpha1, so this example uses the v1 shape. Wraps a builder query in the `anomaly` function with daily seasonality SigNoz compares each point against the forecast for that time of day. Fires when the anomaly score stays below the threshold for the entire window; `requireMinPoints` guards against noisy intervals.",
Summary: "Metric anomaly rule",
Description: "Wraps a builder query in the `anomaly` function with daily seasonality, so SigNoz compares each point against the forecast for that time of day. The threshold is compared against the signed anomaly score, so `below` with a target of -2 fires when an abrupt drop holds for the entire window; `requireMinPoints` guards against noisy intervals.",
Value: map[string]any{
"alert": "Anomalous drop in ingested spans",
"alertType": "METRIC_BASED_ALERT",
"description": "Detect an abrupt drop in span ingestion using a z-score anomaly function",
"ruleType": "anomaly_rule",
"version": "v5",
"evalWindow": "24h",
"frequency": "3h",
"alert": "Anomalous drop in ingested spans",
"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{
"queryType": "builder",
@@ -256,17 +255,29 @@ func postableRuleExamples() []handler.OpenAPIExample {
},
},
},
"op": "below",
"matchType": "all_the_times",
"target": 2,
"algorithm": "standard",
"seasonality": "daily",
"selectedQueryName": "A",
"requireMinPoints": true,
"requiredNumPoints": 3,
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "warning",
"op": "below",
"matchType": "all_the_times",
"target": -2,
"channels": []any{"slack-ingestion"},
},
},
},
},
"labels": map[string]any{"severity": "warning"},
"preferredChannels": []any{"slack-ingestion"},
"evaluation": rolling("24h", "3h"),
"notificationSettings": map[string]any{
"renotify": renotify("4h", "firing"),
},
"labels": map[string]any{"severity": "warning"},
"annotations": map[string]any{
"description": "Ingestion rate for tenant {{$tenant_id}} is anomalously low (z-score {{$value}}).",
"summary": "Span ingestion anomaly",

View File

@@ -59,11 +59,7 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
}
}
if builder.evaluation == nil {
evalWindow := rule.EvalWindow
if evalWindow.IsZero() {
evalWindow = valuer.MustParseTextDuration("5m")
}
builder.evaluation = ruletypes.RollingWindow{EvalWindow: evalWindow}
return nil
}
return builder

View File

@@ -3,10 +3,8 @@ package app
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"github.com/SigNoz/signoz/pkg/clickhousesql"
"github.com/SigNoz/signoz/pkg/errors"
@@ -360,14 +358,6 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/query_range", am.ViewAccess(aH.queryRangeMetrics)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/query", am.ViewAccess(aH.queryMetrics)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules", am.ViewAccess(aH.listRules)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules/{id}", am.ViewAccess(aH.getRule)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules", am.EditAccess(aH.createRule)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.editRule)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.deleteRule)).Methods(http.MethodDelete)
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.patchRule)).Methods(http.MethodPatch)
router.HandleFunc("/api/v1/testRule", am.EditAccess(aH.testRule)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/variables/query", am.ViewAccess(aH.queryDashboardVarsV2)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/explorer/views", am.ViewAccess(aH.Signoz.Handlers.SavedView.List)).Methods(http.MethodGet)
@@ -496,166 +486,6 @@ func (aH *APIHandler) PopulateTemporality(ctx context.Context, orgID valuer.UUID
return nil
}
func (aH *APIHandler) listRules(w http.ResponseWriter, r *http.Request) {
rules, err := aH.ruleManager.ListRuleStates(r.Context())
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
// todo(amol): need to add sorter
aH.Respond(w, rules)
}
func (aH *APIHandler) getRule(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
ruleResponse, err := aH.ruleManager.GetRule(r.Context(), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
return
}
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
aH.Respond(w, ruleResponse)
}
func (aH *APIHandler) createRule(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
aH.logger.ErrorContext(r.Context(), "error reading request body for create rule", errors.Attr(err))
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
rule, err := aH.ruleManager.CreateRule(r.Context(), string(body))
if err != nil {
RespondError(w, toApiError(err), nil)
return
}
aH.Respond(w, rule)
}
func (aH *APIHandler) editRule(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
aH.logger.ErrorContext(r.Context(), "error reading request body for edit rule", errors.Attr(err))
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
err = aH.ruleManager.EditRule(r.Context(), string(body), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
return
}
RespondError(w, toApiError(err), nil)
return
}
aH.Respond(w, "rule successfully edited")
}
func (aH *APIHandler) deleteRule(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
err := aH.ruleManager.DeleteRule(r.Context(), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
return
}
RespondError(w, toApiError(err), nil)
return
}
aH.Respond(w, "rule successfully deleted")
}
func (aH *APIHandler) patchRule(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
aH.logger.ErrorContext(r.Context(), "error reading request body for patch rule", errors.Attr(err))
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
gettableRule, err := aH.ruleManager.PatchRule(r.Context(), string(body), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
return
}
RespondError(w, toApiError(err), nil)
return
}
aH.Respond(w, gettableRule)
}
func (aH *APIHandler) testRule(w http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(w, err)
return
}
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
aH.logger.ErrorContext(r.Context(), "error reading request body for test rule", errors.Attr(err))
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
defer cancel()
alertCount, err := aH.ruleManager.TestNotification(ctx, orgID, string(body))
if err != nil {
RespondError(w, toApiError(err), nil)
return
}
response := map[string]interface{}{
"alertCount": alertCount,
"message": "notification sent",
}
aH.Respond(w, response)
}
func prepareQuery(r *http.Request) (string, error) {
var postData *model.DashboardVars

View File

@@ -34,10 +34,6 @@ type BaseRule struct {
ruleCondition *ruletypes.RuleCondition
Threshold ruletypes.RuleThreshold
// evalWindow is the time window used for evaluating the rule
// i.e. each time we lookback from the current time, we look at data for the last
// evalWindow duration
evalWindow valuer.TextDuration
// holdDuration is the duration for which the alert waits before firing
holdDuration valuer.TextDuration
@@ -49,10 +45,7 @@ type BaseRule struct {
// these are the same for all alerts created for this rule
labels ruletypes.Labels
annotations ruletypes.Labels
// preferredChannels is the list of channels to send the alert to
// if the rule is triggered
preferredChannels []string
mtx sync.Mutex
mtx sync.Mutex
// the time it took to evaluate the rule (most recent evaluation)
evaluationDuration time.Duration
// the timestamp of the last evaluation
@@ -151,26 +144,27 @@ func NewBaseRule(
if err != nil {
return nil, err
}
if err := threshold.Validate(); err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid rule threshold spec: %v", err)
}
evaluation, err := p.Evaluation.GetEvaluation()
if err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to get evaluation: %v", err)
}
baseRule := &BaseRule{
id: id,
orgID: orgID,
externalURL: externalURL,
name: p.AlertName.StringValue(),
typ: p.AlertType,
ruleCondition: p.RuleCondition,
evalWindow: p.EvalWindow,
labels: ruletypes.FromMap(p.Labels),
annotations: ruletypes.FromMap(p.Annotations),
preferredChannels: p.PreferredChannels,
health: ruletypes.HealthUnknown,
Active: map[uint64]*ruletypes.Alert{},
Threshold: threshold,
evaluation: evaluation,
id: id,
orgID: orgID,
externalURL: externalURL,
name: p.AlertName.StringValue(),
typ: p.AlertType,
ruleCondition: p.RuleCondition,
labels: ruletypes.FromMap(p.Labels),
annotations: ruletypes.FromMap(p.Annotations),
health: ruletypes.HealthUnknown,
Active: map[uint64]*ruletypes.Alert{},
Threshold: threshold,
evaluation: evaluation,
}
// Store newGroupEvalDelay and groupBy keys from NotificationSettings
@@ -178,10 +172,6 @@ func NewBaseRule(
baseRule.newGroupEvalDelay = p.NotificationSettings.NewGroupEvalDelay
}
if baseRule.evalWindow.IsZero() {
baseRule.evalWindow = valuer.MustParseTextDuration("5m")
}
for _, opt := range opts {
opt(baseRule)
}
@@ -232,10 +222,6 @@ func (r *BaseRule) EvalDelay() valuer.TextDuration {
return r.evalDelay
}
func (r *BaseRule) EvalWindow() valuer.TextDuration {
return r.evalWindow
}
func (r *BaseRule) HoldDuration() valuer.TextDuration {
return r.holdDuration
}
@@ -246,7 +232,6 @@ func (r *BaseRule) Name() string { return r.name }
func (r *BaseRule) Condition() *ruletypes.RuleCondition { return r.ruleCondition }
func (r *BaseRule) Labels() ruletypes.Labels { return r.labels }
func (r *BaseRule) Annotations() ruletypes.Labels { return r.annotations }
func (r *BaseRule) PreferredChannels() []string { return r.preferredChannels }
func (r *BaseRule) GeneratorURL() string {
params := url.Values{}

View File

@@ -350,6 +350,15 @@ func (m *Manager) validateChannels(ctx context.Context, orgID string, rule *rule
return nil
}
func decodeRule(ruleStr string, rule *ruletypes.PostableRule) error {
err := json.Unmarshal([]byte(ruleStr), rule)
if err == nil || errors.Ast(err, errors.TypeInvalidInput) {
return err
}
// a syntax error is raised before any UnmarshalJSON runs, so it is the one untyped error
return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to parse json: %v", err)
}
// EditRule writes the rule definition to the
// datastore and also updates the rule executor
func (m *Manager) EditRule(ctx context.Context, ruleStr string, id valuer.UUID) error {
@@ -359,7 +368,7 @@ func (m *Manager) EditRule(ctx context.Context, ruleStr string, id valuer.UUID)
}
orgID := valuer.MustNewUUID(claims.OrgID)
parsedRule := ruletypes.PostableRule{}
err = json.Unmarshal([]byte(ruleStr), &parsedRule)
err = decodeRule(ruleStr, &parsedRule)
if err != nil {
return err
}
@@ -547,7 +556,7 @@ func (m *Manager) deleteTask(taskName string) {
// starts an executor for the rule, unless the rule is disabled
func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error) {
parsedRule := ruletypes.PostableRule{}
if err := json.Unmarshal([]byte(ruleStr), &parsedRule); err != nil {
if err := decodeRule(ruleStr, &parsedRule); err != nil {
return nil, err
}
@@ -1049,7 +1058,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
return nil, err
}
if err := json.Unmarshal([]byte(ruleStr), &storedRule); err != nil {
if err := decodeRule(ruleStr, &storedRule); err != nil {
m.logger.ErrorContext(ctx, "failed to unmarshal patched rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err
}
@@ -1105,9 +1114,9 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
// sends a test notification. returns alert count and error (if any)
func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleStr string) (int, error) {
parsedRule := ruletypes.PostableRule{}
err := json.Unmarshal([]byte(ruleStr), &parsedRule)
err := decodeRule(ruleStr, &parsedRule)
if err != nil {
return 0, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to unmarshal rule")
return 0, err
}
if err := parsedRule.Validate(); err != nil {
return 0, err

View File

@@ -49,13 +49,12 @@ func ThresholdRuleAtLeastOnceValueAbove(target float64, recovery *float64) rulet
Annotations: map[string]string{
"value": "{{$value}}",
},
Version: "v5",
Version: "v5",
SchemaVersion: valuer.MustNewUnsetOrNonEmptyString(ruletypes.SchemaVersionV2Alpha1),
RuleCondition: &ruletypes.RuleCondition{
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
PanelType: ruletypes.PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
@@ -108,12 +107,10 @@ func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes
Annotations: map[string]string{
"value": "{{$value}}",
},
Version: "v5",
Version: "v5",
SchemaVersion: valuer.MustNewUnsetOrNonEmptyString(ruletypes.SchemaVersionV2Alpha1),
RuleCondition: &ruletypes.RuleCondition{
MatchType: ruletypes.AtleastOnce,
SelectedQuery: "A",
CompareOperator: ruletypes.ValueIsAbove,
Target: &target,
SelectedQuery: "A",
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypePromQL,
PanelType: ruletypes.PanelTypeGraph,
@@ -138,7 +135,6 @@ func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes
RecoveryTarget: recovery,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
Channels: []string{"slack"},
},
},
},

View File

@@ -356,12 +356,10 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
func (r *PromRule) String() string {
ar := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName(r.name),
RuleCondition: r.ruleCondition,
EvalWindow: r.evalWindow,
Labels: r.labels.Map(),
Annotations: r.annotations.Map(),
PreferredChannels: r.preferredChannels,
AlertName: valuer.NewDisplayName(r.name),
RuleCondition: r.ruleCondition,
Labels: r.labels.Map(),
Annotations: r.annotations.Map(),
}
byt, err := json.Marshal(ar)

View File

@@ -690,13 +690,11 @@ func TestPromRuleEval(t *testing.T) {
logger := instrumentationtest.New().Logger()
for idx, c := range cases {
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: "critical",
TargetValue: &c.target,
MatchType: c.matchType,
CompareOperator: c.compareOperator,
@@ -905,11 +903,7 @@ func TestPromRuleUnitCombinations(t *testing.T) {
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = ruletypes.MatchType(c.matchType)
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -1198,11 +1192,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -1259,6 +1249,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
func TestPromRule_NoData(t *testing.T) {
evalTime := time.Now()
target := 200.0
postableRule := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName("Test no data"),
@@ -1269,8 +1260,6 @@ func TestPromRule_NoData(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypePromQL,
Queries: []qbtypes.QueryEnvelope{
@@ -1282,7 +1271,7 @@ func TestPromRule_NoData(t *testing.T) {
},
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{Name: "Test no data"}},
Spec: ruletypes.BasicRuleThresholds{{Name: "Test no data", TargetValue: &target, CompareOperator: ruletypes.ValueIsAbove, MatchType: ruletypes.AtleastOnce}},
},
},
}
@@ -1365,10 +1354,7 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
AlertOnAbsent: true,
Target: &target,
AlertOnAbsent: true,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypePromQL,
Queries: []qbtypes.QueryEnvelope{
@@ -1491,8 +1477,6 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypePromQL,
Queries: []qbtypes.QueryEnvelope{
@@ -1562,9 +1546,9 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
for _, c := range cases {
rc := postableRule.RuleCondition
rc.Target = &targetForNoAlert
target := &targetForNoAlert
if c.alertCondition {
rc.Target = &targetForAlert
target = &targetForAlert
}
rc.RequireMinPoints = c.requireMinPoints
rc.RequiredNumPoints = c.requiredNumPoints
@@ -1573,9 +1557,9 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
Spec: ruletypes.BasicRuleThresholds{
{
Name: postableRule.AlertName.StringValue(),
TargetValue: rc.Target,
MatchType: rc.MatchType,
CompareOperator: rc.CompareOperator,
TargetValue: target,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
},
},
}

View File

@@ -20,15 +20,12 @@ type Rule interface {
Annotations() ruletypes.Labels
Condition() *ruletypes.RuleCondition
EvalDelay() valuer.TextDuration
EvalWindow() valuer.TextDuration
HoldDuration() valuer.TextDuration
State() ruletypes.AlertState
ActiveAlerts() []*ruletypes.Alert
// ActiveAlertsLabelFP returns a map of active alert labels fingerprint
ActiveAlertsLabelFP() map[uint64]struct{}
PreferredChannels() []string
// Eval evaluates the rule at the given timestamp and returns the number of active alerts.
Eval(context.Context, time.Time) (int, error)
String() string

View File

@@ -61,16 +61,16 @@ func (r *ThresholdRule) Type() ruletypes.RuleType {
}
func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*qbtypes.QueryRangeRequest, error) {
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
r.logger.InfoContext(
ctx, "prepare query range request v5",
slog.Int64("ts", ts.UnixMilli()),
slog.Int64("eval_window", r.evalWindow.Milliseconds()),
slog.Int64("eval_window", end-start),
slog.Int64("eval_delay", r.evalDelay.Milliseconds()),
)
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
req := &qbtypes.QueryRangeRequest{
Start: uint64(start),
End: uint64(end),
@@ -455,12 +455,10 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
func (r *ThresholdRule) String() string {
ar := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName(r.name),
RuleCondition: r.ruleCondition,
EvalWindow: r.evalWindow,
Labels: r.labels.Map(),
Annotations: r.annotations.Map(),
PreferredChannels: r.preferredChannels,
AlertName: valuer.NewDisplayName(r.name),
RuleCondition: r.ruleCondition,
Labels: r.labels.Map(),
Annotations: r.annotations.Map(),
}
byt, err := json.Marshal(ar)

View File

@@ -63,6 +63,7 @@ func TestThresholdRuleEvalWithoutRecoveryTarget(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: "critical",
TargetValue: &c.target,
MatchType: c.matchType,
CompareOperator: c.compareOperator,
@@ -171,10 +172,7 @@ func TestPrepareParamsForLogs(t *testing.T) {
},
},
},
CompareOperator: ruletypes.ValueIsNotEq,
MatchType: ruletypes.AtleastOnce,
Target: &[]float64{0.0}[0],
SelectedQuery: "A",
SelectedQuery: "A",
},
}
@@ -183,9 +181,10 @@ func TestPrepareParamsForLogs(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
TargetValue: postableRule.RuleCondition.Target,
MatchType: postableRule.RuleCondition.MatchType,
CompareOperator: postableRule.RuleCondition.CompareOperator,
Name: "critical",
TargetValue: &[]float64{0.0}[0],
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsNotEq,
},
},
}
@@ -239,10 +238,7 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
},
},
},
CompareOperator: ruletypes.ValueIsNotEq,
MatchType: ruletypes.AtleastOnce,
Target: &[]float64{0.0}[0],
SelectedQuery: "A",
SelectedQuery: "A",
},
Version: "v5",
}
@@ -252,9 +248,10 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
TargetValue: postableRule.RuleCondition.Target,
MatchType: postableRule.RuleCondition.MatchType,
CompareOperator: postableRule.RuleCondition.CompareOperator,
Name: "critical",
TargetValue: &[]float64{0.0}[0],
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsNotEq,
},
},
}
@@ -304,10 +301,7 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
},
},
},
CompareOperator: ruletypes.ValueIsNotEq,
MatchType: ruletypes.AtleastOnce,
Target: &[]float64{0.0}[0],
SelectedQuery: "A",
SelectedQuery: "A",
},
Version: "v5",
}
@@ -317,9 +311,10 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
TargetValue: postableRule.RuleCondition.Target,
MatchType: postableRule.RuleCondition.MatchType,
CompareOperator: postableRule.RuleCondition.CompareOperator,
Name: "critical",
TargetValue: &[]float64{0.0}[0],
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsNotEq,
},
},
}
@@ -366,10 +361,7 @@ func TestPrepareParamsForTraces(t *testing.T) {
},
},
},
CompareOperator: ruletypes.ValueIsNotEq,
MatchType: ruletypes.AtleastOnce,
Target: &[]float64{0.0}[0],
SelectedQuery: "A",
SelectedQuery: "A",
},
}
@@ -378,9 +370,10 @@ func TestPrepareParamsForTraces(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
TargetValue: postableRule.RuleCondition.Target,
MatchType: postableRule.RuleCondition.MatchType,
CompareOperator: postableRule.RuleCondition.CompareOperator,
Name: "critical",
TargetValue: &[]float64{0.0}[0],
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsNotEq,
},
},
}
@@ -470,6 +463,7 @@ func TestThresholdRuleLabelNormalization(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: "critical",
TargetValue: &c.target,
MatchType: c.matchType,
CompareOperator: c.compareOperator,
@@ -653,11 +647,7 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
ExpectQuery(queryString).
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil).
WillReturnRows(rows)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -867,11 +857,7 @@ func TestThresholdRuleTracesLink(t *testing.T) {
querier := prepareQuerierForTraces(t, telemetryStore, keysMap)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -975,11 +961,7 @@ func TestThresholdRuleAITracesLink(t *testing.T) {
querier := prepareQuerierForAITraces(t, telemetryStore, keysMap)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -1083,11 +1065,7 @@ func TestThresholdRuleLogsLink(t *testing.T) {
querier := prepareQuerierForLogs(t, telemetryStore, keysMap)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -1256,11 +1234,7 @@ func TestMultipleThresholdRule(t *testing.T) {
querier, mockMetadataStore := prepareQuerierForMetrics(t, telemetryStore)
mockMetadataStore.TypeMap["signoz_calls_total"] = metrictypes.SumType
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -1930,8 +1904,6 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
@@ -2050,7 +2022,6 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
mockMetadataStore.TypeMap["signoz_calls_total"] = metrictypes.SumType
rc := postableRule.RuleCondition
rc.Target = &c.target
rc.RequireMinPoints = c.requireMinPoints
rc.RequiredNumPoints = c.requiredNumPoints
rc.Thresholds = &ruletypes.RuleThresholdData{
@@ -2059,8 +2030,8 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
{
Name: postableRule.AlertName.StringValue(),
TargetValue: &c.target,
MatchType: rc.MatchType,
CompareOperator: rc.CompareOperator,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
},
},
}

View File

@@ -3,7 +3,6 @@ package ruler
import "net/http"
type Handler interface {
ListRules(http.ResponseWriter, *http.Request)
ListRulesV3(http.ResponseWriter, *http.Request)
GetRuleByID(http.ResponseWriter, *http.Request)
CreateRule(http.ResponseWriter, *http.Request)

View File

@@ -25,24 +25,6 @@ func NewHandler(ruler ruler.Ruler) ruler.Handler {
return &handler{ruler: ruler}
}
func (handler *handler) ListRules(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
rules, err := handler.ruler.ListRuleStates(ctx)
if err != nil {
render.Error(rw, err)
return
}
view := make([]*ruletypes.Rule, 0, len(rules.Rules))
for _, rule := range rules.Rules {
view = append(view, ruletypes.NewRule(rule))
}
render.Success(rw, http.StatusOK, view)
}
func (handler *handler) ListRulesV3(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()

View File

@@ -264,6 +264,9 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewKeepRelevantLLMPricingRulesFactory(),
sqlmigration.NewBackfillTelegramChannelSpecFactory(),
sqlmigration.NewAddMetricsFieldTuplesFactory(sqlstore),
sqlmigration.NewAddGCPConnectionMethodFactory(),
sqlmigration.NewMigrateRulesV1ToV2Alpha1Factory(sqlstore),
sqlmigration.NewRepairRulesV2Alpha1Factory(sqlstore),
)
}

View File

@@ -0,0 +1,85 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addGCPConnectionMethod struct {
logger *slog.Logger
}
type gcpConnectionMethodMigrationRow struct {
bun.BaseModel `bun:"table:cloud_integration"`
ID string `bun:"id"`
Config string `bun:"config"`
}
func NewAddGCPConnectionMethodFactory() factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_gcp_connection_method"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addGCPConnectionMethod{logger: ps.Logger}, nil
})
}
func (migration *addGCPConnectionMethod) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addGCPConnectionMethod) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var accounts []*gcpConnectionMethodMigrationRow
if err := tx.NewSelect().
Model(&accounts).
Where("provider = ?", "gcp").
Scan(ctx); err != nil {
return err
}
for _, account := range accounts {
// Preserve config fields that are not known to this migration.
var config map[string]json.RawMessage
if err := json.Unmarshal([]byte(account.Config), &config); err != nil {
migration.logger.WarnContext(ctx, "skipping GCP account with unreadable config", slog.String("account_id", account.ID), errors.Attr(err))
continue
}
if config == nil {
migration.logger.WarnContext(ctx, "skipping GCP account with null config", slog.String("account_id", account.ID))
continue
}
if _, exists := config["connectionMethod"]; exists {
continue
}
config["connectionMethod"] = json.RawMessage(`"manual"`)
encoded, err := json.Marshal(config)
if err != nil {
return err
}
if _, err := tx.NewUpdate().
Model((*gcpConnectionMethodMigrationRow)(nil)).
Set("config = ?", string(encoded)).
Where("id = ?", account.ID).
Exec(ctx); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addGCPConnectionMethod) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,573 @@
package sqlmigration
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"slices"
"time"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
)
const (
schemaVersionV2Alpha1 = "v2alpha1"
ruleTypeThreshold = "threshold_rule"
ruleTypeProm = "promql_rule"
ruleTypeAnomaly = "anomaly_rule"
queryTypeBuilder = "builder"
queryTypePromQL = "promql"
queryTypeClickHouseSQL = "clickhouse_sql"
panelTypeGraph = "graph"
alertTypeMetric = "METRIC_BASED_ALERT"
alertTypeTraces = "TRACES_BASED_ALERT"
alertTypeLogs = "LOGS_BASED_ALERT"
signalLogs = "logs"
signalTraces = "traces"
compareOperatorBelow = "2"
criticalThresholdName = "critical"
labelSeverity = "severity"
)
// The values the write path accepts; a migrated rule outside them is written
// disabled, for the user to fix.
var (
supportedCompareOperators = []string{"1", "above", ">", "2", "below", "<", "3", "equal", "eq", "=", "4", "not_equal", "not_eq", "!=", "5", "above_or_equal", "above_or_eq", ">=", "6", "below_or_equal", "below_or_eq", "<=", "7", "outside_bounds"}
supportedMatchTypes = []string{"1", "at_least_once", "2", "all_the_times", "3", "on_average", "avg", "4", "in_total", "sum", "5", "last"}
)
// The values the write path accepts; a migrated row outside them cannot be
// edited until the field is supplied, so the migration derives them.
var (
supportedAlertTypes = []string{"METRIC_BASED_ALERT", "TRACES_BASED_ALERT", "LOGS_BASED_ALERT", "EXCEPTIONS_BASED_ALERT", "AI_TRACES_BASED_ALERT"}
supportedQueryTypes = []string{"builder", "clickhouse_sql", "promql"}
supportedPanelTypes = []string{"value", "table", "graph"}
)
type migrateRulesV1ToV2Alpha1 struct {
sqlstore sqlstore.SQLStore
logger *slog.Logger
}
func NewMigrateRulesV1ToV2Alpha1Factory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("migrate_rules_v1_to_v2alpha1"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &migrateRulesV1ToV2Alpha1{sqlstore: sqlstore, logger: ps.Logger}, nil
})
}
func (migration *migrateRulesV1ToV2Alpha1) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
// Up rewrites every v1 rule in every org as v2alpha1 in place. A row that does
// not decode, or a v1 rule that cannot be rewritten, is logged and left as is;
// a failed write aborts the run and rolls back every org.
func (migration *migrateRulesV1ToV2Alpha1) Up(ctx context.Context, _ *bun.DB) error {
return migration.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
var orgIDs []string
if err := migration.sqlstore.BunDBCtx(ctx).NewSelect().Table("organizations").Column("id").Scan(ctx, &orgIDs); err != nil {
return err
}
for _, orgID := range orgIDs {
if err := migration.convertOrg(ctx, orgID); err != nil {
return err
}
}
return nil
})
}
func (migration *migrateRulesV1ToV2Alpha1) convertOrg(ctx context.Context, orgID string) error {
db := migration.sqlstore.BunDBCtx(ctx)
rules := make([]*ruleRow, 0)
if err := db.NewSelect().Model(&rules).Where("org_id = ?", orgID).Scan(ctx); err != nil {
return err
}
routes := make([]*routePolicyRow, 0)
if err := db.NewSelect().Model(&routes).Where("org_id = ?", orgID).Scan(ctx); err != nil {
return err
}
routesByName := make(map[string][]*routePolicyRow)
for _, route := range routes {
routesByName[route.Name] = append(routesByName[route.Name], route)
}
var migrated, disabled, skipped, undecodable, unmigratable int
for _, stored := range rules {
var header struct {
SchemaVersion string `json:"schemaVersion"`
}
if err := json.Unmarshal([]byte(stored.Data), &header); err != nil {
undecodable++
migration.logger.WarnContext(ctx, "failed to decode stored rule", slog.String("org_id", orgID), slog.String("rule_id", stored.ID), errors.Attr(err))
continue
}
if header.SchemaVersion == schemaVersionV2Alpha1 {
skipped++
continue
}
rule := new(postableRule)
if err := json.Unmarshal([]byte(stored.Data), rule); err != nil {
undecodable++
migration.logger.WarnContext(ctx, "failed to decode stored rule", slog.String("org_id", orgID), slog.String("rule_id", stored.ID), errors.Attr(err))
continue
}
routes := routesByName[stored.ID]
if err := validateRoutes(routes, rule.PreferredChannels); err != nil {
unmigratable++
migration.logger.WarnContext(ctx, "leaving rule at v1", slog.String("org_id", orgID), slog.String("rule_id", stored.ID), errors.Attr(err))
continue
}
var route *routePolicyRow
expression := ""
if len(routes) == 1 {
route = routes[0]
expression = route.Expression
}
if err := convertRule(stored.ID, rule, route); err != nil {
unmigratable++
migration.logger.WarnContext(ctx, "leaving rule at v1", slog.String("org_id", orgID), slog.String("rule_id", stored.ID), errors.Attr(err))
continue
}
if reason := rule.validateThreshold(); reason != nil {
rule.Disabled = json.RawMessage("true")
disabled++
migration.logger.WarnContext(ctx, "migrating rule as disabled, its threshold fails the write path", slog.String("org_id", orgID), slog.String("rule_id", stored.ID), errors.Attr(reason))
}
data, err := marshalUnescaped(rule)
if err != nil {
return err
}
stored.Data = string(data)
if _, err := db.NewUpdate().Model(stored).Column("data").WherePK().Exec(ctx); err != nil {
return err
}
if route != nil && route.Expression != expression {
if _, err := db.NewUpdate().Model(route).Column("expression").WherePK().Exec(ctx); err != nil {
return err
}
}
migrated++
}
migration.logger.InfoContext(ctx, "migrated rules from v1 to v2alpha1", slog.String("org_id", orgID), slog.Int("total", len(rules)), slog.Int("migrated", migrated), slog.Int("disabled", disabled), slog.Int("skipped", skipped), slog.Int("undecodable", undecodable), slog.Int("unmigratable", unmigratable))
return nil
}
// convertRule rewrites rule in place, in the order the v1 decode derived the
// same values. Version and source are left as stored. A nil route is a rule
// that never had channels.
func convertRule(ruleID string, rule *postableRule, route *routePolicyRow) error {
if rule.RuleCondition == nil || rule.RuleCondition.CompositeQuery == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition or condition.compositeQuery is null")
}
channels := []string{}
if route != nil {
channels = route.Channels
}
rule.fillDurationDefaults()
if err := rule.validateDurations(); err != nil {
return err
}
rule.deriveAlertType()
rule.deriveQueryType()
rule.repairPanelType()
rule.disableUnmetMinPoints()
rule.deriveRuleType()
rule.negateAnomalyBelowTarget()
rule.repairEmptySeverity(ruleID, route)
rule.relocateThreshold(channels)
rule.relocateEvaluation()
rule.synthesizeNotificationSettings()
rule.SchemaVersion = schemaVersionV2Alpha1
rule.clearV1Fields()
return nil
}
// validateRoutes checks the rule's route_policy rows, named after the rule. A
// rule that never had channels got no row from migration 049 and migrates with
// none; a rule with preferred channels but no row would lose them, so it is
// left at v1.
func validateRoutes(routes []*routePolicyRow, preferredChannels []string) error {
if len(routes) > 1 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "expected one route_policy row, found %d", len(routes))
}
if len(routes) == 0 && len(preferredChannels) > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "no route_policy row for a rule with %d preferred channels", len(preferredChannels))
}
return nil
}
type ruleRow struct {
bun.BaseModel `bun:"table:rule"`
ID string `bun:"id,pk"`
OrgID string `bun:"org_id"`
Data string `bun:"data"`
}
type routePolicyRow struct {
bun.BaseModel `bun:"table:route_policy"`
ID string `bun:"id,pk"`
OrgID string `bun:"org_id"`
Name string `bun:"name"`
Expression string `bun:"expression"`
Channels []string `bun:"channels,type:jsonb"`
}
// postableRule mirrors PostableRule key for key, in its order. Keys the
// crosswalk reads or writes are typed; the rest pass through as stored.
type postableRule struct {
AlertName json.RawMessage `json:"alert,omitempty"`
AlertType string `json:"alertType"`
Description json.RawMessage `json:"description,omitempty"`
RuleType string `json:"ruleType,omitempty"`
EvalWindow textDuration `json:"evalWindow,omitzero"`
Frequency textDuration `json:"frequency,omitzero"`
RuleCondition *ruleCondition `json:"condition"`
Labels map[string]string `json:"labels,omitempty"`
Annotations json.RawMessage `json:"annotations,omitempty"`
Disabled json.RawMessage `json:"disabled,omitempty"`
Source json.RawMessage `json:"source,omitempty"`
PreferredChannels []string `json:"preferredChannels,omitempty"`
Version json.RawMessage `json:"version,omitempty"`
Evaluation *evaluationEnvelope `json:"evaluation,omitempty"`
SchemaVersion string `json:"schemaVersion,omitempty"`
NotificationSettings *notificationSettings `json:"notificationSettings,omitempty"`
}
type ruleCondition struct {
CompositeQuery *compositeQuery `json:"compositeQuery"`
CompareOperator string `json:"op,omitempty"`
Target *float64 `json:"target,omitempty"`
AlertOnAbsent bool `json:"alertOnAbsent,omitempty"`
AbsentFor json.RawMessage `json:"absentFor,omitempty"`
MatchType string `json:"matchType,omitempty"`
TargetUnit string `json:"targetUnit,omitempty"`
Seasonality string `json:"seasonality,omitempty"`
SelectedQuery json.RawMessage `json:"selectedQueryName,omitempty"`
RequireMinPoints json.RawMessage `json:"requireMinPoints,omitempty"`
RequiredNumPoints json.RawMessage `json:"requiredNumPoints,omitempty"`
Thresholds *ruleThresholds `json:"thresholds,omitempty"`
}
type compositeQuery struct {
Queries json.RawMessage `json:"queries,omitempty"`
PanelType string `json:"panelType"`
QueryType string `json:"queryType"`
Unit json.RawMessage `json:"unit,omitempty"`
}
// ruleQueryEnvelope reads the two envelope keys the derivations need.
type ruleQueryEnvelope struct {
Type string `json:"type"`
Spec struct {
Signal string `json:"signal"`
} `json:"spec"`
}
type ruleThresholds struct {
Kind string `json:"kind"`
Spec []basicThreshold `json:"spec"`
}
type basicThreshold struct {
Name string `json:"name"`
TargetValue *float64 `json:"target"`
TargetUnit string `json:"targetUnit"`
RecoveryTarget *float64 `json:"recoveryTarget"`
MatchType string `json:"matchType"`
CompareOperator string `json:"op"`
Channels []string `json:"channels"`
}
type evaluationEnvelope struct {
Kind string `json:"kind"`
Spec rollingWindow `json:"spec"`
}
type rollingWindow struct {
EvalWindow textDuration `json:"evalWindow"`
Frequency textDuration `json:"frequency"`
}
type notificationSettings struct {
Renotify *renotify `json:"renotify,omitempty"`
UsePolicy bool `json:"usePolicy"`
}
type renotify struct {
Enabled bool `json:"enabled"`
ReNotifyInterval textDuration `json:"interval,omitzero"`
AlertStates []string `json:"alertStates,omitzero"`
}
// validateThreshold reports what the v1 write path once let through: a missing
// target, or an op or matchType the v2 write path rejects. It reads the
// relocated threshold, so it runs after convertRule.
func (rule *postableRule) validateThreshold() error {
threshold := rule.RuleCondition.Thresholds.Spec[0]
if threshold.TargetValue == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.target is null")
}
if !slices.Contains(supportedCompareOperators, threshold.CompareOperator) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.op: unsupported value %q", threshold.CompareOperator)
}
if !slices.Contains(supportedMatchTypes, threshold.MatchType) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.matchType: unsupported value %q", threshold.MatchType)
}
return nil
}
func (rule *postableRule) fillDurationDefaults() {
if rule.EvalWindow.IsZero() {
rule.EvalWindow = mustParseTextDuration("5m")
}
if rule.Frequency.IsZero() {
rule.Frequency = mustParseTextDuration("1m")
}
}
// validateDurations rejects what the v1 write path never checked: a negative
// evalWindow or frequency, which no read of a rolling window accepts.
func (rule *postableRule) validateDurations() error {
if !rule.EvalWindow.IsPositive() || !rule.Frequency.IsPositive() {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "evalWindow %s or frequency %s is negative", rule.EvalWindow, rule.Frequency)
}
return nil
}
func (rule *postableRule) deriveAlertType() {
if slices.Contains(supportedAlertTypes, rule.AlertType) {
return
}
// promql, clickhouse and formula queries carry no signal; they alert on metrics
rule.AlertType = alertTypeMetric
for _, envelope := range rule.decodeQueryEnvelopes() {
switch envelope.Spec.Signal {
case signalLogs:
rule.AlertType = alertTypeLogs
return
case signalTraces:
rule.AlertType = alertTypeTraces
return
}
}
}
func (rule *postableRule) deriveQueryType() {
query := rule.RuleCondition.CompositeQuery
if slices.Contains(supportedQueryTypes, query.QueryType) {
return
}
envelopes := rule.decodeQueryEnvelopes()
if len(envelopes) == 0 {
return
}
// a promql or clickhouse_sql envelope spells its type like the composite query does; every other envelope is a builder one
switch envelopes[0].Type {
case queryTypePromQL, queryTypeClickHouseSQL:
query.QueryType = envelopes[0].Type
default:
query.QueryType = queryTypeBuilder
}
}
func (rule *postableRule) repairPanelType() {
query := rule.RuleCondition.CompositeQuery
// nothing reads panelType; both alert forms write graph
if !slices.Contains(supportedPanelTypes, query.PanelType) {
query.PanelType = panelTypeGraph
}
}
func (rule *postableRule) disableUnmetMinPoints() {
condition := rule.RuleCondition
if string(condition.RequireMinPoints) != "true" {
return
}
points := 0.0
if len(condition.RequiredNumPoints) > 0 {
if err := json.Unmarshal(condition.RequiredNumPoints, &points); err != nil {
return
}
}
if points > 0 {
return
}
// zero required points is a check every series passes
condition.RequireMinPoints = json.RawMessage("false")
}
func (rule *postableRule) decodeQueryEnvelopes() []ruleQueryEnvelope {
var envelopes []ruleQueryEnvelope
if err := json.Unmarshal(rule.RuleCondition.CompositeQuery.Queries, &envelopes); err != nil {
return nil
}
return envelopes
}
func (rule *postableRule) deriveRuleType() {
switch rule.RuleCondition.CompositeQuery.QueryType {
case queryTypeBuilder, queryTypeClickHouseSQL:
if rule.RuleType == "" {
rule.RuleType = ruleTypeThreshold
}
case queryTypePromQL:
rule.RuleType = ruleTypeProm
}
}
// negateAnomalyBelowTarget stores "below 3" as -3, since anomaly scores are
// signed. Only the raw "2" negates; "below" and "<" keep the sign the rule
// evaluates with today.
func (rule *postableRule) negateAnomalyBelowTarget() {
condition := rule.RuleCondition
if rule.RuleType != ruleTypeAnomaly || condition.CompareOperator != compareOperatorBelow || condition.Target == nil {
return
}
negated := -*condition.Target
condition.Target = &negated
}
// repairEmptySeverity rewrites a severity of "" as critical in labels and in
// the route expression that matches alerts on it, since a threshold named ""
// fails every later read.
func (rule *postableRule) repairEmptySeverity(ruleID string, route *routePolicyRow) {
if severity, ok := rule.Labels[labelSeverity]; !ok || severity != "" {
return
}
rule.Labels[labelSeverity] = criticalThresholdName
if route != nil {
route.Expression = fmt.Sprintf(`threshold.name == "%s" && ruleId == "%s"`, criticalThresholdName, ruleID)
}
}
func (rule *postableRule) relocateThreshold(channels []string) {
thresholdName := criticalThresholdName
if severity, ok := rule.Labels[labelSeverity]; ok {
thresholdName = severity
}
condition := rule.RuleCondition
condition.Thresholds = &ruleThresholds{
Kind: "basic",
Spec: []basicThreshold{{
Name: thresholdName,
TargetValue: condition.Target,
TargetUnit: condition.TargetUnit,
MatchType: condition.MatchType,
CompareOperator: condition.CompareOperator,
Channels: channels,
}},
}
}
func (rule *postableRule) relocateEvaluation() {
rule.Evaluation = &evaluationEnvelope{
Kind: "rolling",
Spec: rollingWindow{EvalWindow: rule.EvalWindow, Frequency: rule.Frequency},
}
}
func (rule *postableRule) synthesizeNotificationSettings() {
alertStates := []string{"firing"}
if rule.RuleCondition.AlertOnAbsent {
alertStates = append(alertStates, "nodata")
}
rule.NotificationSettings = &notificationSettings{
Renotify: &renotify{
Enabled: true,
ReNotifyInterval: mustParseTextDuration("4h"),
AlertStates: alertStates,
},
UsePolicy: false,
}
}
func (rule *postableRule) clearV1Fields() {
rule.EvalWindow = textDuration{}
rule.Frequency = textDuration{}
rule.PreferredChannels = nil
rule.RuleCondition.Target = nil
rule.RuleCondition.CompareOperator = ""
rule.RuleCondition.MatchType = ""
rule.RuleCondition.TargetUnit = ""
}
// textDuration decodes a duration the way the app's TextDuration does, a
// string or a number of nanoseconds, and writes a string back in its stored
// spelling.
type textDuration struct {
text string
value time.Duration
}
func mustParseTextDuration(text string) textDuration {
value, err := time.ParseDuration(text)
if err != nil {
panic(err)
}
return textDuration{text: text, value: value}
}
func (d textDuration) IsZero() bool {
return d.value == 0
}
func (d textDuration) IsPositive() bool {
return d.value > 0
}
func (d textDuration) String() string {
if d.text != "" {
return d.text
}
return d.value.String()
}
func (d textDuration) MarshalJSON() ([]byte, error) {
return json.Marshal(d.String())
}
func (d *textDuration) UnmarshalJSON(data []byte) error {
var raw any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
switch value := raw.(type) {
case float64:
*d = textDuration{value: time.Duration(value)}
return nil
case string:
parsed, err := time.ParseDuration(value)
if err != nil {
return err
}
*d = textDuration{text: value, value: parsed}
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid duration %s", data)
}
}
func (migration *migrateRulesV1ToV2Alpha1) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,350 @@
package sqlmigration
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"slices"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
)
type repairRulesV2Alpha1 struct {
sqlstore sqlstore.SQLStore
logger *slog.Logger
}
func NewRepairRulesV2Alpha1Factory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("repair_rules_v2alpha1"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &repairRulesV2Alpha1{sqlstore: sqlstore, logger: ps.Logger}, nil
})
}
func (migration *repairRulesV2Alpha1) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
// Up rewrites every v2alpha1 rule in every org where the write path now needs
// a value it lacks: the defaults migration 139 derives for a v1 rule, the
// timezone a cumulative window has to name, the notification settings a rule
// written before they were required, a suffix on a repeated threshold name,
// and disabled when a threshold fails it.
// Keys it does not touch keep their stored bytes. A rule it cannot read is
// logged and left as stored; a failed write aborts the run and rolls back
// every org.
func (migration *repairRulesV2Alpha1) Up(ctx context.Context, _ *bun.DB) error {
return migration.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
var orgIDs []string
if err := migration.sqlstore.BunDBCtx(ctx).NewSelect().Table("organizations").Column("id").Scan(ctx, &orgIDs); err != nil {
return err
}
for _, orgID := range orgIDs {
if err := migration.repairOrg(ctx, orgID); err != nil {
return err
}
}
return nil
})
}
func (migration *repairRulesV2Alpha1) repairOrg(ctx context.Context, orgID string) error {
db := migration.sqlstore.BunDBCtx(ctx)
rules := make([]*ruleRow, 0)
if err := db.NewSelect().Model(&rules).Where("org_id = ?", orgID).Scan(ctx); err != nil {
return err
}
var repaired, disabled, skipped, unrepairable int
for _, stored := range rules {
var header struct {
SchemaVersion string `json:"schemaVersion"`
}
// a row that does not decode, or is still v1, was reported by migration 139
if err := json.Unmarshal([]byte(stored.Data), &header); err != nil || header.SchemaVersion != schemaVersionV2Alpha1 {
skipped++
continue
}
rule, err := repairRule(stored.Data)
if err != nil {
unrepairable++
migration.logger.WarnContext(ctx, "leaving rule as stored", slog.String("org_id", orgID), slog.String("rule_id", stored.ID), errors.Attr(err))
continue
}
if rule.data == "" {
skipped++
continue
}
stored.Data = rule.data
if _, err := db.NewUpdate().Model(stored).Column("data").WherePK().Exec(ctx); err != nil {
return err
}
repaired++
for _, rename := range rule.renamed {
migration.logger.WarnContext(ctx, "renamed duplicate threshold", slog.String("org_id", orgID), slog.String("rule_id", stored.ID), slog.String("from", rename.from), slog.String("to", rename.to))
}
if rule.disabledFor != nil {
disabled++
migration.logger.WarnContext(ctx, "disabling rule, its threshold fails the write path", slog.String("org_id", orgID), slog.String("rule_id", stored.ID), errors.Attr(rule.disabledFor))
}
}
migration.logger.InfoContext(ctx, "repaired v2alpha1 rules", slog.String("org_id", orgID), slog.Int("total", len(rules)), slog.Int("repaired", repaired), slog.Int("disabled", disabled), slog.Int("skipped", skipped), slog.Int("unrepairable", unrepairable))
return nil
}
// repairedRule is a rewritten rule: its data, "" when nothing needed repair,
// the thresholds it renamed and the threshold failure it was disabled for.
type repairedRule struct {
data string
renamed []thresholdRename
disabledFor error
}
type thresholdRename struct {
index int
from, to string
}
// repairRule patches the stored document key by key, so the rest of it keeps
// its bytes. The values come from the same derivations migration 139 runs on
// a v1 rule, applied to a second decode so the stored ones remain to compare
// against.
func repairRule(data string) (repairedRule, error) {
stored, rule := new(postableRule), new(postableRule)
for _, target := range []*postableRule{stored, rule} {
if err := json.Unmarshal([]byte(data), target); err != nil {
return repairedRule{}, err
}
}
if rule.RuleCondition == nil || rule.RuleCondition.CompositeQuery == nil {
return repairedRule{}, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition or condition.compositeQuery is null")
}
rule.deriveAlertType()
rule.deriveQueryType()
rule.repairPanelType()
rule.disableUnmetMinPoints()
rule.deriveRuleType()
var document, condition, query map[string]json.RawMessage
if err := json.Unmarshal([]byte(data), &document); err != nil {
return repairedRule{}, err
}
if err := json.Unmarshal(document["condition"], &condition); err != nil {
return repairedRule{}, err
}
if err := json.Unmarshal(condition["compositeQuery"], &query); err != nil {
return repairedRule{}, err
}
changed := false
if rule.AlertType != stored.AlertType {
document["alertType"] = quote(rule.AlertType)
changed = true
}
// a stored "" goes too, since the strict type rejects it
if rule.RuleType != stored.RuleType || string(document["ruleType"]) == `""` {
delete(document, "ruleType")
if rule.RuleType != "" {
document["ruleType"] = quote(rule.RuleType)
}
changed = true
}
queryChanged := false
if rule.RuleCondition.CompositeQuery.QueryType != stored.RuleCondition.CompositeQuery.QueryType {
query["queryType"] = quote(rule.RuleCondition.CompositeQuery.QueryType)
queryChanged = true
}
if rule.RuleCondition.CompositeQuery.PanelType != stored.RuleCondition.CompositeQuery.PanelType {
query["panelType"] = quote(rule.RuleCondition.CompositeQuery.PanelType)
queryChanged = true
}
conditionChanged := queryChanged
if queryChanged {
raw, err := marshalUnescaped(query)
if err != nil {
return repairedRule{}, err
}
condition["compositeQuery"] = raw
}
if string(rule.RuleCondition.RequireMinPoints) != string(stored.RuleCondition.RequireMinPoints) {
condition["requireMinPoints"] = rule.RuleCondition.RequireMinPoints
conditionChanged = true
}
if string(condition["seasonality"]) == `""` {
delete(condition, "seasonality")
conditionChanged = true
}
renamed := dedupeThresholdNames(rule.RuleCondition.Thresholds)
if len(renamed) > 0 {
if err := renameThresholds(condition, renamed); err != nil {
return repairedRule{}, err
}
conditionChanged = true
}
if conditionChanged {
raw, err := marshalUnescaped(condition)
if err != nil {
return repairedRule{}, err
}
document["condition"] = raw
changed = true
}
timezoneFilled, err := fillCumulativeTimezone(document)
if err != nil {
return repairedRule{}, err
}
changed = changed || timezoneFilled
if rule.NotificationSettings == nil {
rule.synthesizeNotificationSettings()
raw, err := marshalUnescaped(rule.NotificationSettings)
if err != nil {
return repairedRule{}, err
}
document["notificationSettings"] = raw
changed = true
}
disabledFor := validateThresholds(rule.RuleCondition.Thresholds)
if disabledFor != nil && string(document["disabled"]) != "true" {
document["disabled"] = json.RawMessage("true")
changed = true
}
if !changed {
return repairedRule{}, nil
}
raw, err := marshalUnescaped(document)
if err != nil {
return repairedRule{}, err
}
return repairedRule{data: string(raw), renamed: renamed, disabledFor: disabledFor}, nil
}
// dedupeThresholdNames suffixes every repeat of a name with -2, -3, ... in
// order of appearance, skipping a suffix another threshold already carries.
// An empty name stays, since validateThresholds disables the rule for it.
func dedupeThresholdNames(thresholds *ruleThresholds) []thresholdRename {
if thresholds == nil {
return nil
}
names := make(map[string]struct{}, len(thresholds.Spec))
for _, threshold := range thresholds.Spec {
names[threshold.Name] = struct{}{}
}
seen := make(map[string]struct{}, len(thresholds.Spec))
var renamed []thresholdRename
for i, threshold := range thresholds.Spec {
if _, dup := seen[threshold.Name]; !dup || threshold.Name == "" {
seen[threshold.Name] = struct{}{}
continue
}
name := ""
for n := 2; ; n++ {
name = fmt.Sprintf("%s-%d", threshold.Name, n)
if _, taken := names[name]; !taken {
break
}
}
names[name] = struct{}{}
thresholds.Spec[i].Name = name
renamed = append(renamed, thresholdRename{index: i, from: threshold.Name, to: name})
}
return renamed
}
func renameThresholds(condition map[string]json.RawMessage, renamed []thresholdRename) error {
var thresholds map[string]json.RawMessage
if err := json.Unmarshal(condition["thresholds"], &thresholds); err != nil {
return err
}
var spec []map[string]json.RawMessage
if err := json.Unmarshal(thresholds["spec"], &spec); err != nil {
return err
}
for _, rename := range renamed {
spec[rename.index]["name"] = quote(rename.to)
}
raw, err := marshalUnescaped(spec)
if err != nil {
return err
}
thresholds["spec"] = raw
if raw, err = marshalUnescaped(thresholds); err != nil {
return err
}
condition["thresholds"] = raw
return nil
}
// fillCumulativeTimezone names the UTC an empty timezone always resolved to,
// now that the write path requires one.
func fillCumulativeTimezone(document map[string]json.RawMessage) (bool, error) {
var evaluation struct {
Kind string `json:"kind"`
Spec map[string]json.RawMessage `json:"spec"`
}
if err := json.Unmarshal(document["evaluation"], &evaluation); err != nil || evaluation.Kind != "cumulative" || evaluation.Spec == nil {
return false, nil
}
if timezone := string(evaluation.Spec["timezone"]); timezone != "" && timezone != `""` {
return false, nil
}
evaluation.Spec["timezone"] = json.RawMessage(`"UTC"`)
raw, err := marshalUnescaped(evaluation)
if err != nil {
return false, err
}
document["evaluation"] = raw
return true, nil
}
// validateThresholds is migration 139's threshold check over every threshold
// of a v2alpha1 rule, which may hold several, plus the name the v1 path
// always fills.
func validateThresholds(thresholds *ruleThresholds) error {
if thresholds == nil {
return nil
}
for _, threshold := range thresholds.Spec {
if threshold.Name == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.thresholds.spec: threshold name is empty")
}
if threshold.TargetValue == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.target is null")
}
if !slices.Contains(supportedCompareOperators, threshold.CompareOperator) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.op: unsupported value %q", threshold.CompareOperator)
}
if !slices.Contains(supportedMatchTypes, threshold.MatchType) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.matchType: unsupported value %q", threshold.MatchType)
}
}
return nil
}
// quote encodes a value the migration chose itself, so it cannot fail.
func quote(value string) json.RawMessage {
raw, _ := json.Marshal(value)
return raw
}
func (migration *repairRulesV2Alpha1) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -619,10 +619,6 @@ type ConfigStore interface {
// ListAllChannels returns the list of channels for all organizations.
ListAllChannels(context.Context) ([]*Channel, error)
// GetMatchers gets a list of matchers per organization.
// Matchers is an array of ruleId to receiver names.
GetMatchers(context.Context, string) (map[string][]string, error)
}
// MarshalSecretValue if set to true will expose Secret type

View File

@@ -150,7 +150,7 @@ func NewAccountFromStorable(storableAccount *StorableCloudIntegration) (*Account
}
account.Config.Azure = azureConfig
case CloudProviderTypeGCP:
gcpConfig := new(GCPAccountConfig)
gcpConfig := &GCPAccountConfig{ConnectionMethod: GCPConnectionMethodManual}
err := json.Unmarshal([]byte(storableAccount.Config), gcpConfig)
if err != nil {
return nil, err
@@ -240,25 +240,16 @@ func NewAccountConfigFromPostable(provider CloudProviderType, config *PostableAc
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "GCP config can not be nil for GCP provider")
}
if config.GCP.DeploymentProjectID == "" {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "deployment project ID is required for GCP provider")
gcpConfig := &GCPAccountConfig{
ConnectionMethod: config.GCP.ConnectionMethod,
DeploymentProjectID: config.GCP.DeploymentProjectID,
ProjectIDs: config.GCP.ProjectIDs,
DeploymentRegion: config.GCP.DeploymentRegion,
}
if err := validateGCPRegion(config.GCP.DeploymentRegion); err != nil {
if err := gcpConfig.ErrIfInvalid(); err != nil {
return nil, err
}
if len(config.GCP.ProjectIDs) == 0 {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one project id is required for GCP provider")
}
return &AccountConfig{
GCP: &GCPAccountConfig{
DeploymentProjectID: config.GCP.DeploymentProjectID,
ProjectIDs: config.GCP.ProjectIDs,
DeploymentRegion: config.GCP.DeploymentRegion,
},
}, nil
return &AccountConfig{GCP: gcpConfig}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -301,12 +292,12 @@ func NewAccountConfigFromUpdatable(provider CloudProviderType, config *Updatable
return nil, err
}
if len(config.Config.GCP.ProjectIDs) == 0 {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one project id is required for GCP provider")
if err := validateGCPProjectIDs(config.Config.GCP.ProjectIDs); err != nil {
return nil, err
}
if config.Config.GCP.DeploymentProjectID == "" {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "deployment project ID is required for GCP provider")
if err := validateGCPDeploymentProjectID(config.Config.GCP.DeploymentProjectID); err != nil {
return nil, err
}
return &AccountConfig{
@@ -355,6 +346,13 @@ func GetSigNozAPIURLFromDeployment(deployment *zeustypes.GettableDeployment) (st
}
func (account *Account) Update(provider CloudProviderType, config *AccountConfig) error {
if provider == CloudProviderTypeGCP {
if account.Config.GCP.ConnectionMethod == GCPConnectionMethodAgent {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "GCP agent account configuration cannot be updated")
}
config.GCP.ConnectionMethod = account.Config.GCP.ConnectionMethod
}
// deployment region can not be updated once set for Azure
if provider == CloudProviderTypeAzure {
config.Azure.DeploymentRegion = account.Config.Azure.DeploymentRegion
@@ -366,6 +364,18 @@ func (account *Account) Update(provider CloudProviderType, config *AccountConfig
return nil
}
func (account *Account) ErrIfInvalidProviderAccountID(providerAccountID string) error {
if account.Provider != CloudProviderTypeGCP || account.Config.GCP.ConnectionMethod != GCPConnectionMethodAgent {
return nil
}
if providerAccountID != account.Config.GCP.DeploymentProjectID {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "providerAccountId must match deploymentProjectId for GCP agent setup")
}
return nil
}
func (account *Account) UpdateAgentReport(providerAccountID *string, agentReport *AgentReport) {
account.ProviderAccountID = providerAccountID
account.AgentReport = agentReport

View File

@@ -1,32 +1,69 @@
package cloudintegrationtypes
import (
"bytes"
"slices"
"strings"
"text/template"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
GCPConnectionMethodManual = GCPConnectionMethod{valuer.NewString("manual")}
GCPConnectionMethodAgent = GCPConnectionMethod{valuer.NewString("agent")}
foundryScriptURL = "https://signoz.io/foundry.sh"
gcpCLITemplate = template.Must(template.New("gcpCLI").Funcs(template.FuncMap{
"shellQuote": func(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
},
}).Parse(gcpCLITemplateStr()))
)
type GCPConnectionMethod struct{ valuer.String }
func (GCPConnectionMethod) Enum() []any {
return []any{GCPConnectionMethodManual, GCPConnectionMethodAgent}
}
type GCPAccountConfig struct {
// Project ID where central pub/sub for logs exist
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
// Project ID where otel collector will be deployed
DeploymentRegion string `json:"deploymentRegion" required:"true"`
// List of project IDs to monitor
ConnectionMethod GCPConnectionMethod `json:"connectionMethod" required:"true"`
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
DeploymentRegion string `json:"deploymentRegion" required:"true"`
// Agent accounts store exactly the deployment project; manual accounts may monitor multiple projects.
ProjectIDs []string `json:"projectIds" required:"true" nullable:"false"`
}
type GCPPostableAccountConfig = GCPAccountConfig
type UpdatableGCPAccountConfig struct {
// Project ID where central pub/sub for logs exist
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
// Compute service region where otel collector will be deployed
DeploymentRegion string `json:"deploymentRegion" required:"true"`
// List of project IDs to monitor
ProjectIDs []string `json:"projectIds" required:"true"`
type GCPPostableAccountConfig struct {
ConnectionMethod GCPConnectionMethod `json:"connectionMethod" required:"true"`
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
DeploymentRegion string `json:"deploymentRegion" required:"true"`
// Agent setup requires exactly DeploymentProjectID; manual setup may select multiple projects.
ProjectIDs []string `json:"projectIds" required:"true" nullable:"false"`
}
type GCPConnectionArtifact struct{}
// UpdatableGCPAccountConfig contains settings for manual integrations only.
type UpdatableGCPAccountConfig struct {
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
DeploymentRegion string `json:"deploymentRegion" required:"true"`
ProjectIDs []string `json:"projectIds" required:"true" nullable:"false"`
}
type GCPIntegrationConfig struct{}
type GCPConnectionArtifact struct {
CLICommand string `json:"cliCommand" required:"true"`
}
type GCPIntegrationConfig struct {
GCPAccountConfig
TelemetryCollectionStrategy *GCPTelemetryCollectionStrategy `json:"telemetryCollectionStrategy" required:"true" nullable:"false"`
}
type GCPTelemetryCollectionStrategy struct {
Metrics *GCPMetricsCollectionStrategy `json:"metrics" required:"false" nullable:"false"`
Logs *GCPLogsCollectionStrategy `json:"logs" required:"false" nullable:"false"`
Metrics *GCPMetricsCollectionStrategy `json:"metrics" required:"true" nullable:"true"`
Logs *GCPLogsCollectionStrategy `json:"logs" required:"true" nullable:"true"`
}
type GCPMetricsCollectionStrategy struct {
@@ -50,3 +87,120 @@ type GCPServiceLogsConfig struct {
type GCPServiceMetricsConfig struct {
Enabled bool `json:"enabled" required:"true"`
}
func (config *GCPAccountConfig) ErrIfInvalid() error {
if config.ConnectionMethod.IsZero() {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "connectionMethod is required for GCP provider")
}
if config.ConnectionMethod != GCPConnectionMethodManual && config.ConnectionMethod != GCPConnectionMethodAgent {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "unsupported GCP connectionMethod: %q", config.ConnectionMethod.StringValue())
}
if err := validateGCPDeploymentProjectID(config.DeploymentProjectID); err != nil {
return err
}
if err := validateGCPRegion(config.DeploymentRegion); err != nil {
return err
}
if err := validateGCPProjectIDs(config.ProjectIDs); err != nil {
return err
}
if config.ConnectionMethod == GCPConnectionMethodAgent && (len(config.ProjectIDs) != 1 || config.ProjectIDs[0] != config.DeploymentProjectID) {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "projectIds must contain only deploymentProjectId for GCP agent setup")
}
return nil
}
func NewGCPConnectionArtifact(integrationAccountID valuer.UUID, agentVersion string, creds *Credentials, cfg *GCPAccountConfig) (*GCPConnectionArtifact, error) {
data := gcpTemplateData{
FoundryScriptURL: foundryScriptURL,
ProjectID: cfg.DeploymentProjectID,
Region: cfg.DeploymentRegion,
IntegrationAccountID: integrationAccountID.StringValue(),
AgentVersion: agentVersion,
Credentials: creds,
}
var command bytes.Buffer
if err := gcpCLITemplate.Execute(&command, data); err != nil {
return nil, err
}
return &GCPConnectionArtifact{
CLICommand: command.String(),
}, nil
}
// NewGCPIntegrationConfig sorts and deduplicates the input slices in place and formats sink filters for joining with OR.
func NewGCPIntegrationConfig(config *GCPAccountConfig, metricNames, sinkFilters []string) *GCPIntegrationConfig {
// Parentheses preserve each service filter's meaning when combined with OR.
for i, filter := range sinkFilters {
sinkFilters[i] = "(" + strings.TrimSpace(filter) + ")"
}
// Keep the generated config stable and avoid collecting shared metrics more than once.
slices.Sort(metricNames)
metricNames = slices.Compact(metricNames)
slices.Sort(sinkFilters)
sinkFilters = slices.Compact(sinkFilters)
strategy := new(GCPTelemetryCollectionStrategy)
if len(metricNames) > 0 {
strategy.Metrics = &GCPMetricsCollectionStrategy{MetricNames: metricNames}
}
if len(sinkFilters) > 0 {
strategy.Logs = &GCPLogsCollectionStrategy{SinkFilter: strings.Join(sinkFilters, " OR ")}
}
return &GCPIntegrationConfig{
GCPAccountConfig: *config,
TelemetryCollectionStrategy: strategy,
}
}
type gcpTemplateData struct {
FoundryScriptURL string
ProjectID string
Region string
IntegrationAccountID string
AgentVersion string
Credentials *Credentials
}
func gcpCLITemplateStr() string {
return `curl -fsSL {{.FoundryScriptURL | shellQuote}} | bash -s -- cloudintegration gcp terraform \
--region {{.Region | shellQuote}} \
--gcp-project {{.ProjectID | shellQuote}} \
--integration-account-id {{.IntegrationAccountID | shellQuote}} \
--signoz-api-url {{.Credentials.SigNozAPIURL | shellQuote}} \
--signoz-api-key {{.Credentials.SigNozAPIKey | shellQuote}} \
--ingestion-url {{.Credentials.IngestionURL | shellQuote}} \
--ingestion-key {{.Credentials.IngestionKey | shellQuote}} \
--agent-version {{.AgentVersion | shellQuote}}`
}
func validateGCPDeploymentProjectID(projectID string) error {
if strings.TrimSpace(projectID) == "" {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "deploymentProjectId is required for GCP provider")
}
if projectID != strings.TrimSpace(projectID) {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "deploymentProjectId cannot contain leading or trailing whitespace")
}
return nil
}
func validateGCPProjectIDs(projectIDs []string) error {
if len(projectIDs) == 0 {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "projectIds must contain at least one project ID for GCP provider")
}
for _, projectID := range projectIDs {
if strings.TrimSpace(projectID) == "" {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "projectIds cannot contain an empty or whitespace-only project ID")
}
if projectID != strings.TrimSpace(projectID) {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "projectIds cannot contain leading or trailing whitespace")
}
}
return nil
}

View File

@@ -32,5 +32,10 @@ func (q *Query) Validate() error {
if q.Order != qbtypes.OrderDirectionAsc && q.Order != qbtypes.OrderDirectionDesc {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "order must be asc or desc")
}
if !q.State.IsZero() {
if err := q.State.Validate(); err != nil {
return err
}
}
return nil
}

View File

@@ -1,6 +1,9 @@
package ruletypes
import "github.com/SigNoz/signoz/pkg/valuer"
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
type AlertState struct {
valuer.String
@@ -26,6 +29,15 @@ func (AlertState) Enum() []any {
}
}
func (a AlertState) Validate() error {
switch a {
case StateInactive, StatePending, StateRecovering, StateFiring, StateNoData, StateDisabled:
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "state: unsupported value %q; must be one of inactive, pending, recovering, firing, nodata, disabled", a.StringValue())
}
}
var alertStateSeverity = map[AlertState]int{
StateInactive: 0,
StatePending: 1,

View File

@@ -5,6 +5,7 @@ import (
"sort"
"time"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -77,6 +78,15 @@ func (PanelType) Enum() []any {
}
}
func (p PanelType) Validate() error {
switch p {
case PanelTypeValue, PanelTypeTable, PanelTypeGraph:
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery.panelType: unsupported value %q; must be one of value, table, graph", p.StringValue())
}
}
// Note: this is used to represent the state of the alert query
// i.e the active tab which should be used to represent the selection
@@ -99,6 +109,15 @@ func (QueryType) Enum() []any {
}
}
func (q QueryType) Validate() error {
switch q {
case QueryTypeBuilder, QueryTypeClickHouseSQL, QueryTypePromQL:
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery.queryType: unsupported value %q; must be one of builder, clickhouse_sql, promql", q.StringValue())
}
}
type AlertCompositeQuery struct {
Queries []qbtypes.QueryEnvelope `json:"queries" required:"true"`
@@ -111,18 +130,13 @@ type AlertCompositeQuery struct {
type RuleCondition struct {
CompositeQuery *AlertCompositeQuery `json:"compositeQuery" required:"true"`
CompareOperator CompareOperator `json:"op,omitzero"`
Target *float64 `json:"target,omitempty" format:"double"`
AlertOnAbsent bool `json:"alertOnAbsent,omitempty"`
AbsentFor uint64 `json:"absentFor,omitempty"`
MatchType MatchType `json:"matchType,omitzero"`
TargetUnit string `json:"targetUnit,omitempty"`
Algorithm string `json:"algorithm,omitempty"`
Seasonality Seasonality `json:"seasonality,omitzero"`
SelectedQuery string `json:"selectedQueryName,omitempty"`
RequireMinPoints bool `json:"requireMinPoints,omitempty"`
RequiredNumPoints int `json:"requiredNumPoints,omitempty"`
Thresholds *RuleThresholdData `json:"thresholds,omitempty"`
Thresholds *RuleThresholdData `json:"thresholds" required:"true"`
}
func (rc *RuleCondition) SelectedQueryName() string {

View File

@@ -9,6 +9,7 @@ import (
"unicode/utf8"
"github.com/prometheus/alertmanager/config"
"github.com/swaggest/jsonschema-go"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
@@ -48,7 +49,6 @@ func (t AlertType) BuilderQueryType() qbtypes.QueryType {
}
const (
DefaultSchemaVersion = "v1"
SchemaVersionV2Alpha1 = "v2alpha1"
)
@@ -60,12 +60,10 @@ const (
// PostableRule is used to create alerting rule from HTTP api.
type PostableRule struct {
AlertName valuer.DisplayName `json:"alert" required:"true"`
AlertType AlertType `json:"alertType" required:"true"`
Description string `json:"description,omitempty"`
RuleType RuleType `json:"ruleType" required:"true"`
EvalWindow valuer.TextDuration `json:"evalWindow,omitzero"`
Frequency valuer.TextDuration `json:"frequency,omitzero"`
AlertName valuer.DisplayName `json:"alert" required:"true"`
AlertType AlertType `json:"alertType" required:"true"`
Description string `json:"description,omitempty"`
RuleType RuleType `json:"ruleType" required:"true"`
RuleCondition *RuleCondition `json:"condition" required:"true"`
Labels map[string]string `json:"labels,omitempty"`
@@ -76,14 +74,12 @@ type PostableRule struct {
// Source captures the source url where rule has been created
Source string `json:"source,omitempty"`
PreferredChannels []string `json:"preferredChannels,omitempty"`
Version string `json:"version"`
Evaluation *EvaluationEnvelope `json:"evaluation,omitempty"`
SchemaVersion string `json:"schemaVersion,omitempty"`
Evaluation *EvaluationEnvelope `json:"evaluation" required:"true"`
SchemaVersion valuer.UnsetOrNonEmptyString `json:"schemaVersion" required:"true" enum:"v2alpha1"`
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"`
NotificationSettings *NotificationSettings `json:"notificationSettings" required:"true"`
}
type NotificationSettings struct {
@@ -229,101 +225,20 @@ func (ns *NotificationSettings) UnmarshalJSON(data []byte) error {
// processRuleDefaults applies the default values
// for the rule options that are blank or unset.
func (r *PostableRule) processRuleDefaults() {
if r.SchemaVersion == "" {
r.SchemaVersion = DefaultSchemaVersion
}
// TODO(srikanthccv): remove as this is now a legacy field
if r.Version == "" {
r.Version = "v5"
}
// v2alpha1 uses the Evaluation envelope for window/frequency;
// only default top-level fields for v1.
if r.SchemaVersion != SchemaVersionV2Alpha1 {
if r.EvalWindow.IsZero() {
r.EvalWindow = valuer.MustParseTextDuration("5m")
}
if r.Frequency.IsZero() {
r.Frequency = valuer.MustParseTextDuration("1m")
}
}
if r.RuleCondition != nil && r.RuleCondition.CompositeQuery != nil {
switch r.RuleCondition.CompositeQuery.QueryType {
case QueryTypeBuilder:
case QueryTypeBuilder, QueryTypeClickHouseSQL:
if r.RuleType.IsZero() {
r.RuleType = RuleTypeThreshold
}
case QueryTypePromQL:
r.RuleType = RuleTypeProm
}
if r.SchemaVersion == DefaultSchemaVersion {
thresholdName := CriticalThresholdName
if r.Labels != nil {
if severity, ok := r.Labels["severity"]; ok {
thresholdName = severity
}
}
// For anomaly detection with ValueIsBelow, negate the target
targetValue := r.RuleCondition.Target
if r.RuleType == RuleTypeAnomaly && r.RuleCondition.CompareOperator == ValueIsBelow && targetValue != nil {
negated := -1 * *targetValue
targetValue = &negated
}
thresholdData := RuleThresholdData{
Kind: BasicThresholdKind,
Spec: BasicRuleThresholds{{
Name: thresholdName,
TargetUnit: r.RuleCondition.TargetUnit,
TargetValue: targetValue,
MatchType: r.RuleCondition.MatchType,
CompareOperator: r.RuleCondition.CompareOperator,
Channels: r.PreferredChannels,
}},
}
r.RuleCondition.Thresholds = &thresholdData
r.Evaluation = &EvaluationEnvelope{RollingEvaluation, RollingWindow{EvalWindow: r.EvalWindow, Frequency: r.Frequency}}
r.NotificationSettings = &NotificationSettings{
Renotify: &Renotify{
Enabled: true,
ReNotifyInterval: valuer.MustParseTextDuration("4h"),
AlertStates: []AlertState{StateFiring},
},
}
if r.RuleCondition.AlertOnAbsent {
r.NotificationSettings.Renotify.AlertStates = append(r.NotificationSettings.Renotify.AlertStates, StateNoData)
}
}
}
}
func (r *PostableRule) MarshalJSON() ([]byte, error) {
type Alias PostableRule
switch r.SchemaVersion {
case DefaultSchemaVersion:
copyStruct := *r
aux := Alias(copyStruct)
if aux.RuleCondition != nil {
aux.RuleCondition.Thresholds = nil
}
aux.Evaluation = nil
aux.SchemaVersion = ""
aux.NotificationSettings = nil
return json.Marshal(aux)
case SchemaVersionV2Alpha1:
copyStruct := *r
aux := Alias(copyStruct)
return json.Marshal(aux)
default:
copyStruct := *r
aux := Alias(copyStruct)
return json.Marshal(aux)
}
}
@@ -337,6 +252,19 @@ func (r *PostableRule) UnmarshalJSON(bytes []byte) error {
return r.validate()
}
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)
}
}
return nil
}
func isValidLabelName(ln string) bool {
if len(ln) == 0 {
return false
@@ -364,10 +292,6 @@ func (r *PostableRule) validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition: field is required")
}
if r.Version != "v5" {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "version: only v5 is supported, got %q", r.Version))
}
for k, v := range r.Labels {
if !isValidLabelName(k) {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid label name: %s", k))
@@ -411,18 +335,14 @@ func (r *PostableRule) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition: field is required")
}
if r.Version != "v5" {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "version: only v5 is supported, got %q", r.Version))
}
if r.AlertType != "" {
switch r.AlertType {
case AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces:
default:
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"alertType: unsupported value %q; must be one of %q, %q, %q, %q, %q",
r.AlertType, AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces))
}
switch r.AlertType {
case AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces:
case "":
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "alertType: field is required"))
default:
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"alertType: unsupported value %q; must be one of %q, %q, %q, %q, %q",
r.AlertType, AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces))
}
if !r.RuleType.IsZero() {
@@ -440,6 +360,12 @@ func (r *PostableRule) Validate() error {
if r.RuleCondition.CompositeQuery == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery: field is required"))
} else {
if err := r.RuleCondition.CompositeQuery.QueryType.Validate(); err != nil {
errs = append(errs, err)
}
if err := r.RuleCondition.CompositeQuery.PanelType.Validate(); err != nil {
errs = append(errs, err)
}
if len(r.RuleCondition.CompositeQuery.Queries) == 0 {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery.queries: must have at least one query"))
} else {
@@ -501,70 +427,32 @@ func (r *PostableRule) Validate() error {
}
func (r *PostableRule) validateSchemaVersion() []error {
switch r.SchemaVersion {
case DefaultSchemaVersion:
return r.validateV1()
switch r.SchemaVersion.StringValue() {
case SchemaVersionV2Alpha1:
return r.validateV2Alpha1()
default:
return []error{errors.NewInvalidInputf(errors.CodeInvalidInput,
"schemaVersion: unsupported value %q; must be one of %q, %q",
r.SchemaVersion, DefaultSchemaVersion, SchemaVersionV2Alpha1)}
"schemaVersion: unsupported value %q; must be %q",
r.SchemaVersion.StringValue(), SchemaVersionV2Alpha1)}
}
}
func (r *PostableRule) validateV1() []error {
var errs []error
if r.RuleCondition.Target == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.target: field is required for schemaVersion %q", DefaultSchemaVersion))
}
if r.RuleCondition.CompareOperator.IsZero() {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.op: field is required for schemaVersion %q", DefaultSchemaVersion))
} else if err := r.RuleCondition.CompareOperator.Validate(); err != nil {
errs = append(errs, err)
}
if r.RuleCondition.MatchType.IsZero() {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.matchType: field is required for schemaVersion %q", DefaultSchemaVersion))
} else if err := r.RuleCondition.MatchType.Validate(); err != nil {
errs = append(errs, err)
}
return errs
}
func (r *PostableRule) validateV2Alpha1() []error {
var errs []error
// TODO(srikanthccv): reject v1-only fields?
// if r.RuleCondition.Target != nil {
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
// "condition.target: field is not used in schemaVersion %q; set target in condition.thresholds entries instead",
// SchemaVersionV2Alpha1))
// }
// if !r.RuleCondition.CompareOperator.IsZero() {
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
// "condition.op: field is not used in schemaVersion %q; set op in condition.thresholds entries instead",
// SchemaVersionV2Alpha1))
// }
// if !r.RuleCondition.MatchType.IsZero() {
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
// "condition.matchType: field is not used in schemaVersion %q; set matchType in condition.thresholds entries instead",
// SchemaVersionV2Alpha1))
// }
// if len(r.PreferredChannels) > 0 {
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
// "preferredChannels: field is not used in schemaVersion %q; set channels in condition.thresholds entries instead",
// SchemaVersionV2Alpha1))
// }
// Require v2alpha1-specific fields
if r.RuleCondition.Thresholds == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.thresholds: field is required for schemaVersion %q", SchemaVersionV2Alpha1))
} else if threshold, err := r.RuleCondition.Thresholds.GetRuleThreshold(); err == nil {
if err := threshold.Validate(); err != nil {
errs = append(errs, err)
}
if thresholds, ok := threshold.(BasicRuleThresholds); ok {
if err := thresholds.validateUniqueNames(); err != nil {
errs = append(errs, err)
}
}
}
if r.Evaluation == nil {
@@ -644,31 +532,6 @@ type GettableRule struct {
UpdatedBy *string `json:"updateBy" nullable:"true"`
}
func (g *GettableRule) MarshalJSON() ([]byte, error) {
type Alias GettableRule
switch g.SchemaVersion {
case DefaultSchemaVersion:
copyStruct := *g
aux := Alias(copyStruct)
if aux.RuleCondition != nil {
aux.RuleCondition.Thresholds = nil
}
aux.Evaluation = nil
aux.SchemaVersion = ""
aux.NotificationSettings = nil
return json.Marshal(aux)
case SchemaVersionV2Alpha1:
copyStruct := *g
aux := Alias(copyStruct)
return json.Marshal(aux)
default:
copyStruct := *g
aux := Alias(copyStruct)
return json.Marshal(aux)
}
}
// Rule is the v2 API read model for an alerting rule. It aligns audit fields
// with the canonical types.TimeAuditable / types.UserAuditable shape used by
// PlannedMaintenance and other entities. v1 handlers keep serializing
@@ -697,28 +560,3 @@ func NewRule(g *GettableRule) *Rule {
}
return r
}
func (r *Rule) MarshalJSON() ([]byte, error) {
type Alias Rule
switch r.SchemaVersion {
case DefaultSchemaVersion:
copyStruct := *r
aux := Alias(copyStruct)
if aux.RuleCondition != nil {
aux.RuleCondition.Thresholds = nil
}
aux.Evaluation = nil
aux.SchemaVersion = ""
aux.NotificationSettings = nil
return json.Marshal(aux)
case SchemaVersionV2Alpha1:
copyStruct := *r
aux := Alias(copyStruct)
return json.Marshal(aux)
default:
copyStruct := *r
aux := Alias(copyStruct)
return json.Marshal(aux)
}
}

View File

@@ -3,7 +3,6 @@ package ruletypes
import (
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -29,8 +28,7 @@ func TestParseIntoRule(t *testing.T) {
"alertType": "METRIC_BASED_ALERT",
"description": "Test description",
"ruleType": "threshold_rule",
"evalWindow": "5m",
"frequency": "1m",
"schemaVersion": "v2alpha1",
"version": "v5",
"condition": {
"compositeQuery": {
@@ -45,11 +43,11 @@ func TestParseIntoRule(t *testing.T) {
}
}]
},
"target": 10.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A"
}
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 10.0, "matchType": "1", "op": "1"}]},
"selectedQueryName": "A"
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`),
kind: RuleDataKindJson,
expectError: false,
@@ -63,46 +61,6 @@ func TestParseIntoRule(t *testing.T) {
if rule.RuleCondition.Thresholds.Kind.IsZero() {
t.Error("Expected thresholds to be populated")
}
if rule.RuleCondition.Target == nil {
t.Error("Expected target to be populated")
}
},
},
{
name: "rule with default values applied",
initRule: PostableRule{},
content: []byte(`{
"alert": "DefaultsRule",
"ruleType": "threshold_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 5.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A"
}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.EvalWindow.Duration() != 5*time.Minute {
t.Errorf("Expected default eval window '5m', got '%v'", rule.EvalWindow)
}
if rule.Frequency.Duration() != time.Minute {
t.Errorf("Expected default frequency '1m', got '%v'", rule.Frequency)
}
},
},
{
@@ -110,6 +68,7 @@ func TestParseIntoRule(t *testing.T) {
initRule: PostableRule{},
content: []byte(`{
"alert": "PromQLRule",
"schemaVersion": "v2alpha1",
"version": "v5",
"condition": {
"compositeQuery": {
@@ -123,11 +82,11 @@ func TestParseIntoRule(t *testing.T) {
}
}]
},
"target": 10.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A"
}
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 10.0, "matchType": "1", "op": "1"}]},
"selectedQueryName": "A"
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`),
kind: RuleDataKindJson,
expectError: false,
@@ -138,9 +97,6 @@ func TestParseIntoRule(t *testing.T) {
if rule.RuleCondition.Thresholds.Kind.IsZero() {
t.Error("Expected thresholds to be populated")
}
if rule.RuleCondition.Target == nil {
t.Error("Expected target to be populated")
}
},
},
}
@@ -177,233 +133,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
expectValidateError bool // Validate() error (write path only)
validate func(*testing.T, *PostableRule)
}{
{
name: "schema v1 - threshold name from severity label",
initRule: PostableRule{},
content: []byte(`{
"alert": "SeverityLabelTest",
"schemaVersion": "v1",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "cpu_usage", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}],
"unit": "percent"
},
"target": 85.0,
"targetUnit": "%",
"matchType": "1",
"op": "1"
},
"labels": {
"severity": "warning",
"team": "platform"
}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.RuleCondition.Thresholds == nil {
t.Fatal("Expected Thresholds to be populated for v1")
}
threshold := rule.RuleCondition.Thresholds
if threshold.Kind != BasicThresholdKind {
t.Errorf("Expected BasicThresholdKind, got %s", threshold.Kind)
}
specs, ok := threshold.Spec.(BasicRuleThresholds)
if !ok {
t.Fatalf("Expected BasicRuleThresholds, got %T", threshold.Spec)
}
if len(specs) != 1 {
t.Fatalf("Expected 1 threshold spec, got %d", len(specs))
}
spec := specs[0]
if spec.Name != "warning" {
t.Errorf("Expected threshold name 'warning' from severity label, got '%s'", spec.Name)
}
if spec.TargetUnit != "%" {
t.Errorf("Expected TargetUnit '%%', got '%s'", spec.TargetUnit)
}
if *spec.TargetValue != 85.0 {
t.Errorf("Expected TargetValue 85.0, got %v", *spec.TargetValue)
}
if spec.MatchType != rule.RuleCondition.MatchType {
t.Error("Expected MatchType to be copied from RuleCondition")
}
if spec.CompareOperator != rule.RuleCondition.CompareOperator {
t.Error("Expected CompareOp to be copied from RuleCondition")
}
// Verify evaluation envelope is populated
if rule.Evaluation == nil {
t.Fatal("Expected Evaluation to be populated for v1")
}
if rule.Evaluation.Kind != RollingEvaluation {
t.Errorf("Expected RollingEvaluation, got %s", rule.Evaluation.Kind)
}
// Verify evaluation window matches rule settings
if window, ok := rule.Evaluation.Spec.(RollingWindow); ok {
if !window.EvalWindow.Equal(rule.EvalWindow) {
t.Errorf("Expected Evaluation EvalWindow %v, got %v", rule.EvalWindow, window.EvalWindow)
}
if !window.Frequency.Equal(rule.Frequency) {
t.Errorf("Expected Evaluation Frequency %v, got %v", rule.Frequency, window.Frequency)
}
} else {
t.Errorf("Expected RollingWindow spec, got %T", rule.Evaluation.Spec)
}
},
},
{
name: "schema v1 - uses critical threshold when no labels",
initRule: PostableRule{},
content: []byte(`{
"alert": "NoLabelsTest",
"schemaVersion": "v1",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "memory_usage", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 90.0,
"matchType": "1",
"op": "1"
}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.RuleCondition.Thresholds == nil {
t.Fatal("Expected Thresholds to be populated")
}
specs, ok := rule.RuleCondition.Thresholds.Spec.(BasicRuleThresholds)
if !ok {
t.Fatalf("Expected BasicRuleThresholds, got %T", rule.RuleCondition.Thresholds.Spec)
}
spec := specs[0]
// Should default to CriticalThresholdName when no severity label
if spec.Name != CriticalThresholdName {
t.Errorf("Expected threshold name '%s', got '%s'", CriticalThresholdName, spec.Name)
}
},
},
{
name: "schema v1 - overwrites existing thresholds and evaluation",
initRule: PostableRule{},
content: []byte(`{
"alert": "OverwriteTest",
"schemaVersion": "v1",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "cpu_usage", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}],
"unit": "percent"
},
"target": 80.0,
"targetUnit": "%",
"matchType": "1",
"op": "1",
"thresholds": {
"kind": "basic",
"spec": [{
"name": "existing_threshold",
"target": 50.0,
"targetUnit": "MB",
"ruleUnit": "bytes",
"matchType": "1",
"op": "1"
}]
}
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "10m",
"frequency": "2m"
}
},
"frequency":"7m",
"evalWindow":"11m",
"labels": {
"severity": "critical"
}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.RuleCondition.Thresholds == nil {
t.Fatal("Expected Thresholds to be populated")
}
specs, ok := rule.RuleCondition.Thresholds.Spec.(BasicRuleThresholds)
if !ok {
t.Fatalf("Expected BasicRuleThresholds, got %T", rule.RuleCondition.Thresholds.Spec)
}
if len(specs) != 1 {
t.Fatalf("Expected 1 threshold spec, got %d", len(specs))
}
spec := specs[0]
if spec.Name != "critical" {
t.Errorf("Expected threshold name 'critical' (overwritten), got '%s'", spec.Name)
}
if *spec.TargetValue != 80.0 {
t.Errorf("Expected TargetValue 80.0 (overwritten), got %v", *spec.TargetValue)
}
if spec.TargetUnit != "%" {
t.Errorf("Expected TargetUnit '%%' (overwritten), got '%s'", spec.TargetUnit)
}
if rule.Evaluation == nil {
t.Fatal("Expected Evaluation to be populated")
}
if window, ok := rule.Evaluation.Spec.(RollingWindow); ok {
if !window.EvalWindow.Equal(rule.EvalWindow) {
t.Errorf("Expected Evaluation EvalWindow to be overwritten to %v, got %v", rule.EvalWindow, window.EvalWindow)
}
if !window.Frequency.Equal(rule.Frequency) {
t.Errorf("Expected Evaluation Frequency to be overwritten to %v, got %v", rule.Frequency, window.Frequency)
}
} else {
t.Errorf("Expected RollingWindow spec, got %T", rule.Evaluation.Spec)
}
},
},
{
name: "schema v2alpha1 - uses explicit thresholds and evaluation",
initRule: PostableRule{},
@@ -453,8 +182,8 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.SchemaVersion != SchemaVersionV2Alpha1 {
t.Errorf("Expected schemaVersion %q, got %q", SchemaVersionV2Alpha1, rule.SchemaVersion)
if rule.SchemaVersion.StringValue() != SchemaVersionV2Alpha1 {
t.Errorf("Expected schemaVersion %q, got %q", SchemaVersionV2Alpha1, rule.SchemaVersion.StringValue())
}
if rule.RuleCondition.Thresholds == nil {
@@ -645,19 +374,19 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
}
}]
},
"target": 100.0,
"matchType": "1",
"op": "1"
}
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 100.0, "matchType": "1", "op": "1"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`),
kind: RuleDataKindJson,
expectValidateError: true,
},
{
name: "default schema version - defaults to v1 behavior",
name: "missing schema version - rejected on write",
initRule: PostableRule{},
content: []byte(`{
"alert": "DefaultSchemaTest",
"alert": "MissingSchemaTest",
"version": "v5",
"condition": {
"compositeQuery": {
@@ -672,24 +401,13 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
}
}]
},
"target": 75.0,
"matchType": "1",
"op": "1"
}
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 75.0, "matchType": "1", "op": "1"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.SchemaVersion != DefaultSchemaVersion {
t.Errorf("Expected default schemaVersion '%s', got '%s'", DefaultSchemaVersion, rule.SchemaVersion)
}
if rule.RuleCondition.Thresholds == nil {
t.Error("Expected Thresholds to be populated for default schema version")
}
if rule.Evaluation == nil {
t.Error("Expected Evaluation to be populated for default schema version")
}
},
kind: RuleDataKindJson,
expectValidateError: true,
},
}
@@ -737,11 +455,7 @@ func TestParseIntoRuleThresholdGeneration(t *testing.T) {
}
}]
},
"target": 100.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A",
"targetUnit": "ms",
"selectedQueryName": "A",
"thresholds": {
"kind": "basic",
"spec": [
@@ -907,428 +621,6 @@ func TestParseIntoRuleMultipleThresholds(t *testing.T) {
assert.Equal(t, 1, len(vector))
}
func TestAnomalyNegationEval(t *testing.T) {
tests := []struct {
name string
ruleJSON []byte
series *qbtypes.TimeSeries
shouldAlert bool
expectedValue float64
}{
{
name: "anomaly rule with ValueIsBelow - should alert",
ruleJSON: []byte(`{
"alert": "AnomalyBelowTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "1",
"op": "2",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: -2.1}, // below & at least once, should alert
{Timestamp: 2000, Value: -2.3},
},
},
shouldAlert: true,
expectedValue: -2.1,
},
{
name: "anomaly rule with ValueIsBelow; should not alert",
ruleJSON: []byte(`{
"alert": "AnomalyBelowTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "1",
"op": "2",
"selectedQuery": "A"
}
}`), // below & at least once, no value below -2.0
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: -1.9},
{Timestamp: 2000, Value: -1.8},
},
},
shouldAlert: false,
},
{
name: "anomaly rule with ValueIsAbove; should alert",
ruleJSON: []byte(`{
"alert": "AnomalyAboveTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A"
}
}`), // above & at least once, should alert
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: 2.1}, // above 2.0, should alert
{Timestamp: 2000, Value: 2.2},
},
},
shouldAlert: true,
expectedValue: 2.1,
},
{
name: "anomaly rule with ValueIsAbove; should not alert",
ruleJSON: []byte(`{
"alert": "AnomalyAboveTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: 1.1},
{Timestamp: 2000, Value: 1.2},
},
},
shouldAlert: false,
},
{
name: "anomaly rule with ValueIsBelow and AllTheTimes; should alert",
ruleJSON: []byte(`{
"alert": "AnomalyBelowAllTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "2",
"op": "2",
"selectedQuery": "A"
}
}`), // below and all the times
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: -2.1}, // all below -2
{Timestamp: 2000, Value: -2.2},
{Timestamp: 3000, Value: -2.5},
},
},
shouldAlert: true,
expectedValue: -2.1, // max value when all are below threshold
},
{
name: "anomaly rule with ValueIsBelow and AllTheTimes; should not alert",
ruleJSON: []byte(`{
"alert": "AnomalyBelowAllTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "2",
"op": "2",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: -3.0},
{Timestamp: 2000, Value: -1.0}, // above -2, breaks condition
{Timestamp: 3000, Value: -2.5},
},
},
shouldAlert: false,
},
{
name: "anomaly rule with ValueOutsideBounds; should alert",
ruleJSON: []byte(`{
"alert": "AnomalyOutOfBoundsTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 7.0,
"matchType": "1",
"op": "7",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: -8.0}, // abs(−8) >= 7, alert
{Timestamp: 2000, Value: 5.0},
},
},
shouldAlert: true,
expectedValue: -8.0,
},
{
name: "non-anomaly threshold rule with ValueIsBelow; should alert",
ruleJSON: []byte(`{
"alert": "ThresholdTest",
"ruleType": "threshold_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 90.0,
"matchType": "1",
"op": "2",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: 80.0}, // below 90, should alert
{Timestamp: 2000, Value: 85.0},
},
},
shouldAlert: true,
expectedValue: 80.0,
},
{
name: "non-anomaly rule with ValueIsBelow - should not alert",
ruleJSON: []byte(`{
"alert": "ThresholdTest",
"ruleType": "threshold_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 50.0,
"matchType": "1",
"op": "2",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: 60.0}, // below, should alert
{Timestamp: 2000, Value: 90.0},
},
},
shouldAlert: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rule := PostableRule{}
err := json.Unmarshal(tt.ruleJSON, &rule)
if err != nil {
t.Fatalf("Failed to unmarshal rule: %v", err)
}
ruleThreshold, err := rule.RuleCondition.Thresholds.GetRuleThreshold()
if err != nil {
t.Fatalf("unexpected error from GetRuleThreshold: %v", err)
}
resultVector, err := ruleThreshold.Eval(tt.series, "", EvalData{})
if err != nil {
t.Fatalf("unexpected error from Eval: %v", err)
}
shouldAlert := len(resultVector) > 0
if shouldAlert != tt.shouldAlert {
t.Errorf("Expected shouldAlert=%v, got %v. %s",
tt.shouldAlert, shouldAlert, tt.name)
}
if tt.shouldAlert && len(resultVector) > 0 {
sample := resultVector[0]
if sample.V != tt.expectedValue {
t.Errorf("Expected alert value=%.2f, got %.2f. %s",
tt.expectedValue, sample.V, tt.name)
}
}
})
}
}
func TestVersionDefaultsToV5(t *testing.T) {
content := `{
"alert": "cpu high",

View File

@@ -10,12 +10,12 @@ import (
)
type EvaluationKind struct {
valuer.String
valuer.UnsetOrNonEmptyString
}
var (
RollingEvaluation = EvaluationKind{valuer.NewString("rolling")}
CumulativeEvaluation = EvaluationKind{valuer.NewString("cumulative")}
RollingEvaluation = EvaluationKind{valuer.MustNewUnsetOrNonEmptyString("rolling")}
CumulativeEvaluation = EvaluationKind{valuer.MustNewUnsetOrNonEmptyString("cumulative")}
)
// Enum implements jsonschema.Enum; returns the acceptable values for EvaluationKind.
@@ -55,9 +55,9 @@ func (rollingWindow RollingWindow) GetFrequency() valuer.TextDuration {
}
type CumulativeWindow struct {
Schedule CumulativeSchedule `json:"schedule" required:"true"`
Frequency valuer.TextDuration `json:"frequency" required:"true"`
Timezone string `json:"timezone" required:"true"`
Schedule CumulativeSchedule `json:"schedule" required:"true"`
Frequency valuer.TextDuration `json:"frequency" required:"true"`
Timezone valuer.UnsetOrNonEmptyString `json:"timezone" required:"true"`
}
type CumulativeSchedule struct {
@@ -69,14 +69,14 @@ type CumulativeSchedule struct {
}
type ScheduleType struct {
valuer.String
valuer.UnsetOrNonEmptyString
}
var (
ScheduleTypeHourly = ScheduleType{valuer.NewString("hourly")}
ScheduleTypeDaily = ScheduleType{valuer.NewString("daily")}
ScheduleTypeWeekly = ScheduleType{valuer.NewString("weekly")}
ScheduleTypeMonthly = ScheduleType{valuer.NewString("monthly")}
ScheduleTypeHourly = ScheduleType{valuer.MustNewUnsetOrNonEmptyString("hourly")}
ScheduleTypeDaily = ScheduleType{valuer.MustNewUnsetOrNonEmptyString("daily")}
ScheduleTypeWeekly = ScheduleType{valuer.MustNewUnsetOrNonEmptyString("weekly")}
ScheduleTypeMonthly = ScheduleType{valuer.MustNewUnsetOrNonEmptyString("monthly")}
)
// Enum implements jsonschema.Enum; returns the acceptable values for ScheduleType.
@@ -95,7 +95,10 @@ func (cumulativeWindow CumulativeWindow) Validate() error {
return err
}
if _, err := time.LoadLocation(cumulativeWindow.Timezone); err != nil {
if cumulativeWindow.Timezone.IsZero() {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "timezone is required")
}
if _, err := time.LoadLocation(cumulativeWindow.Timezone.StringValue()); err != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "timezone is invalid")
}
if !cumulativeWindow.Frequency.IsPositive() {
@@ -157,8 +160,8 @@ func (cs CumulativeSchedule) Validate() error {
func (cumulativeWindow CumulativeWindow) NextWindowFor(curr time.Time) (time.Time, time.Time) {
loc := time.UTC
if cumulativeWindow.Timezone != "" {
if tz, err := time.LoadLocation(cumulativeWindow.Timezone); err == nil {
if !cumulativeWindow.Timezone.IsZero() {
if tz, err := time.LoadLocation(cumulativeWindow.Timezone.StringValue()); err == nil {
loc = tz
}
}

View File

@@ -72,7 +72,7 @@ func TestCumulativeWindow_NewScheduleSystem(t *testing.T) {
Minute: intPtr(15),
},
Frequency: valuer.MustParseTextDuration("5m"),
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2025, 3, 15, 14, 30, 0, 0, time.UTC),
wantErr: false,
@@ -86,7 +86,7 @@ func TestCumulativeWindow_NewScheduleSystem(t *testing.T) {
Minute: intPtr(30),
},
Frequency: valuer.MustParseTextDuration("1h"),
Timezone: "Asia/Kolkata",
Timezone: valuer.MustNewUnsetOrNonEmptyString("Asia/Kolkata"),
},
current: time.Date(2025, 3, 15, 15, 30, 0, 0, time.UTC),
wantErr: false,
@@ -101,7 +101,7 @@ func TestCumulativeWindow_NewScheduleSystem(t *testing.T) {
Minute: intPtr(0),
},
Frequency: valuer.MustParseTextDuration("24h"),
Timezone: "America/New_York",
Timezone: valuer.MustNewUnsetOrNonEmptyString("America/New_York"),
},
current: time.Date(2025, 3, 18, 19, 0, 0, 0, time.UTC), // Tuesday
wantErr: false,
@@ -116,7 +116,7 @@ func TestCumulativeWindow_NewScheduleSystem(t *testing.T) {
Minute: intPtr(0),
},
Frequency: valuer.MustParseTextDuration("24h"),
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2025, 3, 15, 12, 0, 0, 0, time.UTC),
wantErr: false,
@@ -128,7 +128,7 @@ func TestCumulativeWindow_NewScheduleSystem(t *testing.T) {
Type: ScheduleTypeHourly,
},
Frequency: valuer.MustParseTextDuration("5m"),
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2025, 3, 15, 14, 30, 0, 0, time.UTC),
wantErr: true,
@@ -181,7 +181,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Type: ScheduleTypeHourly,
Minute: intPtr(30),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2025, 3, 15, 14, 30, 0, 0, time.UTC),
wantStart: time.Date(2025, 3, 15, 14, 30, 0, 0, time.UTC),
@@ -194,7 +194,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Type: ScheduleTypeHourly,
Minute: intPtr(15),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2025, 3, 15, 14, 45, 0, 0, time.UTC),
wantStart: time.Date(2025, 3, 15, 14, 15, 0, 0, time.UTC),
@@ -207,7 +207,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Type: ScheduleTypeHourly,
Minute: intPtr(30),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2025, 3, 15, 14, 15, 0, 0, time.UTC),
wantStart: time.Date(2025, 3, 15, 13, 30, 0, 0, time.UTC), // Previous hour
@@ -220,7 +220,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Type: ScheduleTypeHourly,
Minute: intPtr(30),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2025, 3, 15, 13, 14, 0, 0, time.UTC),
wantStart: time.Date(2025, 3, 15, 12, 30, 0, 0, time.UTC), // Previous hour
@@ -233,7 +233,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Type: ScheduleTypeHourly,
Minute: intPtr(30),
},
Timezone: "Asia/Kolkata",
Timezone: valuer.MustNewUnsetOrNonEmptyString("Asia/Kolkata"),
},
current: time.Date(2025, 3, 15, 13, 14, 0, 0, time.UTC),
wantStart: time.Date(2025, 3, 15, 13, 00, 0, 0, time.UTC), // Previous hour
@@ -249,7 +249,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(9),
Minute: intPtr(30),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2025, 3, 15, 9, 30, 0, 0, time.UTC),
wantStart: time.Date(2025, 3, 15, 9, 30, 0, 0, time.UTC),
@@ -263,7 +263,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(9),
Minute: intPtr(30),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2025, 3, 15, 15, 45, 0, 0, time.UTC),
wantStart: time.Date(2025, 3, 15, 9, 30, 0, 0, time.UTC),
@@ -277,7 +277,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(9),
Minute: intPtr(30),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2025, 3, 15, 8, 15, 0, 0, time.UTC),
wantStart: time.Date(2025, 3, 14, 9, 30, 0, 0, time.UTC), // Previous day
@@ -291,7 +291,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(9),
Minute: intPtr(30),
},
Timezone: "Asia/Kolkata",
Timezone: valuer.MustNewUnsetOrNonEmptyString("Asia/Kolkata"),
},
current: time.Date(2025, 3, 15, 15, 30, 0, 0, time.UTC), // 9:00 PM IST
wantStart: time.Date(2025, 3, 15, 4, 0, 0, 0, time.UTC), // 9:30 AM IST in UTC
@@ -308,7 +308,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(14),
Minute: intPtr(0),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2025, 3, 17, 14, 0, 0, 0, time.UTC), // Monday
wantStart: time.Date(2025, 3, 17, 14, 0, 0, 0, time.UTC),
@@ -323,7 +323,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(14),
Minute: intPtr(0),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2025, 3, 19, 10, 30, 0, 0, time.UTC), // Wednesday
wantStart: time.Date(2025, 3, 17, 14, 0, 0, 0, time.UTC), // Previous Monday
@@ -338,7 +338,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(14),
Minute: intPtr(0),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2025, 3, 18, 10, 0, 0, 0, time.UTC), // Tuesday before 2 PM
wantStart: time.Date(2025, 3, 11, 14, 0, 0, 0, time.UTC), // Previous Tuesday
@@ -355,7 +355,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(12),
Minute: intPtr(0),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2025, 3, 15, 12, 0, 0, 0, time.UTC),
wantStart: time.Date(2025, 3, 15, 12, 0, 0, 0, time.UTC),
@@ -370,7 +370,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(0),
Minute: intPtr(0),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2025, 3, 15, 16, 30, 0, 0, time.UTC),
wantStart: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC),
@@ -385,7 +385,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(12),
Minute: intPtr(0),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2025, 3, 10, 10, 0, 0, 0, time.UTC),
wantStart: time.Date(2025, 2, 15, 12, 0, 0, 0, time.UTC), // Previous month
@@ -400,7 +400,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(12),
Minute: intPtr(0),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2025, 3, 15, 10, 0, 0, 0, time.UTC),
wantStart: time.Date(2025, 2, 28, 12, 0, 0, 0, time.UTC), // Feb 28 (last day of Feb)
@@ -415,7 +415,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Type: ScheduleTypeHourly,
Minute: intPtr(45),
},
Timezone: "Asia/Tokyo",
Timezone: valuer.MustNewUnsetOrNonEmptyString("Asia/Tokyo"),
},
current: time.Date(2023, 12, 15, 2, 30, 0, 0, time.UTC), // 11:30 AM JST
wantStart: time.Date(2023, 12, 15, 1, 45, 0, 0, time.UTC), // 10:45 AM JST in UTC
@@ -429,7 +429,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(8), // 8 AM EST
Minute: intPtr(0),
},
Timezone: "America/New_York",
Timezone: valuer.MustNewUnsetOrNonEmptyString("America/New_York"),
},
current: time.Date(2023, 12, 15, 20, 30, 0, 0, time.UTC), // 3:30 PM EST
wantStart: time.Date(2023, 12, 15, 13, 0, 0, 0, time.UTC), // 8 AM EST in UTC
@@ -444,7 +444,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(12),
Minute: intPtr(0),
},
Timezone: "Europe/London",
Timezone: valuer.MustNewUnsetOrNonEmptyString("Europe/London"),
},
current: time.Date(2023, 12, 15, 15, 0, 0, 0, time.UTC), // Friday 3 PM GMT
wantStart: time.Date(2023, 12, 11, 12, 0, 0, 0, time.UTC), // Previous Monday 12 PM GMT
@@ -459,7 +459,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(0), // Midnight AEDT
Minute: intPtr(0),
},
Timezone: "Australia/Sydney",
Timezone: valuer.MustNewUnsetOrNonEmptyString("Australia/Sydney"),
},
current: time.Date(2023, 12, 15, 5, 0, 0, 0, time.UTC), // 4 PM AEDT on 15th
wantStart: time.Date(2023, 11, 30, 13, 0, 0, 0, time.UTC), // Midnight AEDT on Dec 1st in UTC (Nov 30 13:00 UTC)
@@ -472,7 +472,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Type: ScheduleTypeHourly,
Minute: intPtr(30),
},
Timezone: "Pacific/Honolulu",
Timezone: valuer.MustNewUnsetOrNonEmptyString("Pacific/Honolulu"),
},
current: time.Date(2023, 12, 15, 22, 45, 0, 0, time.UTC), // 12:45 PM HST
wantStart: time.Date(2023, 12, 15, 22, 30, 0, 0, time.UTC), // 12:30 PM HST in UTC
@@ -486,7 +486,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(2), // 2 AM PST/PDT
Minute: intPtr(0),
},
Timezone: "America/Los_Angeles",
Timezone: valuer.MustNewUnsetOrNonEmptyString("America/Los_Angeles"),
},
current: time.Date(2023, 3, 12, 15, 0, 0, 0, time.UTC), // Day after DST starts
wantStart: time.Date(2023, 3, 12, 9, 0, 0, 0, time.UTC), // 2 AM PDT in UTC (PDT = UTC-7)
@@ -501,7 +501,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(16), // 4 PM CET
Minute: intPtr(30),
},
Timezone: "Europe/Berlin",
Timezone: valuer.MustNewUnsetOrNonEmptyString("Europe/Berlin"),
},
current: time.Date(2023, 12, 18, 10, 0, 0, 0, time.UTC), // Monday 11 AM CET
wantStart: time.Date(2023, 12, 15, 15, 30, 0, 0, time.UTC), // Previous Friday 4:30 PM CET
@@ -516,7 +516,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(23),
Minute: intPtr(59),
},
Timezone: "Asia/Kolkata",
Timezone: valuer.MustNewUnsetOrNonEmptyString("Asia/Kolkata"),
},
current: time.Date(2023, 3, 10, 12, 0, 0, 0, time.UTC), // March 10th 5:30 PM IST
wantStart: time.Date(2023, 2, 28, 18, 29, 0, 0, time.UTC), // Feb 28 11:59 PM IST (last day of Feb)
@@ -529,7 +529,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Type: ScheduleTypeHourly,
Minute: intPtr(0), // Top of hour
},
Timezone: "America/Chicago",
Timezone: valuer.MustNewUnsetOrNonEmptyString("America/Chicago"),
},
current: time.Date(2023, 12, 15, 6, 30, 0, 0, time.UTC), // 12:30 AM CST
wantStart: time.Date(2023, 12, 15, 6, 0, 0, 0, time.UTC), // Midnight CST in UTC
@@ -545,7 +545,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(0),
Minute: intPtr(0),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), // Jan 1st noon
wantStart: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), // Jan 1st midnight
@@ -560,7 +560,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(15),
Minute: intPtr(30),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2024, 3, 10, 10, 0, 0, 0, time.UTC), // March 10th (leap year)
wantStart: time.Date(2024, 2, 29, 15, 30, 0, 0, time.UTC), // Feb 29th exists in leap year
@@ -575,7 +575,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(15),
Minute: intPtr(30),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2023, 3, 10, 10, 0, 0, 0, time.UTC), // March 10th (non-leap year)
wantStart: time.Date(2023, 2, 28, 15, 30, 0, 0, time.UTC), // Feb 28th (fallback)
@@ -590,7 +590,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(12),
Minute: intPtr(0),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2023, 5, 15, 10, 0, 0, 0, time.UTC), // May 15th
wantStart: time.Date(2023, 4, 30, 12, 0, 0, 0, time.UTC), // April 30th (fallback from 31st)
@@ -605,7 +605,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(23),
Minute: intPtr(59),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2023, 12, 11, 1, 0, 0, 0, time.UTC), // Monday 1 AM
wantStart: time.Date(2023, 12, 10, 23, 59, 0, 0, time.UTC), // Previous Sunday 11:59 PM
@@ -618,7 +618,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Type: ScheduleTypeHourly,
Minute: intPtr(59),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2023, 12, 15, 14, 5, 0, 0, time.UTC), // 14:05
wantStart: time.Date(2023, 12, 15, 13, 59, 0, 0, time.UTC), // 13:59 (previous hour)
@@ -632,7 +632,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(2), // 2 AM (skipped during DST)
Minute: intPtr(30),
},
Timezone: "America/New_York",
Timezone: valuer.MustNewUnsetOrNonEmptyString("America/New_York"),
},
current: time.Date(2023, 3, 12, 15, 0, 0, 0, time.UTC), // Day DST starts
wantStart: time.Date(2023, 3, 12, 6, 30, 0, 0, time.UTC), // Same day 2:30 AM EDT (adjusted for DST)
@@ -646,7 +646,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(2), // 2 AM (occurs twice)
Minute: intPtr(30),
},
Timezone: "America/New_York",
Timezone: valuer.MustNewUnsetOrNonEmptyString("America/New_York"),
},
current: time.Date(2023, 11, 5, 15, 0, 0, 0, time.UTC), // Day DST ends
wantStart: time.Date(2023, 11, 5, 7, 30, 0, 0, time.UTC), // Same day 2:30 AM EST (after fall back)
@@ -661,7 +661,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(0),
Minute: intPtr(0),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2023, 2, 15, 12, 0, 0, 0, time.UTC), // February 15th
wantStart: time.Date(2023, 1, 31, 0, 0, 0, 0, time.UTC), // January 31st (exists)
@@ -675,7 +675,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(12),
Minute: intPtr(0),
},
Timezone: "Pacific/Kiritimati", // UTC+14
Timezone: valuer.MustNewUnsetOrNonEmptyString("Pacific/Kiritimati"), // UTC+14
},
current: time.Date(2023, 12, 15, 5, 0, 0, 0, time.UTC), // 7 PM local time
wantStart: time.Date(2023, 12, 14, 22, 0, 0, 0, time.UTC), // 12 PM local time (previous day in UTC)
@@ -689,7 +689,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(12),
Minute: intPtr(0),
},
Timezone: "Etc/GMT+12", // UTC-12 (use standard timezone name)
Timezone: valuer.MustNewUnsetOrNonEmptyString("Etc/GMT+12"), // UTC-12 (use standard timezone name)
},
current: time.Date(2023, 12, 15, 5, 0, 0, 0, time.UTC), // 5 PM previous day local time
wantStart: time.Date(2023, 12, 15, 0, 0, 0, 0, time.UTC), // 12 PM local time (same day in UTC)
@@ -704,7 +704,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
Hour: intPtr(0),
Minute: intPtr(0),
},
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
current: time.Date(2023, 12, 17, 12, 0, 0, 0, time.UTC), // Sunday noon
wantStart: time.Date(2023, 12, 16, 0, 0, 0, 0, time.UTC), // Saturday midnight
@@ -771,7 +771,7 @@ func TestEvaluationEnvelope_UnmarshalJSON(t *testing.T) {
Minute: intPtr(30),
},
Frequency: valuer.MustParseTextDuration("2m"),
Timezone: "UTC",
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
},
},
{

View File

@@ -147,7 +147,7 @@ func TestV2MinimalReadShape(t *testing.T) {
body := readBody(t, stored)
for _, field := range []string{"labels", "annotations", "description", "preferredChannels", "evalWindow", "frequency"} {
for _, field := range []string{"labels", "annotations", "description"} {
assert.NotContains(t, body, field)
}

View File

@@ -6,13 +6,13 @@ import (
)
type RuleType struct {
valuer.String
valuer.UnsetOrNonEmptyString
}
var (
RuleTypeThreshold = RuleType{valuer.NewString("threshold_rule")}
RuleTypeProm = RuleType{valuer.NewString("promql_rule")}
RuleTypeAnomaly = RuleType{valuer.NewString("anomaly_rule")}
RuleTypeThreshold = RuleType{valuer.MustNewUnsetOrNonEmptyString("threshold_rule")}
RuleTypeProm = RuleType{valuer.MustNewUnsetOrNonEmptyString("promql_rule")}
RuleTypeAnomaly = RuleType{valuer.MustNewUnsetOrNonEmptyString("anomaly_rule")}
)
func (RuleType) Enum() []any {

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