Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Slack integration for alerts and reports #4371

Merged
merged 34 commits into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
c1d3303
Slack integration for alerts
esevastyanov Mar 18, 2024
00c3859
Slack integration for reports
esevastyanov Mar 18, 2024
bf206b6
alert yaml backward compatibility
esevastyanov Mar 19, 2024
eee8eeb
report yaml backward compatibility
esevastyanov Mar 20, 2024
7ea68f2
Slack webhooks for alerts and reports
esevastyanov Mar 20, 2024
d2b4928
Docs
esevastyanov Mar 20, 2024
305ca89
Refactored alert and report specs
esevastyanov Mar 26, 2024
8f0f37c
Merge remote-tracking branch 'origin/main' into 4183-slack-integration
esevastyanov Mar 26, 2024
9b9c5fb
resolved a merge conflict
esevastyanov Mar 26, 2024
84f7d8a
correspondingly updated application code
esevastyanov Mar 26, 2024
6fca635
prettier fix
esevastyanov Mar 26, 2024
e74585d
prettier fix
esevastyanov Mar 26, 2024
356bec1
type check
esevastyanov Mar 26, 2024
c4940e1
type check
esevastyanov Mar 26, 2024
8573262
Removed a footer of slack templates
esevastyanov Mar 27, 2024
1cc20c3
Removed connector name from a spec
esevastyanov Mar 28, 2024
06070a3
Fixed tests
esevastyanov Mar 28, 2024
d1876fb
Removed unnecessary validation
esevastyanov Mar 28, 2024
7ed36a6
Added notifier metrics
esevastyanov Mar 28, 2024
697fe7a
schemaless notifiers
esevastyanov Apr 1, 2024
b9bd728
type check fix
esevastyanov Apr 1, 2024
6ff77fe
Merge remote-tracking branch 'origin/main' into 4183-slack-integration
esevastyanov Apr 1, 2024
6813f95
merge fix
esevastyanov Apr 1, 2024
0ba18f6
Fixed minor issues
esevastyanov Apr 1, 2024
cc417db
Substituted strings with constants
esevastyanov Apr 2, 2024
1169fb2
Moved Slack props encode/decode into Slack notifier
esevastyanov Apr 3, 2024
6e9b50d
Substituted a custom error with a returned one
esevastyanov Apr 3, 2024
21133e0
moved structbp out notifier
esevastyanov Apr 3, 2024
ffa9647
Merge remote-tracking branch 'origin/main' into 4183-slack-integration
esevastyanov Apr 3, 2024
9694b90
Merge remote-tracking branch 'origin/main' into 4183-slack-integration
esevastyanov Apr 3, 2024
2724501
resolved a merge conflict
esevastyanov Apr 3, 2024
d425870
Linter fix
esevastyanov Apr 3, 2024
bdefffb
Revert "Linter fix"
esevastyanov Apr 3, 2024
49bbaf4
Linter fix (updated)
esevastyanov Apr 3, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 62 additions & 15 deletions admin/server/alerts.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import (
adminv1 "github.com/rilldata/rill/proto/gen/rill/admin/v1"
runtimev1 "github.com/rilldata/rill/proto/gen/rill/runtime/v1"
"github.com/rilldata/rill/runtime"
"github.com/rilldata/rill/runtime/drivers/slack"
"github.com/rilldata/rill/runtime/pkg/observability"
"github.com/rilldata/rill/runtime/pkg/pbutil"
"go.opentelemetry.io/otel/attribute"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
Expand Down Expand Up @@ -274,12 +276,22 @@ func (s *Server) UnsubscribeAlert(ctx context.Context, req *adminv1.UnsubscribeA
return nil, status.Error(codes.Internal, err.Error())
}

opts := recreateAlertOptionsFromSpec(spec)
opts, err := recreateAlertOptionsFromSpec(spec)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to recreate alert options: %s", err.Error())
}

found := false
for idx, email := range opts.Recipients {
for idx, email := range opts.EmailRecipients {
if strings.EqualFold(user.Email, email) {
opts.EmailRecipients = slices.Delete(opts.EmailRecipients, idx, idx+1)
found = true
break
}
}
for idx, email := range opts.SlackUsers {
if strings.EqualFold(user.Email, email) {
opts.Recipients = slices.Delete(opts.Recipients, idx, idx+1)
opts.SlackUsers = slices.Delete(opts.SlackUsers, idx, idx+1)
found = true
break
}
Expand All @@ -289,7 +301,7 @@ func (s *Server) UnsubscribeAlert(ctx context.Context, req *adminv1.UnsubscribeA
return nil, status.Error(codes.InvalidArgument, "user is not subscribed to alert")
}

if len(opts.Recipients) == 0 {
if len(opts.EmailRecipients) == 0 && len(opts.SlackUsers) == 0 && len(opts.SlackChannels) == 0 && len(opts.SlackWebhooks) == 0 {
err = s.admin.DB.UpdateVirtualFileDeleted(ctx, proj.ID, proj.ProdBranch, virtualFilePathForManagedAlert(req.Name))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to update virtual file: %s", err.Error())
Expand Down Expand Up @@ -449,9 +461,13 @@ func (s *Server) yamlForManagedAlert(opts *adminv1.AlertOptions, ownerUserID str
res.Query.ArgsJSON = opts.QueryArgsJson
// Hard code the user id to run for (to avoid exposing data through alert creation)
res.Query.For.UserID = ownerUserID
res.Email.Recipients = opts.Recipients
res.Email.Renotify = opts.EmailRenotify
res.Email.RenotifyAfter = opts.EmailRenotifyAfterSeconds
// Notification options
res.Notify.Renotify = opts.Renotify
res.Notify.RenotifyAfter = opts.RenotifyAfterSeconds
res.Notify.Email.Emails = opts.EmailRecipients
res.Notify.Slack.Channels = opts.SlackChannels
res.Notify.Slack.Users = opts.SlackUsers
res.Notify.Slack.Webhooks = opts.SlackWebhooks
res.Annotations.AdminOwnerUserID = ownerUserID
res.Annotations.AdminManaged = true
res.Annotations.AdminNonce = time.Now().Format(time.RFC3339Nano)
Expand All @@ -477,9 +493,13 @@ func (s *Server) yamlForCommittedAlert(opts *adminv1.AlertOptions) ([]byte, erro
res.Intervals.Duration = opts.IntervalDuration
res.Query.Name = opts.QueryName
res.Query.Args = args
res.Email.Recipients = opts.Recipients
res.Email.Renotify = opts.EmailRenotify
res.Email.RenotifyAfter = opts.EmailRenotifyAfterSeconds
// Notification options
res.Notify.Renotify = opts.Renotify
res.Notify.RenotifyAfter = opts.RenotifyAfterSeconds
res.Notify.Email.Emails = opts.EmailRecipients
res.Notify.Slack.Channels = opts.SlackChannels
res.Notify.Slack.Users = opts.SlackUsers
res.Notify.Slack.Webhooks = opts.SlackWebhooks
return yaml.Marshal(res)
}

Expand Down Expand Up @@ -521,16 +541,31 @@ func randomAlertName(title string) string {
return name
}

func recreateAlertOptionsFromSpec(spec *runtimev1.AlertSpec) *adminv1.AlertOptions {
func recreateAlertOptionsFromSpec(spec *runtimev1.AlertSpec) (*adminv1.AlertOptions, error) {
opts := &adminv1.AlertOptions{}
opts.Title = spec.Title
opts.IntervalDuration = spec.IntervalsIsoDuration
opts.QueryName = spec.QueryName
opts.QueryArgsJson = spec.QueryArgsJson
opts.Recipients = spec.EmailRecipients
opts.EmailRenotify = spec.EmailRenotify
opts.EmailRenotifyAfterSeconds = spec.EmailRenotifyAfterSeconds
return opts
opts.Renotify = spec.Renotify
opts.RenotifyAfterSeconds = spec.RenotifyAfterSeconds
for _, notifier := range spec.Notifiers {
switch notifier.Connector {
case "email":
opts.EmailRecipients = pbutil.ToSliceString(notifier.Properties.AsMap()["recipients"])
case "slack":
props, err := slack.DecodeProps(notifier.Properties.AsMap())
if err != nil {
return nil, err
}
opts.SlackUsers = props.Users
opts.SlackChannels = props.Channels
opts.SlackWebhooks = props.Webhooks
default:
return nil, fmt.Errorf("unknown notifier connector: %s", notifier.Connector)
}
}
return opts, nil
}

// alertYAML is derived from rillv1.AlertYAML, but adapted for generating (as opposed to parsing) the alert YAML.
Expand All @@ -555,6 +590,18 @@ type alertYAML struct {
Renotify bool `yaml:"renotify"`
RenotifyAfter uint32 `yaml:"renotify_after"`
} `yaml:"email"`
Notify struct {
Renotify bool `yaml:"renotify"`
RenotifyAfter uint32 `yaml:"renotify_after"`
Slack struct {
Users []string `yaml:"users"`
Channels []string `yaml:"channels"`
Webhooks []string `yaml:"webhooks"`
}
Email struct {
Emails []string `yaml:"emails"`
}
}
Annotations alertAnnotations `yaml:"annotations,omitempty"`
}

Expand Down
64 changes: 52 additions & 12 deletions admin/server/reports.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ import (
adminv1 "github.com/rilldata/rill/proto/gen/rill/admin/v1"
runtimev1 "github.com/rilldata/rill/proto/gen/rill/runtime/v1"
"github.com/rilldata/rill/runtime"
"github.com/rilldata/rill/runtime/drivers/slack"
"github.com/rilldata/rill/runtime/pkg/observability"
"github.com/rilldata/rill/runtime/pkg/pbutil"
"go.opentelemetry.io/otel/attribute"
"golang.org/x/exp/slices"
"google.golang.org/grpc/codes"
Expand Down Expand Up @@ -246,12 +248,22 @@ func (s *Server) UnsubscribeReport(ctx context.Context, req *adminv1.Unsubscribe
return nil, status.Error(codes.Internal, err.Error())
}

opts := recreateReportOptionsFromSpec(spec)
opts, err := recreateReportOptionsFromSpec(spec)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to recreate report options: %s", err.Error())
}

found := false
for idx, email := range opts.Recipients {
for idx, email := range opts.EmailRecipients {
if strings.EqualFold(user.Email, email) {
opts.EmailRecipients = slices.Delete(opts.EmailRecipients, idx, idx+1)
found = true
break
}
}
for idx, email := range opts.SlackUsers {
if strings.EqualFold(user.Email, email) {
opts.Recipients = slices.Delete(opts.Recipients, idx, idx+1)
opts.SlackUsers = slices.Delete(opts.SlackUsers, idx, idx+1)
found = true
break
}
Expand All @@ -261,7 +273,7 @@ func (s *Server) UnsubscribeReport(ctx context.Context, req *adminv1.Unsubscribe
return nil, status.Error(codes.InvalidArgument, "user is not subscribed to report")
}

if len(opts.Recipients) == 0 {
if len(opts.EmailRecipients) == 0 && len(opts.SlackUsers) == 0 && len(opts.SlackChannels) == 0 && len(opts.SlackWebhooks) == 0 {
err = s.admin.DB.UpdateVirtualFileDeleted(ctx, proj.ID, proj.ProdBranch, virtualFilePathForManagedReport(req.Name))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to update virtual file: %s", err.Error())
Expand Down Expand Up @@ -430,7 +442,10 @@ func (s *Server) yamlForManagedReport(opts *adminv1.ReportOptions, ownerUserID s
res.Query.ArgsJSON = opts.QueryArgsJson
res.Export.Format = opts.ExportFormat.String()
res.Export.Limit = uint(opts.ExportLimit)
res.Email.Recipients = opts.Recipients
res.Notify.Email.Recipients = opts.EmailRecipients
res.Notify.Slack.Channels = opts.SlackChannels
res.Notify.Slack.Users = opts.SlackUsers
res.Notify.Slack.Webhooks = opts.SlackWebhooks
res.Annotations.AdminOwnerUserID = ownerUserID
res.Annotations.AdminManaged = true
res.Annotations.AdminNonce = time.Now().Format(time.RFC3339Nano)
Expand Down Expand Up @@ -470,7 +485,10 @@ func (s *Server) yamlForCommittedReport(opts *adminv1.ReportOptions) ([]byte, er
res.Query.Args = args
res.Export.Format = exportFormat
res.Export.Limit = uint(opts.ExportLimit)
res.Email.Recipients = opts.Recipients
res.Notify.Email.Recipients = opts.EmailRecipients
res.Notify.Slack.Channels = opts.SlackChannels
res.Notify.Slack.Users = opts.SlackUsers
res.Notify.Slack.Webhooks = opts.SlackWebhooks
res.Annotations.WebOpenProjectSubpath = opts.OpenProjectSubpath
return yaml.Marshal(res)
}
Expand Down Expand Up @@ -513,7 +531,7 @@ func randomReportName(title string) string {
return name
}

func recreateReportOptionsFromSpec(spec *runtimev1.ReportSpec) *adminv1.ReportOptions {
func recreateReportOptionsFromSpec(spec *runtimev1.ReportSpec) (*adminv1.ReportOptions, error) {
annotations := parseReportAnnotations(spec.Annotations)

opts := &adminv1.ReportOptions{}
Expand All @@ -527,8 +545,23 @@ func recreateReportOptionsFromSpec(spec *runtimev1.ReportSpec) *adminv1.ReportOp
opts.ExportLimit = spec.ExportLimit
opts.ExportFormat = spec.ExportFormat
opts.OpenProjectSubpath = annotations.WebOpenProjectSubpath
opts.Recipients = spec.EmailRecipients
return opts
for _, notifier := range spec.Notifiers {
switch notifier.Connector {
case "email":
opts.EmailRecipients = pbutil.ToSliceString(notifier.Properties.AsMap()["recipients"])
case "slack":
props, err := slack.DecodeProps(notifier.Properties.AsMap())
if err != nil {
return nil, err
}
opts.SlackUsers = props.Users
opts.SlackChannels = props.Channels
opts.SlackWebhooks = props.Webhooks
default:
return nil, fmt.Errorf("unknown notifier connector: %s", notifier.Connector)
}
}
return opts, nil
}

// reportYAML is derived from rillv1.ReportYAML, but adapted for generating (as opposed to parsing) the report YAML.
Expand All @@ -548,9 +581,16 @@ type reportYAML struct {
Format string `yaml:"format"`
Limit uint `yaml:"limit"`
} `yaml:"export"`
Email struct {
Recipients []string `yaml:"recipients"`
} `yaml:"email"`
Notify struct {
Email struct {
Recipients []string `yaml:"recipients"`
} `yaml:"email"`
Slack struct {
Users []string `yaml:"users"`
Channels []string `yaml:"channels"`
Webhooks []string `yaml:"webhooks"`
} `yaml:"slack"`
} `yaml:"notify"`
Annotations reportAnnotations `yaml:"annotations,omitempty"`
}

Expand Down
1 change: 1 addition & 0 deletions cli/cmd/runtime/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import (
_ "github.com/rilldata/rill/runtime/drivers/redshift"
_ "github.com/rilldata/rill/runtime/drivers/s3"
_ "github.com/rilldata/rill/runtime/drivers/salesforce"
_ "github.com/rilldata/rill/runtime/drivers/slack"
_ "github.com/rilldata/rill/runtime/drivers/snowflake"
_ "github.com/rilldata/rill/runtime/drivers/sqlite"
_ "github.com/rilldata/rill/runtime/reconcilers"
Expand Down
96 changes: 96 additions & 0 deletions docs/docs/reference/connectors/slack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
---
title: Slack
description: Deliver notifications to Slack
sidebar_label: Slack
sidebar_position: 999
---


## Overview

[Slack](https://slack.com/) is a popular messaging platform that allows teams to communicate and collaborate in real-time.
Rill supports sending notifications to Slack channels using the [Slack API](https://api.slack.com/).
This can be useful for sending alerts and reports to your team members.

Although Rill does not support reading data from Slack, Slack support is implemented as a connector
with a corresponding bonus like passing a token as an environment variable.

Slack connector can send notifications to channels, direct messages, and via webhooks.
All of these methods require a Slack application to be created and configured with the necessary scopes.
Follow the [Slack documentation](https://api.slack.com/start/quickstart) to create a Slack application and configure it
depending on notification type you want to use: channels, direct messages, or webhooks.

### Slack channels

Sending notifications to a channel requires [`chat:write`](https://api.slack.com/scopes/chat:write) scope. The application needs to be added to the channel.

### Direct messages

Sending notifications to a direct message requires [`chat:write`](https://api.slack.com/scopes/chat:write), [`users:read`](https://api.slack.com/scopes/users:read), and [`users:read.email`](https://api.slack.com/scopes/users:read.email) scopes.
The last two scopes are required to find the user's ID by email.

### Webhooks

Sending notifications via webhooks requires [`incoming-webhook`](https://api.slack.com/scopes/incoming-webhook) scope only and no bot token is required.
The application needs to be added to the channel.

## Local credentials

When using Rill Developer on your local machine (i.e. `rill start`), Rill expects a [bot token](https://api.slack.com/authentication/token-types#bot) to be passed as follows:

```bash
start devproject --env connector.slack.bot_token=xoxb-...
```

## Cloud deployment

Once a project that requires Slack notifications has been deployed using `rill deploy`, Rill Cloud will need to be able to have access to the [bot token](https://api.slack.com/authentication/token-types#bot). This can be done as follows:

```bash
rill env configure
```

## Example usage

### Reports
```yaml
...
notify:
email:
recipients:
- [email protected]
...
slack:
users:
- [email protected]
...
channels:
- alerts
...
webhooks:
- https://hooks.slack.com/services/...
...
```
### Alerts
```yaml
...
notify:
on_recover: true
renotify: true
renotify_after: 24h
email:
recipients:
- [email protected]
...
slack:
users:
- [email protected]
...
channels:
- alerts
...
webhooks:
- https://hooks.slack.com/services/...
...
```

Loading
Loading