mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-17 17:30:31 +01:00
Compare commits
1 Commits
feat/markd
...
ns/td-cach
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6cc0be341 |
@@ -10,7 +10,6 @@ import (
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/cache/memorycache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
@@ -74,25 +73,12 @@ type Server struct {
|
||||
|
||||
// NewServer creates and initializes Server
|
||||
func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
cacheForTraceDetail, err := memorycache.New(context.TODO(), signoz.Instrumentation.ToProviderSettings(), cache.Config{
|
||||
Provider: "memory",
|
||||
Memory: cache.Memory{
|
||||
NumCounters: 10 * 10000,
|
||||
MaxCost: 1 << 27, // 128 MB
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reader := clickhouseReader.NewReader(
|
||||
signoz.Instrumentation.Logger(),
|
||||
signoz.SQLStore,
|
||||
signoz.TelemetryStore,
|
||||
signoz.Prometheus,
|
||||
signoz.TelemetryStore.Cluster(),
|
||||
config.Querier.FluxInterval,
|
||||
cacheForTraceDetail,
|
||||
signoz.Cache,
|
||||
nil,
|
||||
)
|
||||
|
||||
1
go.mod
1
go.mod
@@ -64,7 +64,6 @@ require (
|
||||
github.com/uptrace/bun/dialect/pgdialect v1.2.9
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.2.9
|
||||
github.com/uptrace/bun/extra/bunotel v1.2.9
|
||||
github.com/yuin/goldmark v1.7.16
|
||||
go.opentelemetry.io/collector/confmap v1.51.0
|
||||
go.opentelemetry.io/collector/otelcol v0.144.0
|
||||
go.opentelemetry.io/collector/pdata v1.51.0
|
||||
|
||||
2
go.sum
2
go.sum
@@ -1144,8 +1144,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zeebo/assert v1.3.1 h1:vukIABvugfNMZMQO1ABsyQDJDTVQbn+LWSMy1ol1h6A=
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
package alertmanagertemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"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"
|
||||
)
|
||||
|
||||
// Templater expands user-authored title and body templates against a group
|
||||
// of alerts and returns channel-ready strings along with the aggregate data
|
||||
// a caller might reuse (e.g. to render an email layout around the body).
|
||||
type Templater interface {
|
||||
Expand(ctx context.Context, req alertmanagertypes.ExpandRequest, alerts []*types.Alert) (*alertmanagertypes.ExpandResult, error)
|
||||
}
|
||||
|
||||
type templater struct {
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New returns a Templater bound to the given Prometheus alertmanager
|
||||
// template and logger.
|
||||
func New(tmpl *template.Template, logger *slog.Logger) Templater {
|
||||
return &templater{tmpl: tmpl, logger: logger}
|
||||
}
|
||||
|
||||
func (at *templater) Expand(
|
||||
ctx context.Context,
|
||||
req alertmanagertypes.ExpandRequest,
|
||||
alerts []*types.Alert,
|
||||
) (*alertmanagertypes.ExpandResult, error) {
|
||||
ntd := at.buildNotificationTemplateData(ctx, alerts)
|
||||
missingVars := make(map[string]bool)
|
||||
|
||||
title, titleMissingVars, err := at.expandTitle(req.TitleTemplate, ntd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// if title template results in empty string, use default template
|
||||
// this happens for rules where custom title annotation was not set
|
||||
if title == "" && req.DefaultTitleTemplate != "" {
|
||||
title, err = at.expandDefaultTemplate(ctx, req.DefaultTitleTemplate, alerts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
mergeMissingVars(missingVars, titleMissingVars)
|
||||
}
|
||||
|
||||
isDefaultBody := false
|
||||
body, bodyMissingVars, err := at.expandBody(req.BodyTemplate, ntd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// if body template results in nil, use default template
|
||||
// this happens for rules where custom body annotation was not set
|
||||
if body == nil {
|
||||
isDefaultBody = true
|
||||
defaultBody, err := at.expandDefaultTemplate(ctx, req.DefaultBodyTemplate, alerts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body = []string{defaultBody} // default template combines all alerts message into a single body
|
||||
} else {
|
||||
mergeMissingVars(missingVars, bodyMissingVars)
|
||||
}
|
||||
|
||||
// convert the internal map to a sorted slice for returning missing variables
|
||||
missingVarsList := make([]string, 0, len(missingVars))
|
||||
for k := range missingVars {
|
||||
missingVarsList = append(missingVarsList, k)
|
||||
}
|
||||
sort.Strings(missingVarsList)
|
||||
|
||||
return &alertmanagertypes.ExpandResult{
|
||||
Title: title,
|
||||
Body: body,
|
||||
MissingVars: missingVarsList,
|
||||
IsDefaultBody: isDefaultBody,
|
||||
NotificationData: ntd,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// expandDefaultTemplate uses go-template to expand the default template.
|
||||
func (at *templater) expandDefaultTemplate(
|
||||
ctx context.Context,
|
||||
tmplStr string,
|
||||
alerts []*types.Alert,
|
||||
) (string, error) {
|
||||
// if even the default template is empty, return empty string
|
||||
// this is possible if user added channel with blank template
|
||||
if tmplStr == "" {
|
||||
at.logger.WarnContext(ctx, "default template is empty")
|
||||
return "", nil
|
||||
}
|
||||
data := notify.GetTemplateData(ctx, at.tmpl, alerts, at.logger)
|
||||
result, err := at.tmpl.ExecuteTextString(tmplStr, data)
|
||||
if err != nil {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to execute default template: %s", err.Error())
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// mergeMissingVars adds all keys from src into dst.
|
||||
func mergeMissingVars(dst, src map[string]bool) {
|
||||
for k := range src {
|
||||
dst[k] = true
|
||||
}
|
||||
}
|
||||
|
||||
// expandTitle expands the title template. Returns empty string if the template is empty.
|
||||
func (at *templater) expandTitle(
|
||||
titleTemplate string,
|
||||
ntd *alertmanagertypes.NotificationTemplateData,
|
||||
) (string, map[string]bool, error) {
|
||||
if titleTemplate == "" {
|
||||
return "", nil, nil
|
||||
}
|
||||
processRes, err := preProcessTemplateAndData(titleTemplate, ntd)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
result, err := at.tmpl.ExecuteTextString(processRes.Template, processRes.Data)
|
||||
if err != nil {
|
||||
return "", nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to execute custom title template: %s", err.Error())
|
||||
}
|
||||
return strings.TrimSpace(result), processRes.UnknownVars, nil
|
||||
}
|
||||
|
||||
// expandBody expands the body template for each individual alert. Returns nil if the template is empty.
|
||||
func (at *templater) expandBody(
|
||||
bodyTemplate string,
|
||||
ntd *alertmanagertypes.NotificationTemplateData,
|
||||
) ([]string, map[string]bool, error) {
|
||||
if bodyTemplate == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
var sb []string
|
||||
missingVars := make(map[string]bool)
|
||||
for i := range ntd.Alerts {
|
||||
processRes, err := preProcessTemplateAndData(bodyTemplate, &ntd.Alerts[i])
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
part, err := at.tmpl.ExecuteTextString(processRes.Template, processRes.Data)
|
||||
if err != nil {
|
||||
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to execute custom body template: %s", err.Error())
|
||||
}
|
||||
// add unknown variables and templated text to the result
|
||||
for k := range processRes.UnknownVars {
|
||||
missingVars[k] = true
|
||||
}
|
||||
if strings.TrimSpace(part) != "" {
|
||||
sb = append(sb, strings.TrimSpace(part))
|
||||
}
|
||||
}
|
||||
return sb, missingVars, nil
|
||||
}
|
||||
|
||||
// buildNotificationTemplateData creates the NotificationTemplateData using
|
||||
// info from context and the raw alerts.
|
||||
func (at *templater) buildNotificationTemplateData(
|
||||
ctx context.Context,
|
||||
alerts []*types.Alert,
|
||||
) *alertmanagertypes.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 })
|
||||
|
||||
// Strip private annotations from surfaces visible to templates or
|
||||
// notifications; the structured fields on AlertInfo/RuleInfo already hold
|
||||
// anything a template needs from them.
|
||||
commonAnnotations = alertmanagertypes.FilterPublicAnnotations(commonAnnotations)
|
||||
annotations = alertmanagertypes.FilterPublicAnnotations(annotations)
|
||||
|
||||
// build the alert data slice
|
||||
alertDataSlice := make([]alertmanagertypes.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.Alert.IsFiring {
|
||||
firing++
|
||||
} else if ad.Alert.IsResolved {
|
||||
resolved++
|
||||
}
|
||||
}
|
||||
|
||||
// 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 &alertmanagertypes.NotificationTemplateData{
|
||||
Alert: alertmanagertypes.NotificationAlert{
|
||||
Receiver: receiver,
|
||||
Status: string(types.Alerts(alerts...).Status()),
|
||||
TotalFiring: firing,
|
||||
TotalResolved: resolved,
|
||||
},
|
||||
Rule: buildRuleInfo(commonLabels, commonAnnotations),
|
||||
GroupLabels: gl,
|
||||
CommonLabels: commonLabels,
|
||||
CommonAnnotations: commonAnnotations,
|
||||
ExternalURL: externalURL,
|
||||
Labels: labels,
|
||||
Annotations: annotations,
|
||||
Alerts: alertDataSlice,
|
||||
}
|
||||
}
|
||||
|
||||
// buildAlertData converts a single *types.Alert into an AlertData.
|
||||
func buildAlertData(a *types.Alert, receiver string) alertmanagertypes.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)
|
||||
}
|
||||
|
||||
return alertmanagertypes.AlertData{
|
||||
Alert: alertmanagertypes.AlertInfo{
|
||||
Status: string(a.Status()),
|
||||
Receiver: receiver,
|
||||
Value: annotations[ruletypes.AnnotationValue],
|
||||
StartsAt: a.StartsAt,
|
||||
EndsAt: a.EndsAt,
|
||||
GeneratorURL: a.GeneratorURL,
|
||||
Fingerprint: a.Fingerprint().String(),
|
||||
IsFiring: a.Status() == model.AlertFiring,
|
||||
IsResolved: a.Status() == model.AlertResolved,
|
||||
IsMissingData: labels[ruletypes.LabelNoData] == "true",
|
||||
IsRecovering: labels[ruletypes.LabelIsRecovering] == "true",
|
||||
},
|
||||
Rule: buildRuleInfo(labels, annotations),
|
||||
Log: alertmanagertypes.LinkInfo{URL: annotations[ruletypes.AnnotationRelatedLogs]},
|
||||
Trace: alertmanagertypes.LinkInfo{URL: annotations[ruletypes.AnnotationRelatedTraces]},
|
||||
Labels: labels,
|
||||
// Strip private annotations once the structured fields above have
|
||||
// been populated from the raw map.
|
||||
Annotations: alertmanagertypes.FilterPublicAnnotations(annotations),
|
||||
}
|
||||
}
|
||||
|
||||
// buildRuleInfo extracts the rule metadata from the well-known labels and
|
||||
// annotations that the rule manager attaches to every emitted alert.
|
||||
func buildRuleInfo(labels, annotations template.KV) alertmanagertypes.RuleInfo {
|
||||
return alertmanagertypes.RuleInfo{
|
||||
Name: labels[ruletypes.LabelAlertName],
|
||||
ID: labels[ruletypes.LabelRuleID],
|
||||
URL: labels[ruletypes.LabelRuleSource],
|
||||
Severity: labels[ruletypes.LabelSeverityName],
|
||||
MatchType: annotations[ruletypes.AnnotationMatchType],
|
||||
Threshold: alertmanagertypes.Threshold{
|
||||
Value: annotations[ruletypes.AnnotationThresholdValue],
|
||||
Op: annotations[ruletypes.AnnotationCompareOp],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// maxAggregatedValues caps the number of distinct label/annotation values
|
||||
// joined together when summarising across alerts. Beyond this, extras are
|
||||
// dropped rather than concatenated.
|
||||
const maxAggregatedValues = 5
|
||||
|
||||
// aggregateKV merges label or annotation sets from a group of alerts into a
|
||||
// single KV. Per key, up to maxAggregatedValues distinct values are kept (in
|
||||
// order of first appearance) and joined with ", ". A lossy summary used for
|
||||
// grouped-notification display, not a true union.
|
||||
func aggregateKV(alerts []*types.Alert, extractFn func(*types.Alert) model.LabelSet) template.KV {
|
||||
valuesPerKey := make(map[string][]string)
|
||||
seenValues := make(map[string]map[string]bool)
|
||||
|
||||
for _, alert := range alerts {
|
||||
for k, v := range extractFn(alert) {
|
||||
key := string(k)
|
||||
value := string(v)
|
||||
|
||||
if seenValues[key] == nil {
|
||||
seenValues[key] = make(map[string]bool)
|
||||
}
|
||||
if !seenValues[key][value] && len(valuesPerKey[key]) < maxAggregatedValues {
|
||||
seenValues[key][value] = true
|
||||
valuesPerKey[key] = append(valuesPerKey[key], value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make(template.KV, len(valuesPerKey))
|
||||
for key, values := range valuesPerKey {
|
||||
result[key] = strings.Join(values, ", ")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// extractCommonKV returns the intersection of label or annotation pairs
|
||||
// across all alerts. A pair is included only if every alert carries the same
|
||||
// key with the same value.
|
||||
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
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
package alertmanagertemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
test "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/alertmanagernotifytest"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"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) (Templater, 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 alertmanagertypes.ExpandRequest
|
||||
wantTitle string
|
||||
wantBody []string
|
||||
wantMissingVars []string
|
||||
errorContains string
|
||||
wantIsDefaultBody bool
|
||||
}{
|
||||
{
|
||||
// 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.name": "payment-service",
|
||||
},
|
||||
map[string]string{"description": "Request rate exceeded 10k/s"},
|
||||
true,
|
||||
),
|
||||
},
|
||||
input: alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: "High request throughput for $service.name",
|
||||
BodyTemplate: `The service $service.name is getting high request. Please investigate.
|
||||
Severity: $rule.severity
|
||||
Status: $alert.status
|
||||
Service: $service.name
|
||||
Description: $description`,
|
||||
},
|
||||
wantTitle: "High request throughput for payment-service",
|
||||
wantBody: []string{`The service payment-service is getting high request. Please investigate.
|
||||
Severity: warning
|
||||
Status: firing
|
||||
Service: payment-service
|
||||
Description: Request rate exceeded 10k/s`},
|
||||
wantIsDefaultBody: false,
|
||||
},
|
||||
{
|
||||
// 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: alertmanagertypes.ExpandRequest{
|
||||
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\")",
|
||||
// Written with explicit \n so trailing whitespace inside the body
|
||||
// (emitted by the un-trimmed "{{ end }}" in the default template)
|
||||
// survives format-on-save.
|
||||
wantBody: []string{"*Alert:* DiskUsageHigh - critical\n" +
|
||||
"\n" +
|
||||
" *Summary:* Disk usage high on database host\n" +
|
||||
" *Description:* Disk usage is high on the database host\n" +
|
||||
" *RelatedLogs:* View in <https://logs.example.com/search?q=DiskUsageHigh|logs explorer>\n" +
|
||||
" *RelatedTraces:* View in <https://traces.example.com/search?q=DiskUsageHigh|traces explorer>\n" +
|
||||
"\n" +
|
||||
" *Details:*\n" +
|
||||
" • *alertname:* DiskUsageHigh\n" +
|
||||
" • *instance:* db-primary-01\n" +
|
||||
" • *severity:* critical\n" +
|
||||
" \n" +
|
||||
" "},
|
||||
wantIsDefaultBody: true,
|
||||
},
|
||||
{
|
||||
// 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: alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: "$rule.name: $alert.total_firing pods affected",
|
||||
BodyTemplate: "$labels.pod is crash looping",
|
||||
},
|
||||
wantTitle: "PodCrashLoop: 3 pods affected",
|
||||
wantBody: []string{"api-worker-1 is crash looping", "api-worker-2 is crash looping", "api-worker-3 is crash looping"},
|
||||
wantIsDefaultBody: false,
|
||||
},
|
||||
{
|
||||
// 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: alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: "$alert.total_firing firing, $alert.total_resolved resolved",
|
||||
BodyTemplate: "$labels.service ($alert.status)",
|
||||
},
|
||||
wantTitle: "1 firing, 1 resolved",
|
||||
wantBody: []string{"auth-service (firing)", "payment-service (resolved)"},
|
||||
wantIsDefaultBody: false,
|
||||
},
|
||||
{
|
||||
// $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: alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: "[$environment] $rule.name",
|
||||
},
|
||||
wantTitle: "[<no value>] HighCPU",
|
||||
wantMissingVars: []string{"environment"},
|
||||
wantIsDefaultBody: true,
|
||||
},
|
||||
{
|
||||
// $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: alertmanagertypes.ExpandRequest{
|
||||
BodyTemplate: "$rule.name: see runbook at $runbook_url",
|
||||
},
|
||||
wantBody: []string{"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: alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: "[$environment] $rule.name and [{{ $service }}]",
|
||||
BodyTemplate: "$rule.name: see runbook at $runbook_url",
|
||||
},
|
||||
wantTitle: "[<no value>] HighMemory and [<no value>]",
|
||||
wantBody: []string{"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: alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: " ",
|
||||
DefaultTitleTemplate: "{{ .CommonLabels.alertname }} ({{ .Status | toUpper }})",
|
||||
DefaultBodyTemplate: "Runbook: https://runbook.example.com",
|
||||
},
|
||||
wantTitle: "HighCPU (RESOLVED)",
|
||||
wantBody: []string{"Runbook: https://runbook.example.com"},
|
||||
wantIsDefaultBody: true,
|
||||
},
|
||||
{
|
||||
name: "using non-existing function in template",
|
||||
alerts: []*types.Alert{
|
||||
createAlert(map[string]string{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical"}, nil, true),
|
||||
},
|
||||
input: alertmanagertypes.ExpandRequest{
|
||||
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.Expand(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 != nil {
|
||||
require.Equal(t, tc.wantBody, got.Body)
|
||||
}
|
||||
require.Equal(t, tc.wantIsDefaultBody, got.IsDefaultBody)
|
||||
|
||||
if len(tc.wantMissingVars) == 0 {
|
||||
require.Empty(t, got.MissingVars)
|
||||
} else {
|
||||
sort.Strings(tc.wantMissingVars)
|
||||
require.Equal(t, tc.wantMissingVars, got.MissingVars)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package alertmanagertemplate
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
// ExtractTemplatesFromAnnotations pulls the user-authored title and body
|
||||
// templates off the well-known annotation keys attached by the rule manager.
|
||||
// A template is returned only if every alert in the group carries the same
|
||||
// value under that key; otherwise the empty string is returned for that slot
|
||||
// (which causes Expand to fall back to the channel default).
|
||||
func ExtractTemplatesFromAnnotations(alerts []*types.Alert) (titleTemplate, bodyTemplate string) {
|
||||
if len(alerts) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
title := string(alerts[0].Annotations[ruletypes.AnnotationTitleTemplate])
|
||||
body := string(alerts[0].Annotations[ruletypes.AnnotationBodyTemplate])
|
||||
|
||||
for _, a := range alerts[1:] {
|
||||
if title != "" && string(a.Annotations[ruletypes.AnnotationTitleTemplate]) != title {
|
||||
title = ""
|
||||
}
|
||||
if body != "" && string(a.Annotations[ruletypes.AnnotationBodyTemplate]) != body {
|
||||
body = ""
|
||||
}
|
||||
if title == "" && body == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return title, body
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
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 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
package alertmanagertemplate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
)
|
||||
|
||||
// fieldPath is a dotted mapstructure path into the templating data map,
|
||||
// e.g. "alert.is_firing" or "rule.threshold.value".
|
||||
type fieldPath string
|
||||
|
||||
// extractFieldMappings flattens the struct hierarchy into a list of dotted
|
||||
// mapstructure paths that user templates can reference. It emits:
|
||||
// - a leaf for every scalar field
|
||||
// - a leaf for every map field (labels, annotations)
|
||||
// - a mapping for each intermediate sub-struct itself, so {{ $alert := .alert }}
|
||||
// bindings let action blocks write {{ if $alert.is_firing }}
|
||||
//
|
||||
// Slices and interfaces are not surfaced. Pointer fields are dereferenced.
|
||||
func extractFieldMappings(data any) []fieldPath {
|
||||
val := reflect.ValueOf(data)
|
||||
if val.Kind() == reflect.Ptr {
|
||||
if val.IsNil() {
|
||||
return nil
|
||||
}
|
||||
val = val.Elem()
|
||||
}
|
||||
if val.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
return collectFieldMappings(val, "")
|
||||
}
|
||||
|
||||
func collectFieldMappings(val reflect.Value, prefix string) []fieldPath {
|
||||
typ := val.Type()
|
||||
var paths []fieldPath
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
tag := field.Tag.Get("mapstructure")
|
||||
if tag == "" || tag == "-" {
|
||||
continue
|
||||
}
|
||||
name := strings.Split(tag, ",")[0]
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
key := name
|
||||
if prefix != "" {
|
||||
key = prefix + "." + name
|
||||
}
|
||||
|
||||
ft := field.Type
|
||||
if ft.Kind() == reflect.Ptr {
|
||||
ft = ft.Elem()
|
||||
}
|
||||
|
||||
switch ft.Kind() {
|
||||
case reflect.Slice, reflect.Interface:
|
||||
continue
|
||||
}
|
||||
|
||||
// Recurse into sub-structs (time.Time treated as a leaf).
|
||||
if ft.Kind() == reflect.Struct && ft.String() != "time.Time" {
|
||||
paths = append(paths, fieldPath(key))
|
||||
fv := val.Field(i)
|
||||
if fv.Kind() == reflect.Ptr {
|
||||
if fv.IsNil() {
|
||||
continue
|
||||
}
|
||||
fv = fv.Elem()
|
||||
}
|
||||
paths = append(paths, collectFieldMappings(fv, key)...)
|
||||
continue
|
||||
}
|
||||
|
||||
paths = append(paths, fieldPath(key))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// structRootSet returns the top-level mapstructure tag names whose field
|
||||
// type is a nested struct (excluding time.Time and map/slice/interface
|
||||
// fields). These are the paths the rewriter walks segment-by-segment; any
|
||||
// other dotted $-reference is treated as a flat key on the root map so that
|
||||
// flattened OTel-style label keys like "service.name" resolve naturally.
|
||||
func structRootSet(data any) map[string]bool {
|
||||
val := reflect.ValueOf(data)
|
||||
if val.Kind() == reflect.Ptr {
|
||||
if val.IsNil() {
|
||||
return nil
|
||||
}
|
||||
val = val.Elem()
|
||||
}
|
||||
if val.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
|
||||
roots := make(map[string]bool)
|
||||
typ := val.Type()
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
tag := field.Tag.Get("mapstructure")
|
||||
if tag == "" || tag == "-" {
|
||||
continue
|
||||
}
|
||||
name := strings.Split(tag, ",")[0]
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
ft := field.Type
|
||||
if ft.Kind() == reflect.Ptr {
|
||||
ft = ft.Elem()
|
||||
}
|
||||
if ft.Kind() == reflect.Struct && ft.String() != "time.Time" {
|
||||
roots[name] = true
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
// buildDataMap converts the typed data struct to the map[string]any that the
|
||||
// template engine indexes into. Each label and annotation is additionally
|
||||
// exposed at the root under its raw key, so $service.name resolves a flat
|
||||
// OTel-style label as a single-key index on the root. Struct-path keys
|
||||
// already present at the root take precedence on collisions.
|
||||
func buildDataMap(data any) (map[string]any, error) {
|
||||
var result map[string]any
|
||||
if err := mapstructure.Decode(data, &result); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to build template data map")
|
||||
}
|
||||
|
||||
flatten := 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 *alertmanagertypes.NotificationTemplateData:
|
||||
flatten(data.Labels, data.Annotations)
|
||||
case *alertmanagertypes.AlertData:
|
||||
flatten(data.Labels, data.Annotations)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// renderPreamble serialises a map of binding name → RHS expression into
|
||||
// `{{ $name := expr }}` declarations. Dotted names are skipped: Go's
|
||||
// text/template parser rejects `{{ $a.b := ... }}`; dotted paths are resolved
|
||||
// at expansion time by the rewriter.
|
||||
func renderPreamble(bindings map[string]string) string {
|
||||
if len(bindings) == 0 {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
for name, expr := range bindings {
|
||||
if strings.Contains(name, ".") {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&sb, `{{ $%s := %s }}`, name, expr)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// buildPreamble constructs the variable-definition preamble prepended to the
|
||||
// user template, covering:
|
||||
// - known root-level struct paths ({{ $alert := .alert }})
|
||||
// - "<no value>" stubs for $-refs whose first segment matches nothing, so
|
||||
// action blocks like {{ if $custom_note }} don't error at parse time
|
||||
//
|
||||
// The set of unmatched names is returned separately so callers (preview API)
|
||||
// can surface warnings.
|
||||
func buildPreamble(tmpl string, data any) (string, map[string]bool, error) {
|
||||
bindings := make(map[string]string)
|
||||
// knownFirstSegments tracks every valid first segment of a $-ref, since
|
||||
// extractUsedVariables only gives us first segments. A label key like
|
||||
// "service.name" contributes "service" here, so $service.name isn't
|
||||
// flagged as unknown even though "service" has no direct binding.
|
||||
knownFirstSegments := make(map[string]bool)
|
||||
|
||||
for _, p := range extractFieldMappings(data) {
|
||||
bindings[string(p)] = fmt.Sprintf(".%s", p)
|
||||
knownFirstSegments[firstSegment(string(p))] = true
|
||||
}
|
||||
// Labels/annotations are flattened into the root map by buildDataMap, so
|
||||
// a bare-accessible key (no dots) can be bound in the preamble — this is
|
||||
// what makes {{ if $severity }} or {{ $severity | toUpper }} work in
|
||||
// action blocks. Dotted label keys only contribute to knownFirstSegments:
|
||||
// their action-block use would be a syntax error anyway ($a.b is not a
|
||||
// valid Go template identifier).
|
||||
for k := range dataLabelsAndAnnotations(data) {
|
||||
knownFirstSegments[firstSegment(k)] = true
|
||||
if !strings.Contains(k, ".") {
|
||||
if _, ok := bindings[k]; !ok {
|
||||
bindings[k] = fmt.Sprintf(".%s", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
used, err := extractUsedVariables(tmpl)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
unknown := make(map[string]bool)
|
||||
for name := range used {
|
||||
if !knownFirstSegments[name] {
|
||||
unknown[name] = true
|
||||
bindings[name] = `"<no value>"`
|
||||
}
|
||||
}
|
||||
|
||||
return renderPreamble(bindings), unknown, nil
|
||||
}
|
||||
|
||||
// firstSegment returns the portion of a dotted path before the first dot,
|
||||
// or the whole string if there is no dot.
|
||||
func firstSegment(path string) string {
|
||||
if i := strings.IndexByte(path, '.'); i >= 0 {
|
||||
return path[:i]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// dataLabelsAndAnnotations returns the union of label and annotation keys on
|
||||
// the given data struct (if it carries them). Used for first-segment
|
||||
// recognition of $-refs that point into flat OTel-style label keys.
|
||||
func dataLabelsAndAnnotations(data any) map[string]struct{} {
|
||||
keys := make(map[string]struct{})
|
||||
switch d := data.(type) {
|
||||
case *alertmanagertypes.NotificationTemplateData:
|
||||
for k := range d.Labels {
|
||||
keys[k] = struct{}{}
|
||||
}
|
||||
for k := range d.Annotations {
|
||||
keys[k] = struct{}{}
|
||||
}
|
||||
case *alertmanagertypes.AlertData:
|
||||
for k := range d.Labels {
|
||||
keys[k] = struct{}{}
|
||||
}
|
||||
for k := range d.Annotations {
|
||||
keys[k] = struct{}{}
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// processingResult is the rewritten template and its backing data map,
|
||||
// ready to be passed to Go text/template's Execute.
|
||||
type processingResult struct {
|
||||
// Template is the input template with $-refs rewritten to Go template
|
||||
// syntax and an identifier preamble prepended.
|
||||
Template string
|
||||
// Data is the flattened map the template indexes into.
|
||||
Data map[string]any
|
||||
// UnknownVars are $-refs in the input that had no matching data path.
|
||||
// They render as "<no value>" at execution; useful for preview warnings.
|
||||
UnknownVars map[string]bool
|
||||
}
|
||||
|
||||
// preProcessTemplateAndData prepares a user-authored template and its backing
|
||||
// struct for Go text/template execution.
|
||||
//
|
||||
// Input (with data *AlertData):
|
||||
// "$rule.name fired with value $alert.value"
|
||||
// Output:
|
||||
// "{{ $alert := .alert }}{{ $rule := .rule }}..."
|
||||
// "{{ index . \"rule\" \"name\" }} fired with value {{ index . \"alert\" \"value\" }}"
|
||||
func preProcessTemplateAndData(tmpl string, data any) (*processingResult, error) {
|
||||
unknownVars := make(map[string]bool)
|
||||
if tmpl == "" {
|
||||
result, err := buildDataMap(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &processingResult{Data: result, UnknownVars: unknownVars}, nil
|
||||
}
|
||||
|
||||
preamble, unknownVars, err := buildPreamble(tmpl, data)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to build template preamble")
|
||||
}
|
||||
|
||||
// Prepend the preamble so wrapDollarVariables can parse the AST without
|
||||
// "undefined variable" errors for $-refs inside action blocks.
|
||||
wrapped, err := wrapDollarVariables(preamble+tmpl, structRootSet(data))
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to rewrite template")
|
||||
}
|
||||
|
||||
result, err := buildDataMap(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &processingResult{Template: wrapped, Data: result, UnknownVars: unknownVars}, nil
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
package alertmanagertemplate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExtractFieldMappings(t *testing.T) {
|
||||
// Flat struct: mapstructure-tagged leaves only. Slices and interfaces are
|
||||
// dropped; maps (labels/annotations analogues) are kept as top-level leaves.
|
||||
type Flat struct {
|
||||
Name string `mapstructure:"name"`
|
||||
Status string `mapstructure:"status"`
|
||||
UserCount int `mapstructure:"user_count"`
|
||||
IsActive bool `mapstructure:"is_active"`
|
||||
CreatedAt time.Time `mapstructure:"created_at"`
|
||||
Extra map[string]string `mapstructure:"extra"`
|
||||
Items []string `mapstructure:"items"` // slice skipped
|
||||
unexported string //nolint:unused // unexported skipped
|
||||
NoTag string // no mapstructure tag skipped
|
||||
SkippedTag string `mapstructure:"-"` // explicit skip
|
||||
}
|
||||
|
||||
// Nested struct: sub-struct paths are flattened into dotted mappings; the
|
||||
// parent path itself is also emitted so templates can bind `$alert := .alert`.
|
||||
type Inner struct {
|
||||
Value string `mapstructure:"value"`
|
||||
Op string `mapstructure:"op"`
|
||||
}
|
||||
type Outer struct {
|
||||
Alert struct {
|
||||
Status string `mapstructure:"status"`
|
||||
IsFiring bool `mapstructure:"is_firing"`
|
||||
} `mapstructure:"alert"`
|
||||
Rule struct {
|
||||
Name string `mapstructure:"name"`
|
||||
Threshold Inner `mapstructure:"threshold"`
|
||||
} `mapstructure:"rule"`
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
data any
|
||||
expected []fieldPath
|
||||
}{
|
||||
{
|
||||
name: "flat struct surfaces only mapstructure-tagged scalars",
|
||||
data: Flat{},
|
||||
expected: []fieldPath{
|
||||
"name", "status", "user_count", "is_active", "created_at", "extra",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested struct emits parent and dotted leaf paths",
|
||||
data: Outer{},
|
||||
expected: []fieldPath{
|
||||
"alert",
|
||||
"alert.status",
|
||||
"alert.is_firing",
|
||||
"rule",
|
||||
"rule.name",
|
||||
"rule.threshold",
|
||||
"rule.threshold.value",
|
||||
"rule.threshold.op",
|
||||
},
|
||||
},
|
||||
{
|
||||
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
|
||||
forbiddenVars []string // substrings that must NOT appear (dotted identifiers)
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "empty template still emits struct bindings for title data",
|
||||
tmpl: "",
|
||||
data: &alertmanagertypes.NotificationTemplateData{Alert: alertmanagertypes.NotificationAlert{Receiver: "slack"}},
|
||||
expectedVars: []string{
|
||||
"{{ $alert := .alert }}",
|
||||
"{{ $rule := .rule }}",
|
||||
},
|
||||
// Dotted leaves are NOT emitted as preamble bindings — they're
|
||||
// reached via {{ index . "alert" "status" }} at expansion time.
|
||||
forbiddenVars: []string{
|
||||
"$alert.status",
|
||||
"$rule.threshold.value",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mix of known and unknown vars in alert body",
|
||||
tmpl: "$rule.name fired: $custom_label",
|
||||
data: &alertmanagertypes.AlertData{Rule: alertmanagertypes.RuleInfo{Name: "test"}, Alert: alertmanagertypes.AlertInfo{Status: "firing"}},
|
||||
expectedVars: []string{
|
||||
"{{ $alert := .alert }}",
|
||||
"{{ $rule := .rule }}",
|
||||
`{{ $custom_label := "<no value>" }}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "known dotted variables do not get flagged as unknown",
|
||||
tmpl: "$alert.is_firing and $rule.threshold.value",
|
||||
data: &alertmanagertypes.AlertData{},
|
||||
// $alert and $rule (first segments) are in mappings, so no unknown
|
||||
// stubs; the dotted leaves are resolved by WrapDollarVariables.
|
||||
expectedVars: []string{
|
||||
"{{ $alert := .alert }}",
|
||||
"{{ $rule := .rule }}",
|
||||
},
|
||||
forbiddenVars: []string{
|
||||
`"<no value>"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Label-derived $-refs aren't stubbed as unknown; their first
|
||||
// segment is marked known so {{ $severity := ... }} stubs don't
|
||||
// appear in the preamble. Resolution happens at expansion via the
|
||||
// root-level flattening performed in buildDataMap.
|
||||
name: "label first-segments suppress unknown-var stubs",
|
||||
tmpl: "$severity for $service $cloud.region.instance",
|
||||
data: &alertmanagertypes.NotificationTemplateData{Labels: template.KV{
|
||||
"severity": "critical",
|
||||
"service": "test",
|
||||
"cloud.region.instance": "ap-south-1",
|
||||
}},
|
||||
forbiddenVars: []string{
|
||||
`{{ $severity := "<no value>" }}`,
|
||||
`{{ $service := "<no value>" }}`,
|
||||
`{{ $cloud := "<no value>" }}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "same rule holds for AlertData labels",
|
||||
tmpl: "$severity $service",
|
||||
data: &alertmanagertypes.AlertData{Labels: template.KV{
|
||||
"severity": "critical",
|
||||
"service": "test",
|
||||
}},
|
||||
forbiddenVars: []string{
|
||||
`{{ $severity := "<no value>" }}`,
|
||||
`{{ $service := "<no value>" }}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid template syntax returns error",
|
||||
tmpl: "{{invalid",
|
||||
data: &alertmanagertypes.NotificationTemplateData{},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, _, err := buildPreamble(tc.tmpl, tc.data)
|
||||
if tc.expectError {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
for _, expected := range tc.expectedVars {
|
||||
require.Contains(t, result, expected, "expected preamble substring missing")
|
||||
}
|
||||
for _, forbidden := range tc.forbiddenVars {
|
||||
require.NotContains(t, result, forbidden, "unexpected preamble substring present")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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: "title template: struct-root walks and flat dotted label keys",
|
||||
tmpl: "[$alert.status] $rule.name (ID: $rule.id) firing=$alert.total_firing severity=$severity method=$http.request.method",
|
||||
data: &alertmanagertypes.NotificationTemplateData{
|
||||
Alert: alertmanagertypes.NotificationAlert{
|
||||
Receiver: "pagerduty",
|
||||
Status: "firing",
|
||||
TotalFiring: 3,
|
||||
},
|
||||
Rule: alertmanagertypes.RuleInfo{
|
||||
Name: "HighMemory",
|
||||
ID: "rule-123",
|
||||
},
|
||||
Labels: template.KV{
|
||||
"severity": "critical",
|
||||
"http.request.method": "GET",
|
||||
},
|
||||
},
|
||||
expectedTemplateContains: []string{
|
||||
"{{$alert := .alert}}",
|
||||
"{{$rule := .rule}}",
|
||||
`[{{ index . "alert" "status" }}] {{ index . "rule" "name" }} (ID: {{ index . "rule" "id" }})`,
|
||||
`firing={{ index . "alert" "total_firing" }} severity={{ .severity }}`,
|
||||
`method={{ index . "http.request.method" }}`,
|
||||
},
|
||||
expectedData: map[string]any{
|
||||
"alert": map[string]any{
|
||||
"receiver": "pagerduty",
|
||||
"status": "firing",
|
||||
"total_firing": 3,
|
||||
"total_resolved": 0,
|
||||
},
|
||||
"severity": "critical",
|
||||
"http.request.method": "GET",
|
||||
},
|
||||
expectedUnknownVars: map[string]bool{},
|
||||
},
|
||||
{
|
||||
name: "body template with nested threshold access and per-alert annotation",
|
||||
tmpl: "$rule.name: value $alert.value $rule.threshold.op $rule.threshold.value ($alert.status) desc=$description",
|
||||
data: &alertmanagertypes.AlertData{
|
||||
Alert: alertmanagertypes.AlertInfo{
|
||||
Status: "firing",
|
||||
Value: "85%",
|
||||
IsFiring: true,
|
||||
},
|
||||
Rule: alertmanagertypes.RuleInfo{
|
||||
Name: "DiskFull",
|
||||
ID: "disk-001",
|
||||
Severity: "warning",
|
||||
Threshold: alertmanagertypes.Threshold{Value: "80%", Op: ">"},
|
||||
},
|
||||
Annotations: template.KV{
|
||||
"description": "Disk full and cannot be written to",
|
||||
},
|
||||
},
|
||||
expectedTemplateContains: []string{
|
||||
"{{$alert := .alert}}",
|
||||
"{{$rule := .rule}}",
|
||||
// "description" is an annotation flattened to root; the preamble
|
||||
// now binds it off the root rather than via .annotations lookup.
|
||||
"{{$description := .description}}",
|
||||
`{{ index . "rule" "name" }}: value {{ index . "alert" "value" }} {{ index . "rule" "threshold" "op" }} {{ index . "rule" "threshold" "value" }}`,
|
||||
},
|
||||
expectedData: map[string]any{
|
||||
"description": "Disk full and cannot be written to",
|
||||
},
|
||||
expectedUnknownVars: map[string]bool{},
|
||||
},
|
||||
{
|
||||
// Struct roots reserve their first-segment namespace: a label
|
||||
// whose key starts with "alert." is shadowed by the Alert sub-map,
|
||||
// and must be accessed via the explicit $labels.* prefix.
|
||||
name: "label colliding with struct root is accessed via $labels.*",
|
||||
tmpl: "$alert.status via $labels.alert.custom",
|
||||
data: &alertmanagertypes.NotificationTemplateData{
|
||||
Alert: alertmanagertypes.NotificationAlert{Status: "firing"},
|
||||
Labels: template.KV{"alert.custom": "x"},
|
||||
},
|
||||
expectedTemplateContains: []string{
|
||||
`{{ index . "alert" "status" }}`,
|
||||
`{{ index .labels "alert.custom" }}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Same shadowing rule applies symmetrically to annotations.
|
||||
name: "annotation colliding with struct root is accessed via $annotations.*",
|
||||
tmpl: "$alert.status via $annotations.alert.meta",
|
||||
data: &alertmanagertypes.NotificationTemplateData{
|
||||
Alert: alertmanagertypes.NotificationAlert{Status: "firing"},
|
||||
Annotations: template.KV{"alert.meta": "x"},
|
||||
},
|
||||
expectedTemplateContains: []string{
|
||||
`{{ index . "alert" "status" }}`,
|
||||
`{{ index .annotations "alert.meta" }}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
// When a label and an annotation share a key, the label wins at the
|
||||
// root flattening layer. Users who want the annotation must address
|
||||
// it explicitly via $annotations.<key>.
|
||||
name: "label takes precedence over same-named annotation at root",
|
||||
tmpl: "flat=$env labels_only=$labels.env annotations_only=$annotations.env",
|
||||
data: &alertmanagertypes.NotificationTemplateData{
|
||||
Labels: template.KV{"env": "prod"},
|
||||
Annotations: template.KV{"env": "staging"},
|
||||
},
|
||||
expectedTemplateContains: []string{
|
||||
`flat={{ .env }}`,
|
||||
`labels_only={{ index .labels "env" }}`,
|
||||
`annotations_only={{ index .annotations "env" }}`,
|
||||
},
|
||||
expectedData: map[string]any{
|
||||
"env": "prod",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty template returns flattened data",
|
||||
tmpl: "",
|
||||
data: &alertmanagertypes.NotificationTemplateData{Alert: alertmanagertypes.NotificationAlert{Receiver: "slack"}},
|
||||
},
|
||||
{
|
||||
name: "invalid template syntax",
|
||||
tmpl: "{{invalid",
|
||||
data: &alertmanagertypes.NotificationTemplateData{},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "unknown dollar var in text renders empty",
|
||||
tmpl: "alert $custom_note fired",
|
||||
data: &alertmanagertypes.NotificationTemplateData{Rule: alertmanagertypes.RuleInfo{Name: "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: &alertmanagertypes.NotificationTemplateData{Rule: alertmanagertypes.RuleInfo{Name: "HighCPU"}},
|
||||
expectedTemplateContains: []string{
|
||||
`{{$custom_note := "<no value>"}}`,
|
||||
`alert {{$custom_note}} fired`,
|
||||
},
|
||||
expectedUnknownVars: map[string]bool{"custom_note": 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], "data[%q] mismatch", k)
|
||||
}
|
||||
if tc.expectedUnknownVars != nil {
|
||||
require.Equal(t, tc.expectedUnknownVars, result.UnknownVars)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
package alertmanagertemplate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template/parse"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
// bareVariableRegex matches $-references including dotted paths (e.g. $alert.is_firing).
|
||||
var bareVariableRegex = regexp.MustCompile(`\$(\w+(?:\.\w+)*)`)
|
||||
|
||||
// bareVariableFirstSegRegex captures only the first segment of a $-reference.
|
||||
// $labels.severity yields "$labels"; $alert.is_firing yields "$alert".
|
||||
var bareVariableFirstSegRegex = regexp.MustCompile(`\$\w+`)
|
||||
|
||||
// wrapDollarVariables rewrites bare $-references in a template's plain-text
|
||||
// regions into Go text/template syntax. References inside `{{ ... }}` action
|
||||
// blocks are left untouched — they're already template syntax. structRoots
|
||||
// is the set of top-level mapstructure names whose values are nested structs
|
||||
// (e.g. "alert", "rule"): $-refs whose first segment is in this set are
|
||||
// walked segment-by-segment; everything else dotted is treated as a flat key
|
||||
// on the root map so flattened OTel-style label keys resolve naturally.
|
||||
//
|
||||
// Examples (structRoots = {alert, rule}):
|
||||
//
|
||||
// "$name is $status" -> "{{ .name }} is {{ .status }}"
|
||||
// "$labels.severity" -> `{{ index .labels "severity" }}`
|
||||
// "$labels.http.method" -> `{{ index .labels "http.method" }}`
|
||||
// "$annotations.summary" -> `{{ index .annotations "summary" }}`
|
||||
// "$alert.is_firing" -> `{{ index . "alert" "is_firing" }}`
|
||||
// "$rule.threshold.value" -> `{{ index . "rule" "threshold" "value" }}`
|
||||
// "$service.name" -> `{{ index . "service.name" }}`
|
||||
// "$name is {{ .Status }}" -> "{{ .name }} is {{ .Status }}"
|
||||
func wrapDollarVariables(src string, structRoots map[string]bool) (string, error) {
|
||||
if src == "" {
|
||||
return src, nil
|
||||
}
|
||||
|
||||
tree := parse.New("template")
|
||||
tree.Mode = parse.SkipFuncCheck
|
||||
|
||||
if _, err := tree.Parse(src, "{{", "}}", make(map[string]*parse.Tree), nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
walkAndWrapTextNodes(tree.Root, structRoots)
|
||||
return tree.Root.String(), nil
|
||||
}
|
||||
|
||||
// walkAndWrapTextNodes descends the parse tree and rewrites $-references
|
||||
// found in TextNodes. If/Range bodies are recursed into. ActionNodes and
|
||||
// other `{{...}}` constructs are left alone because they're already template
|
||||
// syntax. WithNode is not yet supported — add when the editor grows a "with"
|
||||
// block.
|
||||
func walkAndWrapTextNodes(node parse.Node, structRoots map[string]bool) {
|
||||
if reflect.ValueOf(node).IsNil() {
|
||||
return
|
||||
}
|
||||
|
||||
switch n := node.(type) {
|
||||
case *parse.ListNode:
|
||||
if n.Nodes != nil {
|
||||
for _, child := range n.Nodes {
|
||||
walkAndWrapTextNodes(child, structRoots)
|
||||
}
|
||||
}
|
||||
|
||||
case *parse.TextNode:
|
||||
n.Text = bareVariableRegex.ReplaceAllFunc(n.Text, func(match []byte) []byte {
|
||||
return rewriteDollarRef(match, structRoots)
|
||||
})
|
||||
|
||||
case *parse.IfNode:
|
||||
walkAndWrapTextNodes(n.List, structRoots)
|
||||
walkAndWrapTextNodes(n.ElseList, structRoots)
|
||||
|
||||
case *parse.RangeNode:
|
||||
walkAndWrapTextNodes(n.List, structRoots)
|
||||
walkAndWrapTextNodes(n.ElseList, structRoots)
|
||||
}
|
||||
}
|
||||
|
||||
// rewriteDollarRef converts one $-reference (with the leading $) into the
|
||||
// corresponding Go template expression.
|
||||
//
|
||||
// - labels./annotations. prefixes: treat the remainder as a single map key
|
||||
// (OTel-style keys like "http.request.method" are flat keys, not paths).
|
||||
// - Dotted path with a struct-root first segment: walk via chained index
|
||||
// arguments. `index x a b c` is equivalent to x[a][b][c].
|
||||
// - Other dotted path: treat as a single flat key on the root map, so a
|
||||
// flattened OTel-style label key like "service.name" resolves.
|
||||
// - Simple names: plain dot access on the root map.
|
||||
func rewriteDollarRef(match []byte, structRoots map[string]bool) []byte {
|
||||
name := string(match[1:])
|
||||
|
||||
if !strings.Contains(name, ".") {
|
||||
return fmt.Appendf(nil, `{{ .%s }}`, name)
|
||||
}
|
||||
|
||||
if key, ok := strings.CutPrefix(name, "labels."); ok {
|
||||
return fmt.Appendf(nil, `{{ index .labels %q }}`, key)
|
||||
}
|
||||
if key, ok := strings.CutPrefix(name, "annotations."); ok {
|
||||
return fmt.Appendf(nil, `{{ index .annotations %q }}`, key)
|
||||
}
|
||||
|
||||
// If the first segment is a known struct root, walk segments.
|
||||
if dot := strings.IndexByte(name, '.'); dot >= 0 && structRoots[name[:dot]] {
|
||||
parts := strings.Split(name, ".")
|
||||
args := make([]string, len(parts))
|
||||
for i, p := range parts {
|
||||
args[i] = fmt.Sprintf("%q", p)
|
||||
}
|
||||
return fmt.Appendf(nil, `{{ index . %s }}`, strings.Join(args, " "))
|
||||
}
|
||||
|
||||
// Otherwise treat the full dotted path as a single flat root key.
|
||||
return fmt.Appendf(nil, `{{ index . %q }}`, name)
|
||||
}
|
||||
|
||||
// extractUsedVariables returns the set of every base $-name referenced in the
|
||||
// template — text nodes, action blocks, branch conditions, loop declarations.
|
||||
// Names are first-segment only: $labels.severity contributes "labels".
|
||||
//
|
||||
// The template is validated during extraction (a synthesised preamble
|
||||
// pre-declares each name so the parser doesn't trip on "undefined variable"
|
||||
// for names that genuinely exist in the data), so a parse error here
|
||||
// indicates a genuine syntax problem rather than a missing binding.
|
||||
func extractUsedVariables(src string) (map[string]bool, error) {
|
||||
if src == "" {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
|
||||
used := make(map[string]bool)
|
||||
for _, m := range bareVariableFirstSegRegex.FindAll([]byte(src), -1) {
|
||||
used[string(m[1:])] = true
|
||||
}
|
||||
|
||||
var preamble strings.Builder
|
||||
for name := range used {
|
||||
fmt.Fprintf(&preamble, `{{$%s := ""}}`, name)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package alertmanagertemplate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"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" }}`,
|
||||
},
|
||||
// Struct-root paths: walk segment-by-segment via chained index.
|
||||
{
|
||||
name: "struct-root dotted path walks via chained index",
|
||||
input: "$alert.is_firing",
|
||||
expected: `{{ index . "alert" "is_firing" }}`,
|
||||
},
|
||||
{
|
||||
name: "deeply nested struct-root path",
|
||||
input: "$rule.threshold.value",
|
||||
expected: `{{ index . "rule" "threshold" "value" }}`,
|
||||
},
|
||||
// Non-struct-root dotted paths: treated as a single flat key on the
|
||||
// root map, so flattened OTel-style label keys resolve naturally.
|
||||
{
|
||||
name: "non-struct-root dotted path hits flat root key",
|
||||
input: "$service.name",
|
||||
expected: `{{ index . "service.name" }}`,
|
||||
},
|
||||
// Hybrid: all types combined
|
||||
{
|
||||
name: "hybrid - all variables types",
|
||||
input: "Alert: $alert_name Labels: $labels.severity Annotations: $annotations.desc Value: $alert.value Count: $error_count",
|
||||
expected: `Alert: {{ .alert_name }} Labels: {{ index .labels "severity" }} Annotations: {{ index .annotations "desc" }} Value: {{ index . "alert" "value" }} 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,
|
||||
},
|
||||
}
|
||||
|
||||
// structRoots used across the test cases: "alert" and "rule" are walked,
|
||||
// anything else dotted is treated as a flat root-map key.
|
||||
structRoots := map[string]bool{"alert": true, "rule": true}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := wrapDollarVariables(tc.input, structRoots)
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -160,11 +160,9 @@ type ClickHouseReader struct {
|
||||
traceResourceTableV3 string
|
||||
traceSummaryTable string
|
||||
|
||||
fluxIntervalForTraceDetail time.Duration
|
||||
cache cache.Cache
|
||||
cacheForTraceDetail cache.Cache
|
||||
metadataDB string
|
||||
metadataTable string
|
||||
cache cache.Cache
|
||||
metadataDB string
|
||||
metadataTable string
|
||||
}
|
||||
|
||||
// NewTraceReader returns a TraceReader for the database
|
||||
@@ -174,8 +172,6 @@ func NewReader(
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
prometheus prometheus.Prometheus,
|
||||
cluster string,
|
||||
fluxIntervalForTraceDetail time.Duration,
|
||||
cacheForTraceDetail cache.Cache,
|
||||
cache cache.Cache,
|
||||
options *Options,
|
||||
) *ClickHouseReader {
|
||||
@@ -189,45 +185,43 @@ func NewReader(
|
||||
traceLocalTableName := options.primary.TraceLocalTableNameV3
|
||||
|
||||
return &ClickHouseReader{
|
||||
db: telemetryStore.ClickhouseDB(),
|
||||
logger: logger,
|
||||
prometheus: prometheus,
|
||||
sqlDB: sqlDB,
|
||||
TraceDB: options.primary.TraceDB,
|
||||
operationsTable: options.primary.OperationsTable,
|
||||
indexTable: options.primary.IndexTable,
|
||||
errorTable: options.primary.ErrorTable,
|
||||
usageExplorerTable: options.primary.UsageExplorerTable,
|
||||
durationTable: options.primary.DurationTable,
|
||||
SpansTable: options.primary.SpansTable,
|
||||
spanAttributeTableV2: options.primary.SpanAttributeTableV2,
|
||||
spanAttributesKeysTable: options.primary.SpanAttributeKeysTable,
|
||||
dependencyGraphTable: options.primary.DependencyGraphTable,
|
||||
topLevelOperationsTable: options.primary.TopLevelOperationsTable,
|
||||
logsDB: options.primary.LogsDB,
|
||||
logsTable: options.primary.LogsTable,
|
||||
logsLocalTable: options.primary.LogsLocalTable,
|
||||
logsAttributeKeys: options.primary.LogsAttributeKeysTable,
|
||||
logsResourceKeys: options.primary.LogsResourceKeysTable,
|
||||
logsTagAttributeTableV2: options.primary.LogsTagAttributeTableV2,
|
||||
liveTailRefreshSeconds: options.primary.LiveTailRefreshSeconds,
|
||||
cluster: cluster,
|
||||
queryProgressTracker: queryprogress.NewQueryProgressTracker(logger),
|
||||
logsTableV2: options.primary.LogsTableV2,
|
||||
logsLocalTableV2: options.primary.LogsLocalTableV2,
|
||||
logsResourceTableV2: options.primary.LogsResourceTableV2,
|
||||
logsResourceLocalTableV2: options.primary.LogsResourceLocalTableV2,
|
||||
logsTableName: logsTableName,
|
||||
logsLocalTableName: logsLocalTableName,
|
||||
traceLocalTableName: traceLocalTableName,
|
||||
traceTableName: traceTableName,
|
||||
traceResourceTableV3: options.primary.TraceResourceTableV3,
|
||||
traceSummaryTable: options.primary.TraceSummaryTable,
|
||||
fluxIntervalForTraceDetail: fluxIntervalForTraceDetail,
|
||||
cache: cache,
|
||||
cacheForTraceDetail: cacheForTraceDetail,
|
||||
metadataDB: options.primary.MetadataDB,
|
||||
metadataTable: options.primary.MetadataTable,
|
||||
db: telemetryStore.ClickhouseDB(),
|
||||
logger: logger,
|
||||
prometheus: prometheus,
|
||||
sqlDB: sqlDB,
|
||||
TraceDB: options.primary.TraceDB,
|
||||
operationsTable: options.primary.OperationsTable,
|
||||
indexTable: options.primary.IndexTable,
|
||||
errorTable: options.primary.ErrorTable,
|
||||
usageExplorerTable: options.primary.UsageExplorerTable,
|
||||
durationTable: options.primary.DurationTable,
|
||||
SpansTable: options.primary.SpansTable,
|
||||
spanAttributeTableV2: options.primary.SpanAttributeTableV2,
|
||||
spanAttributesKeysTable: options.primary.SpanAttributeKeysTable,
|
||||
dependencyGraphTable: options.primary.DependencyGraphTable,
|
||||
topLevelOperationsTable: options.primary.TopLevelOperationsTable,
|
||||
logsDB: options.primary.LogsDB,
|
||||
logsTable: options.primary.LogsTable,
|
||||
logsLocalTable: options.primary.LogsLocalTable,
|
||||
logsAttributeKeys: options.primary.LogsAttributeKeysTable,
|
||||
logsResourceKeys: options.primary.LogsResourceKeysTable,
|
||||
logsTagAttributeTableV2: options.primary.LogsTagAttributeTableV2,
|
||||
liveTailRefreshSeconds: options.primary.LiveTailRefreshSeconds,
|
||||
cluster: cluster,
|
||||
queryProgressTracker: queryprogress.NewQueryProgressTracker(logger),
|
||||
logsTableV2: options.primary.LogsTableV2,
|
||||
logsLocalTableV2: options.primary.LogsLocalTableV2,
|
||||
logsResourceTableV2: options.primary.LogsResourceTableV2,
|
||||
logsResourceLocalTableV2: options.primary.LogsResourceLocalTableV2,
|
||||
logsTableName: logsTableName,
|
||||
logsLocalTableName: logsLocalTableName,
|
||||
traceLocalTableName: traceLocalTableName,
|
||||
traceTableName: traceTableName,
|
||||
traceResourceTableV3: options.primary.TraceResourceTableV3,
|
||||
traceSummaryTable: options.primary.TraceSummaryTable,
|
||||
cache: cache,
|
||||
metadataDB: options.primary.MetadataDB,
|
||||
metadataTable: options.primary.MetadataTable,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -897,23 +891,6 @@ func (r *ClickHouseReader) GetSpansForTrace(ctx context.Context, traceID string,
|
||||
return searchScanResponses, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadataCache(ctx context.Context, orgID valuer.UUID, traceID string) (*model.GetWaterfallSpansForTraceWithMetadataCache, error) {
|
||||
cachedTraceData := new(model.GetWaterfallSpansForTraceWithMetadataCache)
|
||||
err := r.cacheForTraceDetail.Get(ctx, orgID, strings.Join([]string{"getWaterfallSpansForTraceWithMetadata", traceID}, "-"), cachedTraceData)
|
||||
if err != nil {
|
||||
r.logger.Debug("error in retrieving getWaterfallSpansForTraceWithMetadata cache", errorsV2.Attr(err), "traceID", traceID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if time.Since(time.UnixMilli(int64(cachedTraceData.EndTime))) < r.fluxIntervalForTraceDetail {
|
||||
r.logger.Info("the trace end time falls under the flux interval, skipping getWaterfallSpansForTraceWithMetadata cache", "traceID", traceID)
|
||||
return nil, errors.Errorf("the trace end time falls under the flux interval, skipping getWaterfallSpansForTraceWithMetadata cache, traceID: %s", traceID)
|
||||
}
|
||||
|
||||
r.logger.Info("cache is successfully hit, applying cache for getWaterfallSpansForTraceWithMetadata", "traceID", traceID)
|
||||
return cachedTraceData, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadata(ctx context.Context, orgID valuer.UUID, traceID string, req *model.GetWaterfallSpansForTraceWithMetadataParams) (*model.GetWaterfallSpansForTraceWithMetadataResponse, error) {
|
||||
response := new(model.GetWaterfallSpansForTraceWithMetadataResponse)
|
||||
var startTime, endTime, durationNano, totalErrorSpans, totalSpans uint64
|
||||
@@ -923,172 +900,136 @@ func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadata(ctx context.Con
|
||||
var serviceNameIntervalMap = map[string][]tracedetail.Interval{}
|
||||
var hasMissingSpans bool
|
||||
|
||||
cachedTraceData, err := r.GetWaterfallSpansForTraceWithMetadataCache(ctx, orgID, traceID)
|
||||
if err == nil {
|
||||
startTime = cachedTraceData.StartTime
|
||||
endTime = cachedTraceData.EndTime
|
||||
durationNano = cachedTraceData.DurationNano
|
||||
spanIdToSpanNodeMap = cachedTraceData.SpanIdToSpanNodeMap
|
||||
serviceNameToTotalDurationMap = cachedTraceData.ServiceNameToTotalDurationMap
|
||||
traceRoots = cachedTraceData.TraceRoots
|
||||
totalSpans = cachedTraceData.TotalSpans
|
||||
totalErrorSpans = cachedTraceData.TotalErrorSpans
|
||||
hasMissingSpans = cachedTraceData.HasMissingSpans
|
||||
}
|
||||
|
||||
searchScanResponses, err := r.GetSpansForTrace(ctx, traceID, fmt.Sprintf("SELECT DISTINCT ON (span_id) timestamp, duration_nano, span_id, trace_id, has_error, kind, resource_string_service$$name, name, links as references, attributes_string, attributes_number, attributes_bool, resources_string, events, status_message, status_code_string, kind_string FROM %s.%s WHERE trace_id=$1 and ts_bucket_start>=$2 and ts_bucket_start<=$3 ORDER BY timestamp ASC, name ASC", r.TraceDB, r.traceTableName))
|
||||
if err != nil {
|
||||
r.logger.Info("cache miss for getWaterfallSpansForTraceWithMetadata", "traceID", traceID)
|
||||
|
||||
searchScanResponses, err := r.GetSpansForTrace(ctx, traceID, fmt.Sprintf("SELECT DISTINCT ON (span_id) timestamp, duration_nano, span_id, trace_id, has_error, kind, resource_string_service$$name, name, links as references, attributes_string, attributes_number, attributes_bool, resources_string, events, status_message, status_code_string, kind_string FROM %s.%s WHERE trace_id=$1 and ts_bucket_start>=$2 and ts_bucket_start<=$3 ORDER BY timestamp ASC, name ASC", r.TraceDB, r.traceTableName))
|
||||
return nil, err
|
||||
}
|
||||
if len(searchScanResponses) == 0 {
|
||||
return response, nil
|
||||
}
|
||||
totalSpans = uint64(len(searchScanResponses))
|
||||
for _, item := range searchScanResponses {
|
||||
ref := []model.OtelSpanRef{}
|
||||
err := json.Unmarshal([]byte(item.References), &ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
r.logger.Error("getWaterfallSpansForTraceWithMetadata: error unmarshalling references", errorsV2.Attr(err), "traceID", traceID)
|
||||
return nil, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, "getWaterfallSpansForTraceWithMetadata: error unmarshalling references %s", err.Error())
|
||||
}
|
||||
if len(searchScanResponses) == 0 {
|
||||
return response, nil
|
||||
|
||||
// merge attributes_number and attributes_bool to attributes_string
|
||||
for k, v := range item.Attributes_bool {
|
||||
item.Attributes_string[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
totalSpans = uint64(len(searchScanResponses))
|
||||
processingBeforeCache := time.Now()
|
||||
for _, item := range searchScanResponses {
|
||||
ref := []model.OtelSpanRef{}
|
||||
err := json.Unmarshal([]byte(item.References), &ref)
|
||||
for k, v := range item.Attributes_number {
|
||||
item.Attributes_string[k] = strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
for k, v := range item.Resources_string {
|
||||
item.Attributes_string[k] = v
|
||||
}
|
||||
|
||||
events := make([]model.Event, 0)
|
||||
for _, event := range item.Events {
|
||||
var eventMap model.Event
|
||||
err = json.Unmarshal([]byte(event), &eventMap)
|
||||
if err != nil {
|
||||
r.logger.Error("getWaterfallSpansForTraceWithMetadata: error unmarshalling references", errorsV2.Attr(err), "traceID", traceID)
|
||||
return nil, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, "getWaterfallSpansForTraceWithMetadata: error unmarshalling references %s", err.Error())
|
||||
r.logger.Error("Error unmarshalling events", errorsV2.Attr(err))
|
||||
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "getWaterfallSpansForTraceWithMetadata: error in unmarshalling events %s", err.Error())
|
||||
}
|
||||
|
||||
// merge attributes_number and attributes_bool to attributes_string
|
||||
for k, v := range item.Attributes_bool {
|
||||
item.Attributes_string[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
for k, v := range item.Attributes_number {
|
||||
item.Attributes_string[k] = strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
for k, v := range item.Resources_string {
|
||||
item.Attributes_string[k] = v
|
||||
}
|
||||
|
||||
events := make([]model.Event, 0)
|
||||
for _, event := range item.Events {
|
||||
var eventMap model.Event
|
||||
err = json.Unmarshal([]byte(event), &eventMap)
|
||||
if err != nil {
|
||||
r.logger.Error("Error unmarshalling events", errorsV2.Attr(err))
|
||||
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "getWaterfallSpansForTraceWithMetadata: error in unmarshalling events %s", err.Error())
|
||||
}
|
||||
events = append(events, eventMap)
|
||||
}
|
||||
|
||||
startTimeUnixNano := uint64(item.TimeUnixNano.UnixNano())
|
||||
|
||||
jsonItem := model.Span{
|
||||
SpanID: item.SpanID,
|
||||
TraceID: item.TraceID,
|
||||
ServiceName: item.ServiceName,
|
||||
Name: item.Name,
|
||||
Kind: int32(item.Kind),
|
||||
DurationNano: item.DurationNano,
|
||||
HasError: item.HasError,
|
||||
StatusMessage: item.StatusMessage,
|
||||
StatusCodeString: item.StatusCodeString,
|
||||
SpanKind: item.SpanKind,
|
||||
References: ref,
|
||||
Events: events,
|
||||
TagMap: item.Attributes_string,
|
||||
Children: make([]*model.Span, 0),
|
||||
TimeUnixNano: startTimeUnixNano, // Store nanoseconds temporarily
|
||||
}
|
||||
|
||||
// metadata calculation
|
||||
if startTime == 0 || startTimeUnixNano < startTime {
|
||||
startTime = startTimeUnixNano
|
||||
}
|
||||
if endTime == 0 || (startTimeUnixNano+jsonItem.DurationNano) > endTime {
|
||||
endTime = (startTimeUnixNano + jsonItem.DurationNano)
|
||||
}
|
||||
if durationNano == 0 || jsonItem.DurationNano > durationNano {
|
||||
durationNano = jsonItem.DurationNano
|
||||
}
|
||||
|
||||
if jsonItem.HasError {
|
||||
totalErrorSpans = totalErrorSpans + 1
|
||||
}
|
||||
|
||||
// collect the intervals for service for execution time calculation
|
||||
serviceNameIntervalMap[jsonItem.ServiceName] =
|
||||
append(serviceNameIntervalMap[jsonItem.ServiceName], tracedetail.Interval{StartTime: jsonItem.TimeUnixNano, Duration: jsonItem.DurationNano, Service: jsonItem.ServiceName})
|
||||
|
||||
// append to the span node map
|
||||
spanIdToSpanNodeMap[jsonItem.SpanID] = &jsonItem
|
||||
events = append(events, eventMap)
|
||||
}
|
||||
|
||||
// traverse through the map and append each node to the children array of the parent node
|
||||
// and add the missing spans
|
||||
for _, spanNode := range spanIdToSpanNodeMap {
|
||||
hasParentSpanNode := false
|
||||
for _, reference := range spanNode.References {
|
||||
if reference.RefType == "CHILD_OF" && reference.SpanId != "" {
|
||||
hasParentSpanNode = true
|
||||
startTimeUnixNano := uint64(item.TimeUnixNano.UnixNano())
|
||||
|
||||
if parentNode, exists := spanIdToSpanNodeMap[reference.SpanId]; exists {
|
||||
parentNode.Children = append(parentNode.Children, spanNode)
|
||||
} else {
|
||||
// insert the missing span
|
||||
missingSpan := model.Span{
|
||||
SpanID: reference.SpanId,
|
||||
TraceID: spanNode.TraceID,
|
||||
ServiceName: "",
|
||||
Name: "Missing Span",
|
||||
TimeUnixNano: spanNode.TimeUnixNano,
|
||||
Kind: 0,
|
||||
DurationNano: spanNode.DurationNano,
|
||||
HasError: false,
|
||||
StatusMessage: "",
|
||||
StatusCodeString: "",
|
||||
SpanKind: "",
|
||||
Events: make([]model.Event, 0),
|
||||
Children: make([]*model.Span, 0),
|
||||
}
|
||||
missingSpan.Children = append(missingSpan.Children, spanNode)
|
||||
spanIdToSpanNodeMap[missingSpan.SpanID] = &missingSpan
|
||||
traceRoots = append(traceRoots, &missingSpan)
|
||||
hasMissingSpans = true
|
||||
jsonItem := model.Span{
|
||||
SpanID: item.SpanID,
|
||||
TraceID: item.TraceID,
|
||||
ServiceName: item.ServiceName,
|
||||
Name: item.Name,
|
||||
Kind: int32(item.Kind),
|
||||
DurationNano: item.DurationNano,
|
||||
HasError: item.HasError,
|
||||
StatusMessage: item.StatusMessage,
|
||||
StatusCodeString: item.StatusCodeString,
|
||||
SpanKind: item.SpanKind,
|
||||
References: ref,
|
||||
Events: events,
|
||||
TagMap: item.Attributes_string,
|
||||
Children: make([]*model.Span, 0),
|
||||
TimeUnixNano: startTimeUnixNano, // Store nanoseconds temporarily
|
||||
}
|
||||
|
||||
// metadata calculation
|
||||
if startTime == 0 || startTimeUnixNano < startTime {
|
||||
startTime = startTimeUnixNano
|
||||
}
|
||||
if endTime == 0 || (startTimeUnixNano+jsonItem.DurationNano) > endTime {
|
||||
endTime = (startTimeUnixNano + jsonItem.DurationNano)
|
||||
}
|
||||
if durationNano == 0 || jsonItem.DurationNano > durationNano {
|
||||
durationNano = jsonItem.DurationNano
|
||||
}
|
||||
|
||||
if jsonItem.HasError {
|
||||
totalErrorSpans = totalErrorSpans + 1
|
||||
}
|
||||
|
||||
// collect the intervals for service for execution time calculation
|
||||
serviceNameIntervalMap[jsonItem.ServiceName] =
|
||||
append(serviceNameIntervalMap[jsonItem.ServiceName], tracedetail.Interval{StartTime: jsonItem.TimeUnixNano, Duration: jsonItem.DurationNano, Service: jsonItem.ServiceName})
|
||||
|
||||
// append to the span node map
|
||||
spanIdToSpanNodeMap[jsonItem.SpanID] = &jsonItem
|
||||
}
|
||||
|
||||
// traverse through the map and append each node to the children array of the parent node
|
||||
// and add the missing spans
|
||||
for _, spanNode := range spanIdToSpanNodeMap {
|
||||
hasParentSpanNode := false
|
||||
for _, reference := range spanNode.References {
|
||||
if reference.RefType == "CHILD_OF" && reference.SpanId != "" {
|
||||
hasParentSpanNode = true
|
||||
|
||||
if parentNode, exists := spanIdToSpanNodeMap[reference.SpanId]; exists {
|
||||
parentNode.Children = append(parentNode.Children, spanNode)
|
||||
} else {
|
||||
// insert the missing span
|
||||
missingSpan := model.Span{
|
||||
SpanID: reference.SpanId,
|
||||
TraceID: spanNode.TraceID,
|
||||
ServiceName: "",
|
||||
Name: "Missing Span",
|
||||
TimeUnixNano: spanNode.TimeUnixNano,
|
||||
Kind: 0,
|
||||
DurationNano: spanNode.DurationNano,
|
||||
HasError: false,
|
||||
StatusMessage: "",
|
||||
StatusCodeString: "",
|
||||
SpanKind: "",
|
||||
Events: make([]model.Event, 0),
|
||||
Children: make([]*model.Span, 0),
|
||||
}
|
||||
missingSpan.Children = append(missingSpan.Children, spanNode)
|
||||
spanIdToSpanNodeMap[missingSpan.SpanID] = &missingSpan
|
||||
traceRoots = append(traceRoots, &missingSpan)
|
||||
hasMissingSpans = true
|
||||
}
|
||||
}
|
||||
if !hasParentSpanNode && !tracedetail.ContainsWaterfallSpan(traceRoots, spanNode) {
|
||||
traceRoots = append(traceRoots, spanNode)
|
||||
}
|
||||
}
|
||||
|
||||
// sort the trace roots to add missing spans at the right order
|
||||
sort.Slice(traceRoots, func(i, j int) bool {
|
||||
if traceRoots[i].TimeUnixNano == traceRoots[j].TimeUnixNano {
|
||||
return traceRoots[i].Name < traceRoots[j].Name
|
||||
}
|
||||
return traceRoots[i].TimeUnixNano < traceRoots[j].TimeUnixNano
|
||||
})
|
||||
|
||||
serviceNameToTotalDurationMap = tracedetail.CalculateServiceTime(serviceNameIntervalMap)
|
||||
|
||||
traceCache := model.GetWaterfallSpansForTraceWithMetadataCache{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
DurationNano: durationNano,
|
||||
TotalSpans: totalSpans,
|
||||
TotalErrorSpans: totalErrorSpans,
|
||||
SpanIdToSpanNodeMap: spanIdToSpanNodeMap,
|
||||
ServiceNameToTotalDurationMap: serviceNameToTotalDurationMap,
|
||||
TraceRoots: traceRoots,
|
||||
HasMissingSpans: hasMissingSpans,
|
||||
}
|
||||
|
||||
r.logger.Info("getWaterfallSpansForTraceWithMetadata: processing pre cache", "duration", time.Since(processingBeforeCache), "traceID", traceID)
|
||||
cacheErr := r.cacheForTraceDetail.Set(ctx, orgID, strings.Join([]string{"getWaterfallSpansForTraceWithMetadata", traceID}, "-"), &traceCache, time.Minute*5)
|
||||
if cacheErr != nil {
|
||||
r.logger.Debug("failed to store cache for getWaterfallSpansForTraceWithMetadata", "traceID", traceID, errorsV2.Attr(err))
|
||||
if !hasParentSpanNode && !tracedetail.ContainsWaterfallSpan(traceRoots, spanNode) {
|
||||
traceRoots = append(traceRoots, spanNode)
|
||||
}
|
||||
}
|
||||
|
||||
// sort the trace roots to add missing spans at the right order
|
||||
sort.Slice(traceRoots, func(i, j int) bool {
|
||||
if traceRoots[i].TimeUnixNano == traceRoots[j].TimeUnixNano {
|
||||
return traceRoots[i].Name < traceRoots[j].Name
|
||||
}
|
||||
return traceRoots[i].TimeUnixNano < traceRoots[j].TimeUnixNano
|
||||
})
|
||||
|
||||
serviceNameToTotalDurationMap = tracedetail.CalculateServiceTime(serviceNameIntervalMap)
|
||||
|
||||
processingPostCache := time.Now()
|
||||
// When req.Limit is 0 (not set by the client), selectAllSpans is set to false
|
||||
// preserving the old paged behaviour for backward compatibility
|
||||
@@ -1130,23 +1071,6 @@ func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadata(ctx context.Con
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetFlamegraphSpansForTraceCache(ctx context.Context, orgID valuer.UUID, traceID string) (*model.GetFlamegraphSpansForTraceCache, error) {
|
||||
cachedTraceData := new(model.GetFlamegraphSpansForTraceCache)
|
||||
err := r.cacheForTraceDetail.Get(ctx, orgID, strings.Join([]string{"getFlamegraphSpansForTrace", traceID}, "-"), cachedTraceData)
|
||||
if err != nil {
|
||||
r.logger.Debug("error in retrieving getFlamegraphSpansForTrace cache", errorsV2.Attr(err), "traceID", traceID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if time.Since(time.UnixMilli(int64(cachedTraceData.EndTime))) < r.fluxIntervalForTraceDetail {
|
||||
r.logger.Info("the trace end time falls under the flux interval, skipping getFlamegraphSpansForTrace cache", "traceID", traceID)
|
||||
return nil, errors.Errorf("the trace end time falls under the flux interval, skipping getFlamegraphSpansForTrace cache, traceID: %s", traceID)
|
||||
}
|
||||
|
||||
r.logger.Info("cache is successfully hit, applying cache for getFlamegraphSpansForTrace", "traceID", traceID)
|
||||
return cachedTraceData, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID valuer.UUID, traceID string, req *model.GetFlamegraphSpansForTraceParams) (*model.GetFlamegraphSpansForTraceResponse, error) {
|
||||
trace := new(model.GetFlamegraphSpansForTraceResponse)
|
||||
var startTime, endTime, durationNano uint64
|
||||
@@ -1155,125 +1079,96 @@ func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID
|
||||
var selectedSpans = [][]*model.FlamegraphSpan{}
|
||||
var traceRoots []*model.FlamegraphSpan
|
||||
|
||||
// get the trace tree from cache!
|
||||
cachedTraceData, err := r.GetFlamegraphSpansForTraceCache(ctx, orgID, traceID)
|
||||
|
||||
if err == nil {
|
||||
startTime = cachedTraceData.StartTime
|
||||
endTime = cachedTraceData.EndTime
|
||||
durationNano = cachedTraceData.DurationNano
|
||||
selectedSpans = cachedTraceData.SelectedSpans
|
||||
traceRoots = cachedTraceData.TraceRoots
|
||||
}
|
||||
|
||||
searchScanResponses, err := r.GetSpansForTrace(ctx, traceID, fmt.Sprintf("SELECT timestamp, duration_nano, span_id, trace_id, has_error,links as references, resource_string_service$$name, name, events FROM %s.%s WHERE trace_id=$1 and ts_bucket_start>=$2 and ts_bucket_start<=$3 ORDER BY timestamp ASC, name ASC", r.TraceDB, r.traceTableName))
|
||||
if err != nil {
|
||||
r.logger.Info("cache miss for getFlamegraphSpansForTrace", "traceID", traceID)
|
||||
return nil, err
|
||||
}
|
||||
if len(searchScanResponses) == 0 {
|
||||
return trace, nil
|
||||
}
|
||||
|
||||
searchScanResponses, err := r.GetSpansForTrace(ctx, traceID, fmt.Sprintf("SELECT timestamp, duration_nano, span_id, trace_id, has_error,links as references, resource_string_service$$name, name, events FROM %s.%s WHERE trace_id=$1 and ts_bucket_start>=$2 and ts_bucket_start<=$3 ORDER BY timestamp ASC, name ASC", r.TraceDB, r.traceTableName))
|
||||
for _, item := range searchScanResponses {
|
||||
ref := []model.OtelSpanRef{}
|
||||
err := json.Unmarshal([]byte(item.References), &ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(searchScanResponses) == 0 {
|
||||
return trace, nil
|
||||
r.logger.Error("Error unmarshalling references", errorsV2.Attr(err))
|
||||
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "getFlamegraphSpansForTrace: error in unmarshalling references %s", err.Error())
|
||||
}
|
||||
|
||||
processingBeforeCache := time.Now()
|
||||
for _, item := range searchScanResponses {
|
||||
ref := []model.OtelSpanRef{}
|
||||
err := json.Unmarshal([]byte(item.References), &ref)
|
||||
events := make([]model.Event, 0)
|
||||
for _, event := range item.Events {
|
||||
var eventMap model.Event
|
||||
err = json.Unmarshal([]byte(event), &eventMap)
|
||||
if err != nil {
|
||||
r.logger.Error("Error unmarshalling references", errorsV2.Attr(err))
|
||||
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "getFlamegraphSpansForTrace: error in unmarshalling references %s", err.Error())
|
||||
r.logger.Error("Error unmarshalling events", errorsV2.Attr(err))
|
||||
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "getFlamegraphSpansForTrace: error in unmarshalling events %s", err.Error())
|
||||
}
|
||||
|
||||
events := make([]model.Event, 0)
|
||||
for _, event := range item.Events {
|
||||
var eventMap model.Event
|
||||
err = json.Unmarshal([]byte(event), &eventMap)
|
||||
if err != nil {
|
||||
r.logger.Error("Error unmarshalling events", errorsV2.Attr(err))
|
||||
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "getFlamegraphSpansForTrace: error in unmarshalling events %s", err.Error())
|
||||
}
|
||||
events = append(events, eventMap)
|
||||
}
|
||||
|
||||
jsonItem := model.FlamegraphSpan{
|
||||
SpanID: item.SpanID,
|
||||
TraceID: item.TraceID,
|
||||
ServiceName: item.ServiceName,
|
||||
Name: item.Name,
|
||||
DurationNano: item.DurationNano,
|
||||
HasError: item.HasError,
|
||||
References: ref,
|
||||
Events: events,
|
||||
Children: make([]*model.FlamegraphSpan, 0),
|
||||
}
|
||||
|
||||
// metadata calculation
|
||||
startTimeUnixNano := uint64(item.TimeUnixNano.UnixNano())
|
||||
if startTime == 0 || startTimeUnixNano < startTime {
|
||||
startTime = startTimeUnixNano
|
||||
}
|
||||
if endTime == 0 || (startTimeUnixNano+jsonItem.DurationNano) > endTime {
|
||||
endTime = (startTimeUnixNano + jsonItem.DurationNano)
|
||||
}
|
||||
if durationNano == 0 || jsonItem.DurationNano > durationNano {
|
||||
durationNano = jsonItem.DurationNano
|
||||
}
|
||||
|
||||
jsonItem.TimeUnixNano = uint64(item.TimeUnixNano.UnixNano() / 1000000)
|
||||
spanIdToSpanNodeMap[jsonItem.SpanID] = &jsonItem
|
||||
events = append(events, eventMap)
|
||||
}
|
||||
|
||||
// traverse through the map and append each node to the children array of the parent node
|
||||
// and add missing spans
|
||||
for _, spanNode := range spanIdToSpanNodeMap {
|
||||
hasParentSpanNode := false
|
||||
for _, reference := range spanNode.References {
|
||||
if reference.RefType == "CHILD_OF" && reference.SpanId != "" {
|
||||
hasParentSpanNode = true
|
||||
if parentNode, exists := spanIdToSpanNodeMap[reference.SpanId]; exists {
|
||||
parentNode.Children = append(parentNode.Children, spanNode)
|
||||
} else {
|
||||
// insert the missing spans
|
||||
missingSpan := model.FlamegraphSpan{
|
||||
SpanID: reference.SpanId,
|
||||
TraceID: spanNode.TraceID,
|
||||
ServiceName: "",
|
||||
Name: "Missing Span",
|
||||
TimeUnixNano: spanNode.TimeUnixNano,
|
||||
DurationNano: spanNode.DurationNano,
|
||||
HasError: false,
|
||||
Events: make([]model.Event, 0),
|
||||
Children: make([]*model.FlamegraphSpan, 0),
|
||||
}
|
||||
missingSpan.Children = append(missingSpan.Children, spanNode)
|
||||
spanIdToSpanNodeMap[missingSpan.SpanID] = &missingSpan
|
||||
traceRoots = append(traceRoots, &missingSpan)
|
||||
jsonItem := model.FlamegraphSpan{
|
||||
SpanID: item.SpanID,
|
||||
TraceID: item.TraceID,
|
||||
ServiceName: item.ServiceName,
|
||||
Name: item.Name,
|
||||
DurationNano: item.DurationNano,
|
||||
HasError: item.HasError,
|
||||
References: ref,
|
||||
Events: events,
|
||||
Children: make([]*model.FlamegraphSpan, 0),
|
||||
}
|
||||
|
||||
// metadata calculation
|
||||
startTimeUnixNano := uint64(item.TimeUnixNano.UnixNano())
|
||||
if startTime == 0 || startTimeUnixNano < startTime {
|
||||
startTime = startTimeUnixNano
|
||||
}
|
||||
if endTime == 0 || (startTimeUnixNano+jsonItem.DurationNano) > endTime {
|
||||
endTime = (startTimeUnixNano + jsonItem.DurationNano)
|
||||
}
|
||||
if durationNano == 0 || jsonItem.DurationNano > durationNano {
|
||||
durationNano = jsonItem.DurationNano
|
||||
}
|
||||
|
||||
jsonItem.TimeUnixNano = uint64(item.TimeUnixNano.UnixNano() / 1000000)
|
||||
spanIdToSpanNodeMap[jsonItem.SpanID] = &jsonItem
|
||||
}
|
||||
|
||||
// traverse through the map and append each node to the children array of the parent node
|
||||
// and add missing spans
|
||||
for _, spanNode := range spanIdToSpanNodeMap {
|
||||
hasParentSpanNode := false
|
||||
for _, reference := range spanNode.References {
|
||||
if reference.RefType == "CHILD_OF" && reference.SpanId != "" {
|
||||
hasParentSpanNode = true
|
||||
if parentNode, exists := spanIdToSpanNodeMap[reference.SpanId]; exists {
|
||||
parentNode.Children = append(parentNode.Children, spanNode)
|
||||
} else {
|
||||
// insert the missing spans
|
||||
missingSpan := model.FlamegraphSpan{
|
||||
SpanID: reference.SpanId,
|
||||
TraceID: spanNode.TraceID,
|
||||
ServiceName: "",
|
||||
Name: "Missing Span",
|
||||
TimeUnixNano: spanNode.TimeUnixNano,
|
||||
DurationNano: spanNode.DurationNano,
|
||||
HasError: false,
|
||||
Events: make([]model.Event, 0),
|
||||
Children: make([]*model.FlamegraphSpan, 0),
|
||||
}
|
||||
missingSpan.Children = append(missingSpan.Children, spanNode)
|
||||
spanIdToSpanNodeMap[missingSpan.SpanID] = &missingSpan
|
||||
traceRoots = append(traceRoots, &missingSpan)
|
||||
}
|
||||
}
|
||||
if !hasParentSpanNode && !tracedetail.ContainsFlamegraphSpan(traceRoots, spanNode) {
|
||||
traceRoots = append(traceRoots, spanNode)
|
||||
}
|
||||
}
|
||||
|
||||
selectedSpans = tracedetail.GetAllSpansForFlamegraph(traceRoots, spanIdToSpanNodeMap)
|
||||
traceCache := model.GetFlamegraphSpansForTraceCache{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
DurationNano: durationNano,
|
||||
SelectedSpans: selectedSpans,
|
||||
TraceRoots: traceRoots,
|
||||
}
|
||||
|
||||
r.logger.Info("getFlamegraphSpansForTrace: processing pre cache", "duration", time.Since(processingBeforeCache), "traceID", traceID)
|
||||
cacheErr := r.cacheForTraceDetail.Set(ctx, orgID, strings.Join([]string{"getFlamegraphSpansForTrace", traceID}, "-"), &traceCache, time.Minute*5)
|
||||
if cacheErr != nil {
|
||||
r.logger.Debug("failed to store cache for getFlamegraphSpansForTrace", "traceID", traceID, errorsV2.Attr(err))
|
||||
if !hasParentSpanNode && !tracedetail.ContainsFlamegraphSpan(traceRoots, spanNode) {
|
||||
traceRoots = append(traceRoots, spanNode)
|
||||
}
|
||||
}
|
||||
|
||||
selectedSpans = tracedetail.GetAllSpansForFlamegraph(traceRoots, spanIdToSpanNodeMap)
|
||||
|
||||
processingPostCache := time.Now()
|
||||
selectedSpansForRequest := selectedSpans
|
||||
clientLimit := min(req.Limit, tracedetail.MaxLimitWithoutSampling)
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/cache/memorycache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
@@ -79,25 +78,12 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cacheForTraceDetail, err := memorycache.New(context.TODO(), signoz.Instrumentation.ToProviderSettings(), cache.Config{
|
||||
Provider: "memory",
|
||||
Memory: cache.Memory{
|
||||
NumCounters: 10 * 10000,
|
||||
MaxCost: 1 << 27, // 128 MB
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reader := clickhouseReader.NewReader(
|
||||
signoz.Instrumentation.Logger(),
|
||||
signoz.SQLStore,
|
||||
signoz.TelemetryStore,
|
||||
signoz.Prometheus,
|
||||
signoz.TelemetryStore.Cluster(),
|
||||
config.Querier.FluxInterval,
|
||||
cacheForTraceDetail,
|
||||
signoz.Cache,
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -1,74 +1 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/cachetypes"
|
||||
)
|
||||
|
||||
type GetWaterfallSpansForTraceWithMetadataCache struct {
|
||||
StartTime uint64 `json:"startTime"`
|
||||
EndTime uint64 `json:"endTime"`
|
||||
DurationNano uint64 `json:"durationNano"`
|
||||
TotalSpans uint64 `json:"totalSpans"`
|
||||
TotalErrorSpans uint64 `json:"totalErrorSpans"`
|
||||
ServiceNameToTotalDurationMap map[string]uint64 `json:"serviceNameToTotalDurationMap"`
|
||||
SpanIdToSpanNodeMap map[string]*Span `json:"spanIdToSpanNodeMap"`
|
||||
TraceRoots []*Span `json:"traceRoots"`
|
||||
HasMissingSpans bool `json:"hasMissingSpans"`
|
||||
}
|
||||
|
||||
func (c *GetWaterfallSpansForTraceWithMetadataCache) Clone() cachetypes.Cacheable {
|
||||
copyOfServiceNameToTotalDurationMap := make(map[string]uint64)
|
||||
maps.Copy(copyOfServiceNameToTotalDurationMap, c.ServiceNameToTotalDurationMap)
|
||||
|
||||
copyOfSpanIdToSpanNodeMap := make(map[string]*Span)
|
||||
maps.Copy(copyOfSpanIdToSpanNodeMap, c.SpanIdToSpanNodeMap)
|
||||
|
||||
copyOfTraceRoots := make([]*Span, len(c.TraceRoots))
|
||||
copy(copyOfTraceRoots, c.TraceRoots)
|
||||
return &GetWaterfallSpansForTraceWithMetadataCache{
|
||||
StartTime: c.StartTime,
|
||||
EndTime: c.EndTime,
|
||||
DurationNano: c.DurationNano,
|
||||
TotalSpans: c.TotalSpans,
|
||||
TotalErrorSpans: c.TotalErrorSpans,
|
||||
ServiceNameToTotalDurationMap: copyOfServiceNameToTotalDurationMap,
|
||||
SpanIdToSpanNodeMap: copyOfSpanIdToSpanNodeMap,
|
||||
TraceRoots: copyOfTraceRoots,
|
||||
HasMissingSpans: c.HasMissingSpans,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *GetWaterfallSpansForTraceWithMetadataCache) MarshalBinary() (data []byte, err error) {
|
||||
return json.Marshal(c)
|
||||
}
|
||||
func (c *GetWaterfallSpansForTraceWithMetadataCache) UnmarshalBinary(data []byte) error {
|
||||
return json.Unmarshal(data, c)
|
||||
}
|
||||
|
||||
type GetFlamegraphSpansForTraceCache struct {
|
||||
StartTime uint64 `json:"startTime"`
|
||||
EndTime uint64 `json:"endTime"`
|
||||
DurationNano uint64 `json:"durationNano"`
|
||||
SelectedSpans [][]*FlamegraphSpan `json:"selectedSpans"`
|
||||
TraceRoots []*FlamegraphSpan `json:"traceRoots"`
|
||||
}
|
||||
|
||||
func (c *GetFlamegraphSpansForTraceCache) Clone() cachetypes.Cacheable {
|
||||
return &GetFlamegraphSpansForTraceCache{
|
||||
StartTime: c.StartTime,
|
||||
EndTime: c.EndTime,
|
||||
DurationNano: c.DurationNano,
|
||||
SelectedSpans: c.SelectedSpans,
|
||||
TraceRoots: c.TraceRoots,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *GetFlamegraphSpansForTraceCache) MarshalBinary() (data []byte, err error) {
|
||||
return json.Marshal(c)
|
||||
}
|
||||
func (c *GetFlamegraphSpansForTraceCache) UnmarshalBinary(data []byte) error {
|
||||
return json.Unmarshal(data, c)
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
func (r *renderer) renderHTML(_ context.Context, markdown string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := newHTMLRenderer().Convert([]byte(markdown), &buf); err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "failed to convert markdown to HTML")
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
testMarkdown = `# 🔥 FIRING: High CPU Usage on api-gateway
|
||||
|
||||
https://signoz.example.com/alerts/123
|
||||
https://runbooks.example.com/cpu-high
|
||||
|
||||
## Alert Details
|
||||
|
||||
**Status:** **FIRING** | *api-gateway* service is experiencing high CPU usage. ~~resolved~~ previously.
|
||||
|
||||
Alert triggered because ` + "`cpu_usage_percent`" + ` exceeded threshold ` + "`90`" + `.
|
||||
|
||||
[View Alert in SigNoz](https://signoz.example.com/alerts/123) | [View Logs](https://signoz.example.com/logs?service=api-gateway) | [View Traces](https://signoz.example.com/traces?service=api-gateway)
|
||||
|
||||

|
||||
|
||||
## Alert Labels
|
||||
|
||||
| Label | Value |
|
||||
| -------- | ----------- |
|
||||
| service | api-gateway |
|
||||
| instance | pod-5a8b3c |
|
||||
| severity | critical |
|
||||
| region | us-east-1 |
|
||||
|
||||
## Remediation Steps
|
||||
|
||||
1. Check current CPU usage on the pod
|
||||
2. Review recent deployments for regressions
|
||||
3. Scale horizontally if load-related
|
||||
1. Increase replica count
|
||||
2. Verify HPA configuration
|
||||
|
||||
## Affected Services
|
||||
|
||||
* api-gateway
|
||||
* auth-service
|
||||
* payment-service
|
||||
* payment-processor
|
||||
* payment-validator
|
||||
|
||||
## Incident Checklist
|
||||
|
||||
- [x] Alert acknowledged
|
||||
- [x] On-call notified
|
||||
- [ ] Root cause identified
|
||||
- [ ] Fix deployed
|
||||
|
||||
## Alert Rule Description
|
||||
|
||||
> This alert fires when CPU usage exceeds 90% for more than 5 minutes on any pod in the api-gateway service.
|
||||
>
|
||||
>> For capacity planning guidelines, see the infrastructure runbook section on horizontal pod autoscaling.
|
||||
|
||||
## Triggered Query
|
||||
` + "```promql\navg(rate(container_cpu_usage_seconds_total{service=\"api-gateway\"}[5m])) by (pod) > 0.9\n```" + `
|
||||
|
||||
## Inline Details
|
||||
|
||||
This alert was generated by SigNoz using ` + "`alertmanager`" + ` rules engine.
|
||||
`
|
||||
)
|
||||
|
||||
func newTestRenderer() Renderer {
|
||||
return NewRenderer()
|
||||
}
|
||||
|
||||
func TestRenderHTML_Composite(t *testing.T) {
|
||||
renderer := newTestRenderer()
|
||||
|
||||
html, err := renderer.Render(context.Background(), testMarkdown, MarkdownFormatHTML)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Full expected output for exact match
|
||||
expected := "<h1>🔥 FIRING: High CPU Usage on api-gateway</h1>\n" +
|
||||
"<p><a href=\"https://signoz.example.com/alerts/123\">https://signoz.example.com/alerts/123</a>\n<a href=\"https://runbooks.example.com/cpu-high\">https://runbooks.example.com/cpu-high</a></p>\n" +
|
||||
"<h2>Alert Details</h2>\n" +
|
||||
"<p><strong>Status:</strong> <strong>FIRING</strong> | <em>api-gateway</em> service is experiencing high CPU usage. <del>resolved</del> previously.</p>\n" +
|
||||
"<p>Alert triggered because <code>cpu_usage_percent</code> exceeded threshold <code>90</code>.</p>\n" +
|
||||
"<p><a href=\"https://signoz.example.com/alerts/123\">View Alert in SigNoz</a> | <a href=\"https://signoz.example.com/logs?service=api-gateway\">View Logs</a> | <a href=\"https://signoz.example.com/traces?service=api-gateway\">View Traces</a></p>\n" +
|
||||
"<p><img src=\"https://signoz.example.com/badges/critical.svg\" alt=\"critical\" title=\"Critical Alert\"></p>\n" +
|
||||
"<h2>Alert Labels</h2>\n" +
|
||||
"<table>\n<thead>\n<tr>\n<th>Label</th>\n<th>Value</th>\n</tr>\n</thead>\n" +
|
||||
"<tbody>\n<tr>\n<td>service</td>\n<td>api-gateway</td>\n</tr>\n" +
|
||||
"<tr>\n<td>instance</td>\n<td>pod-5a8b3c</td>\n</tr>\n" +
|
||||
"<tr>\n<td>severity</td>\n<td>critical</td>\n</tr>\n" +
|
||||
"<tr>\n<td>region</td>\n<td>us-east-1</td>\n</tr>\n</tbody>\n</table>\n" +
|
||||
"<h2>Remediation Steps</h2>\n" +
|
||||
"<ol>\n<li>Check current CPU usage on the pod</li>\n<li>Review recent deployments for regressions</li>\n<li>Scale horizontally if load-related\n" +
|
||||
"<ol>\n<li>Increase replica count</li>\n<li>Verify HPA configuration</li>\n</ol>\n</li>\n</ol>\n" +
|
||||
"<h2>Affected Services</h2>\n" +
|
||||
"<ul>\n<li>api-gateway</li>\n<li>auth-service</li>\n<li>payment-service\n" +
|
||||
"<ul>\n<li>payment-processor</li>\n<li>payment-validator</li>\n</ul>\n</li>\n</ul>\n" +
|
||||
"<h2>Incident Checklist</h2>\n" +
|
||||
"<ul>\n<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> Alert acknowledged</li>\n" +
|
||||
"<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> On-call notified</li>\n" +
|
||||
"<li><input disabled=\"\" type=\"checkbox\"> Root cause identified</li>\n" +
|
||||
"<li><input disabled=\"\" type=\"checkbox\"> Fix deployed</li>\n</ul>\n" +
|
||||
"<h2>Alert Rule Description</h2>\n" +
|
||||
"<blockquote>\n<p>This alert fires when CPU usage exceeds 90% for more than 5 minutes on any pod in the api-gateway service.</p>\n" +
|
||||
"<blockquote>\n<p>For capacity planning guidelines, see the infrastructure runbook section on horizontal pod autoscaling.</p>\n</blockquote>\n</blockquote>\n" +
|
||||
"<h2>Triggered Query</h2>\n" +
|
||||
"<pre><code class=\"language-promql\">avg(rate(container_cpu_usage_seconds_total{service="api-gateway"}[5m])) by (pod) > 0.9\n</code></pre>\n" +
|
||||
"<h2>Inline Details</h2>\n" +
|
||||
"<p>This alert was generated by SigNoz using <code>alertmanager</code> rules engine.</p>\n"
|
||||
|
||||
assert.Equal(t, expected, html)
|
||||
}
|
||||
|
||||
func TestRenderHTML_InlineFormatting(t *testing.T) {
|
||||
renderer := newTestRenderer()
|
||||
|
||||
input := `# 🔥 FIRING: High CPU on api-gateway
|
||||
## Alert Status
|
||||
|
||||
**FIRING** alert for *api-gateway* service — ~~resolved~~ previously.
|
||||
|
||||
Metric ` + "`cpu_usage_percent`" + ` exceeded threshold. [View in SigNoz](https://signoz.example.com/alerts/123)
|
||||
|
||||
`
|
||||
|
||||
html, err := renderer.Render(context.Background(), input, MarkdownFormatHTML)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := "<h1>🔥 FIRING: High CPU on api-gateway</h1>\n<h2>Alert Status</h2>\n" +
|
||||
"<p><strong>FIRING</strong> alert for <em>api-gateway</em> service — <del>resolved</del> previously.</p>\n" +
|
||||
"<p>Metric <code>cpu_usage_percent</code> exceeded threshold. <a href=\"https://signoz.example.com/alerts/123\">View in SigNoz</a></p>\n" +
|
||||
"<p><img src=\"https://signoz.example.com/badges/critical.svg\" alt=\"critical\" title=\"Critical Alert\"></p>\n"
|
||||
|
||||
assert.Equal(t, expected, html)
|
||||
}
|
||||
|
||||
func TestRenderHTML_BlockElements(t *testing.T) {
|
||||
renderer := newTestRenderer()
|
||||
|
||||
input := `1. Check CPU usage on the pod
|
||||
2. Review recent deployments
|
||||
3. Scale horizontally if needed
|
||||
|
||||
* api-gateway
|
||||
* auth-service
|
||||
* payment-service
|
||||
|
||||
- [x] Alert acknowledged
|
||||
- [ ] Root cause identified
|
||||
|
||||
> This alert fires when CPU usage exceeds 90% for more than 5 minutes.
|
||||
|
||||
| Label | Value |
|
||||
| -------- | ----------- |
|
||||
| service | api-gateway |
|
||||
| severity | <no value> |
|
||||
|
||||
` + "```promql\navg(rate(container_cpu_usage_seconds_total{service=\"api-gateway\"}[5m])) by (pod) > 0.9\n```"
|
||||
|
||||
html, err := renderer.Render(context.Background(), input, MarkdownFormatHTML)
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := "<ol>\n<li>Check CPU usage on the pod</li>\n<li>Review recent deployments</li>\n<li>Scale horizontally if needed</li>\n</ol>\n" +
|
||||
"<ul>\n<li>api-gateway</li>\n<li>auth-service</li>\n<li>payment-service</li>\n</ul>\n" +
|
||||
"<ul>\n<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> Alert acknowledged</li>\n" +
|
||||
"<li><input disabled=\"\" type=\"checkbox\"> Root cause identified</li>\n</ul>\n" +
|
||||
"<blockquote>\n<p>This alert fires when CPU usage exceeds 90% for more than 5 minutes.</p>\n</blockquote>\n" +
|
||||
"<table>\n<thead>\n<tr>\n<th>Label</th>\n<th>Value</th>\n</tr>\n</thead>\n" +
|
||||
"<tbody>\n<tr>\n<td>service</td>\n<td>api-gateway</td>\n</tr>\n" +
|
||||
"<tr>\n<td>severity</td>\n<td><no value></td>\n</tr>\n</tbody>\n</table>\n" +
|
||||
"<pre><code class=\"language-promql\">avg(rate(container_cpu_usage_seconds_total{service="api-gateway"}[5m])) by (pod) > 0.9\n</code></pre>\n"
|
||||
|
||||
assert.Equal(t, expected, html)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/templating/slackblockkitrenderer"
|
||||
"github.com/SigNoz/signoz/pkg/templating/slackmrkdwnrenderer"
|
||||
"github.com/SigNoz/signoz/pkg/templating/templatingextensions"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
)
|
||||
|
||||
// newHTMLRenderer creates a new goldmark.Markdown instance for HTML rendering.
|
||||
func newHTMLRenderer() goldmark.Markdown {
|
||||
return goldmark.New(
|
||||
goldmark.WithExtensions(extension.GFM),
|
||||
goldmark.WithExtensions(templatingextensions.EscapeNoValue),
|
||||
)
|
||||
}
|
||||
|
||||
// newSlackBlockKitRenderer creates a new goldmark.Markdown instance for Slack Block Kit rendering.
|
||||
func newSlackBlockKitRenderer() goldmark.Markdown {
|
||||
return goldmark.New(
|
||||
goldmark.WithExtensions(slackblockkitrenderer.BlockKitV2),
|
||||
)
|
||||
}
|
||||
|
||||
// newSlackMrkdwnRenderer creates a new goldmark.Markdown instance for Slack mrkdwn rendering.
|
||||
func newSlackMrkdwnRenderer() goldmark.Markdown {
|
||||
return goldmark.New(
|
||||
goldmark.WithExtensions(slackmrkdwnrenderer.SlackMrkdwn),
|
||||
)
|
||||
}
|
||||
|
||||
type OutputFormat int
|
||||
|
||||
const (
|
||||
MarkdownFormatHTML OutputFormat = iota
|
||||
MarkdownFormatSlackBlockKit
|
||||
MarkdownFormatSlackMrkdwn
|
||||
MarkdownFormatNoop
|
||||
)
|
||||
|
||||
// Renderer is the interface for rendering markdown to different formats.
|
||||
type Renderer interface {
|
||||
// Render renders the markdown to the given output format.
|
||||
Render(ctx context.Context, markdown string, outputFormat OutputFormat) (string, error)
|
||||
}
|
||||
|
||||
type renderer struct {
|
||||
}
|
||||
|
||||
func NewRenderer() Renderer {
|
||||
return &renderer{}
|
||||
}
|
||||
|
||||
func (r *renderer) Render(ctx context.Context, markdown string, outputFormat OutputFormat) (string, error) {
|
||||
switch outputFormat {
|
||||
case MarkdownFormatHTML:
|
||||
return r.renderHTML(ctx, markdown)
|
||||
case MarkdownFormatSlackBlockKit:
|
||||
return r.renderSlackBlockKit(ctx, markdown)
|
||||
case MarkdownFormatSlackMrkdwn:
|
||||
return r.renderSlackMrkdwn(ctx, markdown)
|
||||
case MarkdownFormatNoop:
|
||||
return markdown, nil
|
||||
default:
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown output format: %v", outputFormat)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRenderNoop(t *testing.T) {
|
||||
renderer := newTestRenderer()
|
||||
|
||||
output, err := renderer.Render(context.Background(), testMarkdown, MarkdownFormatNoop)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testMarkdown, output)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
func (r *renderer) renderSlackBlockKit(_ context.Context, markdown string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := newSlackBlockKitRenderer().Convert([]byte(markdown), &buf); err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "failed to convert markdown to Slack Block Kit")
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func (r *renderer) renderSlackMrkdwn(_ context.Context, markdown string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := newSlackMrkdwnRenderer().Convert([]byte(markdown), &buf); err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "failed to convert markdown to Slack Mrkdwn")
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package markdownrenderer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func jsonEqual(a, b string) bool {
|
||||
var va, vb any
|
||||
if err := json.Unmarshal([]byte(a), &va); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal([]byte(b), &vb); err != nil {
|
||||
return false
|
||||
}
|
||||
ja, _ := json.Marshal(va)
|
||||
jb, _ := json.Marshal(vb)
|
||||
return string(ja) == string(jb)
|
||||
}
|
||||
|
||||
func prettyJSON(s string) string {
|
||||
var v any
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
return s
|
||||
}
|
||||
b, _ := json.MarshalIndent(v, "", " ")
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestRenderSlackBlockKit(t *testing.T) {
|
||||
renderer := NewRenderer()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple paragraph",
|
||||
markdown: "Hello world",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "Hello world" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "alert-themed with heading, list, and code block",
|
||||
markdown: `# Alert Triggered
|
||||
|
||||
- Service: **checkout-api**
|
||||
- Status: _critical_
|
||||
|
||||
` + "```" + `
|
||||
error: connection timeout after 30s
|
||||
` + "```",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "*Alert Triggered*" }
|
||||
},
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [
|
||||
{ "type": "text", "text": "Service: " },
|
||||
{ "type": "text", "text": "checkout-api", "style": { "bold": true } }
|
||||
]},
|
||||
{ "type": "rich_text_section", "elements": [
|
||||
{ "type": "text", "text": "Status: " },
|
||||
{ "type": "text", "text": "critical", "style": { "italic": true } }
|
||||
]}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_preformatted",
|
||||
"border": 0,
|
||||
"elements": [
|
||||
{ "type": "text", "text": "error: connection timeout after 30s" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := renderer.Render(context.Background(), tt.markdown, MarkdownFormatSlackBlockKit)
|
||||
if err != nil {
|
||||
t.Fatalf("Render error: %v", err)
|
||||
}
|
||||
|
||||
// Verify output is valid JSON
|
||||
if !json.Valid([]byte(got)) {
|
||||
t.Fatalf("output is not valid JSON:\n%s", got)
|
||||
}
|
||||
|
||||
if !jsonEqual(got, tt.expected) {
|
||||
t.Errorf("JSON mismatch\n\nMarkdown:\n%s\n\nExpected:\n%s\n\nGot:\n%s",
|
||||
tt.markdown, prettyJSON(tt.expected), prettyJSON(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSlackMrkdwn(t *testing.T) {
|
||||
renderer := NewRenderer()
|
||||
|
||||
markdown := `# Alert Triggered
|
||||
|
||||
- Service: **checkout-api**
|
||||
- Status: _critical_
|
||||
- Dashboard: [View Dashboard](https://example.com/dashboard)
|
||||
|
||||
| Metric | Value | Threshold |
|
||||
| --- | --- | --- |
|
||||
| Latency | 250ms | 100ms |
|
||||
| Error Rate | 5.2% | 1% |
|
||||
|
||||
` + "```" + `
|
||||
error: connection timeout after 30s
|
||||
` + "```"
|
||||
|
||||
expected := "*Alert Triggered*\n\n" +
|
||||
"• Service: *checkout-api*\n" +
|
||||
"• Status: _critical_\n" +
|
||||
"• Dashboard: <https://example.com/dashboard|View Dashboard>\n\n" +
|
||||
"```\nMetric | Value | Threshold\n-----------|-------|----------\nLatency | 250ms | 100ms \nError Rate | 5.2% | 1% \n```\n\n" +
|
||||
"```\nerror: connection timeout after 30s\n```\n\n"
|
||||
|
||||
got, err := renderer.Render(context.Background(), markdown, MarkdownFormatSlackMrkdwn)
|
||||
if err != nil {
|
||||
t.Fatalf("Render error: %v", err)
|
||||
}
|
||||
|
||||
if got != expected {
|
||||
t.Errorf("mrkdwn mismatch\n\nExpected:\n%q\n\nGot:\n%q", expected, got)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package slackblockkitrenderer
|
||||
|
||||
import (
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
type blockKitV2 struct{}
|
||||
|
||||
// BlockKitV2 is a goldmark.Extender that configures the Slack Block Kit v2 renderer.
|
||||
var BlockKitV2 = &blockKitV2{}
|
||||
|
||||
// Extend implements goldmark.Extender.
|
||||
func (e *blockKitV2) Extend(m goldmark.Markdown) {
|
||||
extension.Table.Extend(m)
|
||||
extension.Strikethrough.Extend(m)
|
||||
extension.TaskList.Extend(m)
|
||||
m.Renderer().AddOptions(
|
||||
renderer.WithNodeRenderers(util.Prioritized(NewRenderer(), 1)),
|
||||
)
|
||||
}
|
||||
@@ -1,542 +0,0 @@
|
||||
package slackblockkitrenderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
func jsonEqual(a, b string) bool {
|
||||
var va, vb interface{}
|
||||
if err := json.Unmarshal([]byte(a), &va); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal([]byte(b), &vb); err != nil {
|
||||
return false
|
||||
}
|
||||
ja, _ := json.Marshal(va)
|
||||
jb, _ := json.Marshal(vb)
|
||||
return string(ja) == string(jb)
|
||||
}
|
||||
|
||||
func prettyJSON(s string) string {
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
return s
|
||||
}
|
||||
b, _ := json.MarshalIndent(v, "", " ")
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestRenderer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
markdown: "",
|
||||
expected: `[]`,
|
||||
},
|
||||
{
|
||||
name: "simple paragraph",
|
||||
markdown: "Hello world",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "Hello world" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "heading",
|
||||
markdown: "# My Heading",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "*My Heading*" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "multiple paragraphs",
|
||||
markdown: "First paragraph\n\nSecond paragraph",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "First paragraph\nSecond paragraph" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "todo list ",
|
||||
markdown: "- [ ] item 1\n- [x] item 2",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"border": 0,
|
||||
"elements": [
|
||||
{ "elements": [ { "text": "[ ] ", "type": "text" }, { "text": "item 1", "type": "text" } ], "type": "rich_text_section" },
|
||||
{ "elements": [ { "text": "[x] ", "type": "text" }, { "text": "item 2", "type": "text" } ], "type": "rich_text_section" }
|
||||
],
|
||||
"indent": 0,
|
||||
"style": "bullet",
|
||||
"type": "rich_text_list"
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "thematic break between paragraphs",
|
||||
markdown: "Before\n\n---\n\nAfter",
|
||||
expected: `[
|
||||
{ "type": "section", "text": { "type": "mrkdwn", "text": "Before" } },
|
||||
{ "type": "divider" },
|
||||
{ "type": "section", "text": { "type": "mrkdwn", "text": "After" } }
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "fenced code block with language",
|
||||
markdown: "```go\nfmt.Println(\"hello\")\n```",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_preformatted",
|
||||
"border": 0,
|
||||
"language": "go",
|
||||
"elements": [
|
||||
{ "type": "text", "text": "fmt.Println(\"hello\")" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "indented code block",
|
||||
markdown: " code line 1\n code line 2",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_preformatted",
|
||||
"border": 0,
|
||||
"elements": [
|
||||
{ "type": "text", "text": "code line 1\ncode line 2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "empty fenced code block",
|
||||
markdown: "```\n```",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_preformatted",
|
||||
"border": 0,
|
||||
"elements": [
|
||||
{ "type": "text", "text": " " }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "simple bullet list",
|
||||
markdown: "- item 1\n- item 2\n- item 3",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 1" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 2" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 3" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "simple ordered list",
|
||||
markdown: "1. first\n2. second\n3. third",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "first" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "second" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "third" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "nested bullet list (2 levels)",
|
||||
markdown: "- item 1\n- item 2\n - sub a\n - sub b\n- item 3",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 1" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 2" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 1, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "sub a" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "sub b" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item 3" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "nested ordered list with offset",
|
||||
markdown: "1. first\n 1. nested-a\n 2. nested-b\n2. second\n3. third",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "first" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 1, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "nested-a" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "nested-b" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 1,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "second" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "third" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "mixed ordered/bullet nesting",
|
||||
markdown: "1. ordered\n - bullet child\n2. ordered again",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "ordered" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 1, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "bullet child" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 1,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "ordered again" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "list items with bold/italic/link/code",
|
||||
markdown: "- **bold item**\n- _italic item_\n- [link](http://example.com)\n- `code item`",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "bold item", "style": { "bold": true } }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "italic item", "style": { "italic": true } }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "link", "url": "http://example.com", "text": "link" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "code item", "style": { "code": true } }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "table with header and body",
|
||||
markdown: "| Name | Age |\n|------|-----|\n| Alice | 30 |",
|
||||
expected: `[
|
||||
{
|
||||
"type": "table",
|
||||
"rows": [
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Name", "style": { "bold": true } }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Age", "style": { "bold": true } }] }
|
||||
]}
|
||||
],
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Alice" }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "30" }] }
|
||||
]}
|
||||
]
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "blockquote",
|
||||
markdown: "> quoted text",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "> quoted text" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "blockquote with nested list",
|
||||
markdown: "> item 1\n> > item 2\n> > item 3",
|
||||
expected: `[
|
||||
{
|
||||
"text": {
|
||||
"text": "> item 1\n> > item 2\n> > item 3",
|
||||
"type": "mrkdwn"
|
||||
},
|
||||
"type": "section"
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "inline formatting in paragraph",
|
||||
markdown: "This is **bold** and _italic_ and ~strike~ and `code`",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "This is *bold* and _italic_ and ~strike~ and ` + "`code`" + `" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "link in paragraph",
|
||||
markdown: "Visit [Google](http://google.com)",
|
||||
expected: `[
|
||||
{
|
||||
"type": "section",
|
||||
"text": { "type": "mrkdwn", "text": "Visit <http://google.com|Google>" }
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "image is skipped",
|
||||
markdown: "",
|
||||
// For image skip the block and return empty array
|
||||
expected: `[]`,
|
||||
},
|
||||
{
|
||||
name: "paragraph then list then paragraph",
|
||||
markdown: "Before\n\n- item\n\nAfter",
|
||||
expected: `[
|
||||
{ "type": "section", "text": { "type": "mrkdwn", "text": "Before" } },
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "item" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "type": "section", "text": { "type": "mrkdwn", "text": "After" } }
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "ordered list with start > 1",
|
||||
markdown: "5. fifth\n6. sixth",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 4,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "fifth" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "sixth" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "deeply nested ordered list (3 levels) with offsets",
|
||||
markdown: "1. Some things\n\t1. are best left\n2. to the fate\n\t1. of the world\n\t\t1. and then\n\t\t2. this is how\n3. it turns out to be",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0,
|
||||
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "Some things" }] }] },
|
||||
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 1, "border": 0,
|
||||
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "are best left" }] }] },
|
||||
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 1,
|
||||
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "to the fate" }] }] },
|
||||
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 1, "border": 0,
|
||||
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "of the world" }] }] },
|
||||
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 2, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "and then" }] },
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "this is how" }] }
|
||||
]
|
||||
},
|
||||
|
||||
{ "type": "rich_text_list", "style": "ordered", "indent": 0, "border": 0, "offset": 2,
|
||||
"elements": [{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "it turns out to be" }] }] }
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "link with bold label in list item",
|
||||
markdown: "- [**docs**](http://example.com)",
|
||||
expected: `[
|
||||
{
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_list", "style": "bullet", "indent": 0, "border": 0,
|
||||
"elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "link", "url": "http://example.com", "text": "docs" }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "table with empty cell",
|
||||
markdown: "| A | B |\n|---|---|\n| 1 | |",
|
||||
expected: `[
|
||||
{
|
||||
"type": "table",
|
||||
"rows": [
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "A", "style": { "bold": true } }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "B", "style": { "bold": true } }] }
|
||||
]}
|
||||
],
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "1" }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": " " }] }
|
||||
]}
|
||||
]
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "table with missing column in row",
|
||||
markdown: "| A | B |\n|---|---|\n| 1 |",
|
||||
expected: `[
|
||||
{
|
||||
"type": "table",
|
||||
"rows": [
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "A", "style": { "bold": true } }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "B", "style": { "bold": true } }] }
|
||||
]}
|
||||
],
|
||||
[
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": "1" }] }
|
||||
]},
|
||||
{ "type": "rich_text", "elements": [
|
||||
{ "type": "rich_text_section", "elements": [{ "type": "text", "text": " " }] }
|
||||
]}
|
||||
]
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
md := goldmark.New(
|
||||
goldmark.WithExtensions(BlockKitV2),
|
||||
)
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
|
||||
t.Fatalf("convert error: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !jsonEqual(got, tt.expected) {
|
||||
t.Errorf("JSON mismatch\n\nMarkdown:\n%s\n\nExpected:\n%s\n\nGot:\n%s",
|
||||
tt.markdown, prettyJSON(tt.expected), prettyJSON(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,737 +0,0 @@
|
||||
package slackblockkitrenderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
extensionast "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// listFrame tracks state for a single level of list nesting.
|
||||
type listFrame struct {
|
||||
style string // "bullet" or "ordered"
|
||||
indent int
|
||||
itemCount int
|
||||
}
|
||||
|
||||
// listContext holds all state while processing a list tree.
|
||||
type listContext struct {
|
||||
result []RichTextList
|
||||
stack []listFrame
|
||||
current *RichTextList
|
||||
currentItemInlines []interface{}
|
||||
}
|
||||
|
||||
// tableContext holds state while processing a table.
|
||||
type tableContext struct {
|
||||
rows [][]TableCell
|
||||
currentRow []TableCell
|
||||
currentCellInlines []interface{}
|
||||
isHeader bool
|
||||
}
|
||||
|
||||
// Renderer converts Markdown AST to Slack Block Kit JSON.
|
||||
type Renderer struct {
|
||||
blocks []interface{}
|
||||
mrkdwn strings.Builder
|
||||
// holds active styles for the current rich text element
|
||||
styleStack []RichTextStyle
|
||||
// holds the current list context while processing a list tree.
|
||||
listCtx *listContext
|
||||
// holds the current table context while processing a table.
|
||||
tableCtx *tableContext
|
||||
// stores the current blockquote depth while processing a blockquote.
|
||||
// so blockquote with nested list can be rendered correctly.
|
||||
blockquoteDepth int
|
||||
}
|
||||
|
||||
// NewRenderer returns a new block kit renderer.
|
||||
func NewRenderer() renderer.NodeRenderer {
|
||||
return &Renderer{}
|
||||
}
|
||||
|
||||
// RegisterFuncs registers node rendering functions.
|
||||
func (r *Renderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
// Blocks
|
||||
reg.Register(ast.KindDocument, r.renderDocument)
|
||||
reg.Register(ast.KindHeading, r.renderHeading)
|
||||
reg.Register(ast.KindParagraph, r.renderParagraph)
|
||||
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
|
||||
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
|
||||
reg.Register(ast.KindBlockquote, r.renderBlockquote)
|
||||
reg.Register(ast.KindList, r.renderList)
|
||||
reg.Register(ast.KindListItem, r.renderListItem)
|
||||
reg.Register(ast.KindImage, r.renderImage)
|
||||
|
||||
// Inlines
|
||||
reg.Register(ast.KindText, r.renderText)
|
||||
reg.Register(ast.KindEmphasis, r.renderEmphasis)
|
||||
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
|
||||
reg.Register(ast.KindLink, r.renderLink)
|
||||
|
||||
// Extensions
|
||||
reg.Register(extensionast.KindStrikethrough, r.renderStrikethrough)
|
||||
reg.Register(extensionast.KindTable, r.renderTable)
|
||||
reg.Register(extensionast.KindTableHeader, r.renderTableHeader)
|
||||
reg.Register(extensionast.KindTableRow, r.renderTableRow)
|
||||
reg.Register(extensionast.KindTableCell, r.renderTableCell)
|
||||
reg.Register(extensionast.KindTaskCheckBox, r.renderTaskCheckBox)
|
||||
}
|
||||
|
||||
// inRichTextMode returns true when we're inside a list or table context
|
||||
// in slack blockkit list and table items are rendered as rich_text elements
|
||||
// if more cases are found in future those needs to be added here.
|
||||
func (r *Renderer) inRichTextMode() bool {
|
||||
return r.listCtx != nil || r.tableCtx != nil
|
||||
}
|
||||
|
||||
// currentStyle merges the stored style stack into RichTextStyle
|
||||
// which can be applied on rich text elements.
|
||||
func (r *Renderer) currentStyle() *RichTextStyle {
|
||||
s := RichTextStyle{}
|
||||
for _, f := range r.styleStack {
|
||||
s.Bold = s.Bold || f.Bold
|
||||
s.Italic = s.Italic || f.Italic
|
||||
s.Strike = s.Strike || f.Strike
|
||||
s.Code = s.Code || f.Code
|
||||
}
|
||||
if s == (RichTextStyle{}) {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// flushMrkdwn collects markdown text and adds it as a SectionBlock with mrkdwn text
|
||||
// whenever starting a new block we flush markdown to render it as a separate block.
|
||||
func (r *Renderer) flushMrkdwn() {
|
||||
text := strings.TrimSpace(r.mrkdwn.String())
|
||||
if text != "" {
|
||||
r.blocks = append(r.blocks, SectionBlock{
|
||||
Type: "section",
|
||||
Text: &TextObject{
|
||||
Type: "mrkdwn",
|
||||
Text: text,
|
||||
},
|
||||
})
|
||||
}
|
||||
r.mrkdwn.Reset()
|
||||
}
|
||||
|
||||
// addInline adds an inline element to the appropriate context.
|
||||
func (r *Renderer) addInline(el interface{}) {
|
||||
if r.listCtx != nil {
|
||||
r.listCtx.currentItemInlines = append(r.listCtx.currentItemInlines, el)
|
||||
} else if r.tableCtx != nil {
|
||||
r.tableCtx.currentCellInlines = append(r.tableCtx.currentCellInlines, el)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Document ---
|
||||
|
||||
func (r *Renderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.blocks = nil
|
||||
r.mrkdwn.Reset()
|
||||
r.styleStack = nil
|
||||
r.listCtx = nil
|
||||
r.tableCtx = nil
|
||||
r.blockquoteDepth = 0
|
||||
} else {
|
||||
// on exiting the document node write the json for the collected blocks.
|
||||
r.flushMrkdwn()
|
||||
var data []byte
|
||||
var err error
|
||||
if len(r.blocks) > 0 {
|
||||
data, err = json.Marshal(r.blocks)
|
||||
if err != nil {
|
||||
return ast.WalkStop, err
|
||||
}
|
||||
} else {
|
||||
// if no blocks are collected, write an empty array.
|
||||
data = []byte("[]")
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
if err != nil {
|
||||
return ast.WalkStop, err
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Heading ---
|
||||
|
||||
func (r *Renderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.mrkdwn.WriteString("*")
|
||||
} else {
|
||||
r.mrkdwn.WriteString("*\n")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Paragraph ---
|
||||
|
||||
func (r *Renderer) renderParagraph(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
if r.mrkdwn.Len() > 0 {
|
||||
text := r.mrkdwn.String()
|
||||
if !strings.HasSuffix(text, "\n") {
|
||||
r.mrkdwn.WriteString("\n")
|
||||
}
|
||||
}
|
||||
// handling of nested blockquotes
|
||||
if r.blockquoteDepth > 0 {
|
||||
r.mrkdwn.WriteString(strings.Repeat("> ", r.blockquoteDepth))
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- ThematicBreak ---
|
||||
|
||||
func (r *Renderer) renderThematicBreak(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.flushMrkdwn()
|
||||
r.blocks = append(r.blocks, DividerBlock{Type: "divider"})
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- CodeBlock (indented) ---
|
||||
|
||||
func (r *Renderer) renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
r.flushMrkdwn()
|
||||
|
||||
var buf bytes.Buffer
|
||||
lines := node.Lines()
|
||||
for i := 0; i < lines.Len(); i++ {
|
||||
line := lines.At(i)
|
||||
buf.Write(line.Value(source))
|
||||
}
|
||||
|
||||
text := buf.String()
|
||||
// Remove trailing newline
|
||||
text = strings.TrimRight(text, "\n")
|
||||
// Slack API rejects empty text in rich_text_preformatted elements
|
||||
if text == "" {
|
||||
text = " "
|
||||
}
|
||||
|
||||
elements := []interface{}{
|
||||
RichTextInline{Type: "text", Text: text},
|
||||
}
|
||||
|
||||
r.blocks = append(r.blocks, RichTextBlock{
|
||||
Type: "rich_text",
|
||||
Elements: []interface{}{
|
||||
RichTextPreformatted{
|
||||
Type: "rich_text_preformatted",
|
||||
Elements: elements,
|
||||
Border: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- FencedCodeBlock ---
|
||||
|
||||
func (r *Renderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
r.flushMrkdwn()
|
||||
|
||||
n := node.(*ast.FencedCodeBlock)
|
||||
var buf bytes.Buffer
|
||||
lines := node.Lines()
|
||||
for i := 0; i < lines.Len(); i++ {
|
||||
line := lines.At(i)
|
||||
buf.Write(line.Value(source))
|
||||
}
|
||||
|
||||
text := buf.String()
|
||||
text = strings.TrimRight(text, "\n")
|
||||
// Slack API rejects empty text in rich_text_preformatted elements
|
||||
if text == "" {
|
||||
text = " "
|
||||
}
|
||||
|
||||
elements := []interface{}{
|
||||
RichTextInline{Type: "text", Text: text},
|
||||
}
|
||||
|
||||
// If language is specified, collect it.
|
||||
var language string
|
||||
lang := n.Language(source)
|
||||
if len(lang) > 0 {
|
||||
language = string(lang)
|
||||
}
|
||||
// Add the preformatted block to the blocks slice with the collected language.
|
||||
r.blocks = append(r.blocks, RichTextBlock{
|
||||
Type: "rich_text",
|
||||
Elements: []interface{}{
|
||||
RichTextPreformatted{
|
||||
Type: "rich_text_preformatted",
|
||||
Elements: elements,
|
||||
Border: 0,
|
||||
Language: language,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// --- Blockquote ---
|
||||
|
||||
func (r *Renderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.blockquoteDepth++
|
||||
} else {
|
||||
r.blockquoteDepth--
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- List ---
|
||||
|
||||
func (r *Renderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
list := node.(*ast.List)
|
||||
|
||||
if entering {
|
||||
style := "bullet"
|
||||
if list.IsOrdered() {
|
||||
style = "ordered"
|
||||
}
|
||||
|
||||
if r.listCtx == nil {
|
||||
// Top-level list: flush mrkdwn and create context
|
||||
r.flushMrkdwn()
|
||||
r.listCtx = &listContext{}
|
||||
} else {
|
||||
// Nested list: check if we already have some collected list items that needs to be flushed.
|
||||
// in slack blockkit, list items with different levels of indentation are added as different rich_text_list blocks.
|
||||
if len(r.listCtx.currentItemInlines) > 0 {
|
||||
sec := RichTextBlock{
|
||||
Type: "rich_text_section",
|
||||
Elements: r.listCtx.currentItemInlines,
|
||||
}
|
||||
if r.listCtx.current != nil {
|
||||
r.listCtx.current.Elements = append(r.listCtx.current.Elements, sec)
|
||||
}
|
||||
r.listCtx.currentItemInlines = nil
|
||||
// Increment parent's itemCount
|
||||
if len(r.listCtx.stack) > 0 {
|
||||
r.listCtx.stack[len(r.listCtx.stack)-1].itemCount++
|
||||
}
|
||||
}
|
||||
// Finalize current list to result only if items were collected
|
||||
if r.listCtx.current != nil && len(r.listCtx.current.Elements) > 0 {
|
||||
r.listCtx.result = append(r.listCtx.result, *r.listCtx.current)
|
||||
}
|
||||
}
|
||||
|
||||
// the stack accumulated till this level derives hte indentation
|
||||
// the stack get's collected as we go in more nested levels of list
|
||||
// and as we get our of the nesting we remove the items from the slack
|
||||
indent := len(r.listCtx.stack)
|
||||
r.listCtx.stack = append(r.listCtx.stack, listFrame{
|
||||
style: style,
|
||||
indent: indent,
|
||||
itemCount: 0,
|
||||
})
|
||||
|
||||
newList := &RichTextList{
|
||||
Type: "rich_text_list",
|
||||
Style: style,
|
||||
Indent: indent,
|
||||
Border: 0,
|
||||
Elements: []interface{}{},
|
||||
}
|
||||
|
||||
// Handle ordered list with start > 1
|
||||
if list.IsOrdered() && list.Start > 1 {
|
||||
newList.Offset = list.Start - 1
|
||||
}
|
||||
|
||||
r.listCtx.current = newList
|
||||
|
||||
} else {
|
||||
// Leaving list: finalize current list
|
||||
if r.listCtx.current != nil && len(r.listCtx.current.Elements) > 0 {
|
||||
r.listCtx.result = append(r.listCtx.result, *r.listCtx.current)
|
||||
}
|
||||
|
||||
// Pop stack to so upcoming indentations can be handled correctly.
|
||||
r.listCtx.stack = r.listCtx.stack[:len(r.listCtx.stack)-1]
|
||||
|
||||
if len(r.listCtx.stack) > 0 {
|
||||
// Resume parent: start a new list segment at parent indent/style
|
||||
parent := &r.listCtx.stack[len(r.listCtx.stack)-1]
|
||||
newList := &RichTextList{
|
||||
Type: "rich_text_list",
|
||||
Style: parent.style,
|
||||
Indent: parent.indent,
|
||||
Border: 0,
|
||||
Elements: []interface{}{},
|
||||
}
|
||||
// Set offset for ordered parent continuation
|
||||
if parent.style == "ordered" && parent.itemCount > 0 {
|
||||
newList.Offset = parent.itemCount
|
||||
}
|
||||
r.listCtx.current = newList
|
||||
} else {
|
||||
// Top-level list is done since all stack are popped: build RichTextBlock if non-empty
|
||||
if len(r.listCtx.result) > 0 {
|
||||
elements := make([]interface{}, len(r.listCtx.result))
|
||||
for i, l := range r.listCtx.result {
|
||||
elements[i] = l
|
||||
}
|
||||
r.blocks = append(r.blocks, RichTextBlock{
|
||||
Type: "rich_text",
|
||||
Elements: elements,
|
||||
})
|
||||
}
|
||||
r.listCtx = nil
|
||||
}
|
||||
}
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- ListItem ---
|
||||
|
||||
func (r *Renderer) renderListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.listCtx.currentItemInlines = nil
|
||||
} else {
|
||||
// Only add if there are inlines (might be empty after nested list consumed them)
|
||||
if len(r.listCtx.currentItemInlines) > 0 {
|
||||
sec := RichTextBlock{
|
||||
Type: "rich_text_section",
|
||||
Elements: r.listCtx.currentItemInlines,
|
||||
}
|
||||
if r.listCtx.current != nil {
|
||||
r.listCtx.current.Elements = append(r.listCtx.current.Elements, sec)
|
||||
}
|
||||
r.listCtx.currentItemInlines = nil
|
||||
// Increment parent frame's itemCount
|
||||
if len(r.listCtx.stack) > 0 {
|
||||
r.listCtx.stack[len(r.listCtx.stack)-1].itemCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Table ---
|
||||
// when table is encountered, we flush the markdown and create a table context.
|
||||
// when header row is encountered, we set the isHeader flag to true
|
||||
// when each row ends in renderTableRow we add that row to rows array of table context.
|
||||
// when table cell is encountered, we apply header related styles to the collected inline items,
|
||||
// all inline items are parsed as separate AST items like list item, links, text, etc. are collected
|
||||
// using the addInline function and wrapped in a rich_text_section block.
|
||||
|
||||
func (r *Renderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.flushMrkdwn()
|
||||
r.tableCtx = &tableContext{}
|
||||
} else {
|
||||
// Pad short rows to match header column count for valid Block Kit payload
|
||||
// without this slack blockkit attachment is invalid and the API fails
|
||||
rows := r.tableCtx.rows
|
||||
if len(rows) > 0 {
|
||||
maxCols := len(rows[0])
|
||||
for i, row := range rows {
|
||||
for len(row) < maxCols {
|
||||
emptySec := RichTextBlock{
|
||||
Type: "rich_text_section",
|
||||
Elements: []interface{}{RichTextInline{Type: "text", Text: " "}},
|
||||
}
|
||||
row = append(row, TableCell{
|
||||
Type: "rich_text",
|
||||
Elements: []interface{}{emptySec},
|
||||
})
|
||||
}
|
||||
rows[i] = row
|
||||
}
|
||||
}
|
||||
r.blocks = append(r.blocks, TableBlock{
|
||||
Type: "table",
|
||||
Rows: rows,
|
||||
})
|
||||
r.tableCtx = nil
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderTableHeader(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.tableCtx.isHeader = true
|
||||
r.tableCtx.currentRow = nil
|
||||
} else {
|
||||
r.tableCtx.rows = append(r.tableCtx.rows, r.tableCtx.currentRow)
|
||||
r.tableCtx.currentRow = nil
|
||||
r.tableCtx.isHeader = false
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderTableRow(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.tableCtx.currentRow = nil
|
||||
} else {
|
||||
r.tableCtx.rows = append(r.tableCtx.rows, r.tableCtx.currentRow)
|
||||
r.tableCtx.currentRow = nil
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderTableCell(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.tableCtx.currentCellInlines = nil
|
||||
} else {
|
||||
// If header, make text bold for the collected inline items.
|
||||
if r.tableCtx.isHeader {
|
||||
for i, el := range r.tableCtx.currentCellInlines {
|
||||
if inline, ok := el.(RichTextInline); ok {
|
||||
if inline.Style == nil {
|
||||
inline.Style = &RichTextStyle{Bold: true}
|
||||
} else {
|
||||
inline.Style.Bold = true
|
||||
}
|
||||
r.tableCtx.currentCellInlines[i] = inline
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ensure cell has at least one element for valid Block Kit payload
|
||||
if len(r.tableCtx.currentCellInlines) == 0 {
|
||||
r.tableCtx.currentCellInlines = []interface{}{
|
||||
RichTextInline{Type: "text", Text: " "},
|
||||
}
|
||||
}
|
||||
// All inline items that are collected for a table cell are wrapped in a rich_text_section block.
|
||||
sec := RichTextBlock{
|
||||
Type: "rich_text_section",
|
||||
Elements: r.tableCtx.currentCellInlines,
|
||||
}
|
||||
// The rich_text_section block is wrapped in a rich_text block.
|
||||
cell := TableCell{
|
||||
Type: "rich_text",
|
||||
Elements: []interface{}{sec},
|
||||
}
|
||||
r.tableCtx.currentRow = append(r.tableCtx.currentRow, cell)
|
||||
r.tableCtx.currentCellInlines = nil
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- TaskCheckBox ---
|
||||
|
||||
func (r *Renderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*extensionast.TaskCheckBox)
|
||||
text := "[ ] "
|
||||
if n.IsChecked {
|
||||
text = "[x] "
|
||||
}
|
||||
if r.inRichTextMode() {
|
||||
r.addInline(RichTextInline{Type: "text", Text: text})
|
||||
} else {
|
||||
r.mrkdwn.WriteString(text)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Inline: Text ---
|
||||
|
||||
func (r *Renderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.Text)
|
||||
value := string(n.Segment.Value(source))
|
||||
|
||||
if r.inRichTextMode() {
|
||||
r.addInline(RichTextInline{
|
||||
Type: "text",
|
||||
Text: value,
|
||||
Style: r.currentStyle(),
|
||||
})
|
||||
if n.HardLineBreak() || n.SoftLineBreak() {
|
||||
r.addInline(RichTextInline{Type: "text", Text: "\n"})
|
||||
}
|
||||
} else {
|
||||
r.mrkdwn.WriteString(value)
|
||||
if n.HardLineBreak() || n.SoftLineBreak() {
|
||||
r.mrkdwn.WriteString("\n")
|
||||
if r.blockquoteDepth > 0 {
|
||||
r.mrkdwn.WriteString(strings.Repeat("> ", r.blockquoteDepth))
|
||||
}
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Inline: Emphasis ---
|
||||
|
||||
func (r *Renderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Emphasis)
|
||||
if r.inRichTextMode() {
|
||||
if entering {
|
||||
s := RichTextStyle{}
|
||||
if n.Level == 1 {
|
||||
s.Italic = true
|
||||
} else {
|
||||
s.Bold = true
|
||||
}
|
||||
r.styleStack = append(r.styleStack, s)
|
||||
} else {
|
||||
// the collected style gets used by the rich text element using currentStyle()
|
||||
// so we remove this style from the stack.
|
||||
if len(r.styleStack) > 0 {
|
||||
r.styleStack = r.styleStack[:len(r.styleStack)-1]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if n.Level == 1 {
|
||||
r.mrkdwn.WriteString("_")
|
||||
} else {
|
||||
r.mrkdwn.WriteString("*")
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Inline: Strikethrough ---
|
||||
|
||||
func (r *Renderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if r.inRichTextMode() {
|
||||
if entering {
|
||||
r.styleStack = append(r.styleStack, RichTextStyle{Strike: true})
|
||||
} else {
|
||||
// the collected style gets used by the rich text element using currentStyle()
|
||||
// so we remove this style from the stack.
|
||||
if len(r.styleStack) > 0 {
|
||||
r.styleStack = r.styleStack[:len(r.styleStack)-1]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
r.mrkdwn.WriteString("~")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Inline: CodeSpan ---
|
||||
|
||||
func (r *Renderer) renderCodeSpan(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
if r.inRichTextMode() {
|
||||
// Collect all child text
|
||||
var buf bytes.Buffer
|
||||
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if t, ok := c.(*ast.Text); ok {
|
||||
v := t.Segment.Value(source)
|
||||
if bytes.HasSuffix(v, []byte("\n")) {
|
||||
buf.Write(v[:len(v)-1])
|
||||
buf.WriteByte(' ')
|
||||
} else {
|
||||
buf.Write(v)
|
||||
}
|
||||
} else if s, ok := c.(*ast.String); ok {
|
||||
buf.Write(s.Value)
|
||||
}
|
||||
}
|
||||
style := r.currentStyle()
|
||||
if style == nil {
|
||||
style = &RichTextStyle{Code: true}
|
||||
} else {
|
||||
style.Code = true
|
||||
}
|
||||
r.addInline(RichTextInline{
|
||||
Type: "text",
|
||||
Text: buf.String(),
|
||||
Style: style,
|
||||
})
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
// mrkdwn mode
|
||||
r.mrkdwn.WriteByte('`')
|
||||
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if t, ok := c.(*ast.Text); ok {
|
||||
v := t.Segment.Value(source)
|
||||
if bytes.HasSuffix(v, []byte("\n")) {
|
||||
r.mrkdwn.Write(v[:len(v)-1])
|
||||
r.mrkdwn.WriteByte(' ')
|
||||
} else {
|
||||
r.mrkdwn.Write(v)
|
||||
}
|
||||
} else if s, ok := c.(*ast.String); ok {
|
||||
r.mrkdwn.Write(s.Value)
|
||||
}
|
||||
}
|
||||
r.mrkdwn.WriteByte('`')
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// --- Inline: Link ---
|
||||
|
||||
func (r *Renderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Link)
|
||||
if r.inRichTextMode() {
|
||||
if entering {
|
||||
// Walk the entire subtree to collect text from all descendants,
|
||||
// including nested inline nodes like emphasis, strong, code spans, etc.
|
||||
var buf bytes.Buffer
|
||||
_ = ast.Walk(node, func(child ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering || child == node {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
if t, ok := child.(*ast.Text); ok {
|
||||
buf.Write(t.Segment.Value(source))
|
||||
} else if s, ok := child.(*ast.String); ok {
|
||||
buf.Write(s.Value)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
// Once we've collected the text for the link (given it was present)
|
||||
// let's add the link to the rich text block.
|
||||
r.addInline(RichTextLink{
|
||||
Type: "link",
|
||||
URL: string(n.Destination),
|
||||
Text: buf.String(),
|
||||
Style: r.currentStyle(),
|
||||
})
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
} else {
|
||||
if entering {
|
||||
r.mrkdwn.WriteString("<")
|
||||
r.mrkdwn.Write(n.Destination)
|
||||
r.mrkdwn.WriteString("|")
|
||||
} else {
|
||||
r.mrkdwn.WriteString(">")
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// --- Image (skip) ---
|
||||
|
||||
func (r *Renderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package slackblockkitrenderer
|
||||
|
||||
// SectionBlock represents a Slack section block with mrkdwn text.
|
||||
type SectionBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text *TextObject `json:"text"`
|
||||
}
|
||||
|
||||
// DividerBlock represents a Slack divider block.
|
||||
type DividerBlock struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// RichTextBlock is a container for rich text elements (lists, code blocks, table and cell blocks).
|
||||
type RichTextBlock struct {
|
||||
Type string `json:"type"`
|
||||
Elements []interface{} `json:"elements"`
|
||||
}
|
||||
|
||||
// TableBlock represents a Slack table rendered as a rich_text block with preformatted text.
|
||||
type TableBlock struct {
|
||||
Type string `json:"type"`
|
||||
Rows [][]TableCell `json:"rows"`
|
||||
}
|
||||
|
||||
// TableCell is a cell in a table block.
|
||||
type TableCell struct {
|
||||
Type string `json:"type"`
|
||||
Elements []interface{} `json:"elements"`
|
||||
}
|
||||
|
||||
// TextObject is the text field inside a SectionBlock.
|
||||
type TextObject struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// RichTextList represents an ordered or unordered list.
|
||||
type RichTextList struct {
|
||||
Type string `json:"type"`
|
||||
Style string `json:"style"`
|
||||
Indent int `json:"indent"`
|
||||
Border int `json:"border"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
Elements []interface{} `json:"elements"`
|
||||
}
|
||||
|
||||
// RichTextPreformatted represents a code block.
|
||||
type RichTextPreformatted struct {
|
||||
Type string `json:"type"`
|
||||
Elements []interface{} `json:"elements"`
|
||||
Border int `json:"border"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// RichTextInline represents inline text with optional styling
|
||||
// ex: text inside list, table cell
|
||||
type RichTextInline struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Style *RichTextStyle `json:"style,omitempty"`
|
||||
}
|
||||
|
||||
// RichTextLink represents a link inside rich text
|
||||
// ex: link inside list, table cell
|
||||
type RichTextLink struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Style *RichTextStyle `json:"style,omitempty"`
|
||||
}
|
||||
|
||||
// RichTextStyle holds boolean style flags for inline elements
|
||||
// these bools can toggle different styles for a rich text element at once.
|
||||
type RichTextStyle struct {
|
||||
Bold bool `json:"bold,omitempty"`
|
||||
Italic bool `json:"italic,omitempty"`
|
||||
Strike bool `json:"strike,omitempty"`
|
||||
Code bool `json:"code,omitempty"`
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package slackmrkdwnrenderer
|
||||
|
||||
import (
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
type slackMrkdwn struct{}
|
||||
|
||||
// SlackMrkdwn is a goldmark.Extender that configures the Slack mrkdwn renderer.
|
||||
var SlackMrkdwn = &slackMrkdwn{}
|
||||
|
||||
// Extend implements goldmark.Extender.
|
||||
func (e *slackMrkdwn) Extend(m goldmark.Markdown) {
|
||||
extension.Table.Extend(m)
|
||||
extension.Strikethrough.Extend(m)
|
||||
m.Renderer().AddOptions(
|
||||
renderer.WithNodeRenderers(util.Prioritized(NewRenderer(), 1)),
|
||||
)
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
package slackmrkdwnrenderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
extensionast "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// Renderer renders nodes as Slack mrkdwn.
|
||||
type Renderer struct {
|
||||
prefixes []string
|
||||
}
|
||||
|
||||
// NewRenderer returns a new Renderer with given options.
|
||||
func NewRenderer() renderer.NodeRenderer {
|
||||
return &Renderer{}
|
||||
}
|
||||
|
||||
// RegisterFuncs implements NodeRenderer.RegisterFuncs.
|
||||
func (r *Renderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
// Blocks
|
||||
reg.Register(ast.KindDocument, r.renderDocument)
|
||||
reg.Register(ast.KindHeading, r.renderHeading)
|
||||
reg.Register(ast.KindBlockquote, r.renderBlockquote)
|
||||
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindFencedCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindList, r.renderList)
|
||||
reg.Register(ast.KindListItem, r.renderListItem)
|
||||
reg.Register(ast.KindParagraph, r.renderParagraph)
|
||||
reg.Register(ast.KindTextBlock, r.renderTextBlock)
|
||||
reg.Register(ast.KindRawHTML, r.renderRawHTML)
|
||||
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
|
||||
|
||||
// Inlines
|
||||
reg.Register(ast.KindAutoLink, r.renderAutoLink)
|
||||
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
|
||||
reg.Register(ast.KindEmphasis, r.renderEmphasis)
|
||||
reg.Register(ast.KindImage, r.renderImage)
|
||||
reg.Register(ast.KindLink, r.renderLink)
|
||||
reg.Register(ast.KindText, r.renderText)
|
||||
|
||||
// Extensions
|
||||
reg.Register(extensionast.KindStrikethrough, r.renderStrikethrough)
|
||||
reg.Register(extensionast.KindTable, r.renderTable)
|
||||
}
|
||||
|
||||
func (r *Renderer) writePrefix(w util.BufWriter) {
|
||||
for _, p := range r.prefixes {
|
||||
_, _ = w.WriteString(p)
|
||||
}
|
||||
}
|
||||
|
||||
// writeLineSeparator writes a newline followed by the current prefix.
|
||||
// Used for tight separations (e.g., between list items or text blocks).
|
||||
func (r *Renderer) writeLineSeparator(w util.BufWriter) {
|
||||
_ = w.WriteByte('\n')
|
||||
r.writePrefix(w)
|
||||
}
|
||||
|
||||
// writeBlockSeparator writes a blank line separator between block-level elements,
|
||||
// respecting any active prefixes for proper nesting (e.g., inside blockquotes).
|
||||
func (r *Renderer) writeBlockSeparator(w util.BufWriter) {
|
||||
r.writeLineSeparator(w)
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
|
||||
// separateFromPrevious writes a block separator if the node has a previous sibling.
|
||||
func (r *Renderer) separateFromPrevious(w util.BufWriter, n ast.Node) {
|
||||
if n.PreviousSibling() != nil {
|
||||
r.writeBlockSeparator(w)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Renderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
_, _ = w.WriteString("\n\n")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, node)
|
||||
}
|
||||
_, _ = w.WriteString("*")
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderBlockquote(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
r.prefixes = append(r.prefixes, "> ")
|
||||
_, _ = w.WriteString("> ")
|
||||
} else {
|
||||
r.prefixes = r.prefixes[:len(r.prefixes)-1]
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
// start code block and write code line by line
|
||||
_, _ = w.WriteString("```\n")
|
||||
l := n.Lines().Len()
|
||||
for i := 0; i < l; i++ {
|
||||
line := n.Lines().At(i)
|
||||
v := line.Value(source)
|
||||
_, _ = w.Write(v)
|
||||
}
|
||||
} else {
|
||||
_, _ = w.WriteString("```")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
if node.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
// another line break if not a nested list item and starting another block
|
||||
if node.Parent() == nil || node.Parent().Kind() != ast.KindListItem {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderListItem(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
if n.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
parent := n.Parent().(*ast.List)
|
||||
// compute and write the prefix based on list type and index
|
||||
var prefixStr string
|
||||
if parent.IsOrdered() {
|
||||
index := parent.Start
|
||||
for c := parent.FirstChild(); c != nil && c != n; c = c.NextSibling() {
|
||||
index++
|
||||
}
|
||||
prefixStr = fmt.Sprintf("%d. ", index)
|
||||
} else {
|
||||
prefixStr = "• "
|
||||
}
|
||||
_, _ = w.WriteString(prefixStr)
|
||||
r.prefixes = append(r.prefixes, "\t") // add tab for nested list items
|
||||
} else {
|
||||
r.prefixes = r.prefixes[:len(r.prefixes)-1]
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderParagraph(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderTextBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering && n.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderRawHTML(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
n := n.(*ast.RawHTML)
|
||||
l := n.Segments.Len()
|
||||
for i := 0; i < l; i++ {
|
||||
segment := n.Segments.At(i)
|
||||
_, _ = w.Write(segment.Value(source))
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderThematicBreak(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
_, _ = w.WriteString("---")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.AutoLink)
|
||||
url := string(n.URL(source))
|
||||
label := string(n.Label(source))
|
||||
|
||||
if n.AutoLinkType == ast.AutoLinkEmail && !strings.HasPrefix(strings.ToLower(url), "mailto:") {
|
||||
url = "mailto:" + url
|
||||
}
|
||||
|
||||
if url == label {
|
||||
_, _ = fmt.Fprintf(w, "<%s>", url)
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(w, "<%s|%s>", url, label)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
_ = w.WriteByte('`')
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
segment := c.(*ast.Text).Segment
|
||||
value := segment.Value(source)
|
||||
if bytes.HasSuffix(value, []byte("\n")) { // replace newline with space
|
||||
_, _ = w.Write(value[:len(value)-1])
|
||||
_ = w.WriteByte(' ')
|
||||
} else {
|
||||
_, _ = w.Write(value)
|
||||
}
|
||||
}
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
_ = w.WriteByte('`')
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Emphasis)
|
||||
mark := "_"
|
||||
if n.Level == 2 {
|
||||
mark = "*"
|
||||
}
|
||||
_, _ = w.WriteString(mark)
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Link)
|
||||
if entering {
|
||||
_, _ = w.WriteString("<")
|
||||
_, _ = w.Write(util.URLEscape(n.Destination, true))
|
||||
_, _ = w.WriteString("|")
|
||||
} else {
|
||||
_, _ = w.WriteString(">")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.Image)
|
||||
_, _ = w.WriteString("<")
|
||||
_, _ = w.Write(util.URLEscape(n.Destination, true))
|
||||
_, _ = w.WriteString("|")
|
||||
|
||||
// Write the alt text directly
|
||||
var altBuf bytes.Buffer
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if textNode, ok := c.(*ast.Text); ok {
|
||||
altBuf.Write(textNode.Segment.Value(source))
|
||||
}
|
||||
}
|
||||
_, _ = w.Write(altBuf.Bytes())
|
||||
|
||||
_, _ = w.WriteString(">")
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.Text)
|
||||
segment := n.Segment
|
||||
value := segment.Value(source)
|
||||
_, _ = w.Write(value)
|
||||
if n.HardLineBreak() || n.SoftLineBreak() {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
_, _ = w.WriteString("~")
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
r.separateFromPrevious(w, node)
|
||||
|
||||
// Collect cells and max widths
|
||||
var rows [][]string
|
||||
var colWidths []int
|
||||
|
||||
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if c.Kind() == extensionast.KindTableHeader || c.Kind() == extensionast.KindTableRow {
|
||||
var row []string
|
||||
colIdx := 0
|
||||
for cc := c.FirstChild(); cc != nil; cc = cc.NextSibling() {
|
||||
if cc.Kind() == extensionast.KindTableCell {
|
||||
cellText := extractPlainText(cc, source)
|
||||
row = append(row, cellText)
|
||||
runeLen := utf8.RuneCountInString(cellText)
|
||||
if colIdx >= len(colWidths) {
|
||||
colWidths = append(colWidths, runeLen)
|
||||
} else if runeLen > colWidths[colIdx] {
|
||||
colWidths[colIdx] = runeLen
|
||||
}
|
||||
colIdx++
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
|
||||
// writing table in code block
|
||||
_, _ = w.WriteString("```\n")
|
||||
for i, row := range rows {
|
||||
for colIdx, cellText := range row {
|
||||
width := 0
|
||||
if colIdx < len(colWidths) {
|
||||
width = colWidths[colIdx]
|
||||
}
|
||||
runeLen := utf8.RuneCountInString(cellText)
|
||||
padding := max(0, width-runeLen)
|
||||
|
||||
_, _ = w.WriteString(cellText)
|
||||
_, _ = w.WriteString(strings.Repeat(" ", padding))
|
||||
if colIdx < len(row)-1 {
|
||||
_, _ = w.WriteString(" | ")
|
||||
}
|
||||
}
|
||||
_ = w.WriteByte('\n')
|
||||
|
||||
// Print separator after header
|
||||
if i == 0 {
|
||||
for colIdx := range row {
|
||||
width := 0
|
||||
if colIdx < len(colWidths) {
|
||||
width = colWidths[colIdx]
|
||||
}
|
||||
_, _ = w.WriteString(strings.Repeat("-", width))
|
||||
if colIdx < len(row)-1 {
|
||||
_, _ = w.WriteString("-|-")
|
||||
}
|
||||
}
|
||||
_ = w.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
_, _ = w.WriteString("```")
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// extractPlainText extracts all the text content from the given node.
|
||||
func extractPlainText(n ast.Node, source []byte) string {
|
||||
var buf bytes.Buffer
|
||||
_ = ast.Walk(n, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
if textNode, ok := node.(*ast.Text); ok {
|
||||
buf.Write(textNode.Segment.Value(source))
|
||||
} else if strNode, ok := node.(*ast.String); ok {
|
||||
buf.Write(strNode.Value)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
return strings.TrimSpace(buf.String())
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package slackmrkdwnrenderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
func TestRenderer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Heading with Thematic Break",
|
||||
markdown: "# Title 1\n# Hello World\n---\nthis is sometext",
|
||||
expected: "*Title 1*\n\n*Hello World*\n\n---\n\nthis is sometext\n\n",
|
||||
},
|
||||
{
|
||||
name: "Blockquote",
|
||||
markdown: "> This is a quote\n> It continues",
|
||||
expected: "> This is a quote\n> It continues\n\n",
|
||||
},
|
||||
{
|
||||
name: "Fenced Code Block",
|
||||
markdown: "```go\npackage main\nfunc main() {}\n```",
|
||||
expected: "```\npackage main\nfunc main() {}\n```\n\n",
|
||||
},
|
||||
{
|
||||
name: "Unordered List",
|
||||
markdown: "- item 1\n- item 2\n- item 3",
|
||||
expected: "• item 1\n• item 2\n• item 3\n\n",
|
||||
},
|
||||
{
|
||||
name: "nested unordered list",
|
||||
markdown: "- item 1\n- item 2\n\t- item 2.1\n\t\t- item 2.1.1\n\t\t- item 2.1.2\n\t- item 2.2\n- item 3",
|
||||
expected: "• item 1\n• item 2\n\t• item 2.1\n\t\t• item 2.1.1\n\t\t• item 2.1.2\n\t• item 2.2\n• item 3\n\n",
|
||||
},
|
||||
{
|
||||
name: "Ordered List",
|
||||
markdown: "1. item 1\n2. item 2\n3. item 3",
|
||||
expected: "1. item 1\n2. item 2\n3. item 3\n\n",
|
||||
},
|
||||
{
|
||||
name: "nested ordered list",
|
||||
markdown: "1. item 1\n2. item 2\n\t1. item 2.1\n\t\t1. item 2.1.1\n\t\t2. item 2.1.2\n\t2. item 2.2\n\t3. item 2.3\n3. item 3\n4. item 4",
|
||||
expected: "1. item 1\n2. item 2\n\t1. item 2.1\n\t\t1. item 2.1.1\n\t\t2. item 2.1.2\n\t2. item 2.2\n\t3. item 2.3\n3. item 3\n4. item 4\n\n",
|
||||
},
|
||||
{
|
||||
name: "Links and AutoLinks",
|
||||
markdown: "This is a [link](https://example.com) and an autolink <https://test.com>",
|
||||
expected: "This is a <https://example.com|link> and an autolink <https://test.com>\n\n",
|
||||
},
|
||||
{
|
||||
name: "Images",
|
||||
markdown: "An image ",
|
||||
expected: "An image <https://example.com/image.png|alt text>\n\n",
|
||||
},
|
||||
{
|
||||
name: "Emphasis",
|
||||
markdown: "This is **bold** and *italic* and __bold__ and _italic_",
|
||||
expected: "This is *bold* and _italic_ and *bold* and _italic_\n\n",
|
||||
},
|
||||
{
|
||||
name: "Strikethrough",
|
||||
markdown: "This is ~~strike~~",
|
||||
expected: "This is ~strike~\n\n",
|
||||
},
|
||||
{
|
||||
name: "Code Span",
|
||||
markdown: "This is `inline code` embedded.",
|
||||
expected: "This is `inline code` embedded.\n\n",
|
||||
},
|
||||
{
|
||||
name: "Table",
|
||||
markdown: "Col 1 | Col 2 | Col 3\n--- | --- | ---\nVal 1 | Long Value 2 | 3\nShort | V | 1000",
|
||||
expected: "```\nCol 1 | Col 2 | Col 3\n------|--------------|------\nVal 1 | Long Value 2 | 3 \nShort | V | 1000 \n```\n\n",
|
||||
},
|
||||
{
|
||||
name: "Mixed Nested Lists",
|
||||
markdown: "1. first\n\t- nested bullet\n\t- another bullet\n2. second",
|
||||
expected: "1. first\n\t• nested bullet\n\t• another bullet\n2. second\n\n",
|
||||
},
|
||||
{
|
||||
name: "Email AutoLink",
|
||||
markdown: "<user@example.com>",
|
||||
expected: "<mailto:user@example.com|user@example.com>\n\n",
|
||||
},
|
||||
{
|
||||
name: "No value string parsed as is",
|
||||
markdown: "Service: <no value>",
|
||||
expected: "Service: <no value>\n\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
md := goldmark.New(goldmark.WithExtensions(SlackMrkdwn))
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
|
||||
t.Fatalf("failed to convert: %v", err)
|
||||
}
|
||||
|
||||
// Do exact string matching
|
||||
actual := buf.String()
|
||||
if actual != tt.expected {
|
||||
t.Errorf("\nExpected:\n%q\nGot:\n%q\nRaw Expected:\n%s\nRaw Got:\n%s",
|
||||
tt.expected, actual, tt.expected, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package templatingextensions
|
||||
|
||||
import (
|
||||
"github.com/yuin/goldmark"
|
||||
gast "github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// NoValueHTMLRenderer is a renderer.NodeRenderer implementation that
|
||||
// renders <no value> as escaped visible text instead of omitting it.
|
||||
type NoValueHTMLRenderer struct {
|
||||
html.Config
|
||||
}
|
||||
|
||||
// NewNoValueHTMLRenderer returns a new NoValueHTMLRenderer.
|
||||
func NewNoValueHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
|
||||
r := &NoValueHTMLRenderer{
|
||||
Config: html.NewConfig(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt.SetHTMLOption(&r.Config)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs.
|
||||
func (r *NoValueHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
reg.Register(gast.KindRawHTML, r.renderRawHTML)
|
||||
}
|
||||
|
||||
func (r *NoValueHTMLRenderer) renderRawHTML(
|
||||
w util.BufWriter, source []byte, node gast.Node, entering bool) (gast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return gast.WalkSkipChildren, nil
|
||||
}
|
||||
if r.Unsafe {
|
||||
n := node.(*gast.RawHTML)
|
||||
for i := 0; i < n.Segments.Len(); i++ {
|
||||
segment := n.Segments.At(i)
|
||||
_, _ = w.Write(segment.Value(source))
|
||||
}
|
||||
return gast.WalkSkipChildren, nil
|
||||
}
|
||||
n := node.(*gast.RawHTML)
|
||||
raw := string(n.Segments.Value(source))
|
||||
if raw == "<no value>" {
|
||||
_, _ = w.WriteString("<no value>")
|
||||
return gast.WalkSkipChildren, nil
|
||||
}
|
||||
_, _ = w.WriteString("<!-- raw HTML omitted -->")
|
||||
return gast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
type escapeNoValue struct{}
|
||||
|
||||
// EscapeNoValue is an extension that renders <no value> as visible
|
||||
// escaped text instead of omitting it as raw HTML.
|
||||
var EscapeNoValue = &escapeNoValue{}
|
||||
|
||||
func (e *escapeNoValue) Extend(m goldmark.Markdown) {
|
||||
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
||||
util.Prioritized(NewNoValueHTMLRenderer(), 500),
|
||||
))
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package templatingextensions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
)
|
||||
|
||||
func TestEscapeNoValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "plain text",
|
||||
markdown: "Service: <no value>",
|
||||
expected: "<p>Service: <no value></p>\n",
|
||||
},
|
||||
{
|
||||
name: "inside strong",
|
||||
markdown: "Service: **<no value>**",
|
||||
expected: "<p>Service: <strong><no value></strong></p>\n",
|
||||
},
|
||||
{
|
||||
name: "inside emphasis",
|
||||
markdown: "Service: *<no value>*",
|
||||
expected: "<p>Service: <em><no value></em></p>\n",
|
||||
},
|
||||
{
|
||||
name: "inside strikethrough",
|
||||
markdown: "Service: ~~<no value>~~",
|
||||
expected: "<p>Service: <del><no value></del></p>\n",
|
||||
},
|
||||
{
|
||||
name: "real html still omitted",
|
||||
markdown: "hello <div>world</div>",
|
||||
expected: "<p>hello <!-- raw HTML omitted -->world<!-- raw HTML omitted --></p>\n",
|
||||
},
|
||||
{
|
||||
name: "inside heading",
|
||||
markdown: "# Title <no value>",
|
||||
expected: "<h1>Title <no value></h1>\n",
|
||||
},
|
||||
{
|
||||
name: "inside list item",
|
||||
markdown: "- item <no value>",
|
||||
expected: "<ul>\n<li>item <no value></li>\n</ul>\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gm := goldmark.New(goldmark.WithExtensions(EscapeNoValue, extension.Strikethrough))
|
||||
var buf bytes.Buffer
|
||||
if err := gm.Convert([]byte(tt.markdown), &buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if buf.String() != tt.expected {
|
||||
t.Errorf("expected:\n%s\ngot:\n%s", tt.expected, buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,22 +11,19 @@ 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 "+" 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) {
|
||||
text.Funcs(AdditionalFuncMap())
|
||||
html.Funcs(AdditionalFuncMap())
|
||||
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 "+" if tmplhtml.HTML is not used.
|
||||
"urlescape": func(value string) tmplhtml.HTML {
|
||||
return tmplhtml.HTML(url.QueryEscape(value))
|
||||
},
|
||||
}
|
||||
text.Funcs(funcs)
|
||||
html.Funcs(funcs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
)
|
||||
|
||||
// privateAnnotationPrefix marks annotations the rule manager attaches for
|
||||
// alertmanager-internal use (raw template strings, threshold metadata, link
|
||||
// targets). Annotations whose key starts with this prefix are stripped from
|
||||
// any surface that ends up visible to a template author or a notification
|
||||
// recipient; the alertmanager reads them off the raw alert before stripping.
|
||||
const privateAnnotationPrefix = "_"
|
||||
|
||||
// IsPrivateAnnotation reports whether an annotation key is considered
|
||||
// private — i.e. internal to alertmanager and should not be rendered in
|
||||
// notifications.
|
||||
func IsPrivateAnnotation(key string) bool {
|
||||
return strings.HasPrefix(key, privateAnnotationPrefix)
|
||||
}
|
||||
|
||||
// FilterPublicAnnotations returns a copy of kv with all private-prefixed
|
||||
// keys removed. Callers that expose annotations to templates or notification
|
||||
// payloads should pass them through this first.
|
||||
func FilterPublicAnnotations(kv template.KV) template.KV {
|
||||
out := make(template.KV, len(kv))
|
||||
for k, v := range kv {
|
||||
if IsPrivateAnnotation(k) {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ExpandRequest carries the title/body templates and their defaults handed to
|
||||
// Templater.Expand. Default templates are used when the custom templates
|
||||
// expand to empty strings.
|
||||
type ExpandRequest struct {
|
||||
TitleTemplate string
|
||||
BodyTemplate string
|
||||
DefaultTitleTemplate string
|
||||
DefaultBodyTemplate string
|
||||
}
|
||||
|
||||
// ExpandResult is the rendered output of Templater.Expand.
|
||||
type ExpandResult struct {
|
||||
// Title is the expanded notification title (plain text).
|
||||
Title string
|
||||
// Body is the expanded notification body, one entry per input alert. The
|
||||
// body template is applied per-alert and concatenated by the caller.
|
||||
Body []string
|
||||
// IsDefaultBody is true when Body came from the default template (no
|
||||
// user-authored body was supplied), false when a custom template was used.
|
||||
IsDefaultBody bool
|
||||
// MissingVars is the union of $-references in the title and body templates
|
||||
// that did not resolve to any known field. Surfaced for preview warnings;
|
||||
// at runtime these render as "<no value>".
|
||||
MissingVars []string
|
||||
// NotificationData is the aggregate data that fed the title template,
|
||||
// exposed so callers can reuse it when rendering a channel-specific layout
|
||||
// (e.g. the email HTML shell) without rebuilding it from the alerts.
|
||||
NotificationData *NotificationTemplateData
|
||||
}
|
||||
|
||||
// AlertData holds per-alert data used when expanding body templates.
|
||||
//
|
||||
// Field paths follow OpenTelemetry-style dotted namespaces (via mapstructure
|
||||
// tags) so user templates can reference paths like $alert.is_firing,
|
||||
// $rule.threshold.value, or $log.url. JSON tags use camelCase for the wire
|
||||
// format.
|
||||
type AlertData struct {
|
||||
Alert AlertInfo `json:"alert" mapstructure:"alert"`
|
||||
Rule RuleInfo `json:"rule" mapstructure:"rule"`
|
||||
Log LinkInfo `json:"log" mapstructure:"log"`
|
||||
Trace LinkInfo `json:"trace" mapstructure:"trace"`
|
||||
Labels template.KV `json:"labels" mapstructure:"labels"`
|
||||
Annotations template.KV `json:"annotations" mapstructure:"annotations"`
|
||||
}
|
||||
|
||||
// AlertInfo holds the per-alert state and timing data.
|
||||
type AlertInfo struct {
|
||||
Status string `json:"status" mapstructure:"status"`
|
||||
Receiver string `json:"receiver" mapstructure:"receiver"`
|
||||
Value string `json:"value" mapstructure:"value"`
|
||||
StartsAt time.Time `json:"startsAt" mapstructure:"starts_at"`
|
||||
EndsAt time.Time `json:"endsAt" mapstructure:"ends_at"`
|
||||
GeneratorURL string `json:"generatorURL" mapstructure:"generator_url"`
|
||||
Fingerprint string `json:"fingerprint" mapstructure:"fingerprint"`
|
||||
IsFiring bool `json:"isFiring" mapstructure:"is_firing"`
|
||||
IsResolved bool `json:"isResolved" mapstructure:"is_resolved"`
|
||||
IsMissingData bool `json:"isMissingData" mapstructure:"is_missing_data"`
|
||||
IsRecovering bool `json:"isRecovering" mapstructure:"is_recovering"`
|
||||
}
|
||||
|
||||
// RuleInfo holds the rule metadata extracted from well-known labels and
|
||||
// annotations.
|
||||
type RuleInfo struct {
|
||||
Name string `json:"name" mapstructure:"name"`
|
||||
ID string `json:"id" mapstructure:"id"`
|
||||
URL string `json:"url" mapstructure:"url"`
|
||||
Severity string `json:"severity" mapstructure:"severity"`
|
||||
MatchType string `json:"matchType" mapstructure:"match_type"`
|
||||
Threshold Threshold `json:"threshold" mapstructure:"threshold"`
|
||||
}
|
||||
|
||||
// Threshold describes the breach condition.
|
||||
type Threshold struct {
|
||||
Value string `json:"value" mapstructure:"value"`
|
||||
Op string `json:"op" mapstructure:"op"`
|
||||
}
|
||||
|
||||
// LinkInfo groups a single URL so templates can reference $log.url and
|
||||
// $trace.url uniformly.
|
||||
type LinkInfo struct {
|
||||
URL string `json:"url" mapstructure:"url"`
|
||||
}
|
||||
|
||||
// NotificationTemplateData is the top-level data struct provided to title
|
||||
// templates, representing the aggregate of a grouped notification.
|
||||
type NotificationTemplateData struct {
|
||||
Alert NotificationAlert `json:"alert" mapstructure:"alert"`
|
||||
Rule RuleInfo `json:"rule" mapstructure:"rule"`
|
||||
Labels template.KV `json:"labels" mapstructure:"labels"`
|
||||
Annotations template.KV `json:"annotations" mapstructure:"annotations"`
|
||||
CommonLabels template.KV `json:"commonLabels" mapstructure:"common_labels"`
|
||||
CommonAnnotations template.KV `json:"commonAnnotations" mapstructure:"common_annotations"`
|
||||
GroupLabels template.KV `json:"groupLabels" mapstructure:"group_labels"`
|
||||
ExternalURL string `json:"externalURL" mapstructure:"external_url"`
|
||||
|
||||
// Per-alert data kept for body expansion; not exposed to the title template.
|
||||
Alerts []AlertData `json:"-" mapstructure:"-"`
|
||||
}
|
||||
|
||||
// NotificationAlert holds the aggregate alert fields available to title
|
||||
// templates (counts, overall status, receiver).
|
||||
type NotificationAlert struct {
|
||||
Receiver string `json:"receiver" mapstructure:"receiver"`
|
||||
Status string `json:"status" mapstructure:"status"`
|
||||
TotalFiring int `json:"totalFiring" mapstructure:"total_firing"`
|
||||
TotalResolved int `json:"totalResolved" mapstructure:"total_resolved"`
|
||||
}
|
||||
@@ -9,33 +9,4 @@ const (
|
||||
LabelSeverityName = "severity"
|
||||
LabelLastSeen = "lastSeen"
|
||||
LabelRuleID = "ruleId"
|
||||
LabelRuleSource = "ruleSource"
|
||||
LabelNoData = "nodata"
|
||||
LabelTestAlert = "testalert"
|
||||
LabelAlertName = "alertname"
|
||||
LabelIsRecovering = "is_recovering"
|
||||
)
|
||||
|
||||
// Annotations set by the rule manager and consumed by the alertmanager
|
||||
// templating layer.
|
||||
//
|
||||
// Those prefixed with "_" are private: they're stripped from
|
||||
// notification-visible surfaces by alertmanagertypes.FilterPublicAnnotations
|
||||
// before rendering. Only the raw template strings are private — echoing
|
||||
// them into a notification is circular and never useful.
|
||||
//
|
||||
// The rest are public: they describe the firing alert (the breached value,
|
||||
// the configured threshold, the comparator, the match type, and deep links
|
||||
// to relevant logs/traces) and users may reference them directly as
|
||||
// {{ .Annotations.value }}, {{ .Annotations.threshold.value }}, etc. in
|
||||
// their channel templates.
|
||||
const (
|
||||
AnnotationTitleTemplate = "_title_template"
|
||||
AnnotationBodyTemplate = "_body_template"
|
||||
AnnotationRelatedLogs = "related_logs"
|
||||
AnnotationRelatedTraces = "related_traces"
|
||||
AnnotationValue = "value"
|
||||
AnnotationThresholdValue = "threshold.value"
|
||||
AnnotationCompareOp = "compare_op"
|
||||
AnnotationMatchType = "match_type"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user