gitea, notion webhook
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

144 lines
4.4 KiB

package logging
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
"time"
"git/palnet/slack-notifier/internal/notify"
)
// AlertPoster는 에러 알림을 Slack 채널로 보내는 최소 인터페이스.
// 실패해도 slog를 호출하지 않아야 한다(재귀 방지) — slack.Client.PostAlert가 이를 만족.
type AlertPoster interface {
PostAlert(ctx context.Context, channel, text string, blocks []notify.Block) error
}
// slackAlertHandler는 다른 slog.Handler를 감싸, Error 이상 레벨의 로그를
// 지정한 Slack 채널로도 함께 알린다(보기 좋은 Block Kit 형식).
type slackAlertHandler struct {
base slog.Handler
poster AlertPoster
channel string
attrs []slog.Attr // With로 누적된 속성(service/env 등)
}
func newSlackAlertHandler(base slog.Handler, poster AlertPoster, channel string) *slackAlertHandler {
return &slackAlertHandler{base: base, poster: poster, channel: channel}
}
func (h *slackAlertHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.base.Enabled(ctx, level)
}
func (h *slackAlertHandler) Handle(ctx context.Context, rec slog.Record) error {
if rec.Level >= slog.LevelError {
attrs := make([]slog.Attr, 0, len(h.attrs)+rec.NumAttrs())
attrs = append(attrs, h.attrs...)
rec.Attrs(func(a slog.Attr) bool {
attrs = append(attrs, a)
return true
})
text, blocks := buildAlert(rec.Level, rec.Message, rec.Time, attrs)
// 비동기 — 로깅 경로를 막지 않게. 발송 실패는 stderr로만(다시 slog로 가면 무한루프).
go func() {
if err := h.poster.PostAlert(context.Background(), h.channel, text, blocks); err != nil {
fmt.Fprintf(os.Stderr, "alert post failed: %v\n", err)
}
}()
}
return h.base.Handle(ctx, rec)
}
func (h *slackAlertHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
merged := make([]slog.Attr, 0, len(h.attrs)+len(attrs))
merged = append(merged, h.attrs...)
merged = append(merged, attrs...)
return &slackAlertHandler{base: h.base.WithAttrs(attrs), poster: h.poster, channel: h.channel, attrs: merged}
}
func (h *slackAlertHandler) WithGroup(name string) slog.Handler {
return &slackAlertHandler{base: h.base.WithGroup(name), poster: h.poster, channel: h.channel, attrs: h.attrs}
}
// buildAlert는 로그 레코드를 보기 좋은 Slack 메시지로 만든다.
// 맨 앞에 프로젝트(service)와 환경(env)을 표시해 어느 프로젝트의 에러인지 한눈에 보이게 한다.
func buildAlert(level slog.Level, msg string, t time.Time, attrs []slog.Attr) (string, []notify.Block) {
// service/env는 머리말로 분리, 나머지는 상세 필드로.
service, env := "", ""
var fields []notify.Block
for _, a := range attrs {
switch a.Key {
case "service":
service = a.Value.String()
case "env":
env = a.Value.String()
default:
if len(fields) < 10 { // Slack section fields 최대 10개
fields = append(fields, notify.Block{
"type": "mrkdwn",
"text": "*" + a.Key + "*\n" + truncate(esc(a.Value.String()), 500),
})
}
}
}
project := service
if project == "" {
project = "app"
}
if env != "" {
project += " · " + env
}
// 플레인 텍스트 알림(목록/푸시용)에도 프로젝트를 앞세운다.
text := fmt.Sprintf("🚨 [%s] %s", project, msg)
blocks := []notify.Block{
{"type": "header", "text": notify.Block{
"type": "plain_text", "text": truncate("🚨 ["+project+"] 에러 발생", 150), "emoji": true,
}},
{"type": "section", "text": notify.Block{
"type": "mrkdwn", "text": "*" + truncate(esc(msg), 1500) + "*",
}},
}
if len(fields) > 0 {
blocks = append(blocks, notify.Block{"type": "section", "fields": fields})
}
ts := ""
if !t.IsZero() {
ts = t.Format("2006-01-02 15:04:05")
}
ctxLine := strings.TrimSpace(strings.Join(nonEmpty(level.String(), project, ts), " · "))
blocks = append(blocks, notify.Block{
"type": "context",
"elements": []notify.Block{{"type": "mrkdwn", "text": ctxLine}},
})
return text, blocks
}
func nonEmpty(vals ...string) []string {
out := make([]string, 0, len(vals))
for _, v := range vals {
if v != "" {
out = append(out, v)
}
}
return out
}
func truncate(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max]) + "…"
}
// esc는 Slack mrkdwn에서 의미를 갖는 문자만 최소 이스케이프한다.
func esc(s string) string {
return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace(s)
}