Compare commits

...

42 Commits

Author SHA1 Message Date
Abhishek Kumar Singh
abc0d71c16 Merge branch 'chore/am_custom_notifiers' into feat/alert_manager_template 2026-04-13 22:13:25 +05:30
Abhishek Kumar Singh
2e2dd4c42b Merge branch 'main' into chore/am_custom_notifiers 2026-04-13 22:04:08 +05:30
swapnil-signoz
dce496d099 refactor: cloud integration modules implementation (#10718)
* feat: adding cloud integration type for refactor

* refactor: store interfaces to use local types and error

* feat: adding sql store implementation

* refactor: removing interface check

* feat: adding updated types for cloud integration

* refactor: using struct for map

* refactor: update cloud integration types and module interface

* fix: correct GetService signature and remove shadowed Data field

* feat: implement cloud integration store

* refactor: adding comments and removed wrong code

* refactor: streamlining types

* refactor: add comments for backward compatibility in PostableAgentCheckInRequest

* refactor: update Dashboard struct comments and remove unused fields

* refactor: split upsert store method

* feat: adding integration test

* refactor: clean up types

* refactor: renaming service type to service id

* refactor: using serviceID type

* feat: adding method for service id creation

* refactor: updating store methods

* refactor: clean up

* refactor: clean up

* refactor: review comments

* refactor: clean up

* feat: adding handlers

* fix: lint and ci issues

* fix: lint issues

* fix: update error code for service not found

* feat: adding handler skeleton

* chore: removing todo comment

* feat: adding frontend openapi schema

* feat: adding module implementation for create account

* fix: returning valid error instead of panic

* fix: module test

* refactor: simplify ingestion key retrieval logic

* feat: adding module implementation for AWS

* refactor: ci lint changes

* refactor: python formatting change

* fix: new storable account func was unsetting provider account id

* refactor: python lint changes

* refactor: adding validation on update account request

* refactor: reverting older tests and adding new tests

* chore: lint changes

* feat: using service account for API key

* refactor: renaming tests and cleanup

* refactor: removing dashboard overview images

* feat: adding service definition store

* chore: adding TODO comments

* feat: adding API for getting connection credentials

* feat: adding openapi spec for the endpoint

* feat: adding tests for credential API

* feat: adding cloud integration config

* refactor: updating test with new env variable for config

* refactor: moving few cloud provider interface methods to types

* refactor: review comments resolution

* refactor: lint changes

* refactor: code clean up

* refactor: removing email domain function

* refactor: review comments and clean up

* refactor: lint fixes

* refactor: review changes

- Added get connected account module method
- Fixed integration tests
- Removed cloud integration store as callback function's param

* refactor: changing wrong dashboard id for EKS definition
2026-04-13 15:16:26 +00:00
aniketio-ctrl
c15e3ca59e feat(billing): use meter api from zeus (#10903)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(meter-api): use meter api from zeus

* feat(meter-api): bug fixes

* feat(meter-api): bug fixes

* feat(meter-api): bug fixes
2026-04-13 13:34:06 +00:00
Vikrant Gupta
9ec6da5e76 fix(authz): unauthenticated error code for deleted service account (#10878)
* fix(authz): populate correct error for deleted service account

* chore(authz): reduce the regex restrictions on service accounts

* chore(authz): reduce the regex restrictions on service accounts

* fix(authz): populate correct error for deleted service account

* fix(authz): populate correct error for deleted service account
2026-04-13 13:19:25 +00:00
Vikrant Gupta
3e241944e7 chore(sqlstore): added connection max lifetime support (#10913)
* chore(sqlstore): added connection max lifetime param support

* chore(sqlstore): fix test

* chore(sqlstore): default to 0 for conn_max_lifetime

* chore(sqlstore): rename the config

* chore(sqlstore): rename the config
2026-04-13 13:09:00 +00:00
SagarRajput-7
5251339a16 chore: config setup for asset migration, allow use of @ alias (#10901)
* chore: config setup for asset migration, allow use of @ alias

* chore: added modulewrapper for jests

* chore: jest module resolution fix

* chore: remove the snapshot update since no migration is done under this pr
2026-04-13 12:31:47 +00:00
Abhi kumar
702a16f80d chore: added test-ids to identify elements better in e2es (#10914) 2026-04-13 12:12:53 +00:00
Abhishek Kumar Singh
0ce76a94d6 Merge branch 'chore/am_custom_notifiers' into feat/alert_manager_template 2026-03-31 17:57:02 +05:30
Abhishek Kumar Singh
46ae74ced5 chore: updated email notifier from upstream 2026-03-31 11:52:08 +05:30
Abhishek Kumar Singh
2d8c1b7c86 chore: updated licenses for notifiers 2026-03-31 11:51:45 +05:30
Abhishek Kumar Singh
6602c8c523 refactor: lint fixes 2026-03-31 10:46:52 +05:30
Abhishek Kumar Singh
c22dbcbf74 refactor: review comments 2026-03-31 10:44:37 +05:30
Abhishek Kumar Singh
250bd9abeb Merge branch 'main' into chore/am_custom_notifiers 2026-03-31 10:15:39 +05:30
Abhishek Kumar Singh
f132dc28c3 chore: updated br with new line in test and logs added 2026-03-23 17:02:30 +05:30
Abhishek Kumar Singh
9ffc1203da chore: updated newline to markdown format 2026-03-19 22:50:24 +05:30
Abhishek Kumar Singh
79518b6823 Merge branch 'chore/am_custom_notifiers' into feat/alert_manager_template 2026-03-17 20:15:00 +05:30
Abhishek Kumar Singh
e6a9f49cec Merge branch 'main' into chore/am_custom_notifiers 2026-03-17 20:14:30 +05:30
Abhishek Kumar Singh
fd5fc40823 chore: updated comments 2026-03-16 18:19:03 +05:30
Abhishek Kumar Singh
db2e2a4617 chore: lint fix 2026-03-16 15:54:22 +05:30
Abhishek Kumar Singh
9368d3f393 refactor: comments and test improvements 2026-03-16 15:47:45 +05:30
Abhishek Kumar Singh
0c97ba36d6 refactor: test case and sb related changed 2026-03-16 15:12:23 +05:30
Abhishek Kumar Singh
2e1bdbc2fd chore: added test for missing function 2026-03-16 14:49:52 +05:30
Abhishek Kumar Singh
330737f779 chore: renamed the interface 2026-03-13 19:20:49 +05:30
Abhishek Kumar Singh
f0c531ae2b chore: lint fix 2026-03-13 19:11:34 +05:30
Abhishek Kumar Singh
54477ee786 feat: added support for and in templating 2026-03-13 19:09:39 +05:30
Abhishek Kumar Singh
d281f7b6a2 test: fix preprocessor test case 2026-03-13 17:31:28 +05:30
Abhishek Kumar Singh
378dc350ef refactor: added extractCommonKV instead of 2 different functions 2026-03-13 17:10:13 +05:30
Abhishek Kumar Singh
89c38ed9bc feat: converted alerttemplater to interface and updated tests 2026-03-13 17:02:26 +05:30
Abhishek Kumar Singh
04c4869b12 chore: added handling for missing variable used in template 2026-03-13 14:10:13 +05:30
Abhishek Kumar Singh
388a1184ca chore: fix lint issues 2026-03-12 21:51:13 +05:30
Abhishek Kumar Singh
03901b353b chore: hooked preProcess function in expandTitle and body, added labels and annotations in alertdata 2026-03-12 21:47:43 +05:30
Abhishek Kumar Singh
74441c74a8 feat: added preprocessor for alert templater 2026-03-12 20:54:28 +05:30
Abhishek Kumar Singh
93d332bef2 chore: exposed templates for alertmanager types 2026-03-12 18:52:40 +05:30
Abhishek Kumar Singh
1e730cae8c chore: added utils for using variables with $ notation 2026-03-12 16:34:43 +05:30
Abhishek Kumar Singh
01a09cf6d2 chore: updated test name + code for timeout errors 2026-03-12 10:22:42 +05:30
Abhishek Kumar Singh
403dddab85 feat: alert manager template to template title and notification body 2026-03-11 21:55:09 +05:30
Abhishek Kumar Singh
d07a833574 chore: added tracing to msteamsv2 notifier 2026-03-11 16:05:00 +05:30
Abhishek Kumar Singh
b39bec7245 Merge branch 'main' into chore/am_custom_notifiers 2026-03-10 22:37:24 +05:30
Abhishek Kumar Singh
6ff55c48be chore: fix email linter 2026-03-10 22:19:05 +05:30
Abhishek Kumar Singh
b15fa0f88f chore: lint fixs 2026-03-10 21:57:53 +05:30
Abhishek Kumar Singh
19fe4f860e chore: custom notifiers in alert manager 2026-03-10 13:20:04 +05:30
101 changed files with 9727 additions and 312 deletions

View File

@@ -18,11 +18,15 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/gateway/noopgateway"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/licensing/nooplicensing"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/queryparser"
@@ -100,6 +104,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
return querier.NewHandler(ps, q, a)
},
func(_ sqlstore.SQLStore, _ global.Global, _ zeus.Zeus, _ gateway.Gateway, _ licensing.Licensing, _ serviceaccount.Module, _ cloudintegration.Config) (cloudintegration.Module, error) {
return implcloudintegration.NewModule(), nil
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", errors.Attr(err))

View File

@@ -17,6 +17,8 @@ import (
"github.com/SigNoz/signoz/ee/gateway/httpgateway"
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration/implcloudprovider"
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
eequerier "github.com/SigNoz/signoz/ee/querier"
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
@@ -31,10 +33,14 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
pkgcloudintegration "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/signoz"
@@ -42,6 +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"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus"
)
@@ -127,7 +134,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return nil, err
}
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, dashboardModule), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)
@@ -146,8 +152,21 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
communityHandler := querier.NewHandler(ps, q, a)
return eequerier.NewHandler(ps, q, communityHandler)
},
)
func(sqlStore sqlstore.SQLStore, global global.Global, zeus zeus.Zeus, gateway gateway.Gateway, licensing licensing.Licensing, serviceAccount serviceaccount.Module, config cloudintegration.Config) (cloudintegration.Module, error) {
defStore := pkgcloudintegration.NewServiceDefinitionStore()
awsCloudProviderModule, err := implcloudprovider.NewAWSCloudProvider(defStore)
if err != nil {
return nil, err
}
azureCloudProviderModule := implcloudprovider.NewAzureCloudProvider()
cloudProvidersMap := map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule{
cloudintegrationtypes.CloudProviderTypeAWS: awsCloudProviderModule,
cloudintegrationtypes.CloudProviderTypeAzure: azureCloudProviderModule,
}
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", errors.Attr(err))
return err

View File

@@ -82,6 +82,9 @@ sqlstore:
provider: sqlite
# The maximum number of open connections to the database.
max_open_conns: 100
# The maximum amount of time a connection may be reused.
# If max_conn_lifetime == 0, connections are not closed due to a connection's age.
max_conn_lifetime: 0
sqlite:
# The path to the SQLite database file.
path: /var/lib/signoz/signoz.db
@@ -395,3 +398,10 @@ auditor:
max_interval: 30s
# The total maximum time spent retrying.
max_elapsed_time: 60s
##################### Cloud Integration #####################
cloudintegration:
# cloud integration agent configuration
agent:
# The version of the cloud integration agent.
version: v0.0.8

View File

@@ -0,0 +1,132 @@
package implcloudprovider
import (
"context"
"fmt"
"net/url"
"sort"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
)
type awscloudprovider struct {
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
}
func NewAWSCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore) (cloudintegration.CloudProviderModule, error) {
return &awscloudprovider{serviceDefinitions: defStore}, nil
}
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)
u, _ := url.Parse(baseURL)
q := u.Query()
q.Set("region", req.Config.Aws.DeploymentRegion)
u.Fragment = "/stacks/quickcreate"
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("param_SigNozIntegrationAgentVersion", req.Config.AgentVersion)
q.Set("param_SigNozApiUrl", req.Credentials.SigNozAPIURL)
q.Set("param_SigNozApiKey", req.Credentials.SigNozAPIKey)
q.Set("param_SigNozAccountId", account.ID.StringValue())
q.Set("param_IngestionUrl", req.Credentials.IngestionURL)
q.Set("param_IngestionKey", req.Credentials.IngestionKey)
return &cloudintegrationtypes.ConnectionArtifact{
Aws: &cloudintegrationtypes.AWSConnectionArtifact{
ConnectionURL: 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)
}
func (provider *awscloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
serviceDef, err := provider.serviceDefinitions.Get(ctx, cloudintegrationtypes.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)
}
return serviceDef, nil
}
func (provider *awscloudprovider) BuildIntegrationConfig(
ctx context.Context,
account *cloudintegrationtypes.Account,
services []*cloudintegrationtypes.StorableCloudIntegrationService,
) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
// Sort services for deterministic output
sort.Slice(services, func(i, j int) bool {
return services[i].Type.StringValue() < services[j].Type.StringValue()
})
compiledMetrics := new(cloudintegrationtypes.AWSMetricsCollectionStrategy)
compiledLogs := new(cloudintegrationtypes.AWSLogsCollectionStrategy)
var compiledS3Buckets map[string][]string
for _, storedSvc := range services {
svcCfg, err := cloudintegrationtypes.NewServiceConfigFromJSON(cloudintegrationtypes.CloudProviderTypeAWS, storedSvc.Config)
if err != nil {
return nil, err
}
svcDef, err := provider.GetServiceDefinition(ctx, storedSvc.Type)
if err != nil {
return nil, err
}
strategy := svcDef.TelemetryCollectionStrategy.AWS
logsEnabled := svcCfg.IsLogsEnabled(cloudintegrationtypes.CloudProviderTypeAWS)
// S3Sync: logs come directly from configured S3 buckets, not CloudWatch subscriptions
if storedSvc.Type == cloudintegrationtypes.AWSServiceS3Sync {
if logsEnabled && svcCfg.AWS.Logs.S3Buckets != nil {
compiledS3Buckets = svcCfg.AWS.Logs.S3Buckets
}
// no need to go ahead as the code block specifically checks for the S3Sync service
continue
}
if logsEnabled && strategy.Logs != nil {
compiledLogs.Subscriptions = append(compiledLogs.Subscriptions, strategy.Logs.Subscriptions...)
}
metricsEnabled := svcCfg.IsMetricsEnabled(cloudintegrationtypes.CloudProviderTypeAWS)
if metricsEnabled && strategy.Metrics != nil {
compiledMetrics.StreamFilters = append(compiledMetrics.StreamFilters, strategy.Metrics.StreamFilters...)
}
}
collectionStrategy := new(cloudintegrationtypes.AWSTelemetryCollectionStrategy)
if len(compiledMetrics.StreamFilters) > 0 {
collectionStrategy.Metrics = compiledMetrics
}
if len(compiledLogs.Subscriptions) > 0 {
collectionStrategy.Logs = compiledLogs
}
if compiledS3Buckets != nil {
collectionStrategy.S3Buckets = compiledS3Buckets
}
return &cloudintegrationtypes.ProviderIntegrationConfig{
AWS: &cloudintegrationtypes.AWSIntegrationConfig{
EnabledRegions: account.Config.AWS.Regions,
TelemetryCollectionStrategy: collectionStrategy,
},
}, nil
}

View File

@@ -0,0 +1,34 @@
package implcloudprovider
import (
"context"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
)
type azurecloudprovider struct{}
func NewAzureCloudProvider() cloudintegration.CloudProviderModule {
return &azurecloudprovider{}
}
func (provider *azurecloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
panic("implement me")
}
func (provider *azurecloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
panic("implement me")
}
func (provider *azurecloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
panic("implement me")
}
func (provider *azurecloudprovider) BuildIntegrationConfig(
ctx context.Context,
account *cloudintegrationtypes.Account,
services []*cloudintegrationtypes.StorableCloudIntegrationService,
) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
panic("implement me")
}

View File

@@ -0,0 +1,540 @@
package implcloudintegration
import (
"context"
"fmt"
"sort"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
"github.com/SigNoz/signoz/pkg/types/zeustypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/pkg/zeus"
)
type module struct {
store cloudintegrationtypes.Store
gateway gateway.Gateway
zeus zeus.Zeus
licensing licensing.Licensing
global global.Global
serviceAccount serviceaccount.Module
cloudProvidersMap map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule
config cloudintegration.Config
}
func NewModule(
store cloudintegrationtypes.Store,
global global.Global,
zeus zeus.Zeus,
gateway gateway.Gateway,
licensing licensing.Licensing,
serviceAccount serviceaccount.Module,
cloudProvidersMap map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule,
config cloudintegration.Config,
) (cloudintegration.Module, error) {
return &module{
store: store,
global: global,
zeus: zeus,
gateway: gateway,
licensing: licensing,
serviceAccount: serviceAccount,
cloudProvidersMap: cloudProvidersMap,
config: config,
}, nil
}
// 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) {
// get license to get the deployment details
license, 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())
}
// get deployment details from zeus, ignore error
respBytes, _ := module.zeus.GetDeployment(ctx, license.Key)
var signozAPIURL string
if len(respBytes) > 0 {
// parse deployment details, ignore error, if client is asking api url every time check for possible error
deployment, _ := zeustypes.NewGettableDeployment(respBytes)
if deployment != nil {
signozAPIURL, _ = cloudintegrationtypes.GetSigNozAPIURLFromDeployment(deployment)
}
}
// ignore error
apiKey, _ := module.getOrCreateAPIKey(ctx, orgID, provider)
// ignore error
ingestionKey, _ := module.getOrCreateIngestionKey(ctx, orgID, provider)
return cloudintegrationtypes.NewCredentials(
signozAPIURL,
apiKey,
module.global.GetConfig(ctx).IngestionURL,
ingestionKey,
), nil
}
func (module *module) CreateAccount(ctx context.Context, account *cloudintegrationtypes.Account) error {
_, err := module.licensing.GetActive(ctx, account.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())
}
storableCloudIntegration, err := cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return err
}
return module.store.CreateAccount(ctx, storableCloudIntegration)
}
func (module *module) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
_, err := module.licensing.GetActive(ctx, account.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())
}
cloudProviderModule, err := module.getCloudProvider(account.Provider)
if err != nil {
return nil, err
}
req.Config.SetAgentVersion(module.config.Agent.Version)
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) {
_, 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())
}
storableAccount, err := module.store.GetAccountByID(ctx, orgID, accountID, provider)
if err != nil {
return nil, err
}
return cloudintegrationtypes.NewAccountFromStorable(storableAccount)
}
func (module *module) GetConnectedAccount(ctx context.Context, orgID, accountID valuer.UUID, provider cloudintegrationtypes.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())
}
storableAccount, err := module.store.GetConnectedAccount(ctx, orgID, accountID, provider)
if err != nil {
return nil, err
}
return cloudintegrationtypes.NewAccountFromStorable(storableAccount)
}
// ListAccounts return only agent connected accounts.
func (module *module) ListAccounts(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.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())
}
storableAccounts, err := module.store.ListConnectedAccounts(ctx, orgID, provider)
if err != nil {
return nil, err
}
return cloudintegrationtypes.NewAccountsFromStorables(storableAccounts)
}
func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.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())
}
connectedAccount, err := module.store.GetConnectedAccountByProviderAccountID(ctx, orgID, req.ProviderAccountID, provider)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
// If a different integration is already connected to this provider account ID, reject the check-in.
// Allow re-check-in from the same integration (e.g. agent restarting).
if connectedAccount != nil && connectedAccount.ID != req.CloudIntegrationID {
errMessage := fmt.Sprintf("provider account id %s is already connected to cloud integration id %s", req.ProviderAccountID, connectedAccount.ID)
return nil, errors.New(errors.TypeAlreadyExists, cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected, errMessage)
}
account, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
if err != nil {
return nil, err
}
// If account has been removed (disconnected), return a minimal response with empty integration config.
// The agent uses this response to clean up resources
if account.RemovedAt != nil {
return cloudintegrationtypes.NewAgentCheckInResponse(
req.ProviderAccountID,
account.ID.StringValue(),
new(cloudintegrationtypes.ProviderIntegrationConfig),
account.RemovedAt,
), nil
}
// update account with cloud provider account id and agent report (heartbeat)
account.Update(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data))
err = module.store.UpdateAccount(ctx, account)
if err != nil {
return nil, err
}
// Get account as domain object for config access (enabled regions, etc.)
domainAccount, err := cloudintegrationtypes.NewAccountFromStorable(account)
if err != nil {
return nil, err
}
cloudProvider, err := module.getCloudProvider(provider)
if err != nil {
return nil, err
}
storedServices, err := module.store.ListServices(ctx, req.CloudIntegrationID)
if err != nil {
return nil, err
}
// Delegate integration config building entirely to the provider module
integrationConfig, err := cloudProvider.BuildIntegrationConfig(ctx, domainAccount, storedServices)
if err != nil {
return nil, err
}
return cloudintegrationtypes.NewAgentCheckInResponse(
req.ProviderAccountID,
account.ID.StringValue(),
integrationConfig,
account.RemovedAt,
), nil
}
func (module *module) UpdateAccount(ctx context.Context, account *cloudintegrationtypes.Account) error {
_, err := module.licensing.GetActive(ctx, account.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())
}
storableAccount, err := cloudintegrationtypes.NewStorableCloudIntegration(account)
if err != nil {
return err
}
return module.store.UpdateAccount(ctx, storableAccount)
}
func (module *module) DisconnectAccount(ctx context.Context, orgID valuer.UUID, accountID valuer.UUID, provider cloudintegrationtypes.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())
}
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) {
_, 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())
}
cloudProvider, err := module.getCloudProvider(provider)
if err != nil {
return nil, err
}
serviceDefinitions, err := cloudProvider.ListServiceDefinitions(ctx)
if err != nil {
return nil, err
}
enabledServiceIDs := map[string]bool{}
if !integrationID.IsZero() {
storedServices, err := module.store.ListServices(ctx, integrationID)
if err != nil {
return nil, err
}
for _, svc := range storedServices {
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, svc.Config)
if err != nil {
return nil, err
}
if serviceConfig.IsServiceEnabled(provider) {
enabledServiceIDs[svc.Type.StringValue()] = true
}
}
}
resp := make([]*cloudintegrationtypes.ServiceMetadata, 0, len(serviceDefinitions))
for _, serviceDefinition := range serviceDefinitions {
resp = append(resp, cloudintegrationtypes.NewServiceMetadata(*serviceDefinition, enabledServiceIDs[serviceDefinition.ID]))
}
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) {
_, 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())
}
cloudProvider, err := module.getCloudProvider(provider)
if err != nil {
return nil, err
}
serviceDefinition, err := cloudProvider.GetServiceDefinition(ctx, serviceID)
if err != nil {
return nil, err
}
var integrationService *cloudintegrationtypes.CloudIntegrationService
if !cloudIntegrationID.IsZero() {
storedService, err := module.store.GetServiceByServiceID(ctx, cloudIntegrationID, serviceID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if storedService != nil {
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedService.Config)
if err != nil {
return nil, err
}
integrationService = cloudintegrationtypes.NewCloudIntegrationServiceFromStorable(storedService, serviceConfig)
}
}
return cloudintegrationtypes.NewService(*serviceDefinition, integrationService), nil
}
func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.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())
}
cloudProvider, err := module.getCloudProvider(provider)
if err != nil {
return err
}
serviceDefinition, err := cloudProvider.GetServiceDefinition(ctx, service.Type)
if err != nil {
return err
}
configJSON, err := service.Config.ToJSON(provider, service.Type, &serviceDefinition.SupportedSignals)
if err != nil {
return err
}
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 {
_, 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())
}
cloudProvider, err := module.getCloudProvider(provider)
if err != nil {
return err
}
serviceDefinition, err := cloudProvider.GetServiceDefinition(ctx, integrationService.Type)
if err != nil {
return err
}
configJSON, err := integrationService.Config.ToJSON(provider, integrationService.Type, &serviceDefinition.SupportedSignals)
if err != nil {
return err
}
storableService := cloudintegrationtypes.NewStorableCloudIntegrationService(integrationService, string(configJSON))
return module.store.UpdateService(ctx, storableService)
}
func (module *module) GetDashboardByID(ctx context.Context, orgID valuer.UUID, id string) (*dashboardtypes.Dashboard, 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())
}
_, _, _, err = cloudintegrationtypes.ParseCloudIntegrationDashboardID(id)
if err != nil {
return nil, err
}
allDashboards, err := module.listDashboards(ctx, orgID)
if err != nil {
return nil, err
}
for _, d := range allDashboards {
if d.ID == id {
return d, nil
}
}
return nil, errors.New(errors.TypeNotFound, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "cloud integration dashboard not found")
}
func (module *module) ListDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, 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())
}
return module.listDashboards(ctx, orgID)
}
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
stats := make(map[string]any)
// get connected accounts for AWS
awsAccountsCount, err := module.store.CountConnectedAccounts(ctx, orgID, cloudintegrationtypes.CloudProviderTypeAWS)
if err == nil {
stats["cloudintegration.aws.connectedaccounts.count"] = awsAccountsCount
}
// NOTE: not adding stats for services for now.
// TODO: add more cloud providers when supported
return stats, nil
}
func (module *module) getCloudProvider(provider cloudintegrationtypes.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())
}
func (module *module) getOrCreateIngestionKey(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (string, error) {
keyName := cloudintegrationtypes.NewIngestionKeyName(provider)
result, err := module.gateway.SearchIngestionKeysByName(ctx, orgID, keyName, 1, 10)
if err != nil {
return "", errors.WrapInternalf(err, errors.CodeInternal, "couldn't search ingestion keys")
}
// ideally there should be only one key per cloud integration provider
if len(result.Keys) > 0 {
return result.Keys[0].Value, nil
}
createdIngestionKey, err := module.gateway.CreateIngestionKey(ctx, orgID, keyName, []string{"integration"}, time.Time{})
if err != nil {
return "", errors.WrapInternalf(err, errors.CodeInternal, "couldn't create ingestion key")
}
return createdIngestionKey.Value, nil
}
func (module *module) getOrCreateAPIKey(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.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)
if err != nil {
return "", err
}
err = module.serviceAccount.SetRoleByName(ctx, orgID, serviceAccount.ID, authtypes.SigNozViewerRoleName)
if err != nil {
return "", err
}
factorAPIKey, err := serviceAccount.NewFactorAPIKey(provider.StringValue(), 0)
if err != nil {
return "", err
}
factorAPIKey, err = module.serviceAccount.GetOrCreateFactorAPIKey(ctx, factorAPIKey)
if err != nil {
return "", err
}
return factorAPIKey.Key, nil
}
func (module *module) listDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
var allDashboards []*dashboardtypes.Dashboard
for provider := range module.cloudProvidersMap {
cloudProvider, err := module.getCloudProvider(provider)
if err != nil {
return nil, err
}
connectedAccounts, err := module.store.ListConnectedAccounts(ctx, orgID, provider)
if err != nil {
return nil, err
}
for _, storableAccount := range connectedAccounts {
storedServices, err := module.store.ListServices(ctx, storableAccount.ID)
if err != nil {
return nil, err
}
for _, storedSvc := range storedServices {
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedSvc.Config)
if err != nil || !serviceConfig.IsMetricsEnabled(provider) {
continue
}
svcDef, err := cloudProvider.GetServiceDefinition(ctx, storedSvc.Type)
if err != nil || svcDef == nil {
continue
}
dashboards := cloudintegrationtypes.GetDashboardsFromAssets(
storedSvc.Type.StringValue(),
orgID,
provider,
storableAccount.CreatedAt,
svcDef.Assets,
)
allDashboards = append(allDashboards, dashboards...)
}
}
}
sort.Slice(allDashboards, func(i, j int) bool {
return allDashboards[i].ID < allDashboards[j].ID
})
return allDashboards, nil
}

View File

@@ -7,6 +7,10 @@ import (
"github.com/SigNoz/signoz/ee/query-service/constants"
"github.com/SigNoz/signoz/ee/query-service/model"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type DayWiseBreakdown struct {
@@ -45,15 +49,17 @@ type details struct {
BillTotal float64 `json:"billTotal"`
}
type billingData struct {
BillingPeriodStart int64 `json:"billingPeriodStart"`
BillingPeriodEnd int64 `json:"billingPeriodEnd"`
Details details `json:"details"`
Discount float64 `json:"discount"`
SubscriptionStatus string `json:"subscriptionStatus"`
}
type billingDetails struct {
Status string `json:"status"`
Data struct {
BillingPeriodStart int64 `json:"billingPeriodStart"`
BillingPeriodEnd int64 `json:"billingPeriodEnd"`
Details details `json:"details"`
Discount float64 `json:"discount"`
SubscriptionStatus string `json:"subscriptionStatus"`
} `json:"data"`
Status string `json:"status"`
Data billingData `json:"data"`
}
func (ah *APIHandler) getBilling(w http.ResponseWriter, r *http.Request) {
@@ -64,6 +70,33 @@ func (ah *APIHandler) getBilling(w http.ResponseWriter, r *http.Request) {
return
}
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
RespondError(w, model.InternalError(err), nil)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
useZeus := ah.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureGetMetersFromZeus, evalCtx)
if useZeus {
data, err := ah.Signoz.Zeus.GetMeters(r.Context(), licenseKey)
if err != nil {
RespondError(w, model.InternalError(err), nil)
return
}
var billing billingData
if err := json.Unmarshal(data, &billing); err != nil {
RespondError(w, model.InternalError(err), nil)
return
}
ah.Respond(w, billing)
return
}
billingURL := fmt.Sprintf("%s/usage?licenseKey=%s", constants.LicenseSignozIo, licenseKey)
hClient := &http.Client{}
@@ -79,13 +112,11 @@ func (ah *APIHandler) getBilling(w http.ResponseWriter, r *http.Request) {
return
}
// decode response body
var billingResponse billingDetails
if err := json.NewDecoder(billingResp.Body).Decode(&billingResponse); err != nil {
RespondError(w, model.InternalError(err), nil)
return
}
// TODO(srikanthccv):Fetch the current day usage and add it to the response
ah.Respond(w, billingResponse.Data)
}

View File

@@ -54,6 +54,7 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
// Set the maximum number of open connections
pgConfig.MaxConns = int32(config.Connection.MaxOpenConns)
pgConfig.MaxConnLifetime = config.Connection.MaxConnLifetime
// Use pgxpool to create a connection pool
pool, err := pgxpool.NewWithConfig(ctx, pgConfig)

View File

@@ -109,6 +109,21 @@ func (provider *Provider) GetDeployment(ctx context.Context, key string) ([]byte
return []byte(gjson.GetBytes(response, "data").String()), nil
}
func (provider *Provider) GetMeters(ctx context.Context, key string) ([]byte, error) {
response, err := provider.do(
ctx,
provider.config.URL.JoinPath("/v1/meters"),
http.MethodGet,
key,
nil,
)
if err != nil {
return nil, err
}
return []byte(gjson.GetBytes(response, "data").String()), nil
}
func (provider *Provider) PutMeters(ctx context.Context, key string, data []byte) error {
_, err := provider.do(
ctx,

View File

@@ -0,0 +1 @@
export default 'test-file-stub';

View File

@@ -11,6 +11,9 @@ const config: Config.InitialOptions = {
moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
modulePathIgnorePatterns: ['dist'],
moduleNameMapper: {
'\\.(png|jpg|jpeg|gif|svg|webp|avif|ico|bmp|tiff)$':
'<rootDir>/__mocks__/fileMock.ts',
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss)$': '<rootDir>/__mocks__/cssMock.ts',
'\\.md$': '<rootDir>/__mocks__/cssMock.ts',
'^uplot$': '<rootDir>/__mocks__/uplotMock.ts',

View File

@@ -150,6 +150,7 @@ export const MetricsSelect = memo(function MetricsSelect({
options={SOURCE_OPTIONS}
value={source}
defaultValue="metrics"
data-testid={`metrics-source-selector-${index}`}
onChange={handleSignalSourceChange}
/>
)}
@@ -157,6 +158,7 @@ export const MetricsSelect = memo(function MetricsSelect({
<MetricNameSelector
onChange={handleChangeAggregatorAttribute}
query={query}
data-testid={`metric-name-selector-${index}`}
signalSource={signalSource || ''}
/>
</div>

View File

@@ -55,7 +55,12 @@ function PanelTypeSelectionModal(): JSX.Element {
>
<div className="panel-selection">
{PanelTypesWithData.map(({ name, icon, display }) => (
<Card onClick={(): void => handleCardClick(name)} id={name} key={name}>
<Card
onClick={(): void => handleCardClick(name)}
id={name}
key={name}
data-testid={`panel-type-${name}`}
>
{icon}
<Typography className="panel-type-text">{display}</Typography>
</Card>

View File

@@ -560,6 +560,7 @@ function DashboardsList(): JSX.Element {
label: (
<div
className="create-dashboard-menu-item"
data-testid="import-json-menu-cta"
onClick={(): void => onModalHandler(false)}
>
<Radius size={14} /> Import JSON
@@ -573,6 +574,7 @@ function DashboardsList(): JSX.Element {
href="https://signoz.io/docs/dashboards/dashboard-templates/overview/"
target="_blank"
rel="noopener noreferrer"
data-testid="view-templates-menu-cta"
>
<Flex
justify="space-between"
@@ -596,6 +598,7 @@ function DashboardsList(): JSX.Element {
label: (
<div
className="create-dashboard-menu-item"
data-testid="create-dashboard-menu-cta"
onClick={(): void => {
onNewDashboardHandler();
}}
@@ -759,6 +762,7 @@ function DashboardsList(): JSX.Element {
placeholder="Search by name, description, or tags..."
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
value={searchString}
data-testid="dashboards-list-search"
onChange={handleSearch}
/>
{createNewDashboard && (
@@ -772,6 +776,7 @@ function DashboardsList(): JSX.Element {
type="primary"
className="periscope-btn primary btn"
icon={<Plus size={14} />}
data-testid="new-dashboard-cta"
onClick={(): void => {
logEvent('Dashboard List: New dashboard clicked', {});
}}

View File

@@ -13,6 +13,7 @@ interface ColumnUnitSelectorProps {
columnUnits: ColumnUnit;
setColumnUnits: Dispatch<SetStateAction<ColumnUnit>>;
isNewDashboard: boolean;
'data-testid'?: string;
}
export function ColumnUnitSelector(
@@ -83,6 +84,7 @@ export function ColumnUnitSelector(
}
fieldLabel={label}
key={value}
data-testid={props['data-testid']}
selectedQueryName={baseQueryName}
// Update the column unit value automatically only in create mode
shouldUpdateYAxisUnit={isNewDashboard}

View File

@@ -138,6 +138,7 @@ function ContextLinks({
<Button
type="default"
className="add-context-link-button"
data-testid="add-context-link-cta"
icon={<Plus size={12} />}
style={{ width: '100%' }}
onClick={handleAddContextLink}

View File

@@ -15,12 +15,14 @@ function DashboardYAxisUnitSelectorWrapper({
fieldLabel,
shouldUpdateYAxisUnit,
selectedQueryName,
'data-testid': dataTestId,
}: {
value: string;
onSelect: OnSelectType;
fieldLabel: string;
shouldUpdateYAxisUnit: boolean;
selectedQueryName?: string;
'data-testid'?: string;
}): JSX.Element {
const { yAxisUnit: initialYAxisUnit, isLoading } = useGetYAxisUnit(
selectedQueryName,
@@ -42,6 +44,7 @@ function DashboardYAxisUnitSelectorWrapper({
initialValue={initialYAxisUnit}
source={YAxisSource.DASHBOARDS}
loading={isLoading}
data-testid={dataTestId}
/>
</div>
);

View File

@@ -66,6 +66,7 @@ export default function FormattingUnitsSection({
options={decimapPrecisionOptions}
value={decimalPrecision}
className="panel-type-select"
data-testid="decimal-precision-selector"
defaultValue={decimapPrecisionOptions[0]?.value}
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)}
/>
@@ -77,6 +78,7 @@ export default function FormattingUnitsSection({
columnUnits={columnUnits}
setColumnUnits={setColumnUnits}
isNewDashboard={isNewDashboard}
data-testid="column-unit-selector"
/>
)}
</SettingsSection>

View File

@@ -135,6 +135,7 @@ export default function GeneralSettingsSection({
rootClassName="general-settings__name-input"
ref={inputRef}
onSelect={handleInputCursor}
data-testid="panel-name-input"
onClick={handleInputCursor}
onBlur={(): void => setAutoCompleteOpen(false)}
/>
@@ -145,6 +146,7 @@ export default function GeneralSettingsSection({
bordered
allowClear
value={description}
data-testid="panel-description-input"
onChange={(event): void =>
onChangeHandler(setDescription, event.target.value)
}

View File

@@ -249,6 +249,7 @@ function Threshold({
<Input
defaultValue={label}
onChange={handleLabelChange}
data-testid="threshold-label-input"
bordered={!isDarkMode}
className="label-input"
/>
@@ -275,6 +276,7 @@ function Threshold({
onChange={handleTableOptionsChange}
rootClassName="operator-input-root"
className="operator-input"
data-testid="table-operator-input-selector"
/>
<Typography.Text className="typography">is</Typography.Text>
</Space>
@@ -287,6 +289,7 @@ function Threshold({
style={{ marginLeft: '10px' }}
rootClassName="operator-input-root"
className="operator-input"
data-testid="operator-input-selector"
/>
</div>
) : (
@@ -321,6 +324,7 @@ function Threshold({
defaultValue={value}
onChange={handleValueChange}
className="unit-input"
data-testid="threshold-value-input"
/>
) : (
<ShowCaseValue value={value} className="unit-input" />
@@ -332,6 +336,7 @@ function Threshold({
placeholder="Select unit"
source={YAxisSource.DASHBOARDS}
initialValue={unit}
data-testid="threshold-unit-input"
categoriesOverride={unitSelectCategories}
containerClassName="unit-selection"
/>
@@ -349,6 +354,7 @@ function Threshold({
defaultValue={format}
options={showAsOptions}
onChange={handlerFormatChange}
data-testid="threshold-color-selector"
rootClassName="color-format"
/>
</>

View File

@@ -72,6 +72,7 @@ function ThresholdSelector({
type="default"
icon={<Plus size={14} />}
style={{ width: '100%' }}
data-testid="add-threshold-cta"
onClick={addThresholdHandler}
>
Add Threshold

View File

@@ -4,4 +4,5 @@ import { DataSource } from 'types/common/queryBuilder';
export type QueryLabelProps = {
onChange: (value: DataSource) => void;
isListViewPanel?: boolean;
'data-testid'?: string;
} & Omit<SelectProps, 'onChange'>;

View File

@@ -32,6 +32,7 @@ export const DataSourceDropdown = memo(function DataSourceDropdown(
defaultValue={dataSourceOptions[0].value}
options={dataSourceOptions}
onChange={onChange}
data-testid={props['data-testid']}
value={value}
style={style}
/>

View File

@@ -136,6 +136,7 @@ export default function QBEntityOptions({
onChangeDataSource(value);
}
}}
data-testid={`query-data-source-selector-${index}`}
value={query?.dataSource || DataSource.METRICS}
isListViewPanel={isListViewPanel}
className="query-data-source-dropdown"

View File

@@ -27,6 +27,7 @@ export type MetricNameSelectorProps = {
defaultValue?: string;
onSelect?: (value: BaseAutocompleteData) => void;
signalSource?: 'meter' | '';
'data-testid'?: string;
};
function getAttributeType(
@@ -81,6 +82,7 @@ export const MetricNameSelector = memo(function MetricNameSelector({
defaultValue,
onSelect,
signalSource,
'data-testid': dataTestId,
}: MetricNameSelectorProps): JSX.Element {
const currentMetricName =
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
@@ -279,6 +281,7 @@ export const MetricNameSelector = memo(function MetricNameSelector({
</Typography.Text>
) : null
}
data-testid={dataTestId}
options={optionsData}
value={inputValue}
onBlur={handleBlur}

View File

@@ -12,7 +12,11 @@
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowSyntheticDefaultImports": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "bundler",
@@ -21,8 +25,15 @@
"noEmit": true,
"baseUrl": "./src",
"paths": {
"*": ["./*"],
"@constants/*": ["./container/OnboardingContainer/constants/*"]
"*": [
"./*"
],
"@constants/*": [
"./container/OnboardingContainer/constants/*"
],
"@/*": [
"./*"
]
},
"downlevelIteration": true,
"plugins": [
@@ -30,9 +41,18 @@
"name": "typescript-plugin-css-modules"
}
],
"types": ["vite/client", "node", "jest"]
"types": [
"vite/client",
"node",
"jest"
]
},
"exclude": ["node_modules", "src/parser/*.ts", "src/parser/TraceOperatorParser/*.ts", "orval.config.ts"],
"exclude": [
"node_modules",
"src/parser/*.ts",
"src/parser/TraceOperatorParser/*.ts",
"orval.config.ts"
],
"include": [
"./src",
"./src/**/*.ts",

View File

@@ -83,6 +83,7 @@ export default defineConfig(
plugins,
resolve: {
alias: {
'@': resolve(__dirname, './src'),
utils: resolve(__dirname, './src/utils'),
types: resolve(__dirname, './src/types'),
constants: resolve(__dirname, './src/constants'),

4
go.mod
View File

@@ -15,6 +15,7 @@ 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
@@ -111,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
@@ -164,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

View File

@@ -0,0 +1,440 @@
// Copyright 2019 Prometheus Team
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
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

@@ -0,0 +1,4 @@
smarthost: 127.0.0.1:1026
server: http://127.0.0.1:1081/
username: user
password: pass

View File

@@ -0,0 +1,4 @@
smarthost: maildev-auth:1025
server: http://maildev-auth:1080/
username: user
password: pass

View File

@@ -0,0 +1,2 @@
smarthost: 127.0.0.1:1025
server: http://127.0.0.1:1080/

View File

@@ -0,0 +1,2 @@
smarthost: maildev-noauth:1025
server: http://maildev-noauth:1080/

View File

@@ -1,3 +1,16 @@
// Copyright 2024 Prometheus Team
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package msteamsv2
import (
@@ -27,6 +40,10 @@ const (
colorGrey = "Warning"
)
const (
Integration = "msteamsv2"
)
type Notifier struct {
conf *config.MSTeamsV2Config
titleLink string
@@ -87,7 +104,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,16 @@
// Copyright 2024 Prometheus Team
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package msteamsv2
import (

View File

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

View File

@@ -0,0 +1,303 @@
// Copyright 2019 Prometheus Team
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
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,346 @@
// Copyright 2019 Prometheus Team
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
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,387 @@
// Copyright 2019 Prometheus Team
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
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,892 @@
// Copyright 2019 Prometheus Team
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
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,290 @@
// Copyright 2019 Prometheus Team
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
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,352 @@
// Copyright 2019 Prometheus Team
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
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,149 @@
// Copyright 2019 Prometheus Team
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
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,227 @@
// Copyright 2019 Prometheus Team
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
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

@@ -0,0 +1,256 @@
package alertmanagertemplate
import (
"context"
"log/slog"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
)
// AlertManagerTemplater processes alert notification templates.
type AlertManagerTemplater interface {
// ProcessTemplates expands the title and body templates from input
// against the provided alerts and returns the expanded templates.
ProcessTemplates(ctx context.Context, input TemplateInput, alerts []*types.Alert) (*ExpandedTemplates, error)
}
type alertManagerTemplater struct {
tmpl *template.Template
logger *slog.Logger
}
func New(tmpl *template.Template, logger *slog.Logger) AlertManagerTemplater {
return &alertManagerTemplater{tmpl: tmpl, logger: logger}
}
// ProcessTemplates expands the title and body templates from input
// against the provided alerts and returns the expanded templates.
func (at *alertManagerTemplater) ProcessTemplates(
ctx context.Context,
input TemplateInput,
alerts []*types.Alert,
) (*ExpandedTemplates, error) {
ntd := at.buildNotificationTemplateData(ctx, alerts)
title, titleMissingVars, err := at.expandTitle(ctx, input, alerts, ntd)
if err != nil {
return nil, err
}
body, bodyMissingVars, err := at.expandBody(ctx, input, alerts, ntd)
if err != nil {
return nil, err
}
missingVars := make(map[string]bool)
for k := range titleMissingVars {
missingVars[k] = true
}
for k := range bodyMissingVars {
missingVars[k] = true
}
return &ExpandedTemplates{Title: title, Body: body, MissingVars: missingVars}, nil
}
// expandTitle expands the title template. Falls back to the default if the custom template
// result in empty string.
func (at *alertManagerTemplater) expandTitle(
ctx context.Context,
input TemplateInput,
alerts []*types.Alert,
ntd *NotificationTemplateData,
) (string, map[string]bool, error) {
if input.TitleTemplate != "" {
processRes, err := PreProcessTemplateAndData(input.TitleTemplate, ntd)
if err != nil {
return "", nil, err
}
result, err := at.tmpl.ExecuteTextString(processRes.Template, processRes.Data)
if err != nil {
return "", nil, errors.NewInternalf(errors.CodeInvalidInput, "failed to execute template: %s", err.Error())
}
if strings.TrimSpace(result) != "" {
return result, processRes.UnknownVars, nil
}
}
if input.DefaultTitleTemplate == "" {
at.logger.WarnContext(ctx, "missing default title template")
return "", nil, nil
}
// Fall back to the default title template if present in the input
data := notify.GetTemplateData(ctx, at.tmpl, alerts, at.logger)
result, err := at.tmpl.ExecuteTextString(input.DefaultTitleTemplate, data)
return result, nil, err
}
// expandBody expands the body template once per alert and concatenates the results to return resulting body template
// it falls back to the default templates if body template is empty or result in empty string.
func (at *alertManagerTemplater) expandBody(
ctx context.Context,
input TemplateInput,
alerts []*types.Alert,
ntd *NotificationTemplateData,
) (string, map[string]bool, error) {
if input.BodyTemplate != "" {
var sb strings.Builder
missingVars := make(map[string]bool)
for i := range ntd.Alerts {
processRes, err := PreProcessTemplateAndData(input.BodyTemplate, &ntd.Alerts[i])
if err != nil {
return "", nil, err
}
for k := range processRes.UnknownVars {
missingVars[k] = true
}
part, err := at.tmpl.ExecuteTextString(processRes.Template, processRes.Data)
if err != nil {
return "", nil, errors.NewInternalf(errors.CodeInvalidInput, "failed to execute template: %s", err.Error())
}
sb.WriteString(part)
// Add separator if not last alert
if i < len(ntd.Alerts)-1 {
sb.WriteString("\n\n")
}
}
result := sb.String()
if strings.TrimSpace(result) != "" {
return result, missingVars, nil
}
}
if input.DefaultBodyTemplate == "" {
at.logger.WarnContext(ctx, "missing default body template")
return "", nil, nil
}
// Fall back to the default body template if present in the input
data := notify.GetTemplateData(ctx, at.tmpl, alerts, at.logger)
result, err := at.tmpl.ExecuteTextString(input.DefaultBodyTemplate, data)
return result, nil, err
}
// buildNotificationTemplateData creates the NotificationTemplateData using
// info from context and the raw alerts.
func (at *alertManagerTemplater) buildNotificationTemplateData(
ctx context.Context,
alerts []*types.Alert,
) *NotificationTemplateData {
// extract the required data from the context
receiver, ok := notify.ReceiverName(ctx)
if !ok {
at.logger.WarnContext(ctx, "missing receiver name in context")
}
groupLabels, ok := notify.GroupLabels(ctx)
if !ok {
at.logger.WarnContext(ctx, "missing group labels in context")
}
// extract the external URL from the template
externalURL := ""
if at.tmpl.ExternalURL != nil {
externalURL = at.tmpl.ExternalURL.String()
}
commonAnnotations := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
commonLabels := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Labels })
// aggregate labels and annotations from all alerts
labels := aggregateKV(alerts, func(a *types.Alert) model.LabelSet { return a.Labels })
annotations := aggregateKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
// build the alert data slice
alertDataSlice := make([]AlertData, 0, len(alerts))
for _, a := range alerts {
ad := buildAlertData(a, receiver)
alertDataSlice = append(alertDataSlice, ad)
}
// count the number of firing and resolved alerts
var firing, resolved int
for _, ad := range alertDataSlice {
if ad.IsFiring {
firing++
} else if ad.IsResolved {
resolved++
}
}
// extract the rule-level convenience fields from common labels
alertName := commonLabels[ruletypes.LabelAlertName]
ruleID := commonLabels[ruletypes.LabelRuleId]
ruleLink := commonLabels[ruletypes.LabelRuleSource]
// build the group labels
gl := make(template.KV, len(groupLabels))
for k, v := range groupLabels {
gl[string(k)] = string(v)
}
// build the notification template data
return &NotificationTemplateData{
Receiver: receiver,
Status: string(types.Alerts(alerts...).Status()),
AlertName: alertName,
RuleID: ruleID,
RuleLink: ruleLink,
TotalFiring: firing,
TotalResolved: resolved,
Alerts: alertDataSlice,
GroupLabels: gl,
CommonLabels: commonLabels,
CommonAnnotations: commonAnnotations,
ExternalURL: externalURL,
Labels: labels,
Annotations: annotations,
}
}
// buildAlertData converts a single *types.Alert into an AlertData.
func buildAlertData(a *types.Alert, receiver string) AlertData {
labels := make(template.KV, len(a.Labels))
for k, v := range a.Labels {
labels[string(k)] = string(v)
}
annotations := make(template.KV, len(a.Annotations))
for k, v := range a.Annotations {
annotations[string(k)] = string(v)
}
status := string(a.Status())
isFiring := a.Status() == model.AlertFiring
isResolved := a.Status() == model.AlertResolved
isMissingData := labels[ruletypes.LabelNoData] == "true"
return AlertData{
Receiver: receiver,
Status: status,
Labels: labels,
Annotations: annotations,
StartsAt: a.StartsAt,
EndsAt: a.EndsAt,
GeneratorURL: a.GeneratorURL,
Fingerprint: a.Fingerprint().String(),
AlertName: labels[ruletypes.LabelAlertName],
RuleID: labels[ruletypes.LabelRuleId],
RuleLink: labels[ruletypes.LabelRuleSource],
Severity: labels[ruletypes.LabelSeverityName],
LogLink: annotations[ruletypes.AnnotationRelatedLogs],
TraceLink: annotations[ruletypes.AnnotationRelatedTraces],
Value: annotations[ruletypes.AnnotationValue],
Threshold: annotations[ruletypes.AnnotationThreshold],
CompareOp: annotations[ruletypes.AnnotationCompareOp],
MatchType: annotations[ruletypes.AnnotationMatchType],
IsFiring: isFiring,
IsResolved: isResolved,
IsMissingData: isMissingData,
}
}

View File

@@ -0,0 +1,272 @@
package alertmanagertemplate
import (
"context"
"log/slog"
"testing"
"time"
test "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/alertmanagernotifytest"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/types"
)
// testSetup returns an AlertTemplater and a context pre-populated with group key,
// receiver name, and group labels for use in tests.
func testSetup(t *testing.T) (AlertManagerTemplater, context.Context) {
t.Helper()
tmpl := test.CreateTmpl(t)
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "test-group")
ctx = notify.WithReceiverName(ctx, "slack")
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": "TestAlert", "severity": "critical"})
logger := slog.New(slog.DiscardHandler)
return New(tmpl, logger), ctx
}
func createAlert(labels, annotations map[string]string, isFiring bool) *types.Alert {
ls := model.LabelSet{}
for k, v := range labels {
ls[model.LabelName(k)] = model.LabelValue(v)
}
ann := model.LabelSet{}
for k, v := range annotations {
ann[model.LabelName(k)] = model.LabelValue(v)
}
startsAt := time.Now()
var endsAt time.Time
if isFiring {
endsAt = startsAt.Add(time.Hour)
} else {
startsAt = startsAt.Add(-2 * time.Hour)
endsAt = startsAt.Add(-time.Hour)
}
return &types.Alert{Alert: model.Alert{Labels: ls, Annotations: ann, StartsAt: startsAt, EndsAt: endsAt}}
}
func TestExpandTemplates(t *testing.T) {
at, ctx := testSetup(t)
tests := []struct {
name string
alerts []*types.Alert
input TemplateInput
wantTitle string
wantBody string
wantMissingVars []string
errorContains string
}{
{
// High request throughput on a service — service is a custom label.
// $labels.service extracts the label value; $annotations.description pulls the annotation.
name: "new template: high request throughput for a service",
alerts: []*types.Alert{
createAlert(
map[string]string{
ruletypes.LabelAlertName: "HighRequestThroughput",
ruletypes.LabelSeverityName: "warning",
"service": "payment-service",
},
map[string]string{"description": "Request rate exceeded 10k/s"},
true,
),
},
input: TemplateInput{
TitleTemplate: "High request throughput for $service",
BodyTemplate: `The service $service is getting high request. Please investigate.
Severity: $severity
Status: $status
Service: $service
Description: $description`,
},
wantTitle: "High request throughput for payment-service",
wantBody: `The service payment-service is getting high request. Please investigate.
Severity: warning
Status: firing
Service: payment-service
Description: Request rate exceeded 10k/s`,
},
{
// Disk usage alert using old Go template syntax throughout.
// No custom templates — both title and body use the default fallback path.
name: "old template: disk usage high on database host",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "DiskUsageHigh",
ruletypes.LabelSeverityName: "critical",
"instance": "db-primary-01",
},
map[string]string{
"summary": "Disk usage high on database host",
"description": "Disk usage is high on the database host",
"related_logs": "https://logs.example.com/search?q=DiskUsageHigh",
"related_traces": "https://traces.example.com/search?q=DiskUsageHigh",
},
true,
),
},
input: TemplateInput{
DefaultTitleTemplate: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
{{" "}}(
{{- with .CommonLabels.Remove .GroupLabels.Names }}
{{- range $index, $label := .SortedPairs -}}
{{ if $index }}, {{ end }}
{{- $label.Name }}="{{ $label.Value -}}"
{{- end }}
{{- end -}}
)
{{- end }}`,
DefaultBodyTemplate: `{{ range .Alerts -}}
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
*Summary:* {{ .Annotations.summary }}
*Description:* {{ .Annotations.description }}
*RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}
*RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}
*Details:*
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
{{ end }}
{{ end }}`,
},
wantTitle: "[FIRING:1] DiskUsageHigh for (instance=\"db-primary-01\")",
wantBody: `*Alert:* DiskUsageHigh - critical
*Summary:* Disk usage high on database host
*Description:* Disk usage is high on the database host
*RelatedLogs:* View in <https://logs.example.com/search?q=DiskUsageHigh|logs explorer>
*RelatedTraces:* View in <https://traces.example.com/search?q=DiskUsageHigh|traces explorer>
*Details:*
• *alertname:* DiskUsageHigh
• *instance:* db-primary-01
• *severity:* critical
`,
},
{
// Pod crash loop on multiple pods — body is expanded once per alert
// and joined with "\n\n", with the pod name pulled from labels.
name: "new template: pod crash loop on multiple pods, body per-alert",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "PodCrashLoop", "pod": "api-worker-1"}, nil, true),
createAlert(map[string]string{ruletypes.LabelAlertName: "PodCrashLoop", "pod": "api-worker-2"}, nil, true),
createAlert(map[string]string{ruletypes.LabelAlertName: "PodCrashLoop", "pod": "api-worker-3"}, nil, true),
},
input: TemplateInput{
TitleTemplate: "$rule_name: $total_firing pods affected",
BodyTemplate: "$labels.pod is crash looping",
},
wantTitle: "PodCrashLoop: 3 pods affected",
wantBody: "api-worker-1 is crash looping\n\napi-worker-2 is crash looping\n\napi-worker-3 is crash looping",
},
{
// Incident partially resolved — one service still down, one recovered.
// Title shows the aggregate counts; body shows per-service status.
name: "new template: service degradation with mixed firing and resolved alerts",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "ServiceDown", "service": "auth-service"}, nil, true),
createAlert(map[string]string{ruletypes.LabelAlertName: "ServiceDown", "service": "payment-service"}, nil, false),
},
input: TemplateInput{
TitleTemplate: "$total_firing firing, $total_resolved resolved",
BodyTemplate: "$labels.service ($status)",
},
wantTitle: "1 firing, 1 resolved",
wantBody: "auth-service (firing)\n\npayment-service (resolved)",
},
{
// $environment is not a known AlertData or NotificationTemplateData field,
// so it lands in MissingVars and renders as "<no value>" in the output.
name: "missing vars: unknown $environment variable in title",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical"}, nil, true),
},
input: TemplateInput{
TitleTemplate: "[$environment] $rule_name",
},
wantTitle: "[<no value>] HighCPU",
wantMissingVars: []string{"environment"},
},
{
// $runbook_url is not a known field — someone tried to embed a runbook link
// directly as a variable instead of via annotations.
name: "missing vars: unknown $runbook_url variable in body",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "PodOOMKilled", ruletypes.LabelSeverityName: "warning"}, nil, true),
},
input: TemplateInput{
BodyTemplate: "$rule_name: see runbook at $runbook_url",
},
wantBody: "PodOOMKilled: see runbook at <no value>",
wantMissingVars: []string{"runbook_url"},
},
{
// Both title and body use unknown variables; MissingVars is the union of both.
name: "missing vars: unknown variables in both title and body",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "HighMemory", ruletypes.LabelSeverityName: "critical"}, nil, true),
},
input: TemplateInput{
TitleTemplate: "[$environment] $rule_name and [{{ $service }}]",
BodyTemplate: "$rule_name: see runbook at $runbook_url",
},
wantTitle: "[<no value>] HighMemory and [<no value>]",
wantBody: "HighMemory: see runbook at <no value>",
wantMissingVars: []string{"environment", "runbook_url", "service"},
},
{
// Custom title template that expands to only whitespace triggers the fallback,
// so the default title template is used instead.
name: "fallback: whitespace-only custom title falls back to default",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical"}, nil, false),
},
input: TemplateInput{
TitleTemplate: " ",
DefaultTitleTemplate: "{{ .CommonLabels.alertname }} ({{ .Status | toUpper }})",
BodyTemplate: "$rule_name ($severity) for $alertname",
},
wantTitle: "HighCPU (RESOLVED)",
wantBody: "HighCPU (critical) for HighCPU",
},
{
name: "using non-existing function in template",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical"}, nil, true),
},
input: TemplateInput{
TitleTemplate: "$rule_name ({{$severity | toUpperAndTrim}}) for $alertname",
},
errorContains: "function \"toUpperAndTrim\" not defined",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := at.ProcessTemplates(ctx, tc.input, tc.alerts)
if tc.errorContains != "" {
require.ErrorContains(t, err, tc.errorContains)
return
}
require.NoError(t, err)
if tc.wantTitle != "" {
require.Equal(t, tc.wantTitle, got.Title)
}
if tc.wantBody != "" {
require.Equal(t, tc.wantBody, got.Body)
}
require.Len(t, got.MissingVars, len(tc.wantMissingVars))
for _, v := range tc.wantMissingVars {
require.True(t, got.MissingVars[v], "expected %q in MissingVars", v)
}
})
}
}

View File

@@ -0,0 +1,242 @@
package alertmanagertemplate
import (
"fmt"
"reflect"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/go-viper/mapstructure/v2"
)
// fieldMapping represents a mapping from a JSON tag name to its struct field name.
type fieldMapping struct {
VarName string // JSON tag name (e.g., "receiver", "rule_name")
FieldName string // Struct field name (e.g., "Receiver", "AlertName")
}
// extractFieldMappings uses reflection to extract field mappings from a struct.
func extractFieldMappings(data any) []fieldMapping {
val := reflect.ValueOf(data)
// Handle pointer types
if val.Kind() == reflect.Ptr {
if val.IsNil() {
return nil
}
val = val.Elem()
}
// return nil if the given data is not a struct
if val.Kind() != reflect.Struct {
return nil
}
typ := val.Type()
var mappings []fieldMapping
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
// Skip unexported fields
if !field.IsExported() {
continue
}
// Get JSON tag name
jsonTag := field.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
continue
}
// Extract the name part (before any comma options like omitempty)
varName := strings.Split(jsonTag, ",")[0]
if varName == "" {
continue
}
varFieldName := field.Tag.Get("mapstructure")
if varFieldName == "" {
varFieldName = field.Name
}
// Skip complex types: slices and interfaces
kind := field.Type.Kind()
if kind == reflect.Slice || kind == reflect.Interface {
continue
}
// For struct types, we skip all but with few exceptions like time.Time
if kind == reflect.Struct {
// Allow time.Time which is commonly used
if field.Type.String() != "time.Time" {
continue
}
}
mappings = append(mappings, fieldMapping{
VarName: varName,
FieldName: varFieldName,
})
}
return mappings
}
// extractNestedFieldsDefinitions adds the labels and annotations keys from the data struct to the template variable definitions
// it takes the known data struct and extracts the labels and annotations maps and adds their keys to template variable definitions to be used in the template
func extractNestedFieldsDefinitions(data any) map[string]string {
variables := make(map[string]string)
addLabelsAndAnnotations := func(labels, annotations map[string]string) {
for k := range annotations {
variables[k] = fmt.Sprintf("index .annotations \"%s\"", k)
}
for k := range labels {
variables[k] = fmt.Sprintf("index .labels \"%s\"", k)
}
}
switch data := data.(type) {
case *NotificationTemplateData:
addLabelsAndAnnotations(data.Labels, data.Annotations)
case *AlertData:
addLabelsAndAnnotations(data.Labels, data.Annotations)
default:
return variables
}
return variables
}
// prepareDataForTemplating prepares the data for templating by adding the labels and annotations values to the resulting map
// so they can be accessed directly from root level, the predefined values takes precedence over the labels and annotations values
// for example, if labels has a value called rule_name, which collides with the rule_name field in the data struct, the value from the data struct will take precedence
func prepareDataForTemplating(data any) (map[string]interface{}, error) {
var result map[string]interface{}
if err := mapstructure.Decode(data, &result); err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to prepare data for templating")
}
addLabelsAndAnnotationsValues := func(labels, annotations map[string]string) {
for k, v := range labels {
if _, ok := result[k]; !ok {
result[k] = v
}
}
for k, v := range annotations {
if _, ok := result[k]; !ok {
result[k] = v
}
}
}
switch data := data.(type) {
case *NotificationTemplateData:
addLabelsAndAnnotationsValues(data.Labels, data.Annotations)
case *AlertData:
addLabelsAndAnnotationsValues(data.Labels, data.Annotations)
default:
return result, nil
}
return result, nil
}
// generateVariableDefinitions creates `{{ $varname := "" }}` declarations for each variable name.
func generateVariableDefinitions(varNames map[string]string) string {
if len(varNames) == 0 {
return ""
}
var sb strings.Builder
for name := range varNames {
fmt.Fprintf(&sb, `{{ $%s := %s }}`, name, varNames[name])
}
return sb.String()
}
// buildVariableDefinitions constructs the full variable definition preamble for a template.
// containing all known and unknown variables, the reason to include unknown variables is to
// populate them with "<no value>" in template so go-text-template don't throw errors
// when these variables are used in the template.
func buildVariableDefinitions(tmpl string, data any) (string, map[string]bool, error) {
// Extract the initial fields from the data struct and add to the definitions
mappings := extractFieldMappings(data)
// Add variables from struct root level fields to the definitions
variables := make(map[string]string)
for _, m := range mappings {
variables[m.VarName] = fmt.Sprintf(".%s", m.FieldName)
}
// Extract the nested fields definitions from the data struct, like labels, annotations, etc.
// once extracted we add them to the variables map along with the field address
nestedVariables := extractNestedFieldsDefinitions(data)
for k, v := range nestedVariables {
variables[k] = v
}
// variables that are used throughout the template
usedVars, err := ExtractUsedVariables(tmpl)
if err != nil {
return "", nil, err
}
// Compute unknown variables: used in template but not covered by a field mapping
probableUnknownVars := make(map[string]bool)
for name := range usedVars {
_, ok := variables[name]
if !ok {
probableUnknownVars[name] = true
}
}
// Add missing variables to the definitions with "<no value>"
// missingkey=zero is used to replace the missing value with "<no value>"
// but it only works when getting map values like {{ .keyfrommap }} from map and in struct this breaks
// with missing variable errors, we add missing variables in map so when directly variables
// are accessed directly in template block like {{ $variable }} it's handled and doesn't throw errors.
for name := range probableUnknownVars {
variables[name] = `"<no value>"`
}
return generateVariableDefinitions(variables), probableUnknownVars, nil
}
type ProcessingResult struct {
Template string
Data map[string]interface{}
// UnknownVars is the set of possible unknown variables exptracted using regex
UnknownVars map[string]bool
}
// PreProcessTemplateAndData prepares a template string and struct data for Go template execution.
//
// Input: "$receiver has $rule_name in $status state"
// Output: "{{ $receiver := .Receiver }}...{{ $receiver }} has {{ $rule_name }} in {{ $status }} state"
func PreProcessTemplateAndData(tmpl string, data any) (*ProcessingResult, error) {
// Handle empty template
unknownVars := make(map[string]bool)
if tmpl == "" {
result, err := prepareDataForTemplating(data)
if err != nil {
return nil, err
}
return &ProcessingResult{Data: result, UnknownVars: unknownVars}, nil
}
// Build variable definitions: known struct fields + fallback empty-string declarations
definitions, unknownVars, err := buildVariableDefinitions(tmpl, data)
if err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to build template definitions")
}
// Attach definitions prefix so WrapDollarVariables can parse the AST without "undefined variable" errors.
finalTmpl := definitions + tmpl
// Call WrapDollarVariables to transform bare $variable references to go-text-template format
// with {{ $variable }} syntax from $variable syntax
wrappedTmpl, err := WrapDollarVariables(finalTmpl)
if err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to prepare template for templating")
}
// Convert struct to map using mapstructure to be used for template execution
result, err := prepareDataForTemplating(data)
if err != nil {
return nil, err
}
return &ProcessingResult{Template: wrappedTmpl, Data: result, UnknownVars: unknownVars}, nil
}

View File

@@ -0,0 +1,316 @@
package alertmanagertemplate
import (
"testing"
"time"
"github.com/prometheus/alertmanager/template"
"github.com/stretchr/testify/require"
)
func TestExtractFieldMappings(t *testing.T) {
// Struct with various field types to test extraction logic
type TestStruct struct {
Name string `json:"name"`
Status string `json:"status"`
ActiveUserCount int `json:"user_count" mapstructure:"active_user_count"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"` // time.Time allowed
Items []string `json:"items"` // slice skipped
unexported string // unexported skipped (no tag needed)
NoTag string // no json tag skipped
SkippedTag string `json:"-"` // json:"-" skipped
}
testCases := []struct {
name string
data any
expected []fieldMapping
}{
{
name: "struct with mixed field types",
data: TestStruct{Name: "test", ActiveUserCount: 5, unexported: ""},
expected: []fieldMapping{
{VarName: "name", FieldName: "Name"},
{VarName: "status", FieldName: "Status"},
{VarName: "user_count", FieldName: "active_user_count"},
{VarName: "is_active", FieldName: "IsActive"},
{VarName: "created_at", FieldName: "CreatedAt"},
},
},
{
name: "nil data",
data: nil,
expected: nil,
},
{
name: "non-struct type",
data: "string",
expected: nil,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := extractFieldMappings(tc.data)
require.Equal(t, tc.expected, result)
})
}
}
func TestBuildVariableDefinitions(t *testing.T) {
testCases := []struct {
name string
tmpl string
data any
expectedVars []string // substrings that must appear in result
expectError bool
}{
{
name: "empty template still returns struct field definitions",
tmpl: "",
data: &NotificationTemplateData{Receiver: "test"},
expectedVars: []string{
"{{ $receiver := .receiver }}",
"{{ $status := .status }}",
},
},
{
name: "mix of known and unknown vars",
tmpl: "$rule_name: $custom_label",
data: &AlertData{AlertName: "test", Status: "ok", Severity: "critical"},
expectedVars: []string{
"{{ $rule_name := .rule_name }}",
"{{ $status := .status }}",
"{{ $severity := .severity }}",
`{{ $custom_label := "<no value>" }}`,
},
},
{
name: "nested fields definitions coming from NotificationTemplateData",
tmpl: "$severity for $service",
data: &NotificationTemplateData{Labels: template.KV{
"severity": "critical",
"service": "test",
}},
expectedVars: []string{
"{{ $severity := index .labels \"severity\" }}",
"{{ $service := index .labels \"service\" }}",
},
},
{
name: "nested fields definitions coming from AlertData",
tmpl: "$severity for $service",
data: &AlertData{Labels: template.KV{
"severity": "critical",
"service": "test",
}},
expectedVars: []string{
"{{ $severity := index .labels \"severity\" }}",
"{{ $service := index .labels \"service\" }}",
},
},
{
name: "invalid template syntax returns error",
tmpl: "{{invalid",
data: &NotificationTemplateData{},
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, _, err := buildVariableDefinitions(tc.tmpl, tc.data)
if tc.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
if len(tc.expectedVars) == 0 {
require.Empty(t, result)
return
}
for _, expected := range tc.expectedVars {
require.Contains(t, result, expected)
}
})
}
}
func TestPreProcessTemplateAndData(t *testing.T) {
testCases := []struct {
name string
tmpl string
data any
expectedTemplateContains []string
expectedData map[string]any
expectedUnknownVars map[string]bool
expectError bool
}{
{
name: "NotificationTemplateData with dollar variables",
tmpl: "[$status] $rule_name (ID: $rule_id) - Firing: $total_firing, Resolved: $total_resolved, Severity: $severity",
data: &NotificationTemplateData{
Receiver: "pagerduty",
Status: "firing",
AlertName: "HighMemory",
RuleID: "rule-123",
Labels: template.KV{
"severity": "critical",
},
TotalFiring: 3,
TotalResolved: 1,
},
expectedTemplateContains: []string{
"{{$status := .status}}",
"{{$rule_name := .rule_name}}",
"{{$rule_id := .rule_id}}",
"{{$total_firing := .total_firing}}",
"{{$total_resolved := .total_resolved}}",
"{{$severity := index .labels \"severity\"}}",
"[{{ .status }}] {{ .rule_name }} (ID: {{ .rule_id }}) - Firing: {{ .total_firing }}, Resolved: {{ .total_resolved }}",
},
expectedData: map[string]any{
"status": "firing",
"rule_name": "HighMemory",
"rule_id": "rule-123",
"total_firing": 3,
"total_resolved": 1,
"severity": "critical",
},
expectedUnknownVars: map[string]bool{},
},
{
name: "AlertData with dollar variables",
tmpl: "$rule_name: Value $value exceeded $threshold (Status: $status, Severity: $severity, Description: $description)",
data: &AlertData{
Receiver: "webhook",
Status: "resolved",
AlertName: "DiskFull",
RuleID: "disk-001",
Severity: "warning",
Annotations: template.KV{
"description": "Disk full and cannot be written to",
},
Value: "85%",
Threshold: "80%",
IsFiring: false,
IsResolved: true,
},
expectedTemplateContains: []string{
"{{$rule_name := .rule_name}}",
"{{$value := .value}}",
"{{$threshold := .threshold}}",
"{{$status := .status}}",
"{{$severity := .severity}}",
"{{$description := index .annotations \"description\"}}",
"{{ .rule_name }}: Value {{ .value }} exceeded {{ .threshold }} (Status: {{ .status }}, Severity: {{ .severity }}, Description: {{ .description }})",
},
expectedData: map[string]any{
"status": "resolved",
"rule_name": "DiskFull",
"rule_id": "disk-001",
"severity": "warning",
"value": "85%",
"threshold": "80%",
"description": "Disk full and cannot be written to",
},
expectedUnknownVars: map[string]bool{},
},
{
name: "mixed dollar and dot notation with both labels and annotations",
tmpl: "Alert $rule_name has {{.total_firing}} firing alerts",
data: &NotificationTemplateData{
AlertName: "HighCPU",
TotalFiring: 5,
Labels: template.KV{
"value": "<MASKED VALUE>",
},
Annotations: template.KV{
"value": "85%",
},
},
expectedTemplateContains: []string{
"{{$rule_name := .rule_name}}",
"{{$value := index .labels \"value\"}}",
"Alert {{ .rule_name }} has {{.total_firing}} firing alerts",
},
expectedData: map[string]any{
"rule_name": "HighCPU",
"total_firing": 5,
"value": "<MASKED VALUE>",
},
expectedUnknownVars: map[string]bool{},
},
{
name: "empty template",
tmpl: "",
data: &NotificationTemplateData{Receiver: "slack"},
},
{
name: "invalid template syntax",
tmpl: "{{invalid",
data: &NotificationTemplateData{},
expectError: true,
},
{
name: "unknown dollar var in text renders empty",
tmpl: "alert $custom_note fired",
data: &NotificationTemplateData{AlertName: "HighCPU"},
expectedTemplateContains: []string{
`{{$custom_note := "<no value>"}}`,
"alert {{ .custom_note }} fired",
},
expectedUnknownVars: map[string]bool{"custom_note": true},
},
{
name: "unknown dollar var in action block renders empty",
tmpl: "alert {{ $custom_note }} fired",
data: &NotificationTemplateData{AlertName: "HighCPU"},
expectedTemplateContains: []string{
`{{$custom_note := "<no value>"}}`,
`alert {{$custom_note}} fired`,
},
expectedUnknownVars: map[string]bool{"custom_note": true},
},
{
name: "mix of known and unknown vars",
tmpl: "$rule_name: $custom_label",
data: &NotificationTemplateData{AlertName: "HighCPU"},
expectedTemplateContains: []string{
"{{$rule_name := .rule_name}}",
`{{$custom_label := "<no value>"}}`,
"{{ .rule_name }}: {{ .custom_label }}",
},
expectedData: map[string]any{"rule_name": "HighCPU"},
expectedUnknownVars: map[string]bool{"custom_label": true},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := PreProcessTemplateAndData(tc.tmpl, tc.data)
if tc.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
if tc.tmpl == "" {
require.Equal(t, "", result.Template)
return
}
for _, substr := range tc.expectedTemplateContains {
require.Contains(t, result.Template, substr)
}
for k, v := range tc.expectedData {
require.Equal(t, v, result.Data[k])
}
if tc.expectedUnknownVars != nil {
require.Equal(t, tc.expectedUnknownVars, result.UnknownVars)
}
})
}
}

View File

@@ -0,0 +1,86 @@
package alertmanagertemplate
import (
"time"
"github.com/prometheus/alertmanager/template"
)
// TemplateInput carries the title/body templates
// and their defaults to apply in case the custom templates
// are result in empty strings.
type TemplateInput struct {
TitleTemplate string
BodyTemplate string
DefaultTitleTemplate string
DefaultBodyTemplate string
}
// ExpandedTemplates is the result of ExpandTemplates.
type ExpandedTemplates struct {
Title string
Body string
MissingVars map[string]bool // union of unknown vars from title + body templates
}
// AlertData holds per-alert data used when expanding body templates
type AlertData struct {
Receiver string `json:"receiver" mapstructure:"receiver"`
Status string `json:"status" mapstructure:"status"`
Labels template.KV `json:"labels" mapstructure:"labels"`
Annotations template.KV `json:"annotations" mapstructure:"annotations"`
StartsAt time.Time `json:"starts_at" mapstructure:"starts_at"`
EndsAt time.Time `json:"ends_at" mapstructure:"ends_at"`
GeneratorURL string `json:"generator_url" mapstructure:"generator_url"`
Fingerprint string `json:"fingerprint" mapstructure:"fingerprint"`
// Convenience fields extracted from well-known labels/annotations.
AlertName string `json:"rule_name" mapstructure:"rule_name"`
RuleID string `json:"rule_id" mapstructure:"rule_id"`
RuleLink string `json:"rule_link" mapstructure:"rule_link"`
Severity string `json:"severity" mapstructure:"severity"`
// Alert internal data fields
Value string `json:"value" mapstructure:"value"`
Threshold string `json:"threshold" mapstructure:"threshold"`
CompareOp string `json:"compare_op" mapstructure:"compare_op"`
MatchType string `json:"match_type" mapstructure:"match_type"`
// Link annotations added by the rule evaluator.
LogLink string `json:"log_link" mapstructure:"log_link"`
TraceLink string `json:"trace_link" mapstructure:"trace_link"`
// Status booleans for easy conditional templating.
IsFiring bool `json:"is_firing" mapstructure:"is_firing"`
IsResolved bool `json:"is_resolved" mapstructure:"is_resolved"`
IsMissingData bool `json:"is_missing_data" mapstructure:"is_missing_data"`
IsRecovering bool `json:"is_recovering" mapstructure:"is_recovering"`
}
// NotificationTemplateData is the top-level data struct provided to custom templates.
type NotificationTemplateData struct {
Receiver string `json:"receiver" mapstructure:"receiver"`
Status string `json:"status" mapstructure:"status"`
// Convenience fields for title templates.
AlertName string `json:"rule_name" mapstructure:"rule_name"`
RuleID string `json:"rule_id" mapstructure:"rule_id"`
RuleLink string `json:"rule_link" mapstructure:"rule_link"`
TotalFiring int `json:"total_firing" mapstructure:"total_firing"`
TotalResolved int `json:"total_resolved" mapstructure:"total_resolved"`
// Per-alert data, also available as filtered sub-slices.
Alerts []AlertData `json:"-" mapstructure:"-"`
// Cross-alert aggregates, computed as intersection across all alerts.
GroupLabels template.KV `json:"group_labels" mapstructure:"group_labels"`
CommonLabels template.KV `json:"common_labels" mapstructure:"common_labels"`
CommonAnnotations template.KV `json:"common_annotations" mapstructure:"common_annotations"`
ExternalURL string `json:"external_url" mapstructure:"external_url"`
// Labels and Annotations that are collection of labels
// and annotations from all alerts, it includes only the common labels and annotations
// and for non-common labels and annotations, it picks some first few labels/annotations
// and joins them with ", " to avoid blank values in the template
Labels template.KV `json:"labels" mapstructure:"labels"`
Annotations template.KV `json:"annotations" mapstructure:"annotations"`
}

View File

@@ -0,0 +1,231 @@
package alertmanagertemplate
import (
"fmt"
"reflect"
"regexp"
"strings"
"text/template/parse"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
)
// maxAggregatedValues is the maximum number of unique values to include
// when aggregating non-common label/annotation values across alerts.
const maxAggregatedValues = 5
// bareVariableRegex matches bare $variable references including dotted paths like $service.name.
var bareVariableRegex = regexp.MustCompile(`\$(\w+(?:\.\w+)*)`)
// bareVariableRegexFirstSeg matches only the base $variable name, stopping before any dotted path.
// e.g. "$labels.severity" matches "$labels", "$name" matches "$name".
var bareVariableRegexFirstSeg = regexp.MustCompile(`\$\w+`)
// ExtractTemplatesFromAnnotations computes the common annotations across all alerts
// and returns the values for the title_template and body_template annotation keys as title and body templates.
func ExtractTemplatesFromAnnotations(alerts []*types.Alert) (titleTemplate, bodyTemplate string) {
if len(alerts) == 0 {
return "", ""
}
commonAnnotations := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
return commonAnnotations[ruletypes.AnnotationTitleTemplate], commonAnnotations[ruletypes.AnnotationBodyTemplate]
}
// WrapDollarVariables wraps bare $variable references in Go template syntax.
// Example transformations:
// - "$name is $status" -> "{{ $name }} is {{ $status }}"
// - "$labels.severity" -> "{{ index .labels \"severity\" }}"
// - "$labels.http.status" -> "{{ index .labels \"http.status\" }}"
// - "$annotations.summary" -> "{{ index .annotations \"summary\" }}"
// - "$service.name" -> "{{ index . \"service.name\" }}"
// - "$name is {{ .Status }}" -> "{{ $name }} is {{ .Status }}"
func WrapDollarVariables(src string) (string, error) {
if src == "" {
return src, nil
}
// Create a new parse.Tree directly
tree := parse.New("template")
tree.Mode = parse.SkipFuncCheck
// Parse the template
_, err := tree.Parse(src, "{{", "}}", make(map[string]*parse.Tree), nil)
if err != nil {
return "", err
}
// Walk the AST and transform TextNodes
walkAndWrapTextNodes(tree.Root)
// Return the reassembled template
return tree.Root.String(), nil
}
// walkAndWrapTextNodes recursively walks the parse tree trying to find a text node
// once text node is found it wraps the bare $variable and changes it to index based
// element access form datamap like .key or .key.subkey
func walkAndWrapTextNodes(node parse.Node) {
if reflect.ValueOf(node).IsNil() {
return
}
switch n := node.(type) {
// `$name is {{.Status}}` is a list node with one text and one action node
case *parse.ListNode:
// Recurse into all child nodes
if n.Nodes != nil {
for _, child := range n.Nodes {
walkAndWrapTextNodes(child)
}
}
// `$name is ` is a text node with plain text in root
// we try to find the $name variable and wrap it with template block
// like `{{ .name }}`, for labels and annotations we use the index to access the value
// so `$labels.service` becomes `{{ index .labels "service" }}`
case *parse.TextNode:
// Transform $variable based on its pattern
n.Text = bareVariableRegex.ReplaceAllFunc(n.Text, func(match []byte) []byte {
// Extract variable name without the $
varName := string(match[1:])
// Check if variable contains dots
if strings.Contains(varName, ".") {
// Check for reserved prefixes: labels.* or annotations.*
if strings.HasPrefix(varName, "labels.") {
key := strings.TrimPrefix(varName, "labels.")
return []byte(fmt.Sprintf(`{{ index .labels "%s" }}`, key))
}
if strings.HasPrefix(varName, "annotations.") {
key := strings.TrimPrefix(varName, "annotations.")
return []byte(fmt.Sprintf(`{{ index .annotations "%s" }}`, key))
}
// Other dotted variables: index into root context
return []byte(fmt.Sprintf(`{{ index . "%s" }}`, varName))
}
// Simple variables: use dot notation to directly access the field
// without raising any error due to missing variables
return []byte(fmt.Sprintf("{{ .%s }}", varName))
})
// `{{if pipeline}} T1 {{else}} T0 {{end}}` is a if node with T1 part of List and T0 part of ElseList
case *parse.IfNode:
// Recurse into both branches
walkAndWrapTextNodes(n.List)
walkAndWrapTextNodes(n.ElseList)
// `{{range pipeline}} T1 {{else}} T0 {{end}}` is a range node with T1 part of List and T0 part of ElseList
case *parse.RangeNode:
// Recurse into both branches
walkAndWrapTextNodes(n.List)
walkAndWrapTextNodes(n.ElseList)
// All other node types (ActionNode, PipeNode, VariableNode, etc.) are already
// inside {{ }} action blocks and don't need transformation
// Support for `with` can be added later when we start supporting it in editor block
}
}
// ExtractUsedVariables returns the set of all $variable referenced in template
// — text nodes, action blocks, branch conditions, and loop declarations — regardless of scope.
// After finding all variables we find the ones which are not part of our alert data and handle them so
// Go-text-template parser doesn't rejects undefined $variables
func ExtractUsedVariables(src string) (map[string]bool, error) {
if src == "" {
return map[string]bool{}, nil
}
// Regex-scan raw template string to collect all $var base names.
// bareVariableRegexFirstSeg stops before dots, so "$labels.severity" yields "$labels".
used := make(map[string]bool)
for _, m := range bareVariableRegexFirstSeg.FindAll([]byte(src), -1) {
used[string(m[1:])] = true // strip leading "$"
}
// Build a preamble that pre-declares every found variable.
// This prevents "undefined variable" parse errors for $vars used in action
// blocks while still letting genuine syntax errors propagate.
var preamble strings.Builder
for name := range used {
fmt.Fprintf(&preamble, `{{$%s := ""}}`, name)
}
// Validate template syntax.
tree := parse.New("template")
tree.Mode = parse.SkipFuncCheck
if _, err := tree.Parse(preamble.String()+src, "{{", "}}", make(map[string]*parse.Tree), nil); err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInternal, "failed to extract used variables")
}
return used, nil
}
// aggregateKV aggregates key-value pairs (labels or annotations) from all alerts into a single template.KV
// the result is used to populate the labels and annotations in the notification template data.
// this is done to avoid blank values in the template when labels and annotations used are not common throughout the alerts
func aggregateKV(alerts []*types.Alert, extractFn func(*types.Alert) model.LabelSet) template.KV {
// track unique values per key in order of first appearance
valuesPerKey := make(map[string][]string)
// track which values have been seen for deduplication
seenValues := make(map[string]map[string]bool)
for _, alert := range alerts {
kvPairs := extractFn(alert)
for k, v := range kvPairs {
key := string(k)
value := string(v)
if seenValues[key] == nil {
seenValues[key] = make(map[string]bool)
}
// only add if not already seen and under the limit of maxAggregatedValues
if !seenValues[key][value] && len(valuesPerKey[key]) < maxAggregatedValues {
seenValues[key][value] = true
valuesPerKey[key] = append(valuesPerKey[key], value)
}
}
}
// build the result by joining values
result := make(template.KV, len(valuesPerKey))
for key, values := range valuesPerKey {
result[key] = strings.Join(values, ", ")
}
return result
}
// extractCommonKV returns the intersection of key-value pairs across all alerts.
// A key/value pair is included only if it appears identically on every alert.
func extractCommonKV(alerts []*types.Alert, extractFn func(*types.Alert) model.LabelSet) template.KV {
if len(alerts) == 0 {
return template.KV{}
}
common := make(template.KV, len(extractFn(alerts[0])))
for k, v := range extractFn(alerts[0]) {
common[string(k)] = string(v)
}
for _, a := range alerts[1:] {
kv := extractFn(a)
for k := range common {
if string(kv[model.LabelName(k)]) != common[k] {
delete(common, k)
}
}
if len(common) == 0 {
break
}
}
return common
}

View File

@@ -0,0 +1,348 @@
package alertmanagertemplate
import (
"testing"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
)
func TestWrapBareVars(t *testing.T) {
testCases := []struct {
name string
input string
expected string
expectError bool
}{
{
name: "mixed variables with actions",
input: "$name is {{.Status}}",
expected: "{{ .name }} is {{.Status}}",
},
{
name: "nested variables in range",
input: `{{range .items}}
$title
{{end}}`,
expected: `{{range .items}}
{{ .title }}
{{end}}`,
},
{
name: "nested variables in if else",
input: "{{if .ok}}$a{{else}}$b{{end}}",
expected: "{{if .ok}}{{ .a }}{{else}}{{ .b }}{{end}}",
},
// Labels prefix: index into .labels map
{
name: "labels variables prefix simple",
input: "$labels.service",
expected: `{{ index .labels "service" }}`,
},
{
name: "labels variables prefix nested with multiple dots",
input: "$labels.http.status",
expected: `{{ index .labels "http.status" }}`,
},
{
name: "multiple labels variables simple and nested",
input: "$labels.service and $labels.instance.id",
expected: `{{ index .labels "service" }} and {{ index .labels "instance.id" }}`,
},
// Annotations prefix: index into .annotations map
{
name: "annotations variables prefix simple",
input: "$annotations.summary",
expected: `{{ index .annotations "summary" }}`,
},
{
name: "annotations variables prefix nested with multiple dots",
input: "$annotations.alert.url",
expected: `{{ index .annotations "alert.url" }}`,
},
// Other dotted paths: index into root context
{
name: "other variables with multiple dots",
input: "$service.name",
expected: `{{ index . "service.name" }}`,
},
{
name: "other variables with multiple dots nested",
input: "$http.status.code",
expected: `{{ index . "http.status.code" }}`,
},
// Hybrid: all types combined
{
name: "hybrid - all variables types",
input: "Alert: $alert_name Labels: $labels.severity Annotations: $annotations.desc Service: $service.name Count: $error_count",
expected: `Alert: {{ .alert_name }} Labels: {{ index .labels "severity" }} Annotations: {{ index .annotations "desc" }} Service: {{ index . "service.name" }} Count: {{ .error_count }}`,
},
{
name: "already wrapped should not be changed",
input: "{{$status := .status}}{{.name}} is {{$status | toUpper}}",
expected: "{{$status := .status}}{{.name}} is {{$status | toUpper}}",
},
{
name: "no variables should not be changed",
input: "Hello world",
expected: "Hello world",
},
{
name: "empty string",
input: "",
expected: "",
},
{
name: "deeply nested",
input: "{{range .items}}{{if .ok}}$deep{{end}}{{end}}",
expected: "{{range .items}}{{if .ok}}{{ .deep }}{{end}}{{end}}",
},
{
name: "complex example",
input: `Hello $name, your score is $score.
{{if .isAdmin}}
Welcome back $name, you have {{.unreadCount}} messages.
{{end}}`,
expected: `Hello {{ .name }}, your score is {{ .score }}.
{{if .isAdmin}}
Welcome back {{ .name }}, you have {{.unreadCount}} messages.
{{end}}`,
},
{
name: "with custom function",
input: "$name triggered at {{urlescape .url}}",
expected: "{{ .name }} triggered at {{urlescape .url}}",
},
{
name: "invalid template",
input: "{{invalid",
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := WrapDollarVariables(tc.input)
if tc.expectError {
require.Error(t, err, "should error on invalid template syntax")
} else {
require.NoError(t, err)
require.Equal(t, tc.expected, result)
}
})
}
}
func TestExtractUsedVariables(t *testing.T) {
testCases := []struct {
name string
input string
expected map[string]bool
expectError bool
}{
{
name: "simple usage in text",
input: "$name is $status",
expected: map[string]bool{"name": true, "status": true},
},
{
name: "declared in action block",
input: "{{ $name := .name }}",
expected: map[string]bool{"name": true},
},
{
name: "range loop vars",
input: "{{ range $i, $v := .items }}{{ end }}",
expected: map[string]bool{"i": true, "v": true},
},
{
name: "mixed text and action",
input: "$x and {{ $y }}",
expected: map[string]bool{"x": true, "y": true},
},
{
name: "dotted path in text extracts base only",
input: "$labels.severity",
expected: map[string]bool{"labels": true},
},
{
name: "nested if else",
input: "{{ if .ok }}{{ $a }}{{ else }}{{ $b }}{{ end }}",
expected: map[string]bool{"a": true, "b": true},
},
{
name: "empty string",
input: "",
expected: map[string]bool{},
},
{
name: "no variables",
input: "Hello world",
expected: map[string]bool{},
},
{
name: "invalid template returns error",
input: "{{invalid",
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := ExtractUsedVariables(tc.input)
if tc.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tc.expected, result)
}
})
}
}
func TestAggregateKV(t *testing.T) {
extractLabels := func(a *types.Alert) model.LabelSet { return a.Labels }
testCases := []struct {
name string
alerts []*types.Alert
extractFn func(*types.Alert) model.LabelSet
expected template.KV
}{
{
name: "empty alerts slice",
alerts: []*types.Alert{},
extractFn: extractLabels,
expected: template.KV{},
},
{
name: "single alert",
alerts: []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{
"env": "production",
"service": "backend",
},
},
},
},
extractFn: extractLabels,
expected: template.KV{
"env": "production",
"service": "backend",
},
},
{
name: "varying values with duplicates deduped",
alerts: []*types.Alert{
{Alert: model.Alert{Labels: model.LabelSet{"env": "production", "service": "backend"}}},
{Alert: model.Alert{Labels: model.LabelSet{"env": "production", "service": "api"}}},
{Alert: model.Alert{Labels: model.LabelSet{"env": "production", "service": "frontend"}}},
{Alert: model.Alert{Labels: model.LabelSet{"env": "production", "service": "api"}}},
},
extractFn: extractLabels,
expected: template.KV{
"env": "production",
"service": "backend, api, frontend",
},
},
{
name: "more than 5 unique values truncates to 5",
alerts: []*types.Alert{
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc1"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc2"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc3"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc4"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc5"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc6"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "svc7"}}},
},
extractFn: extractLabels,
expected: template.KV{
"service": "svc1, svc2, svc3, svc4, svc5",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := aggregateKV(tc.alerts, tc.extractFn)
require.Equal(t, tc.expected, result)
})
}
}
func TestExtractCommonKV(t *testing.T) {
extractLabels := func(a *types.Alert) model.LabelSet { return a.Labels }
extractAnnotations := func(a *types.Alert) model.LabelSet { return a.Annotations }
testCases := []struct {
name string
alerts []*types.Alert
extractFn func(*types.Alert) model.LabelSet
expected template.KV
}{
{
name: "empty alerts slice",
alerts: []*types.Alert{},
extractFn: extractLabels,
expected: template.KV{},
},
{
name: "single alert returns all labels",
alerts: []*types.Alert{
{Alert: model.Alert{Labels: model.LabelSet{"env": "prod", "service": "api"}}},
},
extractFn: extractLabels,
expected: template.KV{"env": "prod", "service": "api"},
},
{
name: "multiple alerts with fully common labels",
alerts: []*types.Alert{
{Alert: model.Alert{Labels: model.LabelSet{"env": "prod", "region": "us-east"}}},
{Alert: model.Alert{Labels: model.LabelSet{"env": "prod", "region": "us-east"}}},
},
extractFn: extractLabels,
expected: template.KV{"env": "prod", "region": "us-east"},
},
{
name: "multiple alerts with partially common labels",
alerts: []*types.Alert{
{Alert: model.Alert{Labels: model.LabelSet{"env": "prod", "service": "api"}}},
{Alert: model.Alert{Labels: model.LabelSet{"env": "prod", "service": "worker"}}},
},
extractFn: extractLabels,
expected: template.KV{"env": "prod"},
},
{
name: "multiple alerts with no common labels",
alerts: []*types.Alert{
{Alert: model.Alert{Labels: model.LabelSet{"service": "api"}}},
{Alert: model.Alert{Labels: model.LabelSet{"service": "worker"}}},
},
extractFn: extractLabels,
expected: template.KV{},
},
{
name: "annotations extract common annotations",
alerts: []*types.Alert{
{Alert: model.Alert{Annotations: model.LabelSet{"summary": "high cpu", "runbook": "http://x"}}},
{Alert: model.Alert{Annotations: model.LabelSet{"summary": "high cpu", "runbook": "http://y"}}},
},
extractFn: extractAnnotations,
expected: template.KV{"summary": "high cpu"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := extractCommonKV(tc.alerts, tc.extractFn)
require.Equal(t, tc.expected, result)
})
}
}

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

@@ -3,9 +3,10 @@ package flagger
import "github.com/SigNoz/signoz/pkg/types/featuretypes"
var (
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
)
func MustNewRegistry() featuretypes.Registry {
@@ -34,6 +35,14 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureGetMetersFromZeus,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Controls whether billing details are fetched from Zeus instead of the legacy subscriptions service",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
)
if err != nil {
panic(err)

View File

@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/statsreporter"
citypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -17,6 +18,9 @@ type Module interface {
// GetAccount returns cloud integration account
GetAccount(ctx context.Context, orgID, accountID valuer.UUID, provider citypes.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)
// ListAccounts lists accounts where agent is connected
ListAccounts(ctx context.Context, orgID valuer.UUID, provider citypes.CloudProviderType) ([]*citypes.Account, error)
@@ -32,11 +36,11 @@ 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 citypes.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, integrationID *valuer.UUID, serviceID citypes.ServiceID, provider citypes.CloudProviderType) (*citypes.Service, error)
GetService(ctx context.Context, orgID valuer.UUID, serviceID citypes.ServiceID, provider citypes.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
@@ -55,6 +59,8 @@ type Module interface {
// ListDashboards returns list of dashboards across all connected cloud integration accounts
// for enabled services in the org. This list gets added to dashboard list page
ListDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error)
statsreporter.StatsCollector
}
type CloudProviderModule interface {

View File

@@ -0,0 +1,32 @@
package cloudintegration
import (
"github.com/SigNoz/signoz/pkg/factory"
)
type Config struct {
// Agent config for cloud integration
Agent AgentConfig `mapstructure:"agent"`
}
type AgentConfig struct {
Version string `mapstructure:"version"`
}
func NewConfigFactory() factory.ConfigFactory {
return factory.NewConfigFactory(factory.MustNewName("cloudintegration"), newConfig)
}
func newConfig() factory.Config {
return &Config{
Agent: AgentConfig{
// we will maintain the latest version of cloud integration agent from here,
// till we automate it externally or figure out a way to validate it.
Version: "v0.0.8",
},
}
}
func (c Config) Validate() error {
return nil
}

View File

@@ -1,21 +1,176 @@
package implcloudintegration
import (
"bytes"
"context"
"embed"
"encoding/base64"
"encoding/json"
"fmt"
"io/fs"
"path"
"sort"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
citypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
)
const definitionsRoot = "fs/definitions"
//go:embed fs/definitions/*
var definitionFiles embed.FS
type definitionStore struct{}
func NewDefinitionStore() citypes.ServiceDefinitionStore {
// NewServiceDefinitionStore creates a new ServiceDefinitionStore backed by the embedded filesystem.
func NewServiceDefinitionStore() citypes.ServiceDefinitionStore {
return &definitionStore{}
}
func (d *definitionStore) Get(ctx context.Context, provider citypes.CloudProviderType, serviceID citypes.ServiceID) (*citypes.ServiceDefinition, error) {
panic("unimplemented")
// 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) {
svcDir := path.Join(definitionsRoot, provider.StringValue(), serviceID.StringValue())
def, err := readServiceDefinition(svcDir)
if err != nil {
return nil, errors.New(errors.TypeNotFound, citypes.ErrCodeServiceDefinitionNotFound, fmt.Sprintf("service definition not found for service id %q", serviceID.StringValue()))
}
return def, nil
}
func (d *definitionStore) List(ctx context.Context, provider citypes.CloudProviderType) ([]*citypes.ServiceDefinition, error) {
panic("unimplemented")
// 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) {
providerDir := path.Join(definitionsRoot, provider.StringValue())
entries, err := fs.ReadDir(definitionFiles, providerDir)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read service definition dirs for %s", provider.StringValue())
}
var result []*citypes.ServiceDefinition
for _, entry := range entries {
if !entry.IsDir() {
continue
}
svcDir := path.Join(providerDir, entry.Name())
def, err := readServiceDefinition(svcDir)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read service definition for %s/%s", provider.StringValue(), entry.Name())
}
result = append(result, def)
}
sort.Slice(result, func(i, j int) bool {
return result[i].ID < result[j].ID
})
return result, nil
}
// following are helper functions for reading and hydrating service definitions,
// not keeping this in types as this is an implementation detail of the definition store.
func readServiceDefinition(svcDir string) (*citypes.ServiceDefinition, error) {
integrationJSONPath := path.Join(svcDir, "integration.json")
raw, err := definitionFiles.ReadFile(integrationJSONPath)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read %s", integrationJSONPath)
}
var specMap map[string]any
if err := json.Unmarshal(raw, &specMap); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't parse %s", integrationJSONPath)
}
hydrated, err := hydrateFileURIs(specMap, definitionFiles, svcDir)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't hydrate file URIs in %s", integrationJSONPath)
}
reEncoded, err := json.Marshal(hydrated)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't re-encode hydrated spec from %s", integrationJSONPath)
}
var def citypes.ServiceDefinition
decoder := json.NewDecoder(bytes.NewReader(reEncoded))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&def); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't decode service definition from %s", integrationJSONPath)
}
if err := validateServiceDefinition(&def); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "invalid service definition in %s", svcDir)
}
return &def, nil
}
func validateServiceDefinition(def *citypes.ServiceDefinition) error {
if def.TelemetryCollectionStrategy == nil {
return errors.NewInternalf(errors.CodeInternal, "telemetryCollectionStrategy is required")
}
seenDashboardIDs := map[string]struct{}{}
for _, d := range def.Assets.Dashboards {
if _, seen := seenDashboardIDs[d.ID]; seen {
return errors.NewInternalf(errors.CodeInternal, "duplicate dashboard id %q", d.ID)
}
seenDashboardIDs[d.ID] = struct{}{}
}
return nil
}
// hydrateFileURIs walks a JSON-decoded value and replaces any "file://<path>" strings
// with the actual file contents (text for .md, base64 data URI for .svg, parsed JSON for .json).
func hydrateFileURIs(v any, embeddedFS embed.FS, basedir string) (any, error) {
switch val := v.(type) {
case map[string]any:
result := make(map[string]any, len(val))
for k, child := range val {
hydrated, err := hydrateFileURIs(child, embeddedFS, basedir)
if err != nil {
return nil, err
}
result[k] = hydrated
}
return result, nil
case []any:
result := make([]any, len(val))
for i, child := range val {
hydrated, err := hydrateFileURIs(child, embeddedFS, basedir)
if err != nil {
return nil, err
}
result[i] = hydrated
}
return result, nil
case string:
if !strings.HasPrefix(val, "file://") {
return val, nil
}
return readEmbeddedFile(embeddedFS, path.Join(basedir, val[len("file://"):]))
}
return v, nil
}
func readEmbeddedFile(embeddedFS embed.FS, filePath string) (any, error) {
contents, err := embeddedFS.ReadFile(filePath)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read embedded file %s", filePath)
}
switch {
case strings.HasSuffix(filePath, ".md"):
return string(contents), nil
case strings.HasSuffix(filePath, ".svg"):
return fmt.Sprintf("data:image/svg+xml;base64,%s", base64.StdEncoding.EncodeToString(contents)), nil
case strings.HasSuffix(filePath, ".json"):
var parsed any
if err := json.Unmarshal(contents, &parsed); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't parse JSON file %s", filePath)
}
return parsed, nil
default:
return nil, errors.NewInternalf(errors.CodeInternal, "unsupported file type for embedded reference: %s", filePath)
}
}

View File

@@ -2728,13 +2728,13 @@
"assets": {
"dashboards": [
{
"id": "overview.json",
"id": "overview",
"title": "EKS Control Plane Overview",
"description": "Overview of EKS Control Plane",
"definition": "file://assets/dashboards/overview.json"
},
{
"id": "containerinsights.json",
"id": "containerinsights",
"title": "EKS ContainerInsights Overview",
"description": "Overview of EKS ContainerInsights",
"definition": "file://assets/dashboards/containerinsights.json"

View File

@@ -1,62 +1,436 @@
package implcloudintegration
import (
"context"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
type handler struct{}
func NewHandler() cloudintegration.Handler {
return &handler{}
type handler struct {
module cloudintegration.Module
}
func (handler *handler) GetConnectionCredentials(http.ResponseWriter, *http.Request) {
panic("unimplemented")
func NewHandler(module cloudintegration.Module) cloudintegration.Handler {
return &handler{
module: module,
}
}
func (handler *handler) CreateAccount(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")
func (handler *handler) GetConnectionCredentials(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
}
creds, err := handler.module.GetConnectionCredentials(ctx, valuer.MustNewUUID(claims.OrgID), provider)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, creds)
}
func (handler *handler) ListAccounts(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")
func (handler *handler) CreateAccount(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
}
postableAccount := new(cloudintegrationtypes.PostableAccount)
err = binding.JSON.BindBody(r.Body, postableAccount)
if err != nil {
render.Error(rw, err)
return
}
accountConfig, err := cloudintegrationtypes.NewAccountConfigFromPostable(provider, postableAccount.Config)
if err != nil {
render.Error(rw, err)
return
}
account := cloudintegrationtypes.NewAccount(valuer.MustNewUUID(claims.OrgID), provider, accountConfig)
err = handler.module.CreateAccount(ctx, account)
if err != nil {
render.Error(rw, err)
return
}
connectionArtifact, err := handler.module.GetConnectionArtifact(ctx, account, postableAccount)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, cloudintegrationtypes.NewGettableAccountWithConnectionArtifact(account, connectionArtifact))
}
func (handler *handler) GetAccount(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")
func (handler *handler) GetAccount(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
}
accountID, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
account, err := handler.module.GetAccount(ctx, valuer.MustNewUUID(claims.OrgID), accountID, provider)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, account)
}
func (handler *handler) UpdateAccount(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")
func (handler *handler) ListAccounts(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
}
accounts, err := handler.module.ListAccounts(ctx, valuer.MustNewUUID(claims.OrgID), provider)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, cloudintegrationtypes.NewGettableAccounts(accounts))
}
func (handler *handler) DisconnectAccount(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")
func (handler *handler) UpdateAccount(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
}
cloudIntegrationID, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
req := new(cloudintegrationtypes.UpdatableAccount)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
account, err := handler.module.GetConnectedAccount(ctx, valuer.MustNewUUID(claims.OrgID), cloudIntegrationID, provider)
if err != nil {
render.Error(rw, err)
return
}
accountConfig, err := cloudintegrationtypes.NewAccountConfigFromUpdatable(provider, req)
if err != nil {
render.Error(rw, err)
return
}
if err := account.Update(provider, accountConfig); err != nil {
render.Error(rw, err)
return
}
err = handler.module.UpdateAccount(ctx, account)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) ListServicesMetadata(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")
func (handler *handler) DisconnectAccount(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
}
cloudIntegrationID, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
err = handler.module.DisconnectAccount(ctx, valuer.MustNewUUID(claims.OrgID), cloudIntegrationID, provider)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) GetService(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")
func (handler *handler) ListServicesMetadata(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
}
queryParams := new(cloudintegrationtypes.ListServicesMetadataParams)
if err := binding.Query.BindQuery(r.URL.Query(), queryParams); err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
// check if integration account exists and is not removed.
if !queryParams.CloudIntegrationID.IsZero() {
_, err := handler.module.GetConnectedAccount(ctx, orgID, queryParams.CloudIntegrationID, provider)
if err != nil {
render.Error(rw, err)
return
}
}
services, err := handler.module.ListServicesMetadata(ctx, orgID, provider, queryParams.CloudIntegrationID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, cloudintegrationtypes.NewGettableServicesMetadata(services))
}
func (handler *handler) UpdateService(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")
func (handler *handler) GetService(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
}
serviceID, err := cloudintegrationtypes.NewServiceID(provider, mux.Vars(r)["service_id"])
if err != nil {
render.Error(rw, err)
return
}
queryParams := new(cloudintegrationtypes.GetServiceParams)
if err := binding.Query.BindQuery(r.URL.Query(), queryParams); err != nil {
render.Error(rw, err)
return
}
// check if integration account exists and is not removed.
if !queryParams.CloudIntegrationID.IsZero() {
_, err := handler.module.GetConnectedAccount(ctx, valuer.MustNewUUID(claims.OrgID), queryParams.CloudIntegrationID, provider)
if err != nil {
render.Error(rw, err)
return
}
}
svc, err := handler.module.GetService(ctx, valuer.MustNewUUID(claims.OrgID), serviceID, provider, queryParams.CloudIntegrationID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, svc)
}
func (handler *handler) AgentCheckIn(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")
func (handler *handler) UpdateService(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
}
serviceID, err := cloudintegrationtypes.NewServiceID(provider, mux.Vars(r)["service_id"])
if err != nil {
render.Error(rw, err)
return
}
req := new(cloudintegrationtypes.UpdatableService)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
cloudIntegrationID, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
// check if integration account exists and is not removed.
_, err = handler.module.GetConnectedAccount(ctx, orgID, cloudIntegrationID, provider)
if err != nil {
render.Error(rw, err)
return
}
svc, err := handler.module.GetService(ctx, orgID, serviceID, provider, cloudIntegrationID)
if err != nil {
render.Error(rw, err)
return
}
// update or create service
if svc.CloudIntegrationService == nil {
cloudIntegrationService := cloudintegrationtypes.NewCloudIntegrationService(serviceID, cloudIntegrationID, req.Config)
err = handler.module.CreateService(ctx, orgID, cloudIntegrationService, provider)
} else {
err = svc.CloudIntegrationService.Update(provider, serviceID, req.Config)
if err != nil {
render.Error(rw, err)
return
}
err = handler.module.UpdateService(ctx, orgID, svc.CloudIntegrationService, provider)
}
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) AgentCheckIn(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
}
req := new(cloudintegrationtypes.PostableAgentCheckIn)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
// Map old fields → new fields for backward compatibility with old agents
// Old agents send account_id (=> cloudIntegrationId) and cloud_account_id (=> providerAccountId)
if req.ID != "" {
id, err := valuer.NewUUID(req.ID)
if err != nil {
render.Error(rw, err)
return
}
req.CloudIntegrationID = id
req.ProviderAccountID = req.AccountID
}
resp, err := handler.module.AgentCheckIn(ctx, valuer.MustNewUUID(claims.OrgID), provider, &req.AgentCheckInRequest)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, cloudintegrationtypes.NewGettableAgentCheckIn(provider, resp))
}

View File

@@ -0,0 +1,82 @@
package implcloudintegration
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type module struct{}
func NewModule() cloudintegration.Module {
return &module{}
}
func (module *module) GetConnectionCredentials(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.Credentials, error) {
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "get connection credentials is not supported")
}
func (module *module) CreateAccount(ctx context.Context, account *cloudintegrationtypes.Account) error {
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) {
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) {
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) {
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "list accounts is not supported")
}
func (module *module) UpdateAccount(ctx context.Context, account *cloudintegrationtypes.Account) error {
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 {
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 {
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) {
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) {
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 {
return errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "update service is not supported")
}
func (module *module) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
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) {
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "agent check-in is not supported")
}
func (module *module) GetDashboardByID(ctx context.Context, orgID valuer.UUID, id string) (*dashboardtypes.Dashboard, error) {
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "get dashboard by ID is not supported")
}
func (module *module) ListDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "list dashboards is not supported")
}
func (module *module) Collect(context.Context, valuer.UUID) (map[string]any, error) {
return nil, errors.New(errors.TypeUnsupported, cloudintegrationtypes.ErrCodeUnsupported, "stats collection is not supported")
}

View File

@@ -34,21 +34,41 @@ func (store *store) GetAccountByID(ctx context.Context, orgID, id valuer.UUID, p
return account, nil
}
func (store *store) GetConnectedAccount(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, providerAccountID string) (*cloudintegrationtypes.StorableCloudIntegration, error) {
func (store *store) GetConnectedAccount(ctx context.Context, orgID, id valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.StorableCloudIntegration, error) {
account := new(cloudintegrationtypes.StorableCloudIntegration)
err := store.
store.
BunDBCtx(ctx).
NewSelect().
Model(account).
Where("id = ?", id).
Where("org_id = ?", orgID).
Where("provider = ?", provider).
Where("account_id = ?", providerAccountID).
Where("account_id IS NOT NULL").
Where("last_agent_report IS NOT NULL").
Where("removed_at IS NULL").
Scan(ctx)
if err != nil {
return nil, store.store.WrapNotFoundErrf(err, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "connected account with provider account id %s not found", providerAccountID)
return nil, store.store.WrapNotFoundErrf(err, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "connected cloud integration account with id %s not found", id)
}
return account, nil
}
func (store *store) GetConnectedAccountByProviderAccountID(ctx context.Context, orgID valuer.UUID, providerAccountID string, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.StorableCloudIntegration, error) {
account := new(cloudintegrationtypes.StorableCloudIntegration)
err := store.
store.
BunDBCtx(ctx).
NewSelect().
Model(account).
Where("account_id = ?", providerAccountID).
Where("org_id = ?", orgID).
Where("provider = ?", provider).
Where("last_agent_report IS NOT NULL").
Where("removed_at IS NULL").
Scan(ctx)
if err != nil {
return nil, store.store.WrapNotFoundErrf(err, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "connected cloud integration account with provider account id %s not found", providerAccountID)
}
return account, nil
}
@@ -73,6 +93,26 @@ 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) {
storable := new(cloudintegrationtypes.StorableCloudIntegration)
count, err := store.
store.
BunDBCtx(ctx).
NewSelect().
Model(storable).
Where("org_id = ?", orgID).
Where("provider = ?", provider).
Where("removed_at IS NULL").
Where("account_id IS NOT NULL").
Where("last_agent_report IS NOT NULL").
Count(ctx)
if err != nil {
return 0, err
}
return count, nil
}
func (store *store) CreateAccount(ctx context.Context, account *cloudintegrationtypes.StorableCloudIntegration) error {
_, err := store.
store.

View File

@@ -361,7 +361,7 @@ func (module *module) getOrGetSetIdentity(ctx context.Context, serviceAccountID
return identity, nil
}
storableServiceAccount, err := module.store.GetByID(ctx, serviceAccountID)
storableServiceAccount, err := module.store.GetByIDAndStatus(ctx, serviceAccountID, serviceaccounttypes.ServiceAccountStatusActive)
if err != nil {
return nil, err
}

View File

@@ -105,6 +105,24 @@ func (store *store) GetByID(ctx context.Context, id valuer.UUID) (*serviceaccoun
return storable, nil
}
func (store *store) GetByIDAndStatus(ctx context.Context, id valuer.UUID, status serviceaccounttypes.ServiceAccountStatus) (*serviceaccounttypes.ServiceAccount, error) {
storable := new(serviceaccounttypes.ServiceAccount)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(storable).
Where("id = ?", id).
Where("status = ?", status.StringValue()).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeServiceAccountNotFound, "service account with id: %s and status: %s doesn't exist", id, status.StringValue())
}
return storable, nil
}
func (store *store) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
storable := new(serviceaccounttypes.ServiceAccount)

View File

@@ -63,6 +63,7 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/postprocess"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
@@ -1137,12 +1138,19 @@ func (aH *APIHandler) Get(rw http.ResponseWriter, r *http.Request) {
}
dashboard := new(dashboardtypes.Dashboard)
if aH.CloudIntegrationsController.IsCloudIntegrationDashboardUuid(id) {
cloudIntegrationDashboard, apiErr := aH.CloudIntegrationsController.GetDashboardById(ctx, orgID, id)
if apiErr != nil {
render.Error(rw, errorsV2.Wrapf(apiErr, errorsV2.TypeInternal, errorsV2.CodeInternal, "failed to get dashboard"))
if _, _, _, err := cloudintegrationtypes.ParseCloudIntegrationDashboardID(id); err == nil {
cloudIntegrationDashboard, err := aH.Signoz.Modules.CloudIntegration.GetDashboardByID(ctx, orgID, id)
if err != nil && !errorsV2.Ast(err, errorsV2.TypeLicenseUnavailable) {
render.Error(rw, errorsV2.Wrapf(err, errorsV2.TypeInternal, errorsV2.CodeInternal, "failed to get dashboard"))
return
}
if cloudIntegrationDashboard == nil {
render.Error(rw, errorsV2.Newf(errorsV2.TypeNotFound, errorsV2.CodeNotFound, "dashboard not found"))
return
}
dashboard = cloudIntegrationDashboard
} else if aH.IntegrationsController.IsInstalledIntegrationDashboardID(id) {
integrationDashboard, apiErr := aH.IntegrationsController.GetInstalledIntegrationDashboardById(ctx, orgID, id)
@@ -1207,9 +1215,11 @@ func (aH *APIHandler) List(rw http.ResponseWriter, r *http.Request) {
dashboards = append(dashboards, installedIntegrationDashboards...)
}
cloudIntegrationDashboards, apiErr := aH.CloudIntegrationsController.AvailableDashboards(ctx, orgID)
if apiErr != nil {
aH.logger.ErrorContext(ctx, "failed to get dashboards for cloud integrations", errors.Attr(apiErr))
cloudIntegrationDashboards, err := aH.Signoz.Modules.CloudIntegration.ListDashboards(ctx, orgID)
if err != nil {
if !errors.Ast(err, errorsV2.TypeLicenseUnavailable) {
aH.logger.ErrorContext(ctx, "failed to get dashboards for cloud integrations", errors.Attr(err))
}
} else {
dashboards = append(dashboards, cloudIntegrationDashboards...)
}

View File

@@ -22,6 +22,7 @@ import (
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/user"
@@ -127,6 +128,9 @@ type Config struct {
// Auditor config
Auditor auditor.Config `mapstructure:"auditor"`
// CloudIntegration config
CloudIntegration cloudintegration.Config `mapstructure:"cloudintegration"`
}
func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.ResolverConfig) (Config, error) {
@@ -158,6 +162,7 @@ func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.R
identn.NewConfigFactory(),
serviceaccount.NewConfigFactory(),
auditor.NewConfigFactory(),
cloudintegration.NewConfigFactory(),
}
conf, err := config.New(ctx, resolverConfig, configFactories)
@@ -300,7 +305,6 @@ func mergeAndEnsureBackwardCompatibility(ctx context.Context, logger *slog.Logge
}
config.Flagger.Config.Boolean[flagger.FeatureKafkaSpanEval.String()] = os.Getenv("KAFKA_SPAN_EVAL") == "true"
}
}
func (config Config) Collect(_ context.Context, _ valuer.UUID) (map[string]any, error) {

View File

@@ -97,7 +97,7 @@ func NewHandlers(
QuerierHandler: querierHandler,
ServiceAccountHandler: implserviceaccount.NewHandler(modules.ServiceAccount),
RegistryHandler: registryHandler,
CloudIntegrationHandler: implcloudintegration.NewHandler(),
RuleStateHistory: implrulestatehistory.NewHandler(modules.RuleStateHistory),
CloudIntegrationHandler: implcloudintegration.NewHandler(modules.CloudIntegration),
}
}

View File

@@ -52,8 +52,7 @@ func TestNewHandlers(t *testing.T) {
userRoleStore := impluser.NewUserRoleStore(sqlstore, providerSettings)
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil)
querierHandler := querier.NewHandler(providerSettings, nil, nil)
registryHandler := factory.NewHandler(nil)

View File

@@ -12,6 +12,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/apdex/implapdex"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/modules/authdomain/implauthdomain"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer"
@@ -30,7 +31,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/savedview"
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
"github.com/SigNoz/signoz/pkg/modules/services"
"github.com/SigNoz/signoz/pkg/modules/services/implservices"
"github.com/SigNoz/signoz/pkg/modules/session"
@@ -71,6 +71,7 @@ type Modules struct {
MetricsExplorer metricsexplorer.Module
Promote promote.Module
ServiceAccount serviceaccount.Module
CloudIntegration cloudintegration.Module
RuleStateHistory rulestatehistory.Module
}
@@ -93,6 +94,8 @@ func NewModules(
dashboard dashboard.Module,
userGetter user.Getter,
userRoleStore authtypes.UserRoleStore,
serviceAccount serviceaccount.Module,
cloudIntegrationModule cloudintegration.Module,
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
@@ -117,7 +120,8 @@ func NewModules(
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, providerSettings, config.MetricsExplorer),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
ServiceAccount: implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), authz, cache, analytics, providerSettings, config.ServiceAccount),
ServiceAccount: serviceAccount,
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger)),
CloudIntegration: cloudIntegrationModule,
}
}

View File

@@ -13,8 +13,11 @@ import (
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sharder"
@@ -51,7 +54,9 @@ func TestNewModules(t *testing.T) {
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore)
serviceAccount := implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), nil, nil, nil, providerSettings, serviceaccount.Config{})
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, implcloudintegration.NewModule())
reflectVal := reflect.ValueOf(modules)
for i := 0; i < reflectVal.NumField(); i++ {

View File

@@ -6,10 +6,10 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfroutingstore/sqlroutingstore"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/apiserver"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authn/authnstore/sqlauthnstore"
"github.com/SigNoz/signoz/pkg/authz"
@@ -18,12 +18,16 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
@@ -98,6 +102,7 @@ func New(
gatewayProviderFactory func(licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config],
auditorProviderFactories func(licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]],
querierHandlerCallback func(factory.ProviderSettings, querier.Querier, analytics.Analytics) querier.Handler,
cloudIntegrationCallback func(sqlstore.SQLStore, global.Global, zeus.Zeus, gateway.Gateway, licensing.Licensing, serviceaccount.Module, cloudintegration.Config) (cloudintegration.Module, error),
) (*SigNoz, error) {
// Initialize instrumentation
instrumentation, err := instrumentation.New(ctx, config.Instrumentation, version.Info, "signoz")
@@ -426,11 +431,18 @@ func New(
return nil, err
}
serviceAccount := implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), authz, cache, analytics, providerSettings, config.ServiceAccount)
cloudIntegrationModule, err := cloudIntegrationCallback(sqlstore, global, zeus, gateway, licensing, serviceAccount, config.CloudIntegration)
if err != nil {
return nil, err
}
// Initialize all modules
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, cloudIntegrationModule)
// Initialize identN resolver
identNFactories := NewIdentNProviderFactories(tokenizer, modules.ServiceAccount, orgGetter, userGetter, config.User)
identNFactories := NewIdentNProviderFactories(tokenizer, serviceAccount, orgGetter, userGetter, config.User)
identNResolver, err := identn.NewIdentNResolver(ctx, providerSettings, config.IdentN, identNFactories)
if err != nil {
return nil, err
@@ -452,7 +464,8 @@ func New(
tokenizer,
config,
modules.AuthDomain,
modules.ServiceAccount,
serviceAccount,
cloudIntegrationModule,
}
// Initialize stats reporter from the available stats reporter provider factories

View File

@@ -39,6 +39,10 @@ type SqliteConfig struct {
type ConnectionConfig struct {
// MaxOpenConns is the maximum number of open connections to the database.
MaxOpenConns int `mapstructure:"max_open_conns"`
// MaxConnLifetime is the maximum amount of time a connection may be reused.
// If max_conn_lifetime == 0, connections are not closed due to a connection's age.
MaxConnLifetime time.Duration `mapstructure:"max_conn_lifetime"`
}
func NewConfigFactory() factory.ConfigFactory {
@@ -49,7 +53,8 @@ func newConfig() factory.Config {
return Config{
Provider: "sqlite",
Connection: ConnectionConfig{
MaxOpenConns: 100,
MaxOpenConns: 100,
MaxConnLifetime: 0,
},
Sqlite: SqliteConfig{
Path: "/var/lib/signoz/signoz.db",

View File

@@ -19,6 +19,7 @@ func TestNewWithEnvProvider(t *testing.T) {
t.Setenv("SIGNOZ_SQLSTORE_SQLITE_BUSY__TIMEOUT", "5s")
t.Setenv("SIGNOZ_SQLSTORE_SQLITE_TRANSACTION__MODE", "immediate")
t.Setenv("SIGNOZ_SQLSTORE_MAX__OPEN__CONNS", "50")
t.Setenv("SIGNOZ_SQLSTORE_MAX__CONN__LIFETIME", "3h")
conf, err := config.New(
context.Background(),
@@ -41,7 +42,8 @@ func TestNewWithEnvProvider(t *testing.T) {
expected := &Config{
Provider: "sqlite",
Connection: ConnectionConfig{
MaxOpenConns: 50,
MaxOpenConns: 50,
MaxConnLifetime: time.Hour * 3,
},
Sqlite: SqliteConfig{
Path: "/tmp/test.db",

View File

@@ -56,6 +56,7 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
}
settings.Logger().InfoContext(ctx, "connected to sqlite", slog.String("path", config.Sqlite.Path))
sqldb.SetMaxOpenConns(config.Connection.MaxOpenConns)
sqldb.SetConnMaxLifetime(config.Connection.MaxConnLifetime)
sqliteDialect := sqlitedialect.New()
bunDB := sqlstore.NewBunDB(settings, sqldb, sqliteDialect, hooks)

View File

@@ -11,19 +11,22 @@ import (
alertmanagertemplate "github.com/prometheus/alertmanager/template"
)
func AdditionalFuncMap() tmpltext.FuncMap {
return tmpltext.FuncMap{
// urlescape escapes the string for use in a URL query parameter.
// It returns tmplhtml.HTML to prevent the template engine from escaping the already escaped string.
// url.QueryEscape escapes spaces as "+", and html/template escapes "+" as "&#43;" if tmplhtml.HTML is not used.
"urlescape": func(value string) tmplhtml.HTML {
return tmplhtml.HTML(url.QueryEscape(value))
},
}
}
// customTemplateOption returns an Option that adds custom functions to the template.
func customTemplateOption() alertmanagertemplate.Option {
return func(text *tmpltext.Template, html *tmplhtml.Template) {
funcs := tmpltext.FuncMap{
// urlescape escapes the string for use in a URL query parameter.
// It returns tmplhtml.HTML to prevent the template engine from escaping the already escaped string.
// url.QueryEscape escapes spaces as "+", and html/template escapes "+" as "&#43;" if tmplhtml.HTML is not used.
"urlescape": func(value string) tmplhtml.HTML {
return tmplhtml.HTML(url.QueryEscape(value))
},
}
text.Funcs(funcs)
html.Funcs(funcs)
text.Funcs(AdditionalFuncMap())
html.Funcs(AdditionalFuncMap())
}
}

View File

@@ -2,10 +2,12 @@ package cloudintegrationtypes
import (
"encoding/json"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/zeustypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -97,6 +99,15 @@ func NewAccount(orgID valuer.UUID, provider CloudProviderType, config *AccountCo
}
}
func NewCredentials(sigNozAPIURL, sigNozAPIKey, ingestionURL, ingestionKey string) *Credentials {
return &Credentials{
SigNozAPIURL: sigNozAPIURL,
SigNozAPIKey: sigNozAPIKey,
IngestionURL: ingestionURL,
IngestionKey: ingestionKey,
}
}
func NewAccountFromStorable(storableAccount *StorableCloudIntegration) (*Account, error) {
// config can not be empty
if storableAccount.Config == "" {
@@ -146,54 +157,68 @@ func NewAccountsFromStorables(storableAccounts []*StorableCloudIntegration) ([]*
return accounts, nil
}
func (account *Account) Update(config *AccountConfig) error {
if account.RemovedAt != nil {
return errors.New(errors.TypeUnsupported, ErrCodeCloudIntegrationRemoved, "this operation is not supported for a removed cloud integration account")
func NewGettableAccountWithConnectionArtifact(account *Account, connectionArtifact *ConnectionArtifact) *GettableAccountWithConnectionArtifact {
return &GettableAccountWithConnectionArtifact{
ID: account.ID,
ConnectionArtifact: connectionArtifact,
}
account.Config = config
account.UpdatedAt = time.Now()
return nil
}
func (account *Account) IsRemoved() bool {
return account.RemovedAt != nil
func NewGettableAccounts(accounts []*Account) *GettableAccounts {
return &GettableAccounts{
Accounts: accounts,
}
}
func NewAccountConfigFromPostable(provider CloudProviderType, config *PostableAccountConfig) (*AccountConfig, error) {
switch provider {
case CloudProviderTypeAWS:
if config.Aws == nil {
return nil, errors.NewInternalf(errors.CodeInternal, "AWS config is nil")
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "AWS config can not be nil for AWS provider")
}
return &AccountConfig{
AWS: &AWSAccountConfig{
Regions: config.Aws.Regions,
},
}, nil
}
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
if err := validateAWSRegion(config.Aws.DeploymentRegion); err != nil {
return nil, err
}
if len(config.Aws.Regions) == 0 {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one region is required")
}
for _, region := range config.Aws.Regions {
if err := validateAWSRegion(region); err != nil {
return nil, err
}
}
return &AccountConfig{AWS: &AWSAccountConfig{Regions: config.Aws.Regions}}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
}
// func NewAccountFromPostableAccount(provider CloudProviderType, account *PostableAccount) (*Account, error) {
// req := &Account{
// Credentials: account.Credentials,
// }
func NewAccountConfigFromUpdatable(provider CloudProviderType, config *UpdatableAccount) (*AccountConfig, error) {
switch provider {
case CloudProviderTypeAWS:
if config.Config.AWS == nil {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "AWS config can not be nil for AWS provider")
}
// switch provider {
// case CloudProviderTypeAWS:
// req.Config = &ConnectionArtifactRequestConfig{
// Aws: &AWSConnectionArtifactRequest{
// DeploymentRegion: artifact.Config.Aws.DeploymentRegion,
// Regions: artifact.Config.Aws.Regions,
// },
// }
if len(config.Config.AWS.Regions) == 0 {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one region is required")
}
// return req, nil
// default:
// return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
// }
// }
for _, region := range config.Config.AWS.Regions {
if err := validateAWSRegion(region); err != nil {
return nil, err
}
}
return &AccountConfig{AWS: &AWSAccountConfig{Regions: config.Config.AWS.Regions}}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
}
func NewAgentReport(data map[string]any) *AgentReport {
return &AgentReport{
@@ -202,6 +227,72 @@ func NewAgentReport(data map[string]any) *AgentReport {
}
}
func GetSigNozAPIURLFromDeployment(deployment *zeustypes.GettableDeployment) (string, error) {
if deployment.Name == "" || deployment.Cluster.Region.DNS == "" {
return "", errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput, "invalid deployment: missing name or DNS")
}
return fmt.Sprintf("https://%s.%s", deployment.Name, deployment.Cluster.Region.DNS), nil
}
func (account *Account) Update(provider CloudProviderType, config *AccountConfig) error {
account.Config = config
account.UpdatedAt = time.Now()
return nil
}
func (postableAccount *PostableAccount) UnmarshalJSON(data []byte) error {
type Alias PostableAccount
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
if temp.Config == nil || temp.Credentials == nil {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "config and credentials are required")
}
if temp.Credentials.SigNozAPIURL == "" {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "sigNozApiURL can not be empty")
}
if temp.Credentials.SigNozAPIKey == "" {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "sigNozApiKey can not be empty")
}
if temp.Credentials.IngestionURL == "" {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "ingestionUrl can not be empty")
}
if temp.Credentials.IngestionKey == "" {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "ingestionKey can not be empty")
}
*postableAccount = PostableAccount(temp)
return nil
}
func (updatableAccount *UpdatableAccount) UnmarshalJSON(data []byte) error {
type Alias UpdatableAccount
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
if temp.Config == nil {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "config is required")
}
*updatableAccount = UpdatableAccount(temp)
return nil
}
func (config *PostableAccountConfig) SetAgentVersion(agentVersion string) {
config.AgentVersion = agentVersion
}
// ToJSON return JSON bytes for the provider's config
// thats why not naming it MarshalJSON(), as it will interfere with default JSON marshalling of AccountConfig struct.
// NOTE: this entertains first non-null provider's config.
@@ -212,104 +303,3 @@ func (config *AccountConfig) ToJSON() ([]byte, error) {
return nil, errors.NewInternalf(errors.CodeInternal, "no provider account config found")
}
func (config *PostableAccountConfig) AddAgentVersion(agentVersion string) {
config.AgentVersion = agentVersion
}
// Validate checks that the connection artifact request has a valid provider-specific block
// with non-empty, valid regions and a valid deployment region.
func (account *PostableAccount) Validate(provider CloudProviderType) error {
if account.Config == nil || account.Credentials == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"config and credentials are required")
}
if account.Credentials.SigNozAPIURL == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"sigNozApiURL can not be empty")
}
if account.Credentials.SigNozAPIKey == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"sigNozApiKey can not be empty")
}
if account.Credentials.IngestionURL == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"ingestionUrl can not be empty")
}
if account.Credentials.IngestionKey == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"ingestionKey can not be empty")
}
switch provider {
case CloudProviderTypeAWS:
if account.Config.Aws == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"aws configuration is required")
}
return account.Config.Aws.Validate()
}
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider)
}
// Validate checks that the AWS connection artifact request has a valid deployment region
// and a non-empty list of valid regions.
func (req *AWSPostableAccountConfig) Validate() error {
if req.DeploymentRegion == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"deploymentRegion is required")
}
if _, ok := ValidAWSRegions[req.DeploymentRegion]; !ok {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidCloudRegion,
"invalid deployment region: %s", req.DeploymentRegion)
}
if len(req.Regions) == 0 {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"at least one region is required")
}
for _, region := range req.Regions {
if _, ok := ValidAWSRegions[region]; !ok {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidCloudRegion,
"invalid AWS region: %s", region)
}
}
return nil
}
func (updatable *UpdatableAccount) Validate(provider CloudProviderType) error {
if updatable.Config == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"config is required")
}
switch provider {
case CloudProviderTypeAWS:
if updatable.Config.AWS == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"aws configuration is required")
}
if len(updatable.Config.AWS.Regions) == 0 {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"at least one region is required")
}
for _, region := range updatable.Config.AWS.Regions {
if _, ok := ValidAWSRegions[region]; !ok {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidCloudRegion,
"invalid AWS region: %s", region)
}
}
default:
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput,
"invalid cloud provider: %s", provider.StringValue())
}
return nil
}

View File

@@ -1,6 +1,7 @@
package cloudintegrationtypes
import (
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -75,11 +76,26 @@ func NewGettableAgentCheckIn(provider CloudProviderType, resp *AgentCheckInRespo
return gettable
}
// Validate checks that the request uses either old fields (account_id, cloud_account_id) or
// new fields (cloudIntegrationId, providerAccountId), never a mix of both.
func (req *PostableAgentCheckIn) Validate() error {
hasOldFields := req.ID != "" || req.AccountID != ""
hasNewFields := !req.CloudIntegrationID.IsZero() || req.ProviderAccountID != ""
func NewAgentCheckInResponse(providerAccountID, cloudIntegrationID string, integrationConfig *ProviderIntegrationConfig, removedAt *time.Time) *AgentCheckInResponse {
return &AgentCheckInResponse{
CloudIntegrationID: cloudIntegrationID,
ProviderAccountID: providerAccountID,
IntegrationConfig: integrationConfig,
RemovedAt: removedAt,
}
}
func (postable *PostableAgentCheckIn) UnmarshalJSON(data []byte) error {
type Alias PostableAgentCheckIn
var temp Alias
err := json.Unmarshal(data, &temp)
if err != nil {
return err
}
hasOldFields := temp.ID != "" || temp.AccountID != ""
hasNewFields := !temp.CloudIntegrationID.IsZero() || temp.ProviderAccountID != ""
if hasOldFields && hasNewFields {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
@@ -89,5 +105,7 @@ func (req *PostableAgentCheckIn) Validate() error {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput,
"request must provide either old fields (account_id, cloud_account_id) or new fields (cloudIntegrationId, providerAccountId)")
}
*postable = PostableAgentCheckIn(temp)
return nil
}

View File

@@ -153,30 +153,42 @@ 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 {
func newStorableServiceConfig(provider CloudProviderType, serviceID ServiceID, serviceConfig *ServiceConfig, supportedSignals *SupportedSignals) (*StorableServiceConfig, error) {
switch provider {
case CloudProviderTypeAWS:
storableAWSServiceConfig := new(StorableAWSServiceConfig)
if supportedSignals.Logs {
if serviceConfig.AWS.Logs == nil {
return nil, errors.NewInvalidInputf(ErrCodeCloudIntegrationInvalidConfig, "logs config is required for AWS service: %s", serviceID.StringValue())
}
storableAWSServiceConfig.Logs = &StorableAWSLogsServiceConfig{
Enabled: serviceConfig.AWS.Logs.Enabled,
}
if serviceID == AWSServiceS3Sync {
if serviceConfig.AWS.Logs.S3Buckets == nil {
return nil, errors.NewInvalidInputf(ErrCodeCloudIntegrationInvalidConfig, "s3 buckets config is required for AWS S3 Sync service")
}
storableAWSServiceConfig.Logs.S3Buckets = serviceConfig.AWS.Logs.S3Buckets
}
}
if supportedSignals.Metrics {
if serviceConfig.AWS.Metrics == nil {
return nil, errors.NewInvalidInputf(ErrCodeCloudIntegrationInvalidConfig, "metrics config is required for AWS service: %s", serviceID.StringValue())
}
storableAWSServiceConfig.Metrics = &StorableAWSMetricsServiceConfig{
Enabled: serviceConfig.AWS.Metrics.Enabled,
}
}
return &StorableServiceConfig{AWS: storableAWSServiceConfig}
return &StorableServiceConfig{AWS: storableAWSServiceConfig}, nil
default:
return nil
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
}

View File

@@ -98,3 +98,11 @@ 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

@@ -1,6 +1,7 @@
package cloudintegrationtypes
import (
"encoding/json"
"fmt"
"strings"
"time"
@@ -237,6 +238,12 @@ func NewService(def ServiceDefinition, storableService *CloudIntegrationService)
}
}
func NewGettableServicesMetadata(services []*ServiceMetadata) *GettableServicesMetadata {
return &GettableServicesMetadata{
Services: services,
}
}
func NewServiceConfigFromJSON(provider CloudProviderType, jsonString string) (*ServiceConfig, error) {
storableServiceConfig, err := newStorableServiceConfigFromJSON(provider, jsonString)
if err != nil {
@@ -267,9 +274,27 @@ func NewServiceConfigFromJSON(provider CloudProviderType, jsonString string) (*S
}
// Update sets the service config.
func (service *CloudIntegrationService) Update(config *ServiceConfig) {
func (service *CloudIntegrationService) Update(provider CloudProviderType, serviceID ServiceID, config *ServiceConfig) error {
switch provider {
case CloudProviderTypeAWS:
if config.AWS == nil {
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "AWS config is required for AWS service")
}
if serviceID == 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")
}
}
// other validations happen in newStorableServiceConfig
default:
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
service.Config = config
service.UpdatedAt = time.Now()
return nil
}
// IsServiceEnabled returns true if the service has at least one signal (logs or metrics) enabled
@@ -307,29 +332,32 @@ func (config *ServiceConfig) IsLogsEnabled(provider CloudProviderType) bool {
}
func (config *ServiceConfig) ToJSON(provider CloudProviderType, serviceID ServiceID, supportedSignals *SupportedSignals) ([]byte, error) {
storableServiceConfig := newStorableServiceConfig(provider, serviceID, config, supportedSignals)
storableServiceConfig, err := newStorableServiceConfig(provider, serviceID, config, supportedSignals)
if err != nil {
return nil, err
}
return storableServiceConfig.toJSON(provider)
}
func (updatableService *UpdatableService) Validate(provider CloudProviderType, serviceID ServiceID) error {
switch provider {
case CloudProviderTypeAWS:
if updatableService.Config.AWS == nil {
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "AWS config is required for AWS service")
}
func (updatableService *UpdatableService) UnmarshalJSON(data []byte) error {
type Alias UpdatableService
if serviceID == AWSServiceS3Sync {
if updatableService.Config.AWS.Logs == nil || updatableService.Config.AWS.Logs.S3Buckets == nil {
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "AWS S3 Sync service requires S3 bucket configuration for logs")
}
}
return nil
default:
return errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
if temp.Config == nil {
return errors.NewInvalidInputf(ErrCodeInvalidInput, "config is required")
}
*updatableService = UpdatableService(temp)
return nil
}
// UTILITIES
// 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 {

View File

@@ -11,11 +11,17 @@ type Store interface {
GetAccountByID(ctx context.Context, orgID, id valuer.UUID, provider CloudProviderType) (*StorableCloudIntegration, error)
// GetConnectedAccount for a given provider
GetConnectedAccount(ctx context.Context, orgID valuer.UUID, provider CloudProviderType, providerAccountID string) (*StorableCloudIntegration, error)
GetConnectedAccount(ctx context.Context, orgID, id valuer.UUID, provider 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)
// ListConnectedAccounts returns all the cloud integration accounts for the org and cloud provider
ListConnectedAccounts(ctx context.Context, orgID valuer.UUID, provider 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)
// CreateAccount creates a new cloud integration account
CreateAccount(ctx context.Context, account *StorableCloudIntegration) error

View File

@@ -9,4 +9,19 @@ const (
LabelSeverityName = "severity"
LabelLastSeen = "lastSeen"
LabelRuleID = "ruleId"
LabelRuleSource = "ruleSource"
LabelNoData = "nodata"
LabelTestAlert = "testalert"
LabelAlertName = "alertname"
)
const (
AnnotationRelatedLogs = "related_logs"
AnnotationRelatedTraces = "related_traces"
AnnotationTitleTemplate = "title_template"
AnnotationBodyTemplate = "body_template"
AnnotationValue = "value"
AnnotationThreshold = "threshold"
AnnotationCompareOp = "compare_op"
AnnotationMatchType = "match_type"
)

View File

@@ -12,7 +12,7 @@ import (
)
var (
factorAPIKeyNameRegex = regexp.MustCompile("^[a-z-]{1,80}$")
factorAPIKeyNameRegex = regexp.MustCompile("^[a-z][a-z0-9-]{0,79}$")
)
var (
@@ -20,7 +20,7 @@ var (
ErrCodeAPIKeyAlreadyExists = errors.MustNewCode("api_key_already_exists")
ErrCodeAPIKeytNotFound = errors.MustNewCode("api_key_not_found")
ErrCodeAPIKeyExpired = errors.MustNewCode("api_key_expired")
errInvalidAPIKeyName = errors.New(errors.TypeInvalidInput, ErrCodeAPIKeyInvalidInput, "name must be 180 characters long and contain only lowercase letters (a-z) and hyphens (-)")
errInvalidAPIKeyName = errors.New(errors.TypeInvalidInput, ErrCodeAPIKeyInvalidInput, "name must start with a lowercase letter (a-z), contain only lowercase letters, numbers (0-9), and hyphens (-), and be at most 80 characters long")
)
type FactorAPIKey struct {

View File

@@ -23,7 +23,7 @@ var (
ErrCodeServiceAccountNotFound = errors.MustNewCode("service_account_not_found")
ErrCodeServiceAccountRoleAlreadyExists = errors.MustNewCode("service_account_role_already_exists")
ErrCodeServiceAccountOperationUnsupported = errors.MustNewCode("service_account_operation_unsupported")
errInvalidServiceAccountName = errors.New(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "name must be 150 characters long and contain only lowercase letters (a-z) and hyphens (-)")
errInvalidServiceAccountName = errors.New(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "name must start with a lowercase letter (a-z), contain only lowercase letters, numbers (0-9), and hyphens (-), and be at most 50 characters long")
)
var (
@@ -32,7 +32,7 @@ var (
)
var (
serviceAccountNameRegex = regexp.MustCompile("^[a-z-]{1,50}$")
serviceAccountNameRegex = regexp.MustCompile("^[a-z][a-z0-9-]{0,49}$")
)
type ServiceAccountStatus struct{ valuer.String }
@@ -238,6 +238,7 @@ type Store interface {
Get(context.Context, valuer.UUID, valuer.UUID) (*ServiceAccount, error)
GetActiveByOrgIDAndName(context.Context, valuer.UUID, string) (*ServiceAccount, error)
GetByID(context.Context, valuer.UUID) (*ServiceAccount, error)
GetByIDAndStatus(context.Context, valuer.UUID, ServiceAccountStatus) (*ServiceAccount, error)
CountByOrgID(context.Context, valuer.UUID) (int64, error)
List(context.Context, valuer.UUID) ([]*ServiceAccount, error)
Update(context.Context, valuer.UUID, *ServiceAccount) error

View File

@@ -1,8 +1,10 @@
package zeustypes
import (
"encoding/json"
"net/url"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/tidwall/gjson"
)
@@ -56,3 +58,30 @@ func NewGettableHost(data []byte) *GettableHost {
Hosts: hosts,
}
}
// GettableDeployment represents the parsed deployment info from zeus.GetDeployment.
// NOTE: this is not a full response structure, add more fields from actual response as per requirement.
type GettableDeployment struct {
ID string `json:"id"`
Name string `json:"name"`
Cluster struct {
ID string `json:"id"`
Name string `json:"name"`
Region struct {
ID string `json:"id"`
Name string `json:"name"`
DNS string `json:"dns"`
} `json:"region"`
} `json:"cluster"`
}
// NewGettableDeployment parses raw GetDeployment bytes into a GettableDeployment.
func NewGettableDeployment(data []byte) (*GettableDeployment, error) {
deployment := new(GettableDeployment)
err := json.Unmarshal(data, deployment)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to unmarshal deployment response")
}
return deployment, nil
}

View File

@@ -37,6 +37,10 @@ func (provider *provider) GetDeployment(_ context.Context, _ string) ([]byte, er
return nil, errors.New(errors.TypeUnsupported, zeus.ErrCodeUnsupported, "getting the deployment is not supported")
}
func (provider *provider) GetMeters(_ context.Context, _ string) ([]byte, error) {
return nil, errors.New(errors.TypeUnsupported, zeus.ErrCodeUnsupported, "getting meters is not supported")
}
func (provider *provider) PutMeters(_ context.Context, _ string, _ []byte) error {
return errors.New(errors.TypeUnsupported, zeus.ErrCodeUnsupported, "putting meters is not supported")
}

View File

@@ -26,6 +26,9 @@ type Zeus interface {
// Returns the deployment for the given license key.
GetDeployment(context.Context, string) ([]byte, error)
// Returns the billing details for the given license key.
GetMeters(context.Context, string) ([]byte, error)
// Puts the meters for the given license key.
PutMeters(context.Context, string, []byte) error

View File

@@ -14,7 +14,7 @@ logger = setup_logger(__name__)
@pytest.fixture(scope="function")
def create_cloud_integration_account(
def deprecated_create_cloud_integration_account(
request: pytest.FixtureRequest,
signoz: types.SigNoz,
) -> Callable[[str, str], dict]:
@@ -78,3 +78,78 @@ def create_cloud_integration_account(
logger.info("Cleaned up test account: %s", account_id)
except Exception as exc: # pylint: disable=broad-except
logger.info("Post-test disconnect cleanup failed: %s", exc)
@pytest.fixture(scope="function")
def create_cloud_integration_account(
request: pytest.FixtureRequest,
signoz: types.SigNoz,
) -> Callable[[str, str], dict]:
created_accounts: list[tuple[str, str]] = []
def _create(
admin_token: str,
cloud_provider: str = "aws",
deployment_region: str = "us-east-1",
regions: list[str] | None = None,
) -> dict:
if regions is None:
regions = ["us-east-1"]
endpoint = f"/api/v1/cloud_integrations/{cloud_provider}/accounts"
request_payload = {
"config": {
cloud_provider: {
"deploymentRegion": deployment_region,
"regions": regions,
}
},
"credentials": {
"sigNozApiURL": "https://test-deployment.test.signoz.cloud",
"sigNozApiKey": "test-api-key-789",
"ingestionUrl": "https://ingest.test.signoz.cloud",
"ingestionKey": "test-ingestion-key-123456",
},
}
response = requests.post(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
json=request_payload,
timeout=10,
)
assert (
response.status_code == HTTPStatus.CREATED
), f"Failed to create test account: {response.status_code}: {response.text}"
data = response.json()["data"]
created_accounts.append((data["id"], cloud_provider))
return data
yield _create
if created_accounts:
get_token = request.getfixturevalue("get_token")
try:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
for account_id, cloud_provider in created_accounts:
delete_endpoint = (
f"/api/v1/cloud_integrations/{cloud_provider}/accounts/{account_id}"
)
r = requests.delete(
signoz.self.host_configs["8080"].get(delete_endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
if r.status_code != HTTPStatus.NO_CONTENT:
logger.info(
"Delete cleanup returned %s for account %s",
r.status_code,
account_id,
)
logger.info("Cleaned up test account: %s", account_id)
except Exception as exc: # pylint: disable=broad-except
logger.info("Post-test delete cleanup failed: %s", exc)

View File

@@ -1,6 +1,15 @@
"""Fixtures for cloud integration tests."""
from typing import Callable
import requests
from wiremock.client import (
HttpMethods,
Mapping,
MappingRequest,
MappingResponse,
WireMockMatchers,
)
from fixtures import types
from fixtures.logger import setup_logger
@@ -8,7 +17,7 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def simulate_agent_checkin(
def deprecated_simulate_agent_checkin(
signoz: types.SigNoz,
admin_token: str,
cloud_provider: str,
@@ -38,3 +47,108 @@ def simulate_agent_checkin(
)
return response
def setup_create_account_mocks(
signoz: types.SigNoz,
make_http_mocks: Callable,
) -> None:
"""Set up Zeus and Gateway mocks required by the CreateAccount endpoint."""
make_http_mocks(
signoz.zeus,
[
Mapping(
request=MappingRequest(
method=HttpMethods.GET,
url="/v2/deployments/me",
headers={
"X-Signoz-Cloud-Api-Key": {
WireMockMatchers.EQUAL_TO: "secret-key"
}
},
),
response=MappingResponse(
status=200,
json_body={
"status": "success",
"data": {
"name": "test-deployment",
"cluster": {"region": {"dns": "test.signoz.cloud"}},
},
},
),
persistent=False,
)
],
)
make_http_mocks(
signoz.gateway,
[
Mapping(
request=MappingRequest(
method=HttpMethods.GET,
url="/v1/workspaces/me/keys/search?name=aws-integration&page=1&per_page=10",
),
response=MappingResponse(
status=200,
json_body={
"status": "success",
"data": [],
"_pagination": {"page": 1, "per_page": 10, "total": 0},
},
),
persistent=False,
),
Mapping(
request=MappingRequest(
method=HttpMethods.POST,
url="/v1/workspaces/me/keys",
),
response=MappingResponse(
status=200,
json_body={
"status": "success",
"data": {
"name": "aws-integration",
"value": "test-ingestion-key-123456",
},
"error": "",
},
),
persistent=False,
),
],
)
def simulate_agent_checkin(
signoz: types.SigNoz,
admin_token: str,
cloud_provider: str,
account_id: str,
cloud_account_id: str,
data: dict | None = None,
) -> requests.Response:
endpoint = f"/api/v1/cloud_integrations/{cloud_provider}/accounts/check_in"
checkin_payload = {
"cloudIntegrationId": account_id,
"providerAccountId": cloud_account_id,
"data": data or {},
}
response = requests.post(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
json=checkin_payload,
timeout=10,
)
if not response.ok:
logger.error(
"Agent check-in failed: %s, response: %s",
response.status_code,
response.text,
)
return response

View File

@@ -76,6 +76,7 @@ def create_signoz(
"SIGNOZ_ALERTMANAGER_SIGNOZ_POLL__INTERVAL": "5s",
"SIGNOZ_ALERTMANAGER_SIGNOZ_ROUTE_GROUP__WAIT": "1s",
"SIGNOZ_ALERTMANAGER_SIGNOZ_ROUTE_GROUP__INTERVAL": "5s",
"SIGNOZ_CLOUDINTEGRATION_AGENT_VERSION": "v0.0.8",
}
| sqlstore.env
| clickhouse.env

View File

@@ -6,7 +6,7 @@ import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.cloudintegrationsutils import simulate_agent_checkin
from fixtures.cloudintegrationsutils import deprecated_simulate_agent_checkin
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
@@ -150,7 +150,7 @@ def test_duplicate_cloud_account_checkins(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
deprecated_create_cloud_integration_account: Callable,
) -> None:
"""Test that two accounts cannot check in with the same cloud_account_id."""
@@ -159,16 +159,16 @@ def test_duplicate_cloud_account_checkins(
same_cloud_account_id = str(uuid.uuid4())
# Create two separate cloud integration accounts via generate-connection-url
account1 = create_cloud_integration_account(admin_token, cloud_provider)
account1 = deprecated_create_cloud_integration_account(admin_token, cloud_provider)
account1_id = account1["account_id"]
account2 = create_cloud_integration_account(admin_token, cloud_provider)
account2 = deprecated_create_cloud_integration_account(admin_token, cloud_provider)
account2_id = account2["account_id"]
assert account1_id != account2_id, "Two accounts should have different internal IDs"
# First check-in succeeds: account1 claims cloud_account_id
response = simulate_agent_checkin(
response = deprecated_simulate_agent_checkin(
signoz, admin_token, cloud_provider, account1_id, same_cloud_account_id
)
assert (
@@ -176,7 +176,7 @@ def test_duplicate_cloud_account_checkins(
), f"Expected 200 for first check-in, got {response.status_code}: {response.text}"
#
# Second check-in should fail: account2 tries to use the same cloud_account_id
response = simulate_agent_checkin(
response = deprecated_simulate_agent_checkin(
signoz, admin_token, cloud_provider, account2_id, same_cloud_account_id
)

View File

@@ -6,7 +6,7 @@ import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.cloudintegrationsutils import simulate_agent_checkin
from fixtures.cloudintegrationsutils import deprecated_simulate_agent_checkin
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
@@ -45,19 +45,21 @@ def test_list_connected_accounts_with_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
deprecated_create_cloud_integration_account: Callable,
) -> None:
"""Test listing connected accounts after creating one."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Create a test account
cloud_provider = "aws"
account_data = create_cloud_integration_account(admin_token, cloud_provider)
account_data = deprecated_create_cloud_integration_account(
admin_token, cloud_provider
)
account_id = account_data["account_id"]
# Simulate agent check-in to mark as connected
cloud_account_id = str(uuid.uuid4())
response = simulate_agent_checkin(
response = deprecated_simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
assert (
@@ -93,13 +95,15 @@ def test_get_account_status(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
deprecated_create_cloud_integration_account: Callable,
) -> None:
"""Test getting the status of a specific account."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Create a test account (no check-in needed for status check)
cloud_provider = "aws"
account_data = create_cloud_integration_account(admin_token, cloud_provider)
account_data = deprecated_create_cloud_integration_account(
admin_token, cloud_provider
)
account_id = account_data["account_id"]
# Get account status
@@ -152,19 +156,21 @@ def test_update_account_config(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
deprecated_create_cloud_integration_account: Callable,
) -> None:
"""Test updating account configuration."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Create a test account
cloud_provider = "aws"
account_data = create_cloud_integration_account(admin_token, cloud_provider)
account_data = deprecated_create_cloud_integration_account(
admin_token, cloud_provider
)
account_id = account_data["account_id"]
# Simulate agent check-in to mark as connected
cloud_account_id = str(uuid.uuid4())
response = simulate_agent_checkin(
response = deprecated_simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
assert (
@@ -220,19 +226,21 @@ def test_disconnect_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
deprecated_create_cloud_integration_account: Callable,
) -> None:
"""Test disconnecting an account."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Create a test account
cloud_provider = "aws"
account_data = create_cloud_integration_account(admin_token, cloud_provider)
account_data = deprecated_create_cloud_integration_account(
admin_token, cloud_provider
)
account_id = account_data["account_id"]
# Simulate agent check-in to mark as connected
cloud_account_id = str(uuid.uuid4())
response = simulate_agent_checkin(
response = deprecated_simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
assert (

View File

@@ -6,7 +6,7 @@ import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.cloudintegrationsutils import simulate_agent_checkin
from fixtures.cloudintegrationsutils import deprecated_simulate_agent_checkin
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
@@ -50,18 +50,20 @@ def test_list_services_with_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
deprecated_create_cloud_integration_account: Callable,
) -> None:
"""Test listing services for a specific connected account."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Create a test account and do check-in
cloud_provider = "aws"
account_data = create_cloud_integration_account(admin_token, cloud_provider)
account_data = deprecated_create_cloud_integration_account(
admin_token, cloud_provider
)
account_id = account_data["account_id"]
cloud_account_id = str(uuid.uuid4())
response = simulate_agent_checkin(
response = deprecated_simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
assert (
@@ -144,18 +146,20 @@ def test_get_service_details_with_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
deprecated_create_cloud_integration_account: Callable,
) -> None:
"""Test getting service details for a specific connected account."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Create a test account and do check-in
cloud_provider = "aws"
account_data = create_cloud_integration_account(admin_token, cloud_provider)
account_data = deprecated_create_cloud_integration_account(
admin_token, cloud_provider
)
account_id = account_data["account_id"]
cloud_account_id = str(uuid.uuid4())
response = simulate_agent_checkin(
response = deprecated_simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
assert (
@@ -248,18 +252,20 @@ def test_update_service_config(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
deprecated_create_cloud_integration_account: Callable,
) -> None:
"""Test updating service configuration for a connected account."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Create a test account and do check-in
cloud_provider = "aws"
account_data = create_cloud_integration_account(admin_token, cloud_provider)
account_data = deprecated_create_cloud_integration_account(
admin_token, cloud_provider
)
account_id = account_data["account_id"]
cloud_account_id = str(uuid.uuid4())
response = simulate_agent_checkin(
response = deprecated_simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
assert (
@@ -363,18 +369,20 @@ def test_update_service_config_invalid_service(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
deprecated_create_cloud_integration_account: Callable,
) -> None:
"""Test updating config for a non-existent service should fail."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Create a test account and do check-in
cloud_provider = "aws"
account_data = create_cloud_integration_account(admin_token, cloud_provider)
account_data = deprecated_create_cloud_integration_account(
admin_token, cloud_provider
)
account_id = account_data["account_id"]
cloud_account_id = str(uuid.uuid4())
response = simulate_agent_checkin(
response = deprecated_simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
assert (
@@ -410,18 +418,20 @@ def test_update_service_config_disable_service(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
deprecated_create_cloud_integration_account: Callable,
) -> None:
"""Test disabling a service by updating config with enabled=false."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Create a test account and do check-in
cloud_provider = "aws"
account_data = create_cloud_integration_account(admin_token, cloud_provider)
account_data = deprecated_create_cloud_integration_account(
admin_token, cloud_provider
)
account_id = account_data["account_id"]
cloud_account_id = str(uuid.uuid4())
response = simulate_agent_checkin(
response = deprecated_simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
assert (

View File

@@ -0,0 +1,164 @@
from http import HTTPStatus
from typing import Callable
import requests
from wiremock.client import (
HttpMethods,
Mapping,
MappingRequest,
MappingResponse,
)
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.cloudintegrationsutils import setup_create_account_mocks
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
CLOUD_PROVIDER = "aws"
CREDENTIALS_ENDPOINT = f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/credentials"
def test_apply_license(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
) -> None:
"""Apply a license so that subsequent cloud integration calls succeed."""
add_license(signoz, make_http_mocks, get_token)
def test_get_credentials_success(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
) -> None:
"""Happy path: all four credential fields are returned when Zeus and Gateway respond."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
setup_create_account_mocks(signoz, make_http_mocks)
response = requests.get(
signoz.self.host_configs["8080"].get(CREDENTIALS_ENDPOINT),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200, got {response.status_code}: {response.text}"
data = response.json()["data"]
for field in ("sigNozApiUrl", "sigNozApiKey", "ingestionUrl", "ingestionKey"):
assert field in data, f"Response should contain '{field}'"
assert isinstance(data[field], str), f"'{field}' should be a string"
assert (
len(data[field]) > 0
), f"'{field}' should be non-empty when mocks are set up"
def test_get_credentials_partial_when_zeus_unavailable(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
) -> None:
"""When Zeus is unavailable, server still returns 200 with partial credentials.
The server silently ignores errors from individual credential lookups and returns
whatever it could resolve. The frontend is responsible for prompting the user to
fill in any empty fields.
"""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Reset Zeus mappings so no prior mock bleeds into this test,
# ensuring sigNozApiUrl cannot be resolved and will be returned as empty.
requests.post(
signoz.zeus.host_configs["8080"].get("/__admin/reset"),
timeout=10,
)
# Only set up Gateway mocks — Zeus has no mapping, so sigNozApiUrl will be empty
make_http_mocks(
signoz.gateway,
[
Mapping(
request=MappingRequest(
method=HttpMethods.GET,
url="/v1/workspaces/me/keys/search?name=aws-integration&page=1&per_page=10",
),
response=MappingResponse(
status=200,
json_body={
"status": "success",
"data": [],
"_pagination": {"page": 1, "per_page": 10, "total": 0},
},
),
persistent=False,
),
Mapping(
request=MappingRequest(
method=HttpMethods.POST,
url="/v1/workspaces/me/keys",
),
response=MappingResponse(
status=200,
json_body={
"status": "success",
"data": {
"name": "aws-integration",
"value": "test-ingestion-key-123456",
},
"error": "",
},
),
persistent=False,
),
],
)
response = requests.get(
signoz.self.host_configs["8080"].get(CREDENTIALS_ENDPOINT),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200 even without Zeus, got {response.status_code}: {response.text}"
data = response.json()["data"]
for field in ("sigNozApiUrl", "sigNozApiKey", "ingestionUrl", "ingestionKey"):
assert field in data, f"Response should always contain '{field}' key"
assert isinstance(data[field], str), f"'{field}' should be a string"
# sigNozApiUrl comes from Zeus, which is unavailable, so it should be empty
assert (
data["sigNozApiUrl"] == ""
), "sigNozApiUrl should be empty when Zeus is unavailable"
def test_get_credentials_unsupported_provider(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""Unsupported cloud provider returns 400."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(
"/api/v1/cloud_integrations/gcp/credentials"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.BAD_REQUEST
), f"Expected 400 for unsupported provider, got {response.status_code}"
assert "error" in response.json(), "Response should contain 'error' field"

View File

@@ -0,0 +1,92 @@
from http import HTTPStatus
from typing import Callable
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def test_apply_license(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
) -> None:
"""Apply a license so that subsequent cloud integration calls succeed."""
add_license(signoz, make_http_mocks, get_token)
def test_create_account(
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""Test creating a new cloud integration account for AWS."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
cloud_provider = "aws"
data = create_cloud_integration_account(
admin_token,
cloud_provider,
deployment_region="us-east-1",
regions=["us-east-1", "us-west-2"],
)
assert "id" in data, "Response data should contain 'id' field"
assert len(data["id"]) > 0, "id should be a non-empty UUID string"
assert (
"connectionArtifact" in data
), "Response data should contain 'connectionArtifact' field"
artifact = data["connectionArtifact"]
assert "aws" in artifact, "connectionArtifact should contain 'aws' field"
assert (
"connectionUrl" in artifact["aws"]
), "connectionArtifact.aws should contain 'connectionUrl'"
connection_url = artifact["aws"]["connectionUrl"]
assert (
"console.aws.amazon.com/cloudformation" in connection_url
), "connectionUrl should be an AWS CloudFormation URL"
assert (
"region=us-east-1" in connection_url
), "connectionUrl should contain the deployment region"
def test_create_account_unsupported_provider(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""Test that creating an account with an unsupported cloud provider returns 400."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
cloud_provider = "gcp"
endpoint = f"/api/v1/cloud_integrations/{cloud_provider}/accounts"
response = requests.post(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
json={
"config": {
"gcp": {"deploymentRegion": "us-central1", "regions": ["us-central1"]}
},
"credentials": {
"sigNozApiURL": "https://test.signoz.cloud",
"sigNozApiKey": "test-key",
"ingestionUrl": "https://ingest.test.signoz.cloud",
"ingestionKey": "test-ingestion-key",
},
},
timeout=10,
)
assert (
response.status_code == HTTPStatus.BAD_REQUEST
), f"Expected 400 for unsupported provider, got {response.status_code}"
response_data = response.json()
assert "error" in response_data, "Response should contain 'error' field"

View File

@@ -0,0 +1,129 @@
import uuid
from http import HTTPStatus
from typing import Callable
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.cloudintegrationsutils import simulate_agent_checkin
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
CLOUD_PROVIDER = "aws"
def test_apply_license(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
) -> None:
"""Apply a license so that subsequent cloud integration calls succeed."""
add_license(signoz, make_http_mocks, get_token)
def test_agent_check_in(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""Test agent check-in with new camelCase fields returns 200 with expected response shape."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(
admin_token, CLOUD_PROVIDER, regions=["us-east-1"]
)
account_id = account["id"]
provider_account_id = str(uuid.uuid4())
response = simulate_agent_checkin(
signoz,
admin_token,
CLOUD_PROVIDER,
account_id,
provider_account_id,
data={"version": "v0.0.8"},
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200, got {response.status_code}: {response.text}"
data = response.json()["data"]
# New camelCase fields
assert data["cloudIntegrationId"] == account_id, "cloudIntegrationId should match"
assert (
data["providerAccountId"] == provider_account_id
), "providerAccountId should match"
assert "integrationConfig" in data, "Response should contain 'integrationConfig'"
assert data["removedAt"] is None, "removedAt should be null for a live account"
# Backward-compat snake_case fields
assert data["account_id"] == account_id, "account_id (compat) should match"
assert (
data["cloud_account_id"] == provider_account_id
), "cloud_account_id (compat) should match"
assert (
"integration_config" in data
), "Response should contain 'integration_config' (compat)"
assert "removed_at" in data, "Response should contain 'removed_at' (compat)"
# integrationConfig should reflect the configured regions
integration_config = data["integrationConfig"]
assert "aws" in integration_config, "integrationConfig should contain 'aws' block"
assert integration_config["aws"]["enabledRegions"] == [
"us-east-1"
], "enabledRegions should match account config"
def test_agent_check_in_account_not_found(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""Test that check-in with an unknown cloudIntegrationId returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
fake_id = str(uuid.uuid4())
response = simulate_agent_checkin(
signoz, admin_token, CLOUD_PROVIDER, fake_id, str(uuid.uuid4())
)
assert (
response.status_code == HTTPStatus.NOT_FOUND
), f"Expected 404, got {response.status_code}: {response.text}"
def test_duplicate_cloud_account_checkins(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""Test that two different accounts cannot check in with the same providerAccountId."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account1 = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account2 = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
assert account1["id"] != account2["id"], "Two accounts should have different IDs"
same_provider_account_id = str(uuid.uuid4())
# First check-in: account1 claims the provider account ID
response = simulate_agent_checkin(
signoz, admin_token, CLOUD_PROVIDER, account1["id"], same_provider_account_id
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200 for first check-in, got {response.status_code}: {response.text}"
# Second check-in: account2 tries to claim the same provider account ID → 409
response = simulate_agent_checkin(
signoz, admin_token, CLOUD_PROVIDER, account2["id"], same_provider_account_id
)
assert (
response.status_code == HTTPStatus.CONFLICT
), f"Expected 409 for duplicate providerAccountId, got {response.status_code}: {response.text}"

View File

@@ -0,0 +1,347 @@
import uuid
from http import HTTPStatus
from typing import Callable
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.cloudintegrationsutils import simulate_agent_checkin
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
CLOUD_PROVIDER = "aws"
def test_apply_license(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
) -> None:
"""Apply a license so that subsequent cloud integration calls succeed."""
add_license(signoz, make_http_mocks, get_token)
def test_list_accounts_empty(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""List accounts returns an empty list when no accounts have checked in."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200, got {response.status_code}"
data = response.json()["data"]
assert "accounts" in data, "Response should contain 'accounts' field"
assert isinstance(data["accounts"], list), "accounts should be a list"
assert (
len(data["accounts"]) == 0
), "accounts list should be empty when no accounts have checked in"
def test_list_accounts_after_checkin(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""List accounts returns an account after it has checked in."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(
admin_token, CLOUD_PROVIDER, regions=["us-east-1"]
)
account_id = account["id"]
provider_account_id = str(uuid.uuid4())
checkin = simulate_agent_checkin(
signoz, admin_token, CLOUD_PROVIDER, account_id, provider_account_id
)
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
response = requests.get(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200, got {response.status_code}"
data = response.json()["data"]
found = next((a for a in data["accounts"] if a["id"] == account_id), None)
assert (
found is not None
), f"Account {account_id} should appear in list after check-in"
assert (
found["providerAccountId"] == provider_account_id
), "providerAccountId should match"
assert found["config"]["aws"]["regions"] == [
"us-east-1"
], "regions should match account config"
assert (
found["agentReport"] is not None
), "agentReport should be present after check-in"
assert found["removedAt"] is None, "removedAt should be null for a live account"
def test_get_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""Get a specific account by ID returns the account with correct fields."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(
admin_token, CLOUD_PROVIDER, regions=["us-east-1", "eu-west-1"]
)
account_id = account["id"]
response = requests.get(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200, got {response.status_code}"
data = response.json()["data"]
assert data["id"] == account_id, "id should match"
assert data["config"]["aws"]["regions"] == [
"us-east-1",
"eu-west-1",
], "regions should match"
assert data["removedAt"] is None, "removedAt should be null"
def test_get_account_not_found(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""Get a non-existent account returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{uuid.uuid4()}"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.NOT_FOUND
), f"Expected 404, got {response.status_code}"
def test_update_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""Update account config and verify the change is persisted via GET."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(
admin_token, CLOUD_PROVIDER, regions=["us-east-1"]
)
account_id = account["id"]
checkin = simulate_agent_checkin(
signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4())
)
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
updated_regions = ["us-east-1", "us-west-2", "eu-west-1"]
response = requests.put(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"
),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"aws": {"regions": updated_regions}}},
timeout=10,
)
assert (
response.status_code == HTTPStatus.NO_CONTENT
), f"Expected 204, got {response.status_code}"
get_response = requests.get(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert get_response.status_code == HTTPStatus.OK
assert (
get_response.json()["data"]["config"]["aws"]["regions"] == updated_regions
), "Regions should reflect the update"
def test_update_account_after_checkin_preserves_connected_status(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""Updating config after agent check-in must not remove the account from the connected list.
Regression test: previously, updating an account would reset account_id to NULL,
causing the account to disappear from the connected accounts listing
(which filters on account_id IS NOT NULL).
"""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# 1. Create account
account = create_cloud_integration_account(
admin_token, CLOUD_PROVIDER, regions=["us-east-1"]
)
account_id = account["id"]
provider_account_id = str(uuid.uuid4())
# 2. Agent checks in — sets account_id and last_agent_report
checkin = simulate_agent_checkin(
signoz, admin_token, CLOUD_PROVIDER, account_id, provider_account_id
)
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
# 3. Verify the account appears in the connected list
list_response = requests.get(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert list_response.status_code == HTTPStatus.OK
accounts_before = list_response.json()["data"]["accounts"]
found_before = next((a for a in accounts_before if a["id"] == account_id), None)
assert found_before is not None, "Account should be listed after check-in"
# 4. Update account config
updated_regions = ["us-east-1", "us-west-2"]
update_response = requests.put(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"
),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"aws": {"regions": updated_regions}}},
timeout=10,
)
assert (
update_response.status_code == HTTPStatus.NO_CONTENT
), f"Expected 204, got {update_response.status_code}"
# 5. Verify the account still appears in the connected list with correct fields
list_response = requests.get(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert list_response.status_code == HTTPStatus.OK
accounts_after = list_response.json()["data"]["accounts"]
found_after = next((a for a in accounts_after if a["id"] == account_id), None)
assert (
found_after is not None
), "Account must still be listed after config update (account_id should not be reset)"
assert (
found_after["providerAccountId"] == provider_account_id
), "providerAccountId should be preserved after update"
assert (
found_after["agentReport"] is not None
), "agentReport should be preserved after update"
assert (
found_after["config"]["aws"]["regions"] == updated_regions
), "Config should reflect the update"
assert found_after["removedAt"] is None, "removedAt should still be null"
def test_disconnect_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""Disconnect an account removes it from the connected list."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(
signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4())
)
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
response = requests.delete(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.NO_CONTENT
), f"Expected 204, got {response.status_code}"
list_response = requests.get(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
accounts = list_response.json()["data"]["accounts"]
assert not any(
a["id"] == account_id for a in accounts
), "Disconnected account should not appear in the connected list"
def test_disconnect_account_idempotent(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""Disconnect on a non-existent account ID returns 204 (blind update, no existence check)."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.delete(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{uuid.uuid4()}"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.NO_CONTENT
), f"Expected 204, got {response.status_code}"

View File

@@ -0,0 +1,482 @@
import uuid
from http import HTTPStatus
from typing import Callable
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.cloudintegrationsutils import simulate_agent_checkin
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
CLOUD_PROVIDER = "aws"
SERVICE_ID = "rds"
def test_apply_license(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
) -> None:
"""Apply a license so that subsequent cloud integration calls succeed."""
add_license(signoz, make_http_mocks, get_token)
def test_list_services_without_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""List available services without specifying a cloud_integration_id."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200, got {response.status_code}"
data = response.json()["data"]
assert "services" in data, "Response should contain 'services' field"
assert isinstance(data["services"], list), "services should be a list"
assert len(data["services"]) > 0, "services list should be non-empty"
service = data["services"][0]
assert "id" in service, "Service should have 'id' field"
assert "title" in service, "Service should have 'title' field"
assert "icon" in service, "Service should have 'icon' field"
assert "enabled" in service, "Service should have 'enabled' field"
def test_list_services_with_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""List services filtered to a specific account — all disabled by default."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(
signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4())
)
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
response = requests.get(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services?cloud_integration_id={account_id}"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200, got {response.status_code}"
data = response.json()["data"]
assert "services" in data, "Response should contain 'services' field"
assert len(data["services"]) > 0, "services list should be non-empty"
for svc in data["services"]:
assert "enabled" in svc, "Each service should have 'enabled' field"
assert (
svc["enabled"] is False
), f"Service {svc['id']} should be disabled before any config is set"
def test_get_service_details_without_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""Get full service definition without specifying an account."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200, got {response.status_code}"
data = response.json()["data"]
assert data["id"] == SERVICE_ID, f"id should be '{SERVICE_ID}'"
assert "title" in data, "Service should have 'title'"
assert "overview" in data, "Service should have 'overview' (markdown)"
assert "assets" in data, "Service should have 'assets'"
assert isinstance(
data["assets"]["dashboards"], list
), "assets.dashboards should be a list"
assert (
"telemetryCollectionStrategy" in data
), "Service should have 'telemetryCollectionStrategy'"
assert (
data["cloudIntegrationService"] is None
), "cloudIntegrationService should be null without account context"
def test_get_service_details_with_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""Get service details with account context — cloudIntegrationService is null before first UpdateService."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(
signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4())
)
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
response = requests.get(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}"
f"?cloud_integration_id={account_id}"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200, got {response.status_code}"
data = response.json()["data"]
assert data["id"] == SERVICE_ID
assert (
data["cloudIntegrationService"] is None
), "cloudIntegrationService should be null before any service config is set"
def test_get_service_not_found(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""Get a non-existent service ID returns 400 (invalid service ID is a bad request)."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/non-existent-service"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.BAD_REQUEST
), f"Expected 400, got {response.status_code}"
def test_update_service_config(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""Enable a service and verify the config is persisted via GET."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(
signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4())
)
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
put_response = requests.put(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"
),
headers={"Authorization": f"Bearer {admin_token}"},
json={
"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": True}}}
},
timeout=10,
)
assert (
put_response.status_code == HTTPStatus.NO_CONTENT
), f"Expected 204, got {put_response.status_code}: {put_response.text}"
get_response = requests.get(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}"
f"?cloud_integration_id={account_id}"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert get_response.status_code == HTTPStatus.OK
data = get_response.json()["data"]
svc = data["cloudIntegrationService"]
assert (
svc is not None
), "cloudIntegrationService should be non-null after UpdateService"
assert (
svc["config"]["aws"]["metrics"]["enabled"] is True
), "metrics should be enabled"
assert svc["config"]["aws"]["logs"]["enabled"] is True, "logs should be enabled"
assert (
svc["cloudIntegrationId"] == account_id
), "cloudIntegrationId should match the account"
def test_update_service_config_disable(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""Enable then disable a service — config change is persisted."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(
signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4())
)
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
endpoint = signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"
)
# Enable
r = requests.put(
endpoint,
headers={"Authorization": f"Bearer {admin_token}"},
json={
"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": True}}}
},
timeout=10,
)
assert (
r.status_code == HTTPStatus.NO_CONTENT
), f"Enable failed: {r.status_code}: {r.text}"
# Disable
r = requests.put(
endpoint,
headers={"Authorization": f"Bearer {admin_token}"},
json={
"config": {
"aws": {"metrics": {"enabled": False}, "logs": {"enabled": False}}
}
},
timeout=10,
)
assert (
r.status_code == HTTPStatus.NO_CONTENT
), f"Disable failed: {r.status_code}: {r.text}"
get_response = requests.get(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}"
f"?cloud_integration_id={account_id}"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert get_response.status_code == HTTPStatus.OK
svc = get_response.json()["data"]["cloudIntegrationService"]
assert (
svc is not None
), "cloudIntegrationService should still be present after disable"
assert (
svc["config"]["aws"]["metrics"]["enabled"] is False
), "metrics should be disabled"
assert svc["config"]["aws"]["logs"]["enabled"] is False, "logs should be disabled"
def test_update_service_account_not_found(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""PUT with a non-existent account UUID returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.put(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{uuid.uuid4()}/services/{SERVICE_ID}"
),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"aws": {"metrics": {"enabled": True}}}},
timeout=10,
)
assert (
response.status_code == HTTPStatus.NOT_FOUND
), f"Expected 404, got {response.status_code}"
def test_list_services_unsupported_provider(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""List services for an unsupported cloud provider returns 400."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/cloud_integrations/gcp/services"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.BAD_REQUEST
), f"Expected 400, got {response.status_code}"
def test_list_services_account_removed(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""List services with a cloud_integration_id for a deleted account returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(
signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4())
)
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
delete_response = requests.delete(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
delete_response.status_code == HTTPStatus.NO_CONTENT
), f"Expected 204 on delete, got {delete_response.status_code}"
response = requests.get(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services?cloud_integration_id={account_id}"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.NOT_FOUND
), f"Expected 404, got {response.status_code}"
def test_get_service_details_account_removed(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""Get service details with a cloud_integration_id for a deleted account returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(
signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4())
)
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
delete_response = requests.delete(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
delete_response.status_code == HTTPStatus.NO_CONTENT
), f"Expected 204 on delete, got {delete_response.status_code}"
response = requests.get(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}"
f"?cloud_integration_id={account_id}"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.NOT_FOUND
), f"Expected 404, got {response.status_code}"
def test_update_service_account_removed(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""PUT service config for a deleted account returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(
signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4())
)
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
delete_response = requests.delete(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
delete_response.status_code == HTTPStatus.NO_CONTENT
), f"Expected 204 on delete, got {delete_response.status_code}"
response = requests.put(
signoz.self.host_configs["8080"].get(
f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"
),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"aws": {"metrics": {"enabled": True}}}},
timeout=10,
)
assert (
response.status_code == HTTPStatus.NOT_FOUND
), f"Expected 404, got {response.status_code}"

View File

@@ -245,10 +245,9 @@ def test_service_account_key_deleted_account_rejected(
timeout=5,
)
assert response.status_code in (
HTTPStatus.UNAUTHORIZED,
HTTPStatus.FORBIDDEN,
), f"Expected 401/403 for disabled service account, got {response.status_code}: {response.text}"
assert (
response.status_code == HTTPStatus.UNAUTHORIZED
), f"Expected 401 for disabled service account, got {response.status_code}: {response.text}"
def test_service_account_key_revoked_key_rejected(

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