Compare commits

..

2 Commits

Author SHA1 Message Date
swapnil-signoz
d8abbce47e refactor: moving types to cloud provider specific namespace/pkg 2026-04-17 11:57:41 +05:30
Abhishek Kumar Singh
d085a8fd53 chore: custom notifiers in alert manager (#10541)
* chore: custom notifiers in alert manager

* chore: lint fixs

* chore: fix email linter

* chore: added tracing to msteamsv2 notifier

* chore: updated test name + code for timeout errors

* refactor: review comments

* refactor: lint fixes

* chore: updated licenses for notifiers

* chore: updated email notifier from upstream

* chore: updated license header with short notation

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-04-16 10:33:46 +00:00
44 changed files with 4850 additions and 3223 deletions

View File

@@ -48,7 +48,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstorehook"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
cptypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes/cloudprovidertypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus"
)
@@ -159,9 +159,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return nil, err
}
azureCloudProviderModule := implcloudprovider.NewAzureCloudProvider()
cloudProvidersMap := map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule{
cloudintegrationtypes.CloudProviderTypeAWS: awsCloudProviderModule,
cloudintegrationtypes.CloudProviderTypeAzure: azureCloudProviderModule,
cloudProvidersMap := map[cptypes.CloudProviderType]cloudintegration.CloudProviderModule{
cptypes.CloudProviderTypeAWS: awsCloudProviderModule,
cptypes.CloudProviderTypeAzure: azureCloudProviderModule,
}
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)

View File

@@ -8,6 +8,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
cptypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes/cloudprovidertypes"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes/cloudprovidertypes/awstypes"
)
type awscloudprovider struct {
@@ -19,7 +21,7 @@ func NewAWSCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore)
}
func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
baseURL := fmt.Sprintf(cloudintegrationtypes.CloudFormationQuickCreateBaseURL.StringValue(), req.Config.Aws.DeploymentRegion)
baseURL := fmt.Sprintf(awstypes.CloudFormationQuickCreateBaseURL.StringValue(), req.Config.Aws.DeploymentRegion)
u, _ := url.Parse(baseURL)
q := u.Query()
@@ -29,8 +31,8 @@ func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, acc
u.RawQuery = q.Encode()
q = u.Query()
q.Set("stackName", cloudintegrationtypes.AgentCloudFormationBaseStackName.StringValue())
q.Set("templateURL", fmt.Sprintf(cloudintegrationtypes.AgentCloudFormationTemplateS3Path.StringValue(), req.Config.AgentVersion))
q.Set("stackName", awstypes.AgentCloudFormationBaseStackName.StringValue())
q.Set("templateURL", fmt.Sprintf(awstypes.AgentCloudFormationTemplateS3Path.StringValue(), req.Config.AgentVersion))
q.Set("param_SigNozIntegrationAgentVersion", req.Config.AgentVersion)
q.Set("param_SigNozApiUrl", req.Credentials.SigNozAPIURL)
q.Set("param_SigNozApiKey", req.Credentials.SigNozAPIKey)
@@ -39,25 +41,23 @@ func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, acc
q.Set("param_IngestionKey", req.Credentials.IngestionKey)
return &cloudintegrationtypes.ConnectionArtifact{
Aws: &cloudintegrationtypes.AWSConnectionArtifact{
ConnectionURL: u.String() + "?&" + q.Encode(), // this format is required by AWS
},
AWS: awstypes.NewConnectionArtifact(u.String() + "?&" + q.Encode()), // this format is required by AWS
}, nil
}
func (provider *awscloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
return provider.serviceDefinitions.List(ctx, cloudintegrationtypes.CloudProviderTypeAWS)
return provider.serviceDefinitions.List(ctx, cptypes.CloudProviderTypeAWS)
}
func (provider *awscloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
serviceDef, err := provider.serviceDefinitions.Get(ctx, cloudintegrationtypes.CloudProviderTypeAWS, serviceID)
func (provider *awscloudprovider) GetServiceDefinition(ctx context.Context, serviceID cptypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
serviceDef, err := provider.serviceDefinitions.Get(ctx, cptypes.CloudProviderTypeAWS, serviceID)
if err != nil {
return nil, err
}
// override cloud integration dashboard id
for index, dashboard := range serviceDef.Assets.Dashboards {
serviceDef.Assets.Dashboards[index].ID = cloudintegrationtypes.GetCloudIntegrationDashboardID(cloudintegrationtypes.CloudProviderTypeAWS, serviceID.StringValue(), dashboard.ID)
serviceDef.Assets.Dashboards[index].ID = cloudintegrationtypes.GetCloudIntegrationDashboardID(cptypes.CloudProviderTypeAWS, serviceID.StringValue(), dashboard.ID)
}
return serviceDef, nil
@@ -73,12 +73,12 @@ func (provider *awscloudprovider) BuildIntegrationConfig(
return services[i].Type.StringValue() < services[j].Type.StringValue()
})
compiledMetrics := new(cloudintegrationtypes.AWSMetricsCollectionStrategy)
compiledLogs := new(cloudintegrationtypes.AWSLogsCollectionStrategy)
compiledMetrics := new(awstypes.AWSMetricsCollectionStrategy)
compiledLogs := new(awstypes.AWSLogsCollectionStrategy)
var compiledS3Buckets map[string][]string
for _, storedSvc := range services {
svcCfg, err := cloudintegrationtypes.NewServiceConfigFromJSON(cloudintegrationtypes.CloudProviderTypeAWS, storedSvc.Config)
svcCfg, err := cloudintegrationtypes.NewServiceConfigFromJSON(cptypes.CloudProviderTypeAWS, storedSvc.Config)
if err != nil {
return nil, err
}
@@ -89,10 +89,10 @@ func (provider *awscloudprovider) BuildIntegrationConfig(
}
strategy := svcDef.TelemetryCollectionStrategy.AWS
logsEnabled := svcCfg.IsLogsEnabled(cloudintegrationtypes.CloudProviderTypeAWS)
logsEnabled := svcCfg.IsLogsEnabled(cptypes.CloudProviderTypeAWS)
// S3Sync: logs come directly from configured S3 buckets, not CloudWatch subscriptions
if storedSvc.Type == cloudintegrationtypes.AWSServiceS3Sync {
if storedSvc.Type == cptypes.AWSServiceS3Sync {
if logsEnabled && svcCfg.AWS.Logs.S3Buckets != nil {
compiledS3Buckets = svcCfg.AWS.Logs.S3Buckets
}
@@ -104,14 +104,14 @@ func (provider *awscloudprovider) BuildIntegrationConfig(
compiledLogs.Subscriptions = append(compiledLogs.Subscriptions, strategy.Logs.Subscriptions...)
}
metricsEnabled := svcCfg.IsMetricsEnabled(cloudintegrationtypes.CloudProviderTypeAWS)
metricsEnabled := svcCfg.IsMetricsEnabled(cptypes.CloudProviderTypeAWS)
if metricsEnabled && strategy.Metrics != nil {
compiledMetrics.StreamFilters = append(compiledMetrics.StreamFilters, strategy.Metrics.StreamFilters...)
}
}
collectionStrategy := new(cloudintegrationtypes.AWSTelemetryCollectionStrategy)
collectionStrategy := new(awstypes.AWSTelemetryCollectionStrategy)
if len(compiledMetrics.StreamFilters) > 0 {
collectionStrategy.Metrics = compiledMetrics
@@ -124,9 +124,6 @@ func (provider *awscloudprovider) BuildIntegrationConfig(
}
return &cloudintegrationtypes.ProviderIntegrationConfig{
AWS: &cloudintegrationtypes.AWSIntegrationConfig{
EnabledRegions: account.Config.AWS.Regions,
TelemetryCollectionStrategy: collectionStrategy,
},
AWS: awstypes.NewIntegrationConfig(account.Config.AWS.Regions, collectionStrategy),
}, nil
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
cptypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes/cloudprovidertypes"
)
type azurecloudprovider struct{}
@@ -21,7 +22,7 @@ func (provider *azurecloudprovider) ListServiceDefinitions(ctx context.Context)
panic("implement me")
}
func (provider *azurecloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
func (provider *azurecloudprovider) GetServiceDefinition(ctx context.Context, serviceID cptypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
panic("implement me")
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
cptypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes/cloudprovidertypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
"github.com/SigNoz/signoz/pkg/types/zeustypes"
@@ -28,7 +29,7 @@ type module struct {
licensing licensing.Licensing
global global.Global
serviceAccount serviceaccount.Module
cloudProvidersMap map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule
cloudProvidersMap map[cptypes.CloudProviderType]cloudintegration.CloudProviderModule
config cloudintegration.Config
}
@@ -39,7 +40,7 @@ func NewModule(
gateway gateway.Gateway,
licensing licensing.Licensing,
serviceAccount serviceaccount.Module,
cloudProvidersMap map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule,
cloudProvidersMap map[cptypes.CloudProviderType]cloudintegration.CloudProviderModule,
config cloudintegration.Config,
) (cloudintegration.Module, error) {
return &module{
@@ -56,7 +57,7 @@ func NewModule(
// GetConnectionCredentials returns credentials required to generate connection artifact. eg. apiKey, ingestionKey etc.
// It will return creds it can deduce and return empty value for others.
func (module *module) GetConnectionCredentials(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.Credentials, error) {
func (module *module) GetConnectionCredentials(ctx context.Context, orgID valuer.UUID, provider cptypes.CloudProviderType) (*cloudintegrationtypes.Credentials, error) {
// get license to get the deployment details
license, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
@@ -119,7 +120,7 @@ func (module *module) GetConnectionArtifact(ctx context.Context, account *cloudi
return cloudProviderModule.GetConnectionArtifact(ctx, account, req)
}
func (module *module) GetAccount(ctx context.Context, orgID valuer.UUID, accountID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.Account, error) {
func (module *module) GetAccount(ctx context.Context, orgID valuer.UUID, accountID valuer.UUID, provider cptypes.CloudProviderType) (*cloudintegrationtypes.Account, error) {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
@@ -133,7 +134,7 @@ func (module *module) GetAccount(ctx context.Context, orgID valuer.UUID, account
return cloudintegrationtypes.NewAccountFromStorable(storableAccount)
}
func (module *module) GetConnectedAccount(ctx context.Context, orgID, accountID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.Account, error) {
func (module *module) GetConnectedAccount(ctx context.Context, orgID, accountID valuer.UUID, provider cptypes.CloudProviderType) (*cloudintegrationtypes.Account, error) {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
@@ -148,7 +149,7 @@ func (module *module) GetConnectedAccount(ctx context.Context, orgID, accountID
}
// ListAccounts return only agent connected accounts.
func (module *module) ListAccounts(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) ([]*cloudintegrationtypes.Account, error) {
func (module *module) ListAccounts(ctx context.Context, orgID valuer.UUID, provider cptypes.CloudProviderType) ([]*cloudintegrationtypes.Account, error) {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
@@ -162,7 +163,7 @@ func (module *module) ListAccounts(ctx context.Context, orgID valuer.UUID, provi
return cloudintegrationtypes.NewAccountsFromStorables(storableAccounts)
}
func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, req *cloudintegrationtypes.AgentCheckInRequest) (*cloudintegrationtypes.AgentCheckInResponse, error) {
func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provider cptypes.CloudProviderType, req *cloudintegrationtypes.AgentCheckInRequest) (*cloudintegrationtypes.AgentCheckInResponse, error) {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
@@ -248,7 +249,7 @@ func (module *module) UpdateAccount(ctx context.Context, account *cloudintegrati
return module.store.UpdateAccount(ctx, storableAccount)
}
func (module *module) DisconnectAccount(ctx context.Context, orgID valuer.UUID, accountID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) error {
func (module *module) DisconnectAccount(ctx context.Context, orgID valuer.UUID, accountID valuer.UUID, provider cptypes.CloudProviderType) error {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
@@ -257,7 +258,7 @@ func (module *module) DisconnectAccount(ctx context.Context, orgID valuer.UUID,
return module.store.RemoveAccount(ctx, orgID, accountID, provider)
}
func (module *module) ListServicesMetadata(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, integrationID valuer.UUID) ([]*cloudintegrationtypes.ServiceMetadata, error) {
func (module *module) ListServicesMetadata(ctx context.Context, orgID valuer.UUID, provider cptypes.CloudProviderType, integrationID valuer.UUID) ([]*cloudintegrationtypes.ServiceMetadata, error) {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
@@ -300,7 +301,7 @@ func (module *module) ListServicesMetadata(ctx context.Context, orgID valuer.UUI
return resp, nil
}
func (module *module) GetService(ctx context.Context, orgID valuer.UUID, serviceID cloudintegrationtypes.ServiceID, provider cloudintegrationtypes.CloudProviderType, cloudIntegrationID valuer.UUID) (*cloudintegrationtypes.Service, error) {
func (module *module) GetService(ctx context.Context, orgID valuer.UUID, serviceID cptypes.ServiceID, provider cptypes.CloudProviderType, cloudIntegrationID valuer.UUID) (*cloudintegrationtypes.Service, error) {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
@@ -336,7 +337,7 @@ func (module *module) GetService(ctx context.Context, orgID valuer.UUID, service
return cloudintegrationtypes.NewService(*serviceDefinition, integrationService), nil
}
func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cptypes.CloudProviderType) error {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
@@ -360,7 +361,7 @@ func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, serv
return module.store.CreateService(ctx, cloudintegrationtypes.NewStorableCloudIntegrationService(service, string(configJSON)))
}
func (module *module) UpdateService(ctx context.Context, orgID valuer.UUID, integrationService *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
func (module *module) UpdateService(ctx context.Context, orgID valuer.UUID, integrationService *cloudintegrationtypes.CloudIntegrationService, provider cptypes.CloudProviderType) error {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
@@ -424,7 +425,7 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
stats := make(map[string]any)
// get connected accounts for AWS
awsAccountsCount, err := module.store.CountConnectedAccounts(ctx, orgID, cloudintegrationtypes.CloudProviderTypeAWS)
awsAccountsCount, err := module.store.CountConnectedAccounts(ctx, orgID, cptypes.CloudProviderTypeAWS)
if err == nil {
stats["cloudintegration.aws.connectedaccounts.count"] = awsAccountsCount
}
@@ -436,15 +437,15 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
return stats, nil
}
func (module *module) getCloudProvider(provider cloudintegrationtypes.CloudProviderType) (cloudintegration.CloudProviderModule, error) {
func (module *module) getCloudProvider(provider cptypes.CloudProviderType) (cloudintegration.CloudProviderModule, error) {
if cloudProviderModule, ok := module.cloudProvidersMap[provider]; ok {
return cloudProviderModule, nil
}
return nil, errors.NewInvalidInputf(cloudintegrationtypes.ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
return nil, errors.NewInvalidInputf(cptypes.ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
func (module *module) getOrCreateIngestionKey(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (string, error) {
func (module *module) getOrCreateIngestionKey(ctx context.Context, orgID valuer.UUID, provider cptypes.CloudProviderType) (string, error) {
keyName := cloudintegrationtypes.NewIngestionKeyName(provider)
result, err := module.gateway.SearchIngestionKeysByName(ctx, orgID, keyName, 1, 10)
@@ -465,7 +466,7 @@ func (module *module) getOrCreateIngestionKey(ctx context.Context, orgID valuer.
return createdIngestionKey.Value, nil
}
func (module *module) getOrCreateAPIKey(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (string, error) {
func (module *module) getOrCreateAPIKey(ctx context.Context, orgID valuer.UUID, provider cptypes.CloudProviderType) (string, error) {
domain := module.serviceAccount.Config().Email.Domain
serviceAccount := serviceaccounttypes.NewServiceAccount("integration", domain, serviceaccounttypes.ServiceAccountStatusActive, orgID)
serviceAccount, err := module.serviceAccount.GetOrCreate(ctx, orgID, serviceAccount)

27
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/SigNoz/signoz
go 1.25.7
go 1.25.0
require (
dario.cat/mergo v1.0.2
@@ -15,11 +15,11 @@ 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
github.com/go-openapi/strfmt v0.25.0
github.com/go-playground/validator/v10 v10.27.0
github.com/go-redis/redismock/v9 v9.2.0
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/gojek/heimdall/v7 v7.0.3
@@ -28,8 +28,8 @@ require (
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/huandu/go-sqlbuilder v1.39.1
github.com/jackc/pgx/v5 v5.8.0
github.com/huandu/go-sqlbuilder v1.35.0
github.com/jackc/pgx/v5 v5.7.6
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12
github.com/knadh/koanf v1.5.0
github.com/knadh/koanf/v2 v2.3.2
@@ -39,7 +39,6 @@ require (
github.com/openfga/api/proto v0.0.0-20250909172242-b4b2a12f5c67
github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20251027165255-0f8f255e5f6c
github.com/opentracing/opentracing-go v1.2.0
github.com/perses/perses v0.53.1
github.com/pkg/errors v0.9.1
github.com/prometheus/alertmanager v0.31.0
github.com/prometheus/client_golang v1.23.2
@@ -88,7 +87,7 @@ require (
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/apimachinery v0.35.2
k8s.io/apimachinery v0.35.0
modernc.org/sqlite v1.40.1
)
@@ -113,6 +112,7 @@ 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
@@ -127,14 +127,12 @@ require (
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/hashicorp/go-metrics v0.5.4 // indirect
github.com/huandu/go-clone v1.7.3 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/muhlemmer/gu v0.3.1 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/perses/common v0.30.2 // indirect
github.com/prometheus/client_golang/exp v0.0.0-20260108101519-fb0838f53562 // indirect
github.com/redis/go-redis/extra/rediscmd/v9 v9.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -143,8 +141,6 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 // indirect
github.com/zitadel/oidc/v3 v3.45.4 // indirect
github.com/zitadel/schema v1.3.2 // indirect
go.opentelemetry.io/collector/client v1.50.0 // indirect
go.opentelemetry.io/collector/config/configoptional v1.50.0 // indirect
go.opentelemetry.io/collector/config/configretry v1.50.0 // indirect
@@ -170,7 +166,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 // indirect
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b
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
@@ -214,7 +210,7 @@ require (
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/cel-go v0.27.0 // indirect
github.com/google/cel-go v0.26.1 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
@@ -232,7 +228,7 @@ require (
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/memberlist v0.5.4 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/huandu/xstrings v1.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -304,6 +300,7 @@ require (
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.20.1 // indirect
github.com/stoewer/go-strcase v1.3.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/swaggest/openapi-go v0.2.60
@@ -389,7 +386,7 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9
google.golang.org/grpc v1.80.0 // indirect
gopkg.in/telebot.v3 v3.3.8 // indirect
k8s.io/client-go v0.35.2 // indirect
k8s.io/client-go v0.35.0 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
)

49
go.sum
View File

@@ -489,8 +489,8 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo=
github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw=
github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=
github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -654,15 +654,12 @@ github.com/hetznercloud/hcloud-go/v2 v2.36.0 h1:HlLL/aaVXUulqe+rsjoJmrxKhPi1MflL
github.com/hetznercloud/hcloud-go/v2 v2.36.0/go.mod h1:MnN/QJEa/RYNQiiVoJjNHPntM7Z1wlYPgJ2HA40/cDE=
github.com/hjson/hjson-go/v4 v4.0.0 h1:wlm6IYYqHjOdXH1gHev4VoXCaW20HdQAGCxdOEEg2cs=
github.com/hjson/hjson-go/v4 v4.0.0/go.mod h1:KaYt3bTw3zhBjYqnXkYywcYctk0A2nxeEFTse3rH13E=
github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U=
github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs=
github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs=
github.com/huandu/go-clone v1.7.3 h1:rtQODA+ABThEn6J5LBTppJfKmZy/FwfpMUWa8d01TTQ=
github.com/huandu/go-clone v1.7.3/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE=
github.com/huandu/go-sqlbuilder v1.39.1 h1:uUaj41yLNTQBe7ojNF6Im1RPbHCN4zCjMRySTEC2ooI=
github.com/huandu/go-sqlbuilder v1.39.1/go.mod h1:zdONH67liL+/TvoUMwnZP/sUYGSSvHh9psLe/HpXn8E=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/go-sqlbuilder v1.35.0 h1:ESvxFHN8vxCTudY1Vq63zYpU5yJBESn19sf6k4v2T5Q=
github.com/huandu/go-sqlbuilder v1.35.0/go.mod h1:mS0GAtrtW+XL6nM2/gXHRJax2RwSW1TraavWDFAc1JA=
github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU=
github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc=
github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
@@ -675,8 +672,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
@@ -821,8 +818,6 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM=
github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
@@ -832,11 +827,9 @@ github.com/natefinch/wrap v0.2.0 h1:IXzc/pw5KqxJv55gV0lSOcKHYuEZPGbQrOOXr/bamRk=
github.com/natefinch/wrap v0.2.0/go.mod h1:6gMHlAl12DwYEfKP3TkuykYUfLSEAvHw67itm4/KAS8=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nexucis/lamenv v0.5.2 h1:tK/u3XGhCq9qIoVNcXsK9LZb8fKopm0A5weqSRvHd7M=
github.com/nexucis/lamenv v0.5.2/go.mod h1:HusJm6ltmmT7FMG8A750mOLuME6SHCsr2iFYxp5fFi0=
github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk=
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E=
github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk=
@@ -898,10 +891,6 @@ github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCko
github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perses/common v0.30.2 h1:RAiVxUpX76lTCb4X7pfcXSvYdXQmZwKi4oDKAEO//u0=
github.com/perses/common v0.30.2/go.mod h1:DFtur1QPah2/ChXbKKhw7djYdwNgz27s5fPKpiK0Xao=
github.com/perses/perses v0.53.1 h1:9VY/6p9QWrZwPSV7qiwTMSOsgcB37Lb1AXKT0ORXc6I=
github.com/perses/perses v0.53.1/go.mod h1:ro8fsgBkHYOdrL/MV+fdP9mflKzYCy/+gcbxiaReI/A=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pierrec/lz4/v4 v4.1.23 h1:oJE7T90aYBGtFNrI8+KbETnPymobAhzRrR8Mu8n1yfU=
github.com/pierrec/lz4/v4 v4.1.23/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
@@ -1060,6 +1049,8 @@ github.com/srikanthccv/ClickHouse-go-mock v0.13.0 h1:/b7DQphGkh29ocNtLh4DGmQxQYA
github.com/srikanthccv/ClickHouse-go-mock v0.13.0/go.mod h1:LiiyBUdXNwB/1DE9rgK/8q9qjVYsTzg6WXQ/3mU3TeY=
github.com/stackitcloud/stackit-sdk-go/core v0.21.1 h1:Y/PcAgM7DPYMNqum0MLv4n1mF9ieuevzcCIZYQfm3Ts=
github.com/stackitcloud/stackit-sdk-go/core v0.21.1/go.mod h1:osMglDby4csGZ5sIfhNyYq1bS1TxIdPY88+skE/kkmI=
github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs=
github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
@@ -1159,10 +1150,6 @@ github.com/zeebo/assert v1.3.1 h1:vukIABvugfNMZMQO1ABsyQDJDTVQbn+LWSMy1ol1h6A=
github.com/zeebo/assert v1.3.1/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
github.com/zitadel/oidc/v3 v3.45.4 h1:GKyWaPRVQ8sCu9XgJ3NgNGtG52FzwVJpzXjIUG2+YrI=
github.com/zitadel/oidc/v3 v3.45.4/go.mod h1:XALmFXS9/kSom9B6uWin1yJ2WTI/E4Ti5aXJdewAVEs=
github.com/zitadel/schema v1.3.2 h1:gfJvt7dOMfTmxzhscZ9KkapKo3Nei3B6cAxjav+lyjI=
github.com/zitadel/schema v1.3.2/go.mod h1:IZmdfF9Wu62Zu6tJJTH3UsArevs3Y4smfJIj3L8fzxw=
go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A=
go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/v2 v2.305.4/go.mod h1:Ud+VUwIi9/uQHOMA+4ekToJ12lTxlv0zB/+DHwTGEbU=
@@ -1928,12 +1915,12 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw=
k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60=
k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8=
k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/client-go v0.35.2 h1:YUfPefdGJA4aljDdayAXkc98DnPkIetMl4PrKX97W9o=
k8s.io/client-go v0.35.2/go.mod h1:4QqEwh4oQpeK8AaefZ0jwTFJw/9kIjdQi0jpKeYvz7g=
k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=
k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA=
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=

View File

@@ -0,0 +1,431 @@
// 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

View File

@@ -1,3 +1,7 @@
// Copyright (c) 2026 SigNoz, Inc.
// Copyright 2019 Prometheus Team
// SPDX-License-Identifier: Apache-2.0
package msteamsv2
import (
@@ -27,6 +31,10 @@ const (
colorGrey = "Warning"
)
const (
Integration = "msteamsv2"
)
type Notifier struct {
conf *config.MSTeamsV2Config
titleLink string
@@ -87,7 +95,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 := commoncfg.NewClientFromConfig(*c.HTTPConfig, "msteamsv2", httpOpts...)
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
}

View File

@@ -1,3 +1,7 @@
// Copyright (c) 2026 SigNoz, Inc.
// Copyright 2019 Prometheus Team
// SPDX-License-Identifier: Apache-2.0
package msteamsv2
import (

View File

@@ -0,0 +1,2 @@
my_secret_api_key

View File

@@ -0,0 +1,294 @@
// 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: &notify.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
}

View File

@@ -0,0 +1,337 @@
// 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)
}

View File

@@ -0,0 +1,378 @@
// 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 = &notify.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 = &notify.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
}

View File

@@ -0,0 +1,883 @@
// 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)
})
}
}

View File

@@ -2,8 +2,14 @@ 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"
@@ -11,6 +17,15 @@ 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 {
@@ -31,14 +46,29 @@ func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Templ
)
for _, integration := range upstreamIntegrations {
// skip upstream msteamsv2 integration
if integration.Name() != "msteamsv2" {
// skip upstream integration if we support custom integration for it
if !slices.Contains(customNotifierIntegrations, integration.Name()) {
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", i, c, func(l *slog.Logger) (notify.Notifier, error) {
add(msteamsv2.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
return msteamsv2.New(c, tmpl, `{{ template "msteamsv2.default.titleLink" . }}`, l)
})
}

View File

@@ -0,0 +1,281 @@
// 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: &notify.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
}

View File

@@ -0,0 +1,343 @@
// 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)
}
}

View File

@@ -0,0 +1,140 @@
// 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: &notify.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
}

View File

@@ -0,0 +1,218 @@
// 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)
}
})
}
}

View File

@@ -172,7 +172,7 @@ func Ast(cause error, typ typ) bool {
return t == typ
}
// Ast checks if the provided error matches the specified custom error code.
// Asc checks if the provided error matches the specified custom error code.
func Asc(cause error, code Code) bool {
_, c, _, _, _, _ := Unwrapb(cause)

View File

@@ -6,29 +6,30 @@ import (
"github.com/SigNoz/signoz/pkg/statsreporter"
citypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
cptypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes/cloudprovidertypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Module interface {
GetConnectionCredentials(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType) (*citypes.Credentials, error)
GetConnectionCredentials(ctx context.Context, orgID valuer.UUID, provider cptypes.CloudProviderType) (*citypes.Credentials, error)
CreateAccount(ctx context.Context, account *citypes.Account) error
// GetAccount returns cloud integration account
GetAccount(ctx context.Context, orgID, accountID valuer.UUID, provider citypes.CloudProviderType) (*citypes.Account, error)
GetAccount(ctx context.Context, orgID, accountID valuer.UUID, provider cptypes.CloudProviderType) (*citypes.Account, error)
// GetConnectedAccount returns the account where agent is connected
GetConnectedAccount(ctx context.Context, orgID, accountID valuer.UUID, provider citypes.CloudProviderType) (*citypes.Account, error)
GetConnectedAccount(ctx context.Context, orgID, accountID valuer.UUID, provider cptypes.CloudProviderType) (*citypes.Account, error)
// ListAccounts lists accounts where agent is connected
ListAccounts(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType) ([]*citypes.Account, error)
ListAccounts(ctx context.Context, orgID valuer.UUID, provider cptypes.CloudProviderType) ([]*citypes.Account, error)
// UpdateAccount updates the cloud integration account for a specific organization.
UpdateAccount(ctx context.Context, account *citypes.Account) error
// DisconnectAccount soft deletes/removes a cloud integration account.
DisconnectAccount(ctx context.Context, orgID, accountID valuer.UUID, provider citypes.CloudProviderType) error
DisconnectAccount(ctx context.Context, orgID, accountID valuer.UUID, provider cptypes.CloudProviderType) error
// GetConnectionArtifact returns cloud provider specific connection information,
// client side handles how this information is shown
@@ -36,20 +37,20 @@ type Module interface {
// ListServicesMetadata returns the list of supported services' metadata for a cloud provider with optional filtering for a specific integration
// This just returns a summary of the service and not the whole service definition.
ListServicesMetadata(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType, integrationID valuer.UUID) ([]*citypes.ServiceMetadata, error)
ListServicesMetadata(ctx context.Context, orgID valuer.UUID, provider cptypes.CloudProviderType, integrationID valuer.UUID) ([]*citypes.ServiceMetadata, error)
// GetService returns service definition details for a serviceID. This optionally returns the service config
// for integrationID if provided.
GetService(ctx context.Context, orgID valuer.UUID, serviceID citypes.ServiceID, provider citypes.CloudProviderType, integrationID valuer.UUID) (*citypes.Service, error)
GetService(ctx context.Context, orgID valuer.UUID, serviceID cptypes.ServiceID, provider cptypes.CloudProviderType, integrationID valuer.UUID) (*citypes.Service, error)
// CreateService creates a new service for a cloud integration account.
CreateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService, provider citypes.CloudProviderType) error
CreateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService, provider cptypes.CloudProviderType) error
// UpdateService updates cloud integration service
UpdateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService, provider citypes.CloudProviderType) error
UpdateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService, provider cptypes.CloudProviderType) error
// AgentCheckIn is called by agent to send heartbeat and get latest config in response.
AgentCheckIn(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType, req *citypes.AgentCheckInRequest) (*citypes.AgentCheckInResponse, error)
AgentCheckIn(ctx context.Context, orgID valuer.UUID, provider cptypes.CloudProviderType, req *citypes.AgentCheckInRequest) (*citypes.AgentCheckInResponse, error)
// GetDashboardByID returns dashboard JSON for a given dashboard id.
// this only returns the dashboard when the service (embedded in dashboard id) is enabled
@@ -70,7 +71,7 @@ type CloudProviderModule interface {
ListServiceDefinitions(ctx context.Context) ([]*citypes.ServiceDefinition, error)
// GetServiceDefinition returns the service definition for the given service ID.
GetServiceDefinition(ctx context.Context, serviceID citypes.ServiceID) (*citypes.ServiceDefinition, error)
GetServiceDefinition(ctx context.Context, serviceID cptypes.ServiceID) (*citypes.ServiceDefinition, error)
// BuildIntegrationConfig compiles the provider-specific integration config from the account
// and list of configured services. This is the config returned to the agent on check-in.

View File

@@ -14,6 +14,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
citypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
cptypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes/cloudprovidertypes"
)
const definitionsRoot = "fs/definitions"
@@ -29,7 +30,7 @@ func NewServiceDefinitionStore() citypes.ServiceDefinitionStore {
}
// Get reads and hydrates the service definition for the given provider and service ID.
func (s *definitionStore) Get(ctx context.Context, provider citypes.CloudProviderType, serviceID citypes.ServiceID) (*citypes.ServiceDefinition, error) {
func (s *definitionStore) Get(ctx context.Context, provider cptypes.CloudProviderType, serviceID cptypes.ServiceID) (*citypes.ServiceDefinition, error) {
svcDir := path.Join(definitionsRoot, provider.StringValue(), serviceID.StringValue())
def, err := readServiceDefinition(svcDir)
if err != nil {
@@ -39,7 +40,7 @@ func (s *definitionStore) Get(ctx context.Context, provider citypes.CloudProvide
}
// List reads and hydrates all service definitions for the given provider, sorted by ID.
func (s *definitionStore) List(ctx context.Context, provider citypes.CloudProviderType) ([]*citypes.ServiceDefinition, error) {
func (s *definitionStore) List(ctx context.Context, provider cptypes.CloudProviderType) ([]*citypes.ServiceDefinition, error) {
providerDir := path.Join(definitionsRoot, provider.StringValue())
entries, err := fs.ReadDir(definitionFiles, providerDir)
if err != nil {

View File

@@ -10,6 +10,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
cptypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes/cloudprovidertypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -34,7 +35,7 @@ func (handler *handler) GetConnectionCredentials(rw http.ResponseWriter, r *http
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
provider, err := cptypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
@@ -59,7 +60,7 @@ func (handler *handler) CreateAccount(rw http.ResponseWriter, r *http.Request) {
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
provider, err := cptypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
@@ -104,7 +105,7 @@ func (handler *handler) GetAccount(rw http.ResponseWriter, r *http.Request) {
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
provider, err := cptypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
@@ -135,7 +136,7 @@ func (handler *handler) ListAccounts(rw http.ResponseWriter, r *http.Request) {
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
provider, err := cptypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
@@ -160,7 +161,7 @@ func (handler *handler) UpdateAccount(rw http.ResponseWriter, r *http.Request) {
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
provider, err := cptypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
@@ -214,7 +215,7 @@ func (handler *handler) DisconnectAccount(rw http.ResponseWriter, r *http.Reques
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
provider, err := cptypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
@@ -245,7 +246,7 @@ func (handler *handler) ListServicesMetadata(rw http.ResponseWriter, r *http.Req
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
provider, err := cptypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
@@ -286,13 +287,13 @@ func (handler *handler) GetService(rw http.ResponseWriter, r *http.Request) {
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
provider, err := cptypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
}
serviceID, err := cloudintegrationtypes.NewServiceID(provider, mux.Vars(r)["service_id"])
serviceID, err := cptypes.NewServiceID(provider, mux.Vars(r)["service_id"])
if err != nil {
render.Error(rw, err)
return
@@ -332,13 +333,13 @@ func (handler *handler) UpdateService(rw http.ResponseWriter, r *http.Request) {
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
provider, err := cptypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
}
serviceID, err := cloudintegrationtypes.NewServiceID(provider, mux.Vars(r)["service_id"])
serviceID, err := cptypes.NewServiceID(provider, mux.Vars(r)["service_id"])
if err != nil {
render.Error(rw, err)
return
@@ -402,7 +403,7 @@ func (handler *handler) AgentCheckIn(rw http.ResponseWriter, r *http.Request) {
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
provider, err := cptypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return

View File

@@ -6,6 +6,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
cptypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes/cloudprovidertypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -16,7 +17,7 @@ func NewModule() cloudintegration.Module {
return &module{}
}
func (module *module) GetConnectionCredentials(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.Credentials, error) {
func (module *module) GetConnectionCredentials(ctx context.Context, orgID valuer.UUID, provider cptypes.CloudProviderType) (*cloudintegrationtypes.Credentials, error) {
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "get connection credentials is not supported")
}
@@ -24,16 +25,16 @@ func (module *module) CreateAccount(ctx context.Context, account *cloudintegrati
return errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "create account is not supported")
}
func (module *module) GetAccount(ctx context.Context, orgID valuer.UUID, accountID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.Account, error) {
func (module *module) GetAccount(ctx context.Context, orgID valuer.UUID, accountID valuer.UUID, provider cptypes.CloudProviderType) (*cloudintegrationtypes.Account, error) {
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "get account is not supported")
}
// GetConnectedAccount implements [cloudintegration.Module].
func (module *module) GetConnectedAccount(ctx context.Context, orgID valuer.UUID, accountID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.Account, error) {
func (module *module) GetConnectedAccount(ctx context.Context, orgID valuer.UUID, accountID valuer.UUID, provider cptypes.CloudProviderType) (*cloudintegrationtypes.Account, error) {
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "get connected account is not supported")
}
func (module *module) ListAccounts(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) ([]*cloudintegrationtypes.Account, error) {
func (module *module) ListAccounts(ctx context.Context, orgID valuer.UUID, provider cptypes.CloudProviderType) ([]*cloudintegrationtypes.Account, error) {
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "list accounts is not supported")
}
@@ -41,23 +42,23 @@ func (module *module) UpdateAccount(ctx context.Context, account *cloudintegrati
return errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "update account is not supported")
}
func (module *module) DisconnectAccount(ctx context.Context, orgID valuer.UUID, accountID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) error {
func (module *module) DisconnectAccount(ctx context.Context, orgID valuer.UUID, accountID valuer.UUID, provider cptypes.CloudProviderType) error {
return errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "disconnect account is not supported")
}
func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cptypes.CloudProviderType) error {
return errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "create service is not supported")
}
func (module *module) GetService(ctx context.Context, orgID valuer.UUID, serviceID cloudintegrationtypes.ServiceID, provider cloudintegrationtypes.CloudProviderType, integrationID valuer.UUID) (*cloudintegrationtypes.Service, error) {
func (module *module) GetService(ctx context.Context, orgID valuer.UUID, serviceID cptypes.ServiceID, provider cptypes.CloudProviderType, integrationID valuer.UUID) (*cloudintegrationtypes.Service, error) {
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "get service is not supported")
}
func (module *module) ListServicesMetadata(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, integrationID valuer.UUID) ([]*cloudintegrationtypes.ServiceMetadata, error) {
func (module *module) ListServicesMetadata(ctx context.Context, orgID valuer.UUID, provider cptypes.CloudProviderType, integrationID valuer.UUID) ([]*cloudintegrationtypes.ServiceMetadata, error) {
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "list services metadata is not supported")
}
func (module *module) UpdateService(ctx context.Context, orgID valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
func (module *module) UpdateService(ctx context.Context, orgID valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cptypes.CloudProviderType) error {
return errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "update service is not supported")
}
@@ -65,7 +66,7 @@ func (module *module) GetConnectionArtifact(ctx context.Context, account *cloudi
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "get connection artifact is not supported")
}
func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, req *cloudintegrationtypes.AgentCheckInRequest) (*cloudintegrationtypes.AgentCheckInResponse, error) {
func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provider cptypes.CloudProviderType, req *cloudintegrationtypes.AgentCheckInRequest) (*cloudintegrationtypes.AgentCheckInResponse, error) {
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "agent check-in is not supported")
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
cptypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes/cloudprovidertypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -17,7 +18,7 @@ func NewStore(sqlStore sqlstore.SQLStore) cloudintegrationtypes.Store {
return &store{store: sqlStore}
}
func (store *store) GetAccountByID(ctx context.Context, orgID, id valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.StorableCloudIntegration, error) {
func (store *store) GetAccountByID(ctx context.Context, orgID, id valuer.UUID, provider cptypes.CloudProviderType) (*cloudintegrationtypes.StorableCloudIntegration, error) {
account := new(cloudintegrationtypes.StorableCloudIntegration)
err := store.
store.
@@ -34,7 +35,7 @@ func (store *store) GetAccountByID(ctx context.Context, orgID, id valuer.UUID, p
return account, nil
}
func (store *store) GetConnectedAccount(ctx context.Context, orgID, id valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.StorableCloudIntegration, error) {
func (store *store) GetConnectedAccount(ctx context.Context, orgID, id valuer.UUID, provider cptypes.CloudProviderType) (*cloudintegrationtypes.StorableCloudIntegration, error) {
account := new(cloudintegrationtypes.StorableCloudIntegration)
err := store.
store.
@@ -54,7 +55,7 @@ func (store *store) GetConnectedAccount(ctx context.Context, orgID, id valuer.UU
return account, nil
}
func (store *store) GetConnectedAccountByProviderAccountID(ctx context.Context, orgID valuer.UUID, providerAccountID string, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.StorableCloudIntegration, error) {
func (store *store) GetConnectedAccountByProviderAccountID(ctx context.Context, orgID valuer.UUID, providerAccountID string, provider cptypes.CloudProviderType) (*cloudintegrationtypes.StorableCloudIntegration, error) {
account := new(cloudintegrationtypes.StorableCloudIntegration)
err := store.
store.
@@ -73,7 +74,7 @@ func (store *store) GetConnectedAccountByProviderAccountID(ctx context.Context,
return account, nil
}
func (store *store) ListConnectedAccounts(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) ([]*cloudintegrationtypes.StorableCloudIntegration, error) {
func (store *store) ListConnectedAccounts(ctx context.Context, orgID valuer.UUID, provider cptypes.CloudProviderType) ([]*cloudintegrationtypes.StorableCloudIntegration, error) {
var accounts []*cloudintegrationtypes.StorableCloudIntegration
err := store.
store.
@@ -93,7 +94,7 @@ func (store *store) ListConnectedAccounts(ctx context.Context, orgID valuer.UUID
return accounts, nil
}
func (store *store) CountConnectedAccounts(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (int, error) {
func (store *store) CountConnectedAccounts(ctx context.Context, orgID valuer.UUID, provider cptypes.CloudProviderType) (int, error) {
storable := new(cloudintegrationtypes.StorableCloudIntegration)
count, err := store.
store.
@@ -141,7 +142,7 @@ func (store *store) UpdateAccount(ctx context.Context, account *cloudintegration
return err
}
func (store *store) RemoveAccount(ctx context.Context, orgID, id valuer.UUID, provider cloudintegrationtypes.CloudProviderType) error {
func (store *store) RemoveAccount(ctx context.Context, orgID, id valuer.UUID, provider cptypes.CloudProviderType) error {
_, err := store.
store.
BunDBCtx(ctx).
@@ -155,7 +156,7 @@ func (store *store) RemoveAccount(ctx context.Context, orgID, id valuer.UUID, pr
return err
}
func (store *store) GetServiceByServiceID(ctx context.Context, cloudIntegrationID valuer.UUID, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.StorableCloudIntegrationService, error) {
func (store *store) GetServiceByServiceID(ctx context.Context, cloudIntegrationID valuer.UUID, serviceID cptypes.ServiceID) (*cloudintegrationtypes.StorableCloudIntegrationService, error) {
service := new(cloudintegrationtypes.StorableCloudIntegrationService)
err := store.
store.

View File

@@ -1122,7 +1122,7 @@ func (m *module) computeTimeseriesTreemap(ctx context.Context, req *metricsexplo
)
finalSB.From("__metric_totals mt")
finalSB.Join("__total_time_series tts", "1=1")
finalSB.OrderByDesc("percentage")
finalSB.OrderBy("percentage").Desc()
finalSB.Limit(req.Limit)
query, args := finalSB.BuildWithFlavor(sqlbuilder.ClickHouse)

View File

@@ -7,6 +7,8 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
cptypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes/cloudprovidertypes"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes/cloudprovidertypes/awstypes"
"github.com/SigNoz/signoz/pkg/types/zeustypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -14,12 +16,12 @@ import (
type Account struct {
types.Identifiable
types.TimeAuditable
ProviderAccountID *string `json:"providerAccountId" required:"true" nullable:"true"`
Provider CloudProviderType `json:"provider" required:"true"`
RemovedAt *time.Time `json:"removedAt" required:"true" nullable:"true"`
AgentReport *AgentReport `json:"agentReport" required:"true" nullable:"true"`
OrgID valuer.UUID `json:"orgId" required:"true"`
Config *AccountConfig `json:"config" required:"true" nullable:"false"`
ProviderAccountID *string `json:"providerAccountId" required:"true" nullable:"true"`
Provider cptypes.CloudProviderType `json:"provider" required:"true"`
RemovedAt *time.Time `json:"removedAt" required:"true" nullable:"true"`
AgentReport *AgentReport `json:"agentReport" required:"true" nullable:"true"`
OrgID valuer.UUID `json:"orgId" required:"true"`
Config *AccountConfig `json:"config" required:"true" nullable:"false"`
}
// AgentReport represents heartbeats sent by the agent.
@@ -30,11 +32,7 @@ type AgentReport struct {
type AccountConfig struct {
// required till new providers are added
AWS *AWSAccountConfig `json:"aws" required:"true" nullable:"false"`
}
type AWSAccountConfig struct {
Regions []string `json:"regions" required:"true" nullable:"false"`
AWS *awstypes.AWSAccountConfig `json:"aws" required:"true" nullable:"false"`
}
type PostableAccount struct {
@@ -45,7 +43,7 @@ type PostableAccount struct {
type PostableAccountConfig struct {
// as agent version is common for all providers, we can keep it at top level of this struct
AgentVersion string
Aws *AWSPostableAccountConfig `json:"aws" required:"true" nullable:"false"`
Aws *awstypes.AWSPostableAccountConfig `json:"aws" required:"true" nullable:"false"`
}
type Credentials struct {
@@ -55,11 +53,6 @@ type Credentials struct {
IngestionKey string `json:"ingestionKey" required:"true"`
}
type AWSPostableAccountConfig struct {
DeploymentRegion string `json:"deploymentRegion" required:"true"`
Regions []string `json:"regions" required:"true" nullable:"false"`
}
type GettableAccountWithConnectionArtifact struct {
ID valuer.UUID `json:"id" required:"true"`
ConnectionArtifact *ConnectionArtifact `json:"connectionArtifact" required:"true"`
@@ -67,11 +60,7 @@ type GettableAccountWithConnectionArtifact struct {
type ConnectionArtifact struct {
// required till new providers are added
Aws *AWSConnectionArtifact `json:"aws" required:"true" nullable:"false"`
}
type AWSConnectionArtifact struct {
ConnectionURL string `json:"connectionUrl" required:"true"`
AWS *awstypes.AWSConnectionArtifact `json:"aws" required:"true" nullable:"false"`
}
type GetConnectionArtifactRequest = PostableAccount
@@ -84,7 +73,7 @@ type UpdatableAccount struct {
Config *AccountConfig `json:"config" required:"true" nullable:"false"`
}
func NewAccount(orgID valuer.UUID, provider CloudProviderType, config *AccountConfig) *Account {
func NewAccount(orgID valuer.UUID, provider cptypes.CloudProviderType, config *AccountConfig) *Account {
return &Account{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
@@ -125,8 +114,8 @@ func NewAccountFromStorable(storableAccount *StorableCloudIntegration) (*Account
}
switch storableAccount.Provider {
case CloudProviderTypeAWS:
awsConfig := new(AWSAccountConfig)
case cptypes.CloudProviderTypeAWS:
awsConfig := new(awstypes.AWSAccountConfig)
err := json.Unmarshal([]byte(storableAccount.Config), awsConfig)
if err != nil {
return nil, err
@@ -170,14 +159,14 @@ func NewGettableAccounts(accounts []*Account) *GettableAccounts {
}
}
func NewAccountConfigFromPostable(provider CloudProviderType, config *PostableAccountConfig) (*AccountConfig, error) {
func NewAccountConfigFromPostable(provider cptypes.CloudProviderType, config *PostableAccountConfig) (*AccountConfig, error) {
switch provider {
case CloudProviderTypeAWS:
case cptypes.CloudProviderTypeAWS:
if config.Aws == nil {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "AWS config can not be nil for AWS provider")
}
if err := validateAWSRegion(config.Aws.DeploymentRegion); err != nil {
if err := cptypes.NewAWSRegion(config.Aws.DeploymentRegion); err != nil {
return nil, err
}
@@ -186,20 +175,20 @@ func NewAccountConfigFromPostable(provider CloudProviderType, config *PostableAc
}
for _, region := range config.Aws.Regions {
if err := validateAWSRegion(region); err != nil {
if err := cptypes.NewAWSRegion(region); err != nil {
return nil, err
}
}
return &AccountConfig{AWS: &AWSAccountConfig{Regions: config.Aws.Regions}}, nil
return &AccountConfig{AWS: &awstypes.AWSAccountConfig{Regions: config.Aws.Regions}}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
return nil, errors.NewInvalidInputf(cptypes.ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
}
func NewAccountConfigFromUpdatable(provider CloudProviderType, config *UpdatableAccount) (*AccountConfig, error) {
func NewAccountConfigFromUpdatable(provider cptypes.CloudProviderType, config *UpdatableAccount) (*AccountConfig, error) {
switch provider {
case CloudProviderTypeAWS:
case cptypes.CloudProviderTypeAWS:
if config.Config.AWS == nil {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "AWS config can not be nil for AWS provider")
}
@@ -209,14 +198,14 @@ func NewAccountConfigFromUpdatable(provider CloudProviderType, config *Updatable
}
for _, region := range config.Config.AWS.Regions {
if err := validateAWSRegion(region); err != nil {
if err := cptypes.NewAWSRegion(region); err != nil {
return nil, err
}
}
return &AccountConfig{AWS: &AWSAccountConfig{Regions: config.Config.AWS.Regions}}, nil
return &AccountConfig{AWS: &awstypes.AWSAccountConfig{Regions: config.Config.AWS.Regions}}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
return nil, errors.NewInvalidInputf(cptypes.ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
}
@@ -235,7 +224,7 @@ func GetSigNozAPIURLFromDeployment(deployment *zeustypes.GettableDeployment) (st
return fmt.Sprintf("https://%s.%s", deployment.Name, deployment.Cluster.Region.DNS), nil
}
func (account *Account) Update(provider CloudProviderType, config *AccountConfig) error {
func (account *Account) Update(provider cptypes.CloudProviderType, config *AccountConfig) error {
account.Config = config
account.UpdatedAt = time.Now()
return nil
@@ -303,3 +292,7 @@ func (config *AccountConfig) ToJSON() ([]byte, error) {
return nil, errors.NewInternalf(errors.CodeInternal, "no provider account config found")
}
func NewIngestionKeyName(provider cptypes.CloudProviderType) string {
return fmt.Sprintf("%s-integration", provider.StringValue())
}

View File

@@ -5,6 +5,8 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
cptypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes/cloudprovidertypes"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes/cloudprovidertypes/awstypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -43,24 +45,19 @@ type GettableAgentCheckIn struct {
// IntegrationConfig older integration config struct for backward compatibility,
// this will be eventually removed once agents are updated to use new struct.
type IntegrationConfig struct {
EnabledRegions []string `json:"enabled_regions" required:"true" nullable:"false"` // backward compatible
Telemetry *OldAWSCollectionStrategy `json:"telemetry" required:"true" nullable:"false"` // backward compatible
EnabledRegions []string `json:"enabled_regions" required:"true" nullable:"false"` // backward compatible
Telemetry *awstypes.OldAWSCollectionStrategy `json:"telemetry" required:"true" nullable:"false"` // backward compatible
}
type ProviderIntegrationConfig struct {
AWS *AWSIntegrationConfig `json:"aws" required:"true" nullable:"false"`
}
type AWSIntegrationConfig struct {
EnabledRegions []string `json:"enabledRegions" required:"true" nullable:"false"`
TelemetryCollectionStrategy *AWSTelemetryCollectionStrategy `json:"telemetryCollectionStrategy" required:"true" nullable:"false"`
AWS *awstypes.AWSIntegrationConfig `json:"aws" required:"true" nullable:"false"`
}
// NewGettableAgentCheckIn constructs a backward-compatible response from an AgentCheckInResponse.
// It populates the old snake_case fields (account_id, cloud_account_id, integration_config, removed_at)
// from the new camelCase fields so older agents continue to work unchanged.
// The provider parameter controls which provider-specific block is mapped into the legacy integration_config.
func NewGettableAgentCheckIn(provider CloudProviderType, resp *AgentCheckInResponse) *GettableAgentCheckIn {
func NewGettableAgentCheckIn(provider cptypes.CloudProviderType, resp *AgentCheckInResponse) *GettableAgentCheckIn {
gettable := &GettableAgentCheckIn{
AccountID: resp.CloudIntegrationID,
CloudAccountID: resp.ProviderAccountID,
@@ -69,7 +66,7 @@ func NewGettableAgentCheckIn(provider CloudProviderType, resp *AgentCheckInRespo
}
switch provider {
case CloudProviderTypeAWS:
case cptypes.CloudProviderTypeAWS:
gettable.OlderIntegrationConfig = awsOlderIntegrationConfig(resp.IntegrationConfig)
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/uptrace/bun"
"github.com/SigNoz/signoz/pkg/types"
cptypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes/cloudprovidertypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -32,12 +33,12 @@ type StorableCloudIntegration struct {
types.Identifiable
types.TimeAuditable
Provider CloudProviderType `bun:"provider,type:text"`
Config string `bun:"config,type:text"` // Config is provider-specific data in JSON string format
AccountID *string `bun:"account_id,type:text"`
LastAgentReport *StorableAgentReport `bun:"last_agent_report,type:text"`
RemovedAt *time.Time `bun:"removed_at,type:timestamp,nullzero"`
OrgID valuer.UUID `bun:"org_id,type:text"`
Provider cptypes.CloudProviderType `bun:"provider,type:text"`
Config string `bun:"config,type:text"` // Config is provider-specific data in JSON string format
AccountID *string `bun:"account_id,type:text"`
LastAgentReport *StorableAgentReport `bun:"last_agent_report,type:text"`
RemovedAt *time.Time `bun:"removed_at,type:timestamp,nullzero"`
OrgID valuer.UUID `bun:"org_id,type:text"`
}
// StorableAgentReport represents the last heartbeat and arbitrary data sent by the agent
@@ -53,9 +54,9 @@ type StorableCloudIntegrationService struct {
types.Identifiable
types.TimeAuditable
Type ServiceID `bun:"type,type:text,notnull"` // Keeping Type field name as is, but it is a service id
Config string `bun:"config,type:text"` // Config is cloud provider's service specific data in JSON string format
CloudIntegrationID valuer.UUID `bun:"cloud_integration_id,type:text"`
Type cptypes.ServiceID `bun:"type,type:text,notnull"` // Keeping Type field name as is, but it is a service id
Config string `bun:"config,type:text"` // Config is cloud provider's service specific data in JSON string format
CloudIntegrationID valuer.UUID `bun:"cloud_integration_id,type:text"`
}
// Following Service config types are only internally used to store service config in DB and use JSON snake case keys for backward compatibility.
@@ -153,9 +154,9 @@ func (account *StorableCloudIntegration) Update(providerAccountID *string, agent
}
// following StorableServiceConfig related functions are helper functions to convert between JSON string and ServiceConfig domain struct.
func newStorableServiceConfig(provider CloudProviderType, serviceID ServiceID, serviceConfig *ServiceConfig, supportedSignals *SupportedSignals) (*StorableServiceConfig, error) {
func newStorableServiceConfig(provider cptypes.CloudProviderType, serviceID cptypes.ServiceID, serviceConfig *ServiceConfig, supportedSignals *SupportedSignals) (*StorableServiceConfig, error) {
switch provider {
case CloudProviderTypeAWS:
case cptypes.CloudProviderTypeAWS:
storableAWSServiceConfig := new(StorableAWSServiceConfig)
if supportedSignals.Logs {
@@ -167,7 +168,7 @@ func newStorableServiceConfig(provider CloudProviderType, serviceID ServiceID, s
Enabled: serviceConfig.AWS.Logs.Enabled,
}
if serviceID == AWSServiceS3Sync {
if serviceID == cptypes.AWSServiceS3Sync {
if serviceConfig.AWS.Logs.S3Buckets == nil {
return nil, errors.NewInvalidInputf(ErrCodeCloudIntegrationInvalidConfig, "s3 buckets config is required for AWS S3 Sync service")
}
@@ -188,13 +189,13 @@ func newStorableServiceConfig(provider CloudProviderType, serviceID ServiceID, s
return &StorableServiceConfig{AWS: storableAWSServiceConfig}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
return nil, errors.NewInvalidInputf(cptypes.ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
}
func newStorableServiceConfigFromJSON(provider CloudProviderType, jsonStr string) (*StorableServiceConfig, error) {
func newStorableServiceConfigFromJSON(provider cptypes.CloudProviderType, jsonStr string) (*StorableServiceConfig, error) {
switch provider {
case CloudProviderTypeAWS:
case cptypes.CloudProviderTypeAWS:
awsConfig := new(StorableAWSServiceConfig)
err := json.Unmarshal([]byte(jsonStr), awsConfig)
if err != nil {
@@ -202,13 +203,13 @@ func newStorableServiceConfigFromJSON(provider CloudProviderType, jsonStr string
}
return &StorableServiceConfig{AWS: awsConfig}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
return nil, errors.NewInvalidInputf(cptypes.ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
}
func (config *StorableServiceConfig) toJSON(provider CloudProviderType) ([]byte, error) {
func (config *StorableServiceConfig) toJSON(provider cptypes.CloudProviderType) ([]byte, error) {
switch provider {
case CloudProviderTypeAWS:
case cptypes.CloudProviderTypeAWS:
jsonBytes, err := json.Marshal(config.AWS)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't serialize AWS service config to JSON")
@@ -216,6 +217,6 @@ func (config *StorableServiceConfig) toJSON(provider CloudProviderType) ([]byte,
return jsonBytes, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
return nil, errors.NewInvalidInputf(cptypes.ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
}

View File

@@ -0,0 +1,117 @@
package awstypes
import (
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
CloudFormationQuickCreateBaseURL = valuer.NewString("https://%s.console.aws.amazon.com/cloudformation/home")
AgentCloudFormationTemplateS3Path = valuer.NewString("https://signoz-integrations.s3.us-east-1.amazonaws.com/aws-quickcreate-template-%s.json")
AgentCloudFormationBaseStackName = valuer.NewString("signoz-integration")
)
type AWSPostableAccountConfig struct {
DeploymentRegion string `json:"deploymentRegion" required:"true"`
Regions []string `json:"regions" required:"true" nullable:"false"`
}
type AWSConnectionArtifact struct {
ConnectionURL string `json:"connectionUrl" required:"true"`
}
type AWSAccountConfig struct {
Regions []string `json:"regions" required:"true" nullable:"false"`
}
// OldAWSCollectionStrategy is the backward-compatible snake_case form of AWSCollectionStrategy,
// used in the legacy integration_config response field for older agents.
type OldAWSCollectionStrategy struct {
Provider string `json:"provider"`
Metrics *OldAWSMetricsStrategy `json:"aws_metrics,omitempty"`
Logs *OldAWSLogsStrategy `json:"aws_logs,omitempty"`
S3Buckets map[string][]string `json:"s3_buckets,omitempty"`
}
// OldAWSMetricsStrategy is the snake_case form of AWSMetricsStrategy for older agents.
type OldAWSMetricsStrategy struct {
StreamFilters []struct {
Namespace string `json:"Namespace"`
MetricNames []string `json:"MetricNames,omitempty"`
} `json:"cloudwatch_metric_stream_filters"`
}
// OldAWSLogsStrategy is the snake_case form of AWSLogsStrategy for older agents.
type OldAWSLogsStrategy struct {
Subscriptions []struct {
LogGroupNamePrefix string `json:"log_group_name_prefix"`
FilterPattern string `json:"filter_pattern"`
} `json:"cloudwatch_logs_subscriptions"`
}
type AWSIntegrationConfig struct {
EnabledRegions []string `json:"enabledRegions" required:"true" nullable:"false"`
TelemetryCollectionStrategy *AWSTelemetryCollectionStrategy `json:"telemetryCollectionStrategy" required:"true" nullable:"false"`
}
// AWSTelemetryCollectionStrategy represents signal collection strategy for AWS services.
type AWSTelemetryCollectionStrategy struct {
Metrics *AWSMetricsCollectionStrategy `json:"metrics,omitempty" required:"false" nullable:"false"`
Logs *AWSLogsCollectionStrategy `json:"logs,omitempty" required:"false" nullable:"false"`
S3Buckets map[string][]string `json:"s3Buckets,omitempty" required:"false"` // Only available in S3 Sync Service Type in AWS
}
// AWSMetricsCollectionStrategy represents metrics collection strategy for AWS services.
type AWSMetricsCollectionStrategy struct {
// to be used as https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudwatch-metricstream.html#cfn-cloudwatch-metricstream-includefilters
StreamFilters []*AWSCloudWatchMetricStreamFilter `json:"streamFilters" required:"true" nullable:"false"`
}
type AWSCloudWatchMetricStreamFilter struct {
// https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-cloudwatch-metricstream-metricstreamfilter.html
Namespace string `json:"namespace" required:"true"`
MetricNames []string `json:"metricNames,omitempty" required:"false" nullable:"false"`
}
// AWSLogsCollectionStrategy represents logs collection strategy for AWS services.
type AWSLogsCollectionStrategy struct {
Subscriptions []*AWSCloudWatchLogsSubscription `json:"subscriptions" required:"true" nullable:"false"`
}
type AWSCloudWatchLogsSubscription struct {
// subscribe to all logs groups with specified prefix.
// eg: `/aws/rds/`
LogGroupNamePrefix string `json:"logGroupNamePrefix" required:"true"`
// https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html
// "" implies no filtering is required
FilterPattern string `json:"filterPattern" required:"true"`
}
type AWSServiceConfig struct {
Logs *AWSServiceLogsConfig `json:"logs"`
Metrics *AWSServiceMetricsConfig `json:"metrics"`
}
// AWSServiceLogsConfig is AWS specific logs config for a service
// NOTE: the JSON keys are snake case for backward compatibility with existing agents.
type AWSServiceLogsConfig struct {
Enabled bool `json:"enabled"`
S3Buckets map[string][]string `json:"s3Buckets,omitempty"`
}
type AWSServiceMetricsConfig struct {
Enabled bool `json:"enabled"`
}
func NewConnectionArtifact(connectionURL string) *AWSConnectionArtifact {
return &AWSConnectionArtifact{
ConnectionURL: connectionURL,
}
}
func NewIntegrationConfig(enabledRegions []string, telemetryCollectionStrategy *AWSTelemetryCollectionStrategy) *AWSIntegrationConfig {
return &AWSIntegrationConfig{
EnabledRegions: enabledRegions,
TelemetryCollectionStrategy: telemetryCollectionStrategy,
}
}

View File

@@ -1,13 +1,10 @@
package cloudintegrationtypes
package cloudprovidertypes
import (
"fmt"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
// CloudProviderType type alias.
type CloudProviderType struct{ valuer.String }
var (
@@ -15,16 +12,9 @@ var (
CloudProviderTypeAWS = CloudProviderType{valuer.NewString("aws")}
CloudProviderTypeAzure = CloudProviderType{valuer.NewString("azure")}
// errors.
ErrCodeCloudProviderInvalidInput = errors.MustNewCode("cloud_integration_invalid_cloud_provider")
CloudFormationQuickCreateBaseURL = valuer.NewString("https://%s.console.aws.amazon.com/cloudformation/home")
AgentCloudFormationTemplateS3Path = valuer.NewString("https://signoz-integrations.s3.us-east-1.amazonaws.com/aws-quickcreate-template-%s.json")
AgentCloudFormationBaseStackName = valuer.NewString("signoz-integration")
)
// NewCloudProvider returns a new CloudProviderType from a string.
// It validates the input and returns an error if the input is not valid cloud provider.
func NewCloudProvider(provider string) (CloudProviderType, error) {
switch provider {
case CloudProviderTypeAWS.StringValue():
@@ -35,7 +25,3 @@ func NewCloudProvider(provider string) (CloudProviderType, error) {
return CloudProviderType{}, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider)
}
}
func NewIngestionKeyName(provider CloudProviderType) string {
return fmt.Sprintf("%s-integration", provider.StringValue())
}

View File

@@ -0,0 +1,75 @@
package cloudprovidertypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
type CloudProviderRegion struct{ valuer.String }
var ErrCodeInvalidServiceID = errors.MustNewCode("invalid_service_id")
var (
// AWS regions.
AWSRegionAFSouth1 = CloudProviderRegion{valuer.NewString("af-south-1")} // Africa (Cape Town).
AWSRegionAPEast1 = CloudProviderRegion{valuer.NewString("ap-east-1")} // Asia Pacific (Hong Kong).
AWSRegionAPNortheast1 = CloudProviderRegion{valuer.NewString("ap-northeast-1")} // Asia Pacific (Tokyo).
AWSRegionAPNortheast2 = CloudProviderRegion{valuer.NewString("ap-northeast-2")} // Asia Pacific (Seoul).
AWSRegionAPNortheast3 = CloudProviderRegion{valuer.NewString("ap-northeast-3")} // Asia Pacific (Osaka).
AWSRegionAPSouth1 = CloudProviderRegion{valuer.NewString("ap-south-1")} // Asia Pacific (Mumbai).
AWSRegionAPSouth2 = CloudProviderRegion{valuer.NewString("ap-south-2")} // Asia Pacific (Hyderabad).
AWSRegionAPSoutheast1 = CloudProviderRegion{valuer.NewString("ap-southeast-1")} // Asia Pacific (Singapore).
AWSRegionAPSoutheast2 = CloudProviderRegion{valuer.NewString("ap-southeast-2")} // Asia Pacific (Sydney).
AWSRegionAPSoutheast3 = CloudProviderRegion{valuer.NewString("ap-southeast-3")} // Asia Pacific (Jakarta).
AWSRegionAPSoutheast4 = CloudProviderRegion{valuer.NewString("ap-southeast-4")} // Asia Pacific (Melbourne).
AWSRegionCACentral1 = CloudProviderRegion{valuer.NewString("ca-central-1")} // Canada (Central).
AWSRegionCAWest1 = CloudProviderRegion{valuer.NewString("ca-west-1")} // Canada West (Calgary).
AWSRegionEUCentral1 = CloudProviderRegion{valuer.NewString("eu-central-1")} // Europe (Frankfurt).
AWSRegionEUCentral2 = CloudProviderRegion{valuer.NewString("eu-central-2")} // Europe (Zurich).
AWSRegionEUNorth1 = CloudProviderRegion{valuer.NewString("eu-north-1")} // Europe (Stockholm).
AWSRegionEUSouth1 = CloudProviderRegion{valuer.NewString("eu-south-1")} // Europe (Milan).
AWSRegionEUSouth2 = CloudProviderRegion{valuer.NewString("eu-south-2")} // Europe (Spain).
AWSRegionEUWest1 = CloudProviderRegion{valuer.NewString("eu-west-1")} // Europe (Ireland).
AWSRegionEUWest2 = CloudProviderRegion{valuer.NewString("eu-west-2")} // Europe (London).
AWSRegionEUWest3 = CloudProviderRegion{valuer.NewString("eu-west-3")} // Europe (Paris).
AWSRegionILCentral1 = CloudProviderRegion{valuer.NewString("il-central-1")} // Israel (Tel Aviv).
AWSRegionMECentral1 = CloudProviderRegion{valuer.NewString("me-central-1")} // Middle East (UAE).
AWSRegionMESouth1 = CloudProviderRegion{valuer.NewString("me-south-1")} // Middle East (Bahrain).
AWSRegionSAEast1 = CloudProviderRegion{valuer.NewString("sa-east-1")} // South America (Sao Paulo).
AWSRegionUSEast1 = CloudProviderRegion{valuer.NewString("us-east-1")} // US East (N. Virginia).
AWSRegionUSEast2 = CloudProviderRegion{valuer.NewString("us-east-2")} // US East (Ohio).
AWSRegionUSWest1 = CloudProviderRegion{valuer.NewString("us-west-1")} // US West (N. California).
AWSRegionUSWest2 = CloudProviderRegion{valuer.NewString("us-west-2")} // US West (Oregon).
)
func Enum() []any {
return []any{
AWSRegionAFSouth1, AWSRegionAPEast1, AWSRegionAPNortheast1, AWSRegionAPNortheast2, AWSRegionAPNortheast3,
AWSRegionAPSouth1, AWSRegionAPSouth2, AWSRegionAPSoutheast1, AWSRegionAPSoutheast2, AWSRegionAPSoutheast3,
AWSRegionAPSoutheast4, AWSRegionCACentral1, AWSRegionCAWest1, AWSRegionEUCentral1, AWSRegionEUCentral2, AWSRegionEUNorth1,
AWSRegionEUSouth1, AWSRegionEUSouth2, AWSRegionEUWest1, AWSRegionEUWest2, AWSRegionEUWest3,
AWSRegionILCentral1, AWSRegionMECentral1, AWSRegionMESouth1, AWSRegionSAEast1, AWSRegionUSEast1, AWSRegionUSEast2,
AWSRegionUSWest1, AWSRegionUSWest2,
}
}
var SupportedRegions = map[CloudProviderType][]CloudProviderRegion{
CloudProviderTypeAWS: {
AWSRegionAFSouth1, AWSRegionAPEast1, AWSRegionAPNortheast1, AWSRegionAPNortheast2, AWSRegionAPNortheast3,
AWSRegionAPSouth1, AWSRegionAPSouth2, AWSRegionAPSoutheast1, AWSRegionAPSoutheast2, AWSRegionAPSoutheast3,
AWSRegionAPSoutheast4, AWSRegionCACentral1, AWSRegionCAWest1, AWSRegionEUCentral1, AWSRegionEUCentral2, AWSRegionEUNorth1,
AWSRegionEUSouth1, AWSRegionEUSouth2, AWSRegionEUWest1, AWSRegionEUWest2, AWSRegionEUWest3,
AWSRegionILCentral1, AWSRegionMECentral1, AWSRegionMESouth1, AWSRegionSAEast1, AWSRegionUSEast1, AWSRegionUSEast2,
AWSRegionUSWest1, AWSRegionUSWest2,
},
}
func NewAWSRegion(region string) error {
for _, r := range SupportedRegions[CloudProviderTypeAWS] {
if r.StringValue() == region {
return nil
}
}
return errors.NewInvalidInputf(ErrCodeInvalidCloudRegion, "invalid AWS region: %s", region)
}

View File

@@ -1,4 +1,4 @@
package cloudintegrationtypes
package cloudprovidertypes
import (
"github.com/SigNoz/signoz/pkg/errors"
@@ -8,6 +8,8 @@ import (
type ServiceID struct{ valuer.String }
var (
ErrCodeInvalidCloudRegion = errors.MustNewCode("invalid_cloud_region")
AWSServiceALB = ServiceID{valuer.NewString("alb")}
AWSServiceAPIGateway = ServiceID{valuer.NewString("api-gateway")}
AWSServiceDynamoDB = ServiceID{valuer.NewString("dynamodb")}
@@ -60,16 +62,11 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
},
}
// NewServiceID returns a new ServiceID from a string, validated against the supported services for the given cloud provider.
func NewServiceID(provider CloudProviderType, service string) (ServiceID, error) {
services, ok := SupportedServices[provider]
if !ok {
return ServiceID{}, errors.NewInvalidInputf(ErrCodeInvalidServiceID, "no services defined for cloud provider: %s", provider)
}
for _, s := range services {
for _, s := range SupportedServices[provider] {
if s.StringValue() == service {
return s, nil
}
}
return ServiceID{}, errors.NewInvalidInputf(ErrCodeInvalidServiceID, "invalid service id %q for cloud provider %s", service, provider)
return ServiceID{}, errors.NewInvalidInputf(ErrCodeInvalidServiceID, "invalid service id %q for %s cloud provider", service, provider.StringValue())
}

View File

@@ -1,44 +1,5 @@
package cloudintegrationtypes
import (
"github.com/SigNoz/signoz/pkg/errors"
)
var ErrCodeInvalidCloudRegion = errors.MustNewCode("invalid_cloud_region")
// List of all valid cloud regions on Amazon Web Services.
var ValidAWSRegions = map[string]struct{}{
"af-south-1": {}, // Africa (Cape Town).
"ap-east-1": {}, // Asia Pacific (Hong Kong).
"ap-northeast-1": {}, // Asia Pacific (Tokyo).
"ap-northeast-2": {}, // Asia Pacific (Seoul).
"ap-northeast-3": {}, // Asia Pacific (Osaka).
"ap-south-1": {}, // Asia Pacific (Mumbai).
"ap-south-2": {}, // Asia Pacific (Hyderabad).
"ap-southeast-1": {}, // Asia Pacific (Singapore).
"ap-southeast-2": {}, // Asia Pacific (Sydney).
"ap-southeast-3": {}, // Asia Pacific (Jakarta).
"ap-southeast-4": {}, // Asia Pacific (Melbourne).
"ca-central-1": {}, // Canada (Central).
"ca-west-1": {}, // Canada West (Calgary).
"eu-central-1": {}, // Europe (Frankfurt).
"eu-central-2": {}, // Europe (Zurich).
"eu-north-1": {}, // Europe (Stockholm).
"eu-south-1": {}, // Europe (Milan).
"eu-south-2": {}, // Europe (Spain).
"eu-west-1": {}, // Europe (Ireland).
"eu-west-2": {}, // Europe (London).
"eu-west-3": {}, // Europe (Paris).
"il-central-1": {}, // Israel (Tel Aviv).
"me-central-1": {}, // Middle East (UAE).
"me-south-1": {}, // Middle East (Bahrain).
"sa-east-1": {}, // South America (Sao Paulo).
"us-east-1": {}, // US East (N. Virginia).
"us-east-2": {}, // US East (Ohio).
"us-west-1": {}, // US West (N. California).
"us-west-2": {}, // US West (Oregon).
}
// List of all valid cloud regions for Microsoft Azure.
var ValidAzureRegions = map[string]struct{}{
"australiacentral": {}, // Australia Central
@@ -98,11 +59,3 @@ var ValidAzureRegions = map[string]struct{}{
"westus2": {}, // West US 2
"westus3": {}, // West US 3
}
func validateAWSRegion(region string) error {
_, ok := ValidAWSRegions[region]
if !ok {
return errors.NewInvalidInputf(ErrCodeInvalidCloudRegion, "invalid AWS region: %s", region)
}
return nil
}

View File

@@ -8,39 +8,23 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
cptypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes/cloudprovidertypes"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes/cloudprovidertypes/awstypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
var ErrCodeInvalidServiceID = errors.MustNewCode("invalid_service_id")
type CloudIntegrationService struct {
types.Identifiable
types.TimeAuditable
Type ServiceID `json:"type"`
Config *ServiceConfig `json:"config"`
CloudIntegrationID valuer.UUID `json:"cloudIntegrationId"`
Type cptypes.ServiceID `json:"type"`
Config *ServiceConfig `json:"config"`
CloudIntegrationID valuer.UUID `json:"cloudIntegrationId"`
}
type ServiceConfig struct {
// required till new providers are added
AWS *AWSServiceConfig `json:"aws" required:"true" nullable:"false"`
}
type AWSServiceConfig struct {
Logs *AWSServiceLogsConfig `json:"logs"`
Metrics *AWSServiceMetricsConfig `json:"metrics"`
}
// AWSServiceLogsConfig is AWS specific logs config for a service
// NOTE: the JSON keys are snake case for backward compatibility with existing agents.
type AWSServiceLogsConfig struct {
Enabled bool `json:"enabled"`
S3Buckets map[string][]string `json:"s3Buckets,omitempty"`
}
type AWSServiceMetricsConfig struct {
Enabled bool `json:"enabled"`
AWS *awstypes.AWSServiceConfig `json:"aws" required:"true" nullable:"false"`
}
// ServiceMetadata helps to quickly list available services and whether it is enabled or not.
@@ -106,7 +90,7 @@ type DataCollected struct {
// TelemetryCollectionStrategy is cloud provider specific configuration for signal collection,
// this is used by agent to understand the nitty-gritty for collecting telemetry for the cloud provider.
type TelemetryCollectionStrategy struct {
AWS *AWSTelemetryCollectionStrategy `json:"aws" required:"true" nullable:"false"`
AWS *awstypes.AWSTelemetryCollectionStrategy `json:"aws" required:"true" nullable:"false"`
}
// Assets represents the collection of dashboards.
@@ -130,65 +114,6 @@ type CollectedMetric struct {
Description string `json:"description"`
}
// OldAWSCollectionStrategy is the backward-compatible snake_case form of AWSCollectionStrategy,
// used in the legacy integration_config response field for older agents.
type OldAWSCollectionStrategy struct {
Provider string `json:"provider"`
Metrics *OldAWSMetricsStrategy `json:"aws_metrics,omitempty"`
Logs *OldAWSLogsStrategy `json:"aws_logs,omitempty"`
S3Buckets map[string][]string `json:"s3_buckets,omitempty"`
}
// OldAWSMetricsStrategy is the snake_case form of AWSMetricsStrategy for older agents.
type OldAWSMetricsStrategy struct {
StreamFilters []struct {
Namespace string `json:"Namespace"`
MetricNames []string `json:"MetricNames,omitempty"`
} `json:"cloudwatch_metric_stream_filters"`
}
// OldAWSLogsStrategy is the snake_case form of AWSLogsStrategy for older agents.
type OldAWSLogsStrategy struct {
Subscriptions []struct {
LogGroupNamePrefix string `json:"log_group_name_prefix"`
FilterPattern string `json:"filter_pattern"`
} `json:"cloudwatch_logs_subscriptions"`
}
// AWSTelemetryCollectionStrategy represents signal collection strategy for AWS services.
type AWSTelemetryCollectionStrategy struct {
Metrics *AWSMetricsCollectionStrategy `json:"metrics,omitempty" required:"false" nullable:"false"`
Logs *AWSLogsCollectionStrategy `json:"logs,omitempty" required:"false" nullable:"false"`
S3Buckets map[string][]string `json:"s3Buckets,omitempty" required:"false"` // Only available in S3 Sync Service Type in AWS
}
// AWSMetricsCollectionStrategy represents metrics collection strategy for AWS services.
type AWSMetricsCollectionStrategy struct {
// to be used as https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudwatch-metricstream.html#cfn-cloudwatch-metricstream-includefilters
StreamFilters []*AWSCloudWatchMetricStreamFilter `json:"streamFilters" required:"true" nullable:"false"`
}
type AWSCloudWatchMetricStreamFilter struct {
// https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-cloudwatch-metricstream-metricstreamfilter.html
Namespace string `json:"namespace" required:"true"`
MetricNames []string `json:"metricNames,omitempty" required:"false" nullable:"false"`
}
// AWSLogsCollectionStrategy represents logs collection strategy for AWS services.
type AWSLogsCollectionStrategy struct {
Subscriptions []*AWSCloudWatchLogsSubscription `json:"subscriptions" required:"true" nullable:"false"`
}
type AWSCloudWatchLogsSubscription struct {
// subscribe to all logs groups with specified prefix.
// eg: `/aws/rds/`
LogGroupNamePrefix string `json:"logGroupNamePrefix" required:"true"`
// https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html
// "" implies no filtering is required
FilterPattern string `json:"filterPattern" required:"true"`
}
// Dashboard represents a dashboard definition for cloud integration.
// This is used to show available pre-made dashboards for a service,
// hence has additional fields like id, title and description.
@@ -199,7 +124,7 @@ type Dashboard struct {
Definition dashboardtypes.StorableDashboardData `json:"definition,omitempty"`
}
func NewCloudIntegrationService(serviceID ServiceID, cloudIntegrationID valuer.UUID, config *ServiceConfig) *CloudIntegrationService {
func NewCloudIntegrationService(serviceID cptypes.ServiceID, cloudIntegrationID valuer.UUID, config *ServiceConfig) *CloudIntegrationService {
return &CloudIntegrationService{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
@@ -244,52 +169,52 @@ func NewGettableServicesMetadata(services []*ServiceMetadata) *GettableServicesM
}
}
func NewServiceConfigFromJSON(provider CloudProviderType, jsonString string) (*ServiceConfig, error) {
func NewServiceConfigFromJSON(provider cptypes.CloudProviderType, jsonString string) (*ServiceConfig, error) {
storableServiceConfig, err := newStorableServiceConfigFromJSON(provider, jsonString)
if err != nil {
return nil, err
}
switch provider {
case CloudProviderTypeAWS:
awsServiceConfig := new(AWSServiceConfig)
case cptypes.CloudProviderTypeAWS:
awsServiceConfig := new(awstypes.AWSServiceConfig)
if storableServiceConfig.AWS.Logs != nil {
awsServiceConfig.Logs = &AWSServiceLogsConfig{
awsServiceConfig.Logs = &awstypes.AWSServiceLogsConfig{
Enabled: storableServiceConfig.AWS.Logs.Enabled,
S3Buckets: storableServiceConfig.AWS.Logs.S3Buckets,
}
}
if storableServiceConfig.AWS.Metrics != nil {
awsServiceConfig.Metrics = &AWSServiceMetricsConfig{
awsServiceConfig.Metrics = &awstypes.AWSServiceMetricsConfig{
Enabled: storableServiceConfig.AWS.Metrics.Enabled,
}
}
return &ServiceConfig{AWS: awsServiceConfig}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
return nil, errors.NewInvalidInputf(cptypes.ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
}
// Update sets the service config.
func (service *CloudIntegrationService) Update(provider CloudProviderType, serviceID ServiceID, config *ServiceConfig) error {
func (service *CloudIntegrationService) Update(provider cptypes.CloudProviderType, serviceID cptypes.ServiceID, config *ServiceConfig) error {
switch provider {
case CloudProviderTypeAWS:
case cptypes.CloudProviderTypeAWS:
if config.AWS == nil {
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "AWS config is required for AWS service")
return errors.NewInvalidInputf(cptypes.ErrCodeCloudProviderInvalidInput, "AWS config is required for AWS service")
}
if serviceID == AWSServiceS3Sync {
if serviceID == cptypes.AWSServiceS3Sync {
if config.AWS.Logs == nil || config.AWS.Logs.S3Buckets == nil {
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "AWS S3 Sync service requires S3 bucket configuration for logs")
return errors.NewInvalidInputf(cptypes.ErrCodeCloudProviderInvalidInput, "AWS S3 Sync service requires S3 bucket configuration for logs")
}
}
// other validations happen in newStorableServiceConfig
default:
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
return errors.NewInvalidInputf(cptypes.ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
service.Config = config
@@ -299,9 +224,9 @@ func (service *CloudIntegrationService) Update(provider CloudProviderType, servi
// IsServiceEnabled returns true if the service has at least one signal (logs or metrics) enabled
// for the given cloud provider.
func (config *ServiceConfig) IsServiceEnabled(provider CloudProviderType) bool {
func (config *ServiceConfig) IsServiceEnabled(provider cptypes.CloudProviderType) bool {
switch provider {
case CloudProviderTypeAWS:
case cptypes.CloudProviderTypeAWS:
logsEnabled := config.AWS.Logs != nil && config.AWS.Logs.Enabled
metricsEnabled := config.AWS.Metrics != nil && config.AWS.Metrics.Enabled
return logsEnabled || metricsEnabled
@@ -312,9 +237,9 @@ func (config *ServiceConfig) IsServiceEnabled(provider CloudProviderType) bool {
// IsMetricsEnabled returns true if metrics are explicitly enabled for the given cloud provider.
// Used to gate dashboard availability — dashboards are only shown when metrics are enabled.
func (config *ServiceConfig) IsMetricsEnabled(provider CloudProviderType) bool {
func (config *ServiceConfig) IsMetricsEnabled(provider cptypes.CloudProviderType) bool {
switch provider {
case CloudProviderTypeAWS:
case cptypes.CloudProviderTypeAWS:
return config.AWS.Metrics != nil && config.AWS.Metrics.Enabled
default:
return false
@@ -322,16 +247,16 @@ func (config *ServiceConfig) IsMetricsEnabled(provider CloudProviderType) bool {
}
// IsLogsEnabled returns true if logs are explicitly enabled for the given cloud provider.
func (config *ServiceConfig) IsLogsEnabled(provider CloudProviderType) bool {
func (config *ServiceConfig) IsLogsEnabled(provider cptypes.CloudProviderType) bool {
switch provider {
case CloudProviderTypeAWS:
case cptypes.CloudProviderTypeAWS:
return config.AWS.Logs != nil && config.AWS.Logs.Enabled
default:
return false
}
}
func (config *ServiceConfig) ToJSON(provider CloudProviderType, serviceID ServiceID, supportedSignals *SupportedSignals) ([]byte, error) {
func (config *ServiceConfig) ToJSON(provider cptypes.CloudProviderType, serviceID cptypes.ServiceID, supportedSignals *SupportedSignals) ([]byte, error) {
storableServiceConfig, err := newStorableServiceConfig(provider, serviceID, config, supportedSignals)
if err != nil {
return nil, err
@@ -360,20 +285,20 @@ func (updatableService *UpdatableService) UnmarshalJSON(data []byte) error {
// GetCloudIntegrationDashboardID returns the dashboard id for a cloud integration, given the cloud provider, service id, and dashboard id.
// This is used to generate unique dashboard ids for cloud integration, and also to parse the dashboard id to get the cloud provider and service id when needed.
func GetCloudIntegrationDashboardID(cloudProvider CloudProviderType, svcID, dashboardID string) string {
func GetCloudIntegrationDashboardID(cloudProvider cptypes.CloudProviderType, svcID, dashboardID string) string {
return fmt.Sprintf("cloud-integration--%s--%s--%s", cloudProvider.StringValue(), svcID, dashboardID)
}
// ParseCloudIntegrationDashboardID parses a dashboard id generated by GetCloudIntegrationDashboardID
// into its constituent parts (cloudProvider, serviceID, dashboardID).
func ParseCloudIntegrationDashboardID(id string) (CloudProviderType, string, string, error) {
func ParseCloudIntegrationDashboardID(id string) (cptypes.CloudProviderType, string, string, error) {
parts := strings.SplitN(id, "--", 4)
if len(parts) != 4 || parts[0] != "cloud-integration" {
return CloudProviderType{}, "", "", errors.New(errors.TypeNotFound, ErrCodeCloudIntegrationNotFound, "invalid cloud integration dashboard id")
return cptypes.CloudProviderType{}, "", "", errors.New(errors.TypeNotFound, ErrCodeCloudIntegrationNotFound, "invalid cloud integration dashboard id")
}
provider, err := NewCloudProvider(parts[1])
provider, err := cptypes.NewCloudProvider(parts[1])
if err != nil {
return CloudProviderType{}, "", "", err
return cptypes.CloudProviderType{}, "", "", err
}
return provider, parts[2], parts[3], nil
}
@@ -382,7 +307,7 @@ func ParseCloudIntegrationDashboardID(id string) (CloudProviderType, string, str
func GetDashboardsFromAssets(
svcID string,
orgID valuer.UUID,
cloudProvider CloudProviderType,
cloudProvider cptypes.CloudProviderType,
createdAt time.Time,
assets Assets,
) []*dashboardtypes.Dashboard {
@@ -426,14 +351,14 @@ func awsOlderIntegrationConfig(cfg *ProviderIntegrationConfig) *IntegrationConfi
}
// Older agents expect a "provider" field and fully snake_case keys inside telemetry.
oldTelemetry := &OldAWSCollectionStrategy{
Provider: CloudProviderTypeAWS.StringValue(),
oldTelemetry := &awstypes.OldAWSCollectionStrategy{
Provider: cptypes.CloudProviderTypeAWS.StringValue(),
S3Buckets: awsCfg.TelemetryCollectionStrategy.S3Buckets,
}
if awsCfg.TelemetryCollectionStrategy.Metrics != nil {
// Convert camelCase cloudwatchMetricStreamFilters → snake_case cloudwatch_metric_stream_filters
oldMetrics := &OldAWSMetricsStrategy{}
oldMetrics := &awstypes.OldAWSMetricsStrategy{}
for _, f := range awsCfg.TelemetryCollectionStrategy.Metrics.StreamFilters {
oldMetrics.StreamFilters = append(oldMetrics.StreamFilters, struct {
Namespace string `json:"Namespace"`
@@ -445,7 +370,7 @@ func awsOlderIntegrationConfig(cfg *ProviderIntegrationConfig) *IntegrationConfi
if awsCfg.TelemetryCollectionStrategy.Logs != nil {
// Convert camelCase cloudwatchLogsSubscriptions → snake_case cloudwatch_logs_subscriptions
oldLogs := &OldAWSLogsStrategy{}
oldLogs := &awstypes.OldAWSLogsStrategy{}
for _, s := range awsCfg.TelemetryCollectionStrategy.Logs.Subscriptions {
oldLogs.Subscriptions = append(oldLogs.Subscriptions, struct {
LogGroupNamePrefix string `json:"log_group_name_prefix"`

View File

@@ -3,24 +3,25 @@ package cloudintegrationtypes
import (
"context"
cptypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes/cloudprovidertypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Store interface {
// GetAccountByID returns a cloud integration account by id
GetAccountByID(ctx context.Context, orgID, id valuer.UUID, provider CloudProviderType) (*StorableCloudIntegration, error)
GetAccountByID(ctx context.Context, orgID, id valuer.UUID, provider cptypes.CloudProviderType) (*StorableCloudIntegration, error)
// GetConnectedAccount for a given provider
GetConnectedAccount(ctx context.Context, orgID, id valuer.UUID, provider CloudProviderType) (*StorableCloudIntegration, error)
GetConnectedAccount(ctx context.Context, orgID, id valuer.UUID, provider cptypes.CloudProviderType) (*StorableCloudIntegration, error)
// GetConnectedAccountByProviderAccountID returns the connected cloud integration account for a given provider account id
GetConnectedAccountByProviderAccountID(ctx context.Context, orgID valuer.UUID, providerAccountID string, provider CloudProviderType) (*StorableCloudIntegration, error)
GetConnectedAccountByProviderAccountID(ctx context.Context, orgID valuer.UUID, providerAccountID string, provider cptypes.CloudProviderType) (*StorableCloudIntegration, error)
// ListConnectedAccounts returns all the cloud integration accounts for the org and cloud provider
ListConnectedAccounts(ctx context.Context, orgID valuer.UUID, provider CloudProviderType) ([]*StorableCloudIntegration, error)
ListConnectedAccounts(ctx context.Context, orgID valuer.UUID, provider cptypes.CloudProviderType) ([]*StorableCloudIntegration, error)
// CountConnectedAccounts returns the count of connected accounts for the org and cloud provider
CountConnectedAccounts(ctx context.Context, orgID valuer.UUID, provider CloudProviderType) (int, error)
CountConnectedAccounts(ctx context.Context, orgID valuer.UUID, provider cptypes.CloudProviderType) (int, error)
// CreateAccount creates a new cloud integration account
CreateAccount(ctx context.Context, account *StorableCloudIntegration) error
@@ -29,12 +30,12 @@ type Store interface {
UpdateAccount(ctx context.Context, account *StorableCloudIntegration) error
// RemoveAccount marks a cloud integration account as removed by setting the RemovedAt field
RemoveAccount(ctx context.Context, orgID, id valuer.UUID, provider CloudProviderType) error
RemoveAccount(ctx context.Context, orgID, id valuer.UUID, provider cptypes.CloudProviderType) error
// cloud_integration_service related methods
// GetServiceByServiceID returns the cloud integration service for the given cloud integration id and service id
GetServiceByServiceID(ctx context.Context, cloudIntegrationID valuer.UUID, serviceID ServiceID) (*StorableCloudIntegrationService, error)
GetServiceByServiceID(ctx context.Context, cloudIntegrationID valuer.UUID, serviceID cptypes.ServiceID) (*StorableCloudIntegrationService, error)
// ListServices returns all the cloud integration services for the given cloud integration id
ListServices(ctx context.Context, cloudIntegrationID valuer.UUID) ([]*StorableCloudIntegrationService, error)
@@ -49,6 +50,6 @@ type Store interface {
}
type ServiceDefinitionStore interface {
List(ctx context.Context, provider CloudProviderType) ([]*ServiceDefinition, error)
Get(ctx context.Context, provider CloudProviderType, serviceID ServiceID) (*ServiceDefinition, error)
List(ctx context.Context, provider cptypes.CloudProviderType) ([]*ServiceDefinition, error)
Get(ctx context.Context, provider cptypes.CloudProviderType, serviceID cptypes.ServiceID) (*ServiceDefinition, error)
}

View File

@@ -1,262 +0,0 @@
package dashboardtypes
import (
"bytes"
"encoding/json"
"fmt"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/go-playground/validator/v10"
v1 "github.com/perses/perses/pkg/model/api/v1"
"github.com/perses/perses/pkg/model/api/v1/common"
"github.com/perses/perses/pkg/model/api/v1/dashboard"
)
// StorableDashboardDataV2 wraps v1.DashboardSpec (Perses) with additional SigNoz-specific fields.
//
// We embed DashboardSpec (not v1.Dashboard) to avoid carrying Perses's Metadata
// (Name, Project, CreatedAt, UpdatedAt, Tags, Version) and Kind field. SigNoz
// manages identity (ID), timestamps (TimeAuditable), and multi-tenancy (OrgID)
// separately on StorableDashboardV2/DashboardV2.
//
// The following v1 request fields map to locations inside v1.DashboardSpec:
// - title → Display.Name (common.Display)
// - description → Display.Description (common.Display)
//
// Fields that have no Perses equivalent will be added in this wrapper (like image, uploadGrafana, etc.)
type StorableDashboardDataV2 = v1.DashboardSpec
// UnmarshalAndValidateDashboardV2JSON unmarshals the JSON into a StorableDashboardDataV2
// (= PostableDashboardV2 = UpdatableDashboardV2) and validates plugin kinds and specs.
func UnmarshalAndValidateDashboardV2JSON(data []byte) (*StorableDashboardDataV2, error) {
var d StorableDashboardDataV2
// Note: DashboardSpec has a custom UnmarshalJSON which prevents
// DisallowUnknownFields from working at the top level. Unknown
// fields in plugin specs are still rejected by validateAndNormalizePluginSpec.
if err := json.Unmarshal(data, &d); err != nil {
return nil, err
}
if err := validateDashboardV2(d); err != nil {
return nil, err
}
return &d, nil
}
// Plugin kind → spec type factory. Each value is a pointer to the zero value of the
// expected spec struct. validatePluginSpec marshals plugin.Spec back to JSON and
// unmarshals into the typed struct to catch field-level errors.
var (
panelPluginSpecs = map[PanelPluginKind]func() any{
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
PanelKindNumber: func() any { return new(NumberPanelSpec) },
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
PanelKindTable: func() any { return new(TablePanelSpec) },
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
PanelKindList: func() any { return new(ListPanelSpec) },
}
queryPluginSpecs = map[QueryPluginKind]func() any{
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
QueryKindComposite: func() any { return new(CompositeQuerySpec) },
QueryKindFormula: func() any { return new(FormulaSpec) },
QueryKindPromQL: func() any { return new(PromQLQuerySpec) },
QueryKindClickHouseSQL: func() any { return new(ClickHouseSQLQuerySpec) },
QueryKindTraceOperator: func() any { return new(TraceOperatorSpec) },
}
variablePluginSpecs = map[VariablePluginKind]func() any{
VariableKindDynamic: func() any { return new(DynamicVariableSpec) },
VariableKindQuery: func() any { return new(QueryVariableSpec) },
VariableKindCustom: func() any { return new(CustomVariableSpec) },
VariableKindTextbox: func() any { return new(TextboxVariableSpec) },
}
datasourcePluginSpecs = map[DatasourcePluginKind]func() any{
DatasourceKindSigNoz: func() any { return new(struct{}) },
}
// allowedQueryKinds maps each panel plugin kind to the query plugin
// kinds it supports. Composite sub-query types are mapped to these
// same kind strings via compositeSubQueryTypeToPluginKind.
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder},
}
// compositeSubQueryTypeToPluginKind maps CompositeQuery sub-query type
// strings to the equivalent top-level query plugin kind for validation.
compositeSubQueryTypeToPluginKind = map[qb.QueryType]QueryPluginKind{
qb.QueryTypeBuilder: QueryKindBuilder,
qb.QueryTypeFormula: QueryKindFormula,
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
qb.QueryTypePromQL: QueryKindPromQL,
qb.QueryTypeClickHouseSQL: QueryKindClickHouseSQL,
}
)
func validateDashboardV2(d StorableDashboardDataV2) error {
for name, ds := range d.Datasources {
if err := validateDatasourcePlugin(&ds.Plugin, fmt.Sprintf("spec.datasources.%s.plugin", name)); err != nil {
return err
}
}
for i, v := range d.Variables {
if err := validateVariablePlugin(v, fmt.Sprintf("spec.variables[%d]", i)); err != nil {
return err
}
}
for key, panel := range d.Panels {
if panel == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.panels.%s: panel must not be null", key)
}
path := fmt.Sprintf("spec.panels.%s", key)
if err := validatePanelPlugin(&panel.Spec.Plugin, path+".spec.plugin"); err != nil {
return err
}
panelKind := PanelPluginKind(panel.Spec.Plugin.Kind)
allowed := allowedQueryKinds[panelKind]
for qi := range panel.Spec.Queries {
queryPath := fmt.Sprintf("%s.spec.queries[%d].spec.plugin", path, qi)
if err := validateQueryPlugin(&panel.Spec.Queries[qi].Spec.Plugin, queryPath); err != nil {
return err
}
if err := validateQueryAllowedForPanel(panel.Spec.Queries[qi].Spec.Plugin, allowed, panelKind, queryPath); err != nil {
return err
}
}
}
return nil
}
func validateDatasourcePlugin(plugin *common.Plugin, path string) error {
kind := DatasourcePluginKind(plugin.Kind)
factory, ok := datasourcePluginSpecs[kind]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: unknown datasource plugin kind %q; allowed values: %s", path, kind, formatEnum(kind.Enum()))
}
return validateAndNormalizePluginSpec(plugin, factory, path)
}
func validateVariablePlugin(v dashboard.Variable, path string) error {
switch spec := v.Spec.(type) {
case *dashboard.ListVariableSpec:
pluginPath := path + ".spec.plugin"
kind := VariablePluginKind(spec.Plugin.Kind)
factory, ok := variablePluginSpecs[kind]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: unknown variable plugin kind %q; allowed values: %s", pluginPath, kind, formatEnum(kind.Enum()))
}
return validateAndNormalizePluginSpec(&spec.Plugin, factory, pluginPath)
case *dashboard.TextVariableSpec:
// TextVariables have no plugin, nothing to validate.
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: unsupported variable kind %q", path, v.Kind)
}
}
func validatePanelPlugin(plugin *common.Plugin, path string) error {
kind := PanelPluginKind(plugin.Kind)
factory, ok := panelPluginSpecs[kind]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: unknown panel plugin kind %q; allowed values: %s", path, kind, formatEnum(kind.Enum()))
}
return validateAndNormalizePluginSpec(plugin, factory, path)
}
func validateQueryPlugin(plugin *common.Plugin, path string) error {
kind := QueryPluginKind(plugin.Kind)
factory, ok := queryPluginSpecs[kind]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: unknown query plugin kind %q; allowed values: %s", path, kind, formatEnum(kind.Enum()))
}
return validateAndNormalizePluginSpec(plugin, factory, path)
}
func formatEnum(values []any) string {
parts := make([]string, len(values))
for i, v := range values {
parts[i] = fmt.Sprintf("`%v`", v)
}
return strings.Join(parts, ", ")
}
// validateAndNormalizePluginSpec validates the plugin spec and writes the typed
// struct (with defaults) back into plugin.Spec so that DB storage and API
// responses contain normalized values.
func validateAndNormalizePluginSpec(plugin *common.Plugin, factory func() any, path string) error {
if plugin.Kind == "" {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: plugin kind is required", path)
}
if plugin.Spec == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: plugin spec is required", path)
}
// Re-marshal the spec and unmarshal into the typed struct.
specJSON, err := json.Marshal(plugin.Spec)
if err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
}
target := factory()
decoder := json.NewDecoder(bytes.NewReader(specJSON))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
}
if err := validator.New().Struct(target); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
}
// Write the typed struct back so defaults are included.
plugin.Spec = target
return nil
}
// validateQueryAllowedForPanel checks that the query plugin kind is permitted
// for the given panel. For composite queries it recurses into sub-queries.
func validateQueryAllowedForPanel(plugin common.Plugin, allowed []QueryPluginKind, panelKind PanelPluginKind, path string) error {
queryKind := QueryPluginKind(plugin.Kind)
if !slices.Contains(allowed, queryKind) {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: query kind %q is not supported by panel kind %q", path, queryKind, panelKind)
}
// For composite queries, validate each sub-query type.
if queryKind == QueryKindComposite && plugin.Spec != nil {
specJSON, err := json.Marshal(plugin.Spec)
if err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
}
var composite struct {
Queries []struct {
Type qb.QueryType `json:"type"`
} `json:"queries"`
}
if err := json.Unmarshal(specJSON, &composite); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
}
for si, sub := range composite.Queries {
pluginKind, ok := compositeSubQueryTypeToPluginKind[sub.Type]
if !ok {
continue
}
if !slices.Contains(allowed, pluginKind) {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s.spec.queries[%d]: sub-query type %q is not supported by panel kind %q",
path, si, sub.Type, panelKind)
}
}
}
return nil
}

View File

@@ -1,889 +0,0 @@
package dashboardtypes
import (
"encoding/json"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateBigExample(t *testing.T) {
data, err := os.ReadFile("testdata/perses.json")
require.NoError(t, err, "reading example file")
_, err = UnmarshalAndValidateDashboardV2JSON(data)
require.NoError(t, err, "expected valid dashboard")
}
func TestValidateDashboardWithSections(t *testing.T) {
data, err := os.ReadFile("testdata/perses_with_sections.json")
require.NoError(t, err, "reading example file")
_, err = UnmarshalAndValidateDashboardV2JSON(data)
require.NoError(t, err, "expected valid dashboard")
}
func TestInvalidateNotAJSON(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON([]byte("not json"))
require.Error(t, err, "expected error for invalid JSON")
}
func TestValidateEmptySpec(t *testing.T) {
// no variables no panels
data := []byte(`{}`)
_, err := UnmarshalAndValidateDashboardV2JSON(data)
require.NoError(t, err, "expected valid")
}
func TestValidateOnlyVariables(t *testing.T) {
data := []byte(`{
"variables": [
{
"kind": "ListVariable",
"spec": {
"name": "service",
"allowAllValue": true,
"allowMultiple": false,
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {
"name": "service.name",
"signal": "metrics"
}
}
}
},
{
"kind": "TextVariable",
"spec": {
"name": "mytext",
"value": "default",
"plugin": {
"kind": "signoz/TextboxVariable",
"spec": {}
}
}
}
],
"layouts": []
}`)
_, err := UnmarshalAndValidateDashboardV2JSON(data)
require.NoError(t, err, "expected valid")
}
func TestInvalidateUnknownPluginKind(t *testing.T) {
tests := []struct {
name string
data string
wantContain string
}{
{
name: "unknown panel plugin",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "NonExistentPanel", "spec": {}}
}
}
},
"layouts": []
}`,
wantContain: "NonExistentPanel",
},
{
name: "unknown query plugin",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {"kind": "FakeQueryPlugin", "spec": {}}
}
}]
}
}
},
"layouts": []
}`,
wantContain: "FakeQueryPlugin",
},
{
name: "unknown variable plugin",
data: `{
"variables": [{
"kind": "ListVariable",
"spec": {
"name": "v1",
"allowAllValue": false,
"allowMultiple": false,
"plugin": {"kind": "FakeVariable", "spec": {}}
}
}],
"layouts": []
}`,
wantContain: "FakeVariable",
},
{
name: "unknown datasource plugin",
data: `{
"datasources": {
"ds1": {
"default": true,
"plugin": {"kind": "FakeDatasource", "spec": {}}
}
},
"layouts": []
}`,
wantContain: "FakeDatasource",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
}
}
func TestInvalidateOneInvalidPanel(t *testing.T) {
data := []byte(`{
"panels": {
"good": {
"kind": "Panel",
"spec": {"plugin": {"kind": "signoz/NumberPanel", "spec": {}}}
},
"bad": {
"kind": "Panel",
"spec": {"plugin": {"kind": "FakePanel", "spec": {}}}
}
},
"layouts": []
}`)
_, err := UnmarshalAndValidateDashboardV2JSON(data)
require.Error(t, err, "expected error for invalid panel plugin kind")
require.Contains(t, err.Error(), "FakePanel", "error should mention FakePanel")
}
func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
tests := []struct {
name string
data string
wantContain string
}{
{
name: "unknown field in panel spec",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"bogusField": true}
}
}
}
},
"layouts": []
}`,
wantContain: "bogusField",
},
{
name: "unknown field in query spec",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "signoz/PromQLQuery",
"spec": {"name": "A", "query": "up", "unknownThing": 42}
}
}
}]
}
}
},
"layouts": []
}`,
wantContain: "unknownThing",
},
{
name: "unknown field in variable spec",
data: `{
"variables": [{
"kind": "ListVariable",
"spec": {
"name": "v",
"allowAllValue": false,
"allowMultiple": false,
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {"name": "service.name", "signal": "metrics", "extraField": "bad"}
}
}
}],
"layouts": []
}`,
wantContain: "extraField",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
require.Error(t, err, "expected error for unknown field")
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
}
}
func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
tests := []struct {
name string
data string
wantContain string
}{
{
name: "wrong type on panel plugin field",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"visualization": {"fillSpans": "notabool"}}
}
}
}
},
"layouts": []
}`,
wantContain: "fillSpans",
},
{
name: "wrong type on query plugin field",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "signoz/PromQLQuery",
"spec": {"name": "A", "query": 123}
}
}
}]
}
}
},
"layouts": []
}`,
wantContain: "",
},
{
name: "wrong type on variable plugin field",
data: `{
"variables": [{
"kind": "ListVariable",
"spec": {
"name": "v",
"allowAllValue": false,
"allowMultiple": false,
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {"name": 123, "signal": "metrics"}
}
}
}],
"layouts": []
}`,
wantContain: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
require.Error(t, err, "expected validation error")
if tt.wantContain != "" {
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
}
})
}
}
func TestInvalidateBadPanelSpecValues(t *testing.T) {
tests := []struct {
name string
data string
wantContain string
}{
{
name: "bad signal in builder query",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {}
},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {"signal": "foo"}
}
}
}]
}
}
},
"layouts": []
}`,
wantContain: "signal",
},
{
name: "bad line interpolation",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"chartAppearance": {"lineInterpolation": "cubic"}}
}
}
}
},
"layouts": []
}`,
wantContain: "line interpolation",
},
{
name: "bad line style",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"chartAppearance": {"lineStyle": "dotted"}}
}
}
}
},
"layouts": []
}`,
wantContain: "line style",
},
{
name: "bad fill mode",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"chartAppearance": {"fillMode": "striped"}}
}
}
}
},
"layouts": []
}`,
wantContain: "fill mode",
},
{
name: "bad spanGaps fillLessThan",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"chartAppearance": {"spanGaps": {"fillLessThan": "notaduration"}}}
}
}
}
},
"layouts": []
}`,
wantContain: "duration",
},
{
name: "bad time preference",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"visualization": {"timePreference": "last2Hr"}}
}
}
}
},
"layouts": []
}`,
wantContain: "timePreference",
},
{
name: "bad legend position",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "signoz/BarChartPanel",
"spec": {"legend": {"position": "top"}}
}
}
}
},
"layouts": []
}`,
wantContain: "legend position",
},
{
name: "bad threshold format",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 100, "operator": ">", "color": "Red", "format": "Color"}]}
}
}
}
},
"layouts": []
}`,
wantContain: "threshold format",
},
{
name: "bad comparison operator",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 100, "operator": "!=", "color": "Red", "format": "text"}]}
}
}
}
},
"layouts": []
}`,
wantContain: "comparison operator",
},
{
name: "bad precision",
data: `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"formatting": {"decimalPrecision": "9"}}
}
}
}
},
"layouts": []
}`,
wantContain: "precision",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
}
}
func TestValidateRequiredFields(t *testing.T) {
wrapVariable := func(pluginKind, pluginSpec string) string {
return `{
"variables": [{
"kind": "ListVariable",
"spec": {
"name": "v",
"allowAllValue": false,
"allowMultiple": false,
"plugin": {"kind": "` + pluginKind + `", "spec": ` + pluginSpec + `}
}
}],
"layouts": []
}`
}
wrapPanel := func(panelKind, panelSpec string) string {
return `{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "` + panelKind + `", "spec": ` + panelSpec + `}
}
}
},
"layouts": []
}`
}
tests := []struct {
name string
data string
wantContain string
}{
{
name: "DynamicVariable missing name",
data: wrapVariable("signoz/DynamicVariable", `{"signal": "metrics"}`),
wantContain: "Name",
},
{
name: "QueryVariable missing queryValue",
data: wrapVariable("signoz/QueryVariable", `{}`),
wantContain: "QueryValue",
},
{
name: "CustomVariable missing customValue",
data: wrapVariable("signoz/CustomVariable", `{}`),
wantContain: "CustomValue",
},
{
name: "ThresholdWithLabel missing value",
data: wrapPanel("signoz/TimeSeriesPanel", `{"thresholds": [{"color": "Red", "label": "high"}]}`),
wantContain: "Value",
},
{
name: "ThresholdWithLabel missing color",
data: wrapPanel("signoz/TimeSeriesPanel", `{"thresholds": [{"value": 100, "label": "high", "color": ""}]}`),
wantContain: "Color",
},
{
name: "ThresholdWithLabel missing label",
data: wrapPanel("signoz/TimeSeriesPanel", `{"thresholds": [{"value": 100, "color": "Red", "label": ""}]}`),
wantContain: "Label",
},
{
name: "ComparisonThreshold missing value",
data: wrapPanel("signoz/NumberPanel", `{"thresholds": [{"operator": ">", "format": "text", "color": "Red"}]}`),
wantContain: "Value",
},
{
name: "ComparisonThreshold missing color",
data: wrapPanel("signoz/NumberPanel", `{"thresholds": [{"value": 100, "operator": ">", "format": "text", "color": ""}]}`),
wantContain: "Color",
},
{
name: "TableThreshold missing columnName",
data: wrapPanel("signoz/TablePanel", `{"thresholds": [{"value": 100, "operator": ">", "format": "text", "color": "Red", "columnName": ""}]}`),
wantContain: "ColumnName",
},
{
name: "SelectField missing name",
data: wrapPanel("signoz/ListPanel", `{"selectFields": [{"name": ""}]}`),
wantContain: "Name",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
}
}
func TestTimeSeriesPanelDefaults(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {}
}
}
}
},
"layouts": []
}`)
d, err := UnmarshalAndValidateDashboardV2JSON(data)
require.NoError(t, err, "unmarshal and validate failed")
// After validation+normalization, the plugin spec should be a typed struct.
require.IsType(t, &TimeSeriesPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
spec := d.Panels["p1"].Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
require.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
require.Equal(t, "spline", spec.ChartAppearance.LineInterpolation.ValueOrDefault(), "expected LineInterpolation default spline")
require.Equal(t, "solid", spec.ChartAppearance.LineStyle.ValueOrDefault(), "expected LineStyle default solid")
require.Equal(t, "solid", spec.ChartAppearance.FillMode.ValueOrDefault(), "expected FillMode default solid")
require.False(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow, "expected SpanGaps.FillOnlyBelow default false")
require.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
require.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
// Re-marshal the full dashboard (what we'd store in DB / return in API response)
// and verify the output contains the default values.
output, err := json.Marshal(d)
require.NoError(t, err, "marshal dashboard failed")
outputStr := string(output)
for field, want := range map[string]string{
"decimalPrecision": `"2"`,
"lineInterpolation": `"spline"`,
"lineStyle": `"solid"`,
"fillMode": `"solid"`,
"timePreference": `"global_time"`,
"position": `"bottom"`,
} {
assert.Contains(t, outputStr, `"`+field+`":`+want, "expected stored/response JSON to contain %s:%s", field, want)
}
}
func TestNumberPanelDefaults(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 100, "color": "Red"}]}
}
}
}
},
"layouts": []
}`)
d, err := UnmarshalAndValidateDashboardV2JSON(data)
require.NoError(t, err, "unmarshal and validate failed")
require.IsType(t, &NumberPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
spec := d.Panels["p1"].Spec.Plugin.Spec.(*NumberPanelSpec)
require.Len(t, spec.Thresholds, 1, "expected 1 threshold")
require.Equal(t, ">", spec.Thresholds[0].Operator.ValueOrDefault(), "expected ComparisonOperator default >")
require.Equal(t, "text", spec.Thresholds[0].Format.ValueOrDefault(), "expected ThresholdFormat default text")
// Marshal back and verify defaults in JSON output.
output, err := json.Marshal(d)
require.NoError(t, err, "marshal dashboard failed")
outputStr := string(output)
assert.Contains(t, outputStr, `"format":"text"`, "expected stored/response JSON to contain format:text")
// Go's json.Marshal escapes ">" as "\u003e", so check for both forms.
assert.True(t,
strings.Contains(outputStr, `"operator":">"`) || strings.Contains(outputStr, `"operator":"\u003e"`),
"expected stored/response JSON to contain operator:>, got: %s", outputStr)
}
// TestStorageRoundTrip simulates the future DB store/load cycle:
// marshal the normalized dashboard to JSON (what would be written to DB),
// then unmarshal it back (what would be read from DB), and verify defaults survive.
func TestStorageRoundTrip(t *testing.T) {
input := []byte(`{
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {}
}
}
},
"p2": {
"kind": "Panel",
"spec": {
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 100, "color": "Red"}]}
}
}
}
},
"layouts": []
}`)
// Step 1: Unmarshal + validate + normalize (what the API handler does).
d, err := UnmarshalAndValidateDashboardV2JSON(input)
require.NoError(t, err, "unmarshal and validate failed")
// Step 1.5: Verify struct fields have correct defaults (extra validation before storing).
tsSpec := d.Panels["p1"].Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
assert.Equal(t, "2", tsSpec.Formatting.DecimalPrecision.ValueOrDefault())
assert.Equal(t, "spline", tsSpec.ChartAppearance.LineInterpolation.ValueOrDefault())
assert.Equal(t, "solid", tsSpec.ChartAppearance.LineStyle.ValueOrDefault())
assert.Equal(t, "solid", tsSpec.ChartAppearance.FillMode.ValueOrDefault())
assert.Equal(t, "global_time", tsSpec.Visualization.TimePreference.ValueOrDefault())
assert.Equal(t, "bottom", tsSpec.Legend.Position.ValueOrDefault())
numSpec := d.Panels["p2"].Spec.Plugin.Spec.(*NumberPanelSpec)
assert.Equal(t, ">", numSpec.Thresholds[0].Operator.ValueOrDefault())
assert.Equal(t, "text", numSpec.Thresholds[0].Format.ValueOrDefault())
// Step 2: Marshal to JSON (simulates writing to DB).
stored, err := json.Marshal(d)
require.NoError(t, err, "marshal for storage failed")
// Step 3: Unmarshal from JSON (simulates reading from DB).
loaded, err := UnmarshalAndValidateDashboardV2JSON(stored)
require.NoError(t, err, "unmarshal from storage failed")
// Step 3.5: Verify struct fields have correct defaults after loading (before returning in API).
tsLoaded := loaded.Panels["p1"].Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
assert.Equal(t, "2", tsLoaded.Formatting.DecimalPrecision.ValueOrDefault(), "after load")
assert.Equal(t, "spline", tsLoaded.ChartAppearance.LineInterpolation.ValueOrDefault(), "after load")
assert.Equal(t, "solid", tsLoaded.ChartAppearance.LineStyle.ValueOrDefault(), "after load")
assert.Equal(t, "solid", tsLoaded.ChartAppearance.FillMode.ValueOrDefault(), "after load")
assert.Equal(t, "global_time", tsLoaded.Visualization.TimePreference.ValueOrDefault(), "after load")
assert.Equal(t, "bottom", tsLoaded.Legend.Position.ValueOrDefault(), "after load")
numLoaded := loaded.Panels["p2"].Spec.Plugin.Spec.(*NumberPanelSpec)
assert.Equal(t, ">", numLoaded.Thresholds[0].Operator.ValueOrDefault(), "after load")
assert.Equal(t, "text", numLoaded.Thresholds[0].Format.ValueOrDefault(), "after load")
// Step 4: Marshal again (simulates API response) and verify defaults.
response, err := json.Marshal(loaded)
require.NoError(t, err, "marshal for response failed")
responseStr := string(response)
for field, want := range map[string]string{
"decimalPrecision": `"2"`,
"lineInterpolation": `"spline"`,
"lineStyle": `"solid"`,
"fillMode": `"solid"`,
"timePreference": `"global_time"`,
"position": `"bottom"`,
"format": `"text"`,
} {
assert.Contains(t, responseStr, `"`+field+`":`+want, "expected %s:%s after storage round-trip", field, want)
}
// Verify operator default (Go escapes ">" as "\u003e").
assert.True(t,
strings.Contains(responseStr, `"operator":">"`) || strings.Contains(responseStr, `"operator":"\u003e"`),
"expected operator:> after storage round-trip")
}
func TestSpanGaps(t *testing.T) {
unmarshal := func(t *testing.T, val string) SpanGaps {
t.Helper()
var sg SpanGaps
require.NoError(t, json.Unmarshal([]byte(val), &sg))
return sg
}
t.Run("defaults", func(t *testing.T) {
var sg SpanGaps
assert.False(t, sg.FillOnlyBelow, "expected FillOnlyBelow default false")
assert.True(t, sg.FillLessThan.IsZero(), "expected FillLessThan default zero")
})
t.Run("fillOnlyBelow true", func(t *testing.T) {
sg := unmarshal(t, `{"fillOnlyBelow": true}`)
assert.True(t, sg.FillOnlyBelow)
})
t.Run("fillLessThan duration", func(t *testing.T) {
sg := unmarshal(t, `{"fillOnlyBelow": false, "fillLessThan": "5m"}`)
assert.False(t, sg.FillOnlyBelow)
assert.Equal(t, 5*time.Minute, sg.FillLessThan.Duration())
})
t.Run("fillLessThan compound duration", func(t *testing.T) {
sg := unmarshal(t, `{"fillLessThan": "1h30m"}`)
assert.Equal(t, 90*time.Minute, sg.FillLessThan.Duration())
})
}
func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
mkQuery := func(panelKind, queryKind, querySpec string) []byte {
return []byte(`{
"panels": {"p1": {"kind": "Panel", "spec": {
"plugin": {"kind": "` + panelKind + `", "spec": {}},
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "` + queryKind + `", "spec": ` + querySpec + `}}}]
}}},
"layouts": []
}`)
}
mkComposite := func(panelKind, subType, subSpec string) []byte {
return []byte(`{
"panels": {"p1": {"kind": "Panel", "spec": {
"plugin": {"kind": "` + panelKind + `", "spec": {}},
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/CompositeQuery", "spec": {
"queries": [{"type": "` + subType + `", "spec": ` + subSpec + `}]
}}}}]
}}},
"layouts": []
}`)
}
cases := []struct {
name string
data []byte
wantErr bool
}{
// Top-level: allowed
{"TimeSeries+PromQL", mkQuery("signoz/TimeSeriesPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), false},
{"Table+ClickHouse", mkQuery("signoz/TablePanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), false},
{"List+Builder", mkQuery("signoz/ListPanel", "signoz/BuilderQuery", `{"name":"A","signal":"logs"}`), false},
// Top-level: rejected
{"Table+PromQL", mkQuery("signoz/TablePanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
{"List+ClickHouse", mkQuery("signoz/ListPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), true},
{"List+PromQL", mkQuery("signoz/ListPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
{"List+Composite", mkQuery("signoz/ListPanel", "signoz/CompositeQuery", `{"queries":[]}`), true},
{"List+Formula", mkQuery("signoz/ListPanel", "signoz/Formula", `{"name":"F1","expression":"A+B"}`), true},
// Composite sub-queries
{"Table+Composite(promql)", mkComposite("signoz/TablePanel", "promql", `{"name":"A","query":"up"}`), true},
{"Table+Composite(clickhouse)", mkComposite("signoz/TablePanel", "clickhouse_sql", `{"name":"A","query":"SELECT 1"}`), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON(tc.data)
if tc.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}

View File

@@ -1,615 +0,0 @@
package dashboardtypes
import (
"encoding/json"
"strconv"
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// ══════════════════════════════════════════════
// SigNoz variable plugin specs
// ══════════════════════════════════════════════
type VariablePluginKind string
const (
VariableKindDynamic VariablePluginKind = "signoz/DynamicVariable"
VariableKindQuery VariablePluginKind = "signoz/QueryVariable"
VariableKindCustom VariablePluginKind = "signoz/CustomVariable"
VariableKindTextbox VariablePluginKind = "signoz/TextboxVariable"
)
func (VariablePluginKind) Enum() []any {
return []any{VariableKindDynamic, VariableKindQuery, VariableKindCustom, VariableKindTextbox}
}
type DynamicVariableSpec struct {
// Name is the name of the attribute being fetched dynamically from the
// signal. This could be extended to a richer selector in the future.
Name string `json:"name" validate:"required" required:"true"`
Signal telemetrytypes.Signal `json:"signal"`
}
type QueryVariableSpec struct {
QueryValue string `json:"queryValue" validate:"required" required:"true"`
}
type CustomVariableSpec struct {
CustomValue string `json:"customValue" validate:"required" required:"true"`
}
type TextboxVariableSpec struct{}
// ══════════════════════════════════════════════
// SigNoz query plugin specs — aliased from querybuildertypesv5
// ══════════════════════════════════════════════
type QueryPluginKind string
const (
QueryKindBuilder QueryPluginKind = "signoz/BuilderQuery"
QueryKindComposite QueryPluginKind = "signoz/CompositeQuery"
QueryKindFormula QueryPluginKind = "signoz/Formula"
QueryKindPromQL QueryPluginKind = "signoz/PromQLQuery"
QueryKindClickHouseSQL QueryPluginKind = "signoz/ClickHouseSQL"
QueryKindTraceOperator QueryPluginKind = "signoz/TraceOperator"
)
func (QueryPluginKind) Enum() []any {
return []any{QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL, QueryKindTraceOperator}
}
type (
CompositeQuerySpec = qb.CompositeQuery
QueryEnvelope = qb.QueryEnvelope
FormulaSpec = qb.QueryBuilderFormula
PromQLQuerySpec = qb.PromQuery
ClickHouseSQLQuerySpec = qb.ClickHouseQuery
TraceOperatorSpec = qb.QueryBuilderTraceOperator
)
// BuilderQuerySpec dispatches to the correct generic QueryBuilderQuery type
// based on the signal field, reusing the shared dispatch logic.
type BuilderQuerySpec struct {
Spec any
}
func (b *BuilderQuerySpec) UnmarshalJSON(data []byte) error {
spec, err := qb.UnmarshalBuilderQueryBySignal(data)
if err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid builder query spec")
}
b.Spec = spec
return nil
}
// ══════════════════════════════════════════════
// SigNoz panel plugin specs
// ══════════════════════════════════════════════
type PanelPluginKind string
const (
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
PanelKindTable PanelPluginKind = "signoz/TablePanel"
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
PanelKindList PanelPluginKind = "signoz/ListPanel"
)
func (PanelPluginKind) Enum() []any {
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList}
}
type DatasourcePluginKind string
const (
DatasourceKindSigNoz DatasourcePluginKind = "signoz/Datasource"
)
func (DatasourcePluginKind) Enum() []any {
return []any{DatasourceKindSigNoz}
}
type TimeSeriesPanelSpec struct {
Visualization TimeSeriesVisualization `json:"visualization"`
Formatting PanelFormatting `json:"formatting"`
ChartAppearance TimeSeriesChartAppearance `json:"chartAppearance"`
Axes Axes `json:"axes"`
Legend Legend `json:"legend"`
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive"`
}
type TimeSeriesChartAppearance struct {
LineInterpolation LineInterpolation `json:"lineInterpolation"`
ShowPoints bool `json:"showPoints"`
LineStyle LineStyle `json:"lineStyle"`
FillMode FillMode `json:"fillMode"`
SpanGaps SpanGaps `json:"spanGaps"`
}
type BarChartPanelSpec struct {
Visualization BarChartVisualization `json:"visualization"`
Formatting PanelFormatting `json:"formatting"`
Axes Axes `json:"axes"`
Legend Legend `json:"legend"`
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive"`
}
type NumberPanelSpec struct {
Visualization BasicVisualization `json:"visualization"`
Formatting PanelFormatting `json:"formatting"`
Thresholds []ComparisonThreshold `json:"thresholds" validate:"dive"`
}
type PieChartPanelSpec struct {
Visualization BasicVisualization `json:"visualization"`
Formatting PanelFormatting `json:"formatting"`
Legend Legend `json:"legend"`
}
type TablePanelSpec struct {
Visualization BasicVisualization `json:"visualization"`
Formatting TableFormatting `json:"formatting"`
Thresholds []TableThreshold `json:"thresholds" validate:"dive"`
}
type HistogramPanelSpec struct {
HistogramBuckets HistogramBuckets `json:"histogramBuckets"`
Legend Legend `json:"legend"`
}
type HistogramBuckets struct {
BucketCount *float64 `json:"bucketCount"`
BucketWidth *float64 `json:"bucketWidth"`
MergeAllActiveQueries bool `json:"mergeAllActiveQueries"`
}
type ListPanelSpec struct {
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitempty" validate:"dive"`
}
// ══════════════════════════════════════════════
// Panel common types
// ══════════════════════════════════════════════
type Axes struct {
SoftMin *float64 `json:"softMin"`
SoftMax *float64 `json:"softMax"`
IsLogScale bool `json:"isLogScale"`
}
type BasicVisualization struct {
TimePreference TimePreference `json:"timePreference"`
}
type TimeSeriesVisualization struct {
BasicVisualization
FillSpans bool `json:"fillSpans"`
}
type BarChartVisualization struct {
BasicVisualization
FillSpans bool `json:"fillSpans"`
StackedBarChart bool `json:"stackedBarChart"`
}
type PanelFormatting struct {
Unit string `json:"unit"`
DecimalPrecision PrecisionOption `json:"decimalPrecision"`
}
type TableFormatting struct {
ColumnUnits map[string]string `json:"columnUnits"`
DecimalPrecision PrecisionOption `json:"decimalPrecision"`
}
type Legend struct {
Position LegendPosition `json:"position"`
CustomColors map[string]string `json:"customColors"`
}
type ThresholdWithLabel struct {
Value float64 `json:"value" validate:"required" required:"true"`
Unit string `json:"unit"`
Color string `json:"color" validate:"required" required:"true"`
Label string `json:"label" validate:"required" required:"true"`
}
type ComparisonThreshold struct {
Value float64 `json:"value" validate:"required" required:"true"`
Operator ComparisonOperator `json:"operator"`
Unit string `json:"unit"`
Color string `json:"color" validate:"required" required:"true"`
Format ThresholdFormat `json:"format"`
}
type TableThreshold struct {
ComparisonThreshold
ColumnName string `json:"columnName" validate:"required" required:"true"`
}
// ══════════════════════════════════════════════
// Constrained scalar types — with default value
// ══════════════════════════════════════════════
type TimePreference struct{ valuer.String }
var (
TimePreferenceGlobalTime = TimePreference{valuer.NewString("global_time")} // default
TimePreferenceLast5Min = TimePreference{valuer.NewString("last_5_min")}
TimePreferenceLast15Min = TimePreference{valuer.NewString("last_15_min")}
TimePreferenceLast30Min = TimePreference{valuer.NewString("last_30_min")}
TimePreferenceLast1Hr = TimePreference{valuer.NewString("last_1_hr")}
TimePreferenceLast6Hr = TimePreference{valuer.NewString("last_6_hr")}
TimePreferenceLast1Day = TimePreference{valuer.NewString("last_1_day")}
TimePreferenceLast3Days = TimePreference{valuer.NewString("last_3_days")}
TimePreferenceLast1Week = TimePreference{valuer.NewString("last_1_week")}
TimePreferenceLast1Month = TimePreference{valuer.NewString("last_1_month")}
)
func (TimePreference) Enum() []any {
return []any{TimePreferenceGlobalTime, TimePreferenceLast5Min, TimePreferenceLast15Min, TimePreferenceLast30Min, TimePreferenceLast1Hr, TimePreferenceLast6Hr, TimePreferenceLast1Day, TimePreferenceLast3Days, TimePreferenceLast1Week, TimePreferenceLast1Month}
}
func (t TimePreference) ValueOrDefault() string {
if t.IsZero() {
return TimePreferenceGlobalTime.StringValue()
}
return t.StringValue()
}
func (t TimePreference) MarshalJSON() ([]byte, error) {
return json.Marshal(t.ValueOrDefault())
}
func (t *TimePreference) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid timePreference: must be a string, one of `global_time`, `last_5_min`, `last_15_min`, `last_30_min`, `last_1_hr`, `last_6_hr`, `last_1_day`, `last_3_days`, `last_1_week`, or `last_1_month`")
}
if v == "" {
*t = TimePreferenceGlobalTime
return nil
}
tp := TimePreference{valuer.NewString(v)}
switch tp {
case TimePreferenceGlobalTime, TimePreferenceLast5Min, TimePreferenceLast15Min, TimePreferenceLast30Min, TimePreferenceLast1Hr, TimePreferenceLast6Hr, TimePreferenceLast1Day, TimePreferenceLast3Days, TimePreferenceLast1Week, TimePreferenceLast1Month:
*t = tp
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid timePreference %q: must be `global_time`, `last_5_min`, `last_15_min`, `last_30_min`, `last_1_hr`, `last_6_hr`, `last_1_day`, `last_3_days`, `last_1_week`, or `last_1_month`", v)
}
}
type LegendPosition struct{ valuer.String }
var (
LegendPositionBottom = LegendPosition{valuer.NewString("bottom")} // default
LegendPositionRight = LegendPosition{valuer.NewString("right")}
)
func (LegendPosition) Enum() []any {
return []any{LegendPositionBottom, LegendPositionRight}
}
func (l LegendPosition) ValueOrDefault() string {
if l.IsZero() {
return LegendPositionBottom.StringValue()
}
return l.StringValue()
}
func (l LegendPosition) MarshalJSON() ([]byte, error) {
return json.Marshal(l.ValueOrDefault())
}
func (l *LegendPosition) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid legend position: must be a string, one of `bottom` or `right`")
}
if v == "" {
*l = LegendPositionBottom
return nil
}
lp := LegendPosition{valuer.NewString(v)}
switch lp {
case LegendPositionBottom, LegendPositionRight:
*l = lp
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid legend position %q: must be `bottom` or `right`", v)
}
}
type ThresholdFormat struct{ valuer.String }
var (
ThresholdFormatText = ThresholdFormat{valuer.NewString("text")} // default
ThresholdFormatBackground = ThresholdFormat{valuer.NewString("background")}
)
func (ThresholdFormat) Enum() []any {
return []any{ThresholdFormatText, ThresholdFormatBackground}
}
func (f ThresholdFormat) ValueOrDefault() string {
if f.IsZero() {
return ThresholdFormatText.StringValue()
}
return f.StringValue()
}
func (f ThresholdFormat) MarshalJSON() ([]byte, error) {
return json.Marshal(f.ValueOrDefault())
}
func (f *ThresholdFormat) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid threshold format: must be a string, one of `text` or `background`")
}
if v == "" {
*f = ThresholdFormatText
return nil
}
tf := ThresholdFormat{valuer.NewString(v)}
switch tf {
case ThresholdFormatText, ThresholdFormatBackground:
*f = tf
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid threshold format %q: must be `text` or `background`", v)
}
}
// Uses valuer.String with custom UnmarshalJSON for validation, rather than
// ruletypes.CompareOperator which accepts any string at unmarshal time.
type ComparisonOperator struct{ valuer.String }
var (
ComparisonOperatorGT = ComparisonOperator{valuer.NewString(">")} // default
ComparisonOperatorLT = ComparisonOperator{valuer.NewString("<")}
ComparisonOperatorGTE = ComparisonOperator{valuer.NewString(">=")}
ComparisonOperatorLTE = ComparisonOperator{valuer.NewString("<=")}
ComparisonOperatorEQ = ComparisonOperator{valuer.NewString("=")}
ComparisonOperatorAbove = ComparisonOperator{valuer.NewString("above")}
ComparisonOperatorBelow = ComparisonOperator{valuer.NewString("below")}
ComparisonOperatorAboveOrEqual = ComparisonOperator{valuer.NewString("above_or_equal")}
ComparisonOperatorBelowOrEqual = ComparisonOperator{valuer.NewString("below_or_equal")}
ComparisonOperatorEqual = ComparisonOperator{valuer.NewString("equal")}
ComparisonOperatorNotEqual = ComparisonOperator{valuer.NewString("not_equal")}
)
func (ComparisonOperator) Enum() []any {
return []any{ComparisonOperatorGT, ComparisonOperatorLT, ComparisonOperatorGTE, ComparisonOperatorLTE, ComparisonOperatorEQ, ComparisonOperatorAbove, ComparisonOperatorBelow, ComparisonOperatorAboveOrEqual, ComparisonOperatorBelowOrEqual, ComparisonOperatorEqual, ComparisonOperatorNotEqual}
}
func (o ComparisonOperator) ValueOrDefault() string {
if o.IsZero() {
return ComparisonOperatorGT.StringValue()
}
return o.StringValue()
}
func (o ComparisonOperator) MarshalJSON() ([]byte, error) {
return json.Marshal(o.ValueOrDefault())
}
func (o *ComparisonOperator) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid comparison operator: must be a string, one of `>`, `<`, `>=`, `<=`, `=`, `above`, `below`, `above_or_equal`, `below_or_equal`, `equal`, or `not_equal`")
}
if v == "" {
*o = ComparisonOperatorGT
return nil
}
co := ComparisonOperator{valuer.NewString(v)}
switch co {
case ComparisonOperatorGT, ComparisonOperatorLT, ComparisonOperatorGTE, ComparisonOperatorLTE, ComparisonOperatorEQ,
ComparisonOperatorAbove, ComparisonOperatorBelow, ComparisonOperatorAboveOrEqual, ComparisonOperatorBelowOrEqual,
ComparisonOperatorEqual, ComparisonOperatorNotEqual:
*o = co
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid comparison operator %q: must be `>`, `<`, `>=`, `<=`, `=`, `above`, `below`, `above_or_equal`, `below_or_equal`, `equal`, or `not_equal`", v)
}
}
type LineInterpolation struct{ valuer.String }
var (
LineInterpolationLinear = LineInterpolation{valuer.NewString("linear")}
LineInterpolationSpline = LineInterpolation{valuer.NewString("spline")} // default
LineInterpolationStepAfter = LineInterpolation{valuer.NewString("step_after")}
LineInterpolationStepBefore = LineInterpolation{valuer.NewString("step_before")}
)
func (LineInterpolation) Enum() []any {
return []any{LineInterpolationLinear, LineInterpolationSpline, LineInterpolationStepAfter, LineInterpolationStepBefore}
}
func (li LineInterpolation) ValueOrDefault() string {
if li.IsZero() {
return LineInterpolationSpline.StringValue()
}
return li.StringValue()
}
func (li LineInterpolation) MarshalJSON() ([]byte, error) {
return json.Marshal(li.ValueOrDefault())
}
func (li *LineInterpolation) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid line interpolation: must be a string, one of `linear`, `spline`, `step_after`, or `step_before`")
}
if v == "" {
*li = LineInterpolationSpline
return nil
}
val := LineInterpolation{valuer.NewString(v)}
switch val {
case LineInterpolationLinear, LineInterpolationSpline, LineInterpolationStepAfter, LineInterpolationStepBefore:
*li = val
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid line interpolation %q: must be `linear`, `spline`, `step_after`, or `step_before`", v)
}
}
type LineStyle struct{ valuer.String }
var (
LineStyleSolid = LineStyle{valuer.NewString("solid")} // default
LineStyleDashed = LineStyle{valuer.NewString("dashed")}
)
func (LineStyle) Enum() []any {
return []any{LineStyleSolid, LineStyleDashed}
}
func (ls LineStyle) ValueOrDefault() string {
if ls.IsZero() {
return LineStyleSolid.StringValue()
}
return ls.StringValue()
}
func (ls LineStyle) MarshalJSON() ([]byte, error) {
return json.Marshal(ls.ValueOrDefault())
}
func (ls *LineStyle) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid line style: must be a string, one of `solid` or `dashed`")
}
if v == "" {
*ls = LineStyleSolid
return nil
}
val := LineStyle{valuer.NewString(v)}
switch val {
case LineStyleSolid, LineStyleDashed:
*ls = val
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid line style %q: must be `solid` or `dashed`", v)
}
}
type FillMode struct{ valuer.String }
var (
FillModeSolid = FillMode{valuer.NewString("solid")} // default
FillModeGradient = FillMode{valuer.NewString("gradient")}
FillModeNone = FillMode{valuer.NewString("none")}
)
func (FillMode) Enum() []any {
return []any{FillModeSolid, FillModeGradient, FillModeNone}
}
func (fm FillMode) ValueOrDefault() string {
if fm.IsZero() {
return FillModeSolid.StringValue()
}
return fm.StringValue()
}
func (fm FillMode) MarshalJSON() ([]byte, error) {
return json.Marshal(fm.ValueOrDefault())
}
func (fm *FillMode) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid fill mode: must be a string, one of `solid`, `gradient`, or `none`")
}
if v == "" {
*fm = FillModeSolid
return nil
}
val := FillMode{valuer.NewString(v)}
switch val {
case FillModeSolid, FillModeGradient, FillModeNone:
*fm = val
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid fill mode %q: must be `solid`, `gradient`, or `none`", v)
}
}
// SpanGaps controls whether lines connect across null values.
// When FillOnlyBelow is false (default), all gaps are connected.
// When FillOnlyBelow is true, only gaps smaller than FillLessThan are connected.
type SpanGaps struct {
FillOnlyBelow bool `json:"fillOnlyBelow"`
FillLessThan valuer.TextDuration `json:"fillLessThan"`
}
type PrecisionOption struct{ valuer.String }
var (
PrecisionOption0 = PrecisionOption{valuer.NewString("0")}
PrecisionOption1 = PrecisionOption{valuer.NewString("1")}
PrecisionOption2 = PrecisionOption{valuer.NewString("2")} // default
PrecisionOption3 = PrecisionOption{valuer.NewString("3")}
PrecisionOption4 = PrecisionOption{valuer.NewString("4")}
PrecisionOptionFull = PrecisionOption{valuer.NewString("full")}
)
func (PrecisionOption) Enum() []any {
return []any{PrecisionOption0, PrecisionOption1, PrecisionOption2, PrecisionOption3, PrecisionOption4, PrecisionOptionFull}
}
func (p PrecisionOption) ValueOrDefault() string {
if p.IsZero() {
return PrecisionOption2.StringValue()
}
return p.StringValue()
}
func (p PrecisionOption) MarshalJSON() ([]byte, error) {
return json.Marshal(p.ValueOrDefault())
}
func (p *PrecisionOption) UnmarshalJSON(data []byte) error {
// Accept int values 0-4 and store as string.
var n int
if err := json.Unmarshal(data, &n); err == nil {
switch n {
case 0, 1, 2, 3, 4:
p.String = valuer.NewString(strconv.Itoa(n))
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid precision option %d: must be `0`, `1`, `2`, `3`, `4`, or `full`", n)
}
}
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid precision option: must be `0`, `1`, `2`, `3`, `4`, or `full`")
}
if v == "" {
*p = PrecisionOption2
return nil
}
val := PrecisionOption{valuer.NewString(v)}
switch val {
case PrecisionOption0, PrecisionOption1, PrecisionOption2, PrecisionOption3, PrecisionOption4, PrecisionOptionFull:
*p = val
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid precision option %q: must be `0`, `1`, `2`, `3`, `4`, or `full`", v)
}
}

View File

@@ -1,861 +0,0 @@
{
"display": {
"name": "The everything dashboard",
"description": "Trying to cover as many concepts here as possible"
},
"duration": "1h",
"datasources": {
"SigNozDatasource": {
"default": true,
"plugin": {
"kind": "signoz/Datasource",
"spec": {}
}
}
},
"variables": [
{
"kind": "ListVariable",
"spec": {
"name": "serviceName",
"display": {
"name": "serviceName"
},
"allowAllValue": true,
"allowMultiple": false,
"sort": "none",
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {
"name": "service.name",
"signal": "metrics"
}
}
}
},
{
"kind": "ListVariable",
"spec": {
"name": "statusCodesFromQuery",
"display": {
"name": "statusCodesFromQuery"
},
"allowAllValue": true,
"allowMultiple": true,
"sort": "alphabetical-asc",
"plugin": {
"kind": "signoz/QueryVariable",
"spec": {
"queryValue": "SELECT JSONExtractString(labels, 'http.status_code') AS status_code FROM signoz_metrics.distributed_time_series_v4_1day WHERE status_code != '' GROUP BY status_code"
}
}
}
},
{
"kind": "ListVariable",
"spec": {
"name": "limit",
"display": {
"name": "limit"
},
"allowAllValue": false,
"allowMultiple": false,
"sort": "none",
"plugin": {
"kind": "signoz/CustomVariable",
"spec": {
"customValue": "1,10,20,40,80,160,200"
}
}
}
},
{
"kind": "TextVariable",
"spec": {
"name": "textboxvar",
"display": {
"name": "textboxvar"
},
"value": "defaultvaluegoeshere",
"plugin": {
"kind": "signoz/TextboxVariable",
"spec": {}
}
}
}
],
"panels": {
"24e2697b": {
"kind": "Panel",
"spec": {
"display": {
"name": "total resp size",
"description": ""
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"fillSpans": true
},
"formatting": {
"unit": "By",
"decimalPrecision": "3"
},
"axes": {
"softMax": 800,
"isLogScale": true
},
"legend": {
"position": "right",
"customColors": {
"{service.name=\"sampleapp-gateway\"}": "#9ea5f7"
}
},
"thresholds": [
{
"value": 1024,
"unit": "By",
"color": "Red",
"label": "upper limit"
},
{
"value": 100,
"unit": "By",
"color": "Orange",
"label": "kinda bad"
}
]
}
},
"links": [
{
"name": "View service details",
"url": "http://localhost:8080/{{_service.name}}?dfddf=%7B%7Blimit%7D%7D"
}
],
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [
{
"metricName": "http.server.response.body.size.sum",
"reduceTo": "sum",
"spaceAggregation": "sum",
"timeAggregation": "rate"
}
],
"filter": {
"expression": "http.response.status_code IN $statusCodesFromQuery"
},
"groupBy": [
{
"name": "service.name",
"fieldDataType": "string",
"fieldContext": "tag"
}
]
}
}
}
}
]
}
},
"ff2f72f1": {
"kind": "Panel",
"spec": {
"display": {
"name": "fraction of calls",
"description": ""
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"fillSpans": true
},
"formatting": {
"decimalPrecision": "1"
},
"thresholds": [
{
"value": 1,
"color": "Blue",
"label": "max possible"
}
]
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "signoz/CompositeQuery",
"spec": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"disabled": true,
"aggregations": [
{
"metricName": "signoz_calls_total",
"reduceTo": "sum",
"spaceAggregation": "sum",
"timeAggregation": "rate"
}
],
"filter": {
"expression": "service.name IN $serviceName AND http.status_code IN $statusCodesFromQuery"
}
}
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "metrics",
"disabled": true,
"aggregations": [
{
"metricName": "signoz_calls_total",
"reduceTo": "sum",
"spaceAggregation": "sum",
"timeAggregation": "rate"
}
],
"filter": {
"expression": "service.name in $serviceName"
}
}
},
{
"type": "builder_formula",
"spec": {
"name": "F1",
"expression": "A / B"
}
}
]
}
}
}
}
]
}
},
"011605e7": {
"kind": "Panel",
"spec": {
"display": {
"name": "total resp size"
},
"plugin": {
"kind": "signoz/BarChartPanel",
"spec": {
"visualization": {
"stackedBarChart": false
},
"formatting": {
"unit": "By"
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [
{
"metricName": "http.server.response.body.size.sum",
"reduceTo": "sum",
"spaceAggregation": "sum",
"timeAggregation": "rate"
}
],
"filter": {
"expression": "http.response.status_code IN $statusCodesFromQuery"
},
"groupBy": [
{
"name": "service.name",
"fieldDataType": "string",
"fieldContext": "tag"
}
]
}
}
}
}
]
}
},
"e23516fc": {
"kind": "Panel",
"spec": {
"display": {
"name": "num traces for service"
},
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {
"formatting": {
"unit": "none",
"decimalPrecision": "1"
},
"thresholds": [
{
"value": 1200000,
"operator": ">",
"unit": "none",
"color": "Red",
"format": "text"
},
{
"value": 1200000,
"operator": "<=",
"unit": "none",
"color": "Green",
"format": "text"
}
]
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "traces",
"aggregations": [
{
"expression": "count() "
}
],
"filter": {
"expression": "service.name = $serviceName "
}
}
}
}
}
]
}
},
"130c8d6b": {
"kind": "Panel",
"spec": {
"display": {
"name": "num logs for service"
},
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {
"formatting": {
"unit": "none",
"decimalPrecision": "1"
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "logs",
"aggregations": [
{
"expression": "count() "
}
],
"filter": {
"expression": "service.name = $serviceName "
}
}
}
}
}
]
}
},
"246f7c6d": {
"kind": "Panel",
"spec": {
"display": {
"name": "num traces for service per resp code"
},
"plugin": {
"kind": "signoz/PieChartPanel",
"spec": {
"formatting": {
"decimalPrecision": "1"
},
"legend": {
"customColors": {
"\"201\"": "#2bc051",
"\"400\"": "#cc462e",
"\"500\"": "#ff0000"
}
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "traces",
"aggregations": [
{
"expression": "count() "
}
],
"filter": {
"expression": "service.name = $serviceName isEntryPoint = 'true'"
},
"groupBy": [
{
"name": "http.response.status_code",
"fieldDataType": "float64",
"fieldContext": "tag"
}
],
"legend": "\"{{http.response.status_code}}\""
}
}
}
}
]
}
},
"21f7d4d0": {
"kind": "Panel",
"spec": {
"display": {
"name": "average latency per service"
},
"plugin": {
"kind": "signoz/TablePanel",
"spec": {
"formatting": {
"columnUnits": {
"A": "s"
}
},
"thresholds": [
{
"value": 1,
"operator": ">",
"unit": "min",
"color": "Red",
"format": "text",
"columnName": "A"
}
]
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "signoz/ClickHouseSQL",
"spec": {
"name": "A",
"query": "WITH\n __spatial_aggregation_cte AS\n (\n SELECT\n toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts,\n `service.name`,\n le,\n sum(value) / 60 AS value\n FROM signoz_metrics.distributed_samples_v4 AS points\n INNER JOIN\n (\n SELECT\n fingerprint,\n JSONExtractString(labels, 'service.name') AS `service.name`,\n JSONExtractString(labels, 'le') AS le\n FROM signoz_metrics.time_series_v4\n WHERE (metric_name IN ('signoz_latency.bucket')) AND (LOWER(temporality) LIKE LOWER('delta')) AND (__normalized = 0)\n GROUP BY\n fingerprint,\n `service.name`,\n le\n ) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint\n WHERE metric_name IN ('signoz_latency.bucket')\n GROUP BY\n ts,\n `service.name`,\n le\n ),\n __histogramCTE AS\n (\n SELECT\n ts,\n `service.name`,\n histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.9) AS value\n FROM __spatial_aggregation_cte\n GROUP BY\n `service.name`,\n ts\n ORDER BY\n `service.name` ASC,\n ts ASC\n )\nSELECT\n `service.name` AS service,\n avg(value) AS A\nFROM __histogramCTE\nGROUP BY `service.name`"
}
}
}
}
]
}
},
"ad5fd556": {
"kind": "Panel",
"spec": {
"display": {
"name": "logs from service"
},
"plugin": {
"kind": "signoz/ListPanel",
"spec": {
"selectFields": [
{
"name": "timestamp",
"signal": "logs"
},
{
"name": "body",
"signal": "logs"
},
{
"name": "error",
"fieldDataType": "string"
}
]
}
},
"queries": [
{
"kind": "LogQuery",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "logs",
"aggregations": [
{
"expression": "count() "
}
],
"filter": {
"expression": "service.name = $serviceName"
},
"groupBy": [],
"order": [
{
"key": {
"name": "timestamp"
},
"direction": "desc"
},
{
"key": {
"name": "id"
},
"direction": "desc"
}
]
}
}
}
}
]
}
},
"f07b59ee": {
"kind": "Panel",
"spec": {
"display": {
"name": "response size buckets"
},
"plugin": {
"kind": "signoz/HistogramPanel",
"spec": {
"histogramBuckets": {
"bucketCount": 60,
"mergeAllActiveQueries": true
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [
{
"metricName": "http.server.response.body.size.bucket",
"reduceTo": "avg",
"spaceAggregation": "p90",
"timeAggregation": "rate"
}
]
}
}
}
}
]
}
},
"e1a41831": {
"kind": "Panel",
"spec": {
"display": {
"name": "trace operator",
"description": ""
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"legend": {
"position": "right"
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "signoz/CompositeQuery",
"spec": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"aggregations": [
{
"expression": "count() "
}
],
"filter": {
"expression": "service.name = 'sampleapp-gateway' "
},
"legend": "Gateway"
}
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "traces",
"aggregations": [
{
"expression": "count() "
}
],
"filter": {
"expression": "http.response.status_code = 200"
},
"legend": "$serviceName"
}
},
{
"type": "builder_trace_operator",
"spec": {
"name": "T1",
"aggregations": [
{
"expression": "count()",
"alias": "request_count"
}
]
}
}
]
}
}
}
}
]
}
},
"f0d70491": {
"kind": "Panel",
"spec": {
"display": {
"name": "no results in this promql",
"description": ""
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "signoz/CompositeQuery",
"spec": {
"queries": [
{
"type": "promql",
"spec": {
"name": "A",
"query": "sum(rate(flask_exporter_info[5m]))"
}
},
{
"type": "promql",
"spec": {
"name": "B",
"query": "sum(increase(flask_exporter_info[5m]))"
}
}
]
}
}
}
}
]
}
},
"0e6eb4ca": {
"kind": "Panel",
"spec": {
"display": {
"name": "no results in this promql",
"description": ""
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "signoz/PromQLQuery",
"spec": {
"name": "A",
"query": "sum(rate(flask_exporter_info[5m]))"
}
}
}
}
]
}
}
},
"layouts": [
{
"kind": "Grid",
"spec": {
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/24e2697b"
}
},
{
"x": 6,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/ff2f72f1"
}
},
{
"x": 0,
"y": 6,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/011605e7"
}
},
{
"x": 6,
"y": 6,
"width": 6,
"height": 3,
"content": {
"$ref": "#/spec/panels/e23516fc"
}
},
{
"x": 6,
"y": 9,
"width": 6,
"height": 3,
"content": {
"$ref": "#/spec/panels/130c8d6b"
}
},
{
"x": 0,
"y": 12,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/246f7c6d"
}
},
{
"x": 6,
"y": 12,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/21f7d4d0"
}
},
{
"x": 0,
"y": 18,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/ad5fd556"
}
},
{
"x": 6,
"y": 18,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/f07b59ee"
}
},
{
"x": 0,
"y": 24,
"width": 12,
"height": 6,
"content": {
"$ref": "#/spec/panels/e1a41831"
}
},
{
"x": 0,
"y": 30,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/f0d70491"
}
},
{
"x": 6,
"y": 30,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/0e6eb4ca"
}
}
]
}
}
]
}

View File

@@ -1,154 +0,0 @@
{
"display": {
"name": "NV dashboard with sections",
"description": ""
},
"datasources": {
"SigNozDatasource": {
"default": true,
"plugin": {
"kind": "signoz/Datasource",
"spec": {}
}
}
},
"panels": {
"b424e23b": {
"kind": "Panel",
"spec": {
"display": {
"name": ""
},
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {
"formatting": {
"unit": "s",
"decimalPrecision": "2"
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [
{
"metricName": "container.cpu.time",
"reduceTo": "sum",
"spaceAggregation": "sum",
"timeAggregation": "rate"
}
],
"filter": {
"expression": ""
}
}
}
}
}
]
}
},
"251df4d5": {
"kind": "Panel",
"spec": {
"display": {
"name": ""
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"fillSpans": false
},
"formatting": {
"unit": "recommendations",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {"fillOnlyBelow": true}
},
"legend": {
"position": "bottom"
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [
{
"metricName": "app_recommendations_counter",
"reduceTo": "sum",
"spaceAggregation": "sum",
"timeAggregation": "rate"
}
],
"filter": {
"expression": ""
}
}
}
}
}
]
}
}
},
"layouts": [
{
"kind": "Grid",
"spec": {
"display": {
"title": "Bravo"
},
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/b424e23b"
}
}
]
}
},
{
"kind": "Grid",
"spec": {
"display": {
"title": "Alpha"
},
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/251df4d5"
}
}
]
}
}
]
}

View File

@@ -99,11 +99,45 @@ func (q *QueryEnvelope) UnmarshalJSON(data []byte) error {
// 2. Decode the spec based on the Type.
switch shadow.Type {
case QueryTypeBuilder, QueryTypeSubQuery:
spec, err := UnmarshalBuilderQueryBySignal(shadow.Spec)
if err != nil {
return err
var header struct {
Signal telemetrytypes.Signal `json:"signal"`
}
if err := json.Unmarshal(shadow.Spec, &header); err != nil {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"cannot detect builder signal: %v",
err,
)
}
switch header.Signal {
case telemetrytypes.SignalTraces:
var spec QueryBuilderQuery[TraceAggregation]
if err := json.Unmarshal(shadow.Spec, &spec); err != nil {
return wrapUnmarshalError(err, "invalid trace builder query spec: %v", err)
}
q.Spec = spec
case telemetrytypes.SignalLogs:
var spec QueryBuilderQuery[LogAggregation]
if err := json.Unmarshal(shadow.Spec, &spec); err != nil {
return wrapUnmarshalError(err, "invalid log builder query spec: %v", err)
}
q.Spec = spec
case telemetrytypes.SignalMetrics:
var spec QueryBuilderQuery[MetricAggregation]
if err := json.Unmarshal(shadow.Spec, &spec); err != nil {
return wrapUnmarshalError(err, "invalid metric builder query spec: %v", err)
}
q.Spec = spec
default:
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"unknown builder signal %q",
header.Signal,
).WithAdditional(
"Valid signals are: traces, logs, metrics",
)
}
q.Spec = spec
case QueryTypeFormula:
var spec QueryBuilderFormula
@@ -157,49 +191,6 @@ func (q *QueryEnvelope) UnmarshalJSON(data []byte) error {
return nil
}
// UnmarshalBuilderQueryBySignal peeks at the "signal" field in the JSON data and
// unmarshals into the correct generic QueryBuilderQuery type. Returns the typed spec.
func UnmarshalBuilderQueryBySignal(data []byte) (any, error) {
var header struct {
Signal telemetrytypes.Signal `json:"signal"`
}
if err := json.Unmarshal(data, &header); err != nil {
return nil, errors.NewInvalidInputf(
errors.CodeInvalidInput,
"cannot detect builder signal: %v",
err,
)
}
switch header.Signal {
case telemetrytypes.SignalTraces:
var spec QueryBuilderQuery[TraceAggregation]
if err := json.Unmarshal(data, &spec); err != nil {
return nil, wrapUnmarshalError(err, "invalid trace builder query spec: %v", err)
}
return spec, nil
case telemetrytypes.SignalLogs:
var spec QueryBuilderQuery[LogAggregation]
if err := json.Unmarshal(data, &spec); err != nil {
return nil, wrapUnmarshalError(err, "invalid log builder query spec: %v", err)
}
return spec, nil
case telemetrytypes.SignalMetrics:
var spec QueryBuilderQuery[MetricAggregation]
if err := json.Unmarshal(data, &spec); err != nil {
return nil, wrapUnmarshalError(err, "invalid metric builder query spec: %v", err)
}
return spec, nil
default:
return nil, errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid signal %q; allowed values: %v",
header.Signal.StringValue(),
telemetrytypes.Signal{}.Enum(),
)
}
}
type CompositeQuery struct {
// Queries is the queries to use for the request.
Queries []QueryEnvelope `json:"queries"`

View File

@@ -30,7 +30,7 @@ const (
)
type TelemetryFieldKey struct {
Name string `json:"name" validate:"required" required:"true"`
Name string `json:"name" required:"true"`
Description string `json:"description,omitempty"`
Unit string `json:"unit,omitempty"`
Signal Signal `json:"signal,omitzero"`