Repository: moira-alert/moira
Pull request #1084 (2024-09-13 11:38:53)

refactor: change alert parts calculations
# Change the rule for calculating alert parts if they are too big 

There is no limits on tag length and tags count, so users can use a lot of long tags. In such case the function that resizes alert parts  got bad data, which results to wrong calculations and panic in notifier.

This PR offers new function for resizing alert parts. Now the space is distributed between tags, trigger description and events block. If possible, space is given to description and events.

This PR affects the following senders:
- mattermost
- slack
- telegram 


codecov-commenter: :warning: Please install the !['codecov app svg image'](https://github.com/codecov/engineering-team/assets/152432831/e90313f4-9d3a-4b63-8b54-cfe14e7ec20d) to ensure uploads and comments are reliably processed by Codecov.

## [Codecov](https://app.codecov.io/gh/moira-alert/moira/pull/1084?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira) Report
Attention: Patch coverage is `85.71429%` with `11 lines` in your changes missing coverage. Please review.
> Project coverage is 52.64%. Comparing base [(`0197720`)](https://app.codecov.io/gh/moira-alert/moira/commit/0197720a100fc900a5fb5dc72bee7f42e8366acc?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira) to head [(`3493326`)](https://app.codecov.io/gh/moira-alert/moira/commit/34933268307870d604aa27f6b34ecf4d985274ff?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira).
> Report is 5 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/moira-alert/moira/pull/1084?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira) | Patch % | Lines |
|---|---|---|
| [senders/calc\_message\_parts.go](https://app.codecov.io/gh/moira-alert/moira/pull/1084?src=pr&el=tree&filepath=senders%2Fcalc_message_parts.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira#diff-c2VuZGVycy9jYWxjX21lc3NhZ2VfcGFydHMuZ28=) | 82.75% | [4 Missing and 1 partial :warning: ](https://app.codecov.io/gh/moira-alert/moira/pull/1084?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira) |
| [senders/msgformat/highlighter.go](https://app.codecov.io/gh/moira-alert/moira/pull/1084?src=pr&el=tree&filepath=senders%2Fmsgformat%2Fhighlighter.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira#diff-c2VuZGVycy9tc2dmb3JtYXQvaGlnaGxpZ2h0ZXIuZ28=) | 70.00% | [2 Missing and 1 partial :warning: ](https://app.codecov.io/gh/moira-alert/moira/pull/1084?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira) |
| [senders/telegram/message\_formatter.go](https://app.codecov.io/gh/moira-alert/moira/pull/1084?src=pr&el=tree&filepath=senders%2Ftelegram%2Fmessage_formatter.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira#diff-c2VuZGVycy90ZWxlZ3JhbS9tZXNzYWdlX2Zvcm1hdHRlci5nbw==) | 85.71% | [2 Missing and 1 partial :warning: ](https://app.codecov.io/gh/moira-alert/moira/pull/1084?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira) |

:exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##           master    #1084       +/-   ##
===========================================
- Coverage   66.85%   52.64%   -14.21%     
===========================================
  Files         219      287       +68     
  Lines       12778    16593     +3815     
===========================================
+ Hits         8543     8736      +193     
- Misses       3684     7303     +3619     
- Partials      551      554        +3     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/moira-alert/moira/pull/1084?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira).


Patch:
diff --git a/senders/calc_message_parts.go b/senders/calc_message_parts.go
index bebb2c92..ee4aeaf2 100644
--- a/senders/calc_message_parts.go
+++ b/senders/calc_message_parts.go
@@ -6,11 +6,81 @@ func CalculateMessagePartsLength(maxChars, descLen, eventsLen int) (descNewLen i
 	if descLen+eventsLen <= maxChars {
 		return descLen, eventsLen
 	}
-	if descLen > maxChars/2 && eventsLen <= maxChars/2 {
+
+	halfOfMaxChars := maxChars / partsCountForMessageWithDescAndEvents
+
+	if descLen > halfOfMaxChars && eventsLen <= halfOfMaxChars {
 		return maxChars - eventsLen - 10, eventsLen
 	}
-	if eventsLen > maxChars/2 && descLen <= maxChars/2 {
+
+	if eventsLen > halfOfMaxChars && descLen <= halfOfMaxChars {
 		return descLen, maxChars - descLen
 	}
-	return maxChars/2 - 10, maxChars / 2
+
+	return halfOfMaxChars - 10, halfOfMaxChars
+}
+
+const (
+	// partsCountForMessageWithDescAndEvents is used then you need to split given maxChars fairly by half
+	// between description and events.
+	partsCountForMessageWithDescAndEvents = 2
+	// partsCountForMessageWithTagsDescAndEvents is used then you need to split given maxChars fairly by three parts
+	// between tags, description and events.
+	partsCountForMessageWithTagsDescAndEvents = 3
+)
+
+// CalculateMessagePartsBetweenTagsDescEvents calculates and returns the length of tags, description and events string
+// in order to fit the max chars limit.
+func CalculateMessagePartsBetweenTagsDescEvents(maxChars, tagsLen, descLen, eventsLen int) (tagsNewLen int, descNewLen int, eventsNewLen int) { // nolint
+	if maxChars <= 0 {
+		return 0, 0, 0
+	}
+
+	if tagsLen+descLen+eventsLen <= maxChars {
+		return tagsLen, descLen, eventsLen
+	}
+
+	fairMaxLen := maxChars / partsCountForMessageWithTagsDescAndEvents
+
+	switch {
+	case tagsLen > fairMaxLen && descLen <= fairMaxLen && eventsLen <= fairMaxLen:
+		// give free space to tags
+		tagsNewLen = maxChars - descLen - eventsLen
+
+		return min(tagsNewLen, tagsLen), descLen, eventsLen
+	case tagsLen <= fairMaxLen && descLen > fairMaxLen && eventsLen <= fairMaxLen:
+		// give free space to description
+		descNewLen = maxChars - tagsLen - eventsLen
+
+		return tagsLen, min(descNewLen, descLen), eventsLen
+	case tagsLen <= fairMaxLen && descLen <= fairMaxLen && eventsLen > fairMaxLen:
+		// give free space to events
+		eventsNewLen = maxChars - tagsLen - descLen
+
+		return tagsLen, descLen, min(eventsNewLen, eventsLen)
+	case tagsLen > fairMaxLen && descLen > fairMaxLen && eventsLen <= fairMaxLen:
+		// description is more important than tags
+		tagsNewLen = fairMaxLen
+		descNewLen = maxChars - tagsNewLen - eventsLen
+
+		return tagsNewLen, min(descNewLen, descLen), eventsLen
+	case tagsLen > fairMaxLen && descLen <= fairMaxLen && eventsLen > fairMaxLen:
+		// events are more important than tags
+		tagsNewLen = fairMaxLen
+		eventsNewLen = maxChars - tagsNewLen - descLen
+
+		return tagsNewLen, descLen, min(eventsNewLen, eventsLen)
+	case tagsLen <= fairMaxLen && descLen > fairMaxLen && eventsLen > fairMaxLen:
+		// split free space from tags fairly between description and events
+		spaceFromTags := fairMaxLen - tagsLen
+		halfOfSpaceFromTags := spaceFromTags / partsCountForMessageWithDescAndEvents
+
+		descNewLen = fairMaxLen + halfOfSpaceFromTags
+		eventsNewLen = fairMaxLen + halfOfSpaceFromTags
+
+		return tagsLen, min(descNewLen, descLen), min(eventsNewLen, eventsLen)
+	default:
+		// all 3 blocks have length greater than maxChars/3, so split space fairly
+		return fairMaxLen, fairMaxLen, fairMaxLen
+	}
 }
diff --git a/senders/msgformat/defaults.go b/senders/msgformat/defaults.go
new file mode 100644
index 00000000..ad485a34
--- /dev/null
+++ b/senders/msgformat/defaults.go
@@ -0,0 +1,42 @@
+package msgformat
+
+import "unicode/utf8"
+
+// DefaultDescriptionCutter cuts description, so len(newDesc) <= maxSize. Ensure that len(desc) >= maxSize and
+// maxSize >= len("...\n").
+func DefaultDescriptionCutter(desc string, maxSize int) string {
+	suffix := "...\n"
+	return desc[:maxSize-len(suffix)] + suffix
+}
+
+var bracketsLen = utf8.RuneCountInString("[]")
+
+// DefaultTagsLimiter cuts and formats tags to fit maxSize. There will be no tag parts, for example:
+//
+// if we have
+//
+//	tags = []string{"tag1", "tag2}
+//	maxSize = 8
+//
+// so call DefaultTagsLimiter(tags, maxSize) will return " [tag1]".
+func DefaultTagsLimiter(tags []string, maxSize int) string {
+	tagsStr := " "
+	lenTagsStr := utf8.RuneCountInString(tagsStr)
+
+	for i := range tags {
+		lenTag := utf8.RuneCountInString(tags[i]) + bracketsLen
+
+		if lenTagsStr+lenTag > maxSize {
+			break
+		}
+
+		tagsStr += "[" + tags[i] + "]"
+		lenTagsStr += lenTag
+	}
+
+	if tagsStr == " " {
+		return ""
+	}
+
+	return tagsStr
+}
diff --git a/senders/msgformat/highlighter.go b/senders/msgformat/highlighter.go
index 9a78403e..2e21387f 100644
--- a/senders/msgformat/highlighter.go
+++ b/senders/msgformat/highlighter.go
@@ -2,7 +2,6 @@ package msgformat
 
 import (
 	"fmt"
-	"strings"
 	"time"
 	"unicode/utf8"
 
@@ -27,6 +26,10 @@ type EventStringFormatter func(event moira.NotificationEvent, location *time.Loc
 // DescriptionCutter cuts the given description to fit max size.
 type DescriptionCutter func(desc string, maxSize int) string
 
+// TagsLimiter should prepare tags string in format like " [tag1][tag2][tag3]",
+// but characters count should be less than or equal to maxSize.
+type TagsLimiter func(tags []string, maxSize int) string
+
 // highlightSyntaxFormatter formats message by using functions, emojis and some other highlight patterns.
 type highlightSyntaxFormatter struct {
 	// emojiGetter used in titles for better description.
@@ -74,35 +77,44 @@ func NewHighlightSyntaxFormatter(
 
 // Format formats message using given params and formatter functions.
 func (formatter *highlightSyntaxFormatter) Format(params MessageFormatterParams) string {
-	var message strings.Builder
 	state := params.Events.GetCurrentState(params.Throttled)
 	emoji := formatter.emojiGetter.GetStateEmoji(state)
 
 	title := formatter.buildTitle(params.Events, params.Trigger, emoji, params.Throttled)
-	titleLen := utf8.RuneCountInString(title)
+	titleLen := utf8.RuneCountInString(title) + len("\n")
+
+	var tags string
+	var tagsLen int
+
+	triggerTags := params.Trigger.GetTags()
+	if len(triggerTags) != 0 {
+		tags = " " + triggerTags
+		tagsLen = utf8.RuneCountInString(tags)
+	}
 
 	desc := formatter.descriptionFormatter(params.Trigger)
 	descLen := utf8.RuneCountInString(desc)
 
-	eventsString := formatter.buildEventsString(params.Events, -1, params.Throttled)
-	eventsStringLen := utf8.RuneCountInString(eventsString)
+	events := formatter.buildEventsString(params.Events, -1, params.Throttled)
+	eventsStringLen := utf8.RuneCountInString(events)
 
 	charsLeftAfterTitle := params.MessageMaxChars - titleLen
 
-	descNewLen, eventsNewLen := senders.CalculateMessagePartsLength(charsLeftAfterTitle, descLen, eventsStringLen)
+	tagsNewLen, descNewLen, eventsNewLen := senders.CalculateMessagePartsBetweenTagsDescEvents(charsLeftAfterTitle, tagsLen, descLen, eventsStringLen)
+	if tagsNewLen != tagsLen {
+		tags = DefaultTagsLimiter(params.Trigger.Tags, tagsNewLen)
+	}
 	if descLen != descNewLen {
 		desc = formatter.descriptionCutter(desc, descNewLen)
 	}
 	if eventsNewLen != eventsStringLen {
-		eventsString = formatter.buildEventsString(params.Events, eventsNewLen, params.Throttled)
+		events = formatter.buildEventsString(params.Events, eventsNewLen, params.Throttled)
 	}
 
-	message.WriteString(title)
-	message.WriteString(desc)
-	message.WriteString(eventsString)
-	return message.String()
+	return title + tags + "\n" + desc + events
 }
 
+// buildTitle builds title string for alert (emoji, trigger state, trigger name with link).
 func (formatter *highlightSyntaxFormatter) buildTitle(events moira.NotificationEvents, trigger moira.TriggerData, emoji string, throttled bool) string {
 	state := events.GetCurrentState(throttled)
 	title := ""
@@ -118,12 +130,6 @@ func (formatter *highlightSyntaxFormatter) buildTitle(events moira.NotificationE
 		title += " " + trigger.Name
 	}
 
-	tags := trigger.GetTags()
-	if tags != "" {
-		title += " " + tags
-	}
-
-	title += "\n"
 	return title
 }
 
diff --git a/senders/msgformat/msgformat.go b/senders/msgformat/msgformat.go
index 72a6cdb3..42b4aaf5 100644
--- a/senders/msgformat/msgformat.go
+++ b/senders/msgformat/msgformat.go
@@ -21,10 +21,3 @@ type MessageFormatterParams struct {
 	MessageMaxChars int
 	Throttled       bool
 }
-
-// DefaultDescriptionCutter cuts description, so len(newDesc) <= maxSize. Ensure that len(desc) >= maxSize and
-// maxSize >= len("...\n").
-func DefaultDescriptionCutter(desc string, maxSize int) string {
-	suffix := "...\n"
-	return desc[:maxSize-len(suffix)] + suffix
-}
diff --git a/senders/telegram/message_formatter.go b/senders/telegram/message_formatter.go
index 8059a223..a453ceaa 100644
--- a/senders/telegram/message_formatter.go
+++ b/senders/telegram/message_formatter.go
@@ -46,33 +46,57 @@ func NewTelegramMessageFormatter(
 
 // Format formats message using given params and formatter functions.
 func (formatter *messageFormatter) Format(params msgformat.MessageFormatterParams) string {
+	params.Trigger.Tags = htmlEscapeTags(params.Trigger.Tags)
+
 	state := params.Events.GetCurrentState(params.Throttled)
 	emoji := formatter.emojiGetter.GetStateEmoji(state)
 
 	title := formatter.buildTitle(params.Events, params.Trigger, emoji, params.Throttled)
-	titleLen := calcRunesCountWithoutHTML([]rune(title))
+	titleLen := calcRunesCountWithoutHTML(title) + len("\n")
+
+	var tags string
+	var tagsLen int
+
+	triggerTags := params.Trigger.GetTags()
+	if len(triggerTags) != 0 {
+		tags = " " + triggerTags
+		tagsLen = calcRunesCountWithoutHTML(tags)
+	}
 
 	desc := descriptionFormatter(params.Trigger)
-	descLen := calcRunesCountWithoutHTML([]rune(desc))
+	descLen := calcRunesCountWithoutHTML(desc)
 
-	eventsString := formatter.buildEventsString(params.Events, -1, params.Throttled)
-	eventsStringLen := calcRunesCountWithoutHTML([]rune(eventsString))
+	events := formatter.buildEventsString(params.Events, -1, params.Throttled)
+	eventsStringLen := calcRunesCountWithoutHTML(events)
 
-	descNewLen, eventsNewLen := senders.CalculateMessagePartsLength(params.MessageMaxChars-titleLen, descLen, eventsStringLen)
+	tagsNewLen, descNewLen, eventsNewLen := senders.CalculateMessagePartsBetweenTagsDescEvents(params.MessageMaxChars-titleLen, tagsLen, descLen, eventsStringLen)
+	if tagsLen != tagsNewLen {
+		tags = msgformat.DefaultTagsLimiter(params.Trigger.Tags, tagsNewLen)
+	}
 	if descLen != descNewLen {
 		desc = descriptionCutter(desc, descNewLen)
 	}
 	if eventsStringLen != eventsNewLen {
-		eventsString = formatter.buildEventsString(params.Events, eventsNewLen, params.Throttled)
+		events = formatter.buildEventsString(params.Events, eventsNewLen, params.Throttled)
+	}
+
+	return title + tags + "\n" + desc + events
+}
+
+func htmlEscapeTags(tags []string) []string {
+	escapedTags := make([]string, 0, len(tags))
+
+	for _, tag := range tags {
+		escapedTags = append(escapedTags, html.EscapeString(tag))
 	}
 
-	return title + desc + eventsString
+	return escapedTags
 }
 
 // calcRunesCountWithoutHTML is used for calculating symbols in text without html tags. Special symbols
 // like `&gt;`, `&lt;` etc. are counted not as one symbol, for example, len([]rune("&gt;")).
 // This precision is enough for us to evaluate size of message.
-func calcRunesCountWithoutHTML(htmlText []rune) int {
+func calcRunesCountWithoutHTML(htmlText string) int {
 	textLen := 0
 	isTag := false
 
@@ -109,12 +133,6 @@ func (formatter *messageFormatter) buildTitle(events moira.NotificationEvents, t
 		title += " " + trigger.Name
 	}
 
-	tags := trigger.GetTags()
-	if tags != "" {
-		title += " " + tags
-	}
-
-	title += "\n"
 	return title
 }
 
@@ -125,7 +143,7 @@ var throttleMsg = fmt.Sprintf("\nPlease, %s to generate less events.", boldForma
 func (formatter *messageFormatter) buildEventsString(events moira.NotificationEvents, charsForEvents int, throttled bool) string {
 	charsForThrottleMsg := 0
 	if throttled {
-		charsForThrottleMsg = calcRunesCountWithoutHTML([]rune(throttleMsg))
+		charsForThrottleMsg = calcRunesCountWithoutHTML(throttleMsg)
 	}
 	charsLeftForEvents := charsForEvents - charsForThrottleMsg
 
@@ -144,7 +162,7 @@ func (formatter *messageFormatter) buildEventsString(events moira.NotificationEv
 
 		tailString = fmt.Sprintf("\n...and %d more events.", len(events)-eventsPrinted)
 		tailStringLen := len("\n") + utf8.RuneCountInString(tailString)
-		lineLen := calcRunesCountWithoutHTML([]rune(line))
+		lineLen := calcRunesCountWithoutHTML(line)
 
 		if charsForEvents >= 0 && eventsStringLen+lineLen > charsLeftForEvents-tailStringLen {
 			eventsLenLimitReached = true


Test patch:
diff --git a/senders/calc_message_parts_test.go b/senders/calc_message_parts_test.go
index 19882a9b..82a17d60 100644
--- a/senders/calc_message_parts_test.go
+++ b/senders/calc_message_parts_test.go
@@ -1,6 +1,7 @@
 package senders
 
 import (
+	"fmt"
 	"testing"
 
 	. "github.com/smartystreets/goconvey/convey"
@@ -33,3 +34,179 @@ func TestCalculateMessagePartsLength(t *testing.T) {
 		})
 	})
 }
+
+func TestCalculateMessagePartsBetweenTagsDescEvents(t *testing.T) {
+	Convey("Message parts calculating test (for tags, desc, events)", t, func() {
+		type given struct {
+			maxChars  int
+			tagsLen   int
+			descLen   int
+			eventsLen int
+		}
+
+		type expected struct {
+			tagsLen   int
+			descLen   int
+			eventsLen int
+		}
+
+		type testcase struct {
+			given       given
+			expected    expected
+			description string
+		}
+
+		cases := []testcase{
+			{
+				description: "with maxChars < 0",
+				given: given{
+					maxChars:  -1,
+					tagsLen:   10,
+					descLen:   10,
+					eventsLen: 10,
+				},
+				expected: expected{
+					tagsLen:   0,
+					descLen:   0,
+					eventsLen: 0,
+				},
+			},
+			{
+				description: "with tagsLen + descLen + eventsLen <= maxChars",
+				given: given{
+					maxChars:  100,
+					tagsLen:   20,
+					descLen:   50,
+					eventsLen: 30,
+				},
+				expected: expected{
+					tagsLen:   20,
+					descLen:   50,
+					eventsLen: 30,
+				},
+			},
+			{
+				description: "with tagsLen > maxChars/3, descLen and eventsLen <= maxChars/3",
+				given: given{
+					maxChars:  100,
+					tagsLen:   50,
+					descLen:   30,
+					eventsLen: 30,
+				},
+				expected: expected{
+					tagsLen:   40,
+					descLen:   30,
+					eventsLen: 30,
+				},
+			},
+			{
+				description: "with descLen > maxChars/3, tagsLen and eventsLen <= maxChars/3",
+				given: given{
+					maxChars:  100,
+					tagsLen:   30,
+					descLen:   50,
+					eventsLen: 31,
+				},
+				expected: expected{
+					tagsLen:   30,
+					descLen:   39,
+					eventsLen: 31,
+				},
+			},
+			{
+				description: "with eventsLen > maxChars/3, tagsLen and descLen <= maxChars/3",
+				given: given{
+					maxChars:  100,
+					tagsLen:   33,
+					descLen:   33,
+					eventsLen: 61,
+				},
+				expected: expected{
+					tagsLen:   33,
+					descLen:   33,
+					eventsLen: 34,
+				},
+			},
+			{
+				description: "with tagsLen and descLen > maxChars/3, eventsLen <= maxChars/3",
+				given: given{
+					maxChars:  100,
+					tagsLen:   55,
+					descLen:   46,
+					eventsLen: 31,
+				},
+				expected: expected{
+					tagsLen:   33,
+					descLen:   36,
+					eventsLen: 31,
+				},
+			},
+			{
+				description: "with tagsLen and eventsLen > maxChars/3, descLen <= maxChars/3",
+				given: given{
+					maxChars:  100,
+					tagsLen:   55,
+					descLen:   33,
+					eventsLen: 100,
+				},
+				expected: expected{
+					tagsLen:   33,
+					descLen:   33,
+					eventsLen: 34,
+				},
+			},
+			{
+				description: "with descLen and eventsLen > maxChars/3, tagsLen <= maxChars/3",
+				given: given{
+					maxChars:  100,
+					tagsLen:   29,
+					descLen:   56,
+					eventsLen: 100,
+				},
+				expected: expected{
+					tagsLen:   29,
+					descLen:   35,
+					eventsLen: 35,
+				},
+			},
+			{
+				description: "with tagsLen, descLen and eventsLen > maxChars/3",
+				given: given{
+					maxChars:  100,
+					tagsLen:   55,
+					descLen:   40,
+					eventsLen: 100,
+				},
+				expected: expected{
+					tagsLen:   33,
+					descLen:   33,
+					eventsLen: 33,
+				},
+			},
+			{
+				description: "with tagsLen, descLen > maxChars/3, eventsLen <= maxChars/3 and maxChars - maxChars/3 - eventsLen > descLen",
+				given: given{
+					maxChars:  100,
+					tagsLen:   100,
+					descLen:   34,
+					eventsLen: 20,
+				},
+				expected: expected{
+					tagsLen:   33,
+					descLen:   34,
+					eventsLen: 20,
+				},
+			},
+		}
+
+		for i, c := range cases {
+			Convey(fmt.Sprintf("case %d: %s", i+1, c.description), func() {
+				tagsNewLen, descNewLen, eventsNewLen := CalculateMessagePartsBetweenTagsDescEvents(c.given.maxChars, c.given.tagsLen, c.given.descLen, c.given.eventsLen)
+
+				So(tagsNewLen, ShouldResemble, c.expected.tagsLen)
+				So(descNewLen, ShouldResemble, c.expected.descLen)
+				So(eventsNewLen, ShouldResemble, c.expected.eventsLen)
+			})
+		}
+	})
+}
diff --git a/senders/msgformat/defaults_test.go b/senders/msgformat/defaults_test.go
new file mode 100644
index 00000000..2704e905
--- /dev/null
+++ b/senders/msgformat/defaults_test.go
@@ -0,0 +1,43 @@
+package msgformat
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestDefaultTagsLimiter(t *testing.T) {
+	Convey("Test default tags limiter", t, func() {
+		tags := []string{"tag1", "tag2"}
+
+		Convey("with maxSize < 0", func() {
+			tagsStr := DefaultTagsLimiter(tags, -1)
+
+			So(tagsStr, ShouldResemble, "")
+		})
+
+		Convey("with maxSize > total characters in tags string", func() {
+			tagsStr := DefaultTagsLimiter(tags, 30)
+
+			So(tagsStr, ShouldResemble, " [tag1][tag2]")
+		})
+
+		Convey("with maxSize not enough for all tags", func() {
+			tagsStr := DefaultTagsLimiter(tags, 8)
+
+			So(tagsStr, ShouldResemble, " [tag1]")
+		})
+
+		Convey("with one long tag > maxSize", func() {
+			tagsStr := DefaultTagsLimiter([]string{"long_tag"}, 4)
+
+			So(tagsStr, ShouldResemble, "")
+		})
+
+		Convey("with no tags", func() {
+			tagsStr := DefaultTagsLimiter([]string{}, 0)
+
+			So(tagsStr, ShouldResemble, "")
+		})
+	})
+}
diff --git a/senders/msgformat/highlighter_test.go b/senders/msgformat/highlighter_test.go
index 470db230..0390e080 100644
--- a/senders/msgformat/highlighter_test.go
+++ b/senders/msgformat/highlighter_test.go
@@ -5,6 +5,7 @@ import (
 	"strings"
 	"testing"
 	"time"
+	"unicode/utf8"
 
 	"github.com/moira-alert/moira"
 	"github.com/moira-alert/moira/senders/emoji_provider"
@@ -89,64 +90,196 @@ func TestFormat(t *testing.T) {
 		})
 
 		Convey("Long message parts", func() {
+			trigger.Desc = ""
+			trigger.Tags = []string{}
+
 			const (
-				msgLimit        = 4_000
-				halfLimit       = msgLimit / 2
-				greaterThanHalf = halfLimit + 100
-				lessThanHalf    = halfLimit - 100
+				titleLine    = "**NODATA** [Name](http://moira.url/trigger/TriggerID)"
+				eventLine    = "\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)"
+				endSuffix    = "...\n"
+				lenEndSuffix = 4
 			)
 
-			const eventLine = "\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)"
-			oneEventLineLen := len([]rune(eventLine))
-
-			longDesc := strings.Repeat("a", greaterThanHalf)
+			lenTitle := utf8.RuneCountInString(titleLine) + len("\n") // 54 symbols
+			oneEventLineLen := utf8.RuneCountInString(eventLine)      // 47 symbols
 
-			// Events list with chars greater than half of the message limit
-			var longEvents moira.NotificationEvents
-			for i := 0; i < greaterThanHalf/oneEventLineLen; i++ {
-				longEvents = append(longEvents, event)
-			}
+			var (
+				msgLimit         = testMaxChars - lenTitle // 3947
+				thirdOfLimit     = msgLimit / 3            // 1315
+				greaterThanThird = thirdOfLimit + 100      // 1415
+				lessThanThird    = thirdOfLimit - 100      // 1215
+			)
 
-			Convey("Long description. desc > msgLimit/2", func() {
-				var events moira.NotificationEvents
-				for i := 0; i < lessThanHalf/oneEventLineLen; i++ {
-					events = append(events, event)
+			Convey("with long tags (tagsLen >= msgLimit), desc and events < msgLimit/3", func() {
+				trigger.Tags = []string{
+					strings.Repeat("a", 1000),
+					strings.Repeat("b", 1000),
+					strings.Repeat("c", 1000),
+					strings.Repeat("d", 1000),
 				}
+				trigger.Desc = genDescByLimit(lessThanThird)
+				events := genEventsByLimit(event, oneEventLineLen, lessThanThird)
+
+				expected := titleLine +
+					DefaultTagsLimiter(trigger.Tags,
+						msgLimit-utf8.RuneCountInString(trigger.Desc)-len("```\n```")-oneEventLineLen*len(events),
+					) + "\n" +
+					strings.Repeat("a", lessThanThird) + "\n" +
+					"```" +
+					strings.Repeat(eventLine, len(events)) +
+					"\n```"
+
+				actual := formatter.Format(getParams(events, trigger, false))
 
-				actual := formatter.Format(getParams(events, moira.TriggerData{Desc: longDesc}, false))
-				expected := "**NODATA**\n" +
-					strings.Repeat("a", 2100) + "\n" +
-					"```\n" +
-					strings.Repeat("02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n", 39) +
-					"02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n```"
 				So(actual, ShouldResemble, expected)
+				So(utf8.RuneCountInString(actual), ShouldBeLessThanOrEqualTo, testMaxChars)
 			})
 
-			Convey("Many events. eventString > msgLimit/2", func() {
-				desc := strings.Repeat("a", lessThanHalf)
-				actual := formatter.Format(getParams(longEvents, moira.TriggerData{Desc: desc}, false))
-				expected := "**NODATA**\n" +
-					desc + "\n" +
-					"```\n" +
-					strings.Repeat("02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n", 43) +
-					"02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n```"
+			Convey("with description > msgLimit/3, tags and events < msgLimit/3, and sum of lengths is greater than msgLimit", func() {
+				longDescLen := greaterThanThird + 200
+
+				trigger.Tags = genTagsByLimit(lessThanThird)
+				trigger.Desc = genDescByLimit(longDescLen)
+				events := genEventsByLimit(event, oneEventLineLen, lessThanThird)
+
+				tagsStr := " " + trigger.GetTags()
+
+				expected := titleLine + tagsStr + "\n" +
+					strings.Repeat("a",
+						msgLimit-utf8.RuneCountInString(tagsStr)-len("```\n```")-oneEventLineLen*len(events)-lenEndSuffix,
+					) + endSuffix +
+					"```" +
+					strings.Repeat(eventLine, len(events)) +
+					"\n```"
+
+				actual := formatter.Format(getParams(events, trigger, false))
+
 				So(actual, ShouldResemble, expected)
+				So(utf8.RuneCountInString(actual), ShouldBeLessThanOrEqualTo, testMaxChars)
 			})
 
-			Convey("Long description and many events. both desc and events > msgLimit/2", func() {
-				actual := formatter.Format(getParams(longEvents, moira.TriggerData{Desc: longDesc}, false))
-				expected := "**NODATA**\n" +
-					strings.Repeat("a", 1980) + "...\n" +
-					"```\n" +
-					strings.Repeat("02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n", 40) +
-					"02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n```\n" +
+			Convey("with long events string (> msgLimit/3), desc and tags < msgLimit/3", func() {
+				longEventsLen := greaterThanThird + 200
+
+				trigger.Tags = genTagsByLimit(lessThanThird)
+				trigger.Desc = genDescByLimit(lessThanThird)
+				events := genEventsByLimit(event, oneEventLineLen, longEventsLen)
+
+				tagsStr := " " + trigger.GetTags()
+
+				expected := titleLine + tagsStr + "\n" +
+					strings.Repeat("a", lessThanThird) + "\n" +
+					"```" +
+					strings.Repeat(eventLine, 31) +
+					"\n```\n" +
 					"...and 3 more events."
+
+				actual := formatter.Format(getParams(events, trigger, false))
+
+				So(actual, ShouldResemble, expected)
+				So(utf8.RuneCountInString(actual), ShouldBeLessThanOrEqualTo, testMaxChars)
+			})
+
+			Convey("with tags and desc > msgLimit/3, events <= msgLimit/3", func() {
+				trigger.Tags = genTagsByLimit(greaterThanThird)
+				trigger.Desc = genDescByLimit(greaterThanThird)
+				events := genEventsByLimit(event, oneEventLineLen, lessThanThird)
+
+				expected := titleLine + DefaultTagsLimiter(trigger.Tags, thirdOfLimit) + "\n" +
+					strings.Repeat("a", greaterThanThird) + "\n" +
+					"```" +
+					strings.Repeat(eventLine, len(events)) +
+					"\n```"
+
+				actual := formatter.Format(getParams(events, trigger, false))
+
+				So(actual, ShouldResemble, expected)
+				So(utf8.RuneCountInString(actual), ShouldBeLessThanOrEqualTo, testMaxChars)
+			})
+
+			Convey("with tags and events > msgLimit/3, desc <= msgLimit/3", func() {
+				trigger.Tags = genTagsByLimit(greaterThanThird)
+				trigger.Desc = genDescByLimit(lessThanThird)
+				events := genEventsByLimit(event, oneEventLineLen, greaterThanThird)
+
+				expected := titleLine + DefaultTagsLimiter(trigger.Tags, thirdOfLimit) + "\n" +
+					strings.Repeat("a", lessThanThird) + "\n" +
+					"```" +
+					strings.Repeat(eventLine, 29) +
+					"\n```\n" + "...and 1 more events."
+
+				actual := formatter.Format(getParams(events, trigger, false))
+
 				So(actual, ShouldResemble, expected)
+				So(utf8.RuneCountInString(actual), ShouldBeLessThanOrEqualTo, testMaxChars)
+			})
+
+			Convey("with desc and events > msgLimit/3, tags <= msgLimit/3", func() {
+				trigger.Tags = genTagsByLimit(lessThanThird)
+				trigger.Desc = genDescByLimit(greaterThanThird)
+				events := genEventsByLimit(event, oneEventLineLen, greaterThanThird)
+
+				tagsStr := DefaultTagsLimiter(trigger.Tags, lessThanThird)
+
+				expected := titleLine + tagsStr + "\n" +
+					strings.Repeat("a", thirdOfLimit+(thirdOfLimit-utf8.RuneCountInString(tagsStr))/2-lenEndSuffix) + endSuffix +
+					"```" +
+					strings.Repeat(eventLine, 28) +
+					"\n```\n" + "...and 2 more events."
+
+				actual := formatter.Format(getParams(events, trigger, false))
+
+				So(actual, ShouldResemble, expected)
+				So(utf8.RuneCountInString(actual), ShouldBeLessThanOrEqualTo, testMaxChars)
+			})
+
+			Convey("tags, description and events all have len > msgLimit/3", func() {
+				trigger.Tags = genTagsByLimit(greaterThanThird)
+				trigger.Desc = genDescByLimit(greaterThanThird)
+				events := genEventsByLimit(event, oneEventLineLen, greaterThanThird)
+
+				expected := titleLine + DefaultTagsLimiter(trigger.Tags, thirdOfLimit) + "\n" +
+					strings.Repeat("a", thirdOfLimit-lenEndSuffix) + endSuffix +
+					"```" +
+					strings.Repeat(eventLine, thirdOfLimit/oneEventLineLen) +
+					"\n```\n" +
+					"...and 3 more events."
+
+				actual := formatter.Format(getParams(events, trigger, false))
+
+				So(actual, ShouldResemble, expected)
+				So(utf8.RuneCountInString(actual), ShouldBeLessThanOrEqualTo, testMaxChars)
 			})
 		})
 	})
 }
 
+func genTagsByLimit(limit int) []string {
+	tagName := "tag1"
+
+	tagsCount := (limit - 1) / (len(tagName) + 2)
+
+	tags := make([]string, 0, tagsCount)
+
+	for i := 0; i < tagsCount; i++ {
+		tags = append(tags, tagName)
+	}
+
+	return tags
+}
+
+func genDescByLimit(limit int) string {
+	return strings.Repeat("a", limit)
+}
+
+func genEventsByLimit(event moira.NotificationEvent, oneEventLineLen int, limit int) moira.NotificationEvents {
+	var events moira.NotificationEvents
+	for i := 0; i < limit/oneEventLineLen; i++ {
+		events = append(events, event)
+	}
+	return events
+}
+
 func testBoldFormatter(str string) string {
 	return fmt.Sprintf("**%s**", str)
 }
diff --git a/senders/slack/slack_test.go b/senders/slack/slack_test.go
index 37cd85ed..b4049562 100644
--- a/senders/slack/slack_test.go
+++ b/senders/slack/slack_test.go
@@ -5,6 +5,7 @@ import (
 	"strings"
 	"testing"
 	"time"
+	"unicode/utf8"
 
 	"github.com/go-playground/validator/v10"
 	"github.com/moira-alert/moira"
@@ -129,7 +130,8 @@ some other text italic text
 		})
 
 		eventLine := "\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)"
-		oneEventLineLen := len([]rune(eventLine))
+		oneEventLineLen := utf8.RuneCountInString(eventLine)
+
 		// Events list with chars less than half the message limit
 		var shortEvents moira.NotificationEvents
 		var shortEventsString string
@@ -137,6 +139,7 @@ some other text italic text
 			shortEvents = append(shortEvents, event)
 			shortEventsString += eventLine
 		}
+
 		// Events list with chars greater than half the message limit
 		var longEvents moira.NotificationEvents
 		var longEventsString string
@@ -144,6 +147,7 @@ some other text italic text
 			longEvents = append(longEvents, event)
 			longEventsString += eventLine
 		}
+
 		longDesc := strings.Repeat("a", messageMaxCharacters/2+100)
 
 		Convey("Print moira message with desc + events < msgLimit", func() {
@@ -159,22 +163,44 @@ some other text italic text
 				events = append(events, event)
 				eventsString += eventLine
 			}
+
+			expected := "*NODATA*\n" +
+				strings.Repeat("a", 1991) + "...\n" +
+				"```" +
+				eventsString + "\n```"
+
 			actual := sender.buildMessage(events, moira.TriggerData{Desc: longDesc}, false)
-			expected := "*NODATA*\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...\n```\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n```"
+
 			So(actual, ShouldResemble, expected)
+			So(utf8.RuneCountInString(actual), ShouldBeLessThanOrEqualTo, messageMaxCharacters)
 		})
 
 		Convey("Print moira message events string > msgLimit/2", func() {
 			desc := strings.Repeat("a", messageMaxCharacters/2-100)
+
+			expected := "*NODATA*\n" +
+				desc + "\n" +
+				"```" +
+				strings.Repeat(eventLine, 41) + "\n```" +
+				"\n...and 5 more events."
+
 			actual := sender.buildMessage(longEvents, moira.TriggerData{Desc: desc}, false)
-			expected := "*NODATA*\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n```\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n```\n...and 3 more events."
+
 			So(actual, ShouldResemble, expected)
+			So(utf8.RuneCountInString(actual), ShouldBeLessThanOrEqualTo, messageMaxCharacters)
 		})
 
 		Convey("Print moira message with both desc and events > msgLimit/2", func() {
+			expected := "*NODATA*\n" +
+				strings.Repeat("a", 1991) + "...\n" +
+				"```" +
+				strings.Repeat(eventLine, 41) + "\n```" +
+				"\n...and 5 more events."
+
 			actual := sender.buildMessage(longEvents, moira.TriggerData{Desc: longDesc}, false)
-			expected := "*NODATA*\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...\n```\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n02:40 (GMT+00:00): Metric = 123 (OK to NODATA)\n```\n...and 5 more events."
+
 			So(actual, ShouldResemble, expected)
+			So(utf8.RuneCountInString(actual), ShouldBeLessThanOrEqualTo, messageMaxCharacters)
 		})
 	})
 }
diff --git a/senders/telegram/message_formatter_test.go b/senders/telegram/message_formatter_test.go
index f6ed08cb..f28308cf 100644
--- a/senders/telegram/message_formatter_test.go
+++ b/senders/telegram/message_formatter_test.go
@@ -1,7 +1,6 @@
 package telegram
 
 import (
-	"fmt"
 	"strings"
 	"testing"
 	"time"
@@ -43,8 +42,6 @@ func TestMessageFormatter_Format(t *testing.T) {
 	}
 
 	expectedFirstLine := "💣 <b>NODATA</b> <a href=\"https://moira.uri/trigger/TriggerID\">Name</a> [tag1][tag2]\n"
-	lenFirstLine := utf8.RuneCountInString(expectedFirstLine) -
-		utf8.RuneCountInString("<b></b><a href=\"https://moira.uri/trigger/TriggerID\"></a>")
 
 	eventStr := "02:40 (GMT+00:00): <code>Metric</code> = 123 (OK to NODATA)\n"
 	lenEventStr := utf8.RuneCountInString(eventStr) - utf8.RuneCountInString("<code></code>") // 60 - 13 = 47
@@ -109,86 +106,182 @@ func TestMessageFormatter_Format(t *testing.T) {
 		})
 
 		Convey("with long messages", func() {
-			msgLimit := albumCaptionMaxCharacters - lenFirstLine
-			halfMsgLimit := msgLimit / 2
-			greaterThanHalf := halfMsgLimit + 100
-			lessThanHalf := halfMsgLimit - 100
+			const (
+				titleWithoutTags = "💣 <b>NODATA</b> <a href=\"https://moira.uri/trigger/TriggerID\">Name</a>"
+			)
+
+			titleLen := utf8.RuneCountInString(titleWithoutTags) -
+				utf8.RuneCountInString("<b></b><a href=\"https://moira.uri/trigger/TriggerID\"></a>") + len("\n") // 70 - 57 + 1 = 14
+
+			msgLimit := albumCaptionMaxCharacters - titleLen // 1024 - 14 = 1010
+			thirdOfMsgLimit := msgLimit / 3
+			greaterThanThird := thirdOfMsgLimit + 150
+			lessThanThird := thirdOfMsgLimit - 100
+
+			// see genDescByLimit
+			symbolAtEndOfDescription := ""
+			if thirdOfMsgLimit%2 != 0 {
+				symbolAtEndOfDescription = "i"
+			}
+
+			Convey("with tags > msgLimit/3, desc and events < msgLimit/3", func() {
+				trigger.Tags = genTagsByLimit(greaterThanThird + 200)
+				trigger.Desc = genDescByLimit(lessThanThird)
+				events := genEventsByLimit(event, lenEventStr, lessThanThird)
+
+				expected := titleWithoutTags +
+					msgformat.DefaultTagsLimiter(trigger.Tags,
+						msgLimit-lessThanThird-len(events)*lenEventStr-len("\n")) + "\n" +
+					strings.Repeat("<strong>ё</strong>ж", lessThanThird/2) + symbolAtEndOfDescription + "\n" +
+					eventsBlockStart + "\n" +
+					strings.Repeat(eventStr, len(events)) + eventsBlockEnd
 
-			Convey("text size of description > msgLimit / 2", func() {
-				var events moira.NotificationEvents
-				throttled := false
+				actual := formatter.Format(getParams(events, trigger, false))
 
-				eventsCount := lessThanHalf / lenEventStr
-				for i := 0; i < eventsCount; i++ {
-					events = append(events, event)
-				}
+				So(actual, ShouldResemble, expected)
+				So(calcRunesCountWithoutHTML(actual), ShouldBeLessThanOrEqualTo, albumCaptionMaxCharacters)
+			})
 
-				trigger.Desc = strings.Repeat("**ё**ж", greaterThanHalf/2)
+			Convey("with desc > msgLimit/3, tags and events < msgLimit/3", func() {
+				trigger.Tags = genTagsByLimit(lessThanThird)
+				trigger.Desc = genDescByLimit(greaterThanThird + 200)
+				events := genEventsByLimit(event, lenEventStr, lessThanThird)
 
-				expected := expectedFirstLine +
-					strings.Repeat("<strong>ё</strong>ж", greaterThanHalf/2) + "\n" +
+				expected := titleWithoutTags +
+					msgformat.DefaultTagsLimiter(trigger.Tags, lessThanThird) + "\n" +
+					tooLongDescMessage +
 					eventsBlockStart + "\n" +
-					strings.Repeat(eventStr, eventsCount) +
-					eventsBlockEnd
+					strings.Repeat(eventStr, len(events)) + eventsBlockEnd
 
-				msg := formatter.Format(getParams(events, trigger, throttled))
+				actual := formatter.Format(getParams(events, trigger, false))
 
-				So(calcRunesCountWithoutHTML([]rune(msg)), ShouldBeLessThanOrEqualTo, albumCaptionMaxCharacters)
-				So(msg, ShouldEqual, expected)
+				So(actual, ShouldResemble, expected)
+				So(calcRunesCountWithoutHTML(actual), ShouldBeLessThanOrEqualTo, albumCaptionMaxCharacters)
 			})
 
-			Convey("text size of events block > msgLimit / 2", func() {
-				var events moira.NotificationEvents
-				throttled := false
+			Convey("with events > msgLimit/3, tags and desc < msgLimit/3", func() {
+				trigger.Tags = genTagsByLimit(lessThanThird)
+				trigger.Desc = genDescByLimit(lessThanThird)
+				events := genEventsByLimit(event, lenEventStr, greaterThanThird+200)
+
+				expected := titleWithoutTags +
+					msgformat.DefaultTagsLimiter(trigger.Tags, lessThanThird) + "\n" +
+					strings.Repeat("<strong>ё</strong>ж", lessThanThird/2) + symbolAtEndOfDescription + "\n" +
+					eventsBlockStart + "\n" +
+					strings.Repeat(eventStr, 10) + eventsBlockEnd +
+					"\n...and 4 more events."
+
+				actual := formatter.Format(getParams(events, trigger, false))
 
-				eventsCount := greaterThanHalf / lenEventStr
-				for i := 0; i < eventsCount; i++ {
-					events = append(events, event)
-				}
+				So(actual, ShouldResemble, expected)
+				So(calcRunesCountWithoutHTML(actual), ShouldBeLessThanOrEqualTo, albumCaptionMaxCharacters)
+			})
 
-				trigger.Desc = strings.Repeat("**ё**ж", lessThanHalf/2)
+			Convey("with tags and desc > msgLimit/3, events < msgLimit/3", func() {
+				trigger.Tags = genTagsByLimit(greaterThanThird)
+				trigger.Desc = genDescByLimit(greaterThanThird)
+				events := genEventsByLimit(event, lenEventStr, lessThanThird)
 
-				expected := expectedFirstLine +
-					strings.Repeat("<strong>ё</strong>ж", lessThanHalf/2) + "\n" +
+				expected := titleWithoutTags +
+					msgformat.DefaultTagsLimiter(trigger.Tags, thirdOfMsgLimit) + "\n" +
+					tooLongDescMessage +
 					eventsBlockStart + "\n" +
-					strings.Repeat(eventStr, eventsCount) +
-					eventsBlockEnd
+					strings.Repeat(eventStr, len(events)) + eventsBlockEnd
 
-				msg := formatter.Format(getParams(events, trigger, throttled))
+				actual := formatter.Format(getParams(events, trigger, false))
 
-				So(calcRunesCountWithoutHTML([]rune(msg)), ShouldBeLessThanOrEqualTo, albumCaptionMaxCharacters)
-				So(msg, ShouldEqual, expected)
+				So(actual, ShouldResemble, expected)
+				So(calcRunesCountWithoutHTML(actual), ShouldBeLessThanOrEqualTo, albumCaptionMaxCharacters)
 			})
 
-			Convey("both description and events block have text size > msgLimit/2", func() {
-				var events moira.NotificationEvents
-				throttled := false
+			Convey("with tags and events > msgLimit / 3, desc < msgLimit/3", func() {
+				trigger.Tags = genTagsByLimit(greaterThanThird)
+				trigger.Desc = genDescByLimit(lessThanThird)
+				events := genEventsByLimit(event, lenEventStr, greaterThanThird)
 
-				eventsCount := greaterThanHalf / lenEventStr
-				for i := 0; i < eventsCount; i++ {
-					events = append(events, event)
-				}
+				expected := titleWithoutTags +
+					msgformat.DefaultTagsLimiter(trigger.Tags, thirdOfMsgLimit) + "\n" +
+					strings.Repeat("<strong>ё</strong>ж", lessThanThird/2) + symbolAtEndOfDescription + "\n" +
+					eventsBlockStart + "\n" +
+					strings.Repeat(eventStr, 8) + eventsBlockEnd +
+					"\n...and 2 more events."
+
+				actual := formatter.Format(getParams(events, trigger, false))
 
-				trigger.Desc = strings.Repeat("**ё**ж", greaterThanHalf/2)
+				So(actual, ShouldResemble, expected)
+				So(calcRunesCountWithoutHTML(actual), ShouldBeLessThanOrEqualTo, albumCaptionMaxCharacters)
+			})
 
-				eventsShouldBe := halfMsgLimit / lenEventStr
+			Convey("with desc and events > msgLimit / 3, tags < msgLimit/3", func() {
+				trigger.Tags = genTagsByLimit(lessThanThird)
+				trigger.Desc = genDescByLimit(greaterThanThird)
+				events := genEventsByLimit(event, lenEventStr, greaterThanThird)
 
-				expected := expectedFirstLine +
+				expected := titleWithoutTags +
+					msgformat.DefaultTagsLimiter(trigger.Tags, lessThanThird) + "\n" +
 					tooLongDescMessage +
 					eventsBlockStart + "\n" +
-					strings.Repeat(eventStr, eventsShouldBe) +
+					strings.Repeat(eventStr, 7) + eventsBlockEnd +
+					"\n...and 3 more events."
+
+				actual := formatter.Format(getParams(events, trigger, false))
+
+				So(actual, ShouldResemble, expected)
+				So(calcRunesCountWithoutHTML(actual), ShouldBeLessThanOrEqualTo, albumCaptionMaxCharacters)
+			})
+
+			Convey("with tags, desc, events > msgLimit/3", func() {
+				trigger.Tags = genTagsByLimit(greaterThanThird)
+				trigger.Desc = genDescByLimit(greaterThanThird)
+				events := genEventsByLimit(event, lenEventStr, greaterThanThird)
+
+				expected := titleWithoutTags +
+					msgformat.DefaultTagsLimiter(trigger.Tags, thirdOfMsgLimit) + "\n" +
+					tooLongDescMessage +
+					eventsBlockStart + "\n" +
+					strings.Repeat(eventStr, 6) +
 					eventsBlockEnd +
-					fmt.Sprintf("\n...and %d more events.", len(events)-eventsShouldBe)
+					"\n...and 4 more events."
 
-				msg := formatter.Format(getParams(events, trigger, throttled))
+				actual := formatter.Format(getParams(events, trigger, false))
 
-				So(calcRunesCountWithoutHTML([]rune(msg)), ShouldBeLessThanOrEqualTo, albumCaptionMaxCharacters)
-				So(msg, ShouldEqual, expected)
+				So(actual, ShouldResemble, expected)
+				So(calcRunesCountWithoutHTML(actual), ShouldBeLessThanOrEqualTo, albumCaptionMaxCharacters)
 			})
 		})
 	})
 }
 
+func genTagsByLimit(limit int) []string {
+	tagName := "tag1"
+
+	tagsCount := (limit - 1) / (len(tagName) + 2)
+
+	tags := make([]string, 0, tagsCount)
+
+	for i := 0; i < tagsCount; i++ {
+		tags = append(tags, tagName)
+	}
+
+	return tags
+}
+
+func genDescByLimit(limit int) string {
+	str := strings.Repeat("**ё**ж", limit/2)
+	if limit%2 != 0 {
+		str += "i"
+	}
+	return str
+}
+
+func genEventsByLimit(event moira.NotificationEvent, oneEventLineLen int, limit int) moira.NotificationEvents {
+	var events moira.NotificationEvents
+	for i := 0; i < limit/oneEventLineLen; i++ {
+		events = append(events, event)
+	}
+	return events
+}
+
 func getParams(events moira.NotificationEvents, trigger moira.TriggerData, throttled bool) msgformat.MessageFormatterParams {
 	return msgformat.MessageFormatterParams{
 		Events:          events,