Compare commits

..

11 Commits

Author SHA1 Message Date
swapnil-signoz
ea49afe659 refactor: updating create account types 2026-04-08 17:32:27 +05:30
swapnil-signoz
c0396aae18 Merge branch 'main' into refactor/cloudintegration-openapi-spec 2026-04-08 16:09:17 +05:30
primus-bot[bot]
64be13db85 chore(release): bump to v0.118.0 (#10876)
Some checks are pending
build-staging / staging (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
Co-authored-by: Priyanshu Shrivastava <priyanshu@signoz.io>
2026-04-08 10:13:42 +00:00
swapnil-signoz
97b1c7d3c0 feat: generating OpenAPI spec 2026-04-08 15:03:24 +05:30
swapnil-signoz
bdcd28749e refactor: review changes 2026-04-08 14:50:45 +05:30
Naman Verma
349b98c073 fix: check on type instead of temporality for non existent metrics (#10871)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: show warning for non-existent cost meter metrics

* chore: lint fix by removing unused list

* chore: py fmt add new line

* fix: missing metric check on type instead of temporality

* test: fix unit tests by mocking type data

* test: unit tests

* revert: revert changes from meter branch

* revert: revert changes from meter branch

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-04-08 02:28:39 +00:00
Jatinderjit Singh
19f079dc82 chore(rules): add more context to logs (#10869)
* Log receiverName

* Log stacktrace for panics

* Remove unnecessary nesting of notifyFunc

* Ignore SQLite shm and wal files

* Use logger.With for rule.id

* Use standardized log keys

* Remove unused parameters from RecordRuleStateHistory

* Remove unused parameter from NotifyFunc

* Fix panic message

* Ignore sloglint for snake_case keys
2026-04-08 00:32:17 +00:00
Tushar Vats
926bf1d6e2 chore(qb): added log list queries integration tests (#10841)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore(qb): added log list queries integration tests

* fix: typo

* fix: typo
2026-04-07 17:50:30 +00:00
Vikrant Gupta
e19b9e689d fix(authz): update errors for authz write requests (#10868)
* chore(authz): add error logger for write requests

* chore(authz): send too many requests for authz write error

* chore(authz): send too many requests for authz write error
2026-04-07 17:15:45 +00:00
SagarRajput-7
70b08112f8 fix: fix feedbacks from testing for members and service account feature (#10855)
* fix: fix feedbacks from testing for members and service account feature

* fix: added allow clear and respective delete call and misc fixes

* fix: update member and service account sync

* fix: updated and added test cases

* fix: allow banner to only show to admins

* fix: used react-hook-form in displayName and used enum for promise status in SA

* fix: added retry on 429 for role add and remove calls
2026-04-07 16:58:58 +00:00
swapnil-signoz
79c152bc65 feat: adding types changes and openapi spec 2026-04-07 18:11:59 +05:30
93 changed files with 3556 additions and 2912 deletions

View File

@@ -52,7 +52,6 @@ jobs:
- ingestionkeys
- rootuser
- serviceaccount
- querier_json_body
sqlstore-provider:
- postgres
- sqlite

2
.gitignore vendored
View File

@@ -51,6 +51,8 @@ ee/query-service/tests/test-deploy/data/
# local data
*.backup
*.db
*.db-shm
*.db-wal
**/db
/deploy/docker/clickhouse-setup/data/
/deploy/docker-swarm/clickhouse-setup/data/

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.117.1
image: signoz/signoz:v0.118.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.117.1
image: signoz/signoz:v0.118.0
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.117.1}
image: signoz/signoz:${VERSION:-v0.118.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.117.1}
image: signoz/signoz:${VERSION:-v0.118.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -403,27 +403,65 @@ components:
required:
- regions
type: object
CloudintegrationtypesAWSCollectionStrategy:
CloudintegrationtypesAWSCloudWatchLogsSubscription:
properties:
aws_logs:
$ref: '#/components/schemas/CloudintegrationtypesAWSLogsStrategy'
aws_metrics:
$ref: '#/components/schemas/CloudintegrationtypesAWSMetricsStrategy'
s3_buckets:
additionalProperties:
items:
type: string
type: array
type: object
filterPattern:
type: string
logGroupNamePrefix:
type: string
required:
- logGroupNamePrefix
- filterPattern
type: object
CloudintegrationtypesAWSCloudWatchMetricStreamFilter:
properties:
metricNames:
items:
type: string
type: array
namespace:
type: string
required:
- namespace
type: object
CloudintegrationtypesAWSConnectionArtifact:
properties:
connectionURL:
connectionUrl:
type: string
required:
- connectionURL
- connectionUrl
type: object
CloudintegrationtypesAWSConnectionArtifactRequest:
CloudintegrationtypesAWSIntegrationConfig:
properties:
enabledRegions:
items:
type: string
type: array
telemetryCollectionStrategy:
$ref: '#/components/schemas/CloudintegrationtypesAWSTelemetryCollectionStrategy'
required:
- enabledRegions
- telemetryCollectionStrategy
type: object
CloudintegrationtypesAWSLogsCollectionStrategy:
properties:
subscriptions:
items:
$ref: '#/components/schemas/CloudintegrationtypesAWSCloudWatchLogsSubscription'
type: array
required:
- subscriptions
type: object
CloudintegrationtypesAWSMetricsCollectionStrategy:
properties:
streamFilters:
items:
$ref: '#/components/schemas/CloudintegrationtypesAWSCloudWatchMetricStreamFilter'
type: array
required:
- streamFilters
type: object
CloudintegrationtypesAWSPostableAccountConfig:
properties:
deploymentRegion:
type: string
@@ -435,46 +473,6 @@ components:
- deploymentRegion
- regions
type: object
CloudintegrationtypesAWSIntegrationConfig:
properties:
enabledRegions:
items:
type: string
type: array
telemetry:
$ref: '#/components/schemas/CloudintegrationtypesAWSCollectionStrategy'
required:
- enabledRegions
- telemetry
type: object
CloudintegrationtypesAWSLogsStrategy:
properties:
cloudwatch_logs_subscriptions:
items:
properties:
filter_pattern:
type: string
log_group_name_prefix:
type: string
type: object
nullable: true
type: array
type: object
CloudintegrationtypesAWSMetricsStrategy:
properties:
cloudwatch_metric_stream_filters:
items:
properties:
MetricNames:
items:
type: string
type: array
Namespace:
type: string
type: object
nullable: true
type: array
type: object
CloudintegrationtypesAWSServiceConfig:
properties:
logs:
@@ -486,7 +484,7 @@ components:
properties:
enabled:
type: boolean
s3_buckets:
s3Buckets:
additionalProperties:
items:
type: string
@@ -498,6 +496,19 @@ components:
enabled:
type: boolean
type: object
CloudintegrationtypesAWSTelemetryCollectionStrategy:
properties:
logs:
$ref: '#/components/schemas/CloudintegrationtypesAWSLogsCollectionStrategy'
metrics:
$ref: '#/components/schemas/CloudintegrationtypesAWSMetricsCollectionStrategy'
s3Buckets:
additionalProperties:
items:
type: string
type: array
type: object
type: object
CloudintegrationtypesAccount:
properties:
agentReport:
@@ -561,6 +572,26 @@ components:
nullable: true
type: array
type: object
CloudintegrationtypesCloudIntegrationService:
nullable: true
properties:
cloudIntegrationId:
type: string
config:
$ref: '#/components/schemas/CloudintegrationtypesServiceConfig'
createdAt:
format: date-time
type: string
id:
type: string
type:
$ref: '#/components/schemas/CloudintegrationtypesServiceID'
updatedAt:
format: date-time
type: string
required:
- id
type: object
CloudintegrationtypesCollectedLogAttribute:
properties:
name:
@@ -581,13 +612,6 @@ components:
unit:
type: string
type: object
CloudintegrationtypesCollectionStrategy:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSCollectionStrategy'
required:
- aws
type: object
CloudintegrationtypesConnectionArtifact:
properties:
aws:
@@ -595,12 +619,21 @@ components:
required:
- aws
type: object
CloudintegrationtypesConnectionArtifactRequest:
CloudintegrationtypesCredentials:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSConnectionArtifactRequest'
ingestionKey:
type: string
ingestionUrl:
type: string
sigNozApiKey:
type: string
sigNozApiUrl:
type: string
required:
- aws
- sigNozApiUrl
- sigNozApiKey
- ingestionUrl
- ingestionKey
type: object
CloudintegrationtypesDashboard:
properties:
@@ -626,7 +659,7 @@ components:
nullable: true
type: array
type: object
CloudintegrationtypesGettableAccountWithArtifact:
CloudintegrationtypesGettableAccountWithConnectionArtifact:
properties:
connectionArtifact:
$ref: '#/components/schemas/CloudintegrationtypesConnectionArtifact'
@@ -645,7 +678,7 @@ components:
required:
- accounts
type: object
CloudintegrationtypesGettableAgentCheckInResponse:
CloudintegrationtypesGettableAgentCheckIn:
properties:
account_id:
type: string
@@ -694,12 +727,72 @@ components:
type: string
type: array
telemetry:
$ref: '#/components/schemas/CloudintegrationtypesAWSCollectionStrategy'
$ref: '#/components/schemas/CloudintegrationtypesOldAWSCollectionStrategy'
required:
- enabled_regions
- telemetry
type: object
CloudintegrationtypesPostableAgentCheckInRequest:
CloudintegrationtypesOldAWSCollectionStrategy:
properties:
aws_logs:
$ref: '#/components/schemas/CloudintegrationtypesOldAWSLogsStrategy'
aws_metrics:
$ref: '#/components/schemas/CloudintegrationtypesOldAWSMetricsStrategy'
provider:
type: string
s3_buckets:
additionalProperties:
items:
type: string
type: array
type: object
type: object
CloudintegrationtypesOldAWSLogsStrategy:
properties:
cloudwatch_logs_subscriptions:
items:
properties:
filter_pattern:
type: string
log_group_name_prefix:
type: string
type: object
nullable: true
type: array
type: object
CloudintegrationtypesOldAWSMetricsStrategy:
properties:
cloudwatch_metric_stream_filters:
items:
properties:
MetricNames:
items:
type: string
type: array
Namespace:
type: string
type: object
nullable: true
type: array
type: object
CloudintegrationtypesPostableAccount:
properties:
config:
$ref: '#/components/schemas/CloudintegrationtypesPostableAccountConfig'
credentials:
$ref: '#/components/schemas/CloudintegrationtypesCredentials'
required:
- config
- credentials
type: object
CloudintegrationtypesPostableAccountConfig:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSPostableAccountConfig'
required:
- aws
type: object
CloudintegrationtypesPostableAgentCheckIn:
properties:
account_id:
type: string
@@ -727,6 +820,8 @@ components:
properties:
assets:
$ref: '#/components/schemas/CloudintegrationtypesAssets'
cloudIntegrationService:
$ref: '#/components/schemas/CloudintegrationtypesCloudIntegrationService'
dataCollected:
$ref: '#/components/schemas/CloudintegrationtypesDataCollected'
icon:
@@ -735,12 +830,10 @@ components:
type: string
overview:
type: string
serviceConfig:
$ref: '#/components/schemas/CloudintegrationtypesServiceConfig'
supported_signals:
supportedSignals:
$ref: '#/components/schemas/CloudintegrationtypesSupportedSignals'
telemetryCollectionStrategy:
$ref: '#/components/schemas/CloudintegrationtypesCollectionStrategy'
$ref: '#/components/schemas/CloudintegrationtypesTelemetryCollectionStrategy'
title:
type: string
required:
@@ -749,9 +842,10 @@ components:
- icon
- overview
- assets
- supported_signals
- supportedSignals
- dataCollected
- telemetryCollectionStrategy
- cloudIntegrationService
type: object
CloudintegrationtypesServiceConfig:
properties:
@@ -760,6 +854,22 @@ components:
required:
- aws
type: object
CloudintegrationtypesServiceID:
enum:
- alb
- api-gateway
- dynamodb
- ec2
- ecs
- eks
- elasticache
- lambda
- msk
- rds
- s3sync
- sns
- sqs
type: string
CloudintegrationtypesServiceMetadata:
properties:
enabled:
@@ -783,6 +893,13 @@ components:
metrics:
type: boolean
type: object
CloudintegrationtypesTelemetryCollectionStrategy:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSTelemetryCollectionStrategy'
required:
- aws
type: object
CloudintegrationtypesUpdatableAccount:
properties:
config:
@@ -3081,7 +3198,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckInRequest'
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckIn'
responses:
"200":
content:
@@ -3089,7 +3206,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckInResponse'
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckIn'
status:
type: string
required:
@@ -3190,7 +3307,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/CloudintegrationtypesConnectionArtifactRequest'
$ref: '#/components/schemas/CloudintegrationtypesPostableAccount'
responses:
"200":
content:
@@ -3198,7 +3315,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/CloudintegrationtypesGettableAccountWithArtifact'
$ref: '#/components/schemas/CloudintegrationtypesGettableAccountWithConnectionArtifact'
status:
type: string
required:
@@ -3394,6 +3511,61 @@ paths:
summary: Update account
tags:
- cloudintegration
/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}:
put:
deprecated: false
description: This endpoint updates a service for the specified cloud provider
operationId: UpdateService
parameters:
- in: path
name: cloud_provider
required: true
schema:
type: string
- in: path
name: id
required: true
schema:
type: string
- in: path
name: service_id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CloudintegrationtypesUpdatableService'
responses:
"204":
description: No Content
"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:
- ADMIN
- tokenizer:
- ADMIN
summary: Update service
tags:
- cloudintegration
/api/v1/cloud_integrations/{cloud_provider}/accounts/check_in:
post:
deprecated: false
@@ -3409,7 +3581,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckInRequest'
$ref: '#/components/schemas/CloudintegrationtypesPostableAgentCheckIn'
responses:
"200":
content:
@@ -3417,7 +3589,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckInResponse'
$ref: '#/components/schemas/CloudintegrationtypesGettableAgentCheckIn'
status:
type: string
required:
@@ -3451,6 +3623,59 @@ paths:
summary: Agent check-in
tags:
- cloudintegration
/api/v1/cloud_integrations/{cloud_provider}/credentials:
get:
deprecated: false
description: This endpoint retrieves the connection credentials required for
integration
operationId: GetConnectionCredentials
parameters:
- in: path
name: cloud_provider
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/CloudintegrationtypesCredentials'
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:
- ADMIN
- tokenizer:
- ADMIN
summary: Get connection credentials
tags:
- cloudintegration
/api/v1/cloud_integrations/{cloud_provider}/services:
get:
deprecated: false
@@ -3561,55 +3786,6 @@ paths:
summary: Get service
tags:
- cloudintegration
put:
deprecated: false
description: This endpoint updates a service for the specified cloud provider
operationId: UpdateService
parameters:
- in: path
name: cloud_provider
required: true
schema:
type: string
- in: path
name: service_id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CloudintegrationtypesUpdatableService'
responses:
"204":
description: No Content
"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:
- ADMIN
- tokenizer:
- ADMIN
summary: Update service
tags:
- cloudintegration
/api/v1/complete/google:
get:
deprecated: false

View File

@@ -49,7 +49,6 @@ func NewAnomalyRule(
logger *slog.Logger,
opts ...baserules.RuleOption,
) (*AnomalyRule, error) {
logger.Info("creating new AnomalyRule", slog.String("rule.id", id))
opts = append(opts, baserules.WithLogger(logger))
@@ -59,44 +58,44 @@ func NewAnomalyRule(
return nil, err
}
t := AnomalyRule{
r := AnomalyRule{
BaseRule: baseRule,
querier: querier,
version: p.Version,
logger: logger.With(slog.String("rule.id", id)),
}
switch p.RuleCondition.Seasonality {
case ruletypes.SeasonalityHourly:
t.seasonality = anomaly.SeasonalityHourly
r.seasonality = anomaly.SeasonalityHourly
case ruletypes.SeasonalityDaily:
t.seasonality = anomaly.SeasonalityDaily
r.seasonality = anomaly.SeasonalityDaily
case ruletypes.SeasonalityWeekly:
t.seasonality = anomaly.SeasonalityWeekly
r.seasonality = anomaly.SeasonalityWeekly
default:
t.seasonality = anomaly.SeasonalityDaily
r.seasonality = anomaly.SeasonalityDaily
}
logger.Info("using seasonality", slog.String("rule.id", id), slog.String("rule.seasonality", t.seasonality.StringValue()))
r.logger.Info("using seasonality", slog.String("rule.seasonality", r.seasonality.StringValue()))
if t.seasonality == anomaly.SeasonalityHourly {
t.provider = anomaly.NewHourlyProvider(
if r.seasonality == anomaly.SeasonalityHourly {
r.provider = anomaly.NewHourlyProvider(
anomaly.WithQuerier[*anomaly.HourlyProvider](querier),
anomaly.WithLogger[*anomaly.HourlyProvider](logger),
anomaly.WithLogger[*anomaly.HourlyProvider](r.logger),
)
} else if t.seasonality == anomaly.SeasonalityDaily {
t.provider = anomaly.NewDailyProvider(
} else if r.seasonality == anomaly.SeasonalityDaily {
r.provider = anomaly.NewDailyProvider(
anomaly.WithQuerier[*anomaly.DailyProvider](querier),
anomaly.WithLogger[*anomaly.DailyProvider](logger),
anomaly.WithLogger[*anomaly.DailyProvider](r.logger),
)
} else if t.seasonality == anomaly.SeasonalityWeekly {
t.provider = anomaly.NewWeeklyProvider(
} else if r.seasonality == anomaly.SeasonalityWeekly {
r.provider = anomaly.NewWeeklyProvider(
anomaly.WithQuerier[*anomaly.WeeklyProvider](querier),
anomaly.WithLogger[*anomaly.WeeklyProvider](logger),
anomaly.WithLogger[*anomaly.WeeklyProvider](r.logger),
)
}
t.querier = querier
t.version = p.Version
t.logger = logger
return &t, nil
return &r, nil
}
func (r *AnomalyRule) Type() ruletypes.RuleType {
@@ -104,8 +103,11 @@ 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.String("rule.id", r.ID()), slog.Int64("ts", ts.UnixMilli()), slog.Int64("eval.window_ms", r.EvalWindow().Milliseconds()), slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()))
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()
@@ -145,7 +147,7 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
}
if queryResult == nil {
r.logger.WarnContext(ctx, "nil qb result", slog.String("rule.id", r.ID()), slog.Int64("ts", ts.UnixMilli()))
r.logger.WarnContext(ctx, "nil qb result", slog.Int64("ts", ts.UnixMilli()))
return ruletypes.Vector{}, nil
}
@@ -156,7 +158,7 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
if missingDataAlert := r.HandleMissingDataAlert(ctx, ts, hasData); missingDataAlert != nil {
return ruletypes.Vector{*missingDataAlert}, nil
} else if !hasData {
r.logger.WarnContext(ctx, "no anomaly result", slog.String("rule.id", r.ID()))
r.logger.WarnContext(ctx, "no anomaly result")
return ruletypes.Vector{}, nil
}
@@ -164,7 +166,7 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
scoresJSON, _ := json.Marshal(queryResult.Aggregations[0].AnomalyScores)
// TODO(srikanthccv): this could be noisy but we do this to answer false alert requests
r.logger.InfoContext(ctx, "anomaly scores", slog.String("rule.id", r.ID()), slog.String("anomaly.scores", string(scoresJSON)))
r.logger.InfoContext(ctx, "anomaly scores", slog.String("anomaly.scores", string(scoresJSON)))
// Filter out new series if newGroupEvalDelay is configured
seriesToProcess := queryResult.Aggregations[0].AnomalyScores
@@ -172,7 +174,7 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
filteredSeries, filterErr := r.BaseRule.FilterNewSeries(ctx, ts, seriesToProcess)
// In case of error we log the error and continue with the original series
if filterErr != nil {
r.logger.ErrorContext(ctx, "error filtering new series", slog.String("rule.id", r.ID()), errors.Attr(filterErr))
r.logger.ErrorContext(ctx, "error filtering new series", errors.Attr(filterErr))
} else {
seriesToProcess = filteredSeries
}
@@ -180,7 +182,11 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
for _, series := range seriesToProcess {
if !r.Condition().ShouldEval(series) {
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", slog.String("rule.id", r.ID()), slog.Int("series.num_points", len(series.Values)), slog.Int("series.required_points", r.Condition().RequiredNumPoints))
r.logger.InfoContext(
ctx, "not enough data points to evaluate series, skipping",
slog.Int("series.num_points", len(series.Values)),
slog.Int("series.required_points", r.Condition().RequiredNumPoints),
)
continue
}
results, err := r.Threshold.Eval(series, r.Unit(), ruletypes.EvalData{
@@ -204,7 +210,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
var res ruletypes.Vector
var err error
r.logger.InfoContext(ctx, "running query", slog.String("rule.id", r.ID()))
r.logger.InfoContext(ctx, "running query")
res, err = r.buildAndRunQuery(ctx, r.OrgID(), ts)
if err != nil {
@@ -230,7 +236,10 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
value := valueFormatter.Format(smpl.V, r.Unit())
threshold := valueFormatter.Format(smpl.Target, smpl.TargetUnit)
r.logger.DebugContext(ctx, "alert template data for rule", slog.String("rule.id", r.ID()), slog.String("formatter.name", valueFormatter.Name()), slog.String("alert.value", value), slog.String("alert.threshold", threshold))
r.logger.DebugContext(
ctx, "alert template data for rule", slog.String("formatter.name", valueFormatter.Name()),
slog.String("alert.value", value), slog.String("alert.threshold", threshold),
)
tmplData := ruletypes.AlertTemplateData(l, value, threshold)
// Inject some convenience variables that are easier to remember for users
@@ -250,7 +259,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
result, err := tmpl.Expand()
if err != nil {
result = fmt.Sprintf("<error expanding template: %s>", err)
r.logger.ErrorContext(ctx, "expanding alert template failed", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.template_data", tmplData))
r.logger.ErrorContext(ctx, "expanding alert template failed", errors.Attr(err), slog.Any("alert.template_data", tmplData))
}
return result
}
@@ -280,7 +289,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
resultFPs[h] = struct{}{}
if _, ok := alerts[h]; ok {
r.logger.ErrorContext(ctx, "the alert query returns duplicate records", slog.String("rule.id", r.ID()), slog.Any("alert", alerts[h]))
r.logger.ErrorContext(ctx, "the alert query returns duplicate records", slog.Any("alert", alerts[h]))
err = errors.NewInternalf(errors.CodeInternal, "duplicate alert found, vector contains metrics with the same labelset after applying alert labels")
return 0, err
}
@@ -299,7 +308,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
}
r.logger.InfoContext(ctx, "number of alerts found", slog.String("rule.id", r.ID()), slog.Int("alert.count", len(alerts)))
r.logger.InfoContext(ctx, "number of alerts found", slog.Int("alert.count", len(alerts)))
// alerts[h] is ready, add or update active list now
for h, a := range alerts {
// Check whether we already have alerting state for the identifying label set.
@@ -326,7 +335,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
for fp, a := range r.Active {
labelsJSON, err := json.Marshal(a.QueryResultLabels)
if err != nil {
r.logger.ErrorContext(ctx, "error marshaling labels", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.labels", a.Labels))
r.logger.ErrorContext(ctx, "error marshaling labels", errors.Attr(err), slog.Any("alert.labels", a.Labels))
}
if _, ok := resultFPs[fp]; !ok {
// If the alert was previously firing, keep it around for a given
@@ -381,7 +390,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
state = ruletypes.StateFiring
}
a.State = state
r.logger.DebugContext(ctx, "converting alert state", slog.String("rule.id", r.ID()), slog.Any("alert.state", state))
r.logger.DebugContext(ctx, "converting alert state", slog.Any("alert.state", state))
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
@@ -404,7 +413,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
itemsToAdd[idx] = item
}
r.RecordRuleStateHistory(ctx, prevState, currentState, itemsToAdd)
_ = r.RecordRuleStateHistory(ctx, itemsToAdd)
return len(r.Active), nil
}

View File

@@ -24,8 +24,8 @@ import type {
AgentCheckInDeprecated200,
AgentCheckInDeprecatedPathParameters,
AgentCheckInPathParameters,
CloudintegrationtypesConnectionArtifactRequestDTO,
CloudintegrationtypesPostableAgentCheckInRequestDTO,
CloudintegrationtypesPostableAccountDTO,
CloudintegrationtypesPostableAgentCheckInDTO,
CloudintegrationtypesUpdatableAccountDTO,
CloudintegrationtypesUpdatableServiceDTO,
CreateAccount200,
@@ -33,6 +33,8 @@ import type {
DisconnectAccountPathParameters,
GetAccount200,
GetAccountPathParameters,
GetConnectionCredentials200,
GetConnectionCredentialsPathParameters,
GetService200,
GetServicePathParameters,
ListAccounts200,
@@ -51,14 +53,14 @@ import type {
*/
export const agentCheckInDeprecated = (
{ cloudProvider }: AgentCheckInDeprecatedPathParameters,
cloudintegrationtypesPostableAgentCheckInRequestDTO: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>,
cloudintegrationtypesPostableAgentCheckInDTO: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<AgentCheckInDeprecated200>({
url: `/api/v1/cloud-integrations/${cloudProvider}/agent-check-in`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: cloudintegrationtypesPostableAgentCheckInRequestDTO,
data: cloudintegrationtypesPostableAgentCheckInDTO,
signal,
});
};
@@ -72,7 +74,7 @@ export const getAgentCheckInDeprecatedMutationOptions = <
TError,
{
pathParams: AgentCheckInDeprecatedPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
},
TContext
>;
@@ -81,7 +83,7 @@ export const getAgentCheckInDeprecatedMutationOptions = <
TError,
{
pathParams: AgentCheckInDeprecatedPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
},
TContext
> => {
@@ -98,7 +100,7 @@ export const getAgentCheckInDeprecatedMutationOptions = <
Awaited<ReturnType<typeof agentCheckInDeprecated>>,
{
pathParams: AgentCheckInDeprecatedPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -112,7 +114,7 @@ export const getAgentCheckInDeprecatedMutationOptions = <
export type AgentCheckInDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof agentCheckInDeprecated>>
>;
export type AgentCheckInDeprecatedMutationBody = BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
export type AgentCheckInDeprecatedMutationBody = BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
export type AgentCheckInDeprecatedMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -128,7 +130,7 @@ export const useAgentCheckInDeprecated = <
TError,
{
pathParams: AgentCheckInDeprecatedPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
},
TContext
>;
@@ -137,7 +139,7 @@ export const useAgentCheckInDeprecated = <
TError,
{
pathParams: AgentCheckInDeprecatedPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
},
TContext
> => {
@@ -255,14 +257,14 @@ export const invalidateListAccounts = async (
*/
export const createAccount = (
{ cloudProvider }: CreateAccountPathParameters,
cloudintegrationtypesConnectionArtifactRequestDTO: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>,
cloudintegrationtypesPostableAccountDTO: BodyType<CloudintegrationtypesPostableAccountDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateAccount200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: cloudintegrationtypesConnectionArtifactRequestDTO,
data: cloudintegrationtypesPostableAccountDTO,
signal,
});
};
@@ -276,7 +278,7 @@ export const getCreateAccountMutationOptions = <
TError,
{
pathParams: CreateAccountPathParameters;
data: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAccountDTO>;
},
TContext
>;
@@ -285,7 +287,7 @@ export const getCreateAccountMutationOptions = <
TError,
{
pathParams: CreateAccountPathParameters;
data: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAccountDTO>;
},
TContext
> => {
@@ -302,7 +304,7 @@ export const getCreateAccountMutationOptions = <
Awaited<ReturnType<typeof createAccount>>,
{
pathParams: CreateAccountPathParameters;
data: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAccountDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -316,7 +318,7 @@ export const getCreateAccountMutationOptions = <
export type CreateAccountMutationResult = NonNullable<
Awaited<ReturnType<typeof createAccount>>
>;
export type CreateAccountMutationBody = BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
export type CreateAccountMutationBody = BodyType<CloudintegrationtypesPostableAccountDTO>;
export type CreateAccountMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -331,7 +333,7 @@ export const useCreateAccount = <
TError,
{
pathParams: CreateAccountPathParameters;
data: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAccountDTO>;
},
TContext
>;
@@ -340,7 +342,7 @@ export const useCreateAccount = <
TError,
{
pathParams: CreateAccountPathParameters;
data: BodyType<CloudintegrationtypesConnectionArtifactRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAccountDTO>;
},
TContext
> => {
@@ -628,20 +630,117 @@ export const useUpdateAccount = <
return useMutation(mutationOptions);
};
/**
* This endpoint updates a service for the specified cloud provider
* @summary Update service
*/
export const updateService = (
{ cloudProvider, id, serviceId }: UpdateServicePathParameters,
cloudintegrationtypesUpdatableServiceDTO: BodyType<CloudintegrationtypesUpdatableServiceDTO>,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services/${serviceId}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: cloudintegrationtypesUpdatableServiceDTO,
});
};
export const getUpdateServiceMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
> => {
const mutationKey = ['updateService'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateService>>,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateService(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateServiceMutationResult = NonNullable<
Awaited<ReturnType<typeof updateService>>
>;
export type UpdateServiceMutationBody = BodyType<CloudintegrationtypesUpdatableServiceDTO>;
export type UpdateServiceMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update service
*/
export const useUpdateService = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
> => {
const mutationOptions = getUpdateServiceMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint is called by the deployed agent to check in
* @summary Agent check-in
*/
export const agentCheckIn = (
{ cloudProvider }: AgentCheckInPathParameters,
cloudintegrationtypesPostableAgentCheckInRequestDTO: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>,
cloudintegrationtypesPostableAgentCheckInDTO: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<AgentCheckIn200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/check_in`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: cloudintegrationtypesPostableAgentCheckInRequestDTO,
data: cloudintegrationtypesPostableAgentCheckInDTO,
signal,
});
};
@@ -655,7 +754,7 @@ export const getAgentCheckInMutationOptions = <
TError,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
},
TContext
>;
@@ -664,7 +763,7 @@ export const getAgentCheckInMutationOptions = <
TError,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
},
TContext
> => {
@@ -681,7 +780,7 @@ export const getAgentCheckInMutationOptions = <
Awaited<ReturnType<typeof agentCheckIn>>,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -695,7 +794,7 @@ export const getAgentCheckInMutationOptions = <
export type AgentCheckInMutationResult = NonNullable<
Awaited<ReturnType<typeof agentCheckIn>>
>;
export type AgentCheckInMutationBody = BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
export type AgentCheckInMutationBody = BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
export type AgentCheckInMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -710,7 +809,7 @@ export const useAgentCheckIn = <
TError,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
},
TContext
>;
@@ -719,7 +818,7 @@ export const useAgentCheckIn = <
TError,
{
pathParams: AgentCheckInPathParameters;
data: BodyType<CloudintegrationtypesPostableAgentCheckInRequestDTO>;
data: BodyType<CloudintegrationtypesPostableAgentCheckInDTO>;
},
TContext
> => {
@@ -727,6 +826,114 @@ export const useAgentCheckIn = <
return useMutation(mutationOptions);
};
/**
* This endpoint retrieves the connection credentials required for integration
* @summary Get connection credentials
*/
export const getConnectionCredentials = (
{ cloudProvider }: GetConnectionCredentialsPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetConnectionCredentials200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/credentials`,
method: 'GET',
signal,
});
};
export const getGetConnectionCredentialsQueryKey = ({
cloudProvider,
}: GetConnectionCredentialsPathParameters) => {
return [`/api/v1/cloud_integrations/${cloudProvider}/credentials`] as const;
};
export const getGetConnectionCredentialsQueryOptions = <
TData = Awaited<ReturnType<typeof getConnectionCredentials>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider }: GetConnectionCredentialsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getConnectionCredentials>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetConnectionCredentialsQueryKey({ cloudProvider });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getConnectionCredentials>>
> = ({ signal }) => getConnectionCredentials({ cloudProvider }, signal);
return {
queryKey,
queryFn,
enabled: !!cloudProvider,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getConnectionCredentials>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetConnectionCredentialsQueryResult = NonNullable<
Awaited<ReturnType<typeof getConnectionCredentials>>
>;
export type GetConnectionCredentialsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get connection credentials
*/
export function useGetConnectionCredentials<
TData = Awaited<ReturnType<typeof getConnectionCredentials>>,
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider }: GetConnectionCredentialsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getConnectionCredentials>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetConnectionCredentialsQueryOptions(
{ cloudProvider },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get connection credentials
*/
export const invalidateGetConnectionCredentials = async (
queryClient: QueryClient,
{ cloudProvider }: GetConnectionCredentialsPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetConnectionCredentialsQueryKey({ cloudProvider }) },
options,
);
return queryClient;
};
/**
* This endpoint lists the services metadata for the specified cloud provider
* @summary List services metadata
@@ -941,101 +1148,3 @@ export const invalidateGetService = async (
return queryClient;
};
/**
* This endpoint updates a service for the specified cloud provider
* @summary Update service
*/
export const updateService = (
{ cloudProvider, serviceId }: UpdateServicePathParameters,
cloudintegrationtypesUpdatableServiceDTO: BodyType<CloudintegrationtypesUpdatableServiceDTO>,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: cloudintegrationtypesUpdatableServiceDTO,
});
};
export const getUpdateServiceMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
> => {
const mutationKey = ['updateService'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateService>>,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateService(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateServiceMutationResult = NonNullable<
Awaited<ReturnType<typeof updateService>>
>;
export type UpdateServiceMutationBody = BodyType<CloudintegrationtypesUpdatableServiceDTO>;
export type UpdateServiceMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update service
*/
export const useUpdateService = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateService>>,
TError,
{
pathParams: UpdateServicePathParameters;
data: BodyType<CloudintegrationtypesUpdatableServiceDTO>;
},
TContext
> => {
const mutationOptions = getUpdateServiceMutationOptions(options);
return useMutation(mutationOptions);
};

View File

@@ -512,27 +512,58 @@ export interface CloudintegrationtypesAWSAccountConfigDTO {
regions: string[];
}
export type CloudintegrationtypesAWSCollectionStrategyDTOS3Buckets = {
[key: string]: string[];
};
export interface CloudintegrationtypesAWSCollectionStrategyDTO {
aws_logs?: CloudintegrationtypesAWSLogsStrategyDTO;
aws_metrics?: CloudintegrationtypesAWSMetricsStrategyDTO;
export interface CloudintegrationtypesAWSCloudWatchLogsSubscriptionDTO {
/**
* @type object
* @type string
*/
s3_buckets?: CloudintegrationtypesAWSCollectionStrategyDTOS3Buckets;
filterPattern: string;
/**
* @type string
*/
logGroupNamePrefix: string;
}
export interface CloudintegrationtypesAWSCloudWatchMetricStreamFilterDTO {
/**
* @type array
*/
metricNames?: string[];
/**
* @type string
*/
namespace: string;
}
export interface CloudintegrationtypesAWSConnectionArtifactDTO {
/**
* @type string
*/
connectionURL: string;
connectionUrl: string;
}
export interface CloudintegrationtypesAWSConnectionArtifactRequestDTO {
export interface CloudintegrationtypesAWSIntegrationConfigDTO {
/**
* @type array
*/
enabledRegions: string[];
telemetryCollectionStrategy: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
}
export interface CloudintegrationtypesAWSLogsCollectionStrategyDTO {
/**
* @type array
*/
subscriptions: CloudintegrationtypesAWSCloudWatchLogsSubscriptionDTO[];
}
export interface CloudintegrationtypesAWSMetricsCollectionStrategyDTO {
/**
* @type array
*/
streamFilters: CloudintegrationtypesAWSCloudWatchMetricStreamFilterDTO[];
}
export interface CloudintegrationtypesAWSPostableAccountConfigDTO {
/**
* @type string
*/
@@ -543,56 +574,6 @@ export interface CloudintegrationtypesAWSConnectionArtifactRequestDTO {
regions: string[];
}
export interface CloudintegrationtypesAWSIntegrationConfigDTO {
/**
* @type array
*/
enabledRegions: string[];
telemetry: CloudintegrationtypesAWSCollectionStrategyDTO;
}
export type CloudintegrationtypesAWSLogsStrategyDTOCloudwatchLogsSubscriptionsItem = {
/**
* @type string
*/
filter_pattern?: string;
/**
* @type string
*/
log_group_name_prefix?: string;
};
export interface CloudintegrationtypesAWSLogsStrategyDTO {
/**
* @type array
* @nullable true
*/
cloudwatch_logs_subscriptions?:
| CloudintegrationtypesAWSLogsStrategyDTOCloudwatchLogsSubscriptionsItem[]
| null;
}
export type CloudintegrationtypesAWSMetricsStrategyDTOCloudwatchMetricStreamFiltersItem = {
/**
* @type array
*/
MetricNames?: string[];
/**
* @type string
*/
Namespace?: string;
};
export interface CloudintegrationtypesAWSMetricsStrategyDTO {
/**
* @type array
* @nullable true
*/
cloudwatch_metric_stream_filters?:
| CloudintegrationtypesAWSMetricsStrategyDTOCloudwatchMetricStreamFiltersItem[]
| null;
}
export interface CloudintegrationtypesAWSServiceConfigDTO {
logs?: CloudintegrationtypesAWSServiceLogsConfigDTO;
metrics?: CloudintegrationtypesAWSServiceMetricsConfigDTO;
@@ -610,7 +591,7 @@ export interface CloudintegrationtypesAWSServiceLogsConfigDTO {
/**
* @type object
*/
s3_buckets?: CloudintegrationtypesAWSServiceLogsConfigDTOS3Buckets;
s3Buckets?: CloudintegrationtypesAWSServiceLogsConfigDTOS3Buckets;
}
export interface CloudintegrationtypesAWSServiceMetricsConfigDTO {
@@ -620,6 +601,19 @@ export interface CloudintegrationtypesAWSServiceMetricsConfigDTO {
enabled?: boolean;
}
export type CloudintegrationtypesAWSTelemetryCollectionStrategyDTOS3Buckets = {
[key: string]: string[];
};
export interface CloudintegrationtypesAWSTelemetryCollectionStrategyDTO {
logs?: CloudintegrationtypesAWSLogsCollectionStrategyDTO;
metrics?: CloudintegrationtypesAWSMetricsCollectionStrategyDTO;
/**
* @type object
*/
s3Buckets?: CloudintegrationtypesAWSTelemetryCollectionStrategyDTOS3Buckets;
}
export interface CloudintegrationtypesAccountDTO {
agentReport: CloudintegrationtypesAgentReportDTO;
config: CloudintegrationtypesAccountConfigDTO;
@@ -693,6 +687,32 @@ export interface CloudintegrationtypesAssetsDTO {
dashboards?: CloudintegrationtypesDashboardDTO[] | null;
}
/**
* @nullable
*/
export type CloudintegrationtypesCloudIntegrationServiceDTO = {
/**
* @type string
*/
cloudIntegrationId?: string;
config?: CloudintegrationtypesServiceConfigDTO;
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
*/
id: string;
type?: CloudintegrationtypesServiceIDDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
} | null;
export interface CloudintegrationtypesCollectedLogAttributeDTO {
/**
* @type string
@@ -727,16 +747,27 @@ export interface CloudintegrationtypesCollectedMetricDTO {
unit?: string;
}
export interface CloudintegrationtypesCollectionStrategyDTO {
aws: CloudintegrationtypesAWSCollectionStrategyDTO;
}
export interface CloudintegrationtypesConnectionArtifactDTO {
aws: CloudintegrationtypesAWSConnectionArtifactDTO;
}
export interface CloudintegrationtypesConnectionArtifactRequestDTO {
aws: CloudintegrationtypesAWSConnectionArtifactRequestDTO;
export interface CloudintegrationtypesCredentialsDTO {
/**
* @type string
*/
ingestionKey: string;
/**
* @type string
*/
ingestionUrl: string;
/**
* @type string
*/
sigNozApiKey: string;
/**
* @type string
*/
sigNozApiUrl: string;
}
export interface CloudintegrationtypesDashboardDTO {
@@ -768,7 +799,7 @@ export interface CloudintegrationtypesDataCollectedDTO {
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
}
export interface CloudintegrationtypesGettableAccountWithArtifactDTO {
export interface CloudintegrationtypesGettableAccountWithConnectionArtifactDTO {
connectionArtifact: CloudintegrationtypesConnectionArtifactDTO;
/**
* @type string
@@ -783,7 +814,7 @@ export interface CloudintegrationtypesGettableAccountsDTO {
accounts: CloudintegrationtypesAccountDTO[];
}
export interface CloudintegrationtypesGettableAgentCheckInResponseDTO {
export interface CloudintegrationtypesGettableAgentCheckInDTO {
/**
* @type string
*/
@@ -831,17 +862,85 @@ export type CloudintegrationtypesIntegrationConfigDTO = {
* @type array
*/
enabled_regions: string[];
telemetry: CloudintegrationtypesAWSCollectionStrategyDTO;
telemetry: CloudintegrationtypesOldAWSCollectionStrategyDTO;
} | null;
export type CloudintegrationtypesOldAWSCollectionStrategyDTOS3Buckets = {
[key: string]: string[];
};
export interface CloudintegrationtypesOldAWSCollectionStrategyDTO {
aws_logs?: CloudintegrationtypesOldAWSLogsStrategyDTO;
aws_metrics?: CloudintegrationtypesOldAWSMetricsStrategyDTO;
/**
* @type string
*/
provider?: string;
/**
* @type object
*/
s3_buckets?: CloudintegrationtypesOldAWSCollectionStrategyDTOS3Buckets;
}
export type CloudintegrationtypesOldAWSLogsStrategyDTOCloudwatchLogsSubscriptionsItem = {
/**
* @type string
*/
filter_pattern?: string;
/**
* @type string
*/
log_group_name_prefix?: string;
};
export interface CloudintegrationtypesOldAWSLogsStrategyDTO {
/**
* @type array
* @nullable true
*/
cloudwatch_logs_subscriptions?:
| CloudintegrationtypesOldAWSLogsStrategyDTOCloudwatchLogsSubscriptionsItem[]
| null;
}
export type CloudintegrationtypesOldAWSMetricsStrategyDTOCloudwatchMetricStreamFiltersItem = {
/**
* @type array
*/
MetricNames?: string[];
/**
* @type string
*/
Namespace?: string;
};
export interface CloudintegrationtypesOldAWSMetricsStrategyDTO {
/**
* @type array
* @nullable true
*/
cloudwatch_metric_stream_filters?:
| CloudintegrationtypesOldAWSMetricsStrategyDTOCloudwatchMetricStreamFiltersItem[]
| null;
}
export interface CloudintegrationtypesPostableAccountDTO {
config: CloudintegrationtypesPostableAccountConfigDTO;
credentials: CloudintegrationtypesCredentialsDTO;
}
export interface CloudintegrationtypesPostableAccountConfigDTO {
aws: CloudintegrationtypesAWSPostableAccountConfigDTO;
}
/**
* @nullable
*/
export type CloudintegrationtypesPostableAgentCheckInRequestDTOData = {
export type CloudintegrationtypesPostableAgentCheckInDTOData = {
[key: string]: unknown;
} | null;
export interface CloudintegrationtypesPostableAgentCheckInRequestDTO {
export interface CloudintegrationtypesPostableAgentCheckInDTO {
/**
* @type string
*/
@@ -858,7 +957,7 @@ export interface CloudintegrationtypesPostableAgentCheckInRequestDTO {
* @type object
* @nullable true
*/
data: CloudintegrationtypesPostableAgentCheckInRequestDTOData;
data: CloudintegrationtypesPostableAgentCheckInDTOData;
/**
* @type string
*/
@@ -871,6 +970,7 @@ export interface CloudintegrationtypesProviderIntegrationConfigDTO {
export interface CloudintegrationtypesServiceDTO {
assets: CloudintegrationtypesAssetsDTO;
cloudIntegrationService: CloudintegrationtypesCloudIntegrationServiceDTO;
dataCollected: CloudintegrationtypesDataCollectedDTO;
/**
* @type string
@@ -884,9 +984,8 @@ export interface CloudintegrationtypesServiceDTO {
* @type string
*/
overview: string;
serviceConfig?: CloudintegrationtypesServiceConfigDTO;
supported_signals: CloudintegrationtypesSupportedSignalsDTO;
telemetryCollectionStrategy: CloudintegrationtypesCollectionStrategyDTO;
supportedSignals: CloudintegrationtypesSupportedSignalsDTO;
telemetryCollectionStrategy: CloudintegrationtypesTelemetryCollectionStrategyDTO;
/**
* @type string
*/
@@ -897,6 +996,21 @@ export interface CloudintegrationtypesServiceConfigDTO {
aws: CloudintegrationtypesAWSServiceConfigDTO;
}
export enum CloudintegrationtypesServiceIDDTO {
alb = 'alb',
'api-gateway' = 'api-gateway',
dynamodb = 'dynamodb',
ec2 = 'ec2',
ecs = 'ecs',
eks = 'eks',
elasticache = 'elasticache',
lambda = 'lambda',
msk = 'msk',
rds = 'rds',
s3sync = 's3sync',
sns = 'sns',
sqs = 'sqs',
}
export interface CloudintegrationtypesServiceMetadataDTO {
/**
* @type boolean
@@ -927,6 +1041,10 @@ export interface CloudintegrationtypesSupportedSignalsDTO {
metrics?: boolean;
}
export interface CloudintegrationtypesTelemetryCollectionStrategyDTO {
aws: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
}
export interface CloudintegrationtypesUpdatableAccountDTO {
config: CloudintegrationtypesAccountConfigDTO;
}
@@ -3450,7 +3568,7 @@ export type AgentCheckInDeprecatedPathParameters = {
cloudProvider: string;
};
export type AgentCheckInDeprecated200 = {
data: CloudintegrationtypesGettableAgentCheckInResponseDTO;
data: CloudintegrationtypesGettableAgentCheckInDTO;
/**
* @type string
*/
@@ -3472,7 +3590,7 @@ export type CreateAccountPathParameters = {
cloudProvider: string;
};
export type CreateAccount200 = {
data: CloudintegrationtypesGettableAccountWithArtifactDTO;
data: CloudintegrationtypesGettableAccountWithConnectionArtifactDTO;
/**
* @type string
*/
@@ -3499,11 +3617,27 @@ export type UpdateAccountPathParameters = {
cloudProvider: string;
id: string;
};
export type UpdateServicePathParameters = {
cloudProvider: string;
id: string;
serviceId: string;
};
export type AgentCheckInPathParameters = {
cloudProvider: string;
};
export type AgentCheckIn200 = {
data: CloudintegrationtypesGettableAgentCheckInResponseDTO;
data: CloudintegrationtypesGettableAgentCheckInDTO;
/**
* @type string
*/
status: string;
};
export type GetConnectionCredentialsPathParameters = {
cloudProvider: string;
};
export type GetConnectionCredentials200 = {
data: CloudintegrationtypesCredentialsDTO;
/**
* @type string
*/
@@ -3533,10 +3667,6 @@ export type GetService200 = {
status: string;
};
export type UpdateServicePathParameters = {
cloudProvider: string;
serviceId: string;
};
export type CreateSessionByGoogleCallback303 = {
data: AuthtypesGettableTokenDTO;
/**

View File

@@ -14,6 +14,8 @@ import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schem
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import './CreateServiceAccountModal.styles.scss';
@@ -28,6 +30,8 @@ function CreateServiceAccountModal(): JSX.Element {
parseAsBoolean.withDefault(false),
);
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const {
control,
handleSubmit,
@@ -54,13 +58,10 @@ function CreateServiceAccountModal(): JSX.Element {
await invalidateListServiceAccounts(queryClient);
},
onError: (err) => {
const errMessage =
convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'An error occurred';
toast.error(`Failed to create service account: ${errMessage}`, {
richColors: true,
});
const errMessage = convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
);
showErrorModal(errMessage as APIError);
},
},
});
@@ -90,7 +91,7 @@ function CreateServiceAccountModal(): JSX.Element {
showCloseButton
width="narrow"
className="create-sa-modal"
disableOutsideClick={false}
disableOutsideClick={isErrorModalVisible}
>
<div className="create-sa-modal__content">
<form

View File

@@ -11,6 +11,16 @@ jest.mock('@signozhq/sonner', () => ({
const mockToast = jest.mocked(toast);
const showErrorModal = jest.fn();
jest.mock('providers/ErrorModalProvider', () => ({
__esModule: true,
...jest.requireActual('providers/ErrorModalProvider'),
useErrorModal: jest.fn(() => ({
showErrorModal,
isErrorModalVisible: false,
})),
}));
const SERVICE_ACCOUNTS_ENDPOINT = '*/api/v1/service_accounts';
function renderModal(): ReturnType<typeof render> {
@@ -92,10 +102,13 @@ describe('CreateServiceAccountModal', () => {
await user.click(submitBtn);
await waitFor(() => {
expect(mockToast.error).toHaveBeenCalledWith(
expect.stringMatching(/Failed to create service account/i),
expect.anything(),
expect(showErrorModal).toHaveBeenCalledWith(
expect.objectContaining({
getErrorMessage: expect.any(Function),
}),
);
const passedError = showErrorModal.mock.calls[0][0] as any;
expect(passedError.getErrorMessage()).toBe('Internal Server Error');
});
expect(

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Badge } from '@signozhq/badge';
import { Button } from '@signozhq/button';
@@ -28,6 +28,7 @@ import {
useMemberRoleManager,
} from 'hooks/member/useMemberRoleManager';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
@@ -90,8 +91,11 @@ function EditMemberDrawer({
const [linkType, setLinkType] = useState<'invite' | 'reset' | null>(null);
const isInvited = member?.status === MemberStatus.Invited;
const isDeleted = member?.status === MemberStatus.Deleted;
const isSelf = !!member?.id && member.id === currentUser?.id;
const { showErrorModal } = useErrorModal();
const {
data: fetchedUser,
isLoading: isFetchingUser,
@@ -111,26 +115,39 @@ function EditMemberDrawer({
refetch: refetchRoles,
} = useRoles();
const { fetchedRoleIds, applyDiff } = useMemberRoleManager(
member?.id ?? '',
open && !!member?.id,
);
const {
fetchedRoleIds,
isLoading: isMemberRolesLoading,
applyDiff,
} = useMemberRoleManager(member?.id ?? '', open && !!member?.id);
const fetchedDisplayName =
fetchedUser?.data?.displayName ?? member?.name ?? '';
const fetchedUserId = fetchedUser?.data?.id;
const fetchedUserDisplayName = fetchedUser?.data?.displayName;
const roleSessionRef = useRef<string | null>(null);
useEffect(() => {
if (fetchedUserId) {
setLocalDisplayName(fetchedUserDisplayName ?? member?.name ?? '');
}
setSaveErrors([]);
}, [fetchedUserId, fetchedUserDisplayName, member?.name]);
useEffect(() => {
setLocalRole(fetchedRoleIds[0] ?? '');
}, [fetchedRoleIds]);
if (fetchedUserId) {
setSaveErrors([]);
}
}, [fetchedUserId]);
useEffect(() => {
if (!member?.id) {
roleSessionRef.current = null;
} else if (member.id !== roleSessionRef.current && !isMemberRolesLoading) {
setLocalRole(fetchedRoleIds[0] ?? '');
roleSessionRef.current = member.id;
}
}, [member?.id, fetchedRoleIds, isMemberRolesLoading]);
const isDirty =
member !== null &&
@@ -153,17 +170,10 @@ function EditMemberDrawer({
onClose();
},
onError: (err): void => {
const errMessage =
convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'An error occurred';
const prefix = isInvited
? 'Failed to revoke invite'
: 'Failed to delete member';
toast.error(`${prefix}: ${errMessage}`, {
richColors: true,
position: 'top-right',
});
const errMessage = convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
);
showErrorModal(errMessage as APIError);
},
},
});
@@ -344,15 +354,15 @@ function EditMemberDrawer({
position: 'top-right',
});
}
} catch {
toast.error('Failed to generate password reset link', {
richColors: true,
position: 'top-right',
});
} catch (err) {
const errMsg = convertToApiError(
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
);
showErrorModal(errMsg as APIError);
} finally {
setIsGeneratingLink(false);
}
}, [member, isInvited, onClose]);
}, [member, isInvited, onClose, showErrorModal]);
const [copyState, copyToClipboard] = useCopyToClipboard();
const handleCopyResetLink = useCallback((): void => {
@@ -419,7 +429,7 @@ function EditMemberDrawer({
}}
className="edit-member-drawer__input"
placeholder="Enter name"
disabled={isRootUser}
disabled={isRootUser || isDeleted}
/>
</Tooltip>
</div>
@@ -440,9 +450,15 @@ function EditMemberDrawer({
<label className="edit-member-drawer__label" htmlFor="member-role">
Roles
</label>
{isSelf || isRootUser ? (
{isSelf || isRootUser || isDeleted ? (
<Tooltip
title={isRootUser ? ROOT_USER_TOOLTIP : 'You cannot modify your own role'}
title={
isRootUser
? ROOT_USER_TOOLTIP
: isDeleted
? undefined
: 'You cannot modify your own role'
}
>
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
<div className="edit-member-drawer__disabled-roles">
@@ -467,7 +483,7 @@ function EditMemberDrawer({
onRefetch={refetchRoles}
value={localRole}
onChange={(role): void => {
setLocalRole(role);
setLocalRole(role ?? '');
setSaveErrors((prev) =>
prev.filter(
(err) =>
@@ -476,6 +492,7 @@ function EditMemberDrawer({
);
}}
placeholder="Select role"
allowClear={false}
/>
)}
</div>
@@ -487,6 +504,10 @@ function EditMemberDrawer({
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
) : member?.status === MemberStatus.Deleted ? (
<Badge color="cherry" variant="outline">
DELETED
</Badge>
) : (
<Badge color="amber" variant="outline">
INVITED
@@ -525,55 +546,57 @@ function EditMemberDrawer({
<div className="edit-member-drawer__layout">
<div className="edit-member-drawer__body">{drawerBody}</div>
<div className="edit-member-drawer__footer">
<div className="edit-member-drawer__footer-left">
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
onClick={(): void => setShowDeleteConfirm(true)}
disabled={isRootUser || isSelf}
>
<Trash2 size={12} />
{isInvited ? 'Revoke Invite' : 'Delete Member'}
</Button>
</span>
</Tooltip>
{!isDeleted && (
<div className="edit-member-drawer__footer">
<div className="edit-member-drawer__footer-left">
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
onClick={(): void => setShowDeleteConfirm(true)}
disabled={isRootUser || isSelf}
>
<Trash2 size={12} />
{isInvited ? 'Revoke Invite' : 'Delete Member'}
</Button>
</span>
</Tooltip>
<div className="edit-member-drawer__footer-divider" />
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
onClick={handleGenerateResetLink}
disabled={isGeneratingLink || isRootUser}
>
<RefreshCw size={12} />
{isGeneratingLink && 'Generating...'}
{!isGeneratingLink && isInvited && 'Copy Invite Link'}
{!isGeneratingLink && !isInvited && 'Generate Password Reset Link'}
</Button>
</span>
</Tooltip>
<div className="edit-member-drawer__footer-divider" />
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
onClick={handleGenerateResetLink}
disabled={isGeneratingLink || isRootUser}
>
<RefreshCw size={12} />
{isGeneratingLink && 'Generating...'}
{!isGeneratingLink && isInvited && 'Copy Invite Link'}
{!isGeneratingLink && !isInvited && 'Generate Password Reset Link'}
</Button>
</span>
</Tooltip>
</div>
<div className="edit-member-drawer__footer-right">
<Button variant="solid" color="secondary" size="sm" onClick={handleClose}>
<X size={14} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
disabled={!isDirty || isSaving || isRootUser}
onClick={handleSave}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
</Button>
</div>
</div>
<div className="edit-member-drawer__footer-right">
<Button variant="solid" color="secondary" size="sm" onClick={handleClose}>
<X size={14} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
disabled={!isDirty || isSaving || isRootUser}
onClick={handleSave}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
</Button>
</div>
</div>
)}
</div>
);

View File

@@ -84,6 +84,16 @@ const ROLES_ENDPOINT = '*/api/v1/roles';
const mockDeleteMutate = jest.fn();
const mockGetResetPasswordToken = jest.mocked(getResetPasswordToken);
const showErrorModal = jest.fn();
jest.mock('providers/ErrorModalProvider', () => ({
__esModule: true,
...jest.requireActual('providers/ErrorModalProvider'),
useErrorModal: jest.fn(() => ({
showErrorModal,
isErrorModalVisible: false,
})),
}));
const mockFetchedUser = {
data: {
id: 'user-1',
@@ -147,6 +157,7 @@ function renderDrawer(
describe('EditMemberDrawer', () => {
beforeEach(() => {
jest.clearAllMocks();
showErrorModal.mockClear();
server.use(
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
@@ -459,7 +470,6 @@ describe('EditMemberDrawer', () => {
it('shows API error message when deleteUser fails for active member', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockToast = jest.mocked(toast);
(useDeleteUser as jest.Mock).mockImplementation((options) => ({
mutate: mockDeleteMutate.mockImplementation(() => {
@@ -477,16 +487,20 @@ describe('EditMemberDrawer', () => {
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(mockToast.error).toHaveBeenCalledWith(
'Failed to delete member: Something went wrong on server',
expect.anything(),
expect(showErrorModal).toHaveBeenCalledWith(
expect.objectContaining({
getErrorMessage: expect.any(Function),
}),
);
const passedError = showErrorModal.mock.calls[0][0] as any;
expect(passedError.getErrorMessage()).toBe(
'Something went wrong on server',
);
});
});
it('shows API error message when deleteUser fails for invited member', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockToast = jest.mocked(toast);
(useDeleteUser as jest.Mock).mockImplementation((options) => ({
mutate: mockDeleteMutate.mockImplementation(() => {
@@ -504,9 +518,14 @@ describe('EditMemberDrawer', () => {
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(mockToast.error).toHaveBeenCalledWith(
'Failed to revoke invite: Something went wrong on server',
expect.anything(),
expect(showErrorModal).toHaveBeenCalledWith(
expect.objectContaining({
getErrorMessage: expect.any(Function),
}),
);
const passedError = showErrorModal.mock.calls[0][0] as any;
expect(passedError.getErrorMessage()).toBe(
'Something went wrong on server',
);
});
});

View File

@@ -10,6 +10,7 @@ import { Select } from 'antd';
import inviteUsers from 'api/v1/invite/bulk/create';
import sendInvite from 'api/v1/invite/create';
import { cloneDeep, debounce } from 'lodash-es';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { ROLES } from 'types/roles';
import { EMAIL_REGEX } from 'utils/app';
@@ -40,6 +41,8 @@ function InviteMembersModal({
onClose,
onComplete,
}: InviteMembersModalProps): JSX.Element {
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [rows, setRows] = useState<InviteRow[]>(() => [
EMPTY_ROW(),
EMPTY_ROW(),
@@ -204,13 +207,11 @@ function InviteMembersModal({
resetAndClose();
onComplete?.();
} catch (err) {
const apiErr = err as APIError;
const errorMessage = apiErr?.getErrorMessage?.() ?? 'An error occurred';
toast.error(errorMessage, { richColors: true, position: 'top-right' });
showErrorModal(err as APIError);
} finally {
setIsSubmitting(false);
}
}, [rows, onComplete, resetAndClose, validateAllUsers]);
}, [validateAllUsers, rows, resetAndClose, onComplete, showErrorModal]);
const touchedRows = rows.filter(isRowTouched);
const isSubmitDisabled = isSubmitting || touchedRows.length === 0;
@@ -227,7 +228,7 @@ function InviteMembersModal({
showCloseButton
width="wide"
className="invite-members-modal"
disableOutsideClick={false}
disableOutsideClick={isErrorModalVisible}
>
<div className="invite-members-modal__content">
<div className="invite-members-modal__table">
@@ -329,6 +330,7 @@ function InviteMembersModal({
size="sm"
onClick={handleSubmit}
disabled={isSubmitDisabled}
loading={isSubmitting}
>
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
</Button>

View File

@@ -1,4 +1,3 @@
import { toast } from '@signozhq/sonner';
import inviteUsers from 'api/v1/invite/bulk/create';
import sendInvite from 'api/v1/invite/create';
import { StatusCodes } from 'http-status-codes';
@@ -22,6 +21,16 @@ jest.mock('@signozhq/sonner', () => ({
},
}));
const showErrorModal = jest.fn();
jest.mock('providers/ErrorModalProvider', () => ({
__esModule: true,
...jest.requireActual('providers/ErrorModalProvider'),
useErrorModal: jest.fn(() => ({
showErrorModal,
isErrorModalVisible: false,
})),
}));
const mockSendInvite = jest.mocked(sendInvite);
const mockInviteUsers = jest.mocked(inviteUsers);
@@ -34,6 +43,7 @@ const defaultProps = {
describe('InviteMembersModal', () => {
beforeEach(() => {
jest.clearAllMocks();
showErrorModal.mockClear();
mockSendInvite.mockResolvedValue({
httpStatusCode: 200,
data: { data: 'test', status: 'success' },
@@ -154,9 +164,10 @@ describe('InviteMembersModal', () => {
describe('error handling', () => {
it('shows BE message on single invite 409', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
mockSendInvite.mockRejectedValue(
makeApiError('An invite already exists for this email: single@signoz.io'),
const error = makeApiError(
'An invite already exists for this email: single@signoz.io',
);
mockSendInvite.mockRejectedValue(error);
render(<InviteMembersModal {...defaultProps} />);
@@ -171,18 +182,16 @@ describe('InviteMembersModal', () => {
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
'An invite already exists for this email: single@signoz.io',
expect.anything(),
);
expect(showErrorModal).toHaveBeenCalledWith(error);
});
});
it('shows BE message on bulk invite 409', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
mockInviteUsers.mockRejectedValue(
makeApiError('An invite already exists for this email: alice@signoz.io'),
const error = makeApiError(
'An invite already exists for this email: alice@signoz.io',
);
mockInviteUsers.mockRejectedValue(error);
render(<InviteMembersModal {...defaultProps} />);
@@ -201,18 +210,17 @@ describe('InviteMembersModal', () => {
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
'An invite already exists for this email: alice@signoz.io',
expect.anything(),
);
expect(showErrorModal).toHaveBeenCalledWith(error);
});
});
it('shows BE message on generic error', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
mockSendInvite.mockRejectedValue(
makeApiError('Internal server error', StatusCodes.INTERNAL_SERVER_ERROR),
const error = makeApiError(
'Internal server error',
StatusCodes.INTERNAL_SERVER_ERROR,
);
mockSendInvite.mockRejectedValue(error);
render(<InviteMembersModal {...defaultProps} />);
@@ -227,10 +235,7 @@ describe('InviteMembersModal', () => {
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
'Internal server error',
expect.anything(),
);
expect(showErrorModal).toHaveBeenCalledWith(error);
});
});
});

View File

@@ -210,7 +210,7 @@ function MembersTable({
index % 2 === 0 ? 'members-table-row--tinted' : ''
}
onRow={(record): React.HTMLAttributes<HTMLElement> => {
const isClickable = onRowClick && record.status !== MemberStatus.Deleted;
const isClickable = !!onRowClick;
return {
onClick: (): void => {
if (isClickable) {

View File

@@ -86,7 +86,7 @@ describe('MembersTable', () => {
);
});
it('renders DELETED badge and does not call onRowClick when a deleted member row is clicked', async () => {
it('renders DELETED badge and calls onRowClick when a deleted member row is clicked', async () => {
const onRowClick = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const deletedMember: MemberRow = {
@@ -108,7 +108,7 @@ describe('MembersTable', () => {
expect(screen.getByText('DELETED')).toBeInTheDocument();
await user.click(screen.getByText('Dave Deleted'));
expect(onRowClick).not.toHaveBeenCalledWith(
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: 'user-del' }),
);
});

View File

@@ -85,7 +85,8 @@ interface BaseProps {
interface SingleProps extends BaseProps {
mode?: 'single';
value?: string;
onChange?: (role: string) => void;
onChange?: (role: string | undefined) => void;
allowClear?: boolean;
}
interface MultipleProps extends BaseProps {
@@ -154,13 +155,14 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
);
}
const { value, onChange } = props as SingleProps;
const { value, onChange, allowClear = true } = props as SingleProps;
return (
<Select
id={id}
value={value || undefined}
onChange={onChange}
placeholder={placeholder}
allowClear={allowClear}
className={cx('roles-single-select', className)}
loading={loading}
notFoundContent={notFoundContent}

View File

@@ -17,6 +17,8 @@ import { AxiosError } from 'axios';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import KeyCreatedPhase from './KeyCreatedPhase';
import KeyFormPhase from './KeyFormPhase';
@@ -27,6 +29,7 @@ import './AddKeyModal.styles.scss';
function AddKeyModal(): JSX.Element {
const queryClient = useQueryClient();
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [accountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [isAddKeyOpen, setIsAddKeyOpen] = useQueryState(
SA_QUERY_PARAMS.ADD_KEY,
@@ -81,11 +84,11 @@ function AddKeyModal(): JSX.Element {
}
},
onError: (error) => {
const errMessage =
showErrorModal(
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'Failed to create key';
toast.error(errMessage, { richColors: true });
) as APIError,
);
},
},
});
@@ -151,7 +154,7 @@ function AddKeyModal(): JSX.Element {
width="base"
className="add-key-modal"
showCloseButton
disableOutsideClick={false}
disableOutsideClick={isErrorModalVisible}
>
{phase === Phase.FORM && (
<KeyFormPhase

View File

@@ -16,9 +16,12 @@ import type {
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
function DeleteAccountModal(): JSX.Element {
const queryClient = useQueryClient();
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [accountId, setAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [isDeleteOpen, setIsDeleteOpen] = useQueryState(
SA_QUERY_PARAMS.DELETE_SA,
@@ -45,11 +48,11 @@ function DeleteAccountModal(): JSX.Element {
await invalidateListServiceAccounts(queryClient);
},
onError: (error) => {
const errMessage =
showErrorModal(
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'Failed to delete service account';
toast.error(errMessage, { richColors: true });
) as APIError,
);
},
},
});
@@ -79,7 +82,7 @@ function DeleteAccountModal(): JSX.Element {
width="narrow"
className="alert-dialog sa-delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
disableOutsideClick={isErrorModalVisible}
>
<p className="sa-delete-dialog__body">
Are you sure you want to delete <strong>{accountName}</strong>? This action

View File

@@ -17,7 +17,9 @@ import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import dayjs from 'dayjs';
import { parseAsString, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
import { RevokeKeyContent } from '../RevokeKeyModal';
import EditKeyForm from './EditKeyForm';
@@ -41,6 +43,7 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
const open = !!editKeyId && !!selectedAccountId;
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [isRevokeConfirmOpen, setIsRevokeConfirmOpen] = useState(false);
const {
@@ -78,11 +81,11 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
}
},
onError: (error) => {
const errMessage =
showErrorModal(
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'Failed to update key';
toast.error(errMessage, { richColors: true });
) as APIError,
);
},
},
});
@@ -102,12 +105,13 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
});
}
},
// eslint-disable-next-line sonarjs/no-identical-functions
onError: (error) => {
const errMessage =
showErrorModal(
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'Failed to revoke key';
toast.error(errMessage, { richColors: true });
) as APIError,
);
},
},
});
@@ -160,7 +164,7 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
isRevokeConfirmOpen ? 'alert-dialog delete-dialog' : 'edit-key-modal'
}
showCloseButton={!isRevokeConfirmOpen}
disableOutsideClick={false}
disableOutsideClick={isErrorModalVisible}
>
{isRevokeConfirmOpen ? (
<RevokeKeyContent

View File

@@ -17,7 +17,7 @@ interface OverviewTabProps {
localName: string;
onNameChange: (v: string) => void;
localRole: string;
onRoleChange: (v: string) => void;
onRoleChange: (v: string | undefined) => void;
isDisabled: boolean;
availableRoles: AuthtypesRoleDTO[];
rolesLoading?: boolean;

View File

@@ -16,6 +16,8 @@ import type {
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { parseAsString, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
export interface RevokeKeyContentProps {
isRevoking: boolean;
@@ -56,6 +58,7 @@ export function RevokeKeyContent({
function RevokeKeyModal(): JSX.Element {
const queryClient = useQueryClient();
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [accountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [revokeKeyId, setRevokeKeyId] = useQueryState(
SA_QUERY_PARAMS.REVOKE_KEY,
@@ -83,11 +86,11 @@ function RevokeKeyModal(): JSX.Element {
}
},
onError: (error) => {
const errMessage =
showErrorModal(
convertToApiError(
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
)?.getErrorMessage() || 'Failed to revoke key';
toast.error(errMessage, { richColors: true });
) as APIError,
);
},
},
});
@@ -115,7 +118,7 @@ function RevokeKeyModal(): JSX.Element {
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
disableOutsideClick={isErrorModalVisible}
>
<RevokeKeyContent
isRevoking={isRevoking}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { Button } from '@signozhq/button';
import { DrawerWrapper } from '@signozhq/drawer';
@@ -8,7 +8,9 @@ import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Pagination, Skeleton } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getGetServiceAccountRolesQueryKey,
getListServiceAccountsQueryKey,
useDeleteServiceAccountRole,
useGetServiceAccount,
useListServiceAccountKeys,
useUpdateServiceAccount,
@@ -23,7 +25,10 @@ import {
ServiceAccountStatus,
toServiceAccountRow,
} from 'container/ServiceAccountsSettings/utils';
import { useServiceAccountRoleManager } from 'hooks/serviceAccount/useServiceAccountRoleManager';
import {
RoleUpdateFailure,
useServiceAccountRoleManager,
} from 'hooks/serviceAccount/useServiceAccountRoleManager';
import {
parseAsBoolean,
parseAsInteger,
@@ -32,7 +37,7 @@ import {
useQueryState,
} from 'nuqs';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import { retryOn429, toAPIError } from 'utils/errorUtils';
import AddKeyModal from './AddKeyModal';
import DeleteAccountModal from './DeleteAccountModal';
@@ -49,6 +54,13 @@ export interface ServiceAccountDrawerProps {
const PAGE_SIZE = 15;
function toSaveApiError(err: unknown): APIError {
return (
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
toAPIError(err as AxiosError<RenderErrorResponseDTO>)
);
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function ServiceAccountDrawer({
onSuccess,
@@ -103,21 +115,35 @@ function ServiceAccountDrawer({
[accountData],
);
const { currentRoles, applyDiff } = useServiceAccountRoleManager(
selectedAccountId ?? '',
);
const {
currentRoles,
isLoading: isRolesLoading,
applyDiff,
} = useServiceAccountRoleManager(selectedAccountId ?? '');
const roleSessionRef = useRef<string | null>(null);
useEffect(() => {
if (account?.id) {
setLocalName(account?.name ?? '');
setKeysPage(1);
}
setSaveErrors([]);
}, [account?.id, account?.name, setKeysPage]);
useEffect(() => {
setLocalRole(currentRoles[0]?.id ?? '');
}, [currentRoles]);
if (account?.id) {
setSaveErrors([]);
}
}, [account?.id]);
useEffect(() => {
if (!account?.id) {
roleSessionRef.current = null;
} else if (account.id !== roleSessionRef.current && !isRolesLoading) {
setLocalRole(currentRoles[0]?.id ?? '');
roleSessionRef.current = account.id;
}
}, [account?.id, currentRoles, isRolesLoading]);
const isDeleted =
account?.status?.toUpperCase() === ServiceAccountStatus.Deleted;
@@ -153,12 +179,26 @@ function ServiceAccountDrawer({
// the retry for this mutation is safe due to the api being idempotent on backend
const { mutateAsync: updateMutateAsync } = useUpdateServiceAccount();
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole({
mutation: {
retry: retryOn429,
},
});
const toSaveApiError = useCallback(
(err: unknown): APIError =>
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
toAPIError(err as AxiosError<RenderErrorResponseDTO>),
[],
const executeRolesOperation = useCallback(
async (accountId: string): Promise<RoleUpdateFailure[]> => {
if (localRole === '' && currentRoles[0]?.id) {
await deleteRole({
pathParams: { id: accountId, rid: currentRoles[0].id },
});
await queryClient.invalidateQueries(
getGetServiceAccountRolesQueryKey({ id: accountId }),
);
return [];
}
return applyDiff([localRole].filter(Boolean), availableRoles);
},
[localRole, currentRoles, availableRoles, applyDiff, deleteRole, queryClient],
);
const retryNameUpdate = useCallback(async (): Promise<void> => {
@@ -180,14 +220,7 @@ function ServiceAccountDrawer({
),
);
}
}, [
account,
localName,
updateMutateAsync,
refetchAccount,
queryClient,
toSaveApiError,
]);
}, [account, localName, updateMutateAsync, refetchAccount, queryClient]);
const handleNameChange = useCallback((name: string): void => {
setLocalName(name);
@@ -210,29 +243,39 @@ function ServiceAccountDrawer({
);
}
},
[toSaveApiError],
[],
);
const clearRoleErrors = useCallback((): void => {
setSaveErrors((prev) =>
prev.filter(
(e) => e.context !== 'Roles update' && !e.context.startsWith("Role '"),
),
);
}, []);
const failuresToSaveErrors = useCallback(
(failures: RoleUpdateFailure[]): SaveError[] =>
failures.map((f) => {
const ctx = `Role '${f.roleName}'`;
return {
context: ctx,
apiError: toSaveApiError(f.error),
onRetry: makeRoleRetry(ctx, f.onRetry),
};
}),
[makeRoleRetry],
);
const retryRolesUpdate = useCallback(async (): Promise<void> => {
try {
const failures = await applyDiff(
[localRole].filter(Boolean),
availableRoles,
);
const failures = await executeRolesOperation(selectedAccountId ?? '');
if (failures.length === 0) {
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Roles update'));
} else {
setSaveErrors((prev) => {
const rest = prev.filter((e) => e.context !== 'Roles update');
const roleErrors = failures.map((f) => {
const ctx = `Role '${f.roleName}'`;
return {
context: ctx,
apiError: toSaveApiError(f.error),
onRetry: makeRoleRetry(ctx, f.onRetry),
};
});
return [...rest, ...roleErrors];
return [...rest, ...failuresToSaveErrors(failures)];
});
}
} catch (err) {
@@ -242,7 +285,7 @@ function ServiceAccountDrawer({
),
);
}
}, [localRole, availableRoles, applyDiff, toSaveApiError, makeRoleRetry]);
}, [selectedAccountId, executeRolesOperation, failuresToSaveErrors]);
const handleSave = useCallback(async (): Promise<void> => {
if (!account || !isDirty) {
@@ -261,7 +304,7 @@ function ServiceAccountDrawer({
const [nameResult, rolesResult] = await Promise.allSettled([
namePromise,
applyDiff([localRole].filter(Boolean), availableRoles),
executeRolesOperation(account.id),
]);
const errors: SaveError[] = [];
@@ -281,14 +324,7 @@ function ServiceAccountDrawer({
onRetry: retryRolesUpdate,
});
} else {
for (const failure of rolesResult.value) {
const context = `Role '${failure.roleName}'`;
errors.push({
context,
apiError: toSaveApiError(failure.error),
onRetry: makeRoleRetry(context, failure.onRetry),
});
}
errors.push(...failuresToSaveErrors(rolesResult.value));
}
if (errors.length > 0) {
@@ -310,17 +346,14 @@ function ServiceAccountDrawer({
account,
isDirty,
localName,
localRole,
availableRoles,
updateMutateAsync,
applyDiff,
executeRolesOperation,
refetchAccount,
onSuccess,
queryClient,
toSaveApiError,
retryNameUpdate,
makeRoleRetry,
retryRolesUpdate,
failuresToSaveErrors,
]);
const handleClose = useCallback((): void => {
@@ -413,7 +446,10 @@ function ServiceAccountDrawer({
localName={localName}
onNameChange={handleNameChange}
localRole={localRole}
onRoleChange={setLocalRole}
onRoleChange={(role): void => {
setLocalRole(role ?? '');
clearRoleErrors();
}}
isDisabled={isDeleted}
availableRoles={availableRoles}
rolesLoading={rolesLoading}

View File

@@ -390,6 +390,42 @@ describe('ServiceAccountDrawer save-error UX', () => {
).toBeInTheDocument();
});
it('role add retries on 429 then succeeds without showing an error', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let roleAddCallCount = 0;
// First call → 429, second call → 200
server.use(
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) => {
roleAddCallCount += 1;
if (roleAddCallCount === 1) {
return res(ctx.status(429), ctx.json({ message: 'Too Many Requests' }));
}
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
renderDrawer();
await screen.findByDisplayValue('CI Bot');
await user.click(screen.getByLabelText('Roles'));
await user.click(await screen.findByTitle('signoz-viewer'));
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
// Retried after 429 — at least 2 calls, no error shown
await waitFor(
() => {
expect(roleAddCallCount).toBeGreaterThanOrEqual(2);
},
{ timeout: 5000 },
);
expect(screen.queryByText(/role assign failed/i)).not.toBeInTheDocument();
});
it('clicking Retry on a name-update error re-triggers the request; on success the error item is removed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });

View File

@@ -264,20 +264,22 @@ export default function Home(): JSX.Element {
return (
<div className="home-container">
<PersistedAnnouncementBanner
type="info"
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
action={{
label: 'Go to Service Accounts',
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
}}
>
<>
<strong>API keys</strong> have been deprecated in favour of{' '}
<strong>Service accounts</strong>. The existing API Keys have been migrated
to service accounts.
</>
</PersistedAnnouncementBanner>
{user?.role === USER_ROLES.ADMIN && (
<PersistedAnnouncementBanner
type="info"
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
action={{
label: 'Go to Service Accounts',
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
}}
>
<>
<strong>API keys</strong> have been deprecated in favour of{' '}
<strong>Service accounts</strong>. The existing API Keys have been
migrated to service accounts.
</>
</PersistedAnnouncementBanner>
)}
<div className="sticky-header">
<Header

View File

@@ -51,6 +51,8 @@ function MembersSettings(): JSX.Element {
if (filterMode === FilterMode.Invited) {
result = result.filter((m) => m.status === MemberStatus.Invited);
} else if (filterMode === FilterMode.Deleted) {
result = result.filter((m) => m.status === MemberStatus.Deleted);
}
if (searchQuery.trim()) {
@@ -89,6 +91,9 @@ function MembersSettings(): JSX.Element {
const pendingCount = allMembers.filter(
(m) => m.status === MemberStatus.Invited,
).length;
const deletedCount = allMembers.filter(
(m) => m.status === MemberStatus.Deleted,
).length;
const totalCount = allMembers.length;
const filterMenuItems: MenuProps['items'] = [
@@ -118,12 +123,27 @@ function MembersSettings(): JSX.Element {
setPage(1);
},
},
{
key: FilterMode.Deleted,
label: (
<div className="members-filter-option">
<span>Deleted {deletedCount}</span>
{filterMode === FilterMode.Deleted && <Check size={14} />}
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.Deleted);
setPage(1);
},
},
];
const filterLabel =
filterMode === FilterMode.All
? `All members ⎯ ${totalCount}`
: `Pending invites ⎯ ${pendingCount}`;
: filterMode === FilterMode.Invited
? `Pending invites ⎯ ${pendingCount}`
: `Deleted ⎯ ${deletedCount}`;
const handleInviteComplete = useCallback((): void => {
refetchUsers();

View File

@@ -117,14 +117,14 @@ describe('MembersSettings (integration)', () => {
await screen.findByText('Member Details');
});
it('does not open EditMemberDrawer when a deleted member row is clicked', async () => {
it('opens EditMemberDrawer when a deleted member row is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MembersSettings />);
await user.click(await screen.findByText('Dave Deleted'));
expect(screen.queryByText('Member Details')).not.toBeInTheDocument();
expect(screen.queryByText('Member Details')).toBeInTheDocument();
});
it('opens InviteMembersModal when "Invite member" button is clicked', async () => {

View File

@@ -1,6 +1,7 @@
export enum FilterMode {
All = 'all',
Invited = 'invited',
Deleted = 'deleted',
}
export enum MemberStatus {

View File

@@ -0,0 +1,16 @@
.display-name-form {
.form-field {
margin-bottom: var(--spacing-8);
label {
display: block;
margin-bottom: var(--spacing-4);
}
}
.field-error {
color: var(--destructive);
margin-top: var(--spacing-2);
font-size: var(--font-size-xs);
}
}

View File

@@ -0,0 +1,78 @@
import { toast } from '@signozhq/sonner';
import { rest, server } from 'mocks-server/server';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import DisplayName from '../index';
jest.mock('@signozhq/sonner', () => ({
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));
const ORG_ME_ENDPOINT = '*/api/v2/orgs/me';
const defaultProps = { index: 0, id: 'does-not-matter-id' };
describe('DisplayName', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
server.resetHandlers();
});
it('renders form pre-filled with org displayName from context', async () => {
render(<DisplayName {...defaultProps} />);
const input = await screen.findByRole('textbox');
expect(input).toHaveValue('Pentagon');
expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled();
});
it('enables submit and calls PUT when display name is changed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(rest.put(ORG_ME_ENDPOINT, (_, res, ctx) => res(ctx.status(200))));
render(<DisplayName {...defaultProps} />);
const input = await screen.findByRole('textbox');
await user.clear(input);
await user.type(input, 'New Org Name');
const submitBtn = screen.getByRole('button', { name: /submit/i });
expect(submitBtn).toBeEnabled();
await user.click(submitBtn);
await waitFor(() => {
expect(toast.success).toHaveBeenCalled();
});
});
it('shows validation error when display name is cleared and submitted', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<DisplayName {...defaultProps} />);
const input = await screen.findByRole('textbox');
await user.clear(input);
const form = input.closest('form') as HTMLFormElement;
fireEvent.submit(form);
await waitFor(() => {
expect(screen.getByText(/missing display name/i)).toBeInTheDocument();
});
});
});

View File

@@ -1,21 +1,57 @@
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { toast } from '@signozhq/sonner';
import { Button, Form, Input } from 'antd';
import { Button, Input } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useUpdateMyOrganization } from 'api/generated/services/orgs';
import {
useGetMyOrganization,
useUpdateMyOrganization,
} from 'api/generated/services/orgs';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { useAppContext } from 'providers/App/App';
import { IUser } from 'providers/App/types';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
const [form] = Form.useForm<FormValues>();
const orgName = Form.useWatch('displayName', form);
import './DisplayName.styles.scss';
function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
const { t } = useTranslation(['organizationsettings', 'common']);
const { org, updateOrg } = useAppContext();
const { displayName } = (org || [])[index];
const { showErrorModal } = useErrorModal();
const { org, updateOrg, user } = useAppContext();
const currentOrg = (org || [])[index];
const isAdmin = user.role === USER_ROLES.ADMIN;
const { data: orgData } = useGetMyOrganization({
query: {
enabled: isAdmin && !currentOrg?.displayName,
},
});
const displayName =
currentOrg?.displayName ?? orgData?.data?.displayName ?? '';
const {
control,
handleSubmit,
watch,
getValues,
setValue,
} = useForm<FormValues>({
defaultValues: { displayName },
});
const orgName = watch('displayName');
useEffect(() => {
if (displayName && !getValues('displayName')) {
setValue('displayName', displayName);
}
}, [displayName, getValues, setValue]);
const {
mutateAsync: updateMyOrganization,
@@ -30,20 +66,16 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
updateOrg(orgId, data.displayName ?? '');
},
onError: (error) => {
const apiError = convertToApiError(
error as AxiosError<RenderErrorResponseDTO>,
);
toast.error(
apiError?.getErrorMessage() ?? t('something_went_wrong', { ns: 'common' }),
{ richColors: true, position: 'top-right' },
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
);
},
},
});
const onSubmit = async (values: FormValues): Promise<void> => {
const { displayName } = values;
await updateMyOrganization({ data: { id: orgId, displayName } });
const { displayName: name } = values;
await updateMyOrganization({ data: { id: orgId, displayName: name } });
};
if (!org) {
@@ -53,21 +85,34 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
const isDisabled = isLoading || orgName === displayName || !orgName;
return (
<Form
initialValues={{ displayName }}
form={form}
layout="vertical"
onFinish={onSubmit}
<form
className="display-name-form"
onSubmit={handleSubmit(onSubmit)}
autoComplete="off"
>
<Form.Item
name="displayName"
label="Display name"
rules={[{ required: true, message: requireErrorMessage('Display name') }]}
>
<Input size="large" placeholder={t('signoz')} />
</Form.Item>
<Form.Item>
<div className="form-field">
<label htmlFor="displayName">Display name</label>
<Controller
name="displayName"
control={control}
rules={{ required: requireErrorMessage('Display name') }}
render={({ field, fieldState }): JSX.Element => (
<>
<Input
{...field}
id="displayName"
size="large"
placeholder={t('signoz')}
status={fieldState.error ? 'error' : ''}
/>
{fieldState.error && (
<div className="field-error">{fieldState.error.message}</div>
)}
</>
)}
/>
</div>
<div>
<Button
loading={isLoading}
disabled={isDisabled}
@@ -76,8 +121,8 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
>
Submit
</Button>
</Form.Item>
</Form>
</div>
</form>
);
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useMemo } from 'react';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import { useGetUser, useSetRoleByUserID } from 'api/generated/services/users';
import { retryOn429 } from 'utils/errorUtils';
export interface MemberRoleUpdateFailure {
roleName: string;
@@ -38,7 +39,9 @@ export function useMemberRoleManager(
[currentUserRoles],
);
const { mutateAsync: setRole } = useSetRoleByUserID();
const { mutateAsync: setRole } = useSetRoleByUserID({
mutation: { retry: retryOn429 },
});
const applyDiff = useCallback(
async (

View File

@@ -6,6 +6,12 @@ import {
useGetServiceAccountRoles,
} from 'api/generated/services/serviceaccount';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import { retryOn429 } from 'utils/errorUtils';
const enum PromiseStatus {
Fulfilled = 'fulfilled',
Rejected = 'rejected',
}
export interface RoleUpdateFailure {
roleName: string;
@@ -34,7 +40,9 @@ export function useServiceAccountRoleManager(
]);
// the retry for these mutations is safe due to being idempotent on backend
const { mutateAsync: createRole } = useCreateServiceAccountRole();
const { mutateAsync: createRole } = useCreateServiceAccountRole({
mutation: { retry: retryOn429 },
});
const invalidateRoles = useCallback(
() =>
@@ -73,11 +81,16 @@ export function useServiceAccountRoleManager(
allOperations.map((op) => op.run()),
);
await invalidateRoles();
const successCount = results.filter(
(r) => r.status === PromiseStatus.Fulfilled,
).length;
if (successCount > 0) {
await invalidateRoles();
}
const failures: RoleUpdateFailure[] = [];
results.forEach((result, index) => {
if (result.status === 'rejected') {
if (result.status === PromiseStatus.Rejected) {
const { role, run } = allOperations[index];
failures.push({
roleName: role.name ?? 'unknown',

View File

@@ -12,7 +12,6 @@ import {
import { useQuery } from 'react-query';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { useGetMyOrganization } from 'api/generated/services/orgs';
import { useGetMyUser } from 'api/generated/services/users';
import listOrgPreferences from 'api/v1/org/preferences/list';
import listUserPreferences from 'api/v1/user/preferences/list';
@@ -85,14 +84,6 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
query: { enabled: isLoggedIn },
});
const {
data: orgData,
isFetching: isFetchingOrgData,
error: orgFetchDataError,
} = useGetMyOrganization({
query: { enabled: isLoggedIn },
});
const {
permissions: permissionsResult,
isFetching: isFetchingPermissions,
@@ -102,10 +93,8 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
enabled: isLoggedIn,
});
const isFetchingUser =
isFetchingUserData || isFetchingOrgData || isFetchingPermissions;
const userFetchError =
userFetchDataError || orgFetchDataError || errorOnPermissions;
const isFetchingUser = isFetchingUserData || isFetchingPermissions;
const userFetchError = userFetchDataError || errorOnPermissions;
const userRole = useMemo(() => {
if (permissionsResult?.[IsAdminPermission]?.isGranted) {
@@ -145,39 +134,40 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
createdAt: toISOString(userData.data.createdAt) ?? prev.createdAt,
updatedAt: toISOString(userData.data.updatedAt) ?? prev.updatedAt,
}));
}
}, [userData, isFetchingUserData]);
useEffect(() => {
if (!isFetchingOrgData && orgData?.data) {
const { id: orgId, displayName: orgDisplayName } = orgData.data;
setOrg((prev) => {
// todo: we need to update the org name as well, we should have the [admin only role restriction on the get org api call] - BE input needed
setOrg((prev): any => {
if (!prev) {
return [{ createdAt: 0, id: orgId, displayName: orgDisplayName ?? '' }];
return [
{
createdAt: 0,
id: userData.data.orgId,
},
];
}
const orgIndex = prev.findIndex((e) => e.id === orgId);
const orgIndex = prev.findIndex((e) => e.id === userData.data.orgId);
if (orgIndex === -1) {
return [
...prev,
{ createdAt: 0, id: orgId, displayName: orgDisplayName ?? '' },
{
createdAt: 0,
id: userData.data.orgId,
},
];
}
const updatedOrg: Organization[] = [
return [
...prev.slice(0, orgIndex),
{ createdAt: 0, id: orgId, displayName: orgDisplayName ?? '' },
{
createdAt: 0,
id: userData.data.orgId,
},
...prev.slice(orgIndex + 1),
];
return updatedOrg;
});
setDefaultUser((prev) => ({
...prev,
organization: orgDisplayName ?? prev.organization,
}));
}
}, [orgData, isFetchingOrgData]);
}, [userData, isFetchingUserData]);
// fetcher for licenses v3
const {

View File

@@ -281,48 +281,6 @@ describe('AppProvider user and org data from v2 APIs', () => {
);
});
it('populates org state from GET /api/v2/orgs/me', async () => {
server.use(
rest.get(MY_ORG_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: {
id: 'org-abc',
displayName: 'My Org',
},
}),
),
),
rest.get(MY_USER_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: { id: 'u-default', email: 'default@signoz.io' } }),
),
),
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(authzMockResponse(payload, [false, false, false])),
);
}),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitFor(
() => {
expect(result.current.org).not.toBeNull();
const org = result.current.org?.[0];
expect(org?.id).toBe('org-abc');
expect(org?.displayName).toBe('My Org');
},
{ timeout: 2000 },
);
});
it('sets isFetchingUser false once both user and org calls complete', async () => {
server.use(
rest.get(MY_USER_URL, (_, res, ctx) =>

View File

@@ -14,6 +14,7 @@ import APIError from 'types/api/error';
interface ErrorModalContextType {
showErrorModal: (error: APIError) => void;
hideErrorModal: () => void;
isErrorModalVisible: boolean;
}
const ErrorModalContext = createContext<ErrorModalContextType | undefined>(
@@ -38,10 +39,10 @@ export function ErrorModalProvider({
setIsVisible(false);
}, []);
const value = useMemo(() => ({ showErrorModal, hideErrorModal }), [
showErrorModal,
hideErrorModal,
]);
const value = useMemo(
() => ({ showErrorModal, hideErrorModal, isErrorModalVisible: isVisible }),
[showErrorModal, hideErrorModal, isVisible],
);
return (
<ErrorModalContext.Provider value={value}>

View File

@@ -0,0 +1,45 @@
import { AxiosError } from 'axios';
import { retryOn429 } from './errorUtils';
describe('retryOn429', () => {
const make429 = (): AxiosError =>
Object.assign(new AxiosError('Too Many Requests'), {
response: { status: 429 },
}) as AxiosError;
it('returns true on first failure (failureCount=0) for 429', () => {
expect(retryOn429(0, make429())).toBe(true);
});
it('returns true on second failure (failureCount=1) for 429', () => {
expect(retryOn429(1, make429())).toBe(true);
});
it('returns false on third failure (failureCount=2) for 429 — max retries reached', () => {
expect(retryOn429(2, make429())).toBe(false);
});
it('returns false for non-429 axios errors', () => {
const err = Object.assign(new AxiosError('Server Error'), {
response: { status: 500 },
}) as AxiosError;
expect(retryOn429(0, err)).toBe(false);
});
it('returns false for 401 axios errors', () => {
const err = Object.assign(new AxiosError('Unauthorized'), {
response: { status: 401 },
}) as AxiosError;
expect(retryOn429(0, err)).toBe(false);
});
it('returns false for non-axios errors', () => {
expect(retryOn429(0, new Error('network error'))).toBe(false);
});
it('returns false for null/undefined errors', () => {
expect(retryOn429(0, null)).toBe(false);
expect(retryOn429(0, undefined)).toBe(false);
});
});

View File

@@ -1,6 +1,7 @@
import { ErrorResponseHandlerForGeneratedAPIs } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import { AxiosError } from 'axios';
import APIError from 'types/api/error';
/**
@@ -66,3 +67,10 @@ export function handleApiError(
showErrorFunction(apiError as APIError);
}
}
export const retryOn429 = (failureCount: number, error: unknown): boolean => {
if (error instanceof AxiosError && error.response?.status === 429) {
return failureCount < 2;
}
return false;
};

View File

@@ -278,7 +278,8 @@ func (d *Dispatcher) processAlert(alert *types.Alert, route *dispatch.Route) {
ruleId := getRuleIDFromAlert(alert)
config, err := d.notificationManager.GetNotificationConfig(d.orgID, ruleId)
if err != nil {
d.logger.ErrorContext(d.ctx, "error getting alert notification config", slog.String("rule_id", ruleId), errors.Attr(err))
//nolint:sloglint
d.logger.ErrorContext(d.ctx, "error getting alert notification config", slog.String("rule.id", ruleId), errors.Attr(err))
return
}
renotifyInterval := config.Renotify.RenotifyInterval
@@ -328,7 +329,12 @@ func (d *Dispatcher) processAlert(alert *types.Alert, route *dispatch.Route) {
go ag.run(func(ctx context.Context, alerts ...*types.Alert) bool {
_, _, err := d.stage.Exec(ctx, d.logger, alerts...)
if err != nil {
logger := d.logger.With(slog.Int("num_alerts", len(alerts)), errors.Attr(err))
receiverName, _ := notify.ReceiverName(ctx)
logger := d.logger.With(
slog.String("receiver", receiverName),
slog.Int("num_alerts", len(alerts)),
errors.Attr(err),
)
if errors.Is(ctx.Err(), context.Canceled) {
// It is expected for the context to be canceled on
// configuration reload or shutdown. In this case, the

View File

@@ -10,6 +10,26 @@ import (
)
func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/credentials", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.GetConnectionCredentials),
handler.OpenAPIDef{
ID: "GetConnectionCredentials",
Tags: []string{"cloudintegration"},
Summary: "Get connection credentials",
Description: "This endpoint retrieves the connection credentials required for integration",
Request: nil,
RequestContentType: "application/json",
Response: new(citypes.Credentials),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.CreateAccount),
handler.OpenAPIDef{
@@ -17,9 +37,9 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
Tags: []string{"cloudintegration"},
Summary: "Create account",
Description: "This endpoint creates a new cloud integration account for the specified cloud provider",
Request: new(citypes.PostableConnectionArtifact),
Request: new(citypes.PostableAccount),
RequestContentType: "application/json",
Response: new(citypes.GettableAccountWithArtifact),
Response: new(citypes.GettableAccountWithConnectionArtifact),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -59,7 +79,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
Description: "This endpoint gets an account for the specified cloud provider",
Request: nil,
RequestContentType: "",
Response: new(citypes.GettableAccount),
Response: new(citypes.Account),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
@@ -139,7 +159,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
Description: "This endpoint gets a service for the specified cloud provider",
Request: nil,
RequestContentType: "",
Response: new(citypes.GettableService),
Response: new(citypes.Service),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -150,7 +170,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/services/{service_id}", handler.New(
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.UpdateService),
handler.OpenAPIDef{
ID: "UpdateService",
@@ -179,9 +199,9 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
Tags: []string{"cloudintegration"},
Summary: "Agent check-in",
Description: "[Deprecated] This endpoint is called by the deployed agent to check in",
Request: new(citypes.PostableAgentCheckInRequest),
Request: new(citypes.PostableAgentCheckIn),
RequestContentType: "application/json",
Response: new(citypes.GettableAgentCheckInResponse),
Response: new(citypes.GettableAgentCheckIn),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -199,9 +219,9 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
Tags: []string{"cloudintegration"},
Summary: "Agent check-in",
Description: "This endpoint is called by the deployed agent to check in",
Request: new(citypes.PostableAgentCheckInRequest),
Request: new(citypes.PostableAgentCheckIn),
RequestContentType: "application/json",
Response: new(citypes.GettableAgentCheckInResponse),
Response: new(citypes.GettableAgentCheckIn),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},

View File

@@ -150,7 +150,7 @@ func (provider *provider) Grant(ctx context.Context, orgID valuer.UUID, names []
err = provider.Write(ctx, tuples, nil)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "failed to grant roles: %v to subject: %s", names, subject)
return errors.WithAdditionalf(err, "failed to grant roles: %v to subject: %s", names, subject)
}
return nil
@@ -188,7 +188,7 @@ func (provider *provider) Revoke(ctx context.Context, orgID valuer.UUID, names [
err = provider.Write(ctx, nil, tuples)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "failed to revoke roles: %v to subject: %s", names, subject)
return errors.WithAdditionalf(err, "failed to revoke roles: %v to subject: %s", names, subject)
}
return nil

View File

@@ -15,12 +15,14 @@ import (
openfgav1 "github.com/openfga/api/proto/openfga/v1"
openfgapkgtransformer "github.com/openfga/language/pkg/go/transformer"
openfgapkgserver "github.com/openfga/openfga/pkg/server"
openfgaerrors "github.com/openfga/openfga/pkg/server/errors"
"github.com/openfga/openfga/pkg/storage"
"google.golang.org/protobuf/encoding/protojson"
)
const (
batchCheckItemErrorMessage = "::AUTHZ-CHECK-ERROR::"
writeErrorMessage = "::AUTHZ-WRITE-ERROR::"
)
var (
@@ -248,7 +250,19 @@ func (server *Server) Write(ctx context.Context, additions []*openfgav1.TupleKey
}(),
})
return err
if err != nil {
openfgaError := new(openfgaerrors.InternalError)
ok := errors.As(err, openfgaError)
if ok {
server.settings.Logger().ErrorContext(ctx, writeErrorMessage, errors.Attr(openfgaError.Unwrap()))
return errors.New(errors.TypeTooManyRequests, errors.CodeTooManyRequests, openfgaError.Error())
}
server.settings.Logger().ErrorContext(ctx, writeErrorMessage, errors.Attr(err))
return err
}
return nil
}
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, typeable authtypes.Typeable) ([]*authtypes.Object, error) {

View File

@@ -18,6 +18,7 @@ var (
CodeUnknown = Code{"unknown"}
CodeFatal = Code{"fatal"}
CodeLicenseUnavailable = Code{"license_unavailable"}
CodeTooManyRequests = Code{"too_many_requests"}
)
var (

View File

@@ -12,8 +12,9 @@ var (
TypeCanceled = typ{"canceled"}
TypeTimeout = typ{"timeout"}
TypeUnexpected = typ{"unexpected"} // Generic mismatch of expectations
TypeFatal = typ{"fatal"} // Unrecoverable failure (e.g. panic)
TypeFatal = typ{"fatal"} // Unrecoverable failure (e.g. panic)
TypeLicenseUnavailable = typ{"license-unavailable"}
TypeTooManyRequests = typ{"too-many-requests"}
)
// Defines custom error types.

View File

@@ -77,6 +77,8 @@ func ErrorTypeFromStatusCode(statusCode int) string {
return errors.TypeTimeout.String()
case http.StatusUnavailableForLegalReasons:
return errors.TypeLicenseUnavailable.String()
case http.StatusTooManyRequests:
return errors.TypeTooManyRequests.String()
default:
return errors.TypeInternal.String()
}
@@ -108,6 +110,8 @@ func Error(rw http.ResponseWriter, cause error) {
httpCode = http.StatusInternalServerError
case errors.TypeLicenseUnavailable:
httpCode = http.StatusUnavailableForLegalReasons
case errors.TypeTooManyRequests:
httpCode = http.StatusTooManyRequests
}
body, err := json.Marshal(&ErrorResponse{Status: StatusError.s, Error: errors.AsJSON(cause)})

View File

@@ -10,37 +10,42 @@ import (
)
type Module interface {
GetConnectionCredentials(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType) (*citypes.Credentials, error)
CreateAccount(ctx context.Context, account *citypes.Account) error
// GetAccount returns cloud integration account
GetAccount(ctx context.Context, orgID, accountID valuer.UUID) (*citypes.Account, error)
GetAccount(ctx context.Context, orgID, accountID valuer.UUID, provider citypes.CloudProviderType) (*citypes.Account, error)
// ListAccounts lists accounts where agent is connected
ListAccounts(ctx context.Context, orgID valuer.UUID) ([]*citypes.Account, error)
ListAccounts(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType) ([]*citypes.Account, error)
// UpdateAccount updates the cloud integration account for a specific organization.
UpdateAccount(ctx context.Context, account *citypes.Account) error
// DisconnectAccount soft deletes/removes a cloud integration account.
DisconnectAccount(ctx context.Context, orgID, accountID valuer.UUID) error
DisconnectAccount(ctx context.Context, orgID, accountID valuer.UUID, provider citypes.CloudProviderType) error
// GetConnectionArtifact returns cloud provider specific connection information,
// client side handles how this information is shown
GetConnectionArtifact(ctx context.Context, account *citypes.Account, req *citypes.ConnectionArtifactRequest) (*citypes.ConnectionArtifact, error)
GetConnectionArtifact(ctx context.Context, account *citypes.Account, req *citypes.GetConnectionArtifactRequest) (*citypes.ConnectionArtifact, error)
// ListServicesMetadata returns the list of services metadata for a cloud provider attached with the integrationID.
// This just returns a summary of the service and not the whole service definition
ListServicesMetadata(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID) ([]*citypes.ServiceMetadata, error)
// ListServicesMetadata returns the list of supported services' metadata for a cloud provider with optional filtering for a specific integration
// This just returns a summary of the service and not the whole service definition.
ListServicesMetadata(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType, integrationID *valuer.UUID) ([]*citypes.ServiceMetadata, error)
// GetService returns service definition details for a serviceID. This returns config and
// other details required to show in service details page on web client.
GetService(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID, serviceID string) (*citypes.Service, error)
// GetService returns service definition details for a serviceID. This optionally returns the service config
// for integrationID if provided.
GetService(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID, serviceID citypes.ServiceID, provider citypes.CloudProviderType) (*citypes.Service, error)
// CreateService creates a new service for a cloud integration account.
CreateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService, provider citypes.CloudProviderType) error
// UpdateService updates cloud integration service
UpdateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService) error
UpdateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService, provider citypes.CloudProviderType) error
// AgentCheckIn is called by agent to heartbeat and get latest config in response.
AgentCheckIn(ctx context.Context, orgID valuer.UUID, req *citypes.AgentCheckInRequest) (*citypes.AgentCheckInResponse, error)
// AgentCheckIn is called by agent to send heartbeat and get latest config in response.
AgentCheckIn(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType, req *citypes.AgentCheckInRequest) (*citypes.AgentCheckInResponse, error)
// GetDashboardByID returns dashboard JSON for a given dashboard id.
// this only returns the dashboard when the service (embedded in dashboard id) is enabled
@@ -52,7 +57,22 @@ type Module interface {
ListDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error)
}
type CloudProviderModule interface {
GetConnectionArtifact(ctx context.Context, account *citypes.Account, req *citypes.GetConnectionArtifactRequest) (*citypes.ConnectionArtifact, error)
// ListServiceDefinitions returns all service definitions for this cloud provider.
ListServiceDefinitions(ctx context.Context) ([]*citypes.ServiceDefinition, error)
// GetServiceDefinition returns the service definition for the given service ID.
GetServiceDefinition(ctx context.Context, serviceID citypes.ServiceID) (*citypes.ServiceDefinition, error)
// BuildIntegrationConfig compiles the provider-specific integration config from the account
// and list of configured services. This is the config returned to the agent on check-in.
BuildIntegrationConfig(ctx context.Context, account *citypes.Account, services []*citypes.StorableCloudIntegrationService) (*citypes.ProviderIntegrationConfig, error)
}
type Handler interface {
GetConnectionCredentials(http.ResponseWriter, *http.Request)
CreateAccount(http.ResponseWriter, *http.Request)
ListAccounts(http.ResponseWriter, *http.Request)
GetAccount(http.ResponseWriter, *http.Request)

View File

@@ -447,9 +447,9 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/ApplicationELB"
"namespace": "AWS/ApplicationELB"
}
]
}

View File

@@ -171,14 +171,14 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/ApiGateway"
"namespace": "AWS/ApiGateway"
}
]
},
"logs": {
"cloudwatchLogsSubscriptions": [
"subscriptions": [
{
"logGroupNamePrefix": "API-Gateway",
"filterPattern": ""

View File

@@ -374,9 +374,9 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/DynamoDB"
"namespace": "AWS/DynamoDB"
}
]
}

View File

@@ -495,12 +495,12 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/EC2"
"namespace": "AWS/EC2"
},
{
"Namespace": "CWAgent"
"namespace": "CWAgent"
}
]
}

View File

@@ -823,17 +823,17 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/ECS"
"namespace": "AWS/ECS"
},
{
"Namespace": "ECS/ContainerInsights"
"namespace": "ECS/ContainerInsights"
}
]
},
"logs": {
"cloudwatchLogsSubscriptions": [
"subscriptions": [
{
"logGroupNamePrefix": "/ecs",
"filterPattern": ""

View File

@@ -2702,17 +2702,17 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/EKS"
"namespace": "AWS/EKS"
},
{
"Namespace": "ContainerInsights"
"namespace": "ContainerInsights"
}
]
},
"logs": {
"cloudwatchLogsSubscriptions": [
"subscriptions": [
{
"logGroupNamePrefix": "/aws/containerinsights",
"filterPattern": ""

View File

@@ -1934,9 +1934,9 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/ElastiCache"
"namespace": "AWS/ElastiCache"
}
]
}

View File

@@ -271,14 +271,14 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/Lambda"
"namespace": "AWS/Lambda"
}
]
},
"logs": {
"cloudwatchLogsSubscriptions": [
"subscriptions": [
{
"logGroupNamePrefix": "/aws/lambda",
"filterPattern": ""

View File

@@ -1070,9 +1070,9 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/Kafka"
"namespace": "AWS/Kafka"
}
]
}

View File

@@ -775,14 +775,14 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/RDS"
"namespace": "AWS/RDS"
}
]
},
"logs": {
"cloudwatchLogsSubscriptions": [
"subscriptions": [
{
"logGroupNamePrefix": "/aws/rds",
"filterPattern": ""

View File

@@ -39,7 +39,7 @@
"telemetryCollectionStrategy": {
"aws": {
"logs": {
"cloudwatchLogsSubscriptions": [
"subscriptions": [
{
"logGroupNamePrefix": "x/signoz/forwarder",
"filterPattern": ""

View File

@@ -110,9 +110,9 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/SNS"
"namespace": "AWS/SNS"
}
]
}

View File

@@ -230,9 +230,9 @@
"telemetryCollectionStrategy": {
"aws": {
"metrics": {
"cloudwatchMetricStreamFilters": [
"streamFilters": [
{
"Namespace": "AWS/SQS"
"namespace": "AWS/SQS"
}
]
}

View File

@@ -12,6 +12,10 @@ func NewHandler() cloudintegration.Handler {
return &handler{}
}
func (handler *handler) GetConnectionCredentials(http.ResponseWriter, *http.Request) {
panic("unimplemented")
}
func (handler *handler) CreateAccount(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")

View File

@@ -34,6 +34,25 @@ func (store *store) GetAccountByID(ctx context.Context, orgID, id valuer.UUID, p
return account, nil
}
func (store *store) GetConnectedAccount(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, providerAccountID string) (*cloudintegrationtypes.StorableCloudIntegration, error) {
account := new(cloudintegrationtypes.StorableCloudIntegration)
err := store.
store.
BunDBCtx(ctx).
NewSelect().
Model(account).
Where("org_id = ?", orgID).
Where("provider = ?", provider).
Where("account_id = ?", providerAccountID).
Where("last_agent_report IS NOT NULL").
Where("removed_at IS NULL").
Scan(ctx)
if err != nil {
return nil, store.store.WrapNotFoundErrf(err, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "connected account with provider account id %s not found", providerAccountID)
}
return account, nil
}
func (store *store) ListConnectedAccounts(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) ([]*cloudintegrationtypes.StorableCloudIntegration, error) {
var accounts []*cloudintegrationtypes.StorableCloudIntegration
err := store.
@@ -96,25 +115,6 @@ func (store *store) RemoveAccount(ctx context.Context, orgID, id valuer.UUID, pr
return err
}
func (store *store) GetConnectedAccount(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, providerAccountID string) (*cloudintegrationtypes.StorableCloudIntegration, error) {
account := new(cloudintegrationtypes.StorableCloudIntegration)
err := store.
store.
BunDBCtx(ctx).
NewSelect().
Model(account).
Where("org_id = ?", orgID).
Where("provider = ?", provider).
Where("account_id = ?", providerAccountID).
Where("last_agent_report IS NOT NULL").
Where("removed_at IS NULL").
Scan(ctx)
if err != nil {
return nil, store.store.WrapNotFoundErrf(err, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "connected account with provider account id %s not found", providerAccountID)
}
return account, nil
}
func (store *store) GetServiceByServiceID(ctx context.Context, cloudIntegrationID valuer.UUID, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.StorableCloudIntegrationService, error) {
service := new(cloudintegrationtypes.StorableCloudIntegrationService)
err := store.
@@ -172,3 +172,9 @@ func (store *store) UpdateService(ctx context.Context, service *cloudintegration
Exec(ctx)
return err
}
func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
return store.store.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
return cb(ctx)
})
}

View File

@@ -383,15 +383,15 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
spec.Aggregations[i].Temporality = temp
}
}
if spec.Aggregations[i].Temporality == metrictypes.Unknown {
missingMetrics = append(missingMetrics, spec.Aggregations[i].MetricName)
continue
}
if spec.Aggregations[i].MetricName != "" && spec.Aggregations[i].Type == metrictypes.UnspecifiedType {
if foundMetricType, ok := metricTypes[spec.Aggregations[i].MetricName]; ok && foundMetricType != metrictypes.UnspecifiedType {
spec.Aggregations[i].Type = foundMetricType
}
}
if spec.Aggregations[i].Type == metrictypes.UnspecifiedType {
missingMetrics = append(missingMetrics, spec.Aggregations[i].MetricName)
continue
}
presentAggregations = append(presentAggregations, spec.Aggregations[i])
}
if len(presentAggregations) == 0 {

145
pkg/querier/querier_test.go Normal file
View File

@@ -0,0 +1,145 @@
package querier
import (
"context"
"testing"
"time"
cmock "github.com/srikanthccv/ClickHouse-go-mock"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type queryMatcherAny struct{}
func (m *queryMatcherAny) Match(string, string) error { return nil }
// mockMetricStmtBuilder implements qbtypes.StatementBuilder[qbtypes.MetricAggregation]
// and returns a fixed query string so the mock ClickHouse can match it.
type mockMetricStmtBuilder struct{}
func (m *mockMetricStmtBuilder) Build(_ context.Context, _, _ uint64, _ qbtypes.RequestType, _ qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation], _ map[string]qbtypes.VariableItem) (*qbtypes.Statement, error) {
return &qbtypes.Statement{
Query: "SELECT ts, value FROM signoz_metrics",
Args: nil,
}, nil
}
func TestQueryRange_MetricTypeMissing(t *testing.T) {
// When a metric has UnspecifiedType and is not found in the metadata store,
// the querier should return a not-found error, even if the request provides a temporality
providerSettings := instrumentationtest.New().ToProviderSettings()
metadataStore := telemetrytypestest.NewMockMetadataStore()
q := New(
providerSettings,
nil, // telemetryStore
metadataStore,
nil, // prometheus
nil, // traceStmtBuilder
nil, // logStmtBuilder
nil, // metricStmtBuilder
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
)
req := &qbtypes.QueryRangeRequest{
Start: uint64(time.Now().Add(-5 * time.Minute).UnixMilli()),
End: uint64(time.Now().UnixMilli()),
RequestType: qbtypes.RequestTypeTimeSeries,
CompositeQuery: qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
StepInterval: qbtypes.Step{Duration: time.Minute},
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "unknown_metric",
Temporality: metrictypes.Cumulative,
TimeAggregation: metrictypes.TimeAggregationRate,
SpaceAggregation: metrictypes.SpaceAggregationSum,
},
},
Signal: telemetrytypes.SignalMetrics,
},
}},
},
}
_, err := q.QueryRange(context.Background(), valuer.GenerateUUID(), req)
require.Error(t, err)
assert.Contains(t, err.Error(), "could not find the metric unknown_metric")
}
func TestQueryRange_MetricTypeFromStore(t *testing.T) {
// When a metric has UnspecifiedType but the metadata store returns a valid type,
// the metric should not be treated as missing.
providerSettings := instrumentationtest.New().ToProviderSettings()
metadataStore := telemetrytypestest.NewMockMetadataStore()
metadataStore.TypeMap["my_metric"] = metrictypes.SumType
metadataStore.TemporalityMap["my_metric"] = metrictypes.Cumulative
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
cols := []cmock.ColumnType{
{Name: "ts", Type: "DateTime"},
{Name: "value", Type: "Float64"},
}
rows := cmock.NewRows(cols, [][]any{
{time.Now(), float64(42)},
})
telemetryStore.Mock().
ExpectQuery("SELECT any").
WillReturnRows(rows)
q := New(
providerSettings,
telemetryStore,
metadataStore,
nil, // prometheus
nil, // traceStmtBuilder
nil, // logStmtBuilder
&mockMetricStmtBuilder{}, // metricStmtBuilder
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
)
req := &qbtypes.QueryRangeRequest{
Start: uint64(time.Now().Add(-5 * time.Minute).UnixMilli()),
End: uint64(time.Now().UnixMilli()),
RequestType: qbtypes.RequestTypeTimeSeries,
CompositeQuery: qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
StepInterval: qbtypes.Step{Duration: time.Minute},
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "my_metric",
TimeAggregation: metrictypes.TimeAggregationRate,
SpaceAggregation: metrictypes.SpaceAggregationSum,
},
},
Signal: telemetrytypes.SignalMetrics,
},
}},
},
}
resp, err := q.QueryRange(context.Background(), valuer.GenerateUUID(), req)
require.NoError(t, err)
require.NotNil(t, resp)
}

View File

@@ -110,7 +110,7 @@ func WithEvalDelay(dur valuer.TextDuration) RuleOption {
func WithLogger(logger *slog.Logger) RuleOption {
return func(r *BaseRule) {
r.logger = logger
r.logger = logger.With(slog.String("rule.id", r.id))
}
}
@@ -248,7 +248,7 @@ func (r *BaseRule) SelectedQuery(ctx context.Context) string {
if r.ruleCondition.SelectedQuery != "" {
return r.ruleCondition.SelectedQuery
}
r.logger.WarnContext(ctx, "missing selected query", slog.String("rule.id", r.ID()))
r.logger.WarnContext(ctx, "missing selected query")
return r.ruleCondition.SelectedQueryName()
}
@@ -368,7 +368,7 @@ func (r *BaseRule) SendAlerts(ctx context.Context, ts time.Time, resendDelay tim
alerts = append(alerts, &anew)
}
})
notifyFunc(ctx, orgID, "", alerts...)
notifyFunc(ctx, orgID, alerts...)
}
func (r *BaseRule) ForEachActiveAlert(f func(*ruletypes.Alert)) {
@@ -380,13 +380,13 @@ func (r *BaseRule) ForEachActiveAlert(f func(*ruletypes.Alert)) {
}
}
func (r *BaseRule) RecordRuleStateHistory(ctx context.Context, prevState, currentState ruletypes.AlertState, itemsToAdd []rulestatehistorytypes.RuleStateHistory) error {
func (r *BaseRule) RecordRuleStateHistory(ctx context.Context, itemsToAdd []rulestatehistorytypes.RuleStateHistory) error {
if r.ruleStateHistoryModule == nil {
return nil
}
if err := r.ruleStateHistoryModule.RecordRuleStateHistory(ctx, r.ID(), r.handledRestart, itemsToAdd); err != nil {
r.logger.ErrorContext(ctx, "error while recording rule state history", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("items_to_add", itemsToAdd))
r.logger.ErrorContext(ctx, "error while recording rule state history", errors.Attr(err), slog.Any("items_to_add", itemsToAdd))
return err
}
r.handledRestart = true
@@ -580,7 +580,12 @@ func (r *BaseRule) FilterNewSeries(ctx context.Context, ts time.Time, series []*
// Check if first_seen + delay has passed
if maxFirstSeen+newGroupEvalDelayMs > evalTimeMs {
// Still within grace period, skip this series
r.logger.InfoContext(ctx, "skipping new series", slog.String("rule.id", r.ID()), slog.Int("series.index", i), slog.Int64("series.max_first_seen", maxFirstSeen), slog.Int64("eval.time_ms", evalTimeMs), slog.Int64("eval.delay_ms", newGroupEvalDelayMs), slog.Any("series.labels", series[i].Labels))
r.logger.InfoContext(
ctx, "skipping new series",
slog.Int("series.index", i), slog.Int64("series.max_first_seen", maxFirstSeen),
slog.Int64("eval.time_ms", evalTimeMs), slog.Int64("eval.delay_ms", newGroupEvalDelayMs),
slog.Any("series.labels", series[i].Labels),
)
continue
}
@@ -590,7 +595,11 @@ func (r *BaseRule) FilterNewSeries(ctx context.Context, ts time.Time, series []*
skippedCount := len(series) - len(filteredSeries)
if skippedCount > 0 {
r.logger.InfoContext(ctx, "filtered new series", slog.String("rule.id", r.ID()), slog.Int("series.skipped_count", skippedCount), slog.Int("series.total_count", len(series)), slog.Int64("eval.delay_ms", newGroupEvalDelayMs))
r.logger.InfoContext(
ctx, "filtered new series",
slog.Int("series.skipped_count", skippedCount), slog.Int("series.total_count", len(series)),
slog.Int64("eval.delay_ms", newGroupEvalDelayMs),
)
}
return filteredSeries, nil
@@ -611,7 +620,7 @@ func (r *BaseRule) HandleMissingDataAlert(ctx context.Context, ts time.Time, has
return nil
}
r.logger.InfoContext(ctx, "no data found for rule condition", slog.String("rule.id", r.ID()))
r.logger.InfoContext(ctx, "no data found for rule condition")
lbls := ruletypes.NewBuilder()
if !r.lastTimestampWithDatapoints.IsZero() {
lbls.Set(ruletypes.LabelLastSeen, r.lastTimestampWithDatapoints.Format(ruletypes.AlertTimeFormat))

View File

@@ -438,7 +438,7 @@ func (m *Manager) editTask(_ context.Context, orgID valuer.UUID, rule *ruletypes
Logger: m.opts.Logger,
Cache: m.cache,
ManagerOpts: m.opts,
NotifyFunc: m.prepareNotifyFunc(),
NotifyFunc: m.notifyFunc,
SQLStore: m.sqlstore,
OrgID: orgID,
})
@@ -651,7 +651,7 @@ func (m *Manager) addTask(_ context.Context, orgID valuer.UUID, rule *ruletypes.
Logger: m.opts.Logger,
Cache: m.cache,
ManagerOpts: m.opts,
NotifyFunc: m.prepareNotifyFunc(),
NotifyFunc: m.notifyFunc,
SQLStore: m.sqlstore,
OrgID: orgID,
})
@@ -752,70 +752,65 @@ func (m *Manager) TriggeredAlerts() []*ruletypes.NamedAlert {
}
// NotifyFunc sends notifications about a set of alerts generated by the given expression.
type NotifyFunc func(ctx context.Context, orgID string, expr string, alerts ...*ruletypes.Alert)
type NotifyFunc func(ctx context.Context, orgID string, alerts ...*ruletypes.Alert)
// prepareNotifyFunc implements the NotifyFunc for a Notifier.
func (m *Manager) prepareNotifyFunc() NotifyFunc {
return func(ctx context.Context, orgID string, expr string, alerts ...*ruletypes.Alert) {
var res []*alertmanagertypes.PostableAlert
// notifyFunc implements the NotifyFunc for a Notifier.
func (m *Manager) notifyFunc(ctx context.Context, orgID string, alerts ...*ruletypes.Alert) {
var res []*alertmanagertypes.PostableAlert
for _, alert := range alerts {
generatorURL := alert.GeneratorURL
for _, alert := range alerts {
generatorURL := alert.GeneratorURL
a := &alertmanagertypes.PostableAlert{
Annotations: alert.Annotations.Map(),
StartsAt: strfmt.DateTime(alert.FiredAt),
Alert: alertmanagertypes.AlertModel{
Labels: alert.Labels.Map(),
GeneratorURL: strfmt.URI(generatorURL),
},
}
if !alert.ResolvedAt.IsZero() {
a.EndsAt = strfmt.DateTime(alert.ResolvedAt)
} else {
a.EndsAt = strfmt.DateTime(alert.ValidUntil)
}
res = append(res, a)
a := &alertmanagertypes.PostableAlert{
Annotations: alert.Annotations.Map(),
StartsAt: strfmt.DateTime(alert.FiredAt),
Alert: alertmanagertypes.AlertModel{
Labels: alert.Labels.Map(),
GeneratorURL: strfmt.URI(generatorURL),
},
}
if !alert.ResolvedAt.IsZero() {
a.EndsAt = strfmt.DateTime(alert.ResolvedAt)
} else {
a.EndsAt = strfmt.DateTime(alert.ValidUntil)
}
if len(alerts) > 0 {
m.alertmanager.PutAlerts(ctx, orgID, res)
}
res = append(res, a)
}
if len(alerts) > 0 {
m.alertmanager.PutAlerts(ctx, orgID, res)
}
}
func (m *Manager) prepareTestNotifyFunc() NotifyFunc {
return func(ctx context.Context, orgID string, expr string, alerts ...*ruletypes.Alert) {
if len(alerts) == 0 {
return
}
ruleID := alerts[0].Labels.Map()[ruletypes.AlertRuleIDLabel]
receiverMap := make(map[*alertmanagertypes.PostableAlert][]string)
for _, alert := range alerts {
generatorURL := alert.GeneratorURL
func (m *Manager) testNotifyFunc(ctx context.Context, orgID string, alerts ...*ruletypes.Alert) {
if len(alerts) == 0 {
return
}
ruleID := alerts[0].Labels.Map()[ruletypes.AlertRuleIDLabel]
receiverMap := make(map[*alertmanagertypes.PostableAlert][]string)
for _, alert := range alerts {
generatorURL := alert.GeneratorURL
a := &alertmanagertypes.PostableAlert{}
a.Annotations = alert.Annotations.Map()
a.StartsAt = strfmt.DateTime(alert.FiredAt)
labelsMap := alert.Labels.Map()
labelsMap[ruletypes.TestAlertLabel] = "true"
a.Alert = alertmanagertypes.AlertModel{
Labels: labelsMap,
GeneratorURL: strfmt.URI(generatorURL),
}
if !alert.ResolvedAt.IsZero() {
a.EndsAt = strfmt.DateTime(alert.ResolvedAt)
} else {
a.EndsAt = strfmt.DateTime(alert.ValidUntil)
}
receiverMap[a] = alert.Receivers
a := &alertmanagertypes.PostableAlert{}
a.Annotations = alert.Annotations.Map()
a.StartsAt = strfmt.DateTime(alert.FiredAt)
labelsMap := alert.Labels.Map()
labelsMap[ruletypes.TestAlertLabel] = "true"
a.Alert = alertmanagertypes.AlertModel{
Labels: labelsMap,
GeneratorURL: strfmt.URI(generatorURL),
}
err := m.alertmanager.TestAlert(ctx, orgID, ruleID, receiverMap)
if err != nil {
m.logger.ErrorContext(ctx, "failed to send test notification", errors.Attr(err))
return
if !alert.ResolvedAt.IsZero() {
a.EndsAt = strfmt.DateTime(alert.ResolvedAt)
} else {
a.EndsAt = strfmt.DateTime(alert.ValidUntil)
}
receiverMap[a] = alert.Receivers
}
err := m.alertmanager.TestAlert(ctx, orgID, ruleID, receiverMap)
if err != nil {
m.logger.ErrorContext(ctx, "failed to send test notification", errors.Attr(err))
}
}
@@ -1041,7 +1036,7 @@ func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleS
Logger: m.opts.Logger,
Cache: m.cache,
ManagerOpts: m.opts,
NotifyFunc: m.prepareTestNotifyFunc(),
NotifyFunc: m.testNotifyFunc,
SQLStore: m.sqlstore,
OrgID: orgID,
})

View File

@@ -48,14 +48,13 @@ func NewPromRule(
version: postableRule.Version,
prometheus: prometheus,
}
p.logger = logger
query, err := p.getPqlQuery(context.Background())
if err != nil {
// can not generate a valid prom QL query
return nil, err
}
logger.Info("creating new prom rule", slog.String("rule.id", id), slog.String("rule.query", query))
p.logger.Info("creating new prom rule", slog.String("rule.query", query))
return &p, nil
}
@@ -97,7 +96,7 @@ func (r *PromRule) buildAndRunQuery(ctx context.Context, ts time.Time) (ruletype
if err != nil {
return nil, err
}
r.logger.InfoContext(ctx, "evaluating promql query", slog.String("rule.id", r.ID()), slog.String("rule.query", q))
r.logger.InfoContext(ctx, "evaluating promql query", slog.String("rule.query", q))
res, err := r.RunAlertQuery(ctx, q, start, end, interval)
if err != nil {
r.SetHealth(ruletypes.HealthBad)
@@ -117,7 +116,7 @@ func (r *PromRule) buildAndRunQuery(ctx context.Context, ts time.Time) (ruletype
filteredSeries, filterErr := r.BaseRule.FilterNewSeries(ctx, ts, matrixToProcess)
// In case of error we log the error and continue with the original series
if filterErr != nil {
r.logger.ErrorContext(ctx, "error filtering new series", slog.String("rule.id", r.ID()), errors.Attr(filterErr))
r.logger.ErrorContext(ctx, "error filtering new series", errors.Attr(filterErr))
} else {
matrixToProcess = filteredSeries
}
@@ -129,7 +128,8 @@ func (r *PromRule) buildAndRunQuery(ctx context.Context, ts time.Time) (ruletype
if !r.Condition().ShouldEval(series) {
r.logger.InfoContext(
ctx, "not enough data points to evaluate series, skipping",
"rule.id", r.ID(), "num_points", len(series.Values), "required_points", r.Condition().RequiredNumPoints,
slog.Int("series.num_points", len(series.Values)),
slog.Int("series.required_points", r.Condition().RequiredNumPoints),
)
continue
}
@@ -173,7 +173,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
for _, lbl := range result.Metric {
l[lbl.Name] = lbl.Value
}
r.logger.DebugContext(ctx, "alerting for series", slog.String("rule.id", r.ID()), slog.Any("series", result))
r.logger.DebugContext(ctx, "alerting for series", slog.Any("series", result))
threshold := valueFormatter.Format(result.Target, result.TargetUnit)
@@ -193,7 +193,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
result, err := tmpl.Expand()
if err != nil {
result = fmt.Sprintf("<error expanding template: %s>", err)
r.logger.WarnContext(ctx, "expanding alert template failed", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.template_data", tmplData))
r.logger.WarnContext(ctx, "expanding alert template failed", errors.Attr(err), slog.Any("alert.template_data", tmplData))
}
return result
}
@@ -244,7 +244,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
}
r.logger.InfoContext(ctx, "number of alerts found", slog.String("rule.id", r.ID()), slog.Int("alert.count", len(alerts)))
r.logger.InfoContext(ctx, "number of alerts found", slog.Int("alert.count", len(alerts)))
// alerts[h] is ready, add or update active list now
for h, a := range alerts {
// Check whether we already have alerting state for the identifying label set.
@@ -271,7 +271,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
for fp, a := range r.Active {
labelsJSON, err := json.Marshal(a.QueryResultLabels)
if err != nil {
r.logger.ErrorContext(ctx, "error marshaling labels", slog.String("rule.id", r.ID()), errors.Attr(err))
r.logger.ErrorContext(ctx, "error marshaling labels", errors.Attr(err))
}
if _, ok := resultFPs[fp]; !ok {
// If the alert was previously firing, keep it around for a given
@@ -325,7 +325,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
state = ruletypes.StateFiring
}
a.State = state
r.logger.DebugContext(ctx, "converting alert state", slog.String("rule.id", r.ID()), slog.Any("alert.state", state))
r.logger.DebugContext(ctx, "converting alert state", slog.Any("alert.state", state))
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
@@ -350,7 +350,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
itemsToAdd[idx] = item
}
r.RecordRuleStateHistory(ctx, prevState, currentState, itemsToAdd)
_ = r.RecordRuleStateHistory(ctx, itemsToAdd)
return len(r.Active), nil
}

View File

@@ -41,11 +41,7 @@ type Rule interface {
SetEvaluationTimestamp(time.Time)
GetEvaluationTimestamp() time.Time
RecordRuleStateHistory(
ctx context.Context,
prevState, currentState ruletypes.AlertState,
itemsToAdd []rulestatehistorytypes.RuleStateHistory,
) error
RecordRuleStateHistory(ctx context.Context, itemsToAdd []rulestatehistorytypes.RuleStateHistory) error
SendAlerts(
ctx context.Context,

View File

@@ -2,6 +2,7 @@ package rules
import (
"context"
"runtime/debug"
"sort"
"sync"
"time"
@@ -308,7 +309,10 @@ func (g *RuleTask) Eval(ctx context.Context, ts time.Time) {
defer func() {
if r := recover(); r != nil {
g.logger.ErrorContext(ctx, "panic during threshold rule evaluation", "panic", r)
g.logger.ErrorContext(
ctx, "panic during rule evaluation", slog.Any("panic", r),
slog.String("stack", string(debug.Stack())),
)
}
}()

View File

@@ -17,7 +17,7 @@ import (
"github.com/stretchr/testify/require"
)
func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.TelemetryStore) querier.Querier {
func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.TelemetryStore) (querier.Querier, *telemetrytypestest.MockMetadataStore) {
providerSettings := instrumentationtest.New().ToProviderSettings()
metadataStore := telemetrytypestest.NewMockMetadataStore()
@@ -50,7 +50,7 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
)
), metadataStore
}
func prepareQuerierForLogs(telemetryStore telemetrystore.TelemetryStore, keysMap map[string][]*telemetrytypes.TelemetryFieldKey) querier.Querier {

View File

@@ -40,7 +40,7 @@ func NewThresholdRule(
logger *slog.Logger,
opts ...RuleOption,
) (*ThresholdRule, error) {
logger.Info("creating new ThresholdRule", "id", id)
logger.Info("creating new ThresholdRule", slog.String("rule.id", id))
opts = append(opts, WithLogger(logger))
@@ -76,7 +76,6 @@ func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*q
slog.Int64("ts", ts.UnixMilli()),
slog.Int64("eval_window", r.evalWindow.Milliseconds()),
slog.Int64("eval_delay", r.evalDelay.Milliseconds()),
slog.String("rule.id", r.ID()),
)
startTs, endTs := r.Timestamps(ts)
@@ -199,7 +198,7 @@ func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID,
results = append(results, tsData)
} else {
// NOTE: should not happen but just to ensure we don't miss it if it happens for some reason
r.logger.WarnContext(ctx, "expected qbtypes.TimeSeriesData but got unexpected type", slog.String("rule.id", r.ID()), slog.String("item.type", reflect.TypeOf(item).String()))
r.logger.WarnContext(ctx, "expected qbtypes.TimeSeriesData but got unexpected type", slog.String("item.type", reflect.TypeOf(item).String()))
}
}
@@ -225,7 +224,7 @@ func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID,
var resultVector ruletypes.Vector
if queryResult == nil || len(queryResult.Aggregations) == 0 || queryResult.Aggregations[0] == nil {
r.logger.WarnContext(ctx, "query result is nil", slog.String("rule.id", r.ID()), slog.String("query.name", selectedQuery))
r.logger.WarnContext(ctx, "query result is nil", slog.String("query.name", selectedQuery))
return resultVector, nil
}
@@ -235,7 +234,7 @@ func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID,
filteredSeries, filterErr := r.BaseRule.FilterNewSeries(ctx, ts, seriesToProcess)
// In case of error we log the error and continue with the original series
if filterErr != nil {
r.logger.ErrorContext(ctx, "error filtering new series", slog.String("rule.id", r.ID()), errors.Attr(filterErr))
r.logger.ErrorContext(ctx, "error filtering new series", errors.Attr(filterErr))
} else {
seriesToProcess = filteredSeries
}
@@ -243,7 +242,11 @@ func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID,
for _, series := range seriesToProcess {
if !r.Condition().ShouldEval(series) {
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", slog.String("rule.id", r.ID()), slog.Int("series.num_points", len(series.Values)), slog.Int("series.required_points", r.Condition().RequiredNumPoints))
r.logger.InfoContext(
ctx, "not enough data points to evaluate series, skipping",
slog.Int("series.num_points", len(series.Values)),
slog.Int("series.required_points", r.Condition().RequiredNumPoints),
)
continue
}
resultSeries, err := r.Threshold.Eval(series, r.Unit(), ruletypes.EvalData{
@@ -294,7 +297,10 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
value := valueFormatter.Format(smpl.V, r.Unit())
// todo(aniket): handle different threshold
threshold := valueFormatter.Format(smpl.Target, smpl.TargetUnit)
r.logger.DebugContext(ctx, "alert template data for rule", slog.String("rule.id", r.ID()), slog.String("formatter.name", valueFormatter.Name()), slog.String("alert.value", value), slog.String("alert.threshold", threshold))
r.logger.DebugContext(
ctx, "alert template data for rule", slog.String("formatter.name", valueFormatter.Name()),
slog.String("alert.value", value), slog.String("alert.threshold", threshold),
)
tmplData := ruletypes.AlertTemplateData(l, value, threshold)
// Inject some convenience variables that are easier to remember for users
@@ -313,7 +319,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
result, err := tmpl.Expand()
if err != nil {
result = fmt.Sprintf("<error expanding template: %s>", err)
r.logger.ErrorContext(ctx, "expanding alert template failed", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.template_data", tmplData))
r.logger.ErrorContext(ctx, "expanding alert template failed", errors.Attr(err), slog.Any("alert.template_data", tmplData))
}
return result
}
@@ -345,13 +351,13 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
case ruletypes.AlertTypeTraces:
link := r.prepareLinksToTraces(ctx, ts, smpl.Metric)
if link != "" && r.hostFromSource() != "" {
r.logger.InfoContext(ctx, "adding traces link to annotations", slog.String("rule.id", r.ID()), slog.String("annotation.link", fmt.Sprintf("%s/traces-explorer?%s", r.hostFromSource(), link)))
r.logger.InfoContext(ctx, "adding traces link to annotations", slog.String("annotation.link", fmt.Sprintf("%s/traces-explorer?%s", r.hostFromSource(), link)))
annotations = append(annotations, ruletypes.Label{Name: "related_traces", Value: fmt.Sprintf("%s/traces-explorer?%s", r.hostFromSource(), link)})
}
case ruletypes.AlertTypeLogs:
link := r.prepareLinksToLogs(ctx, ts, smpl.Metric)
if link != "" && r.hostFromSource() != "" {
r.logger.InfoContext(ctx, "adding logs link to annotations", slog.String("rule.id", r.ID()), slog.String("annotation.link", fmt.Sprintf("%s/logs/logs-explorer?%s", r.hostFromSource(), link)))
r.logger.InfoContext(ctx, "adding logs link to annotations", slog.String("annotation.link", fmt.Sprintf("%s/logs/logs-explorer?%s", r.hostFromSource(), link)))
annotations = append(annotations, ruletypes.Label{Name: "related_logs", Value: fmt.Sprintf("%s/logs/logs-explorer?%s", r.hostFromSource(), link)})
}
}
@@ -378,7 +384,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
}
r.logger.InfoContext(ctx, "number of alerts found", slog.String("rule.id", r.ID()), slog.Int("alert.count", len(alerts)))
r.logger.InfoContext(ctx, "number of alerts found", slog.Int("alert.count", len(alerts)))
// alerts[h] is ready, add or update active list now
for h, a := range alerts {
@@ -406,7 +412,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
for fp, a := range r.Active {
labelsJSON, err := json.Marshal(a.QueryResultLabels)
if err != nil {
r.logger.ErrorContext(ctx, "error marshaling labels", slog.String("rule.id", r.ID()), errors.Attr(err), slog.Any("alert.labels", a.Labels))
r.logger.ErrorContext(ctx, "error marshaling labels", errors.Attr(err), slog.Any("alert.labels", a.Labels))
}
if _, ok := resultFPs[fp]; !ok {
// If the alert was previously firing, keep it around for a given
@@ -415,7 +421,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
delete(r.Active, fp)
}
if a.State != ruletypes.StateInactive {
r.logger.DebugContext(ctx, "converting firing alert to inactive", slog.String("rule.id", r.ID()))
r.logger.DebugContext(ctx, "converting firing alert to inactive")
a.State = ruletypes.StateInactive
a.ResolvedAt = ts
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
@@ -433,7 +439,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
if a.State == ruletypes.StatePending && ts.Sub(a.ActiveAt) >= r.holdDuration.Duration() {
r.logger.DebugContext(ctx, "converting pending alert to firing", slog.String("rule.id", r.ID()))
r.logger.DebugContext(ctx, "converting pending alert to firing")
a.State = ruletypes.StateFiring
a.FiredAt = ts
state := ruletypes.StateFiring
@@ -463,7 +469,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
state = ruletypes.StateFiring
}
a.State = state
r.logger.DebugContext(ctx, "converting alert state", slog.String("rule.id", r.ID()), slog.Any("alert.state", state))
r.logger.DebugContext(ctx, "converting alert state", slog.Any("alert.state", state))
itemsToAdd = append(itemsToAdd, rulestatehistorytypes.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
@@ -486,7 +492,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
itemsToAdd[idx] = item
}
r.RecordRuleStateHistory(ctx, prevState, currentState, itemsToAdd)
_ = r.RecordRuleStateHistory(ctx, itemsToAdd)
r.health = ruletypes.HealthGood
r.lastError = err

View File

@@ -511,7 +511,8 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
}
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
querier := prepareQuerierForMetrics(t, telemetryStore)
querier, mockMetadataStore := prepareQuerierForMetrics(t, telemetryStore)
mockMetadataStore.TypeMap["signoz_calls_total"] = metrictypes.SumType
cols := []cmock.ColumnType{
{Name: "ts", Type: "DateTime"},
@@ -727,7 +728,8 @@ func TestThresholdRuleNoData(t *testing.T) {
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil).
WillReturnRows(rows)
querier := prepareQuerierForMetrics(t, telemetryStore)
querier, mockMetadataStore := prepareQuerierForMetrics(t, telemetryStore)
mockMetadataStore.TypeMap["signoz_calls_total"] = metrictypes.SumType
var target float64 = 0
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
@@ -1115,7 +1117,8 @@ func TestMultipleThresholdRule(t *testing.T) {
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil).
WillReturnRows(rows)
querier := prepareQuerierForMetrics(t, telemetryStore)
querier, mockMetadataStore := prepareQuerierForMetrics(t, telemetryStore)
mockMetadataStore.TypeMap["signoz_calls_total"] = metrictypes.SumType
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
@@ -1903,7 +1906,8 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil).
WillReturnRows(rows)
querier := prepareQuerierForMetrics(t, telemetryStore)
querier, mockMetadataStore := prepareQuerierForMetrics(t, telemetryStore)
mockMetadataStore.TypeMap["signoz_calls_total"] = metrictypes.SumType
rc := postableRule.RuleCondition
rc.Target = &c.target

View File

@@ -133,7 +133,8 @@ func (r *rule) GetStoredRulesByMetricName(ctx context.Context, orgID string, met
for _, storedRule := range storedRules {
var ruleData ruletypes.PostableRule
if err := json.Unmarshal([]byte(storedRule.Data), &ruleData); err != nil {
r.logger.WarnContext(ctx, "failed to unmarshal rule data", slog.String("rule_id", storedRule.ID.StringValue()), errors.Attr(err))
//nolint:sloglint
r.logger.WarnContext(ctx, "failed to unmarshal rule data", slog.String("rule.id", storedRule.ID.StringValue()), errors.Attr(err))
continue
}

View File

@@ -1,8 +1,10 @@
package cloudintegrationtypes
import (
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -29,16 +31,285 @@ type AccountConfig struct {
AWS *AWSAccountConfig `json:"aws" required:"true" nullable:"false"`
}
type AWSAccountConfig struct {
Regions []string `json:"regions" required:"true" nullable:"false"`
}
type PostableAccount struct {
Config *PostableAccountConfig `json:"config" required:"true"`
Credentials *Credentials `json:"credentials" required:"true"`
}
type PostableAccountConfig struct {
// as agent version is common for all providers, we can keep it at top level of this struct
AgentVersion string
Aws *AWSPostableAccountConfig `json:"aws" required:"true" nullable:"false"`
}
type Credentials struct {
SigNozAPIURL string `json:"sigNozApiUrl" required:"true"`
SigNozAPIKey string `json:"sigNozApiKey" required:"true"` // PAT
IngestionURL string `json:"ingestionUrl" required:"true"`
IngestionKey string `json:"ingestionKey" required:"true"`
}
type AWSPostableAccountConfig struct {
DeploymentRegion string `json:"deploymentRegion" required:"true"`
Regions []string `json:"regions" required:"true" nullable:"false"`
}
type GettableAccountWithConnectionArtifact struct {
ID valuer.UUID `json:"id" required:"true"`
ConnectionArtifact *ConnectionArtifact `json:"connectionArtifact" required:"true"`
}
type ConnectionArtifact struct {
// required till new providers are added
Aws *AWSConnectionArtifact `json:"aws" required:"true" nullable:"false"`
}
type AWSConnectionArtifact struct {
ConnectionURL string `json:"connectionUrl" required:"true"`
}
type GetConnectionArtifactRequest = PostableAccount
type GettableAccounts struct {
Accounts []*Account `json:"accounts" required:"true" nullable:"false"`
}
type GettableAccount = Account
type UpdatableAccount struct {
Config *AccountConfig `json:"config" required:"true" nullable:"false"`
}
type AWSAccountConfig struct {
Regions []string `json:"regions" required:"true" nullable:"false"`
func NewAccount(orgID valuer.UUID, provider CloudProviderType, config *AccountConfig) *Account {
return &Account{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
OrgID: orgID,
Provider: provider,
Config: config,
}
}
func NewAccountFromStorable(storableAccount *StorableCloudIntegration) (*Account, error) {
// config can not be empty
if storableAccount.Config == "" {
return nil, errors.NewInternalf(errors.CodeInternal, "config is empty for account with id: %s", storableAccount.ID)
}
account := &Account{
Identifiable: storableAccount.Identifiable,
TimeAuditable: storableAccount.TimeAuditable,
ProviderAccountID: storableAccount.AccountID,
Provider: storableAccount.Provider,
RemovedAt: storableAccount.RemovedAt,
OrgID: storableAccount.OrgID,
Config: new(AccountConfig),
}
switch storableAccount.Provider {
case CloudProviderTypeAWS:
awsConfig := new(AWSAccountConfig)
err := json.Unmarshal([]byte(storableAccount.Config), awsConfig)
if err != nil {
return nil, err
}
account.Config.AWS = awsConfig
}
if storableAccount.LastAgentReport != nil {
account.AgentReport = &AgentReport{
TimestampMillis: storableAccount.LastAgentReport.TimestampMillis,
Data: storableAccount.LastAgentReport.Data,
}
}
return account, nil
}
func NewAccountsFromStorables(storableAccounts []*StorableCloudIntegration) ([]*Account, error) {
accounts := make([]*Account, 0, len(storableAccounts))
for _, storableAccount := range storableAccounts {
account, err := NewAccountFromStorable(storableAccount)
if err != nil {
return nil, err
}
accounts = append(accounts, account)
}
return accounts, nil
}
func (account *Account) Update(config *AccountConfig) error {
if account.RemovedAt != nil {
return errors.New(errors.TypeUnsupported, ErrCodeCloudIntegrationRemoved, "this operation is not supported for a removed cloud integration account")
}
account.Config = config
account.UpdatedAt = time.Now()
return nil
}
func (account *Account) IsRemoved() bool {
return account.RemovedAt != nil
}
func NewAccountConfigFromPostable(provider CloudProviderType, config *PostableAccountConfig) (*AccountConfig, error) {
switch provider {
case CloudProviderTypeAWS:
if config.Aws == nil {
return nil, errors.NewInternalf(errors.CodeInternal, "AWS config is nil")
}
return &AccountConfig{
AWS: &AWSAccountConfig{
Regions: config.Aws.Regions,
},
}, nil
}
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
// func NewAccountFromPostableAccount(provider CloudProviderType, account *PostableAccount) (*Account, error) {
// req := &Account{
// Credentials: account.Credentials,
// }
// switch provider {
// case CloudProviderTypeAWS:
// req.Config = &ConnectionArtifactRequestConfig{
// Aws: &AWSConnectionArtifactRequest{
// DeploymentRegion: artifact.Config.Aws.DeploymentRegion,
// Regions: artifact.Config.Aws.Regions,
// },
// }
// return req, nil
// default:
// return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
// }
// }
func NewAgentReport(data map[string]any) *AgentReport {
return &AgentReport{
TimestampMillis: time.Now().UnixMilli(),
Data: data,
}
}
// ToJSON return JSON bytes for the provider's config
// thats why not naming it MarshalJSON(), as it will interfere with default JSON marshalling of AccountConfig struct.
// NOTE: this entertains first non-null provider's config.
func (config *AccountConfig) ToJSON() ([]byte, error) {
if config.AWS != nil {
return json.Marshal(config.AWS)
}
return nil, errors.NewInternalf(errors.CodeInternal, "no provider account config found")
}
func (config *PostableAccountConfig) AddAgentVersion(agentVersion string) {
config.AgentVersion = agentVersion
}
// Validate checks that the connection artifact request has a valid provider-specific block
// with non-empty, valid regions and a valid deployment region.
func (account *PostableAccount) Validate(provider CloudProviderType) error {
if account.Config == nil || account.Credentials == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"config and credentials are required")
}
if account.Credentials.SigNozAPIURL == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"sigNozApiURL can not be empty")
}
if account.Credentials.SigNozAPIKey == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"sigNozApiKey can not be empty")
}
if account.Credentials.IngestionURL == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"ingestionUrl can not be empty")
}
if account.Credentials.IngestionKey == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"ingestionKey can not be empty")
}
switch provider {
case CloudProviderTypeAWS:
if account.Config.Aws == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"aws configuration is required")
}
return account.Config.Aws.Validate()
}
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider)
}
// Validate checks that the AWS connection artifact request has a valid deployment region
// and a non-empty list of valid regions.
func (req *AWSPostableAccountConfig) Validate() error {
if req.DeploymentRegion == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"deploymentRegion is required")
}
if _, ok := ValidAWSRegions[req.DeploymentRegion]; !ok {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidCloudRegion,
"invalid deployment region: %s", req.DeploymentRegion)
}
if len(req.Regions) == 0 {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"at least one region is required")
}
for _, region := range req.Regions {
if _, ok := ValidAWSRegions[region]; !ok {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidCloudRegion,
"invalid AWS region: %s", region)
}
}
return nil
}
func (updatable *UpdatableAccount) Validate(provider CloudProviderType) error {
if updatable.Config == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"config is required")
}
switch provider {
case CloudProviderTypeAWS:
if updatable.Config.AWS == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"aws configuration is required")
}
if len(updatable.Config.AWS.Regions) == 0 {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"at least one region is required")
}
for _, region := range updatable.Config.AWS.Regions {
if _, ok := ValidAWSRegions[region]; !ok {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidCloudRegion,
"invalid AWS region: %s", region)
}
}
default:
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput,
"invalid cloud provider: %s", provider.StringValue())
}
return nil
}

View File

@@ -0,0 +1,93 @@
package cloudintegrationtypes
import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
type AgentCheckInRequest struct {
ProviderAccountID string `json:"providerAccountId" required:"false"`
CloudIntegrationID valuer.UUID `json:"cloudIntegrationId" required:"false"`
Data map[string]any `json:"data" required:"true" nullable:"true"`
}
type PostableAgentCheckIn struct {
AgentCheckInRequest
// following are backward compatible fields for older running agents, hence snake case JSON keys.
// Which get mapped to new fields in AgentCheckInRequest
ID string `json:"account_id" required:"false"` // => CloudIntegrationID
AccountID string `json:"cloud_account_id" required:"false"` // => ProviderAccountID
}
type AgentCheckInResponse struct {
CloudIntegrationID string `json:"cloudIntegrationId" required:"true"`
ProviderAccountID string `json:"providerAccountId" required:"true"`
IntegrationConfig *ProviderIntegrationConfig `json:"integrationConfig" required:"true"`
RemovedAt *time.Time `json:"removedAt" required:"true" nullable:"true"`
}
type GettableAgentCheckIn struct {
// Older fields for backward compatibility with existing AWS agents
AccountID string `json:"account_id" required:"true"`
CloudAccountID string `json:"cloud_account_id" required:"true"`
OlderIntegrationConfig *IntegrationConfig `json:"integration_config" required:"true" nullable:"true"`
OlderRemovedAt *time.Time `json:"removed_at" required:"true" nullable:"true"`
AgentCheckInResponse
}
// IntegrationConfig older integration config struct for backward compatibility,
// this will be eventually removed once agents are updated to use new struct.
type IntegrationConfig struct {
EnabledRegions []string `json:"enabled_regions" required:"true" nullable:"false"` // backward compatible
Telemetry *OldAWSCollectionStrategy `json:"telemetry" required:"true" nullable:"false"` // backward compatible
}
type ProviderIntegrationConfig struct {
AWS *AWSIntegrationConfig `json:"aws" required:"true" nullable:"false"`
}
type AWSIntegrationConfig struct {
EnabledRegions []string `json:"enabledRegions" required:"true" nullable:"false"`
TelemetryCollectionStrategy *AWSTelemetryCollectionStrategy `json:"telemetryCollectionStrategy" required:"true" nullable:"false"`
}
// NewGettableAgentCheckIn constructs a backward-compatible response from an AgentCheckInResponse.
// It populates the old snake_case fields (account_id, cloud_account_id, integration_config, removed_at)
// from the new camelCase fields so older agents continue to work unchanged.
// The provider parameter controls which provider-specific block is mapped into the legacy integration_config.
func NewGettableAgentCheckIn(provider CloudProviderType, resp *AgentCheckInResponse) *GettableAgentCheckIn {
gettable := &GettableAgentCheckIn{
AccountID: resp.CloudIntegrationID,
CloudAccountID: resp.ProviderAccountID,
OlderRemovedAt: resp.RemovedAt,
AgentCheckInResponse: *resp,
}
switch provider {
case CloudProviderTypeAWS:
gettable.OlderIntegrationConfig = awsOlderIntegrationConfig(resp.IntegrationConfig)
}
return gettable
}
// Validate checks that the request uses either old fields (account_id, cloud_account_id) or
// new fields (cloudIntegrationId, providerAccountId), never a mix of both.
func (req *PostableAgentCheckIn) Validate() error {
hasOldFields := req.ID != "" || req.AccountID != ""
hasNewFields := !req.CloudIntegrationID.IsZero() || req.ProviderAccountID != ""
if hasOldFields && hasNewFields {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"request must use either old fields (account_id, cloud_account_id) or new fields (cloudIntegrationId, providerAccountId), not both")
}
if !hasOldFields && !hasNewFields {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"request must provide either old fields (account_id, cloud_account_id) or new fields (cloudIntegrationId, providerAccountId)")
}
return nil
}

View File

@@ -13,10 +13,16 @@ import (
)
var (
ErrCodeUnsupported = errors.MustNewCode("cloud_integration_unsupported")
ErrCodeInvalidInput = errors.MustNewCode("cloud_integration_invalid_input")
ErrCodeCloudIntegrationNotFound = errors.MustNewCode("cloud_integration_not_found")
ErrCodeCloudIntegrationAlreadyExists = errors.MustNewCode("cloud_integration_already_exists")
ErrCodeCloudIntegrationAlreadyConnected = errors.MustNewCode("cloud_integration_already_connected")
ErrCodeCloudIntegrationInvalidConfig = errors.MustNewCode("cloud_integration_invalid_config")
ErrCodeCloudIntegrationRemoved = errors.MustNewCode("cloud_integration_removed")
ErrCodeCloudIntegrationServiceNotFound = errors.MustNewCode("cloud_integration_service_not_found")
ErrCodeCloudIntegrationServiceAlreadyExists = errors.MustNewCode("cloud_integration_service_already_exists")
ErrCodeServiceDefinitionNotFound = errors.MustNewCode("service_definition_not_found")
)
// StorableCloudIntegration represents a cloud integration stored in the database.
@@ -52,6 +58,26 @@ type StorableCloudIntegrationService struct {
CloudIntegrationID valuer.UUID `bun:"cloud_integration_id,type:text"`
}
// Following Service config types are only internally used to store service config in DB and use JSON snake case keys for backward compatibility.
type StorableServiceConfig struct {
AWS *StorableAWSServiceConfig
}
type StorableAWSServiceConfig struct {
Logs *StorableAWSLogsServiceConfig `json:"logs,omitempty"`
Metrics *StorableAWSMetricsServiceConfig `json:"metrics,omitempty"`
}
type StorableAWSLogsServiceConfig struct {
Enabled bool `json:"enabled"`
S3Buckets map[string][]string `json:"s3_buckets,omitempty"` // region -> list of buckets in that region
}
type StorableAWSMetricsServiceConfig struct {
Enabled bool `json:"enabled"`
}
// Scan scans value from DB.
func (r *StorableAgentReport) Scan(src any) error {
var data []byte
@@ -68,10 +94,6 @@ func (r *StorableAgentReport) Scan(src any) error {
// Value creates value to be stored in DB.
func (r *StorableAgentReport) Value() (driver.Value, error) {
if r == nil {
return nil, errors.NewInternalf(errors.CodeInternal, "agent report is nil")
}
serialized, err := json.Marshal(r)
if err != nil {
return nil, errors.WrapInternalf(
@@ -81,3 +103,107 @@ func (r *StorableAgentReport) Value() (driver.Value, error) {
// Return as string instead of []byte to ensure PostgreSQL stores as text, not bytes
return string(serialized), nil
}
func NewStorableCloudIntegration(account *Account) (*StorableCloudIntegration, error) {
configBytes, err := account.Config.ToJSON()
if err != nil {
return nil, err
}
storableAccount := &StorableCloudIntegration{
Identifiable: account.Identifiable,
TimeAuditable: account.TimeAuditable,
Provider: account.Provider,
Config: string(configBytes),
AccountID: account.ProviderAccountID,
OrgID: account.OrgID,
RemovedAt: account.RemovedAt,
}
if account.AgentReport != nil {
storableAccount.LastAgentReport = &StorableAgentReport{
TimestampMillis: account.AgentReport.TimestampMillis,
Data: account.AgentReport.Data,
}
}
return storableAccount, nil
}
// NewStorableCloudIntegrationService creates a new StorableCloudIntegrationService with
// generated ID and timestamps from a CloudIntegrationService and its serialized config JSON.
func NewStorableCloudIntegrationService(svc *CloudIntegrationService, configJSON string) *StorableCloudIntegrationService {
return &StorableCloudIntegrationService{
Identifiable: svc.Identifiable,
TimeAuditable: svc.TimeAuditable,
Type: svc.Type,
Config: configJSON,
CloudIntegrationID: svc.CloudIntegrationID,
}
}
func (account *StorableCloudIntegration) Update(providerAccountID *string, agentReport *AgentReport) {
account.AccountID = providerAccountID
if agentReport != nil {
account.LastAgentReport = &StorableAgentReport{
TimestampMillis: agentReport.TimestampMillis,
Data: agentReport.Data,
}
}
}
// following StorableServiceConfig related functions are helper functions to convert between JSON string and ServiceConfig domain struct.
func newStorableServiceConfig(provider CloudProviderType, serviceID ServiceID, serviceConfig *ServiceConfig, supportedSignals *SupportedSignals) *StorableServiceConfig {
switch provider {
case CloudProviderTypeAWS:
storableAWSServiceConfig := new(StorableAWSServiceConfig)
if supportedSignals.Logs {
storableAWSServiceConfig.Logs = &StorableAWSLogsServiceConfig{
Enabled: serviceConfig.AWS.Logs.Enabled,
}
if serviceID == AWSServiceS3Sync {
storableAWSServiceConfig.Logs.S3Buckets = serviceConfig.AWS.Logs.S3Buckets
}
}
if supportedSignals.Metrics {
storableAWSServiceConfig.Metrics = &StorableAWSMetricsServiceConfig{
Enabled: serviceConfig.AWS.Metrics.Enabled,
}
}
return &StorableServiceConfig{AWS: storableAWSServiceConfig}
default:
return nil
}
}
func newStorableServiceConfigFromJSON(provider CloudProviderType, jsonStr string) (*StorableServiceConfig, error) {
switch provider {
case CloudProviderTypeAWS:
awsConfig := new(StorableAWSServiceConfig)
err := json.Unmarshal([]byte(jsonStr), awsConfig)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't parse AWS service config JSON")
}
return &StorableServiceConfig{AWS: awsConfig}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
}
func (config *StorableServiceConfig) toJSON(provider CloudProviderType) ([]byte, error) {
switch provider {
case CloudProviderTypeAWS:
jsonBytes, err := json.Marshal(config.AWS)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't serialize AWS service config to JSON")
}
return jsonBytes, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
}

View File

@@ -1,6 +1,8 @@
package cloudintegrationtypes
import (
"fmt"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -14,10 +16,14 @@ var (
CloudProviderTypeAzure = CloudProviderType{valuer.NewString("azure")}
// errors.
ErrCodeCloudProviderInvalidInput = errors.MustNewCode("invalid_cloud_provider")
ErrCodeCloudProviderInvalidInput = errors.MustNewCode("cloud_integration_invalid_cloud_provider")
AWSIntegrationUserEmail = valuer.MustNewEmail("aws-integration@signoz.io")
AzureIntegrationUserEmail = valuer.MustNewEmail("azure-integration@signoz.io")
CloudFormationQuickCreateBaseURL = valuer.NewString("https://%s.console.aws.amazon.com/cloudformation/home")
AgentCloudFormationTemplateS3Path = valuer.NewString("https://signoz-integrations.s3.us-east-1.amazonaws.com/aws-quickcreate-template-%s.json")
AgentCloudFormationBaseStackName = valuer.NewString("signoz-integration")
)
// CloudIntegrationUserEmails is the list of valid emails for Cloud One Click integrations.
@@ -39,3 +45,18 @@ func NewCloudProvider(provider string) (CloudProviderType, error) {
return CloudProviderType{}, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider)
}
}
func GetCloudProviderEmail(provider CloudProviderType) (valuer.Email, error) {
switch provider {
case CloudProviderTypeAWS:
return AWSIntegrationUserEmail, nil
case CloudProviderTypeAzure:
return AzureIntegrationUserEmail, nil
default:
return valuer.Email{}, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
}
func NewIngestionKeyName(provider CloudProviderType) string {
return fmt.Sprintf("%s-integration", provider.StringValue())
}

View File

@@ -1,81 +0,0 @@
package cloudintegrationtypes
import (
"time"
"github.com/SigNoz/signoz/pkg/valuer"
)
type ConnectionArtifactRequest struct {
// required till new providers are added
Aws *AWSConnectionArtifactRequest `json:"aws" required:"true" nullable:"false"`
}
type AWSConnectionArtifactRequest struct {
DeploymentRegion string `json:"deploymentRegion" required:"true"`
Regions []string `json:"regions" required:"true" nullable:"false"`
}
type PostableConnectionArtifact = ConnectionArtifactRequest
type ConnectionArtifact struct {
// required till new providers are added
Aws *AWSConnectionArtifact `json:"aws" required:"true" nullable:"false"`
}
type AWSConnectionArtifact struct {
ConnectionURL string `json:"connectionURL" required:"true"`
}
type GettableAccountWithArtifact struct {
ID valuer.UUID `json:"id" required:"true"`
Artifact *ConnectionArtifact `json:"connectionArtifact" required:"true"`
}
type AgentCheckInRequest struct {
ProviderAccountID string `json:"providerAccountId" required:"false"`
CloudIntegrationID string `json:"cloudIntegrationId" required:"false"`
Data map[string]any `json:"data" required:"true" nullable:"true"`
}
type PostableAgentCheckInRequest struct {
AgentCheckInRequest
// following are backward compatible fields for older running agents
// which gets mapped to new fields in AgentCheckInRequest
ID string `json:"account_id" required:"false"` // => CloudIntegrationID
AccountID string `json:"cloud_account_id" required:"false"` // => ProviderAccountID
}
type AgentCheckInResponse struct {
CloudIntegrationID string `json:"cloudIntegrationId" required:"true"`
ProviderAccountID string `json:"providerAccountId" required:"true"`
IntegrationConfig *ProviderIntegrationConfig `json:"integrationConfig" required:"true"`
RemovedAt *time.Time `json:"removedAt" required:"true" nullable:"true"`
}
type GettableAgentCheckInResponse struct {
// Older fields for backward compatibility with existing AWS agents
AccountID string `json:"account_id" required:"true"`
CloudAccountID string `json:"cloud_account_id" required:"true"`
OlderIntegrationConfig *IntegrationConfig `json:"integration_config" required:"true" nullable:"true"`
OlderRemovedAt *time.Time `json:"removed_at" required:"true" nullable:"true"`
AgentCheckInResponse
}
// IntegrationConfig older integration config struct for backward compatibility,
// this will be eventually removed once agents are updated to use new struct.
type IntegrationConfig struct {
EnabledRegions []string `json:"enabled_regions" required:"true" nullable:"false"` // backward compatible
Telemetry *AWSCollectionStrategy `json:"telemetry" required:"true" nullable:"false"` // backward compatible
}
type ProviderIntegrationConfig struct {
AWS *AWSIntegrationConfig `json:"aws" required:"true" nullable:"false"`
}
type AWSIntegrationConfig struct {
EnabledRegions []string `json:"enabledRegions" required:"true" nullable:"false"`
Telemetry *AWSCollectionStrategy `json:"telemetry" required:"true" nullable:"false"`
}

View File

@@ -4,10 +4,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
)
var (
ErrCodeInvalidCloudRegion = errors.MustNewCode("invalid_cloud_region")
ErrCodeMismatchCloudProvider = errors.MustNewCode("cloud_provider_mismatch")
)
var ErrCodeInvalidCloudRegion = errors.MustNewCode("invalid_cloud_region")
// List of all valid cloud regions on Amazon Web Services.
var ValidAWSRegions = map[string]struct{}{

View File

@@ -2,6 +2,7 @@ package cloudintegrationtypes
import (
"fmt"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -25,6 +26,22 @@ type ServiceConfig struct {
AWS *AWSServiceConfig `json:"aws" required:"true" nullable:"false"`
}
type AWSServiceConfig struct {
Logs *AWSServiceLogsConfig `json:"logs"`
Metrics *AWSServiceMetricsConfig `json:"metrics"`
}
// AWSServiceLogsConfig is AWS specific logs config for a service
// NOTE: the JSON keys are snake case for backward compatibility with existing agents.
type AWSServiceLogsConfig struct {
Enabled bool `json:"enabled"`
S3Buckets map[string][]string `json:"s3Buckets,omitempty"`
}
type AWSServiceMetricsConfig struct {
Enabled bool `json:"enabled"`
}
// ServiceMetadata helps to quickly list available services and whether it is enabled or not.
// As getting complete service definition is a heavy operation and the response is also large,
// initial integration page load can be very slow.
@@ -45,24 +62,24 @@ type GettableServicesMetadata struct {
Services []*ServiceMetadata `json:"services" required:"true" nullable:"false"`
}
// Service represents a cloud integration service with its definition,
// cloud integration service is non nil only when the service entry exists in DB with ANY config (enabled or disabled).
type Service struct {
ServiceDefinition
ServiceConfig *ServiceConfig `json:"serviceConfig" required:"false" nullable:"false"`
CloudIntegrationService *CloudIntegrationService `json:"cloudIntegrationService" required:"true" nullable:"true"`
}
type GettableService = Service
type UpdatableService struct {
Config *ServiceConfig `json:"config" required:"true" nullable:"false"`
}
type ServiceDefinition struct {
ServiceDefinitionMetadata
Overview string `json:"overview" required:"true"` // markdown
Assets Assets `json:"assets" required:"true"`
SupportedSignals SupportedSignals `json:"supported_signals" required:"true"`
DataCollected DataCollected `json:"dataCollected" required:"true"`
Strategy *CollectionStrategy `json:"telemetryCollectionStrategy" required:"true" nullable:"false"`
Overview string `json:"overview" required:"true"` // markdown
Assets Assets `json:"assets" required:"true"`
SupportedSignals SupportedSignals `json:"supportedSignals" required:"true"`
DataCollected DataCollected `json:"dataCollected" required:"true"`
TelemetryCollectionStrategy *TelemetryCollectionStrategy `json:"telemetryCollectionStrategy" required:"true" nullable:"false"`
}
// SupportedSignals for cloud provider's service.
@@ -77,26 +94,10 @@ type DataCollected struct {
Metrics []CollectedMetric `json:"metrics"`
}
// CollectionStrategy is cloud provider specific configuration for signal collection,
// TelemetryCollectionStrategy is cloud provider specific configuration for signal collection,
// this is used by agent to understand the nitty-gritty for collecting telemetry for the cloud provider.
type CollectionStrategy struct {
AWS *AWSCollectionStrategy `json:"aws" required:"true" nullable:"false"`
}
type AWSServiceConfig struct {
Logs *AWSServiceLogsConfig `json:"logs"`
Metrics *AWSServiceMetricsConfig `json:"metrics"`
}
// AWSServiceLogsConfig is AWS specific logs config for a service
// NOTE: the JSON keys are snake case for backward compatibility with existing agents.
type AWSServiceLogsConfig struct {
Enabled bool `json:"enabled"`
S3Buckets map[string][]string `json:"s3_buckets,omitempty"`
}
type AWSServiceMetricsConfig struct {
Enabled bool `json:"enabled"`
type TelemetryCollectionStrategy struct {
AWS *AWSTelemetryCollectionStrategy `json:"aws" required:"true" nullable:"false"`
}
// Assets represents the collection of dashboards.
@@ -120,46 +121,65 @@ type CollectedMetric struct {
Description string `json:"description"`
}
// AWSCollectionStrategy represents signal collection strategy for AWS services.
// this is AWS specific.
// NOTE: this structure is still using snake case, for backward compatibility,
// with existing agents.
type AWSCollectionStrategy struct {
Metrics *AWSMetricsStrategy `json:"aws_metrics,omitempty"`
Logs *AWSLogsStrategy `json:"aws_logs,omitempty"`
S3Buckets map[string][]string `json:"s3_buckets,omitempty"` // Only available in S3 Sync Service Type in AWS
// OldAWSCollectionStrategy is the backward-compatible snake_case form of AWSCollectionStrategy,
// used in the legacy integration_config response field for older agents.
type OldAWSCollectionStrategy struct {
Provider string `json:"provider"`
Metrics *OldAWSMetricsStrategy `json:"aws_metrics,omitempty"`
Logs *OldAWSLogsStrategy `json:"aws_logs,omitempty"`
S3Buckets map[string][]string `json:"s3_buckets,omitempty"`
}
// AWSMetricsStrategy represents metrics collection strategy for AWS services.
// this is AWS specific.
// NOTE: this structure is still using snake case, for backward compatibility,
// with existing agents.
type AWSMetricsStrategy struct {
// to be used as https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudwatch-metricstream.html#cfn-cloudwatch-metricstream-includefilters
// OldAWSMetricsStrategy is the snake_case form of AWSMetricsStrategy for older agents.
type OldAWSMetricsStrategy struct {
StreamFilters []struct {
// json tags here are in the shape expected by AWS API as detailed at
// https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-cloudwatch-metricstream-metricstreamfilter.html
Namespace string `json:"Namespace"`
MetricNames []string `json:"MetricNames,omitempty"`
} `json:"cloudwatch_metric_stream_filters"`
}
// AWSLogsStrategy represents logs collection strategy for AWS services.
// this is AWS specific.
// NOTE: this structure is still using snake case, for backward compatibility,
// with existing agents.
type AWSLogsStrategy struct {
// OldAWSLogsStrategy is the snake_case form of AWSLogsStrategy for older agents.
type OldAWSLogsStrategy struct {
Subscriptions []struct {
// subscribe to all logs groups with specified prefix.
// eg: `/aws/rds/`
LogGroupNamePrefix string `json:"log_group_name_prefix"`
// https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html
// "" implies no filtering is required.
FilterPattern string `json:"filter_pattern"`
FilterPattern string `json:"filter_pattern"`
} `json:"cloudwatch_logs_subscriptions"`
}
// AWSTelemetryCollectionStrategy represents signal collection strategy for AWS services.
type AWSTelemetryCollectionStrategy struct {
Metrics *AWSMetricsCollectionStrategy `json:"metrics,omitempty" required:"false" nullable:"false"`
Logs *AWSLogsCollectionStrategy `json:"logs,omitempty" required:"false" nullable:"false"`
S3Buckets map[string][]string `json:"s3Buckets,omitempty" required:"false"` // Only available in S3 Sync Service Type in AWS
}
// AWSMetricsCollectionStrategy represents metrics collection strategy for AWS services.
type AWSMetricsCollectionStrategy struct {
// to be used as https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudwatch-metricstream.html#cfn-cloudwatch-metricstream-includefilters
StreamFilters []*AWSCloudWatchMetricStreamFilter `json:"streamFilters" required:"true" nullable:"false"`
}
type AWSCloudWatchMetricStreamFilter struct {
// https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-cloudwatch-metricstream-metricstreamfilter.html
Namespace string `json:"namespace" required:"true"`
MetricNames []string `json:"metricNames,omitempty" required:"false" nullable:"false"`
}
// AWSLogsCollectionStrategy represents logs collection strategy for AWS services.
type AWSLogsCollectionStrategy struct {
Subscriptions []*AWSCloudWatchLogsSubscription `json:"subscriptions" required:"true" nullable:"false"`
}
type AWSCloudWatchLogsSubscription struct {
// subscribe to all logs groups with specified prefix.
// eg: `/aws/rds/`
LogGroupNamePrefix string `json:"logGroupNamePrefix" required:"true"`
// https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html
// "" implies no filtering is required
FilterPattern string `json:"filterPattern" required:"true"`
}
// Dashboard represents a dashboard definition for cloud integration.
// This is used to show available pre-made dashboards for a service,
// hence has additional fields like id, title and description.
@@ -170,12 +190,156 @@ type Dashboard struct {
Definition dashboardtypes.StorableDashboardData `json:"definition,omitempty"`
}
// UTILS
func NewCloudIntegrationService(serviceID ServiceID, cloudIntegrationID valuer.UUID, config *ServiceConfig) *CloudIntegrationService {
return &CloudIntegrationService{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Type: serviceID,
Config: config,
CloudIntegrationID: cloudIntegrationID,
}
}
func NewCloudIntegrationServiceFromStorable(stored *StorableCloudIntegrationService, config *ServiceConfig) *CloudIntegrationService {
return &CloudIntegrationService{
Identifiable: stored.Identifiable,
TimeAuditable: stored.TimeAuditable,
Type: stored.Type,
Config: config,
CloudIntegrationID: stored.CloudIntegrationID,
}
}
func NewServiceMetadata(definition ServiceDefinition, enabled bool) *ServiceMetadata {
return &ServiceMetadata{
ServiceDefinitionMetadata: definition.ServiceDefinitionMetadata,
Enabled: enabled,
}
}
func NewService(def ServiceDefinition, storableService *CloudIntegrationService) *Service {
return &Service{
ServiceDefinition: def,
CloudIntegrationService: storableService,
}
}
func NewServiceConfigFromJSON(provider CloudProviderType, jsonString string) (*ServiceConfig, error) {
storableServiceConfig, err := newStorableServiceConfigFromJSON(provider, jsonString)
if err != nil {
return nil, err
}
switch provider {
case CloudProviderTypeAWS:
awsServiceConfig := new(AWSServiceConfig)
if storableServiceConfig.AWS.Logs != nil {
awsServiceConfig.Logs = &AWSServiceLogsConfig{
Enabled: storableServiceConfig.AWS.Logs.Enabled,
S3Buckets: storableServiceConfig.AWS.Logs.S3Buckets,
}
}
if storableServiceConfig.AWS.Metrics != nil {
awsServiceConfig.Metrics = &AWSServiceMetricsConfig{
Enabled: storableServiceConfig.AWS.Metrics.Enabled,
}
}
return &ServiceConfig{AWS: awsServiceConfig}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
}
// Update sets the service config.
func (service *CloudIntegrationService) Update(config *ServiceConfig) {
service.Config = config
service.UpdatedAt = time.Now()
}
// IsServiceEnabled returns true if the service has at least one signal (logs or metrics) enabled
// for the given cloud provider.
func (config *ServiceConfig) IsServiceEnabled(provider CloudProviderType) bool {
switch provider {
case CloudProviderTypeAWS:
logsEnabled := config.AWS.Logs != nil && config.AWS.Logs.Enabled
metricsEnabled := config.AWS.Metrics != nil && config.AWS.Metrics.Enabled
return logsEnabled || metricsEnabled
default:
return false
}
}
// IsMetricsEnabled returns true if metrics are explicitly enabled for the given cloud provider.
// Used to gate dashboard availability — dashboards are only shown when metrics are enabled.
func (config *ServiceConfig) IsMetricsEnabled(provider CloudProviderType) bool {
switch provider {
case CloudProviderTypeAWS:
return config.AWS.Metrics != nil && config.AWS.Metrics.Enabled
default:
return false
}
}
// IsLogsEnabled returns true if logs are explicitly enabled for the given cloud provider.
func (config *ServiceConfig) IsLogsEnabled(provider CloudProviderType) bool {
switch provider {
case CloudProviderTypeAWS:
return config.AWS.Logs != nil && config.AWS.Logs.Enabled
default:
return false
}
}
func (config *ServiceConfig) ToJSON(provider CloudProviderType, serviceID ServiceID, supportedSignals *SupportedSignals) ([]byte, error) {
storableServiceConfig := newStorableServiceConfig(provider, serviceID, config, supportedSignals)
return storableServiceConfig.toJSON(provider)
}
func (updatableService *UpdatableService) Validate(provider CloudProviderType, serviceID ServiceID) error {
switch provider {
case CloudProviderTypeAWS:
if updatableService.Config.AWS == nil {
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "AWS config is required for AWS service")
}
if serviceID == AWSServiceS3Sync {
if updatableService.Config.AWS.Logs == nil || updatableService.Config.AWS.Logs.S3Buckets == nil {
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "AWS S3 Sync service requires S3 bucket configuration for logs")
}
}
return nil
default:
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
}
// GetCloudIntegrationDashboardID returns the dashboard id for a cloud integration, given the cloud provider, service id, and dashboard id.
// This is used to generate unique dashboard ids for cloud integration, and also to parse the dashboard id to get the cloud provider and service id when needed.
func GetCloudIntegrationDashboardID(cloudProvider CloudProviderType, svcID, dashboardID string) string {
return fmt.Sprintf("cloud-integration--%s--%s--%s", cloudProvider, svcID, dashboardID)
return fmt.Sprintf("cloud-integration--%s--%s--%s", cloudProvider.StringValue(), svcID, dashboardID)
}
// ParseCloudIntegrationDashboardID parses a dashboard id generated by GetCloudIntegrationDashboardID
// into its constituent parts (cloudProvider, serviceID, dashboardID).
func ParseCloudIntegrationDashboardID(id string) (CloudProviderType, string, string, error) {
parts := strings.SplitN(id, "--", 4)
if len(parts) != 4 || parts[0] != "cloud-integration" {
return CloudProviderType{}, "", "", errors.New(errors.TypeNotFound, ErrCodeCloudIntegrationNotFound, "invalid cloud integration dashboard id")
}
provider, err := NewCloudProvider(parts[1])
if err != nil {
return CloudProviderType{}, "", "", err
}
return provider, parts[2], parts[3], nil
}
// GetDashboardsFromAssets returns the list of dashboards for the cloud provider service from definition.
@@ -189,9 +353,9 @@ func GetDashboardsFromAssets(
dashboards := make([]*dashboardtypes.Dashboard, 0)
for _, d := range assets.Dashboards {
author := fmt.Sprintf("%s-integration", cloudProvider)
author := fmt.Sprintf("%s-integration", cloudProvider.StringValue())
dashboards = append(dashboards, &dashboardtypes.Dashboard{
ID: GetCloudIntegrationDashboardID(cloudProvider, svcID, d.ID),
ID: d.ID,
Locked: true,
OrgID: orgID,
Data: d.Definition,
@@ -208,3 +372,53 @@ func GetDashboardsFromAssets(
return dashboards
}
// awsOlderIntegrationConfig converts a ProviderIntegrationConfig into the legacy snake_case
// IntegrationConfig format consumed by older AWS agents. Returns nil if AWS config is absent.
func awsOlderIntegrationConfig(cfg *ProviderIntegrationConfig) *IntegrationConfig {
if cfg == nil || cfg.AWS == nil {
return nil
}
awsCfg := cfg.AWS
older := &IntegrationConfig{
EnabledRegions: awsCfg.EnabledRegions,
}
if awsCfg.TelemetryCollectionStrategy == nil {
return older
}
// Older agents expect a "provider" field and fully snake_case keys inside telemetry.
oldTelemetry := &OldAWSCollectionStrategy{
Provider: CloudProviderTypeAWS.StringValue(),
S3Buckets: awsCfg.TelemetryCollectionStrategy.S3Buckets,
}
if awsCfg.TelemetryCollectionStrategy.Metrics != nil {
// Convert camelCase cloudwatchMetricStreamFilters → snake_case cloudwatch_metric_stream_filters
oldMetrics := &OldAWSMetricsStrategy{}
for _, f := range awsCfg.TelemetryCollectionStrategy.Metrics.StreamFilters {
oldMetrics.StreamFilters = append(oldMetrics.StreamFilters, struct {
Namespace string `json:"Namespace"`
MetricNames []string `json:"MetricNames,omitempty"`
}{Namespace: f.Namespace, MetricNames: f.MetricNames})
}
oldTelemetry.Metrics = oldMetrics
}
if awsCfg.TelemetryCollectionStrategy.Logs != nil {
// Convert camelCase cloudwatchLogsSubscriptions → snake_case cloudwatch_logs_subscriptions
oldLogs := &OldAWSLogsStrategy{}
for _, s := range awsCfg.TelemetryCollectionStrategy.Logs.Subscriptions {
oldLogs.Subscriptions = append(oldLogs.Subscriptions, struct {
LogGroupNamePrefix string `json:"log_group_name_prefix"`
FilterPattern string `json:"filter_pattern"`
}{LogGroupNamePrefix: s.LogGroupNamePrefix, FilterPattern: s.FilterPattern})
}
oldTelemetry.Logs = oldLogs
}
older.Telemetry = oldTelemetry
return older
}

View File

@@ -38,6 +38,8 @@ type Store interface {
// UpdateService updates an existing cloud integration service
UpdateService(ctx context.Context, service *StorableCloudIntegrationService) error
RunInTx(context.Context, func(ctx context.Context) error) error
}
type ServiceDefinitionStore interface {

View File

@@ -22,7 +22,6 @@ pytest_plugins = [
"fixtures.notification_channel",
"fixtures.alerts",
"fixtures.cloudintegrations",
"fixtures.jsontypeexporter",
]

View File

@@ -1,421 +0,0 @@
"""
Simpler version of jsontypeexporter for test fixtures.
This exports JSON type metadata to the path_types table by parsing JSON bodies
and extracting all paths with their types, similar to how the real jsontypeexporter works.
"""
import datetime
import json
from abc import ABC
from typing import TYPE_CHECKING, Any, Callable, Dict, Generator, List, Optional, Set, Union
import numpy as np
import pytest
from fixtures import types
if TYPE_CHECKING:
from fixtures.logs import Logs
class JSONPathType(ABC):
"""Represents a JSON path with its type information"""
path: str
type: str
last_seen: np.uint64
def __init__(
self,
path: str,
type: str, # pylint: disable=redefined-builtin
last_seen: Optional[datetime.datetime] = None,
) -> None:
self.path = path
self.type = type
if last_seen is None:
last_seen = datetime.datetime.now()
self.last_seen = np.uint64(int(last_seen.timestamp() * 1e9))
def np_arr(self) -> np.array:
"""Return path type data as numpy array for database insertion"""
return np.array([self.path, self.type, self.last_seen])
# Constants matching jsontypeexporter
ARRAY_SEPARATOR = "[]." # Used in paths like "education[].name"
ARRAY_SUFFIX = "[]" # Used when traversing into array element objects
def _infer_array_type_from_type_strings(types: List[str]) -> Optional[str]:
"""
Infer array type from a list of pre-classified type strings.
Matches jsontypeexporter's inferArrayMask logic (v0.144.2+).
Type strings are: "JSON", "String", "Bool", "Float64", "Int64"
SuperTyping rules (matching Go inferArrayMask):
- JSON alone → Array(JSON)
- JSON + any primitive → Array(Dynamic)
- String alone → Array(Nullable(String)); String + other → Array(Dynamic)
- Float64 wins over Int64 and Bool
- Int64 wins over Bool
- Bool alone → Array(Nullable(Bool))
"""
if len(types) == 0:
return None
unique = set(types)
has_json = "JSON" in unique
# hasPrimitive mirrors Go: (hasJSON && len(unique) > 1) || (!hasJSON && len(unique) > 0)
has_primitive = (has_json and len(unique) > 1) or (not has_json and len(unique) > 0)
if has_json:
if not has_primitive:
return "Array(JSON)"
return "Array(Dynamic)"
# ---- Primitive Type Resolution (Float > Int > Bool) ----
if "String" in unique:
if len(unique) > 1:
return "Array(Dynamic)"
return "Array(Nullable(String))"
if "Float64" in unique:
return "Array(Nullable(Float64))"
if "Int64" in unique:
return "Array(Nullable(Int64))"
if "Bool" in unique:
return "Array(Nullable(Bool))"
return "Array(Dynamic)"
def _infer_array_type(elements: List[Any]) -> Optional[str]:
"""
Infer array type from raw Python list elements.
Classifies each element then delegates to _infer_array_type_from_type_strings.
"""
if len(elements) == 0:
return None
types = []
for elem in elements:
if elem is None:
continue
if isinstance(elem, dict):
types.append("JSON")
elif isinstance(elem, str):
types.append("String")
elif isinstance(elem, bool): # must be before int (bool is subclass of int)
types.append("Bool")
elif isinstance(elem, float):
types.append("Float64")
elif isinstance(elem, int):
types.append("Int64")
return _infer_array_type_from_type_strings(types)
def _python_type_to_clickhouse_type(value: Any) -> str:
"""
Convert Python type to ClickHouse JSON type string.
Maps Python types to ClickHouse JSON data types.
"""
if value is None:
return "String" # Default for null values
if isinstance(value, bool):
return "Bool"
elif isinstance(value, int):
return "Int64"
elif isinstance(value, float):
return "Float64"
elif isinstance(value, str):
return "String"
elif isinstance(value, list):
# Use the sophisticated array type inference
array_type = _infer_array_type(value)
return array_type if array_type else "Array(Dynamic)"
elif isinstance(value, dict):
return "JSON"
else:
return "String" # Default fallback
def _extract_json_paths(
obj: Any,
current_path: str = "",
path_types: Optional[Dict[str, Set[str]]] = None,
level: int = 0,
) -> Dict[str, Set[str]]:
"""
Recursively extract all paths and their types from a JSON object.
Matches jsontypeexporter's analyzePValue logic.
Args:
obj: The JSON object to traverse
current_path: Current path being built (e.g., "user.name")
path_types: Dictionary mapping paths to sets of types found
level: Current nesting level (for depth limiting)
Returns:
Dictionary mapping paths to sets of type strings
"""
if path_types is None:
path_types = {}
if obj is None:
if current_path:
if current_path not in path_types:
path_types[current_path] = set()
path_types[current_path].add("String") # Null defaults to String
return path_types
if isinstance(obj, dict):
# For objects, add the object itself and recurse into keys
if current_path:
if current_path not in path_types:
path_types[current_path] = set()
path_types[current_path].add("JSON")
for key, value in obj.items():
# Build the path for this key
if current_path:
new_path = f"{current_path}.{key}"
else:
new_path = key
# Recurse into the value
_extract_json_paths(value, new_path, path_types, level + 1)
elif isinstance(obj, list):
# Skip empty arrays
if len(obj) == 0:
return path_types
# Collect types from array elements (matching Go: types := make([]pcommon.ValueType, 0, s.Len()))
types = []
for item in obj:
if isinstance(item, dict):
# When traversing into array element objects, use ArraySuffix ([])
# This matches: prefix+ArraySuffix in the Go code
# Example: if current_path is "education", we use "education[]" to traverse into objects
array_prefix = current_path + ARRAY_SUFFIX if current_path else ""
for key, value in item.items():
if array_prefix:
# Use array separator: education[].name
array_path = f"{array_prefix}.{key}"
else:
array_path = key
# Recurse without increasing level (matching Go behavior)
_extract_json_paths(value, array_path, path_types, level)
types.append("JSON")
elif isinstance(item, list):
# Arrays inside arrays are not supported - skip the whole path
# Matching Go: e.logger.Error("arrays inside arrays are not supported!", ...); return nil
return path_types
elif isinstance(item, str):
types.append("String")
elif isinstance(item, bool):
types.append("Bool")
elif isinstance(item, float):
types.append("Float64")
elif isinstance(item, int):
types.append("Int64")
# Infer array type from collected types (matching Go: if mask := inferArrayMask(types); mask != 0)
if len(types) > 0:
array_type = _infer_array_type_from_type_strings(types)
if array_type and current_path:
if current_path not in path_types:
path_types[current_path] = set()
path_types[current_path].add(array_type)
else:
# Primitive value (string, number, bool)
if current_path:
if current_path not in path_types:
path_types[current_path] = set()
obj_type = _python_type_to_clickhouse_type(obj)
path_types[current_path].add(obj_type)
return path_types
def _parse_json_bodies_and_extract_paths(
json_bodies: List[str],
timestamp: Optional[datetime.datetime] = None,
) -> List[JSONPathType]:
"""
Parse JSON bodies and extract all paths with their types.
This mimics the behavior of jsontypeexporter.
Args:
json_bodies: List of JSON body strings to parse
timestamp: Timestamp to use for last_seen (defaults to now)
Returns:
List of JSONPathType objects with all discovered paths and types
"""
if timestamp is None:
timestamp = datetime.datetime.now()
# Aggregate all paths and their types across all JSON bodies
all_path_types: Dict[str, Set[str]] = {}
for json_body in json_bodies:
try:
parsed = json.loads(json_body)
_extract_json_paths(parsed, "", all_path_types, level=0)
except (json.JSONDecodeError, TypeError):
# Skip invalid JSON
continue
# Convert to list of JSONPathType objects
# Each path can have multiple types, so we create one JSONPathType per type
path_type_objects: List[JSONPathType] = []
for path, types_set in all_path_types.items():
for type_str in types_set:
path_type_objects.append(
JSONPathType(path=path, type=type_str, last_seen=timestamp)
)
return path_type_objects
@pytest.fixture(name="export_json_types", scope="function")
def export_json_types(
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest, # To access migrator fixture
) -> Generator[Callable[[Union[List[JSONPathType], List[str], List[Any]]], None], Any, None]:
"""
Fixture for exporting JSON type metadata to the path_types table.
This is a simpler version of jsontypeexporter for test fixtures.
The function can accept:
1. List of JSONPathType objects (manual specification)
2. List of JSON body strings (auto-extract paths)
3. List of Logs objects (extract from body_json field)
Usage examples:
# Manual specification
export_json_types([
JSONPathType(path="user.name", type="String"),
JSONPathType(path="user.age", type="Int64"),
])
# Auto-extract from JSON strings
export_json_types([
'{"user": {"name": "alice", "age": 25}}',
'{"user": {"name": "bob", "age": 30}}',
])
# Auto-extract from Logs objects
export_json_types(logs_list)
"""
# Ensure migrator has run to create the table
try:
request.getfixturevalue("migrator")
except Exception:
# If migrator fixture is not available, that's okay - table might already exist
pass
def _export_json_types(
data: Union[List[JSONPathType], List[str], List[Any]], # List[Logs] but avoiding circular import
) -> None:
"""
Export JSON type metadata to signoz_metadata.distributed_json_path_types table.
This table stores path and type information for body JSON fields.
"""
path_types: List[JSONPathType] = []
if len(data) == 0:
return
# Determine input type and convert to JSONPathType list
first_item = data[0]
if isinstance(first_item, JSONPathType):
# Already JSONPathType objects
path_types = data # type: ignore
elif isinstance(first_item, str):
# List of JSON strings - parse and extract paths
path_types = _parse_json_bodies_and_extract_paths(data) # type: ignore
else:
# Assume it's a list of Logs objects - extract body_v2
json_bodies: List[str] = []
for log in data: # type: ignore
# Try to get body_v2 attribute
if hasattr(log, "body_v2") and log.body_v2:
json_bodies.append(log.body_v2)
elif hasattr(log, "body") and log.body:
# Fallback to body if body_v2 not available
try:
# Try to parse as JSON
json.loads(log.body)
json_bodies.append(log.body)
except (json.JSONDecodeError, TypeError):
pass
if json_bodies:
path_types = _parse_json_bodies_and_extract_paths(json_bodies)
if len(path_types) == 0:
return
clickhouse.conn.insert(
database="signoz_metadata",
table="distributed_json_path_types",
data=[path_type.np_arr() for path_type in path_types],
column_names=[
"path",
"type",
"last_seen",
],
)
yield _export_json_types
# Cleanup - truncate the local table after tests (following pattern from logs fixture)
clickhouse.conn.query(
f"TRUNCATE TABLE signoz_metadata.json_path_types ON CLUSTER '{clickhouse.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER']}' SYNC"
)
@pytest.fixture(name="export_promoted_paths", scope="function")
def export_promoted_paths(
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest, # To access migrator fixture
) -> Generator[Callable[[List[str]], None], Any, None]:
"""
Fixture for exporting promoted JSON paths to the promoted paths table.
"""
# Ensure migrator has run to create the table
try:
request.getfixturevalue("migrator")
except Exception:
# If migrator fixture is not available, that's okay - table might already exist
pass
def _export_promoted_paths(paths: List[str]) -> None:
if len(paths) == 0:
return
now_ms = int(datetime.datetime.now().timestamp() * 1000)
rows = [(path, now_ms) for path in paths]
clickhouse.conn.insert(
database="signoz_metadata",
table="distributed_json_promoted_paths",
data=rows,
column_names=[
"path",
"created_at",
],
)
yield _export_promoted_paths
clickhouse.conn.query(
f"TRUNCATE TABLE signoz_metadata.json_promoted_paths ON CLUSTER '{clickhouse.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER']}' SYNC"
)

View File

@@ -122,8 +122,6 @@ class Logs(ABC):
resources: dict[str, Any] = {},
attributes: dict[str, Any] = {},
body: str = "default body",
body_v2: Optional[str] = None,
body_promoted: Optional[str] = None,
severity_text: str = "INFO",
trace_id: str = "",
span_id: str = "",
@@ -169,33 +167,6 @@ class Logs(ABC):
# Set body
self.body = body
# Set body_v2 - if body is JSON, parse and stringify it, otherwise use empty string
# ClickHouse accepts String input for JSON column
if body_v2 is not None:
self.body_v2 = body_v2
else:
# Try to parse body as JSON; if successful use it directly,
# otherwise wrap as {"message": body} matching the normalize operator behavior.
try:
json.loads(body)
self.body_v2 = body
except (json.JSONDecodeError, TypeError):
self.body_v2 = json.dumps({"message": body})
# Set body_promoted - must be valid JSON
# Tests will explicitly pass promoted column's content, but we validate it
if body_promoted is not None:
# Validate that it's valid JSON
try:
json.loads(body_promoted)
self.body_promoted = body_promoted
except (json.JSONDecodeError, TypeError):
# If invalid, default to empty JSON object
self.body_promoted = "{}"
else:
# Default to empty JSON object (valid JSON)
self.body_promoted = "{}"
# Process resources and attributes
self.resources_string = {k: str(v) for k, v in resources.items()}
self.resource_json = (
@@ -355,8 +326,6 @@ class Logs(ABC):
self.severity_text,
self.severity_number,
self.body,
self.body_v2,
self.body_promoted,
self.attributes_string,
self.attributes_number,
self.attributes_bool,
@@ -501,8 +470,6 @@ def insert_logs(
"severity_text",
"severity_number",
"body",
"body_v2",
"body_promoted",
"attributes_string",
"attributes_number",
"attributes_bool",

View File

@@ -1,5 +1,3 @@
from typing import Optional
import docker
import pytest
from testcontainers.core.container import Network
@@ -10,32 +8,27 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def create_migrator(
@pytest.fixture(name="migrator", scope="package")
def migrator(
network: Network,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
cache_key: str = "migrator",
env_overrides: Optional[dict] = None,
) -> types.Operation:
"""
Factory function for running schema migrations.
Accepts optional env_overrides to customize the migrator environment.
Package-scoped fixture for running schema migrations.
"""
def create() -> None:
version = request.config.getoption("--schema-migrator-version")
client = docker.from_env()
environment = dict(env_overrides) if env_overrides else {}
container = client.containers.run(
image=f"signoz/signoz-schema-migrator:{version}",
command=f"sync --replication=true --cluster-name=cluster --up= --dsn={clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN"]}",
detach=True,
auto_remove=False,
network=network.id,
environment=environment,
)
result = container.wait()
@@ -54,7 +47,6 @@ def create_migrator(
detach=True,
auto_remove=False,
network=network.id,
environment=environment,
)
result = container.wait()
@@ -67,7 +59,7 @@ def create_migrator(
container.remove()
return types.Operation(name=cache_key)
return types.Operation(name="migrator")
def delete(_: types.Operation) -> None:
pass
@@ -78,27 +70,9 @@ def create_migrator(
return dev.wrap(
request,
pytestconfig,
cache_key,
"migrator",
lambda: types.Operation(name=""),
create,
delete,
restore,
)
@pytest.fixture(name="migrator", scope="package")
def migrator(
network: Network,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.Operation:
"""
Package-scoped fixture for running schema migrations.
"""
return create_migrator(
network=network,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
)

View File

@@ -14,8 +14,8 @@ QUERY_TIMEOUT = 30 # seconds
@dataclass
class TelemetryFieldKey:
name: str
field_data_type: str
field_context: str
field_data_type: Optional[str] = None
field_context: Optional[str] = None
def to_dict(self) -> Dict:
return {

View File

@@ -14,7 +14,9 @@ from fixtures.querier import (
index_series_by_label,
make_query_request,
)
from src.querier.util import assert_identical_query_response
from src.querier.util import (
assert_identical_query_response,
)
def test_logs_list(
@@ -399,174 +401,6 @@ def test_logs_list(
assert "d-001" in values
def test_logs_list_with_corrupt_data(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[List[Logs]], None],
) -> None:
"""
Setup:
Insert 2 logs with different attributes
Tests:
1. Query logs for the last 10 seconds and check if the logs are returned in the correct order
2. Query values of severity_text attribute from the autocomplete API
3. Query values of severity_text attribute from the fields API
4. Query values of code.file attribute from the autocomplete API
5. Query values of code.file attribute from the fields API
6. Query values of code.line attribute from the autocomplete API
7. Query values of code.line attribute from the fields API
"""
insert_logs(
[
Logs(
timestamp=datetime.now(tz=timezone.utc) - timedelta(seconds=1),
resources={
"deployment.environment": "production",
"service.name": "java",
"os.type": "linux",
"host.name": "linux-001",
"cloud.provider": "integration",
"cloud.account.id": "001",
"timestamp": "2024-01-01T00:00:00Z",
},
attributes={
"log.iostream": "stdout",
"logtag": "F",
"code.file": "/opt/Integration.java",
"code.function": "com.example.Integration.process",
"code.line": 120,
"telemetry.sdk.language": "java",
"id": "1",
},
body="This is a log message, coming from a java application",
severity_text="DEBUG",
),
Logs(
timestamp=datetime.now(tz=timezone.utc),
resources={
"deployment.environment": "production",
"service.name": "go",
"os.type": "linux",
"host.name": "linux-001",
"cloud.provider": "integration",
"cloud.account.id": "001",
"id": 2,
},
attributes={
"log.iostream": "stdout",
"logtag": "F",
"code.file": "/opt/integration.go",
"code.function": "com.example.Integration.process",
"code.line": 120,
"metric.domain_id": "d-001",
"telemetry.sdk.language": "go",
"timestamp": "invalid-timestamp",
},
body="This is a log message, coming from a go application",
severity_text="INFO",
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Query Logs for the last 10 seconds and check if the logs are returned in the correct order
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
json={
"schemaVersion": "v1",
"start": int(
(datetime.now(tz=timezone.utc) - timedelta(seconds=10)).timestamp()
* 1000
),
"end": int(datetime.now(tz=timezone.utc).timestamp() * 1000),
"requestType": "raw",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"disabled": False,
"limit": 100,
"offset": 0,
"order": [
{"key": {"name": "timestamp"}, "direction": "desc"},
{"key": {"name": "id"}, "direction": "desc"},
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
rows = results[0]["rows"]
assert len(rows) == 2
assert (
rows[0]["data"]["body"] == "This is a log message, coming from a go application"
)
assert rows[0]["data"]["resources_string"] == {
"cloud.account.id": "001",
"cloud.provider": "integration",
"deployment.environment": "production",
"host.name": "linux-001",
"os.type": "linux",
"service.name": "go",
"id": "2",
}
assert rows[0]["data"]["attributes_string"] == {
"code.file": "/opt/integration.go",
"code.function": "com.example.Integration.process",
"log.iostream": "stdout",
"logtag": "F",
"metric.domain_id": "d-001",
"telemetry.sdk.language": "go",
"timestamp": "invalid-timestamp",
}
assert rows[0]["data"]["attributes_number"] == {"code.line": 120}
assert (
rows[1]["data"]["body"]
== "This is a log message, coming from a java application"
)
assert rows[1]["data"]["resources_string"] == {
"cloud.account.id": "001",
"cloud.provider": "integration",
"deployment.environment": "production",
"host.name": "linux-001",
"os.type": "linux",
"service.name": "java",
"timestamp": "2024-01-01T00:00:00Z",
}
assert rows[1]["data"]["attributes_string"] == {
"code.file": "/opt/Integration.java",
"code.function": "com.example.Integration.process",
"id": "1",
"log.iostream": "stdout",
"logtag": "F",
"telemetry.sdk.language": "java",
}
assert rows[1]["data"]["attributes_number"] == {"code.line": 120}
@pytest.mark.parametrize(
"order_by_context,expected_order",
####

View File

@@ -0,0 +1,774 @@
from datetime import datetime, timedelta, timezone
from http import HTTPStatus
from typing import Any, Callable, List
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import (
BuilderQuery,
OrderBy,
TelemetryFieldKey,
make_query_request,
)
from src.querier.util import (
generate_logs_with_corrupt_metadata,
)
@pytest.mark.parametrize(
"query,result",
[
# pytest.param(
# BuilderQuery(
# signal="logs",
# name="A",
# limit=1,
# ),
# lambda x: _flatten_log(x[2]),
# id="no-select-no-order",
# # Behaviour:
# # Empty order results in consistent random order
# ),
# pytest.param(
# BuilderQuery(
# signal="logs",
# name="A",
# select_fields=[TelemetryFieldKey("timestamp")],
# limit=1,
# ),
# lambda x: [x[2].id, x[2].timestamp],
# id="select-timestamp-no-order",
# # Behaviour:
# # AdjustKeys no-op
# # Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp field
# # Select timestamp is mapped to top level field by field mapper
# # Empty order results in consistent random order
# ),
# pytest.param(
# BuilderQuery(
# signal="logs",
# name="A",
# select_fields=[TelemetryFieldKey("log.timestamp")],
# limit=1,
# ),
# lambda x: [x[2].id, x[2].timestamp],
# id="select-log-timestamp-no-order",
# # Behaviour:
# # AdjustKeys no-op
# # Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp field
# # Select timestamp is mapped to top level field by field mapper
# # Empty order results in consistent random order
# ),
# pytest.param(
# BuilderQuery(
# signal="logs",
# name="A",
# select_fields=[TelemetryFieldKey("attribute.timestamp")],
# limit=1,
# ),
# lambda x: [x[2].id, x[2].timestamp],
# id="select-attr-timestamp-no-order",
# # Behaviour: [BUG - user didn't get what they expected]
# # AdjustKeys no-op
# # Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp field
# # Select timestamp is mapped to top level field by field mapper
# # Empty order results in consistent random order
# ),
# pytest.param(
# BuilderQuery(
# signal="logs",
# name="A",
# select_fields=[
# TelemetryFieldKey("log.timestamp"),
# TelemetryFieldKey("attribute.timestamp"),
# ],
# limit=1,
# ),
# lambda x: [x[2].id, x[2].timestamp],
# id="select-log-timestamp-and-attr-timestamp-no-order",
# # Behaviour: [BUG - user didn't get what they expected]
# # AdjustKeys logic adjusts key "attribute.timestamp" to "timestamp"
# # AdjustKeys logic adjusts key "log.timestamp" to "timestamp"
# # AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
# # Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp fields
# # Select timestamp is mapped to top level field by field mapper
# # Empty order results in consistent random order
# ),
# pytest.param(
# BuilderQuery(
# signal="logs",
# name="A",
# select_fields=[
# TelemetryFieldKey("timestamp"),
# TelemetryFieldKey("attribute.timestamp"),
# ],
# limit=1,
# ),
# lambda x: [x[2].id, x[2].timestamp],
# id="select-timestamp-and-attr-timestamp-no-order",
# # Behaviour:
# # AdjustKeys logic adjusts key "attribute.timestamp" to "timestamp"
# # AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
# # Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp fields
# # Select timestamp is mapped to top level field by field mapper
# # Empty order results in consistent random order
# ),
# pytest.param(
# BuilderQuery(
# signal="logs",
# name="A",
# select_fields=[
# TelemetryFieldKey("log.timestamp"),
# TelemetryFieldKey("timestamp"),
# ],
# limit=1,
# ),
# lambda x: [x[2].id, x[2].timestamp],
# id="select-log-timestamp-and-timestamp-no-order",
# # Behaviour:
# # AdjustKeys logic adjusts key "log.timestamp" to "timestamp"
# # AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
# # Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp field
# # Select timestamp is mapped to top level field by field mapper
# # Empty order results in consistent random order
# ),
pytest.param(
BuilderQuery(
signal="logs",
name="A",
limit=1,
order=[OrderBy(TelemetryFieldKey("timestamp"), "desc")],
),
lambda x: _flatten_log(x[3]),
id="no-select-order-timestamp-desc",
# Behaviour:
# AdjustKeys no-op
# Order by timestamp is mapped to top level field by field mapper
),
pytest.param(
BuilderQuery(
signal="logs",
name="A",
select_fields=[TelemetryFieldKey("timestamp")],
order=[OrderBy(TelemetryFieldKey("timestamp"), "desc")],
limit=1,
),
lambda x: [x[3].id, x[3].timestamp],
id="select-timestamp-order-timestamp-desc",
# Behaviour:
# AdjustKeys no-op
# Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp field
# Select and OrderBy both timestamp are mapped to top level field by field mapper
),
pytest.param(
BuilderQuery(
signal="logs",
name="A",
select_fields=[TelemetryFieldKey("log.timestamp")],
order=[OrderBy(TelemetryFieldKey("timestamp"), "desc")],
limit=1,
),
lambda x: [x[3].id, x[3].timestamp],
id="select-log-timestamp-order-timestamp-desc",
# Behaviour:
# AdjustKeys logic adjusts key "log.timestamp" to "timestamp"
# AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
# Logs stmt builder by default adds timestamp field to select fields and ignores user input log.timestamp field
# Select and OrderBy both timestamp are mapped to top level field by field mapper
),
pytest.param(
BuilderQuery(
signal="logs",
name="A",
select_fields=[TelemetryFieldKey("attribute.timestamp")],
order=[OrderBy(TelemetryFieldKey("timestamp"), "desc")],
limit=1,
),
lambda x: [x[3].id, x[3].timestamp],
id="select-attr-timestamp-order-timestamp-desc",
# Behaviour:
# AdjustKeys logic adjusts key "attribute.timestamp" to "timestamp"
# AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
# Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp field
# Select and OrderBy both timestamp are mapped to top level field by field mapper
),
pytest.param(
BuilderQuery(
signal="logs",
name="A",
select_fields=[
TelemetryFieldKey("log.timestamp"),
TelemetryFieldKey("attribute.timestamp"),
],
order=[OrderBy(TelemetryFieldKey("timestamp"), "desc")],
limit=1,
),
lambda x: [x[3].id, x[3].timestamp],
id="select-log-timestamp-and-attr-timestamp-order-timestamp-desc",
# Behaviour:
# AdjustKeys logic adjusts key "attribute.timestamp" to "timestamp"
# AdjustKeys logic adjusts key "log.timestamp" to "timestamp"
# AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
# Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp fields
# Select and OrderBy both timestamp are mapped to top level field by field mapper
),
pytest.param(
BuilderQuery(
signal="logs",
name="A",
select_fields=[
TelemetryFieldKey("timestamp"),
TelemetryFieldKey("attribute.timestamp"),
],
order=[OrderBy(TelemetryFieldKey("timestamp"), "desc")],
limit=1,
),
lambda x: [x[3].id, x[3].timestamp],
id="select-timestamp-and-attr-timestamp-order-timestamp-desc",
# Behaviour:
# AdjustKeys logic adjusts key "attribute.timestamp" to "timestamp"
# AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
# Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp fields
# Select and OrderBy both timestamp are mapped to top level field by field mapper
),
pytest.param(
BuilderQuery(
signal="logs",
name="A",
select_fields=[
TelemetryFieldKey("log.timestamp"),
TelemetryFieldKey("timestamp"),
],
order=[OrderBy(TelemetryFieldKey("timestamp"), "desc")],
limit=1,
),
lambda x: [x[3].id, x[3].timestamp],
id="select-log-timestamp-and-timestamp-order-timestamp-desc",
# Behaviour:
# AdjustKeys logic adjusts key "attribute.timestamp" to "timestamp"
# AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
# Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp fields
# Select and OrderBy both timestamp are mapped to top level field by field mapper
),
pytest.param(
BuilderQuery(
signal="logs",
name="A",
limit=1,
order=[OrderBy(TelemetryFieldKey("attribute.timestamp"), "desc")],
),
lambda x: [],
id="no-select-order-attr-timestamp-desc",
# Behaviour: [BUG]
# AdjustKeys logic adjusts key "attribute.timestamp" to "attribute.timestamp:string"
# Because of aliasing bug, result is empty
),
pytest.param(
BuilderQuery(
signal="logs",
name="A",
select_fields=[TelemetryFieldKey("timestamp")],
order=[OrderBy(TelemetryFieldKey("attribute.timestamp"), "desc")],
limit=1,
),
lambda x: [x[3].id, x[3].timestamp],
id="select-timestamp-order-attr-timestamp-desc",
# Behaviour:
# AdjustKeys adjusts key "attribute.timestamp" to "timestamp"
# Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp field
# Select and OrderBy both timestamp are mapped to top level field by field mapper
),
pytest.param(
BuilderQuery(
signal="logs",
name="A",
select_fields=[TelemetryFieldKey("log.timestamp")],
order=[OrderBy(TelemetryFieldKey("attribute.timestamp"), "desc")],
limit=1,
),
lambda x: [x[3].id, x[3].timestamp],
id="select-log-timestamp-order-attr-timestamp-desc",
# Behaviour: [BUG - user didn't get what they expected]
# AdjustKeys logic adjusts key "attribute.timestamp" to "timestamp"
# AdjustKeys logic adjusts key "log.timestamp" to "timestamp"
# Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp field
# Select and OrderBy both timestamp are mapped to top level field by field mapper
),
pytest.param(
BuilderQuery(
signal="logs",
name="A",
select_fields=[TelemetryFieldKey("attribute.timestamp")],
order=[OrderBy(TelemetryFieldKey("attribute.timestamp"), "desc")],
limit=1,
),
lambda x: [], # Because of aliasing bug, this returns no data
id="select-attr-timestamp-order-attr-timestamp-desc",
# Behaviour [BUG - user didn't get what they expected]:
# AdjustKeys logic adjusts key "attribute.timestamp" to "attribute.timestamp:string"
# Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp field
# Because of Logs stmt builder behaviour, we ran into aliasing bug, result is empty
),
pytest.param(
BuilderQuery(
signal="logs",
name="A",
select_fields=[
TelemetryFieldKey("log.timestamp"),
TelemetryFieldKey("attribute.timestamp"),
],
order=[OrderBy(TelemetryFieldKey("attribute.timestamp"), "desc")],
limit=1,
),
lambda x: [x[3].id, x[3].timestamp],
id="select-log-timestamp-and-attr-timestamp-order-attr-timestamp-desc",
# Behaviour: [BUG - user didn't get what they expected]
# AdjustKeys logic adjusts key "attribute.timestamp" to "timestamp"
# AdjustKeys logic adjusts key "log.timestamp" to "timestamp"
# AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
# Logs stmt builder by default adds timestamp field to select fields and ignores user input timestamp field
# Select and OrderBy both timestamp are mapped to top level field by field mapper
),
pytest.param(
BuilderQuery(
signal="logs",
name="A",
select_fields=[
TelemetryFieldKey("timestamp"),
TelemetryFieldKey("attribute.timestamp"),
],
order=[OrderBy(TelemetryFieldKey("attribute.timestamp"), "desc")],
limit=1,
),
lambda x: [x[3].id, x[3].timestamp],
id="select-timestamp-and-attr-timestamp-order-attr-timestamp-desc",
# Behaviour:
# AdjustKeys logic adjusts key "attribute.timestamp" to "timestamp"
# AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
# Select and OrderBy both timestamp are mapped to top level field by field mapper
),
pytest.param(
BuilderQuery(
signal="logs",
name="A",
select_fields=[
TelemetryFieldKey("log.timestamp"),
TelemetryFieldKey("timestamp"),
],
order=[OrderBy(TelemetryFieldKey("attribute.timestamp"), "desc")],
limit=1,
),
lambda x: [x[3].id, x[3].timestamp],
id="select-log-timestamp-and-timestamp-order-attr-timestamp-desc",
# Behaviour:
# AdjustKeys logic adjusts key "attribute.timestamp" to "timestamp"
# AdjustKeys logic adjusts key "log.timestamp" to "timestamp"
# AdjustKeys logic removes duplicate key "timestamp", only 1 select field is left
# Select and OrderBy both timestamp are mapped to top level field by field mapper
),
],
)
def test_logs_list_query_timestamp_expectations(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[List[Logs]], None],
query: BuilderQuery,
result: Callable[[List[Logs]], List[Any]],
) -> None:
"""
Setup:
Insert logs with corrupt data
Tests:
"""
logs = generate_logs_with_corrupt_metadata()
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Query Logs for the last 1 minute and check if the logs are returned in the correct order
response = make_query_request(
signoz,
token,
start_ms=int(
(datetime.now(tz=timezone.utc) - timedelta(minutes=1)).timestamp() * 1000
),
end_ms=int(datetime.now(tz=timezone.utc).timestamp() * 1000),
request_type="raw",
queries=[query.to_dict()],
)
assert response.status_code == HTTPStatus.OK
if response.status_code == HTTPStatus.OK:
if not result(logs):
# No results expected
assert response.json()["data"]["data"]["results"][0]["rows"] is None
else:
data = response.json()["data"]["data"]["results"][0]["rows"][0]["data"]
for key, value in zip(list(data.keys()), result(logs)):
assert data[key] == value
@pytest.mark.parametrize(
"query,results",
[
# pytest.param(
# BuilderQuery(
# signal="logs",
# name="A",
# select_fields=[TelemetryFieldKey("trace_id")],
# ),
# lambda x: [
# [x[2].id, x[2].timestamp, x[2].trace_id],
# [x[1].id, x[1].timestamp, x[1].trace_id],
# [x[0].id, x[0].timestamp, x[0].trace_id],
# [x[3].id, x[3].timestamp, x[3].trace_id],
# ],
# id="select-trace-id-no-order",
# # Justification (expected values and row order):
# # Values: x[0].trace_id="1", x[1].trace_id="", x[2].trace_id="", x[3].trace_id=""
# # Order: no explicit order → ClickHouse internal storage order
# # Behaviour:
# # AdjustKeys no-op
# # Select trace_id is mapped to top level field by field mapper
# # Empty order results in consistent random order
# ),
# pytest.param(
# BuilderQuery(
# signal="logs",
# name="A",
# select_fields=[TelemetryFieldKey("log.trace_id")],
# ),
# lambda x: [
# [x[2].id, x[2].timestamp, x[2].trace_id],
# [x[1].id, x[1].timestamp, x[1].trace_id],
# [x[0].id, x[0].timestamp, x[0].trace_id],
# [x[3].id, x[3].timestamp, x[3].trace_id],
# ],
# id="select-log-trace-id-no-order",
# # Justification (expected values and row order):
# # Values: x[0].trace_id="1", x[1].trace_id="", x[2].trace_id="", x[3].trace_id=""
# # Order: no explicit order → ClickHouse internal storage order
# # Behaviour:
# # AdjustKeys adjusts log.trace_id to log.trace_id:string
# # Select log.trace_id is mapped to top level field by field mapper
# # Empty order results in consistent random order
# ),
# pytest.param(
# BuilderQuery(
# signal="logs",
# name="A",
# select_fields=[TelemetryFieldKey("body.trace_id")],
# ),
# lambda x: [
# [x[2].id, x[2].timestamp, x[2].trace_id],
# [x[1].id, x[1].timestamp, x[1].trace_id],
# [x[0].id, x[0].timestamp, x[0].trace_id],
# [x[3].id, x[3].timestamp, x[3].trace_id],
# ],
# id="select-body-trace-id-no-order",
# # Justification (expected values and row order):
# # Values: x[0].trace_id="1", x[1].trace_id="", x[2].trace_id="", x[3].trace_id=""
# # Order: no explicit order → ClickHouse internal storage order
# # Behaviour:
# # AdjustKeys logic adjusts key "body.trace_id" to "log.trace_id:string"
# ),
# pytest.param(
# BuilderQuery(
# signal="logs",
# name="A",
# select_fields=[TelemetryFieldKey("attribute.trace_id")],
# ),
# lambda x: [
# [x[2].id, x[2].timestamp, x[2].attributes_string.get("trace_id", "")],
# [x[1].id, x[1].timestamp, x[1].attributes_string.get("trace_id", "")],
# [x[0].id, x[0].timestamp, x[0].attributes_string.get("trace_id", "")],
# [x[3].id, x[3].timestamp, x[3].attributes_string.get("trace_id", "")],
# ],
# id="select-attribute-trace-id-no-order",
# # Justification (expected values and row order):
# # Values: x[0]="", x[1]="2", x[2]="", x[3]="" (only x[1] has attribute.trace_id set)
# # Order: no explicit order → ClickHouse internal storage order
# # Behaviour:
# # AdjustKeys no-op
# ),
# pytest.param(
# BuilderQuery(
# signal="logs",
# name="A",
# select_fields=[TelemetryFieldKey("resource.trace_id")],
# ),
# lambda x: [
# [x[2].id, x[2].timestamp, x[2].resources_string.get("trace_id", "")],
# [x[1].id, x[1].timestamp, x[1].resources_string.get("trace_id", "")],
# [x[0].id, x[0].timestamp, x[0].resources_string.get("trace_id", "")],
# [x[3].id, x[3].timestamp, x[3].resources_string.get("trace_id", "")],
# ],
# id="select-resource-trace-id-no-order",
# # Justification (expected values and row order):
# # Values: x[0]="", x[1]="", x[2]="3", x[3]="" (only x[2] has resource.trace_id set)
# # Order: no explicit order → ClickHouse internal storage order
# # Behaviour:
# # AdjustKeys no-op
# ),
pytest.param(
BuilderQuery(
signal="logs",
name="A",
order=[OrderBy(TelemetryFieldKey("trace_id"), "desc")],
),
lambda x: [
_flatten_log(x[0]),
_flatten_log(x[2]),
_flatten_log(x[1]),
_flatten_log(x[3]),
],
id="no-select-trace-id-order",
# Justification (expected values and row order):
# Values: x[0].trace_id="1", x[1].trace_id="", x[2].trace_id="", x[3].trace_id=""
# Order: trace_id DESC → x[0]("1") first, then x[2](""), x[1](""), x[3]("") in storage order
# Behaviour:
# AdjustKeys no-op
# Order by trace_id is mapped to top level field by field mapper
),
pytest.param(
BuilderQuery(
signal="logs",
name="A",
order=[OrderBy(TelemetryFieldKey("attribute.trace_id"), "desc")],
),
lambda x: [
[*_flatten_log(x[1])[:14], x[1].attributes_string.get("trace_id", "")],
[*_flatten_log(x[2])[:14], x[2].attributes_string.get("trace_id", "")],
[*_flatten_log(x[0])[:14], x[0].attributes_string.get("trace_id", "")],
[*_flatten_log(x[3])[:14], x[3].attributes_string.get("trace_id", "")],
],
id="no-select-attribute-trace-id-order",
# Justification (expected values and row order):
# attribute.trace_id values: x[0]="", x[1]="2", x[2]="", x[3]=""
# Behaviour: [BUG - user didn't get what they expected]
# AdjustKeys adjusts "attribute.trace_id" to "attribute.trace_id:string"
# Order by attribute.trace_id maps to attributes_string['trace_id']
),
pytest.param(
BuilderQuery(
signal="logs",
name="A",
select_fields=[TelemetryFieldKey("trace_id")],
order=[OrderBy(TelemetryFieldKey("trace_id"), "desc")],
),
lambda x: [
[x[0].id, x[0].timestamp, x[0].trace_id],
[x[2].id, x[2].timestamp, x[2].trace_id],
[x[1].id, x[1].timestamp, x[1].trace_id],
[x[3].id, x[3].timestamp, x[3].trace_id],
],
id="select-trace-id-order-trace-id-desc",
# Justification (expected values and row order):
# Values: x[0].trace_id="1", x[1].trace_id="", x[2].trace_id="", x[3].trace_id=""
# Order: trace_id DESC → x[0]("1") first, then x[2](""), x[1](""), x[3]("") in storage order
# Behaviour:
# AdjustKeys no-op
),
pytest.param(
BuilderQuery(
signal="logs",
name="A",
select_fields=[TelemetryFieldKey("attribute.trace_id")],
order=[OrderBy(TelemetryFieldKey("attribute.trace_id"), "desc")],
),
lambda x: [
[x[1].id, x[1].timestamp, x[1].attributes_string.get("trace_id", "")],
[x[2].id, x[2].timestamp, x[2].attributes_string.get("trace_id", "")],
[x[0].id, x[0].timestamp, x[0].attributes_string.get("trace_id", "")],
[x[3].id, x[3].timestamp, x[3].attributes_string.get("trace_id", "")],
],
id="select-attribute-trace-id-order-attribute-trace-id-desc",
# Justification (expected values and row order):
# AdjustKeys: no-op for both select and order, "attribute.trace_id" is a valid attribute key
# Field mapping: "attribute.trace_id" → attributes_string["trace_id"]
# Values: x[0]="", x[1]="2", x[2]="", x[3]="" (only x[1] has attribute.trace_id set)
# Order: attribute.trace_id DESC → x[1]("2") first, then x[2](""), x[0](""), x[3]("") in storage order
# Behaviour:
# AdjustKeys no-op
),
pytest.param(
BuilderQuery(
signal="logs",
name="A",
select_fields=[TelemetryFieldKey("resource.trace_id")],
order=[OrderBy(TelemetryFieldKey("resource.trace_id"), "desc")],
),
lambda x: [
[x[2].id, x[2].timestamp, x[2].resources_string.get("trace_id", "")],
[x[1].id, x[1].timestamp, x[1].resources_string.get("trace_id", "")],
[x[0].id, x[0].timestamp, x[0].resources_string.get("trace_id", "")],
[x[3].id, x[3].timestamp, x[3].resources_string.get("trace_id", "")],
],
id="select-resource-trace-id-order-resource-trace-id-desc",
# Justification (expected values and row order):
# AdjustKeys: no-op for both select and order, "resource.trace_id" is a valid resource key
# Field mapping: "resource.trace_id" → resources_string["trace_id"]
# Values: x[0]="", x[1]="", x[2]="3", x[3]="" (only x[2] has resource.trace_id set)
# Order: resource.trace_id DESC → x[2]("3") first, then x[1](""), x[0](""), x[3]("") in storage order
# Behaviour:
# AdjustKeys no-op
),
pytest.param(
BuilderQuery(
signal="logs",
name="A",
select_fields=[TelemetryFieldKey("body.trace_id")],
order=[OrderBy(TelemetryFieldKey("body.trace_id"), "desc")],
),
lambda x: [
[x[0].id, x[0].timestamp, x[0].trace_id],
[x[2].id, x[2].timestamp, x[2].trace_id],
[x[1].id, x[1].timestamp, x[1].trace_id],
[x[3].id, x[3].timestamp, x[3].trace_id],
],
id="select-body-trace-id-order-body-trace-id-desc",
# Justification (expected values and row order):
# AdjustKeys: adjusts "body.trace_id" to "log.trace_id:string" for both select and order
# Field mapping: "log.trace_id:string" → top-level trace_id column
# Values: x[0].trace_id="1", x[1].trace_id="", x[2].trace_id="", x[3].trace_id=""
# Order: trace_id DESC → x[0]("1") first, then x[2](""), x[1](""), x[3]("") in storage order
# Behaviour:
# AdjustKeys logic adjusts key "body.trace_id" to "log.trace_id:string"
),
],
)
def test_logs_list_query_trace_id_expectations(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[List[Logs]], None],
query: BuilderQuery,
results: Callable[[List[Logs]], List[Any]],
) -> None:
"""
Justification for expected rows and ordering:
Four logs differ by where trace_id is set: top-level (x[0]), attribute (x[1]),
resource (x[2]), or only in body text (x[3]). Each parametrized case documents
which column AdjustKeys/field mapping reads and why DESC ties break in storage order.
Setup:
Insert logs with corrupt trace_id
Tests:
"""
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
logs = [
Logs(
timestamp=now - timedelta(seconds=4),
body="POST /integration request received",
severity_text="INFO",
resources={
"service.name": "http-service",
"timestamp": "corrupt_data",
},
attributes={
"severity_text": "corrupt_data",
"timestamp": "corrupt_data",
},
trace_id="1",
),
Logs(
timestamp=now - timedelta(seconds=3),
body="SELECT query executed",
severity_text="DEBUG",
resources={
"service.name": "http-service",
"id": "corrupt_data",
},
attributes={
"trace_id": "2",
},
),
Logs(
timestamp=now - timedelta(seconds=2),
body="HTTP PATCH failed with 404",
severity_text="WARN",
resources={
"service.name": "http-service",
"body": "corrupt_data",
"trace_id": "3",
},
attributes={
"id": "1",
},
),
Logs(
timestamp=now - timedelta(seconds=1),
body="{'trace_id': '4'}",
severity_text="ERROR",
resources={
"service.name": "topic-service",
},
attributes={
"body": "corrupt_data",
"timestamp": "corrupt_data",
},
),
]
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Query Logs for the last 1 minute and check if the logs are returned in the correct order
response = make_query_request(
signoz,
token,
start_ms=int(
(datetime.now(tz=timezone.utc) - timedelta(minutes=10)).timestamp() * 1000
),
end_ms=int(datetime.now(tz=timezone.utc).timestamp() * 1000),
request_type="raw",
queries=[query.to_dict()],
)
assert response.status_code == HTTPStatus.OK
if response.status_code == HTTPStatus.OK:
if not results(logs):
# No results expected
assert response.json()["data"]["data"]["results"][0]["rows"] is None
else:
print(response.json())
rows = response.json()["data"]["data"]["results"][0]["rows"]
assert len(rows) == len(
results(logs)
), f"Expected {len(results(logs))} rows, got {len(rows)}"
for row, expected_row in zip(rows, results(logs)):
data = row["data"]
keys = list(data.keys())
for i, expected_value in enumerate(expected_row):
assert (
data[keys[i]] == expected_value
), f"Row mismatch at key '{keys[i]}': expected {expected_value}, got {data[keys[i]]}"
def _flatten_log(log: Logs) -> List[Any]:
return [
log.attributes_bool,
log.attributes_number,
log.attributes_string,
log.body,
log.id,
log.resources_string,
log.scope_name,
log.scope_string,
log.scope_version,
log.severity_number,
log.severity_text,
log.span_id,
log.timestamp,
log.trace_flags,
log.trace_id,
]

View File

@@ -4,6 +4,7 @@ from typing import List
import requests
from fixtures.logs import Logs
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
@@ -38,6 +39,101 @@ def assert_identical_query_response(
), "Response data do not match"
def generate_logs_with_corrupt_metadata() -> List[Logs]:
"""
Specifically, entries with 'id', 'timestamp', 'severity_text', 'severity_number' and 'body' fields in metadata
"""
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
return [
Logs(
timestamp=now - timedelta(seconds=4),
body="POST /integration request received",
severity_text="INFO",
resources={
"deployment.environment": "production",
"service.name": "http-service",
"os.type": "linux",
"host.name": "linux-000",
"cloud.provider": "integration",
"cloud.account.id": "000",
"timestamp": "corrupt_data",
},
attributes={
"net.transport": "IP.TCP",
"http.scheme": "http",
"http.user_agent": "Integration Test",
"http.request.method": "POST",
"http.response.status_code": "200",
"severity_text": "corrupt_data",
"timestamp": "corrupt_data",
},
trace_id="1",
),
Logs(
timestamp=now - timedelta(seconds=3),
body="SELECT query executed",
severity_text="DEBUG",
resources={
"deployment.environment": "production",
"service.name": "http-service",
"os.type": "linux",
"host.name": "linux-000",
"cloud.provider": "integration",
"cloud.account.id": "000",
"severity_number": "corrupt_data",
"id": "corrupt_data",
},
attributes={
"db.name": "integration",
"db.operation": "SELECT",
"db.statement": "SELECT * FROM integration",
"trace_id": "2",
},
),
Logs(
timestamp=now - timedelta(seconds=2),
body="HTTP PATCH failed with 404",
severity_text="WARN",
resources={
"deployment.environment": "production",
"service.name": "http-service",
"os.type": "linux",
"host.name": "linux-000",
"cloud.provider": "integration",
"cloud.account.id": "000",
"body": "corrupt_data",
"trace_id": "3",
},
attributes={
"http.request.method": "PATCH",
"http.status_code": "404",
"id": "1",
},
),
Logs(
timestamp=now - timedelta(seconds=1),
body="{'trace_id': '4'}",
severity_text="ERROR",
resources={
"deployment.environment": "production",
"service.name": "topic-service",
"os.type": "linux",
"host.name": "linux-001",
"cloud.provider": "integration",
"cloud.account.id": "001",
},
attributes={
"message.type": "SENT",
"messaging.operation": "publish",
"messaging.message.id": "001",
"body": "corrupt_data",
"timestamp": "corrupt_data",
},
),
]
def generate_traces_with_corrupt_metadata() -> List[Traces]:
"""
Specifically, entries with 'id', 'timestamp', 'trace_id' and 'duration_nano' fields in metadata

View File

@@ -1,70 +0,0 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.migrator import create_migrator
from fixtures.signoz import create_signoz
UNSUPPORTED_CLICKHOUSE_VERSIONS = {"25.5.6"}
def pytest_collection_modifyitems(
config: pytest.Config, items: list[pytest.Item]
) -> None:
version = config.getoption("--clickhouse-version")
if version in UNSUPPORTED_CLICKHOUSE_VERSIONS:
skip = pytest.mark.skip(
reason=f"JSON body QB tests require ClickHouse > {version}"
)
for item in items:
item.add_marker(skip)
@pytest.fixture(name="migrator", scope="package")
def migrator_json(
network: Network,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.Operation:
"""
Package-scoped migrator with ENABLE_LOGS_MIGRATIONS_V2=1.
"""
return create_migrator(
network=network,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="migrator-json-body",
env_overrides={
"ENABLE_LOGS_MIGRATIONS_V2": "1",
},
)
@pytest.fixture(name="signoz", scope="package")
def signoz_json_body(
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
"""
Package-scoped fixture for SigNoz with BODY_JSON_QUERY_ENABLED=true.
"""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz-json-body",
env_overrides={
"BODY_JSON_QUERY_ENABLED": "true",
},
)