9 changed files with 302 additions and 27 deletions
@ -0,0 +1,144 @@
|
||||
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("&", "&", "<", "<", ">", ">").Replace(s) |
||||
} |
||||
Loading…
Reference in new issue