mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-16 17:00:28 +01:00
Compare commits
31 Commits
platform-p
...
chore/json
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e534088cf | ||
|
|
fa54d8c01e | ||
|
|
be8e9678e8 | ||
|
|
d265cbbbf9 | ||
|
|
9d61150f96 | ||
|
|
8277742ea6 | ||
|
|
56b589ea4a | ||
|
|
91beeb4949 | ||
|
|
cc193c9be5 | ||
|
|
9690c06cb2 | ||
|
|
8c44925a67 | ||
|
|
9643fae27a | ||
|
|
19a65e80d8 | ||
|
|
0758ff133b | ||
|
|
67e1b82adb | ||
|
|
1e8c0f19f5 | ||
|
|
c027181935 | ||
|
|
7122fb8b54 | ||
|
|
67830c8a16 | ||
|
|
096eee6435 | ||
|
|
30f5f2f2f2 | ||
|
|
52adb84461 | ||
|
|
63b7f15d0e | ||
|
|
6d3c88ed21 | ||
|
|
fc6a67aec1 | ||
|
|
9f41499047 | ||
|
|
9cd554d293 | ||
|
|
415387edfc | ||
|
|
fa1bc3db9b | ||
|
|
6c6dad8a66 | ||
|
|
7403f86773 |
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/SigNoz/signoz/cmd"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/authn"
|
||||
@@ -15,7 +14,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/authz/openfgaauthz"
|
||||
"github.com/SigNoz/signoz/pkg/authz/openfgaschema"
|
||||
"github.com/SigNoz/signoz/pkg/authz/openfgaserver"
|
||||
"github.com/SigNoz/signoz/pkg/cache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/gateway"
|
||||
@@ -28,20 +26,14 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/ruler/signozruler"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
"github.com/SigNoz/signoz/pkg/zeus/noopzeus"
|
||||
@@ -115,9 +107,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
func(_ sqlstore.SQLStore, _ global.Global, _ zeus.Zeus, _ gateway.Gateway, _ licensing.Licensing, _ serviceaccount.Module, _ cloudintegration.Config) (cloudintegration.Module, error) {
|
||||
return implcloudintegration.NewModule(), nil
|
||||
},
|
||||
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
|
||||
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, nil, nil))
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "failed to create signoz", errors.Attr(err))
|
||||
|
||||
@@ -22,17 +22,14 @@ import (
|
||||
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
|
||||
eequerier "github.com/SigNoz/signoz/ee/querier"
|
||||
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
|
||||
eerules "github.com/SigNoz/signoz/ee/query-service/rules"
|
||||
"github.com/SigNoz/signoz/ee/sqlschema/postgressqlschema"
|
||||
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
|
||||
enterprisezeus "github.com/SigNoz/signoz/ee/zeus"
|
||||
"github.com/SigNoz/signoz/ee/zeus/httpzeus"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/authn"
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/cache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/gateway"
|
||||
@@ -43,21 +40,15 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/ruler/signozruler"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstorehook"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
)
|
||||
@@ -175,9 +166,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
|
||||
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)
|
||||
},
|
||||
func(c cache.Cache, am alertmanager.Alertmanager, ss sqlstore.SQLStore, ts telemetrystore.TelemetryStore, ms telemetrytypes.MetadataStore, p prometheus.Prometheus, og organization.Getter, rsh rulestatehistory.Module, q querier.Querier, qp queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
|
||||
return factory.MustNewNamedMap(signozruler.NewFactory(c, am, ss, ts, ms, p, og, rsh, q, qp, eerules.PrepareTaskFunc, eerules.TestNotification))
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "failed to create signoz", errors.Attr(err))
|
||||
|
||||
@@ -3618,20 +3618,6 @@ components:
|
||||
- start
|
||||
- end
|
||||
type: object
|
||||
RuletypesAlertCompositeQuery:
|
||||
properties:
|
||||
panelType:
|
||||
type: string
|
||||
queries:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryEnvelope'
|
||||
nullable: true
|
||||
type: array
|
||||
queryType:
|
||||
type: string
|
||||
unit:
|
||||
type: string
|
||||
type: object
|
||||
RuletypesAlertState:
|
||||
enum:
|
||||
- inactive
|
||||
@@ -3641,271 +3627,6 @@ components:
|
||||
- nodata
|
||||
- disabled
|
||||
type: string
|
||||
RuletypesCompareOperator:
|
||||
enum:
|
||||
- above
|
||||
- below
|
||||
- equal
|
||||
- not_equal
|
||||
- outside_bounds
|
||||
type: string
|
||||
RuletypesEvaluationEnvelope:
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
spec: {}
|
||||
type: object
|
||||
RuletypesGettablePlannedMaintenance:
|
||||
properties:
|
||||
alertIds:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
schedule:
|
||||
$ref: '#/components/schemas/RuletypesSchedule'
|
||||
status:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
type: object
|
||||
RuletypesGettableRule:
|
||||
properties:
|
||||
alert:
|
||||
type: string
|
||||
alertType:
|
||||
type: string
|
||||
annotations:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
condition:
|
||||
$ref: '#/components/schemas/RuletypesRuleCondition'
|
||||
createAt:
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
createBy:
|
||||
nullable: true
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
disabled:
|
||||
type: boolean
|
||||
evalWindow:
|
||||
type: string
|
||||
evaluation:
|
||||
$ref: '#/components/schemas/RuletypesEvaluationEnvelope'
|
||||
frequency:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
labels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
notificationSettings:
|
||||
$ref: '#/components/schemas/RuletypesNotificationSettings'
|
||||
preferredChannels:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
ruleType:
|
||||
$ref: '#/components/schemas/RuletypesRuleType'
|
||||
schemaVersion:
|
||||
type: string
|
||||
source:
|
||||
type: string
|
||||
state:
|
||||
$ref: '#/components/schemas/RuletypesAlertState'
|
||||
updateAt:
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
updateBy:
|
||||
nullable: true
|
||||
type: string
|
||||
version:
|
||||
type: string
|
||||
type: object
|
||||
RuletypesGettableRules:
|
||||
properties:
|
||||
rules:
|
||||
items:
|
||||
$ref: '#/components/schemas/RuletypesGettableRule'
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
RuletypesMatchType:
|
||||
enum:
|
||||
- at_least_once
|
||||
- all_the_times
|
||||
- on_average
|
||||
- in_total
|
||||
- last
|
||||
type: string
|
||||
RuletypesNotificationSettings:
|
||||
properties:
|
||||
groupBy:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
newGroupEvalDelay:
|
||||
type: string
|
||||
renotify:
|
||||
$ref: '#/components/schemas/RuletypesRenotify'
|
||||
usePolicy:
|
||||
type: boolean
|
||||
type: object
|
||||
RuletypesPostableRule:
|
||||
properties:
|
||||
alert:
|
||||
type: string
|
||||
alertType:
|
||||
type: string
|
||||
annotations:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
condition:
|
||||
$ref: '#/components/schemas/RuletypesRuleCondition'
|
||||
description:
|
||||
type: string
|
||||
disabled:
|
||||
type: boolean
|
||||
evalWindow:
|
||||
type: string
|
||||
evaluation:
|
||||
$ref: '#/components/schemas/RuletypesEvaluationEnvelope'
|
||||
frequency:
|
||||
type: string
|
||||
labels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
notificationSettings:
|
||||
$ref: '#/components/schemas/RuletypesNotificationSettings'
|
||||
preferredChannels:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
ruleType:
|
||||
$ref: '#/components/schemas/RuletypesRuleType'
|
||||
schemaVersion:
|
||||
type: string
|
||||
source:
|
||||
type: string
|
||||
version:
|
||||
type: string
|
||||
type: object
|
||||
RuletypesRecurrence:
|
||||
properties:
|
||||
duration:
|
||||
type: string
|
||||
endTime:
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
repeatOn:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
repeatType:
|
||||
type: string
|
||||
startTime:
|
||||
format: date-time
|
||||
type: string
|
||||
type: object
|
||||
RuletypesRenotify:
|
||||
properties:
|
||||
alertStates:
|
||||
items:
|
||||
$ref: '#/components/schemas/RuletypesAlertState'
|
||||
type: array
|
||||
enabled:
|
||||
type: boolean
|
||||
interval:
|
||||
type: string
|
||||
type: object
|
||||
RuletypesRuleCondition:
|
||||
properties:
|
||||
absentFor:
|
||||
minimum: 0
|
||||
type: integer
|
||||
alertOnAbsent:
|
||||
type: boolean
|
||||
algorithm:
|
||||
type: string
|
||||
compositeQuery:
|
||||
$ref: '#/components/schemas/RuletypesAlertCompositeQuery'
|
||||
matchType:
|
||||
$ref: '#/components/schemas/RuletypesMatchType'
|
||||
op:
|
||||
$ref: '#/components/schemas/RuletypesCompareOperator'
|
||||
requireMinPoints:
|
||||
type: boolean
|
||||
requiredNumPoints:
|
||||
type: integer
|
||||
seasonality:
|
||||
$ref: '#/components/schemas/RuletypesSeasonality'
|
||||
selectedQueryName:
|
||||
type: string
|
||||
target:
|
||||
nullable: true
|
||||
type: number
|
||||
targetUnit:
|
||||
type: string
|
||||
thresholds:
|
||||
$ref: '#/components/schemas/RuletypesRuleThresholdData'
|
||||
type: object
|
||||
RuletypesRuleThresholdData:
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
spec: {}
|
||||
type: object
|
||||
RuletypesRuleType:
|
||||
enum:
|
||||
- threshold_rule
|
||||
- promql_rule
|
||||
- anomaly_rule
|
||||
type: string
|
||||
RuletypesSchedule:
|
||||
properties:
|
||||
endTime:
|
||||
format: date-time
|
||||
type: string
|
||||
recurrence:
|
||||
$ref: '#/components/schemas/RuletypesRecurrence'
|
||||
startTime:
|
||||
format: date-time
|
||||
type: string
|
||||
timezone:
|
||||
type: string
|
||||
type: object
|
||||
RuletypesSeasonality:
|
||||
enum:
|
||||
- hourly
|
||||
- daily
|
||||
- weekly
|
||||
type: string
|
||||
ServiceaccounttypesGettableFactorAPIKey:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -6097,257 +5818,6 @@ paths:
|
||||
summary: Update auth domain
|
||||
tags:
|
||||
- authdomains
|
||||
/api/v1/downtime_schedules:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists all planned maintenance / downtime schedules
|
||||
operationId: ListDowntimeSchedules
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/RuletypesGettablePlannedMaintenance'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List downtime schedules
|
||||
tags:
|
||||
- downtimeschedules
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a new planned maintenance / downtime schedule
|
||||
operationId: CreateDowntimeSchedule
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesGettablePlannedMaintenance'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"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:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Create downtime schedule
|
||||
tags:
|
||||
- downtimeschedules
|
||||
/api/v1/downtime_schedules/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint deletes a downtime schedule by ID
|
||||
operationId: DeleteDowntimeScheduleByID
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
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
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Delete downtime schedule
|
||||
tags:
|
||||
- downtimeschedules
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns a downtime schedule by ID
|
||||
operationId: GetDowntimeScheduleByID
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesGettablePlannedMaintenance'
|
||||
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
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get downtime schedule by ID
|
||||
tags:
|
||||
- downtimeschedules
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates a downtime schedule by ID
|
||||
operationId: UpdateDowntimeScheduleByID
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesGettablePlannedMaintenance'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Update downtime schedule
|
||||
tags:
|
||||
- downtimeschedules
|
||||
/api/v1/export_raw_data:
|
||||
post:
|
||||
deprecated: false
|
||||
@@ -7945,385 +7415,6 @@ paths:
|
||||
summary: Update route policy
|
||||
tags:
|
||||
- routepolicies
|
||||
/api/v1/rules:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists all alert rules with their current evaluation
|
||||
state
|
||||
operationId: ListRules
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesGettableRules'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List alert rules
|
||||
tags:
|
||||
- rules
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a new alert rule
|
||||
operationId: CreateRule
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRule'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesGettableRule'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"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:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Create alert rule
|
||||
tags:
|
||||
- rules
|
||||
/api/v1/rules/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint deletes an alert rule by ID
|
||||
operationId: DeleteRuleByID
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
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
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Delete alert rule
|
||||
tags:
|
||||
- rules
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns an alert rule by ID
|
||||
operationId: GetRuleByID
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesGettableRule'
|
||||
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
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get alert rule by ID
|
||||
tags:
|
||||
- rules
|
||||
patch:
|
||||
deprecated: false
|
||||
description: This endpoint applies a partial update to an alert rule by ID
|
||||
operationId: PatchRuleByID
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRule'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesGettableRule'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Patch alert rule
|
||||
tags:
|
||||
- rules
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates an alert rule by ID
|
||||
operationId: UpdateRuleByID
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRule'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Update alert rule
|
||||
tags:
|
||||
- rules
|
||||
/api/v1/rules/test:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint fires a test notification for the given rule definition
|
||||
operationId: TestRule
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRule'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"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:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Test alert rule
|
||||
tags:
|
||||
- rules
|
||||
/api/v1/service_accounts:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -9126,55 +8217,6 @@ paths:
|
||||
summary: Test notification channel (deprecated)
|
||||
tags:
|
||||
- channels
|
||||
/api/v1/testRule:
|
||||
post:
|
||||
deprecated: true
|
||||
description: 'Deprecated: use /api/v1/rules/test instead'
|
||||
operationId: TestRuleDeprecated
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRule'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"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:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Test alert rule (deprecated)
|
||||
tags:
|
||||
- rules
|
||||
/api/v1/user:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -7,12 +7,12 @@ This guide explains how to add new data sources to the SigNoz onboarding flow. T
|
||||
The configuration is located at:
|
||||
|
||||
```
|
||||
frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.ts
|
||||
frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.json
|
||||
```
|
||||
|
||||
## Structure Overview
|
||||
## JSON Structure Overview
|
||||
|
||||
The configuration file exports a TypeScript array (`onboardingConfigWithLinks`) containing data source objects. Each object represents a selectable option in the onboarding flow. SVG logos are imported as ES modules at the top of the file.
|
||||
The configuration file is a JSON array containing data source objects. Each object represents a selectable option in the onboarding flow.
|
||||
|
||||
## Data Source Object Keys
|
||||
|
||||
@@ -24,7 +24,7 @@ The configuration file exports a TypeScript array (`onboardingConfigWithLinks`)
|
||||
| `label` | `string` | Display name shown to users (e.g., `"AWS EC2"`) |
|
||||
| `tags` | `string[]` | Array of category tags for grouping (e.g., `["AWS"]`, `["database"]`) |
|
||||
| `module` | `string` | Destination module after onboarding completion |
|
||||
| `imgUrl` | `string` | Imported SVG URL **(SVG required)** (e.g., `import ec2Url from '@/assets/Logos/ec2.svg'`, then use `ec2Url`) |
|
||||
| `imgUrl` | `string` | Path to the logo/icon **(SVG required)** (e.g., `"/Logos/ec2.svg"`) |
|
||||
|
||||
### Optional Keys
|
||||
|
||||
@@ -57,34 +57,36 @@ The `module` key determines where users are redirected after completing onboardi
|
||||
|
||||
The `question` object enables multi-step selection flows:
|
||||
|
||||
```ts
|
||||
question: {
|
||||
desc: 'What would you like to monitor?',
|
||||
type: 'select',
|
||||
helpText: 'Choose the telemetry type you want to collect.',
|
||||
helpLink: '/docs/azure-monitoring/overview/',
|
||||
helpLinkText: 'Read the guide →',
|
||||
options: [
|
||||
{
|
||||
key: 'logging',
|
||||
label: 'Logs',
|
||||
imgUrl: azureVmUrl,
|
||||
link: '/docs/azure-monitoring/app-service/logging/',
|
||||
},
|
||||
{
|
||||
key: 'metrics',
|
||||
label: 'Metrics',
|
||||
imgUrl: azureVmUrl,
|
||||
link: '/docs/azure-monitoring/app-service/metrics/',
|
||||
},
|
||||
{
|
||||
key: 'tracing',
|
||||
label: 'Traces',
|
||||
imgUrl: azureVmUrl,
|
||||
link: '/docs/azure-monitoring/app-service/tracing/',
|
||||
},
|
||||
],
|
||||
},
|
||||
```json
|
||||
{
|
||||
"question": {
|
||||
"desc": "What would you like to monitor?",
|
||||
"type": "select",
|
||||
"helpText": "Choose the telemetry type you want to collect.",
|
||||
"helpLink": "/docs/azure-monitoring/overview/",
|
||||
"helpLinkText": "Read the guide →",
|
||||
"options": [
|
||||
{
|
||||
"key": "logging",
|
||||
"label": "Logs",
|
||||
"imgUrl": "/Logos/azure-vm.svg",
|
||||
"link": "/docs/azure-monitoring/app-service/logging/"
|
||||
},
|
||||
{
|
||||
"key": "metrics",
|
||||
"label": "Metrics",
|
||||
"imgUrl": "/Logos/azure-vm.svg",
|
||||
"link": "/docs/azure-monitoring/app-service/metrics/"
|
||||
},
|
||||
{
|
||||
"key": "tracing",
|
||||
"label": "Traces",
|
||||
"imgUrl": "/Logos/azure-vm.svg",
|
||||
"link": "/docs/azure-monitoring/app-service/tracing/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Question Keys
|
||||
@@ -104,161 +106,152 @@ Options can be simple (direct link) or nested (with another question):
|
||||
|
||||
### Simple Option (Direct Link)
|
||||
|
||||
```ts
|
||||
```json
|
||||
{
|
||||
key: 'aws-ec2-logs',
|
||||
label: 'Logs',
|
||||
imgUrl: ec2Url,
|
||||
link: '/docs/userguide/collect_logs_from_file/',
|
||||
},
|
||||
"key": "aws-ec2-logs",
|
||||
"label": "Logs",
|
||||
"imgUrl": "/Logos/ec2.svg",
|
||||
"link": "/docs/userguide/collect_logs_from_file/"
|
||||
}
|
||||
```
|
||||
|
||||
### Option with Internal Redirect
|
||||
|
||||
```ts
|
||||
```json
|
||||
{
|
||||
key: 'aws-ec2-metrics-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: ec2Url,
|
||||
link: '/integrations?integration=aws-integration&service=ec2',
|
||||
internalRedirect: true,
|
||||
},
|
||||
"key": "aws-ec2-metrics-one-click",
|
||||
"label": "One Click AWS",
|
||||
"imgUrl": "/Logos/ec2.svg",
|
||||
"link": "/integrations?integration=aws-integration&service=ec2",
|
||||
"internalRedirect": true
|
||||
}
|
||||
```
|
||||
|
||||
> **Important**: Set `internalRedirect: true` only for internal app routes (like `/integrations?...`). Docs links should NOT have this flag.
|
||||
|
||||
### Nested Option (Multi-step Flow)
|
||||
|
||||
```ts
|
||||
```json
|
||||
{
|
||||
key: 'aws-ec2-metrics',
|
||||
label: 'Metrics',
|
||||
imgUrl: ec2Url,
|
||||
question: {
|
||||
desc: 'How would you like to set up monitoring?',
|
||||
helpText: 'Choose your setup method.',
|
||||
options: [...],
|
||||
},
|
||||
},
|
||||
"key": "aws-ec2-metrics",
|
||||
"label": "Metrics",
|
||||
"imgUrl": "/Logos/ec2.svg",
|
||||
"question": {
|
||||
"desc": "How would you like to set up monitoring?",
|
||||
"helpText": "Choose your setup method.",
|
||||
"options": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Simple Data Source (Direct Link)
|
||||
|
||||
```ts
|
||||
import elbUrl from '@/assets/Logos/elb.svg';
|
||||
|
||||
// inside the onboardingConfigWithLinks array:
|
||||
```json
|
||||
{
|
||||
dataSource: 'aws-elb',
|
||||
label: 'AWS ELB',
|
||||
tags: ['AWS'],
|
||||
module: 'logs',
|
||||
relatedSearchKeywords: [
|
||||
'aws',
|
||||
'aws elb',
|
||||
'elb logs',
|
||||
'elastic load balancer',
|
||||
"dataSource": "aws-elb",
|
||||
"label": "AWS ELB",
|
||||
"tags": ["AWS"],
|
||||
"module": "logs",
|
||||
"relatedSearchKeywords": [
|
||||
"aws",
|
||||
"aws elb",
|
||||
"elb logs",
|
||||
"elastic load balancer"
|
||||
],
|
||||
imgUrl: elbUrl,
|
||||
link: '/docs/aws-monitoring/elb/',
|
||||
},
|
||||
"imgUrl": "/Logos/elb.svg",
|
||||
"link": "/docs/aws-monitoring/elb/"
|
||||
}
|
||||
```
|
||||
|
||||
### Data Source with Single Question Level
|
||||
|
||||
```ts
|
||||
import azureVmUrl from '@/assets/Logos/azure-vm.svg';
|
||||
|
||||
// inside the onboardingConfigWithLinks array:
|
||||
```json
|
||||
{
|
||||
dataSource: 'app-service',
|
||||
label: 'App Service',
|
||||
imgUrl: azureVmUrl,
|
||||
tags: ['Azure'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: ['azure', 'app service'],
|
||||
question: {
|
||||
desc: 'What telemetry data do you want to visualise?',
|
||||
type: 'select',
|
||||
options: [
|
||||
"dataSource": "app-service",
|
||||
"label": "App Service",
|
||||
"imgUrl": "/Logos/azure-vm.svg",
|
||||
"tags": ["Azure"],
|
||||
"module": "apm",
|
||||
"relatedSearchKeywords": ["azure", "app service"],
|
||||
"question": {
|
||||
"desc": "What telemetry data do you want to visualise?",
|
||||
"type": "select",
|
||||
"options": [
|
||||
{
|
||||
key: 'logging',
|
||||
label: 'Logs',
|
||||
imgUrl: azureVmUrl,
|
||||
link: '/docs/azure-monitoring/app-service/logging/',
|
||||
"key": "logging",
|
||||
"label": "Logs",
|
||||
"imgUrl": "/Logos/azure-vm.svg",
|
||||
"link": "/docs/azure-monitoring/app-service/logging/"
|
||||
},
|
||||
{
|
||||
key: 'metrics',
|
||||
label: 'Metrics',
|
||||
imgUrl: azureVmUrl,
|
||||
link: '/docs/azure-monitoring/app-service/metrics/',
|
||||
"key": "metrics",
|
||||
"label": "Metrics",
|
||||
"imgUrl": "/Logos/azure-vm.svg",
|
||||
"link": "/docs/azure-monitoring/app-service/metrics/"
|
||||
},
|
||||
{
|
||||
key: 'tracing',
|
||||
label: 'Traces',
|
||||
imgUrl: azureVmUrl,
|
||||
link: '/docs/azure-monitoring/app-service/tracing/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"key": "tracing",
|
||||
"label": "Traces",
|
||||
"imgUrl": "/Logos/azure-vm.svg",
|
||||
"link": "/docs/azure-monitoring/app-service/tracing/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Data Source with Nested Questions (2-3 Levels)
|
||||
|
||||
```ts
|
||||
import ec2Url from '@/assets/Logos/ec2.svg';
|
||||
|
||||
// inside the onboardingConfigWithLinks array:
|
||||
```json
|
||||
{
|
||||
dataSource: 'aws-ec2',
|
||||
label: 'AWS EC2',
|
||||
tags: ['AWS'],
|
||||
module: 'logs',
|
||||
relatedSearchKeywords: ['aws', 'aws ec2', 'ec2 logs', 'ec2 metrics'],
|
||||
imgUrl: ec2Url,
|
||||
question: {
|
||||
desc: 'What would you like to monitor for AWS EC2?',
|
||||
type: 'select',
|
||||
helpText: 'Choose the type of telemetry data you want to collect.',
|
||||
options: [
|
||||
"dataSource": "aws-ec2",
|
||||
"label": "AWS EC2",
|
||||
"tags": ["AWS"],
|
||||
"module": "logs",
|
||||
"relatedSearchKeywords": ["aws", "aws ec2", "ec2 logs", "ec2 metrics"],
|
||||
"imgUrl": "/Logos/ec2.svg",
|
||||
"question": {
|
||||
"desc": "What would you like to monitor for AWS EC2?",
|
||||
"type": "select",
|
||||
"helpText": "Choose the type of telemetry data you want to collect.",
|
||||
"options": [
|
||||
{
|
||||
key: 'aws-ec2-logs',
|
||||
label: 'Logs',
|
||||
imgUrl: ec2Url,
|
||||
link: '/docs/userguide/collect_logs_from_file/',
|
||||
"key": "aws-ec2-logs",
|
||||
"label": "Logs",
|
||||
"imgUrl": "/Logos/ec2.svg",
|
||||
"link": "/docs/userguide/collect_logs_from_file/"
|
||||
},
|
||||
{
|
||||
key: 'aws-ec2-metrics',
|
||||
label: 'Metrics',
|
||||
imgUrl: ec2Url,
|
||||
question: {
|
||||
desc: 'How would you like to set up EC2 Metrics monitoring?',
|
||||
helpText: 'One Click uses AWS CloudWatch integration. Manual setup uses OpenTelemetry.',
|
||||
helpLink: '/docs/aws-monitoring/one-click-vs-manual/',
|
||||
helpLinkText: 'Read the comparison guide →',
|
||||
options: [
|
||||
"key": "aws-ec2-metrics",
|
||||
"label": "Metrics",
|
||||
"imgUrl": "/Logos/ec2.svg",
|
||||
"question": {
|
||||
"desc": "How would you like to set up EC2 Metrics monitoring?",
|
||||
"helpText": "One Click uses AWS CloudWatch integration. Manual setup uses OpenTelemetry.",
|
||||
"helpLink": "/docs/aws-monitoring/one-click-vs-manual/",
|
||||
"helpLinkText": "Read the comparison guide →",
|
||||
"options": [
|
||||
{
|
||||
key: 'aws-ec2-metrics-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: ec2Url,
|
||||
link: '/integrations?integration=aws-integration&service=ec2',
|
||||
internalRedirect: true,
|
||||
"key": "aws-ec2-metrics-one-click",
|
||||
"label": "One Click AWS",
|
||||
"imgUrl": "/Logos/ec2.svg",
|
||||
"link": "/integrations?integration=aws-integration&service=ec2",
|
||||
"internalRedirect": true
|
||||
},
|
||||
{
|
||||
key: 'aws-ec2-metrics-manual',
|
||||
label: 'Manual Setup',
|
||||
imgUrl: ec2Url,
|
||||
link: '/docs/tutorial/opentelemetry-binary-usage-in-virtual-machine/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"key": "aws-ec2-metrics-manual",
|
||||
"label": "Manual Setup",
|
||||
"imgUrl": "/Logos/ec2.svg",
|
||||
"link": "/docs/tutorial/opentelemetry-binary-usage-in-virtual-machine/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
@@ -277,16 +270,11 @@ import ec2Url from '@/assets/Logos/ec2.svg';
|
||||
|
||||
### 3. Logos
|
||||
|
||||
- Place logo files in `src/assets/Logos/`
|
||||
- Place logo files in `public/Logos/`
|
||||
- Use SVG format
|
||||
- Import the SVG at the top of the file and reference the imported variable:
|
||||
```ts
|
||||
import myServiceUrl from '@/assets/Logos/my-service.svg';
|
||||
// then in the config object:
|
||||
imgUrl: myServiceUrl,
|
||||
```
|
||||
- Reference as `"/Logos/your-logo.svg"`
|
||||
- **Fetching Icons**: New icons can be easily fetched from [OpenBrand](https://openbrand.sh/). Use the pattern `https://openbrand.sh/?url=<TARGET_URL>`, where `<TARGET_URL>` is the URL-encoded link to the service's website. For example, to get Render's logo, use [https://openbrand.sh/?url=https%3A%2F%2Frender.com](https://openbrand.sh/?url=https%3A%2F%2Frender.com).
|
||||
- **Optimize new SVGs**: Run any newly downloaded SVGs through an optimizer like [SVGOMG (svgo)](https://svgomg.net/) or use `npx svgo src/assets/Logos/your-logo.svg` to minimise their size before committing.
|
||||
- **Optimize new SVGs**: Run any newly downloaded SVGs through an optimizer like [SVGOMG (svgo)](https://svgomg.net/) or use `npx svgo public/Logos/your-logo.svg` to minimise their size before committing.
|
||||
|
||||
### 4. Links
|
||||
|
||||
@@ -302,8 +290,8 @@ import ec2Url from '@/assets/Logos/ec2.svg';
|
||||
|
||||
## Adding a New Data Source
|
||||
|
||||
1. Add the logo SVG to `src/assets/Logos/` and add a top-level import in the config file (e.g., `import myServiceUrl from '@/assets/Logos/my-service.svg'`)
|
||||
2. Add your data source object to the `onboardingConfigWithLinks` array, referencing the imported variable for `imgUrl`
|
||||
1. Add your data source object to the JSON array
|
||||
2. Ensure the logo exists in `public/Logos/`
|
||||
3. Test the flow locally with `yarn dev`
|
||||
4. Validation:
|
||||
- Navigate to the [onboarding page](http://localhost:3301/get-started-with-signoz-cloud) on your local machine
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
|
||||
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
|
||||
type APIHandlerOptions struct {
|
||||
DataConnector interfaces.Reader
|
||||
RulesManager *rules.Manager
|
||||
UsageManager *usage.Manager
|
||||
IntegrationsController *integrations.Controller
|
||||
CloudIntegrationsController *cloudintegrations.Controller
|
||||
@@ -41,6 +43,7 @@ type APIHandler struct {
|
||||
func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.Config) (*APIHandler, error) {
|
||||
baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{
|
||||
Reader: opts.DataConnector,
|
||||
RuleManager: opts.RulesManager,
|
||||
IntegrationsController: opts.IntegrationsController,
|
||||
CloudIntegrationsController: opts.CloudIntegrationsController,
|
||||
LogsParsingPipelineController: opts.LogsParsingPipelineController,
|
||||
@@ -61,6 +64,10 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.
|
||||
return ah, nil
|
||||
}
|
||||
|
||||
func (ah *APIHandler) RM() *rules.Manager {
|
||||
return ah.opts.RulesManager
|
||||
}
|
||||
|
||||
func (ah *APIHandler) UM() *usage.Manager {
|
||||
return ah.opts.UsageManager
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/cache/memorycache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
|
||||
@@ -19,10 +23,18 @@ import (
|
||||
"github.com/soheilhy/cmux"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/query-service/app/api"
|
||||
"github.com/SigNoz/signoz/ee/query-service/rules"
|
||||
"github.com/SigNoz/signoz/ee/query-service/usage"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/cache"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/web"
|
||||
|
||||
"log/slog"
|
||||
@@ -37,6 +49,7 @@ import (
|
||||
opAmpModel "github.com/SigNoz/signoz/pkg/query-service/app/opamp/model"
|
||||
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
|
||||
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
)
|
||||
|
||||
@@ -44,6 +57,7 @@ import (
|
||||
type Server struct {
|
||||
config signoz.Config
|
||||
signoz *signoz.SigNoz
|
||||
ruleManager *baserules.Manager
|
||||
|
||||
// public http router
|
||||
httpConn net.Listener
|
||||
@@ -83,6 +97,24 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
nil,
|
||||
)
|
||||
|
||||
rm, err := makeRulesManager(
|
||||
signoz.Cache,
|
||||
signoz.Alertmanager,
|
||||
signoz.SQLStore,
|
||||
signoz.TelemetryStore,
|
||||
signoz.TelemetryMetadataStore,
|
||||
signoz.Prometheus,
|
||||
signoz.Modules.OrgGetter,
|
||||
signoz.Modules.RuleStateHistory,
|
||||
signoz.Querier,
|
||||
signoz.Instrumentation.ToProviderSettings(),
|
||||
signoz.QueryParser,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// initiate opamp
|
||||
opAmpModel.Init(signoz.SQLStore, signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter)
|
||||
|
||||
@@ -131,6 +163,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
|
||||
apiOpts := api.APIHandlerOptions{
|
||||
DataConnector: reader,
|
||||
RulesManager: rm,
|
||||
UsageManager: usageManager,
|
||||
IntegrationsController: integrationsController,
|
||||
CloudIntegrationsController: cloudIntegrationsController,
|
||||
@@ -147,7 +180,8 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
|
||||
s := &Server{
|
||||
config: config,
|
||||
signoz: signoz,
|
||||
signoz: signoz,
|
||||
ruleManager: rm,
|
||||
httpHostPort: baseconst.HTTPHostPort,
|
||||
unavailableChannel: make(chan healthcheck.Status),
|
||||
usageManager: usageManager,
|
||||
@@ -254,6 +288,8 @@ func (s *Server) initListeners() error {
|
||||
|
||||
// Start listening on http and private http port concurrently
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
s.ruleManager.Start(ctx)
|
||||
|
||||
err := s.initListeners()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -297,9 +333,47 @@ func (s *Server) Stop(ctx context.Context) error {
|
||||
|
||||
s.opampServer.Stop()
|
||||
|
||||
if s.ruleManager != nil {
|
||||
s.ruleManager.Stop(ctx)
|
||||
}
|
||||
|
||||
// stop usage manager
|
||||
s.usageManager.Stop(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeRulesManager(cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, ruleStateHistoryModule rulestatehistory.Module, querier querier.Querier, providerSettings factory.ProviderSettings, queryParser queryparser.QueryParser) (*baserules.Manager, error) {
|
||||
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
|
||||
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
|
||||
// create manager opts
|
||||
managerOpts := &baserules.ManagerOptions{
|
||||
TelemetryStore: telemetryStore,
|
||||
MetadataStore: metadataStore,
|
||||
Prometheus: prometheus,
|
||||
Context: context.Background(),
|
||||
Querier: querier,
|
||||
Logger: providerSettings.Logger,
|
||||
Cache: cache,
|
||||
EvalDelay: baseconst.GetEvalDelay(),
|
||||
PrepareTaskFunc: rules.PrepareTaskFunc,
|
||||
PrepareTestRuleFunc: rules.TestNotification,
|
||||
Alertmanager: alertmanager,
|
||||
OrgGetter: orgGetter,
|
||||
RuleStore: ruleStore,
|
||||
MaintenanceStore: maintenanceStore,
|
||||
SQLStore: sqlstore,
|
||||
QueryParser: queryParser,
|
||||
RuleStateHistoryModule: ruleStateHistoryModule,
|
||||
}
|
||||
|
||||
// create Manager
|
||||
manager, err := baserules.NewManager(managerOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rule manager error: %v", err)
|
||||
}
|
||||
|
||||
slog.Info("rules manager is ready")
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
@@ -1,480 +0,0 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
|
||||
import type { BodyType, ErrorType } from '../../../generatedAPIInstance';
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type {
|
||||
DeleteDowntimeScheduleByIDPathParameters,
|
||||
GetDowntimeScheduleByID200,
|
||||
GetDowntimeScheduleByIDPathParameters,
|
||||
ListDowntimeSchedules200,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesGettablePlannedMaintenanceDTO,
|
||||
UpdateDowntimeScheduleByIDPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* This endpoint lists all planned maintenance / downtime schedules
|
||||
* @summary List downtime schedules
|
||||
*/
|
||||
export const listDowntimeSchedules = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListDowntimeSchedules200>({
|
||||
url: `/api/v1/downtime_schedules`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListDowntimeSchedulesQueryKey = () => {
|
||||
return [`/api/v1/downtime_schedules`] as const;
|
||||
};
|
||||
|
||||
export const getListDowntimeSchedulesQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listDowntimeSchedules>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listDowntimeSchedules>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListDowntimeSchedulesQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listDowntimeSchedules>>
|
||||
> = ({ signal }) => listDowntimeSchedules(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listDowntimeSchedules>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListDowntimeSchedulesQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listDowntimeSchedules>>
|
||||
>;
|
||||
export type ListDowntimeSchedulesQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List downtime schedules
|
||||
*/
|
||||
|
||||
export function useListDowntimeSchedules<
|
||||
TData = Awaited<ReturnType<typeof listDowntimeSchedules>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listDowntimeSchedules>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListDowntimeSchedulesQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List downtime schedules
|
||||
*/
|
||||
export const invalidateListDowntimeSchedules = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListDowntimeSchedulesQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates a new planned maintenance / downtime schedule
|
||||
* @summary Create downtime schedule
|
||||
*/
|
||||
export const createDowntimeSchedule = (
|
||||
ruletypesGettablePlannedMaintenanceDTO: BodyType<RuletypesGettablePlannedMaintenanceDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/downtime_schedules`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: ruletypesGettablePlannedMaintenanceDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateDowntimeScheduleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createDowntimeSchedule>>,
|
||||
TError,
|
||||
{ data: BodyType<RuletypesGettablePlannedMaintenanceDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createDowntimeSchedule>>,
|
||||
TError,
|
||||
{ data: BodyType<RuletypesGettablePlannedMaintenanceDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createDowntimeSchedule'];
|
||||
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 createDowntimeSchedule>>,
|
||||
{ data: BodyType<RuletypesGettablePlannedMaintenanceDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createDowntimeSchedule(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateDowntimeScheduleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createDowntimeSchedule>>
|
||||
>;
|
||||
export type CreateDowntimeScheduleMutationBody = BodyType<RuletypesGettablePlannedMaintenanceDTO>;
|
||||
export type CreateDowntimeScheduleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create downtime schedule
|
||||
*/
|
||||
export const useCreateDowntimeSchedule = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createDowntimeSchedule>>,
|
||||
TError,
|
||||
{ data: BodyType<RuletypesGettablePlannedMaintenanceDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createDowntimeSchedule>>,
|
||||
TError,
|
||||
{ data: BodyType<RuletypesGettablePlannedMaintenanceDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getCreateDowntimeScheduleMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint deletes a downtime schedule by ID
|
||||
* @summary Delete downtime schedule
|
||||
*/
|
||||
export const deleteDowntimeScheduleByID = ({
|
||||
id,
|
||||
}: DeleteDowntimeScheduleByIDPathParameters) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/downtime_schedules/${id}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteDowntimeScheduleByIDMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteDowntimeScheduleByID>>,
|
||||
TError,
|
||||
{ pathParams: DeleteDowntimeScheduleByIDPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteDowntimeScheduleByID>>,
|
||||
TError,
|
||||
{ pathParams: DeleteDowntimeScheduleByIDPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteDowntimeScheduleByID'];
|
||||
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 deleteDowntimeScheduleByID>>,
|
||||
{ pathParams: DeleteDowntimeScheduleByIDPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteDowntimeScheduleByID(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteDowntimeScheduleByIDMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteDowntimeScheduleByID>>
|
||||
>;
|
||||
|
||||
export type DeleteDowntimeScheduleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete downtime schedule
|
||||
*/
|
||||
export const useDeleteDowntimeScheduleByID = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteDowntimeScheduleByID>>,
|
||||
TError,
|
||||
{ pathParams: DeleteDowntimeScheduleByIDPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteDowntimeScheduleByID>>,
|
||||
TError,
|
||||
{ pathParams: DeleteDowntimeScheduleByIDPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getDeleteDowntimeScheduleByIDMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint returns a downtime schedule by ID
|
||||
* @summary Get downtime schedule by ID
|
||||
*/
|
||||
export const getDowntimeScheduleByID = (
|
||||
{ id }: GetDowntimeScheduleByIDPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetDowntimeScheduleByID200>({
|
||||
url: `/api/v1/downtime_schedules/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetDowntimeScheduleByIDQueryKey = ({
|
||||
id,
|
||||
}: GetDowntimeScheduleByIDPathParameters) => {
|
||||
return [`/api/v1/downtime_schedules/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetDowntimeScheduleByIDQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getDowntimeScheduleByID>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(
|
||||
{ id }: GetDowntimeScheduleByIDPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getDowntimeScheduleByID>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetDowntimeScheduleByIDQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getDowntimeScheduleByID>>
|
||||
> = ({ signal }) => getDowntimeScheduleByID({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getDowntimeScheduleByID>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetDowntimeScheduleByIDQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getDowntimeScheduleByID>>
|
||||
>;
|
||||
export type GetDowntimeScheduleByIDQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get downtime schedule by ID
|
||||
*/
|
||||
|
||||
export function useGetDowntimeScheduleByID<
|
||||
TData = Awaited<ReturnType<typeof getDowntimeScheduleByID>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(
|
||||
{ id }: GetDowntimeScheduleByIDPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getDowntimeScheduleByID>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetDowntimeScheduleByIDQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get downtime schedule by ID
|
||||
*/
|
||||
export const invalidateGetDowntimeScheduleByID = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetDowntimeScheduleByIDPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetDowntimeScheduleByIDQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint updates a downtime schedule by ID
|
||||
* @summary Update downtime schedule
|
||||
*/
|
||||
export const updateDowntimeScheduleByID = (
|
||||
{ id }: UpdateDowntimeScheduleByIDPathParameters,
|
||||
ruletypesGettablePlannedMaintenanceDTO: BodyType<RuletypesGettablePlannedMaintenanceDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/downtime_schedules/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: ruletypesGettablePlannedMaintenanceDTO,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateDowntimeScheduleByIDMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDowntimeScheduleByID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDowntimeScheduleByIDPathParameters;
|
||||
data: BodyType<RuletypesGettablePlannedMaintenanceDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDowntimeScheduleByID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDowntimeScheduleByIDPathParameters;
|
||||
data: BodyType<RuletypesGettablePlannedMaintenanceDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateDowntimeScheduleByID'];
|
||||
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 updateDowntimeScheduleByID>>,
|
||||
{
|
||||
pathParams: UpdateDowntimeScheduleByIDPathParameters;
|
||||
data: BodyType<RuletypesGettablePlannedMaintenanceDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateDowntimeScheduleByID(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateDowntimeScheduleByIDMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateDowntimeScheduleByID>>
|
||||
>;
|
||||
export type UpdateDowntimeScheduleByIDMutationBody = BodyType<RuletypesGettablePlannedMaintenanceDTO>;
|
||||
export type UpdateDowntimeScheduleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update downtime schedule
|
||||
*/
|
||||
export const useUpdateDowntimeScheduleByID = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDowntimeScheduleByID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDowntimeScheduleByIDPathParameters;
|
||||
data: BodyType<RuletypesGettablePlannedMaintenanceDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateDowntimeScheduleByID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDowntimeScheduleByIDPathParameters;
|
||||
data: BodyType<RuletypesGettablePlannedMaintenanceDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getUpdateDowntimeScheduleByIDMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
@@ -6,24 +6,17 @@
|
||||
*/
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import type { BodyType, ErrorType } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType } from '../../../generatedAPIInstance';
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type {
|
||||
CreateRule200,
|
||||
DeleteRuleByIDPathParameters,
|
||||
GetRuleByID200,
|
||||
GetRuleByIDPathParameters,
|
||||
GetRuleHistoryFilterKeys200,
|
||||
GetRuleHistoryFilterKeysParams,
|
||||
GetRuleHistoryFilterKeysPathParameters,
|
||||
@@ -42,715 +35,9 @@ import type {
|
||||
GetRuleHistoryTopContributors200,
|
||||
GetRuleHistoryTopContributorsParams,
|
||||
GetRuleHistoryTopContributorsPathParameters,
|
||||
ListRules200,
|
||||
PatchRuleByID200,
|
||||
PatchRuleByIDPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesPostableRuleDTO,
|
||||
UpdateRuleByIDPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* This endpoint lists all alert rules with their current evaluation state
|
||||
* @summary List alert rules
|
||||
*/
|
||||
export const listRules = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListRules200>({
|
||||
url: `/api/v1/rules`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListRulesQueryKey = () => {
|
||||
return [`/api/v1/rules`] as const;
|
||||
};
|
||||
|
||||
export const getListRulesQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listRules>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof listRules>>, TError, TData>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListRulesQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRules>>> = ({
|
||||
signal,
|
||||
}) => listRules(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRules>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListRulesQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listRules>>
|
||||
>;
|
||||
export type ListRulesQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List alert rules
|
||||
*/
|
||||
|
||||
export function useListRules<
|
||||
TData = Awaited<ReturnType<typeof listRules>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof listRules>>, TError, TData>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListRulesQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List alert rules
|
||||
*/
|
||||
export const invalidateListRules = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListRulesQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates a new alert rule
|
||||
* @summary Create alert rule
|
||||
*/
|
||||
export const createRule = (
|
||||
ruletypesPostableRuleDTO: BodyType<RuletypesPostableRuleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateRule200>({
|
||||
url: `/api/v1/rules`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: ruletypesPostableRuleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateRuleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createRule>>,
|
||||
TError,
|
||||
{ data: BodyType<RuletypesPostableRuleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createRule>>,
|
||||
TError,
|
||||
{ data: BodyType<RuletypesPostableRuleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createRule'];
|
||||
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 createRule>>,
|
||||
{ data: BodyType<RuletypesPostableRuleDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createRule(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateRuleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createRule>>
|
||||
>;
|
||||
export type CreateRuleMutationBody = BodyType<RuletypesPostableRuleDTO>;
|
||||
export type CreateRuleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create alert rule
|
||||
*/
|
||||
export const useCreateRule = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createRule>>,
|
||||
TError,
|
||||
{ data: BodyType<RuletypesPostableRuleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createRule>>,
|
||||
TError,
|
||||
{ data: BodyType<RuletypesPostableRuleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getCreateRuleMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint deletes an alert rule by ID
|
||||
* @summary Delete alert rule
|
||||
*/
|
||||
export const deleteRuleByID = ({ id }: DeleteRuleByIDPathParameters) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/rules/${id}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteRuleByIDMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: DeleteRuleByIDPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: DeleteRuleByIDPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteRuleByID'];
|
||||
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 deleteRuleByID>>,
|
||||
{ pathParams: DeleteRuleByIDPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteRuleByID(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteRuleByIDMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteRuleByID>>
|
||||
>;
|
||||
|
||||
export type DeleteRuleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete alert rule
|
||||
*/
|
||||
export const useDeleteRuleByID = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: DeleteRuleByIDPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: DeleteRuleByIDPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getDeleteRuleByIDMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint returns an alert rule by ID
|
||||
* @summary Get alert rule by ID
|
||||
*/
|
||||
export const getRuleByID = (
|
||||
{ id }: GetRuleByIDPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetRuleByID200>({
|
||||
url: `/api/v1/rules/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetRuleByIDQueryKey = ({ id }: GetRuleByIDPathParameters) => {
|
||||
return [`/api/v1/rules/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetRuleByIDQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getRuleByID>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(
|
||||
{ id }: GetRuleByIDPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getRuleByID>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetRuleByIDQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getRuleByID>>> = ({
|
||||
signal,
|
||||
}) => getRuleByID({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getRuleByID>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetRuleByIDQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getRuleByID>>
|
||||
>;
|
||||
export type GetRuleByIDQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get alert rule by ID
|
||||
*/
|
||||
|
||||
export function useGetRuleByID<
|
||||
TData = Awaited<ReturnType<typeof getRuleByID>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(
|
||||
{ id }: GetRuleByIDPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getRuleByID>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetRuleByIDQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get alert rule by ID
|
||||
*/
|
||||
export const invalidateGetRuleByID = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetRuleByIDPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetRuleByIDQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint applies a partial update to an alert rule by ID
|
||||
* @summary Patch alert rule
|
||||
*/
|
||||
export const patchRuleByID = (
|
||||
{ id }: PatchRuleByIDPathParameters,
|
||||
ruletypesPostableRuleDTO: BodyType<RuletypesPostableRuleDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PatchRuleByID200>({
|
||||
url: `/api/v1/rules/${id}`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: ruletypesPostableRuleDTO,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPatchRuleByIDMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchRuleByID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchRuleByIDPathParameters;
|
||||
data: BodyType<RuletypesPostableRuleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchRuleByID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchRuleByIDPathParameters;
|
||||
data: BodyType<RuletypesPostableRuleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['patchRuleByID'];
|
||||
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 patchRuleByID>>,
|
||||
{
|
||||
pathParams: PatchRuleByIDPathParameters;
|
||||
data: BodyType<RuletypesPostableRuleDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return patchRuleByID(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PatchRuleByIDMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof patchRuleByID>>
|
||||
>;
|
||||
export type PatchRuleByIDMutationBody = BodyType<RuletypesPostableRuleDTO>;
|
||||
export type PatchRuleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Patch alert rule
|
||||
*/
|
||||
export const usePatchRuleByID = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchRuleByID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchRuleByIDPathParameters;
|
||||
data: BodyType<RuletypesPostableRuleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof patchRuleByID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchRuleByIDPathParameters;
|
||||
data: BodyType<RuletypesPostableRuleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getPatchRuleByIDMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint updates an alert rule by ID
|
||||
* @summary Update alert rule
|
||||
*/
|
||||
export const updateRuleByID = (
|
||||
{ id }: UpdateRuleByIDPathParameters,
|
||||
ruletypesPostableRuleDTO: BodyType<RuletypesPostableRuleDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/rules/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: ruletypesPostableRuleDTO,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateRuleByIDMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateRuleByID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRuleByIDPathParameters;
|
||||
data: BodyType<RuletypesPostableRuleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateRuleByID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRuleByIDPathParameters;
|
||||
data: BodyType<RuletypesPostableRuleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateRuleByID'];
|
||||
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 updateRuleByID>>,
|
||||
{
|
||||
pathParams: UpdateRuleByIDPathParameters;
|
||||
data: BodyType<RuletypesPostableRuleDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateRuleByID(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateRuleByIDMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateRuleByID>>
|
||||
>;
|
||||
export type UpdateRuleByIDMutationBody = BodyType<RuletypesPostableRuleDTO>;
|
||||
export type UpdateRuleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update alert rule
|
||||
*/
|
||||
export const useUpdateRuleByID = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateRuleByID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRuleByIDPathParameters;
|
||||
data: BodyType<RuletypesPostableRuleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateRuleByID>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRuleByIDPathParameters;
|
||||
data: BodyType<RuletypesPostableRuleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getUpdateRuleByIDMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint fires a test notification for the given rule definition
|
||||
* @summary Test alert rule
|
||||
*/
|
||||
export const testRule = (
|
||||
ruletypesPostableRuleDTO: BodyType<RuletypesPostableRuleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/rules/test`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: ruletypesPostableRuleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getTestRuleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testRule>>,
|
||||
TError,
|
||||
{ data: BodyType<RuletypesPostableRuleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testRule>>,
|
||||
TError,
|
||||
{ data: BodyType<RuletypesPostableRuleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['testRule'];
|
||||
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 testRule>>,
|
||||
{ data: BodyType<RuletypesPostableRuleDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return testRule(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type TestRuleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof testRule>>
|
||||
>;
|
||||
export type TestRuleMutationBody = BodyType<RuletypesPostableRuleDTO>;
|
||||
export type TestRuleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Test alert rule
|
||||
*/
|
||||
export const useTestRule = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testRule>>,
|
||||
TError,
|
||||
{ data: BodyType<RuletypesPostableRuleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof testRule>>,
|
||||
TError,
|
||||
{ data: BodyType<RuletypesPostableRuleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getTestRuleMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Deprecated: use /api/v1/rules/test instead
|
||||
* @deprecated
|
||||
* @summary Test alert rule (deprecated)
|
||||
*/
|
||||
export const testRuleDeprecated = (
|
||||
ruletypesPostableRuleDTO: BodyType<RuletypesPostableRuleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v1/testRule`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: ruletypesPostableRuleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getTestRuleDeprecatedMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testRuleDeprecated>>,
|
||||
TError,
|
||||
{ data: BodyType<RuletypesPostableRuleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testRuleDeprecated>>,
|
||||
TError,
|
||||
{ data: BodyType<RuletypesPostableRuleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['testRuleDeprecated'];
|
||||
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 testRuleDeprecated>>,
|
||||
{ data: BodyType<RuletypesPostableRuleDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return testRuleDeprecated(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type TestRuleDeprecatedMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof testRuleDeprecated>>
|
||||
>;
|
||||
export type TestRuleDeprecatedMutationBody = BodyType<RuletypesPostableRuleDTO>;
|
||||
export type TestRuleDeprecatedMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Test alert rule (deprecated)
|
||||
*/
|
||||
export const useTestRuleDeprecated = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testRuleDeprecated>>,
|
||||
TError,
|
||||
{ data: BodyType<RuletypesPostableRuleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof testRuleDeprecated>>,
|
||||
TError,
|
||||
{ data: BodyType<RuletypesPostableRuleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getTestRuleDeprecatedMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Returns distinct label keys from rule history entries for the selected range.
|
||||
* @summary Get rule history filter keys
|
||||
|
||||
@@ -4529,26 +4529,6 @@ export interface RulestatehistorytypesGettableRuleStateWindowDTO {
|
||||
state: RuletypesAlertStateDTO;
|
||||
}
|
||||
|
||||
export interface RuletypesAlertCompositeQueryDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
panelType?: string;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
queries?: Querybuildertypesv5QueryEnvelopeDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
queryType?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export enum RuletypesAlertStateDTO {
|
||||
inactive = 'inactive',
|
||||
pending = 'pending',
|
||||
@@ -4557,364 +4537,6 @@ export enum RuletypesAlertStateDTO {
|
||||
nodata = 'nodata',
|
||||
disabled = 'disabled',
|
||||
}
|
||||
export enum RuletypesCompareOperatorDTO {
|
||||
above = 'above',
|
||||
below = 'below',
|
||||
equal = 'equal',
|
||||
not_equal = 'not_equal',
|
||||
outside_bounds = 'outside_bounds',
|
||||
}
|
||||
export interface RuletypesEvaluationEnvelopeDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
kind?: string;
|
||||
spec?: unknown;
|
||||
}
|
||||
|
||||
export interface RuletypesGettablePlannedMaintenanceDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
alertIds?: string[] | null;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
kind?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
schedule?: RuletypesScheduleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export type RuletypesGettableRuleDTOAnnotations = { [key: string]: string };
|
||||
|
||||
export type RuletypesGettableRuleDTOLabels = { [key: string]: string };
|
||||
|
||||
export interface RuletypesGettableRuleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
alert?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
alertType?: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
annotations?: RuletypesGettableRuleDTOAnnotations;
|
||||
condition?: RuletypesRuleConditionDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
* @nullable true
|
||||
*/
|
||||
createAt?: Date | null;
|
||||
/**
|
||||
* @type string
|
||||
* @nullable true
|
||||
*/
|
||||
createBy?: string | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
evalWindow?: string;
|
||||
evaluation?: RuletypesEvaluationEnvelopeDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
frequency?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
labels?: RuletypesGettableRuleDTOLabels;
|
||||
notificationSettings?: RuletypesNotificationSettingsDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
preferredChannels?: string[];
|
||||
ruleType?: RuletypesRuleTypeDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
schemaVersion?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
source?: string;
|
||||
state?: RuletypesAlertStateDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
* @nullable true
|
||||
*/
|
||||
updateAt?: Date | null;
|
||||
/**
|
||||
* @type string
|
||||
* @nullable true
|
||||
*/
|
||||
updateBy?: string | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface RuletypesGettableRulesDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
rules?: RuletypesGettableRuleDTO[] | null;
|
||||
}
|
||||
|
||||
export enum RuletypesMatchTypeDTO {
|
||||
at_least_once = 'at_least_once',
|
||||
all_the_times = 'all_the_times',
|
||||
on_average = 'on_average',
|
||||
in_total = 'in_total',
|
||||
last = 'last',
|
||||
}
|
||||
export interface RuletypesNotificationSettingsDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
groupBy?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
newGroupEvalDelay?: string;
|
||||
renotify?: RuletypesRenotifyDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
usePolicy?: boolean;
|
||||
}
|
||||
|
||||
export type RuletypesPostableRuleDTOAnnotations = { [key: string]: string };
|
||||
|
||||
export type RuletypesPostableRuleDTOLabels = { [key: string]: string };
|
||||
|
||||
export interface RuletypesPostableRuleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
alert?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
alertType?: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
annotations?: RuletypesPostableRuleDTOAnnotations;
|
||||
condition?: RuletypesRuleConditionDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
evalWindow?: string;
|
||||
evaluation?: RuletypesEvaluationEnvelopeDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
frequency?: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
labels?: RuletypesPostableRuleDTOLabels;
|
||||
notificationSettings?: RuletypesNotificationSettingsDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
preferredChannels?: string[];
|
||||
ruleType?: RuletypesRuleTypeDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
schemaVersion?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
source?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface RuletypesRecurrenceDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
duration?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
* @nullable true
|
||||
*/
|
||||
endTime?: Date | null;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
repeatOn?: string[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
repeatType?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
startTime?: Date;
|
||||
}
|
||||
|
||||
export interface RuletypesRenotifyDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
alertStates?: RuletypesAlertStateDTO[];
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
interval?: string;
|
||||
}
|
||||
|
||||
export interface RuletypesRuleConditionDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
absentFor?: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
alertOnAbsent?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
algorithm?: string;
|
||||
compositeQuery?: RuletypesAlertCompositeQueryDTO;
|
||||
matchType?: RuletypesMatchTypeDTO;
|
||||
op?: RuletypesCompareOperatorDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
requireMinPoints?: boolean;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
requiredNumPoints?: number;
|
||||
seasonality?: RuletypesSeasonalityDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
selectedQueryName?: string;
|
||||
/**
|
||||
* @type number
|
||||
* @nullable true
|
||||
*/
|
||||
target?: number | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
targetUnit?: string;
|
||||
thresholds?: RuletypesRuleThresholdDataDTO;
|
||||
}
|
||||
|
||||
export interface RuletypesRuleThresholdDataDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
kind?: string;
|
||||
spec?: unknown;
|
||||
}
|
||||
|
||||
export enum RuletypesRuleTypeDTO {
|
||||
threshold_rule = 'threshold_rule',
|
||||
promql_rule = 'promql_rule',
|
||||
anomaly_rule = 'anomaly_rule',
|
||||
}
|
||||
export interface RuletypesScheduleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
endTime?: Date;
|
||||
recurrence?: RuletypesRecurrenceDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
startTime?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export enum RuletypesSeasonalityDTO {
|
||||
hourly = 'hourly',
|
||||
daily = 'daily',
|
||||
weekly = 'weekly',
|
||||
}
|
||||
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -5841,34 +5463,6 @@ export type DeleteAuthDomainPathParameters = {
|
||||
export type UpdateAuthDomainPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type ListDowntimeSchedules200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: RuletypesGettablePlannedMaintenanceDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteDowntimeScheduleByIDPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetDowntimeScheduleByIDPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetDowntimeScheduleByID200 = {
|
||||
data: RuletypesGettablePlannedMaintenanceDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateDowntimeScheduleByIDPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type HandleExportRawDataPOSTParams = {
|
||||
/**
|
||||
* @enum csv,jsonl
|
||||
@@ -6196,50 +5790,6 @@ export type UpdateRoutePolicy200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListRules200 = {
|
||||
data: RuletypesGettableRulesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateRule200 = {
|
||||
data: RuletypesGettableRuleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteRuleByIDPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetRuleByIDPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetRuleByID200 = {
|
||||
data: RuletypesGettableRuleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type PatchRuleByIDPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type PatchRuleByID200 = {
|
||||
data: RuletypesGettableRuleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateRuleByIDPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type ListServiceAccounts200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -37,5 +37,4 @@ export enum LOCALSTORAGE {
|
||||
SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART',
|
||||
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
|
||||
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
|
||||
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
} from 'types/api/settings/getRetention';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import LicenseRowDismissibleCallout from './LicenseKeyRow/LicenseRowDismissibleCallout/LicenseRowDismissibleCallout';
|
||||
import Retention from './Retention';
|
||||
import StatusMessage from './StatusMessage';
|
||||
import { ActionItemsContainer, ErrorText, ErrorTextContainer } from './styles';
|
||||
@@ -684,12 +683,7 @@ function GeneralSettings({
|
||||
{showCustomDomainSettings && activeLicense?.key && (
|
||||
<div className="custom-domain-card-divider" />
|
||||
)}
|
||||
{activeLicense?.key && (
|
||||
<>
|
||||
<LicenseKeyRow />
|
||||
<LicenseRowDismissibleCallout />
|
||||
</>
|
||||
)}
|
||||
{activeLicense?.key && <LicenseKeyRow />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
.license-key-callout {
|
||||
margin: var(--spacing-4) var(--spacing-6);
|
||||
width: auto;
|
||||
|
||||
.license-key-callout__description {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.license-key-callout__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--spacing-1) var(--spacing-3);
|
||||
border-radius: 2px;
|
||||
background: var(--callout-primary-background);
|
||||
color: var(--callout-primary-description);
|
||||
font-family: 'SF Mono', 'Fira Code', 'Fira Mono', monospace;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background: var(--callout-primary-border);
|
||||
color: var(--callout-primary-icon);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import './LicenseRowDismissible.styles.scss';
|
||||
|
||||
function LicenseRowDismissibleCallout(): JSX.Element | null {
|
||||
const [isCalloutDismissed, setIsCalloutDismissed] = useState<boolean>(
|
||||
() =>
|
||||
getLocalStorageApi(LOCALSTORAGE.LICENSE_KEY_CALLOUT_DISMISSED) === 'true',
|
||||
);
|
||||
|
||||
const { user, featureFlags } = useAppContext();
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
const isEditor = user.role === USER_ROLES.EDITOR;
|
||||
|
||||
const isGatewayEnabled =
|
||||
featureFlags?.find((feature) => feature.name === FeatureKeys.GATEWAY)
|
||||
?.active || false;
|
||||
|
||||
const hasServiceAccountsAccess = isAdmin;
|
||||
|
||||
const hasIngestionAccess =
|
||||
(isCloudUser && !isGatewayEnabled) ||
|
||||
(isGatewayEnabled && (isAdmin || isEditor));
|
||||
|
||||
const handleDismissCallout = (): void => {
|
||||
setLocalStorageApi(LOCALSTORAGE.LICENSE_KEY_CALLOUT_DISMISSED, 'true');
|
||||
setIsCalloutDismissed(true);
|
||||
};
|
||||
|
||||
return !isCalloutDismissed ? (
|
||||
<Callout
|
||||
type="info"
|
||||
size="small"
|
||||
showIcon
|
||||
dismissable
|
||||
onClose={handleDismissCallout}
|
||||
className="license-key-callout"
|
||||
description={
|
||||
<div className="license-key-callout__description">
|
||||
This is <strong>NOT</strong> your ingestion or Service account key.
|
||||
{(hasServiceAccountsAccess || hasIngestionAccess) && (
|
||||
<>
|
||||
{' '}
|
||||
Find your{' '}
|
||||
{hasServiceAccountsAccess && (
|
||||
<Link
|
||||
to={ROUTES.SERVICE_ACCOUNTS_SETTINGS}
|
||||
className="license-key-callout__link"
|
||||
>
|
||||
Service account here
|
||||
</Link>
|
||||
)}
|
||||
{hasServiceAccountsAccess && hasIngestionAccess && ' and '}
|
||||
{hasIngestionAccess && (
|
||||
<Link
|
||||
to={ROUTES.INGESTION_SETTINGS}
|
||||
className="license-key-callout__link"
|
||||
>
|
||||
Ingestion key here
|
||||
</Link>
|
||||
)}
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export default LicenseRowDismissibleCallout;
|
||||
@@ -1,229 +0,0 @@
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import LicenseRowDismissibleCallout from '../LicenseRowDismissibleCallout';
|
||||
|
||||
const getDescription = (): HTMLElement =>
|
||||
screen.getByText(
|
||||
(_, el) =>
|
||||
el?.classList?.contains('license-key-callout__description') ?? false,
|
||||
);
|
||||
|
||||
const queryDescription = (): HTMLElement | null =>
|
||||
screen.queryByText(
|
||||
(_, el) =>
|
||||
el?.classList?.contains('license-key-callout__description') ?? false,
|
||||
);
|
||||
|
||||
jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
useGetTenantLicense: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockLicense = (isCloudUser: boolean): void => {
|
||||
(useGetTenantLicense as jest.Mock).mockReturnValue({
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser: !isCloudUser,
|
||||
isCommunityUser: false,
|
||||
isCommunityEnterpriseUser: false,
|
||||
});
|
||||
};
|
||||
|
||||
const renderCallout = (
|
||||
role: string,
|
||||
isCloudUser: boolean,
|
||||
gatewayActive: boolean,
|
||||
): void => {
|
||||
mockLicense(isCloudUser);
|
||||
render(
|
||||
<LicenseRowDismissibleCallout />,
|
||||
{},
|
||||
{
|
||||
role,
|
||||
appContextOverrides: {
|
||||
featureFlags: [
|
||||
{
|
||||
name: FeatureKeys.GATEWAY,
|
||||
active: gatewayActive,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
describe('LicenseRowDismissibleCallout', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('callout content per access level', () => {
|
||||
it.each([
|
||||
{
|
||||
scenario: 'viewer, non-cloud, gateway off — base text only, no links',
|
||||
role: USER_ROLES.VIEWER,
|
||||
isCloudUser: false,
|
||||
gatewayActive: false,
|
||||
serviceAccountLink: false,
|
||||
ingestionLink: false,
|
||||
expectedText: 'This is NOT your ingestion or Service account key.',
|
||||
},
|
||||
{
|
||||
scenario: 'admin, non-cloud, gateway off — service accounts link only',
|
||||
role: USER_ROLES.ADMIN,
|
||||
isCloudUser: false,
|
||||
gatewayActive: false,
|
||||
serviceAccountLink: true,
|
||||
ingestionLink: false,
|
||||
expectedText:
|
||||
'This is NOT your ingestion or Service account key. Find your Service account here.',
|
||||
},
|
||||
{
|
||||
scenario: 'viewer, cloud, gateway off — ingestion link only',
|
||||
role: USER_ROLES.VIEWER,
|
||||
isCloudUser: true,
|
||||
gatewayActive: false,
|
||||
serviceAccountLink: false,
|
||||
ingestionLink: true,
|
||||
expectedText:
|
||||
'This is NOT your ingestion or Service account key. Find your Ingestion key here.',
|
||||
},
|
||||
{
|
||||
scenario: 'admin, cloud, gateway off — both links',
|
||||
role: USER_ROLES.ADMIN,
|
||||
isCloudUser: true,
|
||||
gatewayActive: false,
|
||||
serviceAccountLink: true,
|
||||
ingestionLink: true,
|
||||
expectedText:
|
||||
'This is NOT your ingestion or Service account key. Find your Service account here and Ingestion key here.',
|
||||
},
|
||||
{
|
||||
scenario: 'admin, non-cloud, gateway on — both links',
|
||||
role: USER_ROLES.ADMIN,
|
||||
isCloudUser: false,
|
||||
gatewayActive: true,
|
||||
serviceAccountLink: true,
|
||||
ingestionLink: true,
|
||||
expectedText:
|
||||
'This is NOT your ingestion or Service account key. Find your Service account here and Ingestion key here.',
|
||||
},
|
||||
{
|
||||
scenario: 'editor, non-cloud, gateway on — ingestion link only',
|
||||
role: USER_ROLES.EDITOR,
|
||||
isCloudUser: false,
|
||||
gatewayActive: true,
|
||||
serviceAccountLink: false,
|
||||
ingestionLink: true,
|
||||
expectedText:
|
||||
'This is NOT your ingestion or Service account key. Find your Ingestion key here.',
|
||||
},
|
||||
{
|
||||
scenario: 'editor, cloud, gateway off — ingestion link only',
|
||||
role: USER_ROLES.EDITOR,
|
||||
isCloudUser: true,
|
||||
gatewayActive: false,
|
||||
serviceAccountLink: false,
|
||||
ingestionLink: true,
|
||||
expectedText:
|
||||
'This is NOT your ingestion or Service account key. Find your Ingestion key here.',
|
||||
},
|
||||
])(
|
||||
'$scenario',
|
||||
({
|
||||
role,
|
||||
isCloudUser,
|
||||
gatewayActive,
|
||||
serviceAccountLink,
|
||||
ingestionLink,
|
||||
expectedText,
|
||||
}) => {
|
||||
renderCallout(role, isCloudUser, gatewayActive);
|
||||
|
||||
const description = getDescription();
|
||||
expect(description).toBeInTheDocument();
|
||||
expect(description).toHaveTextContent(expectedText);
|
||||
|
||||
if (serviceAccountLink) {
|
||||
expect(
|
||||
screen.getByRole('link', { name: /Service account here/ }),
|
||||
).toBeInTheDocument();
|
||||
} else {
|
||||
expect(
|
||||
screen.queryByRole('link', { name: /Service account here/ }),
|
||||
).not.toBeInTheDocument();
|
||||
}
|
||||
|
||||
if (ingestionLink) {
|
||||
expect(
|
||||
screen.getByRole('link', { name: /Ingestion key here/ }),
|
||||
).toBeInTheDocument();
|
||||
} else {
|
||||
expect(
|
||||
screen.queryByRole('link', { name: /Ingestion key here/ }),
|
||||
).not.toBeInTheDocument();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Link routing', () => {
|
||||
it('should link to service accounts settings', () => {
|
||||
renderCallout(USER_ROLES.ADMIN, false, false);
|
||||
|
||||
const link = screen.getByRole('link', {
|
||||
name: /Service account here/,
|
||||
}) as HTMLAnchorElement;
|
||||
|
||||
expect(link.getAttribute('href')).toBe(ROUTES.SERVICE_ACCOUNTS_SETTINGS);
|
||||
});
|
||||
|
||||
it('should link to ingestion settings', () => {
|
||||
renderCallout(USER_ROLES.VIEWER, true, false);
|
||||
|
||||
const link = screen.getByRole('link', {
|
||||
name: /Ingestion key here/,
|
||||
}) as HTMLAnchorElement;
|
||||
|
||||
expect(link.getAttribute('href')).toBe(ROUTES.INGESTION_SETTINGS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dismissal functionality', () => {
|
||||
it('should hide callout when dismiss button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCallout(USER_ROLES.ADMIN, false, false);
|
||||
|
||||
expect(getDescription()).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button'));
|
||||
|
||||
expect(queryDescription()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should persist dismissal in localStorage', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCallout(USER_ROLES.ADMIN, false, false);
|
||||
|
||||
await user.click(screen.getByRole('button'));
|
||||
|
||||
expect(
|
||||
localStorage.getItem(LOCALSTORAGE.LICENSE_KEY_CALLOUT_DISMISSED),
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
it('should not render when localStorage dismissal is set', () => {
|
||||
localStorage.setItem(LOCALSTORAGE.LICENSE_KEY_CALLOUT_DISMISSED, 'true');
|
||||
renderCallout(USER_ROLES.ADMIN, false, false);
|
||||
|
||||
expect(queryDescription()).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
4
go.mod
4
go.mod
@@ -15,7 +15,6 @@ require (
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-co-op/gocron v1.30.1
|
||||
github.com/go-openapi/runtime v0.29.2
|
||||
@@ -112,7 +111,6 @@ require (
|
||||
github.com/bytedance/sonic v1.14.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||
@@ -166,7 +164,7 @@ require (
|
||||
github.com/ClickHouse/ch-go v0.67.0 // indirect
|
||||
github.com/Masterminds/squirrel v1.5.4 // indirect
|
||||
github.com/Yiling-J/theine-go v0.6.2 // indirect
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/armon/go-metrics v0.4.1 // indirect
|
||||
github.com/beevik/etree v1.1.0 // indirect
|
||||
|
||||
@@ -1,431 +0,0 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// Copyright 2019 Prometheus Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
const (
|
||||
Integration = "email"
|
||||
)
|
||||
|
||||
// Email implements a Notifier for email notifications.
|
||||
type Email struct {
|
||||
conf *config.EmailConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
hostname string
|
||||
}
|
||||
|
||||
var errNoAuthUsernameConfigured = errors.NewInternalf(errors.CodeInternal, "no auth username configured")
|
||||
|
||||
// New returns a new Email notifier.
|
||||
func New(c *config.EmailConfig, t *template.Template, l *slog.Logger) *Email {
|
||||
if _, ok := c.Headers["Subject"]; !ok {
|
||||
c.Headers["Subject"] = config.DefaultEmailSubject
|
||||
}
|
||||
if _, ok := c.Headers["To"]; !ok {
|
||||
c.Headers["To"] = c.To
|
||||
}
|
||||
if _, ok := c.Headers["From"]; !ok {
|
||||
c.Headers["From"] = c.From
|
||||
}
|
||||
|
||||
h, err := os.Hostname()
|
||||
// If we can't get the hostname, we'll use localhost
|
||||
if err != nil {
|
||||
h = "localhost.localdomain"
|
||||
}
|
||||
return &Email{conf: c, tmpl: t, logger: l, hostname: h}
|
||||
}
|
||||
|
||||
// auth resolves a string of authentication mechanisms.
|
||||
func (n *Email) auth(mechs string) (smtp.Auth, error) {
|
||||
username := n.conf.AuthUsername
|
||||
|
||||
// If no username is set, return custom error which can be ignored if needed.
|
||||
if strings.TrimSpace(username) == "" {
|
||||
return nil, errNoAuthUsernameConfigured
|
||||
}
|
||||
|
||||
var errs error
|
||||
for mech := range strings.SplitSeq(mechs, " ") {
|
||||
switch mech {
|
||||
case "CRAM-MD5":
|
||||
secret, secretErr := n.getAuthSecret()
|
||||
if secretErr != nil {
|
||||
errs = errors.Join(errs, secretErr)
|
||||
continue
|
||||
}
|
||||
if secret == "" {
|
||||
errs = errors.Join(errs, errors.NewInternalf(errors.CodeInternal, "missing secret for CRAM-MD5 auth mechanism"))
|
||||
continue
|
||||
}
|
||||
return smtp.CRAMMD5Auth(username, secret), nil
|
||||
|
||||
case "PLAIN":
|
||||
password, passwordErr := n.getPassword()
|
||||
if passwordErr != nil {
|
||||
errs = errors.Join(errs, passwordErr)
|
||||
continue
|
||||
}
|
||||
if password == "" {
|
||||
errs = errors.Join(errs, errors.NewInternalf(errors.CodeInternal, "missing password for PLAIN auth mechanism"))
|
||||
continue
|
||||
}
|
||||
return smtp.PlainAuth(n.conf.AuthIdentity, username, password, n.conf.Smarthost.Host), nil
|
||||
case "LOGIN":
|
||||
password, passwordErr := n.getPassword()
|
||||
if passwordErr != nil {
|
||||
errs = errors.Join(errs, passwordErr)
|
||||
continue
|
||||
}
|
||||
if password == "" {
|
||||
errs = errors.Join(errs, errors.NewInternalf(errors.CodeInternal, "missing password for LOGIN auth mechanism"))
|
||||
continue
|
||||
}
|
||||
return LoginAuth(username, password), nil
|
||||
default:
|
||||
errs = errors.Join(errs, errors.NewInternalf(errors.CodeUnsupported, "unknown auth mechanism: %s", mech))
|
||||
}
|
||||
}
|
||||
return nil, errs
|
||||
}
|
||||
|
||||
// Notify implements the Notifier interface.
|
||||
func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
var (
|
||||
c *smtp.Client
|
||||
conn net.Conn
|
||||
err error
|
||||
success = false
|
||||
)
|
||||
// Determine whether to use Implicit TLS
|
||||
var useImplicitTLS bool
|
||||
if n.conf.ForceImplicitTLS != nil {
|
||||
useImplicitTLS = *n.conf.ForceImplicitTLS
|
||||
} else {
|
||||
// Default logic: port 465 uses implicit TLS (backward compatibility)
|
||||
useImplicitTLS = n.conf.Smarthost.Port == "465"
|
||||
}
|
||||
|
||||
if useImplicitTLS {
|
||||
tlsConfig, err := commoncfg.NewTLSConfig(n.conf.TLSConfig)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "parse TLS configuration")
|
||||
}
|
||||
if tlsConfig.ServerName == "" {
|
||||
tlsConfig.ServerName = n.conf.Smarthost.Host
|
||||
}
|
||||
|
||||
conn, err = tls.Dial("tcp", n.conf.Smarthost.String(), tlsConfig)
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "establish TLS connection to server")
|
||||
}
|
||||
} else {
|
||||
var (
|
||||
d = net.Dialer{}
|
||||
err error
|
||||
)
|
||||
conn, err = d.DialContext(ctx, "tcp", n.conf.Smarthost.String())
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "establish connection to server")
|
||||
}
|
||||
}
|
||||
c, err = smtp.NewClient(conn, n.conf.Smarthost.Host)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "create SMTP client")
|
||||
}
|
||||
defer func() {
|
||||
// Try to clean up after ourselves but don't log anything if something has failed.
|
||||
if err := c.Quit(); success && err != nil {
|
||||
n.logger.WarnContext(ctx, "failed to close SMTP connection", slog.Any("err", err))
|
||||
}
|
||||
}()
|
||||
|
||||
if n.conf.Hello != "" {
|
||||
err = c.Hello(n.conf.Hello)
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "send EHLO command")
|
||||
}
|
||||
}
|
||||
|
||||
// Global Config guarantees RequireTLS is not nil.
|
||||
if *n.conf.RequireTLS && !useImplicitTLS {
|
||||
if ok, _ := c.Extension("STARTTLS"); !ok {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "'require_tls' is true (default) but %q does not advertise the STARTTLS extension", n.conf.Smarthost)
|
||||
}
|
||||
|
||||
tlsConf, err := commoncfg.NewTLSConfig(n.conf.TLSConfig)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "parse TLS configuration")
|
||||
}
|
||||
if tlsConf.ServerName == "" {
|
||||
tlsConf.ServerName = n.conf.Smarthost.Host
|
||||
}
|
||||
|
||||
if err := c.StartTLS(tlsConf); err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "send STARTTLS command")
|
||||
}
|
||||
}
|
||||
|
||||
if ok, mech := c.Extension("AUTH"); ok {
|
||||
auth, err := n.auth(mech)
|
||||
if err != nil && err != errNoAuthUsernameConfigured {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "find auth mechanism")
|
||||
} else if err == errNoAuthUsernameConfigured {
|
||||
n.logger.DebugContext(ctx, "no auth username configured. Attempting to send email without authenticating")
|
||||
}
|
||||
if auth != nil {
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "%T auth", auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
tmplErr error
|
||||
data = notify.GetTemplateData(ctx, n.tmpl, as, n.logger)
|
||||
tmpl = notify.TmplText(n.tmpl, data, &tmplErr)
|
||||
)
|
||||
from := tmpl(n.conf.From)
|
||||
if tmplErr != nil {
|
||||
return false, errors.WrapInternalf(tmplErr, errors.CodeInternal, "execute 'from' template")
|
||||
}
|
||||
to := tmpl(n.conf.To)
|
||||
if tmplErr != nil {
|
||||
return false, errors.WrapInternalf(tmplErr, errors.CodeInternal, "execute 'to' template")
|
||||
}
|
||||
|
||||
addrs, err := mail.ParseAddressList(from)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "parse 'from' addresses")
|
||||
}
|
||||
if len(addrs) != 1 {
|
||||
return false, errors.NewInternalf(errors.CodeInternal, "must be exactly one 'from' address (got: %d)", len(addrs))
|
||||
}
|
||||
if err = c.Mail(addrs[0].Address); err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "send MAIL command")
|
||||
}
|
||||
addrs, err = mail.ParseAddressList(to)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "parse 'to' addresses")
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if err = c.Rcpt(addr.Address); err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "send RCPT command")
|
||||
}
|
||||
}
|
||||
|
||||
// Send the email headers and body.
|
||||
message, err := c.Data()
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "send DATA command")
|
||||
}
|
||||
closeOnce := sync.OnceValue(func() error {
|
||||
return message.Close()
|
||||
})
|
||||
// Close the message when this method exits in order to not leak resources. Even though we're calling this explicitly
|
||||
// further down, the method may exit before then.
|
||||
defer func() {
|
||||
// If we try close an already-closed writer, it'll send a subsequent request to the server which is invalid.
|
||||
_ = closeOnce()
|
||||
}()
|
||||
|
||||
buffer := &bytes.Buffer{}
|
||||
for header, t := range n.conf.Headers {
|
||||
value, err := n.tmpl.ExecuteTextString(t, data)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "execute %q header template", header)
|
||||
}
|
||||
fmt.Fprintf(buffer, "%s: %s\r\n", header, mime.QEncoding.Encode("utf-8", value))
|
||||
}
|
||||
|
||||
if _, ok := n.conf.Headers["Message-Id"]; !ok {
|
||||
fmt.Fprintf(buffer, "Message-Id: %s\r\n", fmt.Sprintf("<%d.%d@%s>", time.Now().UnixNano(), rand.Uint64(), n.hostname))
|
||||
}
|
||||
|
||||
if n.conf.Threading.Enabled {
|
||||
key, err := notify.ExtractGroupKey(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Add threading headers. All notifications for the same alert group
|
||||
// (identified by key hash) are threaded together.
|
||||
threadBy := ""
|
||||
if n.conf.Threading.ThreadByDate != "none" {
|
||||
// ThreadByDate is 'daily':
|
||||
// Use current date so all mails for this alert today thread together.
|
||||
threadBy = time.Now().Format("2006-01-02")
|
||||
}
|
||||
keyHash := key.Hash()
|
||||
if len(keyHash) > 16 {
|
||||
keyHash = keyHash[:16]
|
||||
}
|
||||
// The thread root ID is a Message-ID that doesn't correspond to
|
||||
// any actual email. Email clients following the (commonly used) JWZ
|
||||
// algorithm will create a dummy container to group these messages.
|
||||
threadRootID := fmt.Sprintf("<alert-%s-%s@alertmanager>", keyHash, threadBy)
|
||||
fmt.Fprintf(buffer, "References: %s\r\n", threadRootID)
|
||||
fmt.Fprintf(buffer, "In-Reply-To: %s\r\n", threadRootID)
|
||||
}
|
||||
|
||||
multipartBuffer := &bytes.Buffer{}
|
||||
multipartWriter := multipart.NewWriter(multipartBuffer)
|
||||
|
||||
fmt.Fprintf(buffer, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
|
||||
fmt.Fprintf(buffer, "Content-Type: multipart/alternative; boundary=%s\r\n", multipartWriter.Boundary())
|
||||
fmt.Fprintf(buffer, "MIME-Version: 1.0\r\n\r\n")
|
||||
|
||||
// TODO: Add some useful headers here, such as URL of the alertmanager
|
||||
// and active/resolved.
|
||||
_, err = message.Write(buffer.Bytes())
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "write headers")
|
||||
}
|
||||
|
||||
if len(n.conf.Text) > 0 {
|
||||
// Text template
|
||||
w, err := multipartWriter.CreatePart(textproto.MIMEHeader{
|
||||
"Content-Transfer-Encoding": {"quoted-printable"},
|
||||
"Content-Type": {"text/plain; charset=UTF-8"},
|
||||
})
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "create part for text template")
|
||||
}
|
||||
body, err := n.tmpl.ExecuteTextString(n.conf.Text, data)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "execute text template")
|
||||
}
|
||||
qw := quotedprintable.NewWriter(w)
|
||||
_, err = qw.Write([]byte(body))
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "write text part")
|
||||
}
|
||||
err = qw.Close()
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "close text part")
|
||||
}
|
||||
}
|
||||
|
||||
if len(n.conf.HTML) > 0 {
|
||||
// Html template
|
||||
// Preferred alternative placed last per section 5.1.4 of RFC 2046
|
||||
// https://www.ietf.org/rfc/rfc2046.txt
|
||||
w, err := multipartWriter.CreatePart(textproto.MIMEHeader{
|
||||
"Content-Transfer-Encoding": {"quoted-printable"},
|
||||
"Content-Type": {"text/html; charset=UTF-8"},
|
||||
})
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "create part for html template")
|
||||
}
|
||||
body, err := n.tmpl.ExecuteHTMLString(n.conf.HTML, data)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "execute html template")
|
||||
}
|
||||
qw := quotedprintable.NewWriter(w)
|
||||
_, err = qw.Write([]byte(body))
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "write HTML part")
|
||||
}
|
||||
err = qw.Close()
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "close HTML part")
|
||||
}
|
||||
}
|
||||
|
||||
err = multipartWriter.Close()
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "close multipartWriter")
|
||||
}
|
||||
|
||||
_, err = message.Write(multipartBuffer.Bytes())
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "write body buffer")
|
||||
}
|
||||
|
||||
// Complete the message and await response.
|
||||
if err = closeOnce(); err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "delivery failure")
|
||||
}
|
||||
|
||||
success = true
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type loginAuth struct {
|
||||
username, password string
|
||||
}
|
||||
|
||||
func LoginAuth(username, password string) smtp.Auth {
|
||||
return &loginAuth{username, password}
|
||||
}
|
||||
|
||||
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
return "LOGIN", []byte{}, nil
|
||||
}
|
||||
|
||||
// Used for AUTH LOGIN. (Maybe password should be encrypted).
|
||||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
switch strings.ToLower(string(fromServer)) {
|
||||
case "username:":
|
||||
return []byte(a.username), nil
|
||||
case "password:":
|
||||
return []byte(a.password), nil
|
||||
default:
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "unexpected server challenge")
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (n *Email) getPassword() (string, error) {
|
||||
if len(n.conf.AuthPasswordFile) > 0 {
|
||||
content, err := os.ReadFile(n.conf.AuthPasswordFile)
|
||||
if err != nil {
|
||||
return "", errors.NewInternalf(errors.CodeInternal, "could not read %s: %v", n.conf.AuthPasswordFile, err)
|
||||
}
|
||||
return strings.TrimSpace(string(content)), nil
|
||||
}
|
||||
return string(n.conf.AuthPassword), nil
|
||||
}
|
||||
|
||||
func (n *Email) getAuthSecret() (string, error) {
|
||||
if len(n.conf.AuthSecretFile) > 0 {
|
||||
content, err := os.ReadFile(n.conf.AuthSecretFile)
|
||||
if err != nil {
|
||||
return "", errors.NewInternalf(errors.CodeInternal, "could not read %s: %v", n.conf.AuthSecretFile, err)
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
return string(n.conf.AuthSecret), nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,3 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// Copyright 2019 Prometheus Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package msteamsv2
|
||||
|
||||
import (
|
||||
@@ -31,10 +27,6 @@ const (
|
||||
colorGrey = "Warning"
|
||||
)
|
||||
|
||||
const (
|
||||
Integration = "msteamsv2"
|
||||
)
|
||||
|
||||
type Notifier struct {
|
||||
conf *config.MSTeamsV2Config
|
||||
titleLink string
|
||||
@@ -95,7 +87,7 @@ type teamsMessage struct {
|
||||
|
||||
// New returns a new notifier that uses the Microsoft Teams Power Platform connector.
|
||||
func New(c *config.MSTeamsV2Config, t *template.Template, titleLink string, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
|
||||
client, err := commoncfg.NewClientFromConfig(*c.HTTPConfig, "msteamsv2", httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// Copyright 2019 Prometheus Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package msteamsv2
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
my_secret_api_key
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// Copyright 2019 Prometheus Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package opsgenie
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
const (
|
||||
Integration = "opsgenie"
|
||||
)
|
||||
|
||||
// https://docs.opsgenie.com/docs/alert-api - 130 characters meaning runes.
|
||||
const maxMessageLenRunes = 130
|
||||
|
||||
// Notifier implements a Notifier for OpsGenie notifications.
|
||||
type Notifier struct {
|
||||
conf *config.OpsGenieConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
}
|
||||
|
||||
// New returns a new OpsGenie notifier.
|
||||
func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Notifier{
|
||||
conf: c,
|
||||
tmpl: t,
|
||||
logger: l,
|
||||
client: client,
|
||||
retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type opsGenieCreateMessage struct {
|
||||
Alias string `json:"alias"`
|
||||
Message string `json:"message"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Details map[string]string `json:"details"`
|
||||
Source string `json:"source"`
|
||||
Responders []opsGenieCreateMessageResponder `json:"responders,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
Entity string `json:"entity,omitempty"`
|
||||
Actions []string `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
type opsGenieCreateMessageResponder struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Type string `json:"type"` // team, user, escalation, schedule etc.
|
||||
}
|
||||
|
||||
type opsGenieCloseMessage struct {
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type opsGenieUpdateMessageMessage struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type opsGenieUpdateDescriptionMessage struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// Notify implements the Notifier interface.
|
||||
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
requests, retry, err := n.createRequests(ctx, as...)
|
||||
if err != nil {
|
||||
return retry, err
|
||||
}
|
||||
|
||||
for _, req := range requests {
|
||||
req.Header.Set("User-Agent", notify.UserAgentHeader)
|
||||
resp, err := n.client.Do(req) //nolint:bodyclose
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body)
|
||||
notify.Drain(resp)
|
||||
if err != nil {
|
||||
return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Like Split but filter out empty strings.
|
||||
func safeSplit(s, sep string) []string {
|
||||
a := strings.Split(strings.TrimSpace(s), sep)
|
||||
b := a[:0]
|
||||
for _, x := range a {
|
||||
if x != "" {
|
||||
b = append(b, x)
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Create requests for a list of alerts.
|
||||
func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*http.Request, bool, error) {
|
||||
key, err := notify.ExtractGroupKey(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
logger := n.logger.With(slog.Any("group_key", key))
|
||||
logger.DebugContext(ctx, "extracted group key")
|
||||
|
||||
data := notify.GetTemplateData(ctx, n.tmpl, as, logger)
|
||||
|
||||
tmpl := notify.TmplText(n.tmpl, data, &err)
|
||||
|
||||
details := make(map[string]string)
|
||||
|
||||
maps.Copy(details, data.CommonLabels)
|
||||
|
||||
for k, v := range n.conf.Details {
|
||||
details[k] = tmpl(v)
|
||||
}
|
||||
|
||||
requests := []*http.Request{}
|
||||
|
||||
var (
|
||||
alias = key.Hash()
|
||||
alerts = types.Alerts(as...)
|
||||
)
|
||||
switch alerts.Status() {
|
||||
case model.AlertResolved:
|
||||
resolvedEndpointURL := n.conf.APIURL.Copy()
|
||||
resolvedEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/close", alias)
|
||||
q := resolvedEndpointURL.Query()
|
||||
q.Set("identifierType", "alias")
|
||||
resolvedEndpointURL.RawQuery = q.Encode()
|
||||
msg := &opsGenieCloseMessage{Source: tmpl(n.conf.Source)}
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
req, err := http.NewRequest("POST", resolvedEndpointURL.String(), &buf)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
requests = append(requests, req.WithContext(ctx))
|
||||
default:
|
||||
message, truncated := notify.TruncateInRunes(tmpl(n.conf.Message), maxMessageLenRunes)
|
||||
if truncated {
|
||||
logger.WarnContext(ctx, "Truncated message", slog.Any("alert", key), slog.Int("max_runes", maxMessageLenRunes))
|
||||
}
|
||||
|
||||
createEndpointURL := n.conf.APIURL.Copy()
|
||||
createEndpointURL.Path += "v2/alerts"
|
||||
|
||||
var responders []opsGenieCreateMessageResponder
|
||||
for _, r := range n.conf.Responders {
|
||||
responder := opsGenieCreateMessageResponder{
|
||||
ID: tmpl(r.ID),
|
||||
Name: tmpl(r.Name),
|
||||
Username: tmpl(r.Username),
|
||||
Type: tmpl(r.Type),
|
||||
}
|
||||
|
||||
if responder == (opsGenieCreateMessageResponder{}) {
|
||||
// Filter out empty responders. This is useful if you want to fill
|
||||
// responders dynamically from alert's common labels.
|
||||
continue
|
||||
}
|
||||
|
||||
if responder.Type == "teams" {
|
||||
teams := safeSplit(responder.Name, ",")
|
||||
for _, team := range teams {
|
||||
newResponder := opsGenieCreateMessageResponder{
|
||||
Name: tmpl(team),
|
||||
Type: tmpl("team"),
|
||||
}
|
||||
responders = append(responders, newResponder)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
responders = append(responders, responder)
|
||||
}
|
||||
|
||||
msg := &opsGenieCreateMessage{
|
||||
Alias: alias,
|
||||
Message: message,
|
||||
Description: tmpl(n.conf.Description),
|
||||
Details: details,
|
||||
Source: tmpl(n.conf.Source),
|
||||
Responders: responders,
|
||||
Tags: safeSplit(tmpl(n.conf.Tags), ","),
|
||||
Note: tmpl(n.conf.Note),
|
||||
Priority: tmpl(n.conf.Priority),
|
||||
Entity: tmpl(n.conf.Entity),
|
||||
Actions: safeSplit(tmpl(n.conf.Actions), ","),
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
req, err := http.NewRequest("POST", createEndpointURL.String(), &buf)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
requests = append(requests, req.WithContext(ctx))
|
||||
|
||||
if n.conf.UpdateAlerts {
|
||||
updateMessageEndpointURL := n.conf.APIURL.Copy()
|
||||
updateMessageEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/message", alias)
|
||||
q := updateMessageEndpointURL.Query()
|
||||
q.Set("identifierType", "alias")
|
||||
updateMessageEndpointURL.RawQuery = q.Encode()
|
||||
updateMsgMsg := &opsGenieUpdateMessageMessage{
|
||||
Message: msg.Message,
|
||||
}
|
||||
var updateMessageBuf bytes.Buffer
|
||||
if err := json.NewEncoder(&updateMessageBuf).Encode(updateMsgMsg); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
req, err := http.NewRequest("PUT", updateMessageEndpointURL.String(), &updateMessageBuf)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
requests = append(requests, req)
|
||||
|
||||
updateDescriptionEndpointURL := n.conf.APIURL.Copy()
|
||||
updateDescriptionEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/description", alias)
|
||||
q = updateDescriptionEndpointURL.Query()
|
||||
q.Set("identifierType", "alias")
|
||||
updateDescriptionEndpointURL.RawQuery = q.Encode()
|
||||
updateDescMsg := &opsGenieUpdateDescriptionMessage{
|
||||
Description: msg.Description,
|
||||
}
|
||||
|
||||
var updateDescriptionBuf bytes.Buffer
|
||||
if err := json.NewEncoder(&updateDescriptionBuf).Encode(updateDescMsg); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
req, err = http.NewRequest("PUT", updateDescriptionEndpointURL.String(), &updateDescriptionBuf)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
requests = append(requests, req.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
var apiKey string
|
||||
if n.conf.APIKey != "" {
|
||||
apiKey = tmpl(string(n.conf.APIKey))
|
||||
} else {
|
||||
content, err := os.ReadFile(n.conf.APIKeyFile)
|
||||
if err != nil {
|
||||
return nil, false, errors.WrapInternalf(err, errors.CodeInternal, "read key_file error")
|
||||
}
|
||||
apiKey = tmpl(string(content))
|
||||
apiKey = strings.TrimSpace(string(apiKey))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, false, errors.WrapInternalf(err, errors.CodeInternal, "templating error")
|
||||
}
|
||||
|
||||
for _, req := range requests {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", fmt.Sprintf("GenieKey %s", apiKey))
|
||||
}
|
||||
|
||||
return requests, true, nil
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// Copyright 2019 Prometheus Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package opsgenie
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/common/promslog"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/notify/test"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
func TestOpsGenieRetry(t *testing.T) {
|
||||
notifier, err := New(
|
||||
&config.OpsGenieConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
retryCodes := append(test.DefaultRetryCodes(), http.StatusTooManyRequests)
|
||||
for statusCode, expected := range test.RetryTests(retryCodes) {
|
||||
actual, _ := notifier.retrier.Check(statusCode, nil)
|
||||
require.Equal(t, expected, actual, "error on status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsGenieRedactedURL(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
key := "key"
|
||||
notifier, err := New(
|
||||
&config.OpsGenieConfig{
|
||||
APIURL: &config.URL{URL: u},
|
||||
APIKey: config.Secret(key),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key)
|
||||
}
|
||||
|
||||
func TestGettingOpsGegineApikeyFromFile(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
key := "key"
|
||||
|
||||
f, err := os.CreateTemp(t.TempDir(), "opsgenie_test")
|
||||
require.NoError(t, err, "creating temp file failed")
|
||||
_, err = f.WriteString(key)
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
notifier, err := New(
|
||||
&config.OpsGenieConfig{
|
||||
APIURL: &config.URL{URL: u},
|
||||
APIKeyFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key)
|
||||
}
|
||||
|
||||
func TestOpsGenie(t *testing.T) {
|
||||
u, err := url.Parse("https://opsgenie/api")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse URL: %v", err)
|
||||
}
|
||||
logger := promslog.NewNopLogger()
|
||||
tmpl := test.CreateTmpl(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
title string
|
||||
cfg *config.OpsGenieConfig
|
||||
|
||||
expectedEmptyAlertBody string
|
||||
expectedBody string
|
||||
}{
|
||||
{
|
||||
title: "config without details",
|
||||
cfg: &config.OpsGenieConfig{
|
||||
NotifierConfig: config.NotifierConfig{
|
||||
VSendResolved: true,
|
||||
},
|
||||
Message: `{{ .CommonLabels.Message }}`,
|
||||
Description: `{{ .CommonLabels.Description }}`,
|
||||
Source: `{{ .CommonLabels.Source }}`,
|
||||
Responders: []config.OpsGenieConfigResponder{
|
||||
{
|
||||
Name: `{{ .CommonLabels.ResponderName1 }}`,
|
||||
Type: `{{ .CommonLabels.ResponderType1 }}`,
|
||||
},
|
||||
{
|
||||
Name: `{{ .CommonLabels.ResponderName2 }}`,
|
||||
Type: `{{ .CommonLabels.ResponderType2 }}`,
|
||||
},
|
||||
},
|
||||
Tags: `{{ .CommonLabels.Tags }}`,
|
||||
Note: `{{ .CommonLabels.Note }}`,
|
||||
Priority: `{{ .CommonLabels.Priority }}`,
|
||||
Entity: `{{ .CommonLabels.Entity }}`,
|
||||
Actions: `{{ .CommonLabels.Actions }}`,
|
||||
APIKey: `{{ .ExternalURL }}`,
|
||||
APIURL: &config.URL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
expectedEmptyAlertBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"","details":{},"source":""}
|
||||
`,
|
||||
expectedBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"message","description":"description","details":{"Actions":"doThis,doThat","Description":"description","Entity":"test-domain","Message":"message","Note":"this is a note","Priority":"P1","ResponderName1":"TeamA","ResponderName2":"EscalationA","ResponderName3":"TeamA,TeamB","ResponderType1":"team","ResponderType2":"escalation","ResponderType3":"teams","Source":"http://prometheus","Tags":"tag1,tag2"},"source":"http://prometheus","responders":[{"name":"TeamA","type":"team"},{"name":"EscalationA","type":"escalation"}],"tags":["tag1","tag2"],"note":"this is a note","priority":"P1","entity":"test-domain","actions":["doThis","doThat"]}
|
||||
`,
|
||||
},
|
||||
{
|
||||
title: "config with details",
|
||||
cfg: &config.OpsGenieConfig{
|
||||
NotifierConfig: config.NotifierConfig{
|
||||
VSendResolved: true,
|
||||
},
|
||||
Message: `{{ .CommonLabels.Message }}`,
|
||||
Description: `{{ .CommonLabels.Description }}`,
|
||||
Source: `{{ .CommonLabels.Source }}`,
|
||||
Details: map[string]string{
|
||||
"Description": `adjusted {{ .CommonLabels.Description }}`,
|
||||
},
|
||||
Responders: []config.OpsGenieConfigResponder{
|
||||
{
|
||||
Name: `{{ .CommonLabels.ResponderName1 }}`,
|
||||
Type: `{{ .CommonLabels.ResponderType1 }}`,
|
||||
},
|
||||
{
|
||||
Name: `{{ .CommonLabels.ResponderName2 }}`,
|
||||
Type: `{{ .CommonLabels.ResponderType2 }}`,
|
||||
},
|
||||
},
|
||||
Tags: `{{ .CommonLabels.Tags }}`,
|
||||
Note: `{{ .CommonLabels.Note }}`,
|
||||
Priority: `{{ .CommonLabels.Priority }}`,
|
||||
Entity: `{{ .CommonLabels.Entity }}`,
|
||||
Actions: `{{ .CommonLabels.Actions }}`,
|
||||
APIKey: `{{ .ExternalURL }}`,
|
||||
APIURL: &config.URL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
expectedEmptyAlertBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"","details":{"Description":"adjusted "},"source":""}
|
||||
`,
|
||||
expectedBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"message","description":"description","details":{"Actions":"doThis,doThat","Description":"adjusted description","Entity":"test-domain","Message":"message","Note":"this is a note","Priority":"P1","ResponderName1":"TeamA","ResponderName2":"EscalationA","ResponderName3":"TeamA,TeamB","ResponderType1":"team","ResponderType2":"escalation","ResponderType3":"teams","Source":"http://prometheus","Tags":"tag1,tag2"},"source":"http://prometheus","responders":[{"name":"TeamA","type":"team"},{"name":"EscalationA","type":"escalation"}],"tags":["tag1","tag2"],"note":"this is a note","priority":"P1","entity":"test-domain","actions":["doThis","doThat"]}
|
||||
`,
|
||||
},
|
||||
{
|
||||
title: "config with multiple teams",
|
||||
cfg: &config.OpsGenieConfig{
|
||||
NotifierConfig: config.NotifierConfig{
|
||||
VSendResolved: true,
|
||||
},
|
||||
Message: `{{ .CommonLabels.Message }}`,
|
||||
Description: `{{ .CommonLabels.Description }}`,
|
||||
Source: `{{ .CommonLabels.Source }}`,
|
||||
Details: map[string]string{
|
||||
"Description": `adjusted {{ .CommonLabels.Description }}`,
|
||||
},
|
||||
Responders: []config.OpsGenieConfigResponder{
|
||||
{
|
||||
Name: `{{ .CommonLabels.ResponderName3 }}`,
|
||||
Type: `{{ .CommonLabels.ResponderType3 }}`,
|
||||
},
|
||||
},
|
||||
Tags: `{{ .CommonLabels.Tags }}`,
|
||||
Note: `{{ .CommonLabels.Note }}`,
|
||||
Priority: `{{ .CommonLabels.Priority }}`,
|
||||
APIKey: `{{ .ExternalURL }}`,
|
||||
APIURL: &config.URL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
expectedEmptyAlertBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"","details":{"Description":"adjusted "},"source":""}
|
||||
`,
|
||||
expectedBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"message","description":"description","details":{"Actions":"doThis,doThat","Description":"adjusted description","Entity":"test-domain","Message":"message","Note":"this is a note","Priority":"P1","ResponderName1":"TeamA","ResponderName2":"EscalationA","ResponderName3":"TeamA,TeamB","ResponderType1":"team","ResponderType2":"escalation","ResponderType3":"teams","Source":"http://prometheus","Tags":"tag1,tag2"},"source":"http://prometheus","responders":[{"name":"TeamA","type":"team"},{"name":"TeamB","type":"team"}],"tags":["tag1","tag2"],"note":"this is a note","priority":"P1"}
|
||||
`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.title, func(t *testing.T) {
|
||||
notifier, err := New(tc.cfg, tmpl, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
|
||||
expectedURL, _ := url.Parse("https://opsgenie/apiv2/alerts")
|
||||
|
||||
// Empty alert.
|
||||
alert1 := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
req, retry, err := notifier.createRequests(ctx, alert1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, req, 1)
|
||||
require.True(t, retry)
|
||||
require.Equal(t, expectedURL, req[0].URL)
|
||||
require.Equal(t, "GenieKey http://am", req[0].Header.Get("Authorization"))
|
||||
require.Equal(t, tc.expectedEmptyAlertBody, readBody(t, req[0]))
|
||||
|
||||
// Fully defined alert.
|
||||
alert2 := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"Message": "message",
|
||||
"Description": "description",
|
||||
"Source": "http://prometheus",
|
||||
"ResponderName1": "TeamA",
|
||||
"ResponderType1": "team",
|
||||
"ResponderName2": "EscalationA",
|
||||
"ResponderType2": "escalation",
|
||||
"ResponderName3": "TeamA,TeamB",
|
||||
"ResponderType3": "teams",
|
||||
"Tags": "tag1,tag2",
|
||||
"Note": "this is a note",
|
||||
"Priority": "P1",
|
||||
"Entity": "test-domain",
|
||||
"Actions": "doThis,doThat",
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
req, retry, err = notifier.createRequests(ctx, alert2)
|
||||
require.NoError(t, err)
|
||||
require.True(t, retry)
|
||||
require.Len(t, req, 1)
|
||||
require.Equal(t, tc.expectedBody, readBody(t, req[0]))
|
||||
|
||||
// Broken API Key Template.
|
||||
tc.cfg.APIKey = "{{ kaput "
|
||||
_, _, err = notifier.createRequests(ctx, alert2)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "template: :1: function \"kaput\" not defined", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsGenieWithUpdate(t *testing.T) {
|
||||
u, err := url.Parse("https://test-opsgenie-url")
|
||||
require.NoError(t, err)
|
||||
tmpl := test.CreateTmpl(t)
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
opsGenieConfigWithUpdate := config.OpsGenieConfig{
|
||||
Message: `{{ .CommonLabels.Message }}`,
|
||||
Description: `{{ .CommonLabels.Description }}`,
|
||||
UpdateAlerts: true,
|
||||
APIKey: "test-api-key",
|
||||
APIURL: &config.URL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
}
|
||||
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger())
|
||||
alert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
Labels: model.LabelSet{
|
||||
"Message": "new message",
|
||||
"Description": "new description",
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, err)
|
||||
requests, retry, err := notifierWithUpdate.createRequests(ctx, alert)
|
||||
require.NoError(t, err)
|
||||
require.True(t, retry)
|
||||
require.Len(t, requests, 3)
|
||||
|
||||
body0 := readBody(t, requests[0])
|
||||
body1 := readBody(t, requests[1])
|
||||
body2 := readBody(t, requests[2])
|
||||
key, _ := notify.ExtractGroupKey(ctx)
|
||||
alias := key.Hash()
|
||||
|
||||
require.Equal(t, "https://test-opsgenie-url/v2/alerts", requests[0].URL.String())
|
||||
require.NotEmpty(t, body0)
|
||||
|
||||
require.Equal(t, requests[1].URL.String(), fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/message?identifierType=alias", alias))
|
||||
require.JSONEq(t, `{"message":"new message"}`, body1)
|
||||
require.Equal(t, requests[2].URL.String(), fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/description?identifierType=alias", alias))
|
||||
require.JSONEq(t, `{"description":"new description"}`, body2)
|
||||
}
|
||||
|
||||
func TestOpsGenieApiKeyFile(t *testing.T) {
|
||||
u, err := url.Parse("https://test-opsgenie-url")
|
||||
require.NoError(t, err)
|
||||
tmpl := test.CreateTmpl(t)
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
opsGenieConfigWithUpdate := config.OpsGenieConfig{
|
||||
APIKeyFile: `./api_key_file`,
|
||||
APIURL: &config.URL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
}
|
||||
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger())
|
||||
|
||||
require.NoError(t, err)
|
||||
requests, _, err := notifierWithUpdate.createRequests(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "GenieKey my_secret_api_key", requests[0].Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func readBody(t *testing.T, r *http.Request) string {
|
||||
t.Helper()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
return string(body)
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// Copyright 2019 Prometheus Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pagerduty
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/alecthomas/units"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
const (
|
||||
Integration = "pagerduty"
|
||||
)
|
||||
|
||||
const (
|
||||
maxEventSize int = 512000
|
||||
// https://developer.pagerduty.com/docs/ZG9jOjExMDI5NTc4-send-a-v1-event - 1024 characters or runes.
|
||||
maxV1DescriptionLenRunes = 1024
|
||||
// https://developer.pagerduty.com/docs/ZG9jOjExMDI5NTgx-send-an-alert-event - 1024 characters or runes.
|
||||
maxV2SummaryLenRunes = 1024
|
||||
)
|
||||
|
||||
// Notifier implements a Notifier for PagerDuty notifications.
|
||||
type Notifier struct {
|
||||
conf *config.PagerdutyConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
apiV1 string // for tests.
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
}
|
||||
|
||||
// New returns a new PagerDuty notifier.
|
||||
func New(c *config.PagerdutyConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := &Notifier{conf: c, tmpl: t, logger: l, client: client}
|
||||
if c.ServiceKey != "" || c.ServiceKeyFile != "" {
|
||||
n.apiV1 = "https://events.pagerduty.com/generic/2010-04-15/create_event.json"
|
||||
// Retrying can solve the issue on 403 (rate limiting) and 5xx response codes.
|
||||
// https://developer.pagerduty.com/docs/events-api-v1-overview#api-response-codes--retry-logic
|
||||
n.retrier = ¬ify.Retrier{RetryCodes: []int{http.StatusForbidden}, CustomDetailsFunc: errDetails}
|
||||
} else {
|
||||
// Retrying can solve the issue on 429 (rate limiting) and 5xx response codes.
|
||||
// https://developer.pagerduty.com/docs/events-api-v2-overview#response-codes--retry-logic
|
||||
n.retrier = ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}, CustomDetailsFunc: errDetails}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
const (
|
||||
pagerDutyEventTrigger = "trigger"
|
||||
pagerDutyEventResolve = "resolve"
|
||||
)
|
||||
|
||||
type pagerDutyMessage struct {
|
||||
RoutingKey string `json:"routing_key,omitempty"`
|
||||
ServiceKey string `json:"service_key,omitempty"`
|
||||
DedupKey string `json:"dedup_key,omitempty"`
|
||||
IncidentKey string `json:"incident_key,omitempty"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
EventAction string `json:"event_action"`
|
||||
Payload *pagerDutyPayload `json:"payload"`
|
||||
Client string `json:"client,omitempty"`
|
||||
ClientURL string `json:"client_url,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
Images []pagerDutyImage `json:"images,omitempty"`
|
||||
Links []pagerDutyLink `json:"links,omitempty"`
|
||||
}
|
||||
|
||||
type pagerDutyLink struct {
|
||||
HRef string `json:"href"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type pagerDutyImage struct {
|
||||
Src string `json:"src"`
|
||||
Alt string `json:"alt"`
|
||||
Href string `json:"href"`
|
||||
}
|
||||
|
||||
type pagerDutyPayload struct {
|
||||
Summary string `json:"summary"`
|
||||
Source string `json:"source"`
|
||||
Severity string `json:"severity"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
Class string `json:"class,omitempty"`
|
||||
Component string `json:"component,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
CustomDetails map[string]any `json:"custom_details,omitempty"`
|
||||
}
|
||||
|
||||
func (n *Notifier) encodeMessage(ctx context.Context, msg *pagerDutyMessage) (bytes.Buffer, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
|
||||
return buf, errors.WrapInternalf(err, errors.CodeInternal, "failed to encode PagerDuty message")
|
||||
}
|
||||
|
||||
if buf.Len() > maxEventSize {
|
||||
truncatedMsg := fmt.Sprintf("Custom details have been removed because the original event exceeds the maximum size of %s", units.MetricBytes(maxEventSize).String())
|
||||
|
||||
if n.apiV1 != "" {
|
||||
msg.Details = map[string]any{"error": truncatedMsg}
|
||||
} else {
|
||||
msg.Payload.CustomDetails = map[string]any{"error": truncatedMsg}
|
||||
}
|
||||
|
||||
n.logger.WarnContext(ctx, "Truncated Details because message of size exceeds limit", slog.String("message_size", units.MetricBytes(buf.Len()).String()), slog.String("max_size", units.MetricBytes(maxEventSize).String()))
|
||||
|
||||
buf.Reset()
|
||||
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
|
||||
return buf, errors.WrapInternalf(err, errors.CodeInternal, "failed to encode PagerDuty message")
|
||||
}
|
||||
}
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func (n *Notifier) notifyV1(
|
||||
ctx context.Context,
|
||||
eventType string,
|
||||
key notify.Key,
|
||||
data *template.Data,
|
||||
details map[string]any,
|
||||
) (bool, error) {
|
||||
var tmplErr error
|
||||
tmpl := notify.TmplText(n.tmpl, data, &tmplErr)
|
||||
|
||||
description, truncated := notify.TruncateInRunes(tmpl(n.conf.Description), maxV1DescriptionLenRunes)
|
||||
if truncated {
|
||||
n.logger.WarnContext(ctx, "Truncated description", slog.Any("key", key), slog.Int("max_runes", maxV1DescriptionLenRunes))
|
||||
}
|
||||
|
||||
serviceKey := string(n.conf.ServiceKey)
|
||||
if serviceKey == "" {
|
||||
content, fileErr := os.ReadFile(n.conf.ServiceKeyFile)
|
||||
if fileErr != nil {
|
||||
return false, errors.WrapInternalf(fileErr, errors.CodeInternal, "failed to read service key from file")
|
||||
}
|
||||
serviceKey = strings.TrimSpace(string(content))
|
||||
}
|
||||
|
||||
msg := &pagerDutyMessage{
|
||||
ServiceKey: tmpl(serviceKey),
|
||||
EventType: eventType,
|
||||
IncidentKey: key.Hash(),
|
||||
Description: description,
|
||||
Details: details,
|
||||
}
|
||||
|
||||
if eventType == pagerDutyEventTrigger {
|
||||
msg.Client = tmpl(n.conf.Client)
|
||||
msg.ClientURL = tmpl(n.conf.ClientURL)
|
||||
}
|
||||
|
||||
if tmplErr != nil {
|
||||
return false, errors.WrapInternalf(tmplErr, errors.CodeInternal, "failed to template PagerDuty v1 message")
|
||||
}
|
||||
|
||||
// Ensure that the service key isn't empty after templating.
|
||||
if msg.ServiceKey == "" {
|
||||
return false, errors.NewInternalf(errors.CodeInternal, "service key cannot be empty")
|
||||
}
|
||||
|
||||
encodedMsg, err := n.encodeMessage(ctx, msg)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
resp, err := notify.PostJSON(ctx, n.client, n.apiV1, &encodedMsg) //nolint:bodyclose
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "failed to post message to PagerDuty v1")
|
||||
}
|
||||
defer notify.Drain(resp)
|
||||
|
||||
return n.retrier.Check(resp.StatusCode, resp.Body)
|
||||
}
|
||||
|
||||
func (n *Notifier) notifyV2(
|
||||
ctx context.Context,
|
||||
eventType string,
|
||||
key notify.Key,
|
||||
data *template.Data,
|
||||
details map[string]any,
|
||||
) (bool, error) {
|
||||
var tmplErr error
|
||||
tmpl := notify.TmplText(n.tmpl, data, &tmplErr)
|
||||
|
||||
if n.conf.Severity == "" {
|
||||
n.conf.Severity = "error"
|
||||
}
|
||||
|
||||
summary, truncated := notify.TruncateInRunes(tmpl(n.conf.Description), maxV2SummaryLenRunes)
|
||||
if truncated {
|
||||
n.logger.WarnContext(ctx, "Truncated summary", slog.Any("key", key), slog.Int("max_runes", maxV2SummaryLenRunes))
|
||||
}
|
||||
|
||||
routingKey := string(n.conf.RoutingKey)
|
||||
if routingKey == "" {
|
||||
content, fileErr := os.ReadFile(n.conf.RoutingKeyFile)
|
||||
if fileErr != nil {
|
||||
return false, errors.WrapInternalf(fileErr, errors.CodeInternal, "failed to read routing key from file")
|
||||
}
|
||||
routingKey = strings.TrimSpace(string(content))
|
||||
}
|
||||
|
||||
msg := &pagerDutyMessage{
|
||||
Client: tmpl(n.conf.Client),
|
||||
ClientURL: tmpl(n.conf.ClientURL),
|
||||
RoutingKey: tmpl(routingKey),
|
||||
EventAction: eventType,
|
||||
DedupKey: key.Hash(),
|
||||
Images: make([]pagerDutyImage, 0, len(n.conf.Images)),
|
||||
Links: make([]pagerDutyLink, 0, len(n.conf.Links)),
|
||||
Payload: &pagerDutyPayload{
|
||||
Summary: summary,
|
||||
Source: tmpl(n.conf.Source),
|
||||
Severity: tmpl(n.conf.Severity),
|
||||
CustomDetails: details,
|
||||
Class: tmpl(n.conf.Class),
|
||||
Component: tmpl(n.conf.Component),
|
||||
Group: tmpl(n.conf.Group),
|
||||
},
|
||||
}
|
||||
|
||||
for _, item := range n.conf.Images {
|
||||
image := pagerDutyImage{
|
||||
Src: tmpl(item.Src),
|
||||
Alt: tmpl(item.Alt),
|
||||
Href: tmpl(item.Href),
|
||||
}
|
||||
|
||||
if image.Src != "" {
|
||||
msg.Images = append(msg.Images, image)
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range n.conf.Links {
|
||||
link := pagerDutyLink{
|
||||
HRef: tmpl(item.Href),
|
||||
Text: tmpl(item.Text),
|
||||
}
|
||||
|
||||
if link.HRef != "" {
|
||||
msg.Links = append(msg.Links, link)
|
||||
}
|
||||
}
|
||||
|
||||
if tmplErr != nil {
|
||||
return false, errors.WrapInternalf(tmplErr, errors.CodeInternal, "failed to template PagerDuty v2 message")
|
||||
}
|
||||
|
||||
// Ensure that the routing key isn't empty after templating.
|
||||
if msg.RoutingKey == "" {
|
||||
return false, errors.NewInternalf(errors.CodeInternal, "routing key cannot be empty")
|
||||
}
|
||||
|
||||
encodedMsg, err := n.encodeMessage(ctx, msg)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
resp, err := notify.PostJSON(ctx, n.client, n.conf.URL.String(), &encodedMsg) //nolint:bodyclose
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "failed to post message to PagerDuty")
|
||||
}
|
||||
defer notify.Drain(resp)
|
||||
|
||||
retry, err := n.retrier.Check(resp.StatusCode, resp.Body)
|
||||
if err != nil {
|
||||
return retry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
|
||||
}
|
||||
return retry, err
|
||||
}
|
||||
|
||||
// Notify implements the Notifier interface.
|
||||
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
key, err := notify.ExtractGroupKey(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
logger := n.logger.With(slog.Any("group_key", key))
|
||||
|
||||
var (
|
||||
alerts = types.Alerts(as...)
|
||||
data = notify.GetTemplateData(ctx, n.tmpl, as, logger)
|
||||
eventType = pagerDutyEventTrigger
|
||||
)
|
||||
|
||||
if alerts.Status() == model.AlertResolved {
|
||||
eventType = pagerDutyEventResolve
|
||||
}
|
||||
|
||||
logger.DebugContext(ctx, "extracted group key", slog.String("event_type", eventType))
|
||||
|
||||
details, err := n.renderDetails(data)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "failed to render details: %v", err)
|
||||
}
|
||||
|
||||
if n.conf.Timeout > 0 {
|
||||
nfCtx, cancel := context.WithTimeoutCause(ctx, n.conf.Timeout, errors.NewInternalf(errors.CodeTimeout, "configured pagerduty timeout reached (%s)", n.conf.Timeout))
|
||||
defer cancel()
|
||||
ctx = nfCtx
|
||||
}
|
||||
|
||||
nf := n.notifyV2
|
||||
if n.apiV1 != "" {
|
||||
nf = n.notifyV1
|
||||
}
|
||||
retry, err := nf(ctx, eventType, key, data, details)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
err = errors.WrapInternalf(err, errors.CodeInternal, "failed to notify PagerDuty: %v", context.Cause(ctx))
|
||||
}
|
||||
return retry, err
|
||||
}
|
||||
return retry, nil
|
||||
}
|
||||
|
||||
func errDetails(status int, body io.Reader) string {
|
||||
// See https://v2.developer.pagerduty.com/docs/trigger-events for the v1 events API.
|
||||
// See https://v2.developer.pagerduty.com/docs/send-an-event-events-api-v2 for the v2 events API.
|
||||
if status != http.StatusBadRequest || body == nil {
|
||||
return ""
|
||||
}
|
||||
var pgr struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
if err := json.NewDecoder(body).Decode(&pgr); err != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", pgr.Message, strings.Join(pgr.Errors, ","))
|
||||
}
|
||||
|
||||
func (n *Notifier) renderDetails(
|
||||
data *template.Data,
|
||||
) (map[string]any, error) {
|
||||
var (
|
||||
tmplTextErr error
|
||||
tmplText = notify.TmplText(n.tmpl, data, &tmplTextErr)
|
||||
tmplTextFunc = func(tmpl string) (string, error) {
|
||||
return tmplText(tmpl), tmplTextErr
|
||||
}
|
||||
)
|
||||
var err error
|
||||
rendered := make(map[string]any, len(n.conf.Details))
|
||||
for k, v := range n.conf.Details {
|
||||
rendered[k], err = template.DeepCopyWithTemplate(v, tmplTextFunc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return rendered, nil
|
||||
}
|
||||
@@ -1,883 +0,0 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// Copyright 2019 Prometheus Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pagerduty
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/common/promslog"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/notify/test"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
func TestPagerDutyRetryV1(t *testing.T) {
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
ServiceKey: config.Secret("01234567890123456789012345678901"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
retryCodes := append(test.DefaultRetryCodes(), http.StatusForbidden)
|
||||
for statusCode, expected := range test.RetryTests(retryCodes) {
|
||||
actual, _ := notifier.retrier.Check(statusCode, nil)
|
||||
require.Equal(t, expected, actual, "retryv1 - error on status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagerDutyRetryV2(t *testing.T) {
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
retryCodes := append(test.DefaultRetryCodes(), http.StatusTooManyRequests)
|
||||
for statusCode, expected := range test.RetryTests(retryCodes) {
|
||||
actual, _ := notifier.retrier.Check(statusCode, nil)
|
||||
require.Equal(t, expected, actual, "retryv2 - error on status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagerDutyRedactedURLV1(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
key := "01234567890123456789012345678901"
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
ServiceKey: config.Secret(key),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
notifier.apiV1 = u.String()
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key)
|
||||
}
|
||||
|
||||
func TestPagerDutyRedactedURLV2(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
key := "01234567890123456789012345678901"
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
URL: &config.URL{URL: u},
|
||||
RoutingKey: config.Secret(key),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key)
|
||||
}
|
||||
|
||||
func TestPagerDutyV1ServiceKeyFromFile(t *testing.T) {
|
||||
key := "01234567890123456789012345678901"
|
||||
f, err := os.CreateTemp(t.TempDir(), "pagerduty_test")
|
||||
require.NoError(t, err, "creating temp file failed")
|
||||
_, err = f.WriteString(key)
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
ServiceKeyFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
notifier.apiV1 = u.String()
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key)
|
||||
}
|
||||
|
||||
func TestPagerDutyV2RoutingKeyFromFile(t *testing.T) {
|
||||
key := "01234567890123456789012345678901"
|
||||
f, err := os.CreateTemp(t.TempDir(), "pagerduty_test")
|
||||
require.NoError(t, err, "creating temp file failed")
|
||||
_, err = f.WriteString(key)
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
notifier, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
URL: &config.URL{URL: u},
|
||||
RoutingKeyFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key)
|
||||
}
|
||||
|
||||
func TestPagerDutyTemplating(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
out := make(map[string]any)
|
||||
err := dec.Decode(&out)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
u, _ := url.Parse(srv.URL)
|
||||
|
||||
for _, tc := range []struct {
|
||||
title string
|
||||
cfg *config.PagerdutyConfig
|
||||
|
||||
retry bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
title: "full-blown legacy message",
|
||||
cfg: &config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
Images: []config.PagerdutyImage{
|
||||
{
|
||||
Src: "{{ .Status }}",
|
||||
Alt: "{{ .Status }}",
|
||||
Href: "{{ .Status }}",
|
||||
},
|
||||
},
|
||||
Links: []config.PagerdutyLink{
|
||||
{
|
||||
Href: "{{ .Status }}",
|
||||
Text: "{{ .Status }}",
|
||||
},
|
||||
},
|
||||
Details: map[string]any{
|
||||
"firing": `{{ .Alerts.Firing | toJson }}`,
|
||||
"resolved": `{{ .Alerts.Resolved | toJson }}`,
|
||||
"num_firing": `{{ .Alerts.Firing | len }}`,
|
||||
"num_resolved": `{{ .Alerts.Resolved | len }}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "full-blown legacy message",
|
||||
cfg: &config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
Images: []config.PagerdutyImage{
|
||||
{
|
||||
Src: "{{ .Status }}",
|
||||
Alt: "{{ .Status }}",
|
||||
Href: "{{ .Status }}",
|
||||
},
|
||||
},
|
||||
Links: []config.PagerdutyLink{
|
||||
{
|
||||
Href: "{{ .Status }}",
|
||||
Text: "{{ .Status }}",
|
||||
},
|
||||
},
|
||||
Details: map[string]any{
|
||||
"firing": `{{ template "pagerduty.default.instances" .Alerts.Firing }}`,
|
||||
"resolved": `{{ template "pagerduty.default.instances" .Alerts.Resolved }}`,
|
||||
"num_firing": `{{ .Alerts.Firing | len }}`,
|
||||
"num_resolved": `{{ .Alerts.Resolved | len }}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "nested details",
|
||||
cfg: &config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
Details: map[string]any{
|
||||
"a": map[string]any{
|
||||
"b": map[string]any{
|
||||
"c": map[string]any{
|
||||
"firing": `{{ .Alerts.Firing | toJson }}`,
|
||||
"resolved": `{{ .Alerts.Resolved | toJson }}`,
|
||||
"num_firing": `{{ .Alerts.Firing | len }}`,
|
||||
"num_resolved": `{{ .Alerts.Resolved | len }}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "nested details with template error",
|
||||
cfg: &config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
Details: map[string]any{
|
||||
"a": map[string]any{
|
||||
"b": map[string]any{
|
||||
"c": map[string]any{
|
||||
"firing": `{{ template "pagerduty.default.instances" .Alerts.Firing`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
errMsg: "failed to render details: template: :1: unclosed action",
|
||||
},
|
||||
{
|
||||
title: "details with templating errors",
|
||||
cfg: &config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
Details: map[string]any{
|
||||
"firing": `{{ .Alerts.Firing | toJson`,
|
||||
"resolved": `{{ .Alerts.Resolved | toJson }}`,
|
||||
"num_firing": `{{ .Alerts.Firing | len }}`,
|
||||
"num_resolved": `{{ .Alerts.Resolved | len }}`,
|
||||
},
|
||||
},
|
||||
errMsg: "failed to render details: template: :1: unclosed action",
|
||||
},
|
||||
{
|
||||
title: "v2 message with templating errors",
|
||||
cfg: &config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
Severity: "{{ ",
|
||||
},
|
||||
errMsg: "failed to template",
|
||||
},
|
||||
{
|
||||
title: "v1 message with templating errors",
|
||||
cfg: &config.PagerdutyConfig{
|
||||
ServiceKey: config.Secret("01234567890123456789012345678901"),
|
||||
Client: "{{ ",
|
||||
},
|
||||
errMsg: "failed to template",
|
||||
},
|
||||
{
|
||||
title: "routing key cannot be empty",
|
||||
cfg: &config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret(`{{ "" }}`),
|
||||
},
|
||||
errMsg: "routing key cannot be empty",
|
||||
},
|
||||
{
|
||||
title: "service_key cannot be empty",
|
||||
cfg: &config.PagerdutyConfig{
|
||||
ServiceKey: config.Secret(`{{ "" }}`),
|
||||
},
|
||||
errMsg: "service key cannot be empty",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.title, func(t *testing.T) {
|
||||
tc.cfg.URL = &config.URL{URL: u}
|
||||
tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{}
|
||||
pd, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger())
|
||||
require.NoError(t, err)
|
||||
if pd.apiV1 != "" {
|
||||
pd.apiV1 = u.String()
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
|
||||
ok, err := pd.Notify(ctx, []*types.Alert{
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"lbl1": "val1",
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
},
|
||||
}...)
|
||||
if tc.errMsg == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
if errors.Asc(err, errors.CodeInternal) {
|
||||
_, _, errMsg, _, _, _ := errors.Unwrapb(err)
|
||||
require.Contains(t, errMsg, tc.errMsg)
|
||||
} else {
|
||||
require.Contains(t, err.Error(), tc.errMsg)
|
||||
}
|
||||
}
|
||||
require.Equal(t, tc.retry, ok)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrDetails(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
status int
|
||||
body io.Reader
|
||||
|
||||
exp string
|
||||
}{
|
||||
{
|
||||
status: http.StatusBadRequest,
|
||||
body: bytes.NewBuffer([]byte(
|
||||
`{"status":"invalid event","message":"Event object is invalid","errors":["Length of 'routing_key' is incorrect (should be 32 characters)"]}`,
|
||||
)),
|
||||
|
||||
exp: "Length of 'routing_key' is incorrect",
|
||||
},
|
||||
{
|
||||
status: http.StatusBadRequest,
|
||||
body: bytes.NewBuffer([]byte(`{"status"}`)),
|
||||
|
||||
exp: "",
|
||||
},
|
||||
{
|
||||
status: http.StatusBadRequest,
|
||||
|
||||
exp: "",
|
||||
},
|
||||
{
|
||||
status: http.StatusTooManyRequests,
|
||||
|
||||
exp: "",
|
||||
},
|
||||
} {
|
||||
t.Run("", func(t *testing.T) {
|
||||
err := errDetails(tc.status, tc.body)
|
||||
require.Contains(t, err, tc.exp)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventSizeEnforcement(t *testing.T) {
|
||||
bigDetailsV1 := map[string]any{
|
||||
"firing": strings.Repeat("a", 513000),
|
||||
}
|
||||
bigDetailsV2 := map[string]any{
|
||||
"firing": strings.Repeat("a", 513000),
|
||||
}
|
||||
|
||||
// V1 Messages
|
||||
msgV1 := &pagerDutyMessage{
|
||||
ServiceKey: "01234567890123456789012345678901",
|
||||
EventType: "trigger",
|
||||
Details: bigDetailsV1,
|
||||
}
|
||||
|
||||
notifierV1, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
ServiceKey: config.Secret("01234567890123456789012345678901"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
encodedV1, err := notifierV1.encodeMessage(context.Background(), msgV1)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, encodedV1.String(), `"details":{"error":"Custom details have been removed because the original event exceeds the maximum size of 512KB"}`)
|
||||
|
||||
// V2 Messages
|
||||
msgV2 := &pagerDutyMessage{
|
||||
RoutingKey: "01234567890123456789012345678901",
|
||||
EventAction: "trigger",
|
||||
Payload: &pagerDutyPayload{
|
||||
CustomDetails: bigDetailsV2,
|
||||
},
|
||||
}
|
||||
|
||||
notifierV2, err := New(
|
||||
&config.PagerdutyConfig{
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
encodedV2, err := notifierV2.encodeMessage(context.Background(), msgV2)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, encodedV2.String(), `"custom_details":{"error":"Custom details have been removed because the original event exceeds the maximum size of 512KB"}`)
|
||||
}
|
||||
|
||||
func TestPagerDutyEmptySrcHref(t *testing.T) {
|
||||
type pagerDutyEvent struct {
|
||||
RoutingKey string `json:"routing_key"`
|
||||
EventAction string `json:"event_action"`
|
||||
DedupKey string `json:"dedup_key"`
|
||||
Payload pagerDutyPayload `json:"payload"`
|
||||
Images []pagerDutyImage
|
||||
Links []pagerDutyLink
|
||||
}
|
||||
|
||||
images := []config.PagerdutyImage{
|
||||
{
|
||||
Src: "",
|
||||
Alt: "Empty src",
|
||||
Href: "https://example.com/",
|
||||
},
|
||||
{
|
||||
Src: "https://example.com/cat.jpg",
|
||||
Alt: "Empty href",
|
||||
Href: "",
|
||||
},
|
||||
{
|
||||
Src: "https://example.com/cat.jpg",
|
||||
Alt: "",
|
||||
Href: "https://example.com/",
|
||||
},
|
||||
}
|
||||
|
||||
links := []config.PagerdutyLink{
|
||||
{
|
||||
Href: "",
|
||||
Text: "Empty href",
|
||||
},
|
||||
{
|
||||
Href: "https://example.com/",
|
||||
Text: "",
|
||||
},
|
||||
}
|
||||
|
||||
expectedImages := make([]pagerDutyImage, 0, len(images))
|
||||
for _, image := range images {
|
||||
if image.Src == "" {
|
||||
continue
|
||||
}
|
||||
expectedImages = append(expectedImages, pagerDutyImage{
|
||||
Src: image.Src,
|
||||
Alt: image.Alt,
|
||||
Href: image.Href,
|
||||
})
|
||||
}
|
||||
|
||||
expectedLinks := make([]pagerDutyLink, 0, len(links))
|
||||
for _, link := range links {
|
||||
if link.Href == "" {
|
||||
continue
|
||||
}
|
||||
expectedLinks = append(expectedLinks, pagerDutyLink{
|
||||
HRef: link.Href,
|
||||
Text: link.Text,
|
||||
})
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var event pagerDutyEvent
|
||||
if err := decoder.Decode(&event); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if event.RoutingKey == "" || event.EventAction == "" {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
for _, image := range event.Images {
|
||||
if image.Src == "" {
|
||||
http.Error(w, "Event object is invalid: 'image src' is missing or blank", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, link := range event.Links {
|
||||
if link.HRef == "" {
|
||||
http.Error(w, "Event object is invalid: 'link href' is missing or blank", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, expectedImages, event.Images)
|
||||
require.Equal(t, expectedLinks, event.Links)
|
||||
},
|
||||
))
|
||||
defer server.Close()
|
||||
|
||||
url, err := url.Parse(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
pagerDutyConfig := config.PagerdutyConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
URL: &config.URL{URL: url},
|
||||
Images: images,
|
||||
Links: links,
|
||||
}
|
||||
|
||||
pagerDuty, err := New(&pagerDutyConfig, test.CreateTmpl(t), promslog.NewNopLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
|
||||
_, err = pagerDuty.Notify(ctx, []*types.Alert{
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"lbl1": "val1",
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
},
|
||||
}...)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPagerDutyTimeout(t *testing.T) {
|
||||
type pagerDutyEvent struct {
|
||||
RoutingKey string `json:"routing_key"`
|
||||
EventAction string `json:"event_action"`
|
||||
DedupKey string `json:"dedup_key"`
|
||||
Payload pagerDutyPayload `json:"payload"`
|
||||
Images []pagerDutyImage
|
||||
Links []pagerDutyLink
|
||||
}
|
||||
|
||||
tests := map[string]struct {
|
||||
latency time.Duration
|
||||
timeout time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
"success": {latency: 100 * time.Millisecond, timeout: 120 * time.Millisecond, wantErr: false},
|
||||
"error": {latency: 100 * time.Millisecond, timeout: 80 * time.Millisecond, wantErr: true},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var event pagerDutyEvent
|
||||
if err := decoder.Decode(&event); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if event.RoutingKey == "" || event.EventAction == "" {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
time.Sleep(tt.latency)
|
||||
},
|
||||
))
|
||||
defer srv.Close()
|
||||
u, err := url.Parse(srv.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := config.PagerdutyConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
RoutingKey: config.Secret("01234567890123456789012345678901"),
|
||||
URL: &config.URL{URL: u},
|
||||
Timeout: tt.timeout,
|
||||
}
|
||||
|
||||
pd, err := New(&cfg, test.CreateTmpl(t), promslog.NewNopLogger())
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
alert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"lbl1": "val1",
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
_, err = pd.Notify(ctx, alert)
|
||||
require.Equal(t, tt.wantErr, err != nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDetails(t *testing.T) {
|
||||
type args struct {
|
||||
details map[string]any
|
||||
data *template.Data
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want map[string]any
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "flat",
|
||||
args: args{
|
||||
details: map[string]any{
|
||||
"a": "{{ .Status }}",
|
||||
"b": "String",
|
||||
},
|
||||
data: &template.Data{
|
||||
Status: "Flat",
|
||||
},
|
||||
},
|
||||
want: map[string]any{
|
||||
"a": "Flat",
|
||||
"b": "String",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "flat error",
|
||||
args: args{
|
||||
details: map[string]any{
|
||||
"a": "{{ .Status",
|
||||
},
|
||||
data: &template.Data{
|
||||
Status: "Error",
|
||||
},
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "nested",
|
||||
args: args{
|
||||
details: map[string]any{
|
||||
"a": map[string]any{
|
||||
"b": map[string]any{
|
||||
"c": "{{ .Status }}",
|
||||
"d": "String",
|
||||
},
|
||||
},
|
||||
},
|
||||
data: &template.Data{
|
||||
Status: "Nested",
|
||||
},
|
||||
},
|
||||
want: map[string]any{
|
||||
"a": map[string]any{
|
||||
"b": map[string]any{
|
||||
"c": "Nested",
|
||||
"d": "String",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "nested error",
|
||||
args: args{
|
||||
details: map[string]any{
|
||||
"a": map[string]any{
|
||||
"b": map[string]any{
|
||||
"c": "{{ .Status",
|
||||
},
|
||||
},
|
||||
},
|
||||
data: &template.Data{
|
||||
Status: "Error",
|
||||
},
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "alerts",
|
||||
args: args{
|
||||
details: map[string]any{
|
||||
"alerts": map[string]any{
|
||||
"firing": "{{ .Alerts.Firing | toJson }}",
|
||||
"resolved": "{{ .Alerts.Resolved | toJson }}",
|
||||
"num_firing": "{{ len .Alerts.Firing }}",
|
||||
"num_resolved": "{{ len .Alerts.Resolved }}",
|
||||
},
|
||||
},
|
||||
data: &template.Data{
|
||||
Alerts: template.Alerts{
|
||||
{
|
||||
Status: "firing",
|
||||
Annotations: template.KV{
|
||||
"annotation1": "value1",
|
||||
"annotation2": "value2",
|
||||
},
|
||||
Labels: template.KV{
|
||||
"alertname": "Firing1",
|
||||
"label1": "value1",
|
||||
"label2": "value2",
|
||||
},
|
||||
Fingerprint: "fingerprint1",
|
||||
GeneratorURL: "http://generator1",
|
||||
StartsAt: time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC),
|
||||
EndsAt: time.Date(2001, time.January, 1, 1, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Status: "firing",
|
||||
Annotations: template.KV{
|
||||
"annotation1": "value1",
|
||||
"annotation2": "value2",
|
||||
},
|
||||
Labels: template.KV{
|
||||
"alertname": "Firing2",
|
||||
"label1": "value1",
|
||||
"label2": "value2",
|
||||
},
|
||||
Fingerprint: "fingerprint2",
|
||||
GeneratorURL: "http://generator2",
|
||||
StartsAt: time.Date(2002, time.January, 1, 0, 0, 0, 0, time.UTC),
|
||||
EndsAt: time.Date(2002, time.January, 1, 1, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Status: "resolved",
|
||||
Annotations: template.KV{
|
||||
"annotation1": "value1",
|
||||
"annotation2": "value2",
|
||||
},
|
||||
Labels: template.KV{
|
||||
"alertname": "Resolved1",
|
||||
"label1": "value1",
|
||||
"label2": "value2",
|
||||
},
|
||||
Fingerprint: "fingerprint3",
|
||||
GeneratorURL: "http://generator3",
|
||||
StartsAt: time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC),
|
||||
EndsAt: time.Date(2001, time.January, 1, 1, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
Status: "resolved",
|
||||
Annotations: template.KV{
|
||||
"annotation1": "value1",
|
||||
"annotation2": "value2",
|
||||
},
|
||||
Labels: template.KV{
|
||||
"alertname": "Resolved2",
|
||||
"label1": "value1",
|
||||
"label2": "value2",
|
||||
},
|
||||
Fingerprint: "fingerprint4",
|
||||
GeneratorURL: "http://generator4",
|
||||
StartsAt: time.Date(2002, time.January, 1, 0, 0, 0, 0, time.UTC),
|
||||
EndsAt: time.Date(2002, time.January, 1, 1, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: map[string]any{
|
||||
"alerts": map[string]any{
|
||||
"firing": []any{
|
||||
map[string]any{
|
||||
"status": "firing",
|
||||
"labels": map[string]any{
|
||||
"alertname": "Firing1",
|
||||
"label1": "value1",
|
||||
"label2": "value2",
|
||||
},
|
||||
"annotations": map[string]any{
|
||||
"annotation1": "value1",
|
||||
"annotation2": "value2",
|
||||
},
|
||||
"startsAt": time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
"endsAt": time.Date(2001, time.January, 1, 1, 0, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
"fingerprint": "fingerprint1",
|
||||
"generatorURL": "http://generator1",
|
||||
},
|
||||
map[string]any{
|
||||
"status": "firing",
|
||||
"labels": map[string]any{
|
||||
"alertname": "Firing2",
|
||||
"label1": "value1",
|
||||
"label2": "value2",
|
||||
},
|
||||
"annotations": map[string]any{
|
||||
"annotation1": "value1",
|
||||
"annotation2": "value2",
|
||||
},
|
||||
"startsAt": time.Date(2002, time.January, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
"endsAt": time.Date(2002, time.January, 1, 1, 0, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
"fingerprint": "fingerprint2",
|
||||
"generatorURL": "http://generator2",
|
||||
},
|
||||
},
|
||||
"resolved": []any{
|
||||
map[string]any{
|
||||
"status": "resolved",
|
||||
"labels": map[string]any{
|
||||
"alertname": "Resolved1",
|
||||
"label1": "value1",
|
||||
"label2": "value2",
|
||||
},
|
||||
"annotations": map[string]any{
|
||||
"annotation1": "value1",
|
||||
"annotation2": "value2",
|
||||
},
|
||||
"startsAt": time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
"endsAt": time.Date(2001, time.January, 1, 1, 0, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
"fingerprint": "fingerprint3",
|
||||
"generatorURL": "http://generator3",
|
||||
},
|
||||
map[string]any{
|
||||
"status": "resolved",
|
||||
"labels": map[string]any{
|
||||
"alertname": "Resolved2",
|
||||
"label1": "value1",
|
||||
"label2": "value2",
|
||||
},
|
||||
"annotations": map[string]any{
|
||||
"annotation1": "value1",
|
||||
"annotation2": "value2",
|
||||
},
|
||||
"startsAt": time.Date(2002, time.January, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
"endsAt": time.Date(2002, time.January, 1, 1, 0, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
"fingerprint": "fingerprint4",
|
||||
"generatorURL": "http://generator4",
|
||||
},
|
||||
},
|
||||
"num_firing": 2,
|
||||
"num_resolved": 2,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
n := &Notifier{
|
||||
conf: &config.PagerdutyConfig{
|
||||
Details: tt.args.details,
|
||||
},
|
||||
tmpl: test.CreateTmpl(t),
|
||||
}
|
||||
got, err := n.renderDetails(tt.args.data)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("renderDetails() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,8 @@ package alertmanagernotify
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/email"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/msteamsv2"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/pagerduty"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/slack"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/webhook"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/prometheus/alertmanager/config/receiver"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
@@ -17,15 +11,6 @@ import (
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
var customNotifierIntegrations = []string{
|
||||
webhook.Integration,
|
||||
email.Integration,
|
||||
pagerduty.Integration,
|
||||
opsgenie.Integration,
|
||||
slack.Integration,
|
||||
msteamsv2.Integration,
|
||||
}
|
||||
|
||||
func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger) ([]notify.Integration, error) {
|
||||
upstreamIntegrations, err := receiver.BuildReceiverIntegrations(nc, tmpl, logger)
|
||||
if err != nil {
|
||||
@@ -46,29 +31,14 @@ func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Templ
|
||||
)
|
||||
|
||||
for _, integration := range upstreamIntegrations {
|
||||
// skip upstream integration if we support custom integration for it
|
||||
if !slices.Contains(customNotifierIntegrations, integration.Name()) {
|
||||
// skip upstream msteamsv2 integration
|
||||
if integration.Name() != "msteamsv2" {
|
||||
integrations = append(integrations, integration)
|
||||
}
|
||||
}
|
||||
|
||||
for i, c := range nc.WebhookConfigs {
|
||||
add(webhook.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return webhook.New(c, tmpl, l) })
|
||||
}
|
||||
for i, c := range nc.EmailConfigs {
|
||||
add(email.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return email.New(c, tmpl, l), nil })
|
||||
}
|
||||
for i, c := range nc.PagerdutyConfigs {
|
||||
add(pagerduty.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l) })
|
||||
}
|
||||
for i, c := range nc.OpsGenieConfigs {
|
||||
add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l) })
|
||||
}
|
||||
for i, c := range nc.SlackConfigs {
|
||||
add(slack.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l) })
|
||||
}
|
||||
for i, c := range nc.MSTeamsV2Configs {
|
||||
add(msteamsv2.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
|
||||
add("msteamsv2", i, c, func(l *slog.Logger) (notify.Notifier, error) {
|
||||
return msteamsv2.New(c, tmpl, `{{ template "msteamsv2.default.titleLink" . }}`, l)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// Copyright 2019 Prometheus Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package slack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
const (
|
||||
Integration = "slack"
|
||||
)
|
||||
|
||||
// https://api.slack.com/reference/messaging/attachments#legacy_fields - 1024, no units given, assuming runes or characters.
|
||||
const maxTitleLenRunes = 1024
|
||||
|
||||
// Notifier implements a Notifier for Slack notifications.
|
||||
type Notifier struct {
|
||||
conf *config.SlackConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
|
||||
postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error)
|
||||
}
|
||||
|
||||
// New returns a new Slack notification handler.
|
||||
func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Notifier{
|
||||
conf: c,
|
||||
tmpl: t,
|
||||
logger: l,
|
||||
client: client,
|
||||
retrier: ¬ify.Retrier{},
|
||||
postJSONFunc: notify.PostJSON,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// request is the request for sending a slack notification.
|
||||
type request struct {
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
IconEmoji string `json:"icon_emoji,omitempty"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
LinkNames bool `json:"link_names,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Attachments []attachment `json:"attachments"`
|
||||
}
|
||||
|
||||
// attachment is used to display a richly-formatted message block.
|
||||
type attachment struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
TitleLink string `json:"title_link,omitempty"`
|
||||
Pretext string `json:"pretext,omitempty"`
|
||||
Text string `json:"text"`
|
||||
Fallback string `json:"fallback"`
|
||||
CallbackID string `json:"callback_id"`
|
||||
Fields []config.SlackField `json:"fields,omitempty"`
|
||||
Actions []config.SlackAction `json:"actions,omitempty"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
ThumbURL string `json:"thumb_url,omitempty"`
|
||||
Footer string `json:"footer"`
|
||||
Color string `json:"color,omitempty"`
|
||||
MrkdwnIn []string `json:"mrkdwn_in,omitempty"`
|
||||
}
|
||||
|
||||
// Notify implements the Notifier interface.
|
||||
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
|
||||
key, err := notify.ExtractGroupKey(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
logger := n.logger.With(slog.Any("group_key", key))
|
||||
logger.DebugContext(ctx, "extracted group key")
|
||||
|
||||
var (
|
||||
data = notify.GetTemplateData(ctx, n.tmpl, as, logger)
|
||||
tmplText = notify.TmplText(n.tmpl, data, &err)
|
||||
)
|
||||
var markdownIn []string
|
||||
|
||||
if len(n.conf.MrkdwnIn) == 0 {
|
||||
markdownIn = []string{"fallback", "pretext", "text"}
|
||||
} else {
|
||||
markdownIn = n.conf.MrkdwnIn
|
||||
}
|
||||
|
||||
title, truncated := notify.TruncateInRunes(tmplText(n.conf.Title), maxTitleLenRunes)
|
||||
if truncated {
|
||||
logger.WarnContext(ctx, "Truncated title", slog.Int("max_runes", maxTitleLenRunes))
|
||||
}
|
||||
att := &attachment{
|
||||
Title: title,
|
||||
TitleLink: tmplText(n.conf.TitleLink),
|
||||
Pretext: tmplText(n.conf.Pretext),
|
||||
Text: tmplText(n.conf.Text),
|
||||
Fallback: tmplText(n.conf.Fallback),
|
||||
CallbackID: tmplText(n.conf.CallbackID),
|
||||
ImageURL: tmplText(n.conf.ImageURL),
|
||||
ThumbURL: tmplText(n.conf.ThumbURL),
|
||||
Footer: tmplText(n.conf.Footer),
|
||||
Color: tmplText(n.conf.Color),
|
||||
MrkdwnIn: markdownIn,
|
||||
}
|
||||
|
||||
numFields := len(n.conf.Fields)
|
||||
if numFields > 0 {
|
||||
fields := make([]config.SlackField, numFields)
|
||||
for index, field := range n.conf.Fields {
|
||||
// Check if short was defined for the field otherwise fallback to the global setting
|
||||
var short bool
|
||||
if field.Short != nil {
|
||||
short = *field.Short
|
||||
} else {
|
||||
short = n.conf.ShortFields
|
||||
}
|
||||
|
||||
// Rebuild the field by executing any templates and setting the new value for short
|
||||
fields[index] = config.SlackField{
|
||||
Title: tmplText(field.Title),
|
||||
Value: tmplText(field.Value),
|
||||
Short: &short,
|
||||
}
|
||||
}
|
||||
att.Fields = fields
|
||||
}
|
||||
|
||||
numActions := len(n.conf.Actions)
|
||||
if numActions > 0 {
|
||||
actions := make([]config.SlackAction, numActions)
|
||||
for index, action := range n.conf.Actions {
|
||||
slackAction := config.SlackAction{
|
||||
Type: tmplText(action.Type),
|
||||
Text: tmplText(action.Text),
|
||||
URL: tmplText(action.URL),
|
||||
Style: tmplText(action.Style),
|
||||
Name: tmplText(action.Name),
|
||||
Value: tmplText(action.Value),
|
||||
}
|
||||
|
||||
if action.ConfirmField != nil {
|
||||
slackAction.ConfirmField = &config.SlackConfirmationField{
|
||||
Title: tmplText(action.ConfirmField.Title),
|
||||
Text: tmplText(action.ConfirmField.Text),
|
||||
OkText: tmplText(action.ConfirmField.OkText),
|
||||
DismissText: tmplText(action.ConfirmField.DismissText),
|
||||
}
|
||||
}
|
||||
|
||||
actions[index] = slackAction
|
||||
}
|
||||
att.Actions = actions
|
||||
}
|
||||
|
||||
req := &request{
|
||||
Channel: tmplText(n.conf.Channel),
|
||||
Username: tmplText(n.conf.Username),
|
||||
IconEmoji: tmplText(n.conf.IconEmoji),
|
||||
IconURL: tmplText(n.conf.IconURL),
|
||||
LinkNames: n.conf.LinkNames,
|
||||
Text: tmplText(n.conf.MessageText),
|
||||
Attachments: []attachment{*att},
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(req); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var u string
|
||||
if n.conf.APIURL != nil {
|
||||
u = n.conf.APIURL.String()
|
||||
} else {
|
||||
content, err := os.ReadFile(n.conf.APIURLFile)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
u = strings.TrimSpace(string(content))
|
||||
}
|
||||
|
||||
if n.conf.Timeout > 0 {
|
||||
postCtx, cancel := context.WithTimeoutCause(ctx, n.conf.Timeout, errors.NewInternalf(errors.CodeTimeout, "configured slack timeout reached (%s)", n.conf.Timeout))
|
||||
defer cancel()
|
||||
ctx = postCtx
|
||||
}
|
||||
|
||||
resp, err := n.postJSONFunc(ctx, n.client, u, &buf) //nolint:bodyclose
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
err = errors.NewInternalf(errors.CodeInternal, "failed to post JSON to slack: %v", context.Cause(ctx))
|
||||
}
|
||||
return true, notify.RedactURL(err)
|
||||
}
|
||||
defer notify.Drain(resp)
|
||||
|
||||
// Use a retrier to generate an error message for non-200 responses and
|
||||
// classify them as retriable or not.
|
||||
retry, err := n.retrier.Check(resp.StatusCode, resp.Body)
|
||||
if err != nil {
|
||||
err = errors.NewInternalf(errors.CodeInternal, "channel %q: %v", req.Channel, err)
|
||||
return retry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
|
||||
}
|
||||
|
||||
// Slack web API might return errors with a 200 response code.
|
||||
// https://docs.slack.dev/tools/node-slack-sdk/web-api/#handle-errors
|
||||
retry, err = checkResponseError(resp)
|
||||
if err != nil {
|
||||
err = errors.NewInternalf(errors.CodeInternal, "channel %q: %v", req.Channel, err)
|
||||
return retry, notify.NewErrorWithReason(notify.ClientErrorReason, err)
|
||||
}
|
||||
|
||||
return retry, nil
|
||||
}
|
||||
|
||||
// checkResponseError parses out the error message from Slack API response.
|
||||
func checkResponseError(resp *http.Response) (bool, error) {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return true, errors.WrapInternalf(err, errors.CodeInternal, "could not read response body")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
|
||||
return checkJSONResponseError(body)
|
||||
}
|
||||
return checkTextResponseError(body)
|
||||
}
|
||||
|
||||
// checkTextResponseError classifies plaintext responses from Slack.
|
||||
// A plaintext (non-JSON) response is successful if it's a string "ok".
|
||||
// This is typically a response for an Incoming Webhook
|
||||
// (https://api.slack.com/messaging/webhooks#handling_errors)
|
||||
func checkTextResponseError(body []byte) (bool, error) {
|
||||
if !bytes.Equal(body, []byte("ok")) {
|
||||
return false, errors.NewInternalf(errors.CodeInternal, "received an error response from Slack: %s", string(body))
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// checkJSONResponseError classifies JSON responses from Slack.
|
||||
func checkJSONResponseError(body []byte) (bool, error) {
|
||||
// response is for parsing out errors from the JSON response.
|
||||
type response struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
var data response
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return true, errors.NewInternalf(errors.CodeInternal, "could not unmarshal JSON response %q: %v", string(body), err)
|
||||
}
|
||||
if !data.OK {
|
||||
return false, errors.NewInternalf(errors.CodeInternal, "error response from Slack: %s", data.Error)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// Copyright 2019 Prometheus Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package slack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/common/promslog"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/notify/test"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
func TestSlackRetry(t *testing.T) {
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) {
|
||||
actual, _ := notifier.retrier.Check(statusCode, nil)
|
||||
require.Equal(t, expected, actual, "error on status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlackRedactedURL(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
APIURL: &config.SecretURL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String())
|
||||
}
|
||||
|
||||
func TestGettingSlackURLFromFile(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
f, err := os.CreateTemp(t.TempDir(), "slack_test")
|
||||
require.NoError(t, err, "creating temp file failed")
|
||||
_, err = f.WriteString(u.String())
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
APIURLFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String())
|
||||
}
|
||||
|
||||
func TestTrimmingSlackURLFromFile(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
f, err := os.CreateTemp(t.TempDir(), "slack_test_newline")
|
||||
require.NoError(t, err, "creating temp file failed")
|
||||
_, err = f.WriteString(u.String() + "\n\n")
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
APIURLFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String())
|
||||
}
|
||||
|
||||
func TestNotifier_Notify_WithReason(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
responseBody string
|
||||
expectedReason notify.Reason
|
||||
expectedErr string
|
||||
expectedRetry bool
|
||||
noError bool
|
||||
}{
|
||||
{
|
||||
name: "with a 4xx status code",
|
||||
statusCode: http.StatusUnauthorized,
|
||||
expectedReason: notify.ClientErrorReason,
|
||||
expectedRetry: false,
|
||||
expectedErr: "unexpected status code 401",
|
||||
},
|
||||
{
|
||||
name: "with a 5xx status code",
|
||||
statusCode: http.StatusInternalServerError,
|
||||
expectedReason: notify.ServerErrorReason,
|
||||
expectedRetry: true,
|
||||
expectedErr: "unexpected status code 500",
|
||||
},
|
||||
{
|
||||
name: "with a 3xx status code",
|
||||
statusCode: http.StatusTemporaryRedirect,
|
||||
expectedReason: notify.DefaultReason,
|
||||
expectedRetry: false,
|
||||
expectedErr: "unexpected status code 307",
|
||||
},
|
||||
{
|
||||
name: "with a 1xx status code",
|
||||
statusCode: http.StatusSwitchingProtocols,
|
||||
expectedReason: notify.DefaultReason,
|
||||
expectedRetry: false,
|
||||
expectedErr: "unexpected status code 101",
|
||||
},
|
||||
{
|
||||
name: "2xx response with invalid JSON",
|
||||
statusCode: http.StatusOK,
|
||||
responseBody: `{"not valid json"}`,
|
||||
expectedReason: notify.ClientErrorReason,
|
||||
expectedRetry: true,
|
||||
expectedErr: "could not unmarshal",
|
||||
},
|
||||
{
|
||||
name: "2xx response with a JSON error",
|
||||
statusCode: http.StatusOK,
|
||||
responseBody: `{"ok":false,"error":"error_message"}`,
|
||||
expectedReason: notify.ClientErrorReason,
|
||||
expectedRetry: false,
|
||||
expectedErr: "error response from Slack: error_message",
|
||||
},
|
||||
{
|
||||
name: "2xx response with a plaintext error",
|
||||
statusCode: http.StatusOK,
|
||||
responseBody: "no_channel",
|
||||
expectedReason: notify.ClientErrorReason,
|
||||
expectedRetry: false,
|
||||
expectedErr: "error response from Slack: no_channel",
|
||||
},
|
||||
{
|
||||
name: "successful JSON response",
|
||||
statusCode: http.StatusOK,
|
||||
responseBody: `{"ok":true}`,
|
||||
noError: true,
|
||||
},
|
||||
{
|
||||
name: "successful plaintext response",
|
||||
statusCode: http.StatusOK,
|
||||
responseBody: "ok",
|
||||
noError: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
apiurl, _ := url.Parse("https://slack.com/post.Message")
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
NotifierConfig: config.NotifierConfig{},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
APIURL: &config.SecretURL{URL: apiurl},
|
||||
Channel: "channelname",
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
notifier.postJSONFunc = func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) {
|
||||
resp := httptest.NewRecorder()
|
||||
if strings.HasPrefix(tt.responseBody, "{") {
|
||||
resp.Header().Add("Content-Type", "application/json; charset=utf-8")
|
||||
}
|
||||
resp.WriteHeader(tt.statusCode)
|
||||
_, _ = resp.WriteString(tt.responseBody)
|
||||
return resp.Result(), nil
|
||||
}
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
|
||||
alert1 := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
retry, err := notifier.Notify(ctx, alert1)
|
||||
require.Equal(t, tt.expectedRetry, retry)
|
||||
if tt.noError {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
var reasonError *notify.ErrorWithReason
|
||||
require.ErrorAs(t, err, &reasonError)
|
||||
require.Equal(t, tt.expectedReason, reasonError.Reason)
|
||||
require.Contains(t, err.Error(), tt.expectedErr)
|
||||
require.Contains(t, err.Error(), "channelname")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlackTimeout(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
latency time.Duration
|
||||
timeout time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
"success": {latency: 100 * time.Millisecond, timeout: 120 * time.Millisecond, wantErr: false},
|
||||
"error": {latency: 100 * time.Millisecond, timeout: 80 * time.Millisecond, wantErr: true},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
u, _ := url.Parse("https://slack.com/post.Message")
|
||||
notifier, err := New(
|
||||
&config.SlackConfig{
|
||||
NotifierConfig: config.NotifierConfig{},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
APIURL: &config.SecretURL{URL: u},
|
||||
Channel: "channelname",
|
||||
Timeout: tt.timeout,
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
notifier.postJSONFunc = func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(tt.latency):
|
||||
resp := httptest.NewRecorder()
|
||||
resp.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
resp.WriteHeader(http.StatusOK)
|
||||
_, _ = resp.WriteString(`{"ok":true}`)
|
||||
|
||||
return resp.Result(), nil
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "1")
|
||||
|
||||
alert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
_, err = notifier.Notify(ctx, alert)
|
||||
require.Equal(t, tt.wantErr, err != nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlackMessageField(t *testing.T) {
|
||||
// 1. Setup a fake Slack server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 2. VERIFY: Top-level text exists
|
||||
if body["text"] != "My Top Level Message" {
|
||||
t.Errorf("Expected top-level 'text' to be 'My Top Level Message', got %v", body["text"])
|
||||
}
|
||||
|
||||
// 3. VERIFY: Old attachments still exist
|
||||
attachments, ok := body["attachments"].([]any)
|
||||
if !ok || len(attachments) == 0 {
|
||||
t.Errorf("Expected attachments to exist")
|
||||
} else {
|
||||
first := attachments[0].(map[string]any)
|
||||
if first["title"] != "Old Attachment Title" {
|
||||
t.Errorf("Expected attachment title 'Old Attachment Title', got %v", first["title"])
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok": true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// 4. Configure Notifier with BOTH new and old fields
|
||||
u, _ := url.Parse(server.URL)
|
||||
conf := &config.SlackConfig{
|
||||
APIURL: &config.SecretURL{URL: u},
|
||||
MessageText: "My Top Level Message", // Your NEW field
|
||||
Title: "Old Attachment Title", // An OLD field
|
||||
Channel: "#test-channel",
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
}
|
||||
|
||||
tmpl, err := template.FromGlobs([]string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpl.ExternalURL = u
|
||||
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
notifier, err := New(conf, tmpl, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "test-group-key")
|
||||
|
||||
if _, err := notifier.Notify(ctx); err != nil {
|
||||
t.Fatal("Notify failed:", err)
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// Copyright 2019 Prometheus Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
const (
|
||||
Integration = "webhook"
|
||||
)
|
||||
|
||||
// Notifier implements a Notifier for generic webhooks.
|
||||
type Notifier struct {
|
||||
conf *config.WebhookConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
}
|
||||
|
||||
// New returns a new Webhook.
|
||||
func New(conf *config.WebhookConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
client, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration, httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Notifier{
|
||||
conf: conf,
|
||||
tmpl: t,
|
||||
logger: l,
|
||||
client: client,
|
||||
// Webhooks are assumed to respond with 2xx response codes on a successful
|
||||
// request and 5xx response codes are assumed to be recoverable.
|
||||
retrier: ¬ify.Retrier{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Message defines the JSON object send to webhook endpoints.
|
||||
type Message struct {
|
||||
*template.Data
|
||||
|
||||
// The protocol version.
|
||||
Version string `json:"version"`
|
||||
GroupKey string `json:"groupKey"`
|
||||
TruncatedAlerts uint64 `json:"truncatedAlerts"`
|
||||
}
|
||||
|
||||
func truncateAlerts(maxAlerts uint64, alerts []*types.Alert) ([]*types.Alert, uint64) {
|
||||
if maxAlerts != 0 && uint64(len(alerts)) > maxAlerts {
|
||||
return alerts[:maxAlerts], uint64(len(alerts)) - maxAlerts
|
||||
}
|
||||
|
||||
return alerts, 0
|
||||
}
|
||||
|
||||
// Notify implements the Notifier interface.
|
||||
func (n *Notifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) {
|
||||
alerts, numTruncated := truncateAlerts(n.conf.MaxAlerts, alerts)
|
||||
data := notify.GetTemplateData(ctx, n.tmpl, alerts, n.logger)
|
||||
|
||||
groupKey, err := notify.ExtractGroupKey(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
logger := n.logger.With(slog.Any("group_key", groupKey))
|
||||
logger.DebugContext(ctx, "extracted group key")
|
||||
|
||||
msg := &Message{
|
||||
Version: "4",
|
||||
Data: data,
|
||||
GroupKey: groupKey.String(),
|
||||
TruncatedAlerts: numTruncated,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var url string
|
||||
var tmplErr error
|
||||
tmpl := notify.TmplText(n.tmpl, data, &tmplErr)
|
||||
|
||||
if n.conf.URL != "" {
|
||||
url = tmpl(string(n.conf.URL))
|
||||
} else {
|
||||
content, err := os.ReadFile(n.conf.URLFile)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, errors.CodeInternal, "read url_file")
|
||||
}
|
||||
url = tmpl(strings.TrimSpace(string(content)))
|
||||
}
|
||||
|
||||
if tmplErr != nil {
|
||||
return false, errors.NewInternalf(errors.CodeInternal, "failed to template webhook URL: %v", tmplErr)
|
||||
}
|
||||
|
||||
if url == "" {
|
||||
return false, errors.NewInternalf(errors.CodeInternal, "webhook URL is empty after templating")
|
||||
}
|
||||
|
||||
if n.conf.Timeout > 0 {
|
||||
postCtx, cancel := context.WithTimeoutCause(ctx, n.conf.Timeout, errors.NewInternalf(errors.CodeTimeout, "configured webhook timeout reached (%s)", n.conf.Timeout))
|
||||
defer cancel()
|
||||
ctx = postCtx
|
||||
}
|
||||
|
||||
resp, err := notify.PostJSON(ctx, n.client, url, &buf) //nolint:bodyclose
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
err = errors.NewInternalf(errors.CodeInternal, "failed to post JSON to webhook: %v", context.Cause(ctx))
|
||||
}
|
||||
return true, notify.RedactURL(err)
|
||||
}
|
||||
defer notify.Drain(resp)
|
||||
|
||||
shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body)
|
||||
if err != nil {
|
||||
return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
|
||||
}
|
||||
return shouldRetry, err
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// Copyright 2019 Prometheus Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/common/promslog"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/notify/test"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
func TestWebhookRetry(t *testing.T) {
|
||||
notifier, err := New(
|
||||
&config.WebhookConfig{
|
||||
URL: config.SecretTemplateURL("http://example.com"),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
if err != nil {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
t.Run("test retry status code", func(t *testing.T) {
|
||||
for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) {
|
||||
actual, _ := notifier.retrier.Check(statusCode, nil)
|
||||
require.Equal(t, expected, actual, "error on status %d", statusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("test retry error details", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
status int
|
||||
body io.Reader
|
||||
|
||||
exp string
|
||||
}{
|
||||
{
|
||||
status: http.StatusBadRequest,
|
||||
body: bytes.NewBuffer([]byte(
|
||||
`{"status":"invalid event"}`,
|
||||
)),
|
||||
|
||||
exp: fmt.Sprintf(`unexpected status code %d: {"status":"invalid event"}`, http.StatusBadRequest),
|
||||
},
|
||||
{
|
||||
status: http.StatusBadRequest,
|
||||
|
||||
exp: fmt.Sprintf(`unexpected status code %d`, http.StatusBadRequest),
|
||||
},
|
||||
} {
|
||||
t.Run("", func(t *testing.T) {
|
||||
_, err = notifier.retrier.Check(tc.status, tc.body)
|
||||
require.Equal(t, tc.exp, err.Error())
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhookTruncateAlerts(t *testing.T) {
|
||||
alerts := make([]*types.Alert, 10)
|
||||
|
||||
truncatedAlerts, numTruncated := truncateAlerts(0, alerts)
|
||||
require.Len(t, truncatedAlerts, 10)
|
||||
require.EqualValues(t, 0, numTruncated)
|
||||
|
||||
truncatedAlerts, numTruncated = truncateAlerts(4, alerts)
|
||||
require.Len(t, truncatedAlerts, 4)
|
||||
require.EqualValues(t, 6, numTruncated)
|
||||
|
||||
truncatedAlerts, numTruncated = truncateAlerts(100, alerts)
|
||||
require.Len(t, truncatedAlerts, 10)
|
||||
require.EqualValues(t, 0, numTruncated)
|
||||
}
|
||||
|
||||
func TestWebhookRedactedURL(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
secret := "secret"
|
||||
notifier, err := New(
|
||||
&config.WebhookConfig{
|
||||
URL: config.SecretTemplateURL(u.String()),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, secret)
|
||||
}
|
||||
|
||||
func TestWebhookReadingURLFromFile(t *testing.T) {
|
||||
ctx, u, fn := test.GetContextWithCancelingURL()
|
||||
defer fn()
|
||||
|
||||
f, err := os.CreateTemp(t.TempDir(), "webhook_url")
|
||||
require.NoError(t, err, "creating temp file failed")
|
||||
_, err = f.WriteString(u.String() + "\n")
|
||||
require.NoError(t, err, "writing to temp file failed")
|
||||
|
||||
notifier, err := New(
|
||||
&config.WebhookConfig{
|
||||
URLFile: f.Name(),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String())
|
||||
}
|
||||
|
||||
func TestWebhookURLTemplating(t *testing.T) {
|
||||
var calledURL string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calledURL = r.URL.Path
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
groupLabels model.LabelSet
|
||||
alertLabels model.LabelSet
|
||||
expectError bool
|
||||
expectedErrMsg string
|
||||
expectedPath string
|
||||
}{
|
||||
{
|
||||
name: "templating with alert labels",
|
||||
url: srv.URL + "/{{ .GroupLabels.alertname }}/{{ .CommonLabels.severity }}",
|
||||
groupLabels: model.LabelSet{"alertname": "TestAlert"},
|
||||
alertLabels: model.LabelSet{"alertname": "TestAlert", "severity": "critical"},
|
||||
expectError: false,
|
||||
expectedPath: "/TestAlert/critical",
|
||||
},
|
||||
{
|
||||
name: "invalid template field",
|
||||
url: srv.URL + "/{{ .InvalidField }}",
|
||||
groupLabels: model.LabelSet{"alertname": "TestAlert"},
|
||||
alertLabels: model.LabelSet{"alertname": "TestAlert"},
|
||||
expectError: true,
|
||||
expectedErrMsg: "failed to template webhook URL",
|
||||
},
|
||||
{
|
||||
name: "template renders to empty string",
|
||||
url: "{{ if .CommonLabels.nonexistent }}http://example.com{{ end }}",
|
||||
groupLabels: model.LabelSet{"alertname": "TestAlert"},
|
||||
alertLabels: model.LabelSet{"alertname": "TestAlert"},
|
||||
expectError: true,
|
||||
expectedErrMsg: "webhook URL is empty after templating",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
calledURL = "" // Reset for each test
|
||||
|
||||
notifier, err := New(
|
||||
&config.WebhookConfig{
|
||||
URL: config.SecretTemplateURL(tc.url),
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
},
|
||||
test.CreateTmpl(t),
|
||||
promslog.NewNopLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "test-group")
|
||||
if tc.groupLabels != nil {
|
||||
ctx = notify.WithGroupLabels(ctx, tc.groupLabels)
|
||||
}
|
||||
|
||||
alerts := []*types.Alert{
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: tc.alertLabels,
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err = notifier.Notify(ctx, alerts...)
|
||||
|
||||
if tc.expectError {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tc.expectedErrMsg)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expectedPath, calledURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
@@ -60,7 +59,6 @@ type provider struct {
|
||||
cloudIntegrationHandler cloudintegration.Handler
|
||||
ruleStateHistoryHandler rulestatehistory.Handler
|
||||
alertmanagerHandler alertmanager.Handler
|
||||
rulerHandler ruler.Handler
|
||||
}
|
||||
|
||||
func NewFactory(
|
||||
@@ -88,7 +86,6 @@ func NewFactory(
|
||||
cloudIntegrationHandler cloudintegration.Handler,
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
alertmanagerHandler alertmanager.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
|
||||
return newProvider(
|
||||
@@ -119,7 +116,6 @@ func NewFactory(
|
||||
cloudIntegrationHandler,
|
||||
ruleStateHistoryHandler,
|
||||
alertmanagerHandler,
|
||||
rulerHandler,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -152,7 +148,6 @@ func newProvider(
|
||||
cloudIntegrationHandler cloudintegration.Handler,
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
alertmanagerHandler alertmanager.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
) (apiserver.APIServer, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
|
||||
router := mux.NewRouter().UseEncodedPath()
|
||||
@@ -183,7 +178,6 @@ func newProvider(
|
||||
cloudIntegrationHandler: cloudIntegrationHandler,
|
||||
ruleStateHistoryHandler: ruleStateHistoryHandler,
|
||||
alertmanagerHandler: alertmanagerHandler,
|
||||
rulerHandler: rulerHandler,
|
||||
}
|
||||
|
||||
provider.authZ = middleware.NewAuthZ(settings.Logger(), orgGetter, authz)
|
||||
@@ -288,10 +282,6 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addRulerRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/rules", handler.New(provider.authZ.ViewAccess(provider.rulerHandler.ListRules), handler.OpenAPIDef{
|
||||
ID: "ListRules",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "List alert rules",
|
||||
Description: "This endpoint lists all alert rules with their current evaluation state",
|
||||
Response: new(ruletypes.GettableRules),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/rules/{id}", handler.New(provider.authZ.ViewAccess(provider.rulerHandler.GetRuleByID), handler.OpenAPIDef{
|
||||
ID: "GetRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Get alert rule by ID",
|
||||
Description: "This endpoint returns an alert rule by ID",
|
||||
Response: new(ruletypes.GettableRule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/rules", handler.New(provider.authZ.EditAccess(provider.rulerHandler.CreateRule), handler.OpenAPIDef{
|
||||
ID: "CreateRule",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Create alert rule",
|
||||
Description: "This endpoint creates a new alert rule",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(ruletypes.GettableRule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/rules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.UpdateRuleByID), handler.OpenAPIDef{
|
||||
ID: "UpdateRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Update alert rule",
|
||||
Description: "This endpoint updates an alert rule by ID",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/rules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.DeleteRuleByID), handler.OpenAPIDef{
|
||||
ID: "DeleteRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Delete alert rule",
|
||||
Description: "This endpoint deletes an alert rule by ID",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/rules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.PatchRuleByID), handler.OpenAPIDef{
|
||||
ID: "PatchRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Patch alert rule",
|
||||
Description: "This endpoint applies a partial update to an alert rule by ID",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(ruletypes.GettableRule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPatch).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/rules/test", handler.New(provider.authZ.EditAccess(provider.rulerHandler.TestRule), handler.OpenAPIDef{
|
||||
ID: "TestRule",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Test alert rule",
|
||||
Description: "This endpoint fires a test notification for the given rule definition",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/testRule", handler.New(provider.authZ.EditAccess(provider.rulerHandler.TestRule), handler.OpenAPIDef{
|
||||
ID: "TestRuleDeprecated",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Test alert rule (deprecated)",
|
||||
Description: "Deprecated: use /api/v1/rules/test instead",
|
||||
Request: new(ruletypes.PostableRule),
|
||||
RequestContentType: "application/json",
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authZ.ViewAccess(provider.rulerHandler.ListDowntimeSchedules), handler.OpenAPIDef{
|
||||
ID: "ListDowntimeSchedules",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "List downtime schedules",
|
||||
Description: "This endpoint lists all planned maintenance / downtime schedules",
|
||||
Response: make([]*ruletypes.GettablePlannedMaintenance, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authZ.ViewAccess(provider.rulerHandler.GetDowntimeScheduleByID), handler.OpenAPIDef{
|
||||
ID: "GetDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Get downtime schedule by ID",
|
||||
Description: "This endpoint returns a downtime schedule by ID",
|
||||
Response: new(ruletypes.GettablePlannedMaintenance),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authZ.EditAccess(provider.rulerHandler.CreateDowntimeSchedule), handler.OpenAPIDef{
|
||||
ID: "CreateDowntimeSchedule",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Create downtime schedule",
|
||||
Description: "This endpoint creates a new planned maintenance / downtime schedule",
|
||||
Request: new(ruletypes.GettablePlannedMaintenance),
|
||||
RequestContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.UpdateDowntimeScheduleByID), handler.OpenAPIDef{
|
||||
ID: "UpdateDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Update downtime schedule",
|
||||
Description: "This endpoint updates a downtime schedule by ID",
|
||||
Request: new(ruletypes.GettablePlannedMaintenance),
|
||||
RequestContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authZ.EditAccess(provider.rulerHandler.DeleteDowntimeScheduleByID), handler.OpenAPIDef{
|
||||
ID: "DeleteDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Delete downtime schedule",
|
||||
Description: "This endpoint deletes a downtime schedule by ID",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -172,7 +172,7 @@ func Ast(cause error, typ typ) bool {
|
||||
return t == typ
|
||||
}
|
||||
|
||||
// Asc checks if the provided error matches the specified custom error code.
|
||||
// Ast checks if the provided error matches the specified custom error code.
|
||||
func Asc(cause error, code Code) bool {
|
||||
_, c, _, _, _, _ := Unwrapb(cause)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/thirdpartyapi"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
@@ -76,7 +78,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
)
|
||||
|
||||
@@ -96,7 +98,7 @@ func NewRouter() *mux.Router {
|
||||
type APIHandler struct {
|
||||
logger *slog.Logger
|
||||
reader interfaces.Reader
|
||||
ruleManager ruler.Ruler
|
||||
ruleManager *rules.Manager
|
||||
querier interfaces.Querier
|
||||
querierV2 interfaces.Querier
|
||||
queryBuilder *queryBuilder.QueryBuilder
|
||||
@@ -148,6 +150,9 @@ type APIHandlerOpts struct {
|
||||
// business data reader e.g. clickhouse
|
||||
Reader interfaces.Reader
|
||||
|
||||
// rule manager handles rule crud operations
|
||||
RuleManager *rules.Manager
|
||||
|
||||
// Integrations
|
||||
IntegrationsController *integrations.Controller
|
||||
|
||||
@@ -203,7 +208,7 @@ func NewAPIHandler(opts APIHandlerOpts, config signoz.Config) (*APIHandler, erro
|
||||
logger: slog.Default(),
|
||||
reader: opts.Reader,
|
||||
temporalityMap: make(map[string]map[v3.Temporality]bool),
|
||||
ruleManager: opts.Signoz.Ruler,
|
||||
ruleManager: opts.RuleManager,
|
||||
IntegrationsController: opts.IntegrationsController,
|
||||
CloudIntegrationsController: opts.CloudIntegrationsController,
|
||||
LogsParsingPipelineController: opts.LogsParsingPipelineController,
|
||||
@@ -491,11 +496,24 @@ func (aH *APIHandler) Respond(w http.ResponseWriter, data interface{}) {
|
||||
func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
router.HandleFunc("/api/v1/query_range", am.ViewAccess(aH.queryRangeMetrics)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/query", am.ViewAccess(aH.queryMetrics)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/rules", am.ViewAccess(aH.listRules)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/rules/{id}", am.ViewAccess(aH.getRule)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/rules", am.EditAccess(aH.createRule)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.editRule)).Methods(http.MethodPut)
|
||||
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.deleteRule)).Methods(http.MethodDelete)
|
||||
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.patchRule)).Methods(http.MethodPatch)
|
||||
router.HandleFunc("/api/v1/testRule", am.EditAccess(aH.testRule)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v1/rules/{id}/history/stats", am.ViewAccess(aH.getRuleStats)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v1/rules/{id}/history/timeline", am.ViewAccess(aH.getRuleStateHistory)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v1/rules/{id}/history/top_contributors", am.ViewAccess(aH.getRuleStateHistoryTopContributors)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v1/rules/{id}/history/overall_status", am.ViewAccess(aH.getOverallStateTransitions)).Methods(http.MethodPost)
|
||||
|
||||
router.HandleFunc("/api/v1/downtime_schedules", am.ViewAccess(aH.listDowntimeSchedules)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/downtime_schedules/{id}", am.ViewAccess(aH.getDowntimeSchedule)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/downtime_schedules", am.EditAccess(aH.createDowntimeSchedule)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v1/downtime_schedules/{id}", am.EditAccess(aH.editDowntimeSchedule)).Methods(http.MethodPut)
|
||||
router.HandleFunc("/api/v1/downtime_schedules/{id}", am.EditAccess(aH.deleteDowntimeSchedule)).Methods(http.MethodDelete)
|
||||
|
||||
router.HandleFunc("/api/v1/dashboards", am.ViewAccess(aH.List)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/dashboards", am.EditAccess(aH.Signoz.Handlers.Dashboard.Create)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v1/dashboards/{id}", am.ViewAccess(aH.Get)).Methods(http.MethodGet)
|
||||
@@ -585,6 +603,26 @@ func Intersection(a, b []int) (c []int) {
|
||||
return
|
||||
}
|
||||
|
||||
func (aH *APIHandler) getRule(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := mux.Vars(r)["id"]
|
||||
id, err := valuer.NewUUID(idStr)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
ruleResponse, err := aH.ruleManager.GetRule(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
|
||||
return
|
||||
}
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, ruleResponse)
|
||||
}
|
||||
|
||||
// populateTemporality adds the temporality to the query if it is not present
|
||||
func (aH *APIHandler) PopulateTemporality(ctx context.Context, orgID valuer.UUID, qp *v3.QueryRangeParamsV3) error {
|
||||
|
||||
@@ -640,6 +678,129 @@ func (aH *APIHandler) PopulateTemporality(ctx context.Context, orgID valuer.UUID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (aH *APIHandler) listDowntimeSchedules(w http.ResponseWriter, r *http.Request) {
|
||||
claims, errv2 := authtypes.ClaimsFromContext(r.Context())
|
||||
if errv2 != nil {
|
||||
render.Error(w, errv2)
|
||||
return
|
||||
}
|
||||
|
||||
schedules, err := aH.ruleManager.MaintenanceStore().GetAllPlannedMaintenance(r.Context(), claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// The schedules are stored as JSON in the database, so we need to filter them here
|
||||
// Since the number of schedules is expected to be small, this should be fine
|
||||
|
||||
if r.URL.Query().Get("active") != "" {
|
||||
activeSchedules := make([]*ruletypes.GettablePlannedMaintenance, 0)
|
||||
active, _ := strconv.ParseBool(r.URL.Query().Get("active"))
|
||||
for _, schedule := range schedules {
|
||||
now := time.Now().In(time.FixedZone(schedule.Schedule.Timezone, 0))
|
||||
if schedule.IsActive(now) == active {
|
||||
activeSchedules = append(activeSchedules, schedule)
|
||||
}
|
||||
}
|
||||
schedules = activeSchedules
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("recurring") != "" {
|
||||
recurringSchedules := make([]*ruletypes.GettablePlannedMaintenance, 0)
|
||||
recurring, _ := strconv.ParseBool(r.URL.Query().Get("recurring"))
|
||||
for _, schedule := range schedules {
|
||||
if schedule.IsRecurring() == recurring {
|
||||
recurringSchedules = append(recurringSchedules, schedule)
|
||||
}
|
||||
}
|
||||
schedules = recurringSchedules
|
||||
}
|
||||
|
||||
aH.Respond(w, schedules)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) getDowntimeSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := mux.Vars(r)["id"]
|
||||
id, err := valuer.NewUUID(idStr)
|
||||
if err != nil {
|
||||
render.Error(w, errorsV2.New(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
schedule, err := aH.ruleManager.MaintenanceStore().GetPlannedMaintenanceByID(r.Context(), id)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, schedule)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) createDowntimeSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
var schedule ruletypes.GettablePlannedMaintenance
|
||||
err := json.NewDecoder(r.Body).Decode(&schedule)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
if err := schedule.Validate(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = aH.ruleManager.MaintenanceStore().CreatePlannedMaintenance(r.Context(), schedule)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, nil)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) editDowntimeSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := mux.Vars(r)["id"]
|
||||
id, err := valuer.NewUUID(idStr)
|
||||
if err != nil {
|
||||
render.Error(w, errorsV2.New(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
var schedule ruletypes.GettablePlannedMaintenance
|
||||
err = json.NewDecoder(r.Body).Decode(&schedule)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
if err := schedule.Validate(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = aH.ruleManager.MaintenanceStore().EditPlannedMaintenance(r.Context(), schedule, id)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
aH.Respond(w, nil)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) deleteDowntimeSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := mux.Vars(r)["id"]
|
||||
id, err := valuer.NewUUID(idStr)
|
||||
if err != nil {
|
||||
render.Error(w, errorsV2.New(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
err = aH.ruleManager.MaintenanceStore().DeletePlannedMaintenance(r.Context(), id)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
aH.Respond(w, nil)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) getRuleStats(w http.ResponseWriter, r *http.Request) {
|
||||
ruleID := mux.Vars(r)["id"]
|
||||
params := model.QueryRuleStateHistory{}
|
||||
@@ -851,6 +1012,19 @@ func (aH *APIHandler) getRuleStateHistoryTopContributors(w http.ResponseWriter,
|
||||
aH.Respond(w, res)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) listRules(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
rules, err := aH.ruleManager.ListRuleStates(r.Context())
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// todo(amol): need to add sorter
|
||||
|
||||
aH.Respond(w, rules)
|
||||
}
|
||||
|
||||
func prepareQuery(r *http.Request) (string, error) {
|
||||
var postData *model.DashboardVars
|
||||
|
||||
@@ -1052,6 +1226,142 @@ func (aH *APIHandler) queryDashboardVarsV2(w http.ResponseWriter, r *http.Reques
|
||||
aH.Respond(w, dashboardVars)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) testRule(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
aH.logger.ErrorContext(r.Context(), "error reading request body for test rule", errors.Attr(err))
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
alertCount, err := aH.ruleManager.TestNotification(ctx, orgID, string(body))
|
||||
if err != nil {
|
||||
RespondError(w, toApiError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"alertCount": alertCount,
|
||||
"message": "notification sent",
|
||||
}
|
||||
aH.Respond(w, response)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) deleteRule(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
|
||||
err := aH.ruleManager.DeleteRule(r.Context(), id)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
|
||||
return
|
||||
}
|
||||
RespondError(w, toApiError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
aH.Respond(w, "rule successfully deleted")
|
||||
|
||||
}
|
||||
|
||||
// patchRule updates only requested changes in the rule
|
||||
func (aH *APIHandler) patchRule(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := mux.Vars(r)["id"]
|
||||
id, err := valuer.NewUUID(idStr)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
aH.logger.ErrorContext(r.Context(), "error reading request body for patch rule", errors.Attr(err))
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
gettableRule, err := aH.ruleManager.PatchRule(r.Context(), string(body), id)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
|
||||
return
|
||||
}
|
||||
RespondError(w, toApiError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
aH.Respond(w, gettableRule)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) editRule(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := mux.Vars(r)["id"]
|
||||
id, err := valuer.NewUUID(idStr)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
aH.logger.ErrorContext(r.Context(), "error reading request body for edit rule", errors.Attr(err))
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
err = aH.ruleManager.EditRule(r.Context(), string(body), id)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
|
||||
return
|
||||
}
|
||||
RespondError(w, toApiError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
aH.Respond(w, "rule successfully edited")
|
||||
|
||||
}
|
||||
|
||||
func (aH *APIHandler) createRule(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
defer r.Body.Close()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
aH.logger.ErrorContext(r.Context(), "error reading request body for create rule", errors.Attr(err))
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
rule, err := aH.ruleManager.CreateRule(r.Context(), string(body))
|
||||
if err != nil {
|
||||
RespondError(w, toApiError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
aH.Respond(w, rule)
|
||||
|
||||
}
|
||||
|
||||
func (aH *APIHandler) queryRangeMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
query, apiErrorObj := parseQueryRangeRequest(r)
|
||||
|
||||
@@ -9,16 +9,24 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/cache/memorycache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
|
||||
"github.com/rs/cors"
|
||||
"github.com/soheilhy/cmux"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/cache"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/licensing/nooplicensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
|
||||
@@ -26,7 +34,10 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/opamp"
|
||||
opAmpModel "github.com/SigNoz/signoz/pkg/query-service/app/opamp/model"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/web"
|
||||
|
||||
"log/slog"
|
||||
@@ -36,13 +47,15 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/query-service/constants"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
)
|
||||
|
||||
// Server runs HTTP, Mux and a grpc server
|
||||
type Server struct {
|
||||
config signoz.Config
|
||||
signoz *signoz.SigNoz
|
||||
config signoz.Config
|
||||
signoz *signoz.SigNoz
|
||||
ruleManager *rules.Manager
|
||||
|
||||
// public http router
|
||||
httpConn net.Listener
|
||||
@@ -89,6 +102,24 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
nil,
|
||||
)
|
||||
|
||||
rm, err := makeRulesManager(
|
||||
reader,
|
||||
signoz.Cache,
|
||||
signoz.Alertmanager,
|
||||
signoz.SQLStore,
|
||||
signoz.TelemetryStore,
|
||||
signoz.TelemetryMetadataStore,
|
||||
signoz.Prometheus,
|
||||
signoz.Modules.OrgGetter,
|
||||
signoz.Modules.RuleStateHistory,
|
||||
signoz.Querier,
|
||||
signoz.Instrumentation.ToProviderSettings(),
|
||||
signoz.QueryParser,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logParsingPipelineController, err := logparsingpipeline.NewLogParsingPipelinesController(
|
||||
signoz.SQLStore,
|
||||
integrationsController.GetPipelinesForInstalledIntegrations,
|
||||
@@ -100,6 +131,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
|
||||
apiHandler, err := NewAPIHandler(APIHandlerOpts{
|
||||
Reader: reader,
|
||||
RuleManager: rm,
|
||||
IntegrationsController: integrationsController,
|
||||
CloudIntegrationsController: cloudIntegrationsController,
|
||||
LogsParsingPipelineController: logParsingPipelineController,
|
||||
@@ -114,7 +146,8 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
|
||||
s := &Server{
|
||||
config: config,
|
||||
signoz: signoz,
|
||||
signoz: signoz,
|
||||
ruleManager: rm,
|
||||
httpHostPort: constants.HTTPHostPort,
|
||||
unavailableChannel: make(chan healthcheck.Status),
|
||||
}
|
||||
@@ -237,6 +270,8 @@ func (s *Server) initListeners() error {
|
||||
|
||||
// Start listening on http and private http port concurrently
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
s.ruleManager.Start(ctx)
|
||||
|
||||
err := s.initListeners()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -280,6 +315,55 @@ func (s *Server) Stop(ctx context.Context) error {
|
||||
|
||||
s.opampServer.Stop()
|
||||
|
||||
if s.ruleManager != nil {
|
||||
s.ruleManager.Stop(ctx)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeRulesManager(
|
||||
ch interfaces.Reader,
|
||||
cache cache.Cache,
|
||||
alertmanager alertmanager.Alertmanager,
|
||||
sqlstore sqlstore.SQLStore,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
prometheus prometheus.Prometheus,
|
||||
orgGetter organization.Getter,
|
||||
ruleStateHistoryModule rulestatehistory.Module,
|
||||
querier querier.Querier,
|
||||
providerSettings factory.ProviderSettings,
|
||||
queryParser queryparser.QueryParser,
|
||||
) (*rules.Manager, error) {
|
||||
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
|
||||
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
|
||||
// create manager opts
|
||||
managerOpts := &rules.ManagerOptions{
|
||||
TelemetryStore: telemetryStore,
|
||||
MetadataStore: metadataStore,
|
||||
Prometheus: prometheus,
|
||||
Context: context.Background(),
|
||||
Querier: querier,
|
||||
Logger: providerSettings.Logger,
|
||||
Cache: cache,
|
||||
EvalDelay: constants.GetEvalDelay(),
|
||||
OrgGetter: orgGetter,
|
||||
Alertmanager: alertmanager,
|
||||
RuleStore: ruleStore,
|
||||
MaintenanceStore: maintenanceStore,
|
||||
SQLStore: sqlstore,
|
||||
QueryParser: queryParser,
|
||||
RuleStateHistoryModule: ruleStateHistoryModule,
|
||||
}
|
||||
|
||||
// create Manager
|
||||
manager, err := rules.NewManager(managerOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rule manager error: %v", err)
|
||||
}
|
||||
|
||||
slog.Info("rules manager is ready")
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
@@ -207,23 +207,16 @@ func AdjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemet
|
||||
indexes := []telemetrytypes.JSONDataTypeIndex{}
|
||||
fieldContextsSeen := map[telemetrytypes.FieldContext]bool{}
|
||||
dataTypesSeen := map[telemetrytypes.FieldDataType]bool{}
|
||||
jsonTypesSeen := map[string]*telemetrytypes.JSONDataType{}
|
||||
for _, matchingKey := range matchingKeys {
|
||||
materialized = materialized && matchingKey.Materialized
|
||||
fieldContextsSeen[matchingKey.FieldContext] = true
|
||||
dataTypesSeen[matchingKey.FieldDataType] = true
|
||||
if matchingKey.JSONDataType != nil {
|
||||
jsonTypesSeen[matchingKey.JSONDataType.StringValue()] = matchingKey.JSONDataType
|
||||
}
|
||||
indexes = append(indexes, matchingKey.Indexes...)
|
||||
}
|
||||
for _, matchingKey := range contextPrefixedMatchingKeys {
|
||||
materialized = materialized && matchingKey.Materialized
|
||||
fieldContextsSeen[matchingKey.FieldContext] = true
|
||||
dataTypesSeen[matchingKey.FieldDataType] = true
|
||||
if matchingKey.JSONDataType != nil {
|
||||
jsonTypesSeen[matchingKey.JSONDataType.StringValue()] = matchingKey.JSONDataType
|
||||
}
|
||||
indexes = append(indexes, matchingKey.Indexes...)
|
||||
}
|
||||
key.Materialized = materialized
|
||||
@@ -248,15 +241,6 @@ func AdjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemet
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(jsonTypesSeen) == 1 && key.JSONDataType == nil {
|
||||
// all matching keys have same JSON data type, use it
|
||||
for _, jt := range jsonTypesSeen {
|
||||
actions = append(actions, fmt.Sprintf("Adjusting key %s to have JSON data type %s", key, jt.StringValue()))
|
||||
key.JSONDataType = jt
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return actions
|
||||
|
||||
@@ -318,7 +318,7 @@ func TestVisitKey(t *testing.T) {
|
||||
{
|
||||
Name: "count",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeNumber,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeInt64,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -326,7 +326,7 @@ func TestVisitKey(t *testing.T) {
|
||||
{
|
||||
Name: "count",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeNumber,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeInt64,
|
||||
},
|
||||
},
|
||||
expectedErrors: nil,
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package ruler
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
EvalDelay time.Duration `mapstructure:"eval_delay"`
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
@@ -15,9 +12,7 @@ func NewConfigFactory() factory.ConfigFactory {
|
||||
}
|
||||
|
||||
func newConfig() factory.Config {
|
||||
return Config{
|
||||
EvalDelay: 2 * time.Minute,
|
||||
}
|
||||
return Config{}
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
package ruler
|
||||
|
||||
import "net/http"
|
||||
|
||||
type Handler interface {
|
||||
ListRules(http.ResponseWriter, *http.Request)
|
||||
GetRuleByID(http.ResponseWriter, *http.Request)
|
||||
CreateRule(http.ResponseWriter, *http.Request)
|
||||
UpdateRuleByID(http.ResponseWriter, *http.Request)
|
||||
DeleteRuleByID(http.ResponseWriter, *http.Request)
|
||||
PatchRuleByID(http.ResponseWriter, *http.Request)
|
||||
TestRule(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListDowntimeSchedules(http.ResponseWriter, *http.Request)
|
||||
GetDowntimeScheduleByID(http.ResponseWriter, *http.Request)
|
||||
CreateDowntimeSchedule(http.ResponseWriter, *http.Request)
|
||||
UpdateDowntimeScheduleByID(http.ResponseWriter, *http.Request)
|
||||
DeleteDowntimeScheduleByID(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
@@ -1,48 +1,7 @@
|
||||
package ruler
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
import "github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
|
||||
type Ruler interface {
|
||||
factory.Service
|
||||
statsreporter.StatsCollector
|
||||
|
||||
// ListRuleStates returns all rules with their current evaluation state.
|
||||
ListRuleStates(ctx context.Context) (*ruletypes.GettableRules, error)
|
||||
|
||||
// GetRule returns a single rule by ID.
|
||||
GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error)
|
||||
|
||||
// CreateRule persists a new rule from a JSON string and starts its evaluator.
|
||||
// TODO: accept PostableRule instead of raw string; the manager currently unmarshals
|
||||
// internally because it stores the raw JSON as Data. Requires changing the storage
|
||||
// model to store structured data.
|
||||
CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error)
|
||||
|
||||
// EditRule replaces the rule identified by id with the given JSON string.
|
||||
// TODO: same as CreateRule — accept PostableRule instead of raw string.
|
||||
EditRule(ctx context.Context, ruleStr string, id valuer.UUID) error
|
||||
|
||||
// DeleteRule removes the rule identified by the string ID.
|
||||
// TODO: accept valuer.UUID instead of string for consistency with other methods.
|
||||
DeleteRule(ctx context.Context, idStr string) error
|
||||
|
||||
// PatchRule applies a partial update to the rule identified by id.
|
||||
// TODO: same as CreateRule — accept PostableRule instead of raw string.
|
||||
PatchRule(ctx context.Context, ruleStr string, id valuer.UUID) (*ruletypes.GettableRule, error)
|
||||
|
||||
// TestNotification fires a test alert for the rule defined in ruleStr.
|
||||
// TODO: same as CreateRule — accept PostableRule instead of raw string.
|
||||
TestNotification(ctx context.Context, orgID valuer.UUID, ruleStr string) (int, error)
|
||||
|
||||
// MaintenanceStore returns the store for planned maintenance / downtime schedules.
|
||||
// TODO: expose downtime CRUD as methods on Ruler directly instead of leaking the
|
||||
// store interface. The handler should not call store methods directly.
|
||||
MaintenanceStore() ruletypes.MaintenanceStore
|
||||
}
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
package signozruler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
ruler ruler.Ruler
|
||||
}
|
||||
|
||||
func NewHandler(ruler ruler.Ruler) ruler.Handler {
|
||||
return &handler{ruler: ruler}
|
||||
}
|
||||
|
||||
func (handler *handler) ListRules(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rules, err := handler.ruler.ListRuleStates(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, rules)
|
||||
}
|
||||
|
||||
func (handler *handler) GetRuleByID(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
return
|
||||
}
|
||||
|
||||
rule, err := handler.ruler.GetRule(ctx, id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, rule)
|
||||
}
|
||||
|
||||
func (handler *handler) CreateRule(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
defer req.Body.Close() //nolint:errcheck
|
||||
|
||||
rule, err := handler.ruler.CreateRule(ctx, string(body))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, rule)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateRuleByID(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
defer req.Body.Close() //nolint:errcheck
|
||||
|
||||
err = handler.ruler.EditRule(ctx, string(body), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) DeleteRuleByID(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
idStr := mux.Vars(req)["id"]
|
||||
|
||||
err := handler.ruler.DeleteRule(ctx, idStr)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) PatchRuleByID(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
defer req.Body.Close() //nolint:errcheck
|
||||
|
||||
rule, err := handler.ruler.PatchRule(ctx, string(body), id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, rule)
|
||||
}
|
||||
|
||||
func (handler *handler) TestRule(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 1*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
defer req.Body.Close() //nolint:errcheck
|
||||
|
||||
alertCount, err := handler.ruler.TestNotification(ctx, orgID, string(body))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, map[string]any{"alertCount": alertCount, "message": "notification sent"})
|
||||
}
|
||||
|
||||
func (handler *handler) ListDowntimeSchedules(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
schedules, err := handler.ruler.MaintenanceStore().GetAllPlannedMaintenance(ctx, claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if req.URL.Query().Get("active") != "" {
|
||||
activeSchedules := make([]*ruletypes.GettablePlannedMaintenance, 0)
|
||||
active, _ := strconv.ParseBool(req.URL.Query().Get("active"))
|
||||
for _, schedule := range schedules {
|
||||
now := time.Now().In(time.FixedZone(schedule.Schedule.Timezone, 0))
|
||||
if schedule.IsActive(now) == active {
|
||||
activeSchedules = append(activeSchedules, schedule)
|
||||
}
|
||||
}
|
||||
schedules = activeSchedules
|
||||
}
|
||||
|
||||
if req.URL.Query().Get("recurring") != "" {
|
||||
recurringSchedules := make([]*ruletypes.GettablePlannedMaintenance, 0)
|
||||
recurring, _ := strconv.ParseBool(req.URL.Query().Get("recurring"))
|
||||
for _, schedule := range schedules {
|
||||
if schedule.IsRecurring() == recurring {
|
||||
recurringSchedules = append(recurringSchedules, schedule)
|
||||
}
|
||||
}
|
||||
schedules = recurringSchedules
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, schedules)
|
||||
}
|
||||
|
||||
func (handler *handler) GetDowntimeScheduleByID(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
return
|
||||
}
|
||||
|
||||
schedule, err := handler.ruler.MaintenanceStore().GetPlannedMaintenanceByID(ctx, id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, schedule)
|
||||
}
|
||||
|
||||
func (handler *handler) CreateDowntimeSchedule(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var schedule ruletypes.GettablePlannedMaintenance
|
||||
if err := binding.JSON.BindBody(req.Body, &schedule); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := schedule.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := handler.ruler.MaintenanceStore().CreatePlannedMaintenance(ctx, schedule)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateDowntimeScheduleByID(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
return
|
||||
}
|
||||
|
||||
var schedule ruletypes.GettablePlannedMaintenance
|
||||
if err := binding.JSON.BindBody(req.Body, &schedule); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := schedule.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.ruler.MaintenanceStore().EditPlannedMaintenance(ctx, schedule, id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) DeleteDowntimeScheduleByID(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.ruler.MaintenanceStore().DeletePlannedMaintenance(ctx, id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
@@ -3,87 +3,27 @@ package signozruler
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/cache"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type provider struct {
|
||||
manager *rules.Manager
|
||||
ruleStore ruletypes.RuleStore
|
||||
stopC chan struct{}
|
||||
}
|
||||
|
||||
func NewFactory(
|
||||
cache cache.Cache,
|
||||
alertmanager alertmanager.Alertmanager,
|
||||
sqlstore sqlstore.SQLStore,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
prometheus prometheus.Prometheus,
|
||||
orgGetter organization.Getter,
|
||||
ruleStateHistoryModule rulestatehistory.Module,
|
||||
querier querier.Querier,
|
||||
queryParser queryparser.QueryParser,
|
||||
prepareTaskFunc func(rules.PrepareTaskOptions) (rules.Task, error),
|
||||
prepareTestRuleFunc func(rules.PrepareTestRuleOptions) (int, error),
|
||||
) factory.ProviderFactory[ruler.Ruler, ruler.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config ruler.Config) (ruler.Ruler, error) {
|
||||
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
|
||||
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
|
||||
|
||||
managerOpts := &rules.ManagerOptions{
|
||||
TelemetryStore: telemetryStore,
|
||||
MetadataStore: metadataStore,
|
||||
Prometheus: prometheus,
|
||||
Context: context.Background(),
|
||||
Querier: querier,
|
||||
Logger: providerSettings.Logger,
|
||||
Cache: cache,
|
||||
EvalDelay: valuer.MustParseTextDuration(config.EvalDelay.String()),
|
||||
PrepareTaskFunc: prepareTaskFunc,
|
||||
PrepareTestRuleFunc: prepareTestRuleFunc,
|
||||
Alertmanager: alertmanager,
|
||||
OrgGetter: orgGetter,
|
||||
RuleStore: ruleStore,
|
||||
MaintenanceStore: maintenanceStore,
|
||||
SQLStore: sqlstore,
|
||||
QueryParser: queryParser,
|
||||
RuleStateHistoryModule: ruleStateHistoryModule,
|
||||
}
|
||||
|
||||
manager, err := rules.NewManager(managerOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &provider{manager: manager, ruleStore: ruleStore, stopC: make(chan struct{})}, nil
|
||||
func NewFactory(sqlstore sqlstore.SQLStore, queryParser queryparser.QueryParser) factory.ProviderFactory[ruler.Ruler, ruler.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, settings factory.ProviderSettings, config ruler.Config) (ruler.Ruler, error) {
|
||||
return New(ctx, settings, config, sqlstore, queryParser)
|
||||
})
|
||||
}
|
||||
|
||||
func (provider *provider) Start(ctx context.Context) error {
|
||||
provider.manager.Start(ctx)
|
||||
<-provider.stopC
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) Stop(ctx context.Context) error {
|
||||
close(provider.stopC)
|
||||
provider.manager.Stop(ctx)
|
||||
return nil
|
||||
func New(ctx context.Context, settings factory.ProviderSettings, config ruler.Config, sqlstore sqlstore.SQLStore, queryParser queryparser.QueryParser) (ruler.Ruler, error) {
|
||||
return &provider{ruleStore: sqlrulestore.NewRuleStore(sqlstore, queryParser, settings)}, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
@@ -94,35 +34,3 @@ func (provider *provider) Collect(ctx context.Context, orgID valuer.UUID) (map[s
|
||||
|
||||
return ruletypes.NewStatsFromRules(rules), nil
|
||||
}
|
||||
|
||||
func (provider *provider) ListRuleStates(ctx context.Context) (*ruletypes.GettableRules, error) {
|
||||
return provider.manager.ListRuleStates(ctx)
|
||||
}
|
||||
|
||||
func (provider *provider) GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error) {
|
||||
return provider.manager.GetRule(ctx, id)
|
||||
}
|
||||
|
||||
func (provider *provider) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error) {
|
||||
return provider.manager.CreateRule(ctx, ruleStr)
|
||||
}
|
||||
|
||||
func (provider *provider) EditRule(ctx context.Context, ruleStr string, id valuer.UUID) error {
|
||||
return provider.manager.EditRule(ctx, ruleStr, id)
|
||||
}
|
||||
|
||||
func (provider *provider) DeleteRule(ctx context.Context, idStr string) error {
|
||||
return provider.manager.DeleteRule(ctx, idStr)
|
||||
}
|
||||
|
||||
func (provider *provider) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID) (*ruletypes.GettableRule, error) {
|
||||
return provider.manager.PatchRule(ctx, ruleStr, id)
|
||||
}
|
||||
|
||||
func (provider *provider) TestNotification(ctx context.Context, orgID valuer.UUID, ruleStr string) (int, error) {
|
||||
return provider.manager.TestNotification(ctx, orgID, ruleStr)
|
||||
}
|
||||
|
||||
func (provider *provider) MaintenanceStore() ruletypes.MaintenanceStore {
|
||||
return provider.manager.MaintenanceStore()
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ package signoz
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/ruler/signozruler"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/authz/signozauthzapi"
|
||||
@@ -67,7 +65,6 @@ type Handlers struct {
|
||||
CloudIntegrationHandler cloudintegration.Handler
|
||||
RuleStateHistory rulestatehistory.Handler
|
||||
AlertmanagerHandler alertmanager.Handler
|
||||
RulerHandler ruler.Handler
|
||||
}
|
||||
|
||||
func NewHandlers(
|
||||
@@ -84,7 +81,6 @@ func NewHandlers(
|
||||
zeusService zeus.Zeus,
|
||||
registryHandler factory.Handler,
|
||||
alertmanagerService alertmanager.Alertmanager,
|
||||
rulerService ruler.Ruler,
|
||||
) Handlers {
|
||||
return Handlers{
|
||||
SavedView: implsavedview.NewHandler(modules.SavedView),
|
||||
@@ -108,6 +104,5 @@ func NewHandlers(
|
||||
RuleStateHistory: implrulestatehistory.NewHandler(modules.RuleStateHistory),
|
||||
CloudIntegrationHandler: implcloudintegration.NewHandler(modules.CloudIntegration),
|
||||
AlertmanagerHandler: signozalertmanager.NewHandler(alertmanagerService),
|
||||
RulerHandler: signozruler.NewHandler(rulerService),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestNewHandlers(t *testing.T) {
|
||||
|
||||
querierHandler := querier.NewHandler(providerSettings, nil, nil)
|
||||
registryHandler := factory.NewHandler(nil)
|
||||
handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil, registryHandler, alertmanager, nil)
|
||||
handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil, registryHandler, alertmanager)
|
||||
reflectVal := reflect.ValueOf(handlers)
|
||||
for i := 0; i < reflectVal.NumField(); i++ {
|
||||
f := reflectVal.Field(i)
|
||||
|
||||
@@ -31,7 +31,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
@@ -72,7 +71,6 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ cloudintegration.Handler }{},
|
||||
struct{ rulestatehistory.Handler }{},
|
||||
struct{ alertmanager.Handler }{},
|
||||
struct{ ruler.Handler }{},
|
||||
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -44,6 +44,9 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/querier/signozquerier"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/ruler/signozruler"
|
||||
"github.com/SigNoz/signoz/pkg/sharder"
|
||||
"github.com/SigNoz/signoz/pkg/sharder/noopsharder"
|
||||
"github.com/SigNoz/signoz/pkg/sharder/singlesharder"
|
||||
@@ -226,7 +229,11 @@ func NewAlertmanagerProviderFactories(sqlstore sqlstore.SQLStore, orgGetter orga
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
func NewRulerProviderFactories(sqlstore sqlstore.SQLStore, queryParser queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
signozruler.NewFactory(sqlstore, queryParser),
|
||||
)
|
||||
}
|
||||
|
||||
func NewEmailingProviderFactories() factory.NamedMap[factory.ProviderFactory[emailing.Emailing, emailing.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
@@ -282,7 +289,6 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.CloudIntegrationHandler,
|
||||
handlers.RuleStateHistory,
|
||||
handlers.AlertmanagerHandler,
|
||||
handlers.RulerHandler,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema/sqlschematest"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
@@ -63,6 +64,11 @@ func TestNewProviderFactories(t *testing.T) {
|
||||
NewAlertmanagerProviderFactories(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), orgGetter, notificationManager)
|
||||
})
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
queryParser := queryparser.New(instrumentationtest.New().ToProviderSettings())
|
||||
NewRulerProviderFactories(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), queryParser)
|
||||
})
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
NewEmailingProviderFactories()
|
||||
})
|
||||
|
||||
@@ -26,14 +26,12 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/sharder"
|
||||
"github.com/SigNoz/signoz/pkg/sqlmigration"
|
||||
"github.com/SigNoz/signoz/pkg/sqlmigrator"
|
||||
@@ -77,7 +75,6 @@ type SigNoz struct {
|
||||
Tokenizer pkgtokenizer.Tokenizer
|
||||
IdentNResolver identn.IdentNResolver
|
||||
Authz authz.AuthZ
|
||||
Ruler ruler.Ruler
|
||||
Modules Modules
|
||||
Handlers Handlers
|
||||
QueryParser queryparser.QueryParser
|
||||
@@ -106,7 +103,6 @@ func New(
|
||||
auditorProviderFactories func(licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]],
|
||||
querierHandlerCallback func(factory.ProviderSettings, querier.Querier, analytics.Analytics) querier.Handler,
|
||||
cloudIntegrationCallback func(sqlstore.SQLStore, global.Global, zeus.Zeus, gateway.Gateway, licensing.Licensing, serviceaccount.Module, cloudintegration.Config) (cloudintegration.Module, error),
|
||||
rulerProviderFactories func(cache.Cache, alertmanager.Alertmanager, sqlstore.SQLStore, telemetrystore.TelemetryStore, telemetrytypes.MetadataStore, prometheus.Prometheus, organization.Getter, rulestatehistory.Module, querier.Querier, queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]],
|
||||
) (*SigNoz, error) {
|
||||
// Initialize instrumentation
|
||||
instrumentation, err := instrumentation.New(ctx, config.Instrumentation, version.Info, "signoz")
|
||||
@@ -365,6 +361,18 @@ func New(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize ruler from the available ruler provider factories
|
||||
ruler, err := factory.NewProviderFromNamedMap(
|
||||
ctx,
|
||||
providerSettings,
|
||||
config.Ruler,
|
||||
NewRulerProviderFactories(sqlstore, queryParser),
|
||||
"signoz",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gatewayFactory := gatewayProviderFactory(licensing)
|
||||
gateway, err := gatewayFactory.New(ctx, providerSettings, config.Gateway)
|
||||
if err != nil {
|
||||
@@ -433,12 +441,6 @@ func New(
|
||||
// Initialize all modules
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, cloudIntegrationModule)
|
||||
|
||||
// Initialize ruler from the variant-specific provider factories
|
||||
rulerInstance, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Ruler, rulerProviderFactories(cache, alertmanager, sqlstore, telemetrystore, telemetryMetadataStore, prometheus, orgGetter, modules.RuleStateHistory, querier, queryParser), "signoz")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize identN resolver
|
||||
identNFactories := NewIdentNProviderFactories(tokenizer, serviceAccount, orgGetter, userGetter, config.User)
|
||||
identNResolver, err := identn.NewIdentNResolver(ctx, providerSettings, config.IdentN, identNFactories)
|
||||
@@ -454,7 +456,7 @@ func New(
|
||||
// Create a list of all stats collectors
|
||||
statsCollectors := []statsreporter.StatsCollector{
|
||||
alertmanager,
|
||||
rulerInstance,
|
||||
ruler,
|
||||
modules.Dashboard,
|
||||
modules.SavedView,
|
||||
modules.UserSetter,
|
||||
@@ -491,7 +493,6 @@ func New(
|
||||
factory.NewNamedService(factory.MustNewName("authz"), authz),
|
||||
factory.NewNamedService(factory.MustNewName("user"), userService, factory.MustNewName("authz")),
|
||||
factory.NewNamedService(factory.MustNewName("auditor"), auditor),
|
||||
factory.NewNamedService(factory.MustNewName("ruler"), rulerInstance),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -499,7 +500,7 @@ func New(
|
||||
|
||||
// Initialize all handlers for the modules
|
||||
registryHandler := factory.NewHandler(registry)
|
||||
handlers := NewHandlers(modules, providerSettings, analytics, querierHandler, licensing, global, flagger, gateway, telemetryMetadataStore, authz, zeus, registryHandler, alertmanager, rulerInstance)
|
||||
handlers := NewHandlers(modules, providerSettings, analytics, querierHandler, licensing, global, flagger, gateway, telemetryMetadataStore, authz, zeus, registryHandler, alertmanager)
|
||||
|
||||
// Initialize the API server (after registry so it can access service health)
|
||||
apiserverInstance, err := factory.NewProviderFromNamedMap(
|
||||
@@ -533,7 +534,6 @@ func New(
|
||||
Tokenizer: tokenizer,
|
||||
IdentNResolver: identNResolver,
|
||||
Authz: authz,
|
||||
Ruler: rulerInstance,
|
||||
Modules: modules,
|
||||
Handlers: handlers,
|
||||
QueryParser: queryParser,
|
||||
|
||||
@@ -44,7 +44,6 @@ func (c *conditionBuilder) conditionFor(
|
||||
}
|
||||
return cond, nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if operator.IsStringSearchOperator() {
|
||||
|
||||
@@ -276,14 +276,10 @@ func (m *fieldMapper) FieldFor(ctx context.Context, tsStart, tsEnd uint64, key *
|
||||
continue
|
||||
}
|
||||
|
||||
if key.JSONDataType == nil {
|
||||
if key.FieldDataType == telemetrytypes.FieldDataTypeUnspecified {
|
||||
return "", qbtypes.ErrColumnNotFound
|
||||
}
|
||||
|
||||
if key.KeyNameContainsArray() && !key.JSONDataType.IsArray {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "FieldFor not supported for nested fields; only supported for flat paths (e.g. body.status.detail) and paths of Array type: %s(%s)", key.Name, key.FieldDataType)
|
||||
}
|
||||
|
||||
expr, err := m.buildFieldForJSON(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -354,7 +350,6 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
field *telemetrytypes.TelemetryFieldKey,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
) (string, error) {
|
||||
|
||||
fieldExpression, err := m.FieldFor(ctx, tsStart, tsEnd, field)
|
||||
if errors.Is(err, qbtypes.ErrColumnNotFound) {
|
||||
// the key didn't have the right context to be added to the query
|
||||
@@ -393,6 +388,8 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
}
|
||||
fieldExpression = fmt.Sprintf("multiIf(%s, NULL)", strings.Join(args, ", "))
|
||||
}
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s AS `%s`", sqlbuilder.Escape(fieldExpression), field.Name), nil
|
||||
|
||||
@@ -173,29 +173,9 @@ func (c *jsonConditionBuilder) terminalIndexedCondition(node *telemetrytypes.JSO
|
||||
return "", errors.NewInternalf(CodeArrayNavigationFailed, "can not build index condition for array field %s", fieldPath)
|
||||
}
|
||||
|
||||
elemType := node.TerminalConfig.ElemType
|
||||
dynamicExpr := fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, elemType.StringValue())
|
||||
indexedExpr := assumeNotNull(dynamicExpr)
|
||||
|
||||
// switch the operator and value for exists and not exists
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorExists:
|
||||
operator = qbtypes.FilterOperatorNotEqual
|
||||
value = getEmptyValue(elemType)
|
||||
case qbtypes.FilterOperatorNotExists:
|
||||
operator = qbtypes.FilterOperatorEqual
|
||||
value = getEmptyValue(elemType)
|
||||
default:
|
||||
// do nothing
|
||||
}
|
||||
|
||||
indexedExpr := assumeNotNull(fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, node.TerminalConfig.ElemType.StringValue()))
|
||||
indexedExpr, formattedValue := querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, value, indexedExpr, operator)
|
||||
cond, err := c.applyOperator(sb, indexedExpr, operator, formattedValue)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return cond, nil
|
||||
return c.applyOperator(sb, indexedExpr, operator, formattedValue)
|
||||
}
|
||||
|
||||
// buildPrimitiveTerminalCondition builds the condition if the terminal node is a primitive type
|
||||
@@ -204,32 +184,31 @@ func (c *jsonConditionBuilder) buildPrimitiveTerminalCondition(node *telemetryty
|
||||
fieldPath := node.FieldPath()
|
||||
conditions := []string{}
|
||||
|
||||
// utilize indexes for the condition if available
|
||||
// Utilize indexes when available, except for EXISTS/NOT EXISTS checks.
|
||||
// Indexed columns always store a default empty value for absent fields (e.g. "" for strings,
|
||||
// 0 for numbers), so using the index for existence checks would incorrectly exclude rows where
|
||||
// the field genuinely holds the empty/zero value.
|
||||
//
|
||||
// Note: Indexing code doesn't get executed for Array Nested fields because they can not be indexed
|
||||
// Note: indexing is also skipped for Array Nested fields because they cannot be indexed.
|
||||
indexed := slices.ContainsFunc(node.TerminalConfig.Key.Indexes, func(index telemetrytypes.JSONDataTypeIndex) bool {
|
||||
return index.Type == node.TerminalConfig.ElemType
|
||||
})
|
||||
if node.TerminalConfig.ElemType.IndexSupported && indexed {
|
||||
isExistsCheck := operator == qbtypes.FilterOperatorExists || operator == qbtypes.FilterOperatorNotExists
|
||||
if node.TerminalConfig.ElemType.IndexSupported && indexed && !isExistsCheck {
|
||||
indexCond, err := c.terminalIndexedCondition(node, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// if qb has a definitive value, we can skip adding a condition to
|
||||
// check the existence of the path in the json column
|
||||
// With a concrete non-zero value the index condition is self-contained.
|
||||
if value != nil && value != getEmptyValue(node.TerminalConfig.ElemType) {
|
||||
return indexCond, nil
|
||||
}
|
||||
|
||||
// The value is nil or the type's zero/empty value. Because indexed columns always store
|
||||
// that zero value for absent fields, the index alone cannot distinguish "field is absent"
|
||||
// from "field exists with zero value". Append a path-existence check (IS NOT NULL) as a
|
||||
// second condition and AND them together.
|
||||
conditions = append(conditions, indexCond)
|
||||
|
||||
// Switch operator to EXISTS except when operator is NOT EXISTS since
|
||||
// indexed paths on assumedNotNull, indexes will always have a default
|
||||
// value so we flip the operator to Exists and filter the rows that
|
||||
// actually have the value
|
||||
if operator != qbtypes.FilterOperatorNotExists {
|
||||
operator = qbtypes.FilterOperatorExists
|
||||
}
|
||||
operator = qbtypes.FilterOperatorExists
|
||||
}
|
||||
|
||||
var formattedValue = value
|
||||
@@ -239,20 +218,15 @@ func (c *jsonConditionBuilder) buildPrimitiveTerminalCondition(node *telemetryty
|
||||
|
||||
fieldExpr := fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, node.TerminalConfig.ElemType.StringValue())
|
||||
|
||||
// if operator is negative and has a value comparison i.e. excluding EXISTS and NOT EXISTS, we need to assume that the field exists everywhere
|
||||
// For non-nested paths with a negative comparison operator (e.g. !=, NOT LIKE, NOT IN),
|
||||
// wrap in assumeNotNull so ClickHouse treats absent paths as the zero value rather than NULL,
|
||||
// which would otherwise cause them to be silently dropped from results.
|
||||
// NOT EXISTS is excluded: we want a true NULL check there, not a zero-value stand-in.
|
||||
//
|
||||
// Note: here applyNotCondition will return true only if; top level path is being queried and operator is a negative operator
|
||||
// Otherwise this code will be triggered by buildAccessNodeBranches; Where operator would've been already inverted if needed.
|
||||
if node.IsNonNestedPath() {
|
||||
yes, _ := applyNotCondition(operator)
|
||||
if yes {
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorNotExists:
|
||||
// skip
|
||||
default:
|
||||
fieldExpr = assumeNotNull(fieldExpr)
|
||||
}
|
||||
}
|
||||
// Note: for nested array paths, buildAccessNodeBranches already inverts the operator before
|
||||
// reaching here, so IsNonNestedPath() guards against double-applying the wrapping.
|
||||
if node.IsNonNestedPath() && operator.IsNegativeOperator() && operator != qbtypes.FilterOperatorNotExists {
|
||||
fieldExpr = assumeNotNull(fieldExpr)
|
||||
}
|
||||
|
||||
fieldExpr, formattedValue = querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, formattedValue, fieldExpr, operator)
|
||||
|
||||
@@ -220,7 +220,7 @@ func TestJSONStmtBuilder_PrimitivePaths(t *testing.T) {
|
||||
expected: TestExpected{
|
||||
WhereClause: "(((LOWER(toString(dynamicElement(body_v2.`user.age`, 'Int64'))) LIKE LOWER(?)) AND has(JSONAllPaths(body_v2), 'user.age')) OR ((LOWER(dynamicElement(body_v2.`user.age`, 'String')) LIKE LOWER(?)) AND has(JSONAllPaths(body_v2), 'user.age')))",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "%25%", "%25%", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `user.age` is ambiguous, found 2 different combinations of field context / data type: [name=user.age,context=body,datatype=int64,jsondatatype=Int64 name=user.age,context=body,datatype=string,jsondatatype=String]."},
|
||||
Warnings: []string{"Key `user.age` is ambiguous, found 2 different combinations of field context / data type: [name=user.age,context=body,datatype=int64 name=user.age,context=body,datatype=string]."},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -414,7 +414,7 @@ func TestStatementBuilderListQueryBodyPromoted(t *testing.T) {
|
||||
},
|
||||
expected: TestExpected{
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "%1.65%", 1.65, "%1.65%", 1.65, "%1.65%", 1.65, "%1.65%", 1.65, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,materialized=true,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,materialized=true,jsondatatype=Array(Dynamic)]."},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,materialized=true name=education[].parameters,context=body,datatype=[]dynamic,materialized=true]."},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
@@ -441,7 +441,7 @@ func TestStatementBuilderListQueryBodyPromoted(t *testing.T) {
|
||||
},
|
||||
expected: TestExpected{
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "%true%", true, "%true%", true, "%true%", true, "%true%", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,materialized=true,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,materialized=true,jsondatatype=Array(Dynamic)]."},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,materialized=true name=education[].parameters,context=body,datatype=[]dynamic,materialized=true]."},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
@@ -455,7 +455,7 @@ func TestStatementBuilderListQueryBodyPromoted(t *testing.T) {
|
||||
},
|
||||
expected: TestExpected{
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "%passed%", "passed", "%passed%", "passed", "%passed%", "passed", "%passed%", "passed", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,materialized=true,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,materialized=true,jsondatatype=Array(Dynamic)]."},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,materialized=true name=education[].parameters,context=body,datatype=[]dynamic,materialized=true]."},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
@@ -549,7 +549,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
expected: TestExpected{
|
||||
WhereClause: "((NOT arrayExists(`body_v2.education`-> toFloat64OrNull(dynamicElement(`body_v2.education`.`type`, 'String')) = ?, dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND (NOT arrayExists(`body_v2.education`-> dynamicElement(`body_v2.education`.`type`, 'Int64') = ?, dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))))",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), int64(10001), int64(10001), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].type` is ambiguous, found 2 different combinations of field context / data type: [name=education[].type,context=body,datatype=string,jsondatatype=String name=education[].type,context=body,datatype=int64,jsondatatype=Int64]."},
|
||||
Warnings: []string{"Key `education[].type` is ambiguous, found 2 different combinations of field context / data type: [name=education[].type,context=body,datatype=string name=education[].type,context=body,datatype=int64]."},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -576,7 +576,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
expected: TestExpected{
|
||||
WhereClause: "(((arrayExists(`body_v2.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> toFloat64(x) = ?, dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'education')) OR ((arrayExists(`body_v2.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)'))) OR arrayExists(x -> accurateCastOrNull(x, 'Float64') = ?, arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)')))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'education')))",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "%1.65%", 1.65, "%1.65%", 1.65, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64 name=education[].parameters,context=body,datatype=[]dynamic]."},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -585,7 +585,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
expected: TestExpected{
|
||||
WhereClause: "(((arrayExists(`body_v2.education`-> arrayExists(x -> toString(x) = ?, dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))')), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'education')) OR ((arrayExists(`body_v2.education`-> arrayExists(x -> toString(x) = ?, arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)'))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'education')))",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "passed", "passed", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64 name=education[].parameters,context=body,datatype=[]dynamic]."},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -594,7 +594,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
expected: TestExpected{
|
||||
WhereClause: "(((arrayExists(`body_v2.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> toString(x) = ?, dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'education')) OR ((arrayExists(`body_v2.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)'))) OR arrayExists(x -> toString(x) = ?, arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)')))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'education')))",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "%passed%", "passed", "%passed%", "passed", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64 name=education[].parameters,context=body,datatype=[]dynamic]."},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -603,7 +603,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
expected: TestExpected{
|
||||
WhereClause: "(((arrayExists(`body_v2.education`-> arrayExists(x -> toFloat64(x) IN (?, ?), dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))')), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'education')) OR ((arrayExists(`body_v2.education`-> arrayExists(x -> toString(x) IN (?, ?), arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)'))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'education')))",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), 1.65, 1.99, "1.65", "1.99", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64 name=education[].parameters,context=body,datatype=[]dynamic]."},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -612,7 +612,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
expected: TestExpected{
|
||||
WhereClause: "((NOT arrayExists(`body_v2.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> toFloat64(x) = ?, dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND (NOT arrayExists(`body_v2.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)'))) OR arrayExists(x -> accurateCastOrNull(x, 'Float64') = ?, arrayFilter(x->(dynamicType(x) IN ('String', 'Int64', 'Float64', 'Bool')), dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)')))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))))",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "%1.65%", float64(1.65), "%1.65%", float64(1.65), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
|
||||
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64 name=education[].parameters,context=body,datatype=[]dynamic]."},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -622,7 +622,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
WhereClause: "(has(arrayFlatten(arrayConcat(arrayMap(`body_v2.education`->dynamicElement(`body_v2.education`.`parameters`, 'Array(Nullable(Float64))'), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?) OR has(arrayFlatten(arrayConcat(arrayMap(`body_v2.education`->dynamicElement(`body_v2.education`.`parameters`, 'Array(Dynamic)'), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?))",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), 1.65, 1.65, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{
|
||||
"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)].",
|
||||
"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64 name=education[].parameters,context=body,datatype=[]dynamic].",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -702,7 +702,7 @@ func TestJSONStmtBuilder_ArrayPaths(t *testing.T) {
|
||||
expected: TestExpected{
|
||||
WhereClause: "(((arrayExists(`body_v2.interests`-> arrayExists(`body_v2.interests[].entities`-> arrayExists(`body_v2.interests[].entities[].reviews`-> arrayExists(`body_v2.interests[].entities[].reviews[].entries`-> arrayExists(`body_v2.interests[].entities[].reviews[].entries[].metadata`-> arrayExists(`body_v2.interests[].entities[].reviews[].entries[].metadata[].positions`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_v2.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(Int64))')) OR arrayExists(x -> toFloat64(x) = ?, dynamicElement(`body_v2.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(Int64))'))), dynamicElement(`body_v2.interests[].entities[].reviews[].entries[].metadata`.`positions`, 'Array(JSON(max_dynamic_types=0, max_dynamic_paths=0))')), dynamicElement(`body_v2.interests[].entities[].reviews[].entries`.`metadata`, 'Array(JSON(max_dynamic_types=1, max_dynamic_paths=0))')), dynamicElement(`body_v2.interests[].entities[].reviews`.`entries`, 'Array(JSON(max_dynamic_types=2, max_dynamic_paths=0))')), dynamicElement(`body_v2.interests[].entities`.`reviews`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), dynamicElement(`body_v2.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_v2.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'interests')) OR ((arrayExists(`body_v2.interests`-> arrayExists(`body_v2.interests[].entities`-> arrayExists(`body_v2.interests[].entities[].reviews`-> arrayExists(`body_v2.interests[].entities[].reviews[].entries`-> arrayExists(`body_v2.interests[].entities[].reviews[].entries[].metadata`-> arrayExists(`body_v2.interests[].entities[].reviews[].entries[].metadata[].positions`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), dynamicElement(`body_v2.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(String))')) OR arrayExists(x -> toFloat64OrNull(x) = ?, dynamicElement(`body_v2.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(String))'))), dynamicElement(`body_v2.interests[].entities[].reviews[].entries[].metadata`.`positions`, 'Array(JSON(max_dynamic_types=0, max_dynamic_paths=0))')), dynamicElement(`body_v2.interests[].entities[].reviews[].entries`.`metadata`, 'Array(JSON(max_dynamic_types=1, max_dynamic_paths=0))')), dynamicElement(`body_v2.interests[].entities[].reviews`.`entries`, 'Array(JSON(max_dynamic_types=2, max_dynamic_paths=0))')), dynamicElement(`body_v2.interests[].entities`.`reviews`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), dynamicElement(`body_v2.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_v2.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND has(JSONAllPaths(body_v2), 'interests')))",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "%4%", float64(4), "%4%", float64(4), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{"Key `interests[].entities[].reviews[].entries[].metadata[].positions[].ratings` is ambiguous, found 2 different combinations of field context / data type: [name=interests[].entities[].reviews[].entries[].metadata[].positions[].ratings,context=body,datatype=[]int64,jsondatatype=Array(Nullable(Int64)) name=interests[].entities[].reviews[].entries[].metadata[].positions[].ratings,context=body,datatype=[]string,jsondatatype=Array(Nullable(String))]."},
|
||||
Warnings: []string{"Key `interests[].entities[].reviews[].entries[].metadata[].positions[].ratings` is ambiguous, found 2 different combinations of field context / data type: [name=interests[].entities[].reviews[].entries[].metadata[].positions[].ratings,context=body,datatype=[]int64 name=interests[].entities[].reviews[].entries[].metadata[].positions[].ratings,context=body,datatype=[]string]."},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -844,7 +844,10 @@ func TestJSONStmtBuilder_IndexedPaths(t *testing.T) {
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), float64(110001), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
// ── indexed exists: emits assumeNotNull != nil AND dynamicElement IS NOT NULL ─
|
||||
// ── indexed exists: index is skipped; emits plain IS NOT NULL ───────────
|
||||
// EXISTS/NOT EXISTS bypass the index because indexed columns store a default
|
||||
// empty value for absent fields, making != "" unreliable for existence checks
|
||||
// (a field with value "" would be incorrectly excluded).
|
||||
{
|
||||
name: "Indexed String Exists",
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
@@ -853,8 +856,8 @@ func TestJSONStmtBuilder_IndexedPaths(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: TestExpected{
|
||||
WhereClause: "((assumeNotNull(dynamicElement(body_v2.`user.name`, 'String')) <> ? AND dynamicElement(body_v2.`user.name`, 'String') IS NOT NULL))",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
WhereClause: "(dynamicElement(body_v2.`user.name`, 'String') IS NOT NULL)",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
// ── indexed not-equal: assumeNotNull wrapping + no path index ─────────
|
||||
@@ -870,22 +873,6 @@ func TestJSONStmtBuilder_IndexedPaths(t *testing.T) {
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "alice", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
// ── indexed not-exists: assumeNotNull = "" AND assumeNotNull IS NOT NULL ─
|
||||
// FilterOperatorNotExists → Equal + emptyValue("") in the indexed branch,
|
||||
// then a second condition flipped to Exists (IS NOT NULL) on the same
|
||||
// assumeNotNull expr, producing AND(= "", IS NOT NULL).
|
||||
{
|
||||
name: "Indexed String NotExists",
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Filter: &qbtypes.Filter{Expression: "body.user.name NOT EXISTS"},
|
||||
Limit: 10,
|
||||
},
|
||||
expected: TestExpected{
|
||||
WhereClause: "((assumeNotNull(dynamicElement(body_v2.`user.name`, 'String')) = ? AND dynamicElement(body_v2.`user.name`, 'String') IS NULL))",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
|
||||
// ── special-character indexed paths ───────────────────────────────────
|
||||
{
|
||||
@@ -942,20 +929,171 @@ func TestJSONStmtBuilder_IndexedPaths(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStmtBuilder_SelectField(t *testing.T) {
|
||||
enable, disable := jsonQueryTestUtil(t)
|
||||
enable()
|
||||
defer disable()
|
||||
statementBuilder := buildJSONTestStatementBuilder(t, false)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
expected qbtypes.Statement
|
||||
expectedErrContains string
|
||||
}{
|
||||
{
|
||||
name: "select_x_education[].awards[].participated[].members",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Limit: 10,
|
||||
SelectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{
|
||||
Name: "education[].awards[].participated[].members",
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "user.name exists",
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, arrayFlatten(arrayConcat(arrayMap(`body_v2.education`->arrayConcat(arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), dynamicElement(`body_v2.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AS `education[].awards[].participated[].members` FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (dynamicElement(body_v2.`user.name`, 'String') IS NOT NULL) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "select_x_user.name",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Limit: 10,
|
||||
SelectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{
|
||||
Name: "user.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, dynamicElement(body_v2.`user.name`, 'String') AS `user.name` FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
|
||||
if c.expectedErrContains != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), c.expectedErrContains)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.expected.Query, q.Query)
|
||||
require.Equal(t, c.expected.Args, q.Args)
|
||||
require.Equal(t, c.expected.Warnings, q.Warnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStmtBuilder_OrderBy(t *testing.T) {
|
||||
enable, disable := jsonQueryTestUtil(t)
|
||||
enable()
|
||||
defer disable()
|
||||
statementBuilder := buildJSONTestStatementBuilder(t, false)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
expected qbtypes.Statement
|
||||
expectedErrContains string
|
||||
}{
|
||||
{
|
||||
name: "order_by_education[].awards[].participated[].members",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Limit: 10,
|
||||
Order: []qbtypes.OrderBy{
|
||||
{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "education[].awards[].participated[].members",
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionAsc,
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "user.name exists",
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (dynamicElement(body_v2.`user.name`, 'String') IS NOT NULL) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY arrayFlatten(arrayConcat(arrayMap(`body_v2.education`->arrayConcat(arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), dynamicElement(`body_v2.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AS `education[].awards[].participated[].members` asc LIMIT ?",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "order_by_user.name",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Limit: 10,
|
||||
Order: []qbtypes.OrderBy{
|
||||
{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "user.name",
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionAsc,
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY dynamicElement(body_v2.`user.name`, 'String') AS `user.name` asc LIMIT ?",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
|
||||
if c.expectedErrContains != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), c.expectedErrContains)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.expected.Query, q.Query)
|
||||
require.Equal(t, c.expected.Args, q.Args)
|
||||
require.Equal(t, c.expected.Warnings, q.Warnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func buildTestTelemetryMetadataStore(t *testing.T, addIndexes bool) *telemetrytypestest.MockMetadataStore {
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.SetStaticFields(IntrinsicFields)
|
||||
types, _ := telemetrytypes.TestJSONTypeSet()
|
||||
for path, jsonTypes := range types {
|
||||
for _, jsonType := range jsonTypes {
|
||||
for path, fieldDataTypes := range types {
|
||||
for _, fdt := range fieldDataTypes {
|
||||
key := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: path,
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: telemetrytypes.FieldContextBody,
|
||||
FieldDataType: telemetrytypes.MappingJSONDataTypeToFieldDataType[jsonType],
|
||||
JSONDataType: &jsonType,
|
||||
FieldDataType: fdt,
|
||||
}
|
||||
if addIndexes {
|
||||
jsonType := telemetrytypes.MappingFieldDataTypeToJSONDataType[fdt]
|
||||
idx := slices.IndexFunc(telemetrytypes.TestIndexedPaths, func(entry telemetrytypes.TestIndexedPathEntry) bool {
|
||||
return entry.Path == path && entry.Type == jsonType
|
||||
})
|
||||
|
||||
@@ -875,7 +875,6 @@ func TestAdjustKey(t *testing.T) {
|
||||
require.Equal(t, c.expectedKey.FieldContext, key.FieldContext, "field context should match")
|
||||
require.Equal(t, c.expectedKey.FieldDataType, key.FieldDataType, "field data type should match")
|
||||
require.Equal(t, c.expectedKey.Materialized, key.Materialized, "materialized should match")
|
||||
require.Equal(t, c.expectedKey.JSONDataType, key.JSONDataType, "json data type should match")
|
||||
require.Equal(t, c.expectedKey.Indexes, key.Indexes, "json exists should match")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,133 +21,84 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
defaultPathLimit = 100 // Default limit to prevent full table scans
|
||||
|
||||
CodeUnknownJSONDataType = errors.MustNewCode("unknown_json_data_type")
|
||||
CodeFailLoadPromotedPaths = errors.MustNewCode("fail_load_promoted_paths")
|
||||
CodeFailCheckPathPromoted = errors.MustNewCode("fail_check_path_promoted")
|
||||
CodeFailIterateBodyJSONKeys = errors.MustNewCode("fail_iterate_body_json_keys")
|
||||
CodeFailExtractBodyJSONKeys = errors.MustNewCode("fail_extract_body_json_keys")
|
||||
CodeFailLoadLogsJSONIndexes = errors.MustNewCode("fail_load_logs_json_indexes")
|
||||
CodeFailListJSONValues = errors.MustNewCode("fail_list_json_values")
|
||||
CodeFailScanJSONValue = errors.MustNewCode("fail_scan_json_value")
|
||||
CodeFailScanVariant = errors.MustNewCode("fail_scan_variant")
|
||||
CodeFailBuildJSONPathsQuery = errors.MustNewCode("fail_build_json_paths_query")
|
||||
CodeNoPathsToQueryIndexes = errors.MustNewCode("no_paths_to_query_indexes_provided")
|
||||
|
||||
CodeFailedToPrepareBatch = errors.MustNewCode("failed_to_prepare_batch_promoted_paths")
|
||||
CodeFailedToSendBatch = errors.MustNewCode("failed_to_send_batch_promoted_paths")
|
||||
CodeFailedToAppendPath = errors.MustNewCode("failed_to_append_path_promoted_paths")
|
||||
)
|
||||
|
||||
// fetchBodyJSONPaths extracts body JSON paths from the path_types table
|
||||
// This function can be used by both JSONQueryBuilder and metadata extraction
|
||||
// uniquePathLimit: 0 for no limit, >0 for maximum number of unique paths to return
|
||||
// - For startup load: set to 10000 to get top 10k unique paths
|
||||
// - For lookup: set to 0 (no limit needed for single path)
|
||||
// - For metadata API: set to desired pagination limit
|
||||
// enrichJSONKeys enriches body-context keys with promoted path info, indexes,
|
||||
// and JSON access plans. parentTypeCache contains parent array types (ArrayJSON/ArrayDynamic)
|
||||
// pre-fetched in the main UNION query.
|
||||
//
|
||||
// searchOperator: LIKE for pattern matching, EQUAL for exact match.
|
||||
func (t *telemetryMetaStore) fetchBodyJSONPaths(ctx context.Context,
|
||||
fieldKeySelectors []*telemetrytypes.FieldKeySelector) ([]*telemetrytypes.TelemetryFieldKey, []string, bool, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "metadata",
|
||||
instrumentationtypes.CodeFunctionName: "fetchBodyJSONPaths",
|
||||
})
|
||||
|
||||
query, args, limit := buildGetBodyJSONPathsQuery(fieldKeySelectors)
|
||||
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, nil, false, errors.WrapInternalf(err, CodeFailExtractBodyJSONKeys, "failed to extract body JSON keys")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
fieldKeys := []*telemetrytypes.TelemetryFieldKey{}
|
||||
paths := []string{}
|
||||
rowCount := 0
|
||||
for rows.Next() {
|
||||
var path string
|
||||
var typesArray []string // ClickHouse returns array as []string
|
||||
var lastSeen uint64
|
||||
|
||||
err = rows.Scan(&path, &typesArray, &lastSeen)
|
||||
if err != nil {
|
||||
return nil, nil, false, errors.WrapInternalf(err, CodeFailExtractBodyJSONKeys, "failed to scan body JSON key row")
|
||||
// NOTE: enrichment can not work with FuzzySelectors; QB requests exact matches for query building so
|
||||
// parentTypeCache will actually have proper matches and
|
||||
// FuzzyMatching is for Suggestions API so enrichment is not needed.
|
||||
func (t *telemetryMetaStore) enrichJSONKeys(ctx context.Context, selectors []*telemetrytypes.FieldKeySelector, keys []*telemetrytypes.TelemetryFieldKey, parentTypeCache map[string][]telemetrytypes.FieldDataType) error {
|
||||
mapOfExactSelectors := make(map[string]*telemetrytypes.FieldKeySelector)
|
||||
for _, selector := range selectors {
|
||||
if selector.SelectorMatchType != telemetrytypes.FieldSelectorMatchTypeExact {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, typ := range typesArray {
|
||||
mapping, found := telemetrytypes.MappingStringToJSONDataType[typ]
|
||||
if !found {
|
||||
t.logger.ErrorContext(ctx, "failed to map type string to JSON data type", slog.String("type", typ), slog.String("path", path))
|
||||
continue
|
||||
}
|
||||
fieldKeys = append(fieldKeys, &telemetrytypes.TelemetryFieldKey{
|
||||
Name: path,
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: telemetrytypes.FieldContextBody,
|
||||
FieldDataType: telemetrytypes.MappingJSONDataTypeToFieldDataType[mapping],
|
||||
JSONDataType: &mapping,
|
||||
})
|
||||
}
|
||||
|
||||
paths = append(paths, path)
|
||||
rowCount++
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
return nil, nil, false, errors.WrapInternalf(rows.Err(), CodeFailIterateBodyJSONKeys, "error iterating body JSON keys")
|
||||
mapOfExactSelectors[selector.Name] = selector
|
||||
}
|
||||
|
||||
return fieldKeys, paths, rowCount <= limit, nil
|
||||
}
|
||||
|
||||
func (t *telemetryMetaStore) buildBodyJSONPaths(ctx context.Context,
|
||||
fieldKeySelectors []*telemetrytypes.FieldKeySelector) ([]*telemetrytypes.TelemetryFieldKey, bool, error) {
|
||||
|
||||
fieldKeys, paths, finished, err := t.fetchBodyJSONPaths(ctx, fieldKeySelectors)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
promoted, err := t.GetPromotedPaths(ctx, paths...)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
indexes, err := t.getJSONPathIndexes(ctx, paths...)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
for _, fieldKey := range fieldKeys {
|
||||
promotedKey := strings.Split(fieldKey.Name, telemetrytypes.ArraySep)[0]
|
||||
fieldKey.Materialized = promoted[promotedKey]
|
||||
fieldKey.Indexes = indexes[fieldKey.Name]
|
||||
}
|
||||
|
||||
return fieldKeys, finished, t.buildJSONPlans(ctx, fieldKeys)
|
||||
}
|
||||
|
||||
func (t *telemetryMetaStore) buildJSONPlans(ctx context.Context, keys []*telemetrytypes.TelemetryFieldKey) error {
|
||||
parentSelectors := make([]*telemetrytypes.FieldKeySelector, 0, len(keys))
|
||||
var filteredKeys []*telemetrytypes.TelemetryFieldKey
|
||||
for _, key := range keys {
|
||||
parentSelectors = append(parentSelectors, key.ArrayParentSelectors()...)
|
||||
if key.FieldContext == telemetrytypes.FieldContextBody && mapOfExactSelectors[key.Name] != nil {
|
||||
filteredKeys = append(filteredKeys, key)
|
||||
}
|
||||
}
|
||||
|
||||
parentKeys, _, _, err := t.fetchBodyJSONPaths(ctx, parentSelectors)
|
||||
if len(filteredKeys) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
paths := make([]string, 0, len(filteredKeys))
|
||||
for _, key := range filteredKeys {
|
||||
paths = append(paths, key.Name)
|
||||
}
|
||||
|
||||
// fetch promoted paths
|
||||
promoted, err := t.GetPromotedPaths(ctx, paths...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
typeCache := make(map[string][]telemetrytypes.JSONDataType)
|
||||
for _, key := range parentKeys {
|
||||
typeCache[key.Name] = append(typeCache[key.Name], *key.JSONDataType)
|
||||
// fetch JSON path indexes
|
||||
indexes, err := t.getJSONPathIndexes(ctx, paths...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// build plans for keys now
|
||||
// apply promoted/index metadata to keys
|
||||
for _, key := range filteredKeys {
|
||||
promotedKey := strings.Split(key.Name, telemetrytypes.ArraySep)[0]
|
||||
key.Materialized = promoted[promotedKey]
|
||||
key.Indexes = indexes[key.Name]
|
||||
}
|
||||
|
||||
// build JSON access plans using the pre-fetched parent type cache
|
||||
return t.buildJSONPlans(ctx, filteredKeys, parentTypeCache)
|
||||
}
|
||||
|
||||
// buildJSONPlans builds JSON access plans for the given keys
|
||||
// using the provided parent type cache (pre-fetched in the main UNION query).
|
||||
func (t *telemetryMetaStore) buildJSONPlans(_ context.Context, keys []*telemetrytypes.TelemetryFieldKey, typeCache map[string][]telemetrytypes.FieldDataType) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
columnMeta := t.jsonColumnMetadata[telemetrytypes.SignalLogs][telemetrytypes.FieldContextBody]
|
||||
for _, key := range keys {
|
||||
err = key.SetJSONAccessPlan(t.jsonColumnMetadata[telemetrytypes.SignalLogs][telemetrytypes.FieldContextBody], typeCache)
|
||||
if err != nil {
|
||||
if err := key.SetJSONAccessPlan(columnMeta, typeCache); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -155,51 +106,6 @@ func (t *telemetryMetaStore) buildJSONPlans(ctx context.Context, keys []*telemet
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildGetBodyJSONPathsQuery(fieldKeySelectors []*telemetrytypes.FieldKeySelector) (string, []any, int) {
|
||||
if len(fieldKeySelectors) == 0 {
|
||||
return "", nil, defaultPathLimit
|
||||
}
|
||||
from := fmt.Sprintf("%s.%s", DBName, PathTypesTableName)
|
||||
|
||||
// Build a better query using GROUP BY to deduplicate at database level
|
||||
// This aggregates all types per path and gets the max last_seen, then applies LIMIT
|
||||
sb := sqlbuilder.Select(
|
||||
"path",
|
||||
"groupArray(DISTINCT type) AS types",
|
||||
"max(last_seen) AS last_seen",
|
||||
).From(from)
|
||||
|
||||
limit := 0
|
||||
// Add search filter if provided
|
||||
orClauses := []string{}
|
||||
for _, fieldKeySelector := range fieldKeySelectors {
|
||||
// replace [*] with []
|
||||
fieldKeySelector.Name = strings.ReplaceAll(fieldKeySelector.Name, telemetrytypes.ArrayAnyIndex, telemetrytypes.ArraySep)
|
||||
// Extract search text for body JSON keys
|
||||
keyName := CleanPathPrefixes(fieldKeySelector.Name)
|
||||
if fieldKeySelector.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeExact {
|
||||
orClauses = append(orClauses, sb.Equal("path", keyName))
|
||||
} else {
|
||||
// Pattern matching for metadata API (defaults to LIKE behavior for other operators)
|
||||
orClauses = append(orClauses, sb.ILike("path", fmt.Sprintf("%%%s%%", querybuilder.FormatValueForContains(keyName))))
|
||||
}
|
||||
limit += fieldKeySelector.Limit
|
||||
}
|
||||
sb.Where(sb.Or(orClauses...))
|
||||
// Group by path to get unique paths with aggregated types
|
||||
sb.GroupBy("path")
|
||||
|
||||
// Order by max last_seen to get most recent paths first
|
||||
sb.OrderBy("last_seen DESC")
|
||||
if limit == 0 {
|
||||
limit = defaultPathLimit
|
||||
}
|
||||
sb.Limit(limit)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return query, args, limit
|
||||
}
|
||||
|
||||
func (t *telemetryMetaStore) getJSONPathIndexes(ctx context.Context, paths ...string) (map[string][]telemetrytypes.JSONDataTypeIndex, error) {
|
||||
filteredPaths := []string{}
|
||||
for _, path := range paths {
|
||||
|
||||
@@ -7,99 +7,9 @@ import (
|
||||
"github.com/SigNoz/signoz-otel-collector/constants"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildGetBodyJSONPathsQuery(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
fieldKeySelectors []*telemetrytypes.FieldKeySelector
|
||||
expectedSQL string
|
||||
expectedArgs []any
|
||||
expectedLimit int
|
||||
}{
|
||||
|
||||
{
|
||||
name: "Single search text with EQUAL operator",
|
||||
fieldKeySelectors: []*telemetrytypes.FieldKeySelector{
|
||||
{
|
||||
Name: "user.name",
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
|
||||
},
|
||||
},
|
||||
expectedSQL: "SELECT path, groupArray(DISTINCT type) AS types, max(last_seen) AS last_seen FROM signoz_metadata.distributed_field_keys WHERE (path = ?) GROUP BY path ORDER BY last_seen DESC LIMIT ?",
|
||||
expectedArgs: []any{"user.name", defaultPathLimit},
|
||||
expectedLimit: defaultPathLimit,
|
||||
},
|
||||
{
|
||||
name: "Single search text with LIKE operator",
|
||||
fieldKeySelectors: []*telemetrytypes.FieldKeySelector{
|
||||
{
|
||||
Name: "user",
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeFuzzy,
|
||||
},
|
||||
},
|
||||
expectedSQL: "SELECT path, groupArray(DISTINCT type) AS types, max(last_seen) AS last_seen FROM signoz_metadata.distributed_field_keys WHERE (LOWER(path) LIKE LOWER(?)) GROUP BY path ORDER BY last_seen DESC LIMIT ?",
|
||||
expectedArgs: []any{"%user%", 100},
|
||||
expectedLimit: 100,
|
||||
},
|
||||
{
|
||||
name: "Multiple search texts with EQUAL operator",
|
||||
fieldKeySelectors: []*telemetrytypes.FieldKeySelector{
|
||||
{
|
||||
Name: "user.name",
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
|
||||
},
|
||||
{
|
||||
Name: "user.age",
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
|
||||
},
|
||||
},
|
||||
expectedSQL: "SELECT path, groupArray(DISTINCT type) AS types, max(last_seen) AS last_seen FROM signoz_metadata.distributed_field_keys WHERE (path = ? OR path = ?) GROUP BY path ORDER BY last_seen DESC LIMIT ?",
|
||||
expectedArgs: []any{"user.name", "user.age", defaultPathLimit},
|
||||
expectedLimit: defaultPathLimit,
|
||||
},
|
||||
{
|
||||
name: "Multiple search texts with LIKE operator",
|
||||
fieldKeySelectors: []*telemetrytypes.FieldKeySelector{
|
||||
{
|
||||
Name: "user",
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeFuzzy,
|
||||
},
|
||||
{
|
||||
Name: "admin",
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeFuzzy,
|
||||
},
|
||||
},
|
||||
expectedSQL: "SELECT path, groupArray(DISTINCT type) AS types, max(last_seen) AS last_seen FROM signoz_metadata.distributed_field_keys WHERE (LOWER(path) LIKE LOWER(?) OR LOWER(path) LIKE LOWER(?)) GROUP BY path ORDER BY last_seen DESC LIMIT ?",
|
||||
expectedArgs: []any{"%user%", "%admin%", defaultPathLimit},
|
||||
expectedLimit: defaultPathLimit,
|
||||
},
|
||||
{
|
||||
name: "Search with Contains operator (should default to LIKE)",
|
||||
fieldKeySelectors: []*telemetrytypes.FieldKeySelector{
|
||||
{
|
||||
Name: "test",
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeFuzzy,
|
||||
},
|
||||
},
|
||||
expectedSQL: "SELECT path, groupArray(DISTINCT type) AS types, max(last_seen) AS last_seen FROM signoz_metadata.distributed_field_keys WHERE (LOWER(path) LIKE LOWER(?)) GROUP BY path ORDER BY last_seen DESC LIMIT ?",
|
||||
expectedArgs: []any{"%test%", defaultPathLimit},
|
||||
expectedLimit: defaultPathLimit,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
query, args, limit := buildGetBodyJSONPathsQuery(tc.fieldKeySelectors)
|
||||
require.Equal(t, tc.expectedSQL, query)
|
||||
require.Equal(t, tc.expectedArgs, args)
|
||||
require.Equal(t, tc.expectedLimit, limit)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListLogsJSONIndexesQuery(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
|
||||
@@ -368,6 +368,20 @@ func (t *telemetryMetaStore) logsTblStatementToFieldKeys(ctx context.Context) ([
|
||||
return materialisedKeys, nil
|
||||
}
|
||||
|
||||
// logKeysUnionArm declares one arm of the UNION ALL in getLogsKeys.
|
||||
// All per-table variance is captured here so the loop body can stay uniform.
|
||||
type logKeysUnionArm struct {
|
||||
shouldQuery bool
|
||||
fieldContext telemetrytypes.FieldContext
|
||||
table string
|
||||
nameColumn string // column holding the field/key name (e.g., "name" or "field_name")
|
||||
dataTypeColumn string // column used in WHERE/GROUP BY
|
||||
dataTypeSelectExpr string // expression used in SELECT (may wrap with lower())
|
||||
addBaseFilters func(sb *sqlbuilder.SelectBuilder) // mandatory WHERE filters (e.g., signal, field_context)
|
||||
encodeDataType func(telemetrytypes.FieldDataType) string // how to render a FieldDataType in WHERE values
|
||||
extraOrBranch func(sb *sqlbuilder.SelectBuilder) string // optional extra OR branch (e.g., body parent-types)
|
||||
}
|
||||
|
||||
// getLogsKeys returns the keys from the spans that match the field selection criteria.
|
||||
func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, fieldKeySelectors []*telemetrytypes.FieldKeySelector) ([]*telemetrytypes.TelemetryFieldKey, bool, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
@@ -397,90 +411,152 @@ func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, fieldKeySelectors
|
||||
// tables to query based on field selectors
|
||||
queryAttributeTable := false
|
||||
queryResourceTable := false
|
||||
queryBodyTable := false
|
||||
|
||||
for _, selector := range fieldKeySelectors {
|
||||
if selector.FieldContext == telemetrytypes.FieldContextUnspecified {
|
||||
// unspecified context, query both tables
|
||||
// unspecified context, query all tables
|
||||
queryAttributeTable = true
|
||||
queryResourceTable = true
|
||||
queryBodyTable = true
|
||||
break
|
||||
} else if selector.FieldContext == telemetrytypes.FieldContextAttribute {
|
||||
queryAttributeTable = true
|
||||
} else if selector.FieldContext == telemetrytypes.FieldContextResource {
|
||||
queryResourceTable = true
|
||||
} else if selector.FieldContext == telemetrytypes.FieldContextBody && querybuilder.BodyJSONQueryEnabled {
|
||||
queryBodyTable = true
|
||||
}
|
||||
}
|
||||
|
||||
tablesToQuery := []struct {
|
||||
fieldContext telemetrytypes.FieldContext
|
||||
shouldQuery bool
|
||||
}{
|
||||
{telemetrytypes.FieldContextAttribute, queryAttributeTable},
|
||||
{telemetrytypes.FieldContextResource, queryResourceTable},
|
||||
// body keys are gated behind the feature flag
|
||||
queryBodyTable = queryBodyTable && querybuilder.BodyJSONQueryEnabled
|
||||
|
||||
// pre-compute parent array path names from body selectors for JSON plan building;
|
||||
// these will be fetched as a separate UNION arm filtered to ArrayJSON/ArrayDynamic only
|
||||
parentPaths := make(map[string]bool)
|
||||
if queryBodyTable {
|
||||
for _, sel := range fieldKeySelectors {
|
||||
if sel.FieldContext != telemetrytypes.FieldContextBody &&
|
||||
sel.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||
continue
|
||||
}
|
||||
key := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: sel.Name,
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: telemetrytypes.FieldContextBody,
|
||||
}
|
||||
if !key.KeyNameContainsArray() {
|
||||
continue
|
||||
}
|
||||
for _, parent := range key.ArrayParentPaths() {
|
||||
parentPaths[parent] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, table := range tablesToQuery {
|
||||
if !table.shouldQuery {
|
||||
// Each UNION arm differs only in: table, data-type column name and SELECT
|
||||
// expression (lower-wrapped for historical mixed-case in attr/resource),
|
||||
// base WHERE filters, the per-selector data-type encoding, and (for body)
|
||||
// an extra OR branch that fetches parent array types for JSON plan building.
|
||||
// All other logic is shared by the loop below.
|
||||
tablesToQuery := []logKeysUnionArm{
|
||||
{
|
||||
shouldQuery: queryAttributeTable,
|
||||
fieldContext: telemetrytypes.FieldContextAttribute,
|
||||
table: t.logsDBName + "." + t.logAttributeKeysTblName,
|
||||
nameColumn: "name",
|
||||
dataTypeColumn: "datatype",
|
||||
dataTypeSelectExpr: "lower(datatype)",
|
||||
addBaseFilters: func(*sqlbuilder.SelectBuilder) {},
|
||||
encodeDataType: func(ft telemetrytypes.FieldDataType) string { return ft.TagDataType() },
|
||||
extraOrBranch: func(*sqlbuilder.SelectBuilder) string { return "" },
|
||||
},
|
||||
{
|
||||
shouldQuery: queryResourceTable,
|
||||
fieldContext: telemetrytypes.FieldContextResource,
|
||||
table: t.logsDBName + "." + t.logResourceKeysTblName,
|
||||
nameColumn: "name",
|
||||
dataTypeColumn: "datatype",
|
||||
dataTypeSelectExpr: "lower(datatype)",
|
||||
addBaseFilters: func(*sqlbuilder.SelectBuilder) {},
|
||||
encodeDataType: func(ft telemetrytypes.FieldDataType) string { return ft.TagDataType() },
|
||||
extraOrBranch: func(*sqlbuilder.SelectBuilder) string { return "" },
|
||||
},
|
||||
{
|
||||
shouldQuery: queryBodyTable,
|
||||
fieldContext: telemetrytypes.FieldContextBody,
|
||||
table: fmt.Sprintf("%s.%s", DBName, FieldKeysTable),
|
||||
nameColumn: "field_name",
|
||||
dataTypeColumn: "field_data_type",
|
||||
dataTypeSelectExpr: "field_data_type",
|
||||
addBaseFilters: func(sb *sqlbuilder.SelectBuilder) {
|
||||
sb.Where(sb.E("signal", telemetrytypes.SignalLogs.StringValue()))
|
||||
sb.Where(sb.E("field_context", telemetrytypes.FieldContextBody.StringValue()))
|
||||
},
|
||||
encodeDataType: func(ft telemetrytypes.FieldDataType) string { return ft.StringValue() },
|
||||
extraOrBranch: func(sb *sqlbuilder.SelectBuilder) string {
|
||||
if len(parentPaths) == 0 {
|
||||
return ""
|
||||
}
|
||||
names := make([]any, 0, len(parentPaths))
|
||||
for n := range parentPaths {
|
||||
names = append(names, n)
|
||||
}
|
||||
return sb.And(
|
||||
sb.In("field_name", names...),
|
||||
sb.In("field_data_type",
|
||||
telemetrytypes.FieldDataTypeArrayDynamic.StringValue(),
|
||||
telemetrytypes.FieldDataTypeArrayJSON.StringValue(),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, arm := range tablesToQuery {
|
||||
if !arm.shouldQuery {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldContext := table.fieldContext
|
||||
|
||||
// table name based on field context
|
||||
var tblName string
|
||||
if fieldContext == telemetrytypes.FieldContextAttribute {
|
||||
tblName = t.logsDBName + "." + t.logAttributeKeysTblName
|
||||
} else {
|
||||
tblName = t.logsDBName + "." + t.logResourceKeysTblName
|
||||
}
|
||||
|
||||
sb := sqlbuilder.Select(
|
||||
"name AS tag_key",
|
||||
fmt.Sprintf("'%s' AS tag_type", fieldContext.TagType()),
|
||||
"lower(datatype) AS tag_data_type", // in logs, we had some historical data with capital and small case
|
||||
fmt.Sprintf(`%d AS priority`, getPriorityForContext(fieldContext)),
|
||||
).From(tblName)
|
||||
arm.nameColumn+" AS tag_key",
|
||||
fmt.Sprintf("'%s' AS tag_type", arm.fieldContext.TagType()),
|
||||
arm.dataTypeSelectExpr+" AS tag_data_type",
|
||||
fmt.Sprintf("%d AS priority", getPriorityForContext(arm.fieldContext)),
|
||||
).From(arm.table)
|
||||
|
||||
var limit int
|
||||
conds := []string{}
|
||||
arm.addBaseFilters(sb)
|
||||
|
||||
for _, fieldKeySelector := range fieldKeySelectors {
|
||||
// Include this selector if:
|
||||
// 1. It has unspecified context (matches all tables)
|
||||
// 2. Its context matches the current table's context
|
||||
if fieldKeySelector.FieldContext != telemetrytypes.FieldContextUnspecified &&
|
||||
fieldKeySelector.FieldContext != fieldContext {
|
||||
branches := []string{}
|
||||
for _, sel := range fieldKeySelectors {
|
||||
if sel.FieldContext != telemetrytypes.FieldContextUnspecified && sel.FieldContext != arm.fieldContext {
|
||||
continue
|
||||
}
|
||||
|
||||
// key part of the selector
|
||||
fieldKeyConds := []string{}
|
||||
if fieldKeySelector.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeExact {
|
||||
fieldKeyConds = append(fieldKeyConds, sb.E("name", fieldKeySelector.Name))
|
||||
parts := []string{}
|
||||
if sel.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeExact {
|
||||
parts = append(parts, sb.E(arm.nameColumn, sel.Name))
|
||||
} else {
|
||||
fieldKeyConds = append(fieldKeyConds, sb.ILike("name", "%"+escapeForLike(fieldKeySelector.Name)+"%"))
|
||||
parts = append(parts, sb.ILike(arm.nameColumn, "%"+escapeForLike(sel.Name)+"%"))
|
||||
}
|
||||
|
||||
// now look at the field data type
|
||||
if fieldKeySelector.FieldDataType != telemetrytypes.FieldDataTypeUnspecified {
|
||||
fieldKeyConds = append(fieldKeyConds, sb.E("datatype", fieldKeySelector.FieldDataType.TagDataType()))
|
||||
if sel.FieldDataType != telemetrytypes.FieldDataTypeUnspecified {
|
||||
parts = append(parts, sb.E(arm.dataTypeColumn, arm.encodeDataType(sel.FieldDataType)))
|
||||
}
|
||||
|
||||
if len(fieldKeyConds) > 0 {
|
||||
conds = append(conds, sb.And(fieldKeyConds...))
|
||||
if len(parts) > 0 {
|
||||
branches = append(branches, sb.And(parts...))
|
||||
}
|
||||
limit += fieldKeySelector.Limit
|
||||
}
|
||||
|
||||
if len(conds) > 0 {
|
||||
sb.Where(sb.Or(conds...))
|
||||
if extra := arm.extraOrBranch(sb); extra != "" {
|
||||
branches = append(branches, extra)
|
||||
}
|
||||
|
||||
sb.GroupBy("name", "datatype")
|
||||
if limit == 0 {
|
||||
limit = 1000
|
||||
if len(branches) > 0 {
|
||||
sb.Where(sb.Or(branches...))
|
||||
}
|
||||
|
||||
sb.GroupBy(arm.nameColumn, arm.dataTypeColumn)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
queries = append(queries, query)
|
||||
allArgs = append(allArgs, args...)
|
||||
@@ -517,6 +593,7 @@ func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, fieldKeySelectors
|
||||
defer rows.Close()
|
||||
|
||||
keys := []*telemetrytypes.TelemetryFieldKey{}
|
||||
parentTypeCache := make(map[string][]telemetrytypes.FieldDataType)
|
||||
rowCount := 0
|
||||
searchTexts := []string{}
|
||||
|
||||
@@ -540,6 +617,17 @@ func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, fieldKeySelectors
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetLogsKeys.Error())
|
||||
}
|
||||
|
||||
// body keys with ArrayJSON/ArrayDynamic types are internal container types
|
||||
// used only for JSON access plan building; route to parentTypeCache, not to results
|
||||
switch fieldDataType {
|
||||
case telemetrytypes.FieldDataTypeArrayJSON, telemetrytypes.FieldDataTypeArrayDynamic:
|
||||
if fieldContext == telemetrytypes.FieldContextBody && parentPaths[name] {
|
||||
parentTypeCache[name] = append(parentTypeCache[name], fieldDataType)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
key, ok := mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()]
|
||||
|
||||
// if there is no materialised column, create a key with the field context and data type
|
||||
@@ -593,13 +681,11 @@ func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, fieldKeySelectors
|
||||
}
|
||||
}
|
||||
|
||||
// enrich body keys with promoted paths, indexes, and JSON access plans
|
||||
if querybuilder.BodyJSONQueryEnabled {
|
||||
bodyJSONPaths, finished, err := t.buildBodyJSONPaths(ctx, fieldKeySelectors) // LIKE for pattern matching
|
||||
if err != nil {
|
||||
t.logger.ErrorContext(ctx, "failed to extract body JSON paths", errors.Attr(err))
|
||||
if err := t.enrichJSONKeys(ctx, fieldKeySelectors, keys, parentTypeCache); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
keys = append(keys, bodyJSONPaths...)
|
||||
complete = complete && finished
|
||||
}
|
||||
|
||||
if _, err := t.updateColumnEvolutionMetadataForKeys(ctx, keys); err != nil {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package telemetrymetadata
|
||||
|
||||
import otelcollectorconst "github.com/SigNoz/signoz-otel-collector/constants"
|
||||
import otelconst "github.com/SigNoz/signoz-otel-collector/constants"
|
||||
|
||||
const (
|
||||
DBName = "signoz_metadata"
|
||||
AttributesMetadataTableName = "distributed_attributes_metadata"
|
||||
AttributesMetadataLocalTableName = "attributes_metadata"
|
||||
ColumnEvolutionMetadataTableName = "distributed_column_evolution_metadata"
|
||||
PathTypesTableName = otelcollectorconst.DistributedFieldKeysTable
|
||||
FieldKeysTable = otelconst.DistributedFieldKeysTable
|
||||
// Column Evolution table stores promoted paths as (signal, column_name, field_context, field_name); see signoz-otel-collector metadata_migrations.
|
||||
PromotedPathsTableName = "distributed_column_evolution_metadata"
|
||||
SkipIndexTableName = "system.data_skipping_indices"
|
||||
|
||||
@@ -37,7 +37,6 @@ type TelemetryFieldKey struct {
|
||||
FieldContext FieldContext `json:"fieldContext,omitzero"`
|
||||
FieldDataType FieldDataType `json:"fieldDataType,omitzero"`
|
||||
|
||||
JSONDataType *JSONDataType `json:"-"`
|
||||
JSONPlan JSONAccessPlan `json:"-"`
|
||||
Indexes []JSONDataTypeIndex `json:"-"`
|
||||
Materialized bool `json:"-"` // refers to promoted in case of body.... fields
|
||||
@@ -80,6 +79,11 @@ func (f *TelemetryFieldKey) ArrayParentSelectors() []*FieldKeySelector {
|
||||
return selectors
|
||||
}
|
||||
|
||||
// GetJSONDataType derives the JSONDataType from FieldDataType.
|
||||
func (f *TelemetryFieldKey) GetJSONDataType() JSONDataType {
|
||||
return MappingFieldDataTypeToJSONDataType[f.FieldDataType]
|
||||
}
|
||||
|
||||
func (f TelemetryFieldKey) String() string {
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "name=%s", f.Name)
|
||||
@@ -92,9 +96,6 @@ func (f TelemetryFieldKey) String() string {
|
||||
if f.Materialized {
|
||||
sb.WriteString(",materialized=true")
|
||||
}
|
||||
if f.JSONDataType != nil {
|
||||
fmt.Fprintf(&sb, ",jsondatatype=%s", f.JSONDataType.StringValue())
|
||||
}
|
||||
if len(f.Indexes) > 0 {
|
||||
sb.WriteString(",indexes=[")
|
||||
for i, index := range f.Indexes {
|
||||
@@ -117,7 +118,6 @@ func (f TelemetryFieldKey) Text() string {
|
||||
func (f *TelemetryFieldKey) OverrideMetadataFrom(src *TelemetryFieldKey) {
|
||||
f.FieldContext = src.FieldContext
|
||||
f.FieldDataType = src.FieldDataType
|
||||
f.JSONDataType = src.JSONDataType
|
||||
f.Indexes = src.Indexes
|
||||
f.Materialized = src.Materialized
|
||||
f.JSONPlan = src.JSONPlan
|
||||
|
||||
@@ -31,7 +31,7 @@ var (
|
||||
FieldDataTypeArrayInt64 = FieldDataType{valuer.NewString("[]int64")}
|
||||
FieldDataTypeArrayNumber = FieldDataType{valuer.NewString("[]number")}
|
||||
|
||||
FieldDataTypeArrayObject = FieldDataType{valuer.NewString("[]object")}
|
||||
FieldDataTypeArrayJSON = FieldDataType{valuer.NewString("[]json")}
|
||||
FieldDataTypeArrayDynamic = FieldDataType{valuer.NewString("[]dynamic")}
|
||||
|
||||
// Map string representations to FieldDataType values
|
||||
@@ -51,7 +51,7 @@ var (
|
||||
"int8": FieldDataTypeNumber,
|
||||
"int16": FieldDataTypeNumber,
|
||||
"int32": FieldDataTypeNumber,
|
||||
"int64": FieldDataTypeNumber,
|
||||
"int64": FieldDataTypeInt64,
|
||||
"uint": FieldDataTypeNumber,
|
||||
"uint8": FieldDataTypeNumber,
|
||||
"uint16": FieldDataTypeNumber,
|
||||
@@ -72,6 +72,8 @@ var (
|
||||
"[]float64": FieldDataTypeArrayFloat64,
|
||||
"[]number": FieldDataTypeArrayNumber,
|
||||
"[]bool": FieldDataTypeArrayBool,
|
||||
"[]json": FieldDataTypeArrayJSON,
|
||||
"[]dynamic": FieldDataTypeArrayDynamic,
|
||||
|
||||
// c-style array types
|
||||
"string[]": FieldDataTypeArrayString,
|
||||
@@ -79,6 +81,8 @@ var (
|
||||
"float64[]": FieldDataTypeArrayFloat64,
|
||||
"number[]": FieldDataTypeArrayNumber,
|
||||
"bool[]": FieldDataTypeArrayBool,
|
||||
"json[]": FieldDataTypeArrayJSON,
|
||||
"dynamic[]": FieldDataTypeArrayDynamic,
|
||||
}
|
||||
|
||||
fieldDataTypeToCHDataType = map[FieldDataType]string{
|
||||
|
||||
@@ -19,7 +19,8 @@ var (
|
||||
BranchJSON = JSONAccessBranchType{valuer.NewString("json")}
|
||||
BranchDynamic = JSONAccessBranchType{valuer.NewString("dynamic")}
|
||||
|
||||
CodePlanIndexOutOfBounds = errors.MustNewCode("plan_index_out_of_bounds")
|
||||
CodePlanIndexOutOfBounds = errors.MustNewCode("plan_index_out_of_bounds")
|
||||
CodePlanFieldDataTypeMissing = errors.MustNewCode("field_data_type_missing")
|
||||
)
|
||||
|
||||
type JSONColumnMetadata struct {
|
||||
@@ -42,9 +43,6 @@ type JSONAccessNode struct {
|
||||
IsTerminal bool
|
||||
isRoot bool // marked true for only body_v2 and body_promoted
|
||||
|
||||
// Precomputed type information (single source of truth)
|
||||
AvailableTypes []JSONDataType
|
||||
|
||||
// Array type branches (Array(JSON) vs Array(Dynamic))
|
||||
Branches map[JSONAccessBranchType]*JSONAccessNode
|
||||
|
||||
@@ -106,7 +104,7 @@ type planBuilder struct {
|
||||
paths []string // cumulative paths for type cache lookups
|
||||
segments []string // individual path segments for node names
|
||||
isPromoted bool
|
||||
typeCache map[string][]JSONDataType
|
||||
typeCache map[string][]FieldDataType
|
||||
}
|
||||
|
||||
// buildPlan recursively builds the path plan tree.
|
||||
@@ -138,34 +136,41 @@ func (pb *planBuilder) buildPlan(index int, parent *JSONAccessNode, isDynArrChil
|
||||
}
|
||||
}
|
||||
|
||||
// Use cached types from the batched metadata query
|
||||
types, ok := pb.typeCache[pathSoFar]
|
||||
if !ok {
|
||||
return nil, errors.NewInternalf(errors.CodeInvalidInput, "types missing for path %s", pathSoFar)
|
||||
}
|
||||
|
||||
// Create node for this path segment
|
||||
node := &JSONAccessNode{
|
||||
Name: segmentName,
|
||||
IsTerminal: isTerminal,
|
||||
AvailableTypes: types,
|
||||
Branches: make(map[JSONAccessBranchType]*JSONAccessNode),
|
||||
Parent: parent,
|
||||
MaxDynamicTypes: maxTypes,
|
||||
MaxDynamicPaths: maxPaths,
|
||||
}
|
||||
|
||||
hasJSON := slices.Contains(node.AvailableTypes, ArrayJSON)
|
||||
hasDynamic := slices.Contains(node.AvailableTypes, ArrayDynamic)
|
||||
|
||||
// Configure terminal if this is the last part
|
||||
if isTerminal {
|
||||
// fielddatatype must not be unspecified else expression can not be generated
|
||||
if pb.key.FieldDataType == FieldDataTypeUnspecified {
|
||||
return nil, errors.NewInternalf(CodePlanFieldDataTypeMissing, "field data type is missing for path %s", pathSoFar)
|
||||
}
|
||||
|
||||
node.TerminalConfig = &TerminalConfig{
|
||||
Key: pb.key,
|
||||
ElemType: *pb.key.JSONDataType,
|
||||
ElemType: pb.key.GetJSONDataType(),
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
// Use cached types from the batched metadata query
|
||||
types, ok := pb.typeCache[pathSoFar]
|
||||
if !ok {
|
||||
return nil, errors.NewInternalf(errors.CodeInvalidInput, "types missing for path %s", pathSoFar)
|
||||
}
|
||||
|
||||
hasJSON := slices.Contains(types, FieldDataTypeArrayJSON)
|
||||
hasDynamic := slices.Contains(types, FieldDataTypeArrayDynamic)
|
||||
if !hasJSON && !hasDynamic {
|
||||
return nil, errors.NewInternalf(CodePlanFieldDataTypeMissing, "array data type missing for path %s", pathSoFar)
|
||||
}
|
||||
|
||||
if hasJSON {
|
||||
node.Branches[BranchJSON], err = pb.buildPlan(index+1, node, false)
|
||||
if err != nil {
|
||||
@@ -185,7 +190,7 @@ func (pb *planBuilder) buildPlan(index int, parent *JSONAccessNode, isDynArrChil
|
||||
|
||||
// buildJSONAccessPlan builds a tree structure representing the complete JSON path traversal
|
||||
// that precomputes all possible branches and their types.
|
||||
func (key *TelemetryFieldKey) SetJSONAccessPlan(columnInfo JSONColumnMetadata, typeCache map[string][]JSONDataType,
|
||||
func (key *TelemetryFieldKey) SetJSONAccessPlan(columnInfo JSONColumnMetadata, typeCache map[string][]FieldDataType,
|
||||
) error {
|
||||
// if path is empty, return nil
|
||||
if key.Name == "" {
|
||||
|
||||
@@ -19,11 +19,11 @@ const (
|
||||
// ============================================================================
|
||||
|
||||
// makeKey creates a TelemetryFieldKey for testing.
|
||||
func makeKey(name string, dataType JSONDataType, materialized bool) *TelemetryFieldKey {
|
||||
func makeKey(name string, dataType FieldDataType, materialized bool) *TelemetryFieldKey {
|
||||
return &TelemetryFieldKey{
|
||||
Name: name,
|
||||
JSONDataType: &dataType,
|
||||
Materialized: materialized,
|
||||
Name: name,
|
||||
FieldDataType: dataType,
|
||||
Materialized: materialized,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,14 +65,6 @@ func toTestNode(n *JSONAccessNode) *jsonAccessTestNode {
|
||||
out.Column = n.Parent.Name
|
||||
}
|
||||
|
||||
// AvailableTypes as strings (using StringValue for stable representation)
|
||||
if len(n.AvailableTypes) > 0 {
|
||||
out.AvailableTypes = make([]string, 0, len(n.AvailableTypes))
|
||||
for _, t := range n.AvailableTypes {
|
||||
out.AvailableTypes = append(out.AvailableTypes, t.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal config
|
||||
if n.TerminalConfig != nil {
|
||||
out.ElemType = n.TerminalConfig.ElemType.StringValue()
|
||||
@@ -242,12 +234,10 @@ func TestPlanJSON_BasicStructure(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "Simple path not promoted",
|
||||
key: makeKey("user.name", String, false),
|
||||
key: makeKey("user.name", FieldDataTypeString, false),
|
||||
expectedYAML: fmt.Sprintf(`
|
||||
- name: user.name
|
||||
column: %s
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
@@ -255,19 +245,15 @@ func TestPlanJSON_BasicStructure(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "Simple path promoted",
|
||||
key: makeKey("user.name", String, true),
|
||||
key: makeKey("user.name", FieldDataTypeString, true),
|
||||
expectedYAML: fmt.Sprintf(`
|
||||
- name: user.name
|
||||
column: %s
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
- name: user.name
|
||||
column: %s
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
isTerminal: true
|
||||
@@ -276,7 +262,7 @@ func TestPlanJSON_BasicStructure(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "Empty path returns error",
|
||||
key: makeKey("", String, false),
|
||||
key: makeKey("", FieldDataTypeString, false),
|
||||
expectErr: true,
|
||||
expectedYAML: "",
|
||||
},
|
||||
@@ -314,14 +300,10 @@ func TestPlanJSON_ArrayPaths(t *testing.T) {
|
||||
expectedYAML: fmt.Sprintf(`
|
||||
- name: education
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: name
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 8
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
@@ -333,28 +315,19 @@ func TestPlanJSON_ArrayPaths(t *testing.T) {
|
||||
expectedYAML: fmt.Sprintf(`
|
||||
- name: education
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: awards
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
branches:
|
||||
json:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 4
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
dynamic:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
isTerminal: true
|
||||
@@ -367,43 +340,29 @@ func TestPlanJSON_ArrayPaths(t *testing.T) {
|
||||
expectedYAML: fmt.Sprintf(`
|
||||
- name: interests
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: entities
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
branches:
|
||||
json:
|
||||
name: reviews
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 4
|
||||
branches:
|
||||
json:
|
||||
name: entries
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 2
|
||||
branches:
|
||||
json:
|
||||
name: metadata
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 1
|
||||
branches:
|
||||
json:
|
||||
name: positions
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
branches:
|
||||
json:
|
||||
name: name
|
||||
availableTypes:
|
||||
- String
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
`, bodyV2Column),
|
||||
@@ -414,14 +373,10 @@ func TestPlanJSON_ArrayPaths(t *testing.T) {
|
||||
expectedYAML: fmt.Sprintf(`
|
||||
- name: education
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: name
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 8
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
@@ -431,7 +386,7 @@ func TestPlanJSON_ArrayPaths(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
key := makeKey(tt.path, String, false)
|
||||
key := makeKey(tt.path, FieldDataTypeString, false)
|
||||
err := key.SetJSONAccessPlan(JSONColumnMetadata{
|
||||
BaseColumn: bodyV2Column,
|
||||
PromotedColumn: bodyPromotedColumn,
|
||||
@@ -450,7 +405,7 @@ func TestPlanJSON_PromotedVsNonPromoted(t *testing.T) {
|
||||
path := "education[].awards[].type"
|
||||
|
||||
t.Run("Non-promoted plan", func(t *testing.T) {
|
||||
key := makeKey(path, String, false)
|
||||
key := makeKey(path, FieldDataTypeString, false)
|
||||
err := key.SetJSONAccessPlan(JSONColumnMetadata{
|
||||
BaseColumn: bodyV2Column,
|
||||
PromotedColumn: bodyPromotedColumn,
|
||||
@@ -461,28 +416,19 @@ func TestPlanJSON_PromotedVsNonPromoted(t *testing.T) {
|
||||
expectedYAML := fmt.Sprintf(`
|
||||
- name: education
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: awards
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
branches:
|
||||
json:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 4
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
dynamic:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
isTerminal: true
|
||||
@@ -493,7 +439,7 @@ func TestPlanJSON_PromotedVsNonPromoted(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Promoted plan", func(t *testing.T) {
|
||||
key := makeKey(path, String, true)
|
||||
key := makeKey(path, FieldDataTypeString, true)
|
||||
err := key.SetJSONAccessPlan(JSONColumnMetadata{
|
||||
BaseColumn: bodyV2Column,
|
||||
PromotedColumn: bodyPromotedColumn,
|
||||
@@ -504,59 +450,41 @@ func TestPlanJSON_PromotedVsNonPromoted(t *testing.T) {
|
||||
expectedYAML := fmt.Sprintf(`
|
||||
- name: education
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: awards
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
branches:
|
||||
json:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 4
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
dynamic:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
- name: education
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
branches:
|
||||
json:
|
||||
name: awards
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
maxDynamicPaths: 64
|
||||
branches:
|
||||
json:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 4
|
||||
maxDynamicPaths: 16
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
dynamic:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
isTerminal: true
|
||||
@@ -578,7 +506,7 @@ func TestPlanJSON_EdgeCases(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "Path with no available types",
|
||||
path: "unknown.path",
|
||||
path: "unknown[].path",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
@@ -587,43 +515,29 @@ func TestPlanJSON_EdgeCases(t *testing.T) {
|
||||
expectedYAML: fmt.Sprintf(`
|
||||
- name: interests
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: entities
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
branches:
|
||||
json:
|
||||
name: reviews
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 4
|
||||
branches:
|
||||
json:
|
||||
name: entries
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 2
|
||||
branches:
|
||||
json:
|
||||
name: metadata
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 1
|
||||
branches:
|
||||
json:
|
||||
name: positions
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
branches:
|
||||
json:
|
||||
name: name
|
||||
availableTypes:
|
||||
- String
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
`, bodyV2Column),
|
||||
@@ -634,15 +548,10 @@ func TestPlanJSON_EdgeCases(t *testing.T) {
|
||||
expectedYAML: fmt.Sprintf(`
|
||||
- name: education
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: type
|
||||
availableTypes:
|
||||
- String
|
||||
- Int64
|
||||
maxDynamicTypes: 8
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
@@ -654,8 +563,6 @@ func TestPlanJSON_EdgeCases(t *testing.T) {
|
||||
expectedYAML: fmt.Sprintf(`
|
||||
- name: education
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
isTerminal: true
|
||||
elemType: Array(JSON)
|
||||
@@ -666,12 +573,12 @@ func TestPlanJSON_EdgeCases(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Choose key type based on path; operator does not affect the tree shape asserted here.
|
||||
keyType := String
|
||||
keyType := FieldDataTypeString
|
||||
switch tt.path {
|
||||
case "education":
|
||||
keyType = ArrayJSON
|
||||
keyType = FieldDataTypeArrayJSON
|
||||
case "education[].type":
|
||||
keyType = String
|
||||
keyType = FieldDataTypeString
|
||||
}
|
||||
key := makeKey(tt.path, keyType, false)
|
||||
err := key.SetJSONAccessPlan(JSONColumnMetadata{
|
||||
@@ -692,7 +599,7 @@ func TestPlanJSON_EdgeCases(t *testing.T) {
|
||||
func TestPlanJSON_TreeStructure(t *testing.T) {
|
||||
types, _ := TestJSONTypeSet()
|
||||
path := "education[].awards[].participated[].team[].branch"
|
||||
key := makeKey(path, String, false)
|
||||
key := makeKey(path, FieldDataTypeString, false)
|
||||
err := key.SetJSONAccessPlan(JSONColumnMetadata{
|
||||
BaseColumn: bodyV2Column,
|
||||
PromotedColumn: bodyPromotedColumn,
|
||||
@@ -703,86 +610,59 @@ func TestPlanJSON_TreeStructure(t *testing.T) {
|
||||
expectedYAML := fmt.Sprintf(`
|
||||
- name: education
|
||||
column: %s
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
branches:
|
||||
json:
|
||||
name: awards
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
branches:
|
||||
json:
|
||||
name: participated
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 4
|
||||
branches:
|
||||
json:
|
||||
name: team
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 2
|
||||
branches:
|
||||
json:
|
||||
name: branch
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 1
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
dynamic:
|
||||
name: team
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
branches:
|
||||
json:
|
||||
name: branch
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 8
|
||||
maxDynamicPaths: 64
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
dynamic:
|
||||
name: participated
|
||||
availableTypes:
|
||||
- Array(Dynamic)
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
branches:
|
||||
json:
|
||||
name: team
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 8
|
||||
maxDynamicPaths: 64
|
||||
branches:
|
||||
json:
|
||||
name: branch
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 4
|
||||
maxDynamicPaths: 16
|
||||
isTerminal: true
|
||||
elemType: String
|
||||
dynamic:
|
||||
name: team
|
||||
availableTypes:
|
||||
- Array(JSON)
|
||||
maxDynamicTypes: 16
|
||||
maxDynamicPaths: 256
|
||||
branches:
|
||||
json:
|
||||
name: branch
|
||||
availableTypes:
|
||||
- String
|
||||
maxDynamicTypes: 8
|
||||
maxDynamicPaths: 64
|
||||
isTerminal: true
|
||||
|
||||
@@ -46,14 +46,6 @@ var MappingStringToJSONDataType = map[string]JSONDataType{
|
||||
"Array(JSON)": ArrayJSON,
|
||||
}
|
||||
|
||||
var ScalerTypeToArrayType = map[JSONDataType]JSONDataType{
|
||||
String: ArrayString,
|
||||
Int64: ArrayInt64,
|
||||
Float64: ArrayFloat64,
|
||||
Bool: ArrayBool,
|
||||
Dynamic: ArrayDynamic,
|
||||
}
|
||||
|
||||
var MappingFieldDataTypeToJSONDataType = map[FieldDataType]JSONDataType{
|
||||
FieldDataTypeString: String,
|
||||
FieldDataTypeInt64: Int64,
|
||||
@@ -63,18 +55,8 @@ var MappingFieldDataTypeToJSONDataType = map[FieldDataType]JSONDataType{
|
||||
FieldDataTypeArrayString: ArrayString,
|
||||
FieldDataTypeArrayInt64: ArrayInt64,
|
||||
FieldDataTypeArrayFloat64: ArrayFloat64,
|
||||
FieldDataTypeArrayNumber: ArrayFloat64,
|
||||
FieldDataTypeArrayBool: ArrayBool,
|
||||
}
|
||||
|
||||
var MappingJSONDataTypeToFieldDataType = map[JSONDataType]FieldDataType{
|
||||
String: FieldDataTypeString,
|
||||
Int64: FieldDataTypeInt64,
|
||||
Float64: FieldDataTypeFloat64,
|
||||
Bool: FieldDataTypeBool,
|
||||
ArrayString: FieldDataTypeArrayString,
|
||||
ArrayInt64: FieldDataTypeArrayInt64,
|
||||
ArrayFloat64: FieldDataTypeArrayFloat64,
|
||||
ArrayBool: FieldDataTypeArrayBool,
|
||||
ArrayDynamic: FieldDataTypeArrayDynamic,
|
||||
ArrayJSON: FieldDataTypeArrayObject,
|
||||
FieldDataTypeArrayDynamic: ArrayDynamic,
|
||||
FieldDataTypeArrayJSON: ArrayJSON,
|
||||
}
|
||||
|
||||
@@ -4,69 +4,69 @@ package telemetrytypes
|
||||
// Test JSON Type Set Data Setup
|
||||
// ============================================================================
|
||||
|
||||
// TestJSONTypeSet returns a map of path->types for testing.
|
||||
// TestJSONTypeSet returns a map of path->field data types for testing.
|
||||
// This represents the type information available in the test JSON structure.
|
||||
func TestJSONTypeSet() (map[string][]JSONDataType, MetadataStore) {
|
||||
types := map[string][]JSONDataType{
|
||||
func TestJSONTypeSet() (map[string][]FieldDataType, MetadataStore) {
|
||||
types := map[string][]FieldDataType{
|
||||
|
||||
// ── user (primitives) ─────────────────────────────────────────────
|
||||
"user.name": {String},
|
||||
"user.permissions": {ArrayString},
|
||||
"user.age": {Int64, String}, // Int64/String ambiguity
|
||||
"user.height": {Float64},
|
||||
"user.active": {Bool}, // Bool — not IndexSupported
|
||||
"user.name": {FieldDataTypeString},
|
||||
"user.permissions": {FieldDataTypeArrayString},
|
||||
"user.age": {FieldDataTypeInt64, FieldDataTypeString}, // Int64/String ambiguity
|
||||
"user.height": {FieldDataTypeFloat64},
|
||||
"user.active": {FieldDataTypeBool}, // Bool — not IndexSupported
|
||||
|
||||
// Deeper non-array nesting (a.b.c — no array hops)
|
||||
"user.address.zip": {Int64},
|
||||
"user.address.zip": {FieldDataTypeInt64},
|
||||
|
||||
// ── education[] ───────────────────────────────────────────────────
|
||||
// Pattern: x[].y
|
||||
"education": {ArrayJSON},
|
||||
"education[].name": {String},
|
||||
"education[].type": {String, Int64},
|
||||
"education[].year": {Int64},
|
||||
"education[].scores": {ArrayInt64},
|
||||
"education[].parameters": {ArrayFloat64, ArrayDynamic},
|
||||
"education": {FieldDataTypeArrayJSON},
|
||||
"education[].name": {FieldDataTypeString},
|
||||
"education[].type": {FieldDataTypeString, FieldDataTypeInt64},
|
||||
"education[].year": {FieldDataTypeInt64},
|
||||
"education[].scores": {FieldDataTypeArrayInt64},
|
||||
"education[].parameters": {FieldDataTypeArrayFloat64, FieldDataTypeArrayDynamic},
|
||||
|
||||
// Pattern: x[].y[]
|
||||
"education[].awards": {ArrayDynamic, ArrayJSON},
|
||||
"education[].awards": {FieldDataTypeArrayDynamic, FieldDataTypeArrayJSON},
|
||||
|
||||
// Pattern: x[].y[].z
|
||||
"education[].awards[].name": {String},
|
||||
"education[].awards[].type": {String},
|
||||
"education[].awards[].semester": {Int64},
|
||||
"education[].awards[].name": {FieldDataTypeString},
|
||||
"education[].awards[].type": {FieldDataTypeString},
|
||||
"education[].awards[].semester": {FieldDataTypeInt64},
|
||||
|
||||
// Pattern: x[].y[].z[]
|
||||
"education[].awards[].participated": {ArrayDynamic, ArrayJSON},
|
||||
"education[].awards[].participated": {FieldDataTypeArrayDynamic, FieldDataTypeArrayJSON},
|
||||
|
||||
// Pattern: x[].y[].z[].w
|
||||
"education[].awards[].participated[].members": {ArrayString},
|
||||
"education[].awards[].participated[].members": {FieldDataTypeArrayString},
|
||||
|
||||
// Pattern: x[].y[].z[].w[]
|
||||
"education[].awards[].participated[].team": {ArrayJSON},
|
||||
"education[].awards[].participated[].team": {FieldDataTypeArrayJSON},
|
||||
|
||||
// Pattern: x[].y[].z[].w[].v
|
||||
"education[].awards[].participated[].team[].branch": {String},
|
||||
"education[].awards[].participated[].team[].branch": {FieldDataTypeString},
|
||||
|
||||
// ── interests[] ───────────────────────────────────────────────────
|
||||
"interests": {ArrayJSON},
|
||||
"interests[].entities": {ArrayJSON},
|
||||
"interests[].entities[].reviews": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions": {ArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].name": {String},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].ratings": {ArrayInt64, ArrayString},
|
||||
"http-events": {ArrayJSON},
|
||||
"http-events[].request-info.host": {String},
|
||||
"ids": {ArrayDynamic},
|
||||
"interests": {FieldDataTypeArrayJSON},
|
||||
"interests[].entities": {FieldDataTypeArrayJSON},
|
||||
"interests[].entities[].reviews": {FieldDataTypeArrayJSON},
|
||||
"interests[].entities[].reviews[].entries": {FieldDataTypeArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata": {FieldDataTypeArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions": {FieldDataTypeArrayJSON},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].name": {FieldDataTypeString},
|
||||
"interests[].entities[].reviews[].entries[].metadata[].positions[].ratings": {FieldDataTypeArrayInt64, FieldDataTypeArrayString},
|
||||
"http-events": {FieldDataTypeArrayJSON},
|
||||
"http-events[].request-info.host": {FieldDataTypeString},
|
||||
"ids": {FieldDataTypeArrayDynamic},
|
||||
|
||||
// ── top-level primitives ──────────────────────────────────────────
|
||||
"message": {String},
|
||||
"http-status": {Int64, String}, // hyphen in root key, ambiguous
|
||||
"message": {FieldDataTypeString},
|
||||
"http-status": {FieldDataTypeInt64, FieldDataTypeString}, // hyphen in root key, ambiguous
|
||||
|
||||
// ── top-level nested objects (no array hops) ───────────────────────
|
||||
"response.time-taken": {Float64}, // hyphen inside nested key
|
||||
"response.time-taken": {FieldDataTypeFloat64}, // hyphen inside nested key
|
||||
}
|
||||
|
||||
return types, nil
|
||||
|
||||
Reference in New Issue
Block a user