2026-01-23 20:54:16 +03:00

421 lines
10 KiB
Go

package plugin
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/mattermost/mattermost/server/public/model"
)
func levelToColor(level string) string {
switch strings.ToLower(level) {
case "fatal":
return "#B10DC9" // фиолетовый, критический
case "error":
return "#FF4136" // красный
case "warning":
return "#FF851B" // оранжевый
case "log":
return "#AAAAAA" // серый
case "info":
return "#0074D9" // синий
case "debug":
return "#2ECC40" // зелёный
default:
return "#AAAAAA" // серый для неизвестных
}
}
func getTagFromArray(tags [][]string, key string) string {
for _, t := range tags {
if len(t) == 2 && t[0] == key {
return t[1]
}
}
return ""
}
func (p *Plugin) getProject(projectID string) (*LinkedProject, error) {
key := "ru.loop.plugin.sentry:project:" + projectID
data, appErr := p.API.KVGet(key)
if appErr != nil || data == nil {
return nil, appErr
}
var project LinkedProject
if err := json.Unmarshal(data, &project); err != nil {
return nil, err
}
return &project, nil
}
func formatStacktrace(ex *SentryExceptionValue) string {
if ex == nil || len(ex.Stacktrace.Frames) == 0 {
return ""
}
lines := make([]string, 0, len(ex.Stacktrace.Frames))
for _, f := range ex.Stacktrace.Frames {
lines = append(lines, fmt.Sprintf("%s:%d %s - %s", f.Filename, f.Lineno, f.Function, f.ContextLine))
}
return strings.Join(lines, "\n")
}
func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
resource := r.Header.Get("Sentry-Hook-Resource")
switch resource {
case "event_alert":
p.handleEventAlert(w, r)
case "comment":
p.handleComment(w, r)
case "metric_alert":
p.handleMetricAlert(w, r)
case "issue":
p.handleIssue(w, r)
default:
p.API.LogWarn("Unsupported Sentry hook resource", "resource", resource)
w.WriteHeader(http.StatusOK)
}
}
func (p *Plugin) handleEventAlert(w http.ResponseWriter, r *http.Request) {
var payload SentryPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
p.API.LogError("Failed to decode Sentry event alert payload", "error", err.Error())
w.WriteHeader(http.StatusBadRequest)
return
}
event := payload.Data.Event
channelID, err := p.getChannelForProject(strconv.Itoa(event.Project))
if err != nil || channelID == "" {
p.API.LogWarn("No channel linked for project", "project", event.Project)
w.WriteHeader(http.StatusOK)
return
}
project, err := p.getProject(strconv.Itoa(event.Project))
if err != nil || project == nil || project.ChannelID == "" {
p.API.LogWarn("No channel linked for project", "project", event.Project)
w.WriteHeader(http.StatusOK)
return
}
environment := getTagFromArray(event.Tags, "environment")
release := getTagFromArray(event.Tags, "release")
user := getTagFromArray(event.Tags, "user")
attachment := &model.SlackAttachment{
Color: levelToColor(event.Level),
Title: event.Title,
TitleLink: event.WebURL,
Text: event.Message,
Fields: []*model.SlackAttachmentField{
{
Title: "Project",
Value: fmt.Sprintf("%s (`%s`)", project.Name, project.Slug),
Short: true,
},
{Title: "Project ID", Value: strconv.Itoa(event.Project), Short: true},
{Title: "Issue ID", Value: event.IssueID, Short: true},
{Title: "Environment", Value: environment, Short: true},
{Title: "Level", Value: event.Level, Short: true},
{Title: "Culprit", Value: event.Culprit, Short: false},
{Title: "Logger", Value: event.Logger, Short: true},
{Title: "Platform", Value: event.Platform, Short: true},
{Title: "Release", Value: release, Short: true},
{Title: "User", Value: user, Short: true},
},
}
if event.Exception != nil && len(event.Exception.Values) > 0 {
for _, ex := range event.Exception.Values {
attachment.Fields = append(attachment.Fields, &model.SlackAttachmentField{
Title: "Exception",
Value: fmt.Sprintf(
"Type: %s\nValue: %s\nStacktrace:\n%s",
ex.Type,
ex.Value,
formatStacktrace(&ex),
),
Short: true,
})
}
}
for _, tag := range event.Tags {
if len(tag) != 2 {
continue
}
key := tag[0]
value := tag[1]
if key == "environment" || key == "release" || key == "user" {
continue
}
attachment.Fields = append(attachment.Fields, &model.SlackAttachmentField{
Title: key,
Value: value,
Short: true,
})
}
post := &model.Post{
UserId: p.botUserID,
ChannelId: channelID,
Props: map[string]interface{}{
"attachments": []*model.SlackAttachment{attachment},
},
}
if _, err := p.API.CreatePost(post); err != nil {
p.API.LogError("Failed to create post", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (p *Plugin) handleComment(w http.ResponseWriter, r *http.Request) {
var payload SentryCommentPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
p.API.LogError("Failed to decode Sentry comment payload", "error", err.Error())
w.WriteHeader(http.StatusBadRequest)
return
}
project, err := p.getProjectBySlug(payload.Data.ProjectSlug)
if err != nil || project == nil || project.ChannelID == "" {
p.API.LogWarn("No channel linked for project", "slug", payload.Data.ProjectSlug)
w.WriteHeader(http.StatusOK)
return
}
attachment := &model.SlackAttachment{
Color: "#439FE0",
Title: "💬 New Sentry comment",
Text: fmt.Sprintf(
"*%s* commented on issue `%d`\n\n>%s",
payload.Actor.Name,
payload.Data.IssueID,
payload.Data.Comment,
),
Fields: []*model.SlackAttachmentField{
{Title: "Project", Value: project.Name, Short: true},
{Title: "Action", Value: payload.Action, Short: true},
{Title: "Comment ID", Value: strconv.Itoa(payload.Data.CommentID), Short: true},
},
}
post := &model.Post{
UserId: p.botUserID,
ChannelId: project.ChannelID,
Props: map[string]interface{}{
"attachments": []*model.SlackAttachment{attachment},
},
}
if _, err := p.API.CreatePost(post); err != nil {
p.API.LogError("Failed to create comment post", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (p *Plugin) getProjectBySlug(slug string) (*LinkedProject, error) {
// Получаем все ключи плагина
keys, appErr := p.API.KVList(0, 1000)
if appErr != nil {
return nil, appErr
}
for _, key := range keys {
// интересуют только наши проекты
if !strings.HasPrefix(key, "ru.loop.plugin.sentry:project:") {
continue
}
data, appErr := p.API.KVGet(key)
if appErr != nil || data == nil {
continue
}
var project LinkedProject
if err := json.Unmarshal(data, &project); err != nil {
continue
}
if project.Slug == slug {
return &project, nil
}
}
return nil, nil
}
func (p *Plugin) handleMetricAlert(w http.ResponseWriter, r *http.Request) {
var payload SentryMetricAlertPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
p.API.LogError("Failed to decode metric alert payload", "error", err.Error())
w.WriteHeader(http.StatusBadRequest)
return
}
alert := payload.Data.MetricAlert
rule := alert.AlertRule
// Обычно metric alerts привязаны к проекту по slug
var project *LinkedProject
if len(alert.Projects) > 0 {
project, _ = p.getProjectBySlug(alert.Projects[0])
}
channelID := ""
if project != nil {
channelID = project.ChannelID
}
if channelID == "" {
p.API.LogWarn("No channel linked for metric alert", "projects", alert.Projects)
w.WriteHeader(http.StatusOK)
return
}
color := "#E01E5A" // default: critical
switch payload.Action {
case "resolved":
color = "#2EB67D"
case "warning":
color = "#ECB22E"
}
attachment := &model.SlackAttachment{
Color: color,
Title: payload.Data.DescriptionTitle,
TitleLink: payload.Data.WebURL,
Text: payload.Data.DescriptionText,
Fields: []*model.SlackAttachmentField{
{
Title: "Rule",
Value: rule.Name,
Short: true,
},
{
Title: "Aggregate",
Value: rule.Aggregate,
Short: true,
},
{
Title: "Query",
Value: rule.Query,
Short: false,
},
{
Title: "Window (min)",
Value: strconv.Itoa(rule.TimeWindow),
Short: true,
},
{
Title: "Action",
Value: payload.Action,
Short: true,
},
},
}
post := &model.Post{
UserId: p.botUserID,
ChannelId: channelID,
Props: map[string]interface{}{
"attachments": []*model.SlackAttachment{attachment},
},
}
if _, err := p.API.CreatePost(post); err != nil {
p.API.LogError("Failed to create metric alert post", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (p *Plugin) handleIssue(w http.ResponseWriter, r *http.Request) {
var payload SentryIssuePayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
p.API.LogError("Failed to decode issue payload", "error", err.Error())
w.WriteHeader(http.StatusBadRequest)
return
}
issue := payload.Data.Issue
project, err := p.getProjectBySlug(issue.Project.Slug)
if err != nil || project == nil || project.ChannelID == "" {
p.API.LogWarn("No channel linked for issue", "project", issue.Project.Slug)
w.WriteHeader(http.StatusOK)
return
}
color := "#E01E5A" // default: red
switch payload.Action {
case "resolved":
color = "#2EB67D"
case "archived":
color = "#6B7280"
case "unresolved":
color = "#F97316"
}
attachment := &model.SlackAttachment{
Color: color,
Title: fmt.Sprintf("[%s] %s", issue.ShortID, issue.Title),
TitleLink: issue.WebURL,
Text: fmt.Sprintf(
"**Action:** `%s`\n**Status:** `%s`\n**Level:** `%s`\n**Priority:** `%s`",
payload.Action,
issue.Status,
issue.Level,
issue.Priority,
),
Fields: []*model.SlackAttachmentField{
{Title: "Project", Value: issue.Project.Name, Short: true},
{Title: "Platform", Value: issue.Platform, Short: true},
{Title: "Type", Value: issue.IssueType, Short: true},
{Title: "Category", Value: issue.IssueCategory, Short: true},
{Title: "Events", Value: issue.Count, Short: true},
{Title: "Users", Value: strconv.Itoa(issue.UserCount), Short: true},
},
}
post := &model.Post{
UserId: p.botUserID,
ChannelId: project.ChannelID,
Props: map[string]interface{}{
"attachments": []*model.SlackAttachment{attachment},
},
}
if _, err := p.API.CreatePost(post); err != nil {
p.API.LogError("Failed to create issue post", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}