From 18dd1e32ee0d6e4dc37a1ce1ad46135c7bf270a8 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Thu, 4 Jun 2026 11:40:33 +1000 Subject: [PATCH 1/6] Include grouped alert query results inline --- .../Messages/AlertV2MessageBuilder.cs | 106 +++++++++++++----- 1 file changed, 80 insertions(+), 26 deletions(-) diff --git a/src/Seq.App.Slack/Messages/AlertV2MessageBuilder.cs b/src/Seq.App.Slack/Messages/AlertV2MessageBuilder.cs index 9b29797..ce909fc 100644 --- a/src/Seq.App.Slack/Messages/AlertV2MessageBuilder.cs +++ b/src/Seq.App.Slack/Messages/AlertV2MessageBuilder.cs @@ -35,9 +35,25 @@ protected override string GenerateMessageText(Event evt) protected override void AddNecessaryAttachments(SlackMessage message, Event evt, string color) { var resultsUrl = EventFormatting.SafeGetProperty(evt, "Source.ResultsUrl"); - var resultsText = SlackSyntax.Hyperlink(resultsUrl, "Explore detected results in Seq"); - var results = new SlackMessageAttachment(color, resultsText); - message.Attachments.Add(results); + var contributingEventsUrl = EventFormatting.SafeGetProperty(evt, "Source.ContributingEventsUrl"); + + if (!string.IsNullOrWhiteSpace(contributingEventsUrl)) + { + // 2026.1+ + var exploreText = + "Explore " + + SlackSyntax.Hyperlink(resultsUrl, "detected results") + + " and " + + SlackSyntax.Hyperlink(contributingEventsUrl, "contributing events") + + " in Seq"; + message.Attachments.Add(new SlackMessageAttachment(color, exploreText)); + } + else + { + // 2025.2 and earlier + var resultsText = SlackSyntax.Hyperlink(resultsUrl, "Explore detected results in Seq"); + message.Attachments.Add(new SlackMessageAttachment(color, resultsText)); + } if (_messageTemplate != null) { @@ -49,11 +65,12 @@ protected override void AddNecessaryAttachments(SlackMessage message, Event rd && - rd.TryGetValue("ContributingEvents", out var ce) && - ce is IEnumerable contributingEvents && - contributingEvents.Count() > 1) + r is IReadOnlyDictionary rd) { - var text = new StringBuilder(); - foreach (var contributing in contributingEvents.Skip(1).Cast>()) + if (rd.TryGetValue("Results", out var rs) && + rs is IEnumerable results && + results.Count() > 1 && + results.First() is IEnumerable labelsRow) + { + var labels = labelsRow.ToArray(); + if (labels.Length > 1 && labels[0] is "time") + { + var text = new StringBuilder(); + foreach (var result in results.Skip(1).Cast>()) + { + var values = result.ToArray(); + + var pre = new StringBuilder(); + pre.Append(_propertyValueFormatter.ConvertPropertyValueToString(values[0])); + pre.Append("\n"); + for (var i = 1; i < values.Length; ++i) + { + if (i != 1) + pre.Append("\n"); + + var label = labels[i]; + var value = _propertyValueFormatter.ConvertPropertyValueToString(values[i]); + pre.AppendFormat("{0}: {1}", label, value); + } + + text.Append(SlackSyntax.Preformatted(pre.ToString())); + text.Append("\n"); + } + + message.Attachments.Add(new SlackMessageAttachment(color, text.ToString(), "Results")); + } + } + + // Contributing events are opted-in per notification, so they're considered minimal (the user can configure + // the alert to exclude them if desired). + if (rd.TryGetValue("ContributingEvents", out var ce) && + ce is IEnumerable contributingEvents && + contributingEvents.Count() > 1) { - var columns = contributing.Cast().ToArray(); - - // Timestamp as ISO-8601 string - text.Append(SlackSyntax.Code(columns[1])); - text.Append(" "); - - // Message, linking to event - text.Append(SlackSyntax.Hyperlink(EventFormatting.LinkToId(_host, columns[0]), SlackSyntax.Escape(columns[2]))); - text.Append("\n"); + var text = new StringBuilder(); + foreach (var contributing in contributingEvents.Skip(1).Cast>()) + { + var columns = contributing.Cast().ToArray(); + + // Timestamp as ISO-8601 string + text.Append(SlackSyntax.Code(columns[1])); + text.Append(" "); + + // Message, linking to event + text.Append(SlackSyntax.Hyperlink(EventFormatting.LinkToId(_host, columns[0]), + SlackSyntax.Escape(columns[2]))); + text.Append("\n"); + } + + var events = new SlackMessageAttachment(color, text.ToString(), "Contributing Events"); + message.Attachments.Add(events); } - - var events = new SlackMessageAttachment(color, text.ToString(), "Contributing Events"); - message.Attachments.Add(events); } } } From 085731b6cc2fa00c22ea8b030f1bbd6b83dc69a8 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Thu, 4 Jun 2026 13:31:24 +1000 Subject: [PATCH 2/6] Update app target to .NET 8 and solution to .NET 10 (breaking chagnge; major version bump to 2.0.0) --- Directory.Build.props | 9 ++++ README.md | 13 +++--- Seq.App.Slack.sln | 1 + src/Seq.App.Slack/Api/SlackApi.cs | 2 +- src/Seq.App.Slack/Api/SlackMessage.cs | 4 +- .../Api/SlackMessageAttachment.cs | 6 +-- .../Formatting/EventFormatting.cs | 15 +++---- .../Formatting/PropertyValueFormatter.cs | 28 ++++-------- .../Messages/AlertV1MessageBuilder.cs | 4 +- .../Messages/AlertV2MessageBuilder.cs | 35 +++++++-------- .../Messages/DefaultMessageBuilder.cs | 18 +++----- .../Messages/SlackMessageBuilder.cs | 13 +++--- src/Seq.App.Slack/Seq.App.Slack.csproj | 4 +- src/Seq.App.Slack/SlackApp.cs | 45 ++++++++----------- .../EventFormattingTests.cs | 18 ++++---- .../Seq.App.Slack.Tests.csproj | 2 +- test/Seq.App.Slack.Tests/SlackAppTests.cs | 25 +++++------ 17 files changed, 109 insertions(+), 133 deletions(-) create mode 100644 Directory.Build.props diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..18de150 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,9 @@ + + + enable + enable + latest + true + Apache-2.0 + + diff --git a/README.md b/README.md index 9ca0bcf..c83407f 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ An app for [Seq](https://datalust.co/seq) that forwards messages to [Slack](http ### Getting started - 1. Install the app into Seq through the Seq UI: _Settings_ > _Apps_ > _Install from Nuget_. The package id is _Seq.App.Slack_. - 2. In Slack, select _Manage apps_ > _Search App Directory_ > _"Incoming WebHooks"_ - 3. Add a new incoming webhook configuration and copy the _Webhook URL_ - 4. Back in Seq, under _Settings_ > _Apps_, select _Add Instance_ next to the Slack app icon - 5. Configure the app instance, providing the webhook URL + 1. Install the app into Seq through the Seq UI: _Settings_ > _Apps_ > _Install from Nuget_; the package id is _Seq.App.Slack_ + 2. In Slack, select _Admin_ > _Apps and Workflows_ > _Build_ > _Create new App_ > _From Scratch_ + 3. In the app registration, choose _Incoming WebHooks_ (this is the new endpoint, not the legacy one) + 4. Add a new incoming webhook configuration and copy the _Webhook URL_ + 5. Back in Seq, under _Settings_ > _Apps_, select _Add Instance_ next to the Slack app icon + 6. Configure the app instance, providing the webhook URL Consult the Seq documentation for further information about [installing Seq apps](https://docs.datalust.co/docs/installing-seq-apps). -For more information see [Notifying with Slack](https://docs.datalust.co/docs/slack-notifications). \ No newline at end of file +For more information see [Notifying with Slack](https://docs.datalust.co/docs/slack-notifications). diff --git a/Seq.App.Slack.sln b/Seq.App.Slack.sln index 91eba8e..1016df2 100644 --- a/Seq.App.Slack.sln +++ b/Seq.App.Slack.sln @@ -10,6 +10,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{225F20AD README.md = README.md Build.ps1 = Build.ps1 Run.ps1 = Run.ps1 + Directory.Build.props = Directory.Build.props EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Seq.App.Slack.Tests", "test\Seq.App.Slack.Tests\Seq.App.Slack.Tests.csproj", "{143E44C0-C8B0-473D-9522-B31BCAA81A80}" diff --git a/src/Seq.App.Slack/Api/SlackApi.cs b/src/Seq.App.Slack/Api/SlackApi.cs index 8675022..ec1ad5e 100644 --- a/src/Seq.App.Slack/Api/SlackApi.cs +++ b/src/Seq.App.Slack/Api/SlackApi.cs @@ -20,7 +20,7 @@ static SlackApi() NullValueHandling = NullValueHandling.Ignore }; - public SlackApi(string proxyServer) + public SlackApi(string? proxyServer) { if (!string.IsNullOrWhiteSpace(proxyServer)) { diff --git a/src/Seq.App.Slack/Api/SlackMessage.cs b/src/Seq.App.Slack/Api/SlackMessage.cs index f4eb8bf..c402be6 100644 --- a/src/Seq.App.Slack/Api/SlackMessage.cs +++ b/src/Seq.App.Slack/Api/SlackMessage.cs @@ -21,9 +21,9 @@ public class SlackMessage public string IconUrl { get; } [JsonProperty("channel")] - public string Channel { get; } + public string? Channel { get; } - public SlackMessage(string fallback, string text, string username, string iconUrl, string channel) + public SlackMessage(string fallback, string text, string username, string iconUrl, string? channel) { this.Fallback = fallback; this.Text = text; diff --git a/src/Seq.App.Slack/Api/SlackMessageAttachment.cs b/src/Seq.App.Slack/Api/SlackMessageAttachment.cs index dd9f010..16cbd8c 100644 --- a/src/Seq.App.Slack/Api/SlackMessageAttachment.cs +++ b/src/Seq.App.Slack/Api/SlackMessageAttachment.cs @@ -9,10 +9,10 @@ public class SlackMessageAttachment public string Color { get; } [JsonProperty("text")] - public string Text { get; } + public string? Text { get; } [JsonProperty("title")] - public string Title { get; } + public string? Title { get; } [JsonProperty("fields")] public List Fields { get; } @@ -20,7 +20,7 @@ public class SlackMessageAttachment [JsonProperty("mrkdwn_in")] public List MarkdownIn { get; } - public SlackMessageAttachment(string color, string text = null, string title = null, bool textIsMarkdown = false) + public SlackMessageAttachment(string color, string? text = null, string? title = null, bool textIsMarkdown = false) { this.Color = color; this.Text = text; diff --git a/src/Seq.App.Slack/Formatting/EventFormatting.cs b/src/Seq.App.Slack/Formatting/EventFormatting.cs index 607e023..0b0b733 100644 --- a/src/Seq.App.Slack/Formatting/EventFormatting.cs +++ b/src/Seq.App.Slack/Formatting/EventFormatting.cs @@ -1,6 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using Seq.Apps; using Seq.Apps.LogEvents; using Serilog; @@ -9,7 +7,7 @@ namespace Seq.App.Slack.Formatting { static class EventFormatting { - private static readonly Regex PlaceholdersRegex = new Regex(@"(\[(?[^\[\]]+?)(\:(?[^\[\]]+?))?\])", RegexOptions.CultureInvariant | RegexOptions.Compiled); + private static readonly Regex PlaceholdersRegex = new(@"(\[(?[^\[\]]+?)(\:(?[^\[\]]+?))?\])", RegexOptions.CultureInvariant | RegexOptions.Compiled); private static readonly IReadOnlyDictionary LevelColorMap = new Dictionary { @@ -40,7 +38,7 @@ public static string SafeGetProperty(Event evt, string propertyPat if (path.Count == 0) { if (next == null) return "`null`"; - return raw ? next.ToString() : SlackSyntax.Escape(next.ToString()); + return raw ? next.ToString() ?? "" : SlackSyntax.Escape(next.ToString() ?? ""); } root = next as IReadOnlyDictionary; @@ -71,11 +69,11 @@ public static string SubstitutePlaceholders(string messageTemplateToUse, Event placeholders, string key, object value) { var loweredKey = key.ToLower(); - if (!placeholders.ContainsKey(loweredKey)) - placeholders.Add(loweredKey, value); + placeholders.TryAdd(loweredKey, value); } public static string LinkToId(Host host, string eventId) diff --git a/src/Seq.App.Slack/Formatting/PropertyValueFormatter.cs b/src/Seq.App.Slack/Formatting/PropertyValueFormatter.cs index fdbcd91..b02e7e1 100644 --- a/src/Seq.App.Slack/Formatting/PropertyValueFormatter.cs +++ b/src/Seq.App.Slack/Formatting/PropertyValueFormatter.cs @@ -1,6 +1,4 @@ -using System; -using System.Collections.Generic; -using Newtonsoft.Json; +using Newtonsoft.Json; namespace Seq.App.Slack.Formatting { @@ -8,7 +6,7 @@ class PropertyValueFormatter { private readonly int? _maxPropertyLength; - private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings + private static readonly JsonSerializerSettings JsonSettings = new() { NullValueHandling = NullValueHandling.Ignore }; @@ -18,32 +16,24 @@ public PropertyValueFormatter(int? maxPropertyLength) _maxPropertyLength = maxPropertyLength; } - public string ConvertPropertyValueToString(object propertyValue) + public string ConvertPropertyValueToString(object? propertyValue) { if (propertyValue == null) return string.Empty; - string result; - Type t = propertyValue.GetType(); - bool isDict = t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Dictionary<,>); - if (isDict) - { - result = JsonConvert.SerializeObject(propertyValue, JsonSettings); - } - else - { - result = propertyValue.ToString(); - } + var t = propertyValue.GetType(); + var isDict = t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Dictionary<,>); + var result = isDict ? JsonConvert.SerializeObject(propertyValue, JsonSettings) : propertyValue.ToString(); if (_maxPropertyLength.HasValue) { - if (result.Length > _maxPropertyLength) + if (result?.Length > _maxPropertyLength) { - result = result.Substring(0, _maxPropertyLength.Value) + "..."; + result = result[.._maxPropertyLength.Value] + "..."; } } - return result; + return result ?? ""; } } } \ No newline at end of file diff --git a/src/Seq.App.Slack/Messages/AlertV1MessageBuilder.cs b/src/Seq.App.Slack/Messages/AlertV1MessageBuilder.cs index cad5515..f9a5843 100644 --- a/src/Seq.App.Slack/Messages/AlertV1MessageBuilder.cs +++ b/src/Seq.App.Slack/Messages/AlertV1MessageBuilder.cs @@ -7,9 +7,9 @@ namespace Seq.App.Slack.Messages { class AlertV1MessageBuilder : SlackMessageBuilder { - private readonly string _messageTemplate; + private readonly string? _messageTemplate; - public AlertV1MessageBuilder(Apps.App app, string channel, string username, string messageTemplate, string iconUrl, bool excludeOptionalAttachments) + public AlertV1MessageBuilder(Apps.App app, string? channel, string? username, string? messageTemplate, string? iconUrl, bool excludeOptionalAttachments) : base(app, channel, username, iconUrl, excludeOptionalAttachments) { _messageTemplate = messageTemplate; diff --git a/src/Seq.App.Slack/Messages/AlertV2MessageBuilder.cs b/src/Seq.App.Slack/Messages/AlertV2MessageBuilder.cs index ce909fc..9a1c734 100644 --- a/src/Seq.App.Slack/Messages/AlertV2MessageBuilder.cs +++ b/src/Seq.App.Slack/Messages/AlertV2MessageBuilder.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +using System.Text; using Seq.App.Slack.Api; using Seq.App.Slack.Formatting; using Seq.Apps; @@ -14,10 +11,10 @@ class AlertV2MessageBuilder : SlackMessageBuilder { private readonly Host _host; private readonly PropertyValueFormatter _propertyValueFormatter; - private readonly string _messageTemplate; - private static readonly HashSet SpecialProperties = new HashSet(new[] { "NamespacedAlertTitle", "Alert", "Source", "SuppressedUntil", "Failures" }); + private readonly string? _messageTemplate; + private static readonly HashSet SpecialProperties = ["NamespacedAlertTitle", "Alert", "Source", "SuppressedUntil", "Failures"]; - public AlertV2MessageBuilder(Host host, Apps.App app, PropertyValueFormatter propertyValueFormatter, string channel, string username, string messageTemplate, string iconUrl, bool excludeOptionalAttachments) + public AlertV2MessageBuilder(Host host, Apps.App app, PropertyValueFormatter propertyValueFormatter, string? channel, string? username, string? messageTemplate, string? iconUrl, bool excludeOptionalAttachments) : base(app, channel, username, iconUrl, excludeOptionalAttachments) { _host = host; @@ -61,7 +58,7 @@ protected override void AddNecessaryAttachments(SlackMessage message, Event failures) + f is IEnumerable failures) { foreach (var failure in failures) { @@ -83,12 +80,12 @@ protected override void AddNecessaryAttachments(SlackMessage message, Event rd) + r is IReadOnlyDictionary rd) { if (rd.TryGetValue("Results", out var rs) && - rs is IEnumerable results && + rs is IEnumerable results && results.Count() > 1 && - results.First() is IEnumerable labelsRow) + results.First() is IEnumerable labelsRow) { var labels = labelsRow.ToArray(); if (labels.Length > 1 && labels[0] is "time") @@ -100,19 +97,19 @@ rs is IEnumerable results && var pre = new StringBuilder(); pre.Append(_propertyValueFormatter.ConvertPropertyValueToString(values[0])); - pre.Append("\n"); + pre.Append('\n'); for (var i = 1; i < values.Length; ++i) { if (i != 1) - pre.Append("\n"); + pre.Append('\n'); var label = labels[i]; var value = _propertyValueFormatter.ConvertPropertyValueToString(values[i]); - pre.AppendFormat("{0}: {1}", label, value); + pre.Append($"{label}: {value}"); } text.Append(SlackSyntax.Preformatted(pre.ToString())); - text.Append("\n"); + text.Append('\n'); } message.Attachments.Add(new SlackMessageAttachment(color, text.ToString(), "Results")); @@ -122,22 +119,22 @@ rs is IEnumerable results && // Contributing events are opted-in per notification, so they're considered minimal (the user can configure // the alert to exclude them if desired). if (rd.TryGetValue("ContributingEvents", out var ce) && - ce is IEnumerable contributingEvents && + ce is IEnumerable contributingEvents && contributingEvents.Count() > 1) { var text = new StringBuilder(); - foreach (var contributing in contributingEvents.Skip(1).Cast>()) + foreach (var contributing in contributingEvents.Skip(1).Cast>()) { var columns = contributing.Cast().ToArray(); // Timestamp as ISO-8601 string text.Append(SlackSyntax.Code(columns[1])); - text.Append(" "); + text.Append(' '); // Message, linking to event text.Append(SlackSyntax.Hyperlink(EventFormatting.LinkToId(_host, columns[0]), SlackSyntax.Escape(columns[2]))); - text.Append("\n"); + text.Append('\n'); } var events = new SlackMessageAttachment(color, text.ToString(), "Contributing Events"); diff --git a/src/Seq.App.Slack/Messages/DefaultMessageBuilder.cs b/src/Seq.App.Slack/Messages/DefaultMessageBuilder.cs index 9fb36dd..97843ba 100644 --- a/src/Seq.App.Slack/Messages/DefaultMessageBuilder.cs +++ b/src/Seq.App.Slack/Messages/DefaultMessageBuilder.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Seq.App.Slack.Api; +using Seq.App.Slack.Api; using Seq.App.Slack.Formatting; using Seq.Apps; using Seq.Apps.LogEvents; @@ -12,13 +9,13 @@ class DefaultMessageBuilder : SlackMessageBuilder { private readonly Host _host; private readonly PropertyValueFormatter _propertyValueFormatter; - private readonly string _messageTemplate; + private readonly string? _messageTemplate; private readonly HashSet _includedProperties; - private static readonly IEnumerable SpecialProperties = new[] { "Id", "Host" }; + private static readonly IEnumerable SpecialProperties = ["Id", "Host"]; - public DefaultMessageBuilder(Host host, Apps.App app, PropertyValueFormatter propertyValueFormatter, string channel, - string username, string iconUrl, string messageTemplate, bool excludeOptionalAttachments, IEnumerable includedProperties) + public DefaultMessageBuilder(Host host, Apps.App app, PropertyValueFormatter propertyValueFormatter, string? channel, + string? username, string? iconUrl, string? messageTemplate, bool excludeOptionalAttachments, IEnumerable includedProperties) : base(app, channel, username, iconUrl, excludeOptionalAttachments) { _host = host ?? throw new ArgumentNullException(nameof(host)); @@ -50,10 +47,9 @@ protected override void AddOptionalAttachments(SlackMessage message, Event - netstandard2.0 - 1.0.0 + net8.0 + 2.0.0 An app for Seq that forwards events and notifications to Slack. bytenik seq-app diff --git a/src/Seq.App.Slack/SlackApp.cs b/src/Seq.App.Slack/SlackApp.cs index 71ae058..3da1db4 100644 --- a/src/Seq.App.Slack/SlackApp.cs +++ b/src/Seq.App.Slack/SlackApp.cs @@ -1,9 +1,5 @@ using Seq.Apps; using Seq.Apps.LogEvents; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Seq.App.Slack.Api; using Seq.App.Slack.Formatting; using Seq.App.Slack.Messages; @@ -18,70 +14,70 @@ public class SlackApp : SeqApp, ISubscribeToAsync { private const uint AlertV1EventType = 0xA1E77000, AlertV2EventType = 0xA1E77001; - private Dictionary _messageBuilders; - private SlackMessageBuilder _defaultMessageBuilder; + private Dictionary? _messageBuilders; + private SlackMessageBuilder? _defaultMessageBuilder; [SeqAppSetting( DisplayName = "Webhook URL", HelpText = "Add the Incoming WebHooks app to your Slack to get this URL.")] - public string WebhookUrl { get; set; } + public required string WebhookUrl { get; init; } [SeqAppSetting( DisplayName = "Channel", IsOptional = true, HelpText = "The channel to be used for the Slack notification. If not specified, uses the webhook default.")] - public string Channel { get; set; } + public string? Channel { get; init; } [SeqAppSetting( DisplayName = "App name", IsOptional = true, HelpText = "The name that Seq uses when posting to Slack. If not specified, uses the name of the Seq app instance. The name can also be read from a property by using the format [PropertyName].")] - public string Username { get; set; } + public string? Username { get; init; } [SeqAppSetting( DisplayName = "Suppression time (minutes)", IsOptional = true, HelpText = "Once an event type has been sent to Slack, the time to wait before sending again. The default is zero.")] - public int SuppressionMinutes { get; set; } = 0; + public int SuppressionMinutes { get; init; } = 0; [SeqAppSetting( DisplayName = "Exclude optional attachments", IsOptional = true, HelpText = "Should event property information and other optional attachments be excluded from the message? The default is to attach all properties.")] - public bool ExcludePropertyInformation { get; set; } + public bool ExcludePropertyInformation { get; init; } [SeqAppSetting( DisplayName = "Message", HelpText = "The message to send to Slack. Refer to https://api.slack.com/docs/formatting for formatting options. Event property values can be added in the format [PropertyName]. The default is \"[RenderedMessage]\". Added as a Markdown attachment for Alerts.", IsOptional = true)] - public string MessageTemplate { get; set; } + public string? MessageTemplate { get; init; } [SeqAppSetting( DisplayName = "Icon URL", HelpText = "The image to show in the channel for the message. The default is " + SlackMessageBuilder.DefaultIconUrl + ".", IsOptional = true)] - public string IconUrl { get; set; } + public string? IconUrl { get; init; } [SeqAppSetting( DisplayName = "Proxy Server", HelpText = "Proxy server to be used when making HTTPS requests to the Slack API. Uses default credentials.", IsOptional = true)] - public string ProxyServer { get; set; } + public string? ProxyServer { get; init; } [SeqAppSetting( DisplayName = "Maximum property length", IsOptional = true, HelpText = "If a property when converted to a string is longer than this number it will be truncated.")] - public int? MaxPropertyLength { get; set; } = null; + public int? MaxPropertyLength { get; init; } [SeqAppSetting( DisplayName = "Included properties", IsOptional = true, HelpText = "Comma separated list of properties to include as attachments. The default is to include all properties.")] - public string IncludedProperties { get; set; } + public string? IncludedProperties { get; init; } - private EventTypeSuppressions _suppressions; - private ISlackApi _slackApi; + private EventTypeSuppressions? _suppressions; + private ISlackApi? _slackApi; // Used reflectively by the app host. // ReSharper disable once UnusedMember.Global @@ -96,10 +92,7 @@ internal SlackApp(ISlackApi slackApi) protected override void OnAttached() { - if (_slackApi == null) - { - _slackApi = new SlackApi(ProxyServer); - } + _slackApi ??= new SlackApi(ProxyServer); var propertyValueFormatter = new PropertyValueFormatter(MaxPropertyLength); @@ -117,16 +110,16 @@ protected override void OnAttached() public async Task OnAsync(Event evt) { - _suppressions = _suppressions ?? new EventTypeSuppressions(SuppressionMinutes); + _suppressions ??= new EventTypeSuppressions(SuppressionMinutes); if (_suppressions.ShouldSuppressAt(evt.EventType, DateTime.UtcNow)) return; - if (!_messageBuilders.TryGetValue(evt.EventType, out var builder)) - builder = _defaultMessageBuilder; + if (!_messageBuilders!.TryGetValue(evt.EventType, out var builder)) + builder = _defaultMessageBuilder!; var message = builder.BuildMessage(evt); - await _slackApi.SendMessageAsync(WebhookUrl, message); + await _slackApi!.SendMessageAsync(WebhookUrl, message); } } } diff --git a/test/Seq.App.Slack.Tests/EventFormattingTests.cs b/test/Seq.App.Slack.Tests/EventFormattingTests.cs index 2c43c4d..0f2bc4f 100644 --- a/test/Seq.App.Slack.Tests/EventFormattingTests.cs +++ b/test/Seq.App.Slack.Tests/EventFormattingTests.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using Seq.App.Slack.Formatting; using Seq.Apps; using Seq.Apps.LogEvents; @@ -12,7 +10,7 @@ public class EventFormattingTests [Fact] public void SubstitutePlaceholders_ReplacesValue() { - var result = ExecuteSubstitutePlaceholders(new Dictionary() + var result = ExecuteSubstitutePlaceholders(new Dictionary { {"noun", "force"}, {"name", "Luke"} @@ -24,7 +22,7 @@ public void SubstitutePlaceholders_ReplacesValue() [Fact] public void SubstitutePlaceholders_IgnoresCase() { - var result = ExecuteSubstitutePlaceholders(new Dictionary() + var result = ExecuteSubstitutePlaceholders(new Dictionary { {"Noun", "force"}, {"naMe", "Newton"} @@ -36,7 +34,7 @@ public void SubstitutePlaceholders_IgnoresCase() [Fact] public void SubstitutePlaceholders_IgnoresMissingProperties() { - var result = ExecuteSubstitutePlaceholders(new Dictionary() + var result = ExecuteSubstitutePlaceholders(new Dictionary { {"noun", "spoon"} }); @@ -47,7 +45,7 @@ public void SubstitutePlaceholders_IgnoresMissingProperties() [Fact] public void SubstitutePlaceholders_AllowsPropertiesThatOnlyDifferByCase() { - var result = ExecuteSubstitutePlaceholders(new Dictionary() + var result = ExecuteSubstitutePlaceholders(new Dictionary { {"noun", "velcro"}, {"Noun", "zipper"} @@ -74,11 +72,11 @@ public void PropertiesAreRetrievedSafely(string path, string expected) { var data = new LogEventData { - Properties = new Dictionary + Properties = new Dictionary { ["First"] = null, ["Second"] = 20, - ["Third"] = new Dictionary + ["Third"] = new Dictionary { ["Fourth"] = "test" } @@ -91,10 +89,10 @@ public void PropertiesAreRetrievedSafely(string path, string expected) Assert.Equal(expected, actual); } - private static string ExecuteSubstitutePlaceholders(IReadOnlyDictionary properties) + private static string ExecuteSubstitutePlaceholders(IReadOnlyDictionary? properties) => EventFormatting.SubstitutePlaceholders( "Use the [noun] [name]", - new Event("", 1, DateTime.Now, new LogEventData() {Properties = properties}) + new Event("", 1, DateTime.Now, new LogEventData {Properties = properties}) ); } } diff --git a/test/Seq.App.Slack.Tests/Seq.App.Slack.Tests.csproj b/test/Seq.App.Slack.Tests/Seq.App.Slack.Tests.csproj index 2d9eb50..952c798 100644 --- a/test/Seq.App.Slack.Tests/Seq.App.Slack.Tests.csproj +++ b/test/Seq.App.Slack.Tests/Seq.App.Slack.Tests.csproj @@ -1,7 +1,7 @@ - net6.0 + net10.0 false diff --git a/test/Seq.App.Slack.Tests/SlackAppTests.cs b/test/Seq.App.Slack.Tests/SlackAppTests.cs index 712ee2c..8c03b32 100644 --- a/test/Seq.App.Slack.Tests/SlackAppTests.cs +++ b/test/Seq.App.Slack.Tests/SlackAppTests.cs @@ -1,10 +1,6 @@ using NSubstitute; using Seq.Apps; using Seq.Apps.LogEvents; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Seq.App.Slack.Api; using Xunit; @@ -12,24 +8,23 @@ namespace Seq.App.Slack.Tests { public class SlackAppTests { - private ISlackApi _slackApi; - private IAppHost _appHost; - private Event _event; + private ISlackApi _slackApi = null!; + private IAppHost _appHost = null!; + private Event _event = null!; - private SlackApp CreateSlackApp(Action configure = null) + private SlackApp CreateSlackApp(string? includedProperties = null) { _slackApi = Substitute.For(); _appHost = Substitute.For(); - _appHost.Host.Returns(new Host("http://listen.example.com", "instance")); + _appHost.Host.Returns(new Host("https://listen.example.com", "instance")); _appHost.App.Returns(new Apps.App("app-id", "App Title", new Dictionary(), "storage-path")); var slackApp = new SlackApp(_slackApi) { - WebhookUrl = "http://webhook.example.com" + WebhookUrl = "https://webhook.example.com", + IncludedProperties = includedProperties }; - configure?.Invoke(slackApp); - slackApp.Attach(_appHost); _event = new Event("id", 1, DateTime.Now, new LogEventData @@ -50,7 +45,7 @@ private SlackApp CreateSlackApp(Action configure = null) [Fact] public async Task GivenIncludedPropertiesWithWhitespaceAreSuppliedThenTheyAreRespected() { - var slackApp = CreateSlackApp(app => app.IncludedProperties = " Property1 , Property2 "); + var slackApp = CreateSlackApp(" Property1 , Property2 "); await slackApp.OnAsync(_event); @@ -63,7 +58,7 @@ await _slackApi.Received().SendMessageAsync(slackApp.WebhookUrl, Arg.Is app.IncludedProperties = "Property1,Property3"); + var slackApp = CreateSlackApp("Property1,Property3"); await slackApp.OnAsync(_event); @@ -89,7 +84,7 @@ await _slackApi.Received().SendMessageAsync(slackApp.WebhookUrl, Arg.Is app.IncludedProperties = "Property1,PropertyDoesntExist"); + var slackApp = CreateSlackApp("Property1,PropertyDoesntExist"); await slackApp.OnAsync(_event); From 3f181e181f622b45894cd3796773e6fcc5ba7d6c Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Thu, 4 Jun 2026 13:32:22 +1000 Subject: [PATCH 3/6] Globally switch to file-scoped namespaes (saving death by a thousand cuts as these files are edited one by one :-) --- src/Seq.App.Slack/Api/ISlackApi.cs | 11 +- src/Seq.App.Slack/Api/SlackApi.cs | 73 +++--- src/Seq.App.Slack/Api/SlackMessage.cs | 47 ++-- .../Api/SlackMessageAttachment.cs | 47 ++-- .../Api/SlackMessageAttachmentField.cs | 29 ++- .../Formatting/EventFormatting.cs | 155 +++++++------ .../Formatting/PropertyValueFormatter.cs | 51 ++--- src/Seq.App.Slack/Formatting/SlackSyntax.cs | 53 +++-- .../Messages/AlertV1MessageBuilder.cs | 59 +++-- .../Messages/AlertV2MessageBuilder.cs | 213 +++++++++-------- .../Messages/DefaultMessageBuilder.cs | 117 +++++----- .../Messages/SlackMessageBuilder.cs | 97 ++++---- src/Seq.App.Slack/SlackApp.cs | 215 +++++++++--------- .../Suppression/EventTypeSuppressions.cs | 59 +++-- .../EventFormattingTests.cs | 139 ++++++----- .../PropertyFormatterTests.cs | 78 ++++--- test/Seq.App.Slack.Tests/SlackAppTests.cs | 147 ++++++------ test/Seq.App.Slack.Tests/SlackSyntaxTests.cs | 17 +- 18 files changed, 794 insertions(+), 813 deletions(-) diff --git a/src/Seq.App.Slack/Api/ISlackApi.cs b/src/Seq.App.Slack/Api/ISlackApi.cs index 992d057..f70a0ba 100644 --- a/src/Seq.App.Slack/Api/ISlackApi.cs +++ b/src/Seq.App.Slack/Api/ISlackApi.cs @@ -1,9 +1,8 @@ using System.Threading.Tasks; -namespace Seq.App.Slack.Api +namespace Seq.App.Slack.Api; + +public interface ISlackApi { - public interface ISlackApi - { - Task SendMessageAsync(string webhookUrl, SlackMessage message); - } -} + Task SendMessageAsync(string webhookUrl, SlackMessage message); +} \ No newline at end of file diff --git a/src/Seq.App.Slack/Api/SlackApi.cs b/src/Seq.App.Slack/Api/SlackApi.cs index ec1ad5e..1ef5695 100644 --- a/src/Seq.App.Slack/Api/SlackApi.cs +++ b/src/Seq.App.Slack/Api/SlackApi.cs @@ -4,52 +4,51 @@ using System.Threading.Tasks; using Newtonsoft.Json; -namespace Seq.App.Slack.Api +namespace Seq.App.Slack.Api; + +class SlackApi : ISlackApi { - class SlackApi : ISlackApi + static SlackApi() { - static SlackApi() - { - // Enable TLS 1.2 before any connection to the Slack API is made. - ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12; - } + // Enable TLS 1.2 before any connection to the Slack API is made. + ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12; + } - private readonly HttpClient _httpClient; - private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings - { - NullValueHandling = NullValueHandling.Ignore - }; + private readonly HttpClient _httpClient; + private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore + }; - public SlackApi(string? proxyServer) + public SlackApi(string? proxyServer) + { + if (!string.IsNullOrWhiteSpace(proxyServer)) { - if (!string.IsNullOrWhiteSpace(proxyServer)) + var proxy = new WebProxy(proxyServer, false) { - var proxy = new WebProxy(proxyServer, false) - { - UseDefaultCredentials = true - }; - var httpClientHandler = new HttpClientHandler() - { - Proxy = proxy, - PreAuthenticate = true, - UseDefaultCredentials = true, - }; - _httpClient = new HttpClient(handler: httpClientHandler); - } - else + UseDefaultCredentials = true + }; + var httpClientHandler = new HttpClientHandler() { - _httpClient = new HttpClient(); - } + Proxy = proxy, + PreAuthenticate = true, + UseDefaultCredentials = true, + }; + _httpClient = new HttpClient(handler: httpClientHandler); + } + else + { + _httpClient = new HttpClient(); } + } - public async Task SendMessageAsync(string webhookUrl, SlackMessage message) + public async Task SendMessageAsync(string webhookUrl, SlackMessage message) + { + var json = JsonConvert.SerializeObject(message, JsonSettings); + using (var content = new StringContent(json, Encoding.UTF8, "application/json")) { - var json = JsonConvert.SerializeObject(message, JsonSettings); - using (var content = new StringContent(json, Encoding.UTF8, "application/json")) - { - var resp = await _httpClient.PostAsync(webhookUrl, content); - resp.EnsureSuccessStatusCode(); - } + var resp = await _httpClient.PostAsync(webhookUrl, content); + resp.EnsureSuccessStatusCode(); } } -} +} \ No newline at end of file diff --git a/src/Seq.App.Slack/Api/SlackMessage.cs b/src/Seq.App.Slack/Api/SlackMessage.cs index c402be6..c57cac0 100644 --- a/src/Seq.App.Slack/Api/SlackMessage.cs +++ b/src/Seq.App.Slack/Api/SlackMessage.cs @@ -1,36 +1,35 @@ using System.Collections.Generic; using Newtonsoft.Json; -namespace Seq.App.Slack.Api +namespace Seq.App.Slack.Api; + +public class SlackMessage { - public class SlackMessage - { - [JsonProperty("fallback")] - public string Fallback { get; } + [JsonProperty("fallback")] + public string Fallback { get; } - [JsonProperty("text")] - public string Text { get; } + [JsonProperty("text")] + public string Text { get; } - [JsonProperty("attachments")] - public List Attachments { get; } + [JsonProperty("attachments")] + public List Attachments { get; } - [JsonProperty("username")] - public string Username { get; } + [JsonProperty("username")] + public string Username { get; } - [JsonProperty("icon_url")] - public string IconUrl { get; } + [JsonProperty("icon_url")] + public string IconUrl { get; } - [JsonProperty("channel")] - public string? Channel { get; } + [JsonProperty("channel")] + public string? Channel { get; } - public SlackMessage(string fallback, string text, string username, string iconUrl, string? channel) - { - this.Fallback = fallback; - this.Text = text; - this.Attachments = new List(); - this.Username = username; - this.IconUrl = iconUrl; - this.Channel = channel; - } + public SlackMessage(string fallback, string text, string username, string iconUrl, string? channel) + { + this.Fallback = fallback; + this.Text = text; + this.Attachments = new List(); + this.Username = username; + this.IconUrl = iconUrl; + this.Channel = channel; } } \ No newline at end of file diff --git a/src/Seq.App.Slack/Api/SlackMessageAttachment.cs b/src/Seq.App.Slack/Api/SlackMessageAttachment.cs index 16cbd8c..eab7953 100644 --- a/src/Seq.App.Slack/Api/SlackMessageAttachment.cs +++ b/src/Seq.App.Slack/Api/SlackMessageAttachment.cs @@ -1,35 +1,34 @@ using System.Collections.Generic; using Newtonsoft.Json; -namespace Seq.App.Slack.Api +namespace Seq.App.Slack.Api; + +public class SlackMessageAttachment { - public class SlackMessageAttachment - { - [JsonProperty("color")] - public string Color { get; } + [JsonProperty("color")] + public string Color { get; } - [JsonProperty("text")] - public string? Text { get; } + [JsonProperty("text")] + public string? Text { get; } - [JsonProperty("title")] - public string? Title { get; } + [JsonProperty("title")] + public string? Title { get; } - [JsonProperty("fields")] - public List Fields { get; } + [JsonProperty("fields")] + public List Fields { get; } - [JsonProperty("mrkdwn_in")] - public List MarkdownIn { get; } + [JsonProperty("mrkdwn_in")] + public List MarkdownIn { get; } - public SlackMessageAttachment(string color, string? text = null, string? title = null, bool textIsMarkdown = false) - { - this.Color = color; - this.Text = text; - this.Title = title; - this.Fields = new List(); - this.MarkdownIn = new List(); + public SlackMessageAttachment(string color, string? text = null, string? title = null, bool textIsMarkdown = false) + { + this.Color = color; + this.Text = text; + this.Title = title; + this.Fields = new List(); + this.MarkdownIn = new List(); - if (textIsMarkdown) - this.MarkdownIn.Add("text"); - } + if (textIsMarkdown) + this.MarkdownIn.Add("text"); } -} +} \ No newline at end of file diff --git a/src/Seq.App.Slack/Api/SlackMessageAttachmentField.cs b/src/Seq.App.Slack/Api/SlackMessageAttachmentField.cs index d2ad399..63926f8 100644 --- a/src/Seq.App.Slack/Api/SlackMessageAttachmentField.cs +++ b/src/Seq.App.Slack/Api/SlackMessageAttachmentField.cs @@ -1,23 +1,22 @@ using Newtonsoft.Json; -namespace Seq.App.Slack.Api +namespace Seq.App.Slack.Api; + +public class SlackMessageAttachmentField { - public class SlackMessageAttachmentField - { - [JsonProperty("title")] - public string Title { get; } + [JsonProperty("title")] + public string Title { get; } - [JsonProperty("value")] - public string Value { get; } + [JsonProperty("value")] + public string Value { get; } - [JsonProperty("short")] - public bool Short { get; } + [JsonProperty("short")] + public bool Short { get; } - public SlackMessageAttachmentField(string title, string value, bool @short) - { - Title = title; - Value = value; - Short = @short; - } + public SlackMessageAttachmentField(string title, string value, bool @short) + { + Title = title; + Value = value; + Short = @short; } } \ No newline at end of file diff --git a/src/Seq.App.Slack/Formatting/EventFormatting.cs b/src/Seq.App.Slack/Formatting/EventFormatting.cs index 0b0b733..9aa5033 100644 --- a/src/Seq.App.Slack/Formatting/EventFormatting.cs +++ b/src/Seq.App.Slack/Formatting/EventFormatting.cs @@ -3,105 +3,104 @@ using Seq.Apps.LogEvents; using Serilog; -namespace Seq.App.Slack.Formatting +namespace Seq.App.Slack.Formatting; + +static class EventFormatting { - static class EventFormatting + private static readonly Regex PlaceholdersRegex = new(@"(\[(?[^\[\]]+?)(\:(?[^\[\]]+?))?\])", RegexOptions.CultureInvariant | RegexOptions.Compiled); + + private static readonly IReadOnlyDictionary LevelColorMap = new Dictionary + { + [LogEventLevel.Verbose] = "#D3D3D3", + [LogEventLevel.Debug] = "#D3D3D3", + [LogEventLevel.Information] = "#00A000", + [LogEventLevel.Warning] = "#f9c019", + [LogEventLevel.Error] = "#e03836", + [LogEventLevel.Fatal] = "#e03836", + }; + + public static string LevelToColor(LogEventLevel level) { - private static readonly Regex PlaceholdersRegex = new(@"(\[(?[^\[\]]+?)(\:(?[^\[\]]+?))?\])", RegexOptions.CultureInvariant | RegexOptions.Compiled); + return LevelColorMap[level]; + } - private static readonly IReadOnlyDictionary LevelColorMap = new Dictionary - { - [LogEventLevel.Verbose] = "#D3D3D3", - [LogEventLevel.Debug] = "#D3D3D3", - [LogEventLevel.Information] = "#00A000", - [LogEventLevel.Warning] = "#f9c019", - [LogEventLevel.Error] = "#e03836", - [LogEventLevel.Fatal] = "#e03836", - }; - - public static string LevelToColor(LogEventLevel level) - { - return LevelColorMap[level]; - } + public static string SafeGetProperty(Event evt, string propertyPath, bool raw = false) + { + var path = new Queue(propertyPath.Split('.')); + var root = evt.Data.Properties; - public static string SafeGetProperty(Event evt, string propertyPath, bool raw = false) + while(root != null) { - var path = new Queue(propertyPath.Split('.')); - var root = evt.Data.Properties; + var step = path.Dequeue(); + if (!root.TryGetValue(step, out var next)) + return ""; - while(root != null) + if (path.Count == 0) { - var step = path.Dequeue(); - if (!root.TryGetValue(step, out var next)) - return ""; - - if (path.Count == 0) - { - if (next == null) return "`null`"; - return raw ? next.ToString() ?? "" : SlackSyntax.Escape(next.ToString() ?? ""); - } - - root = next as IReadOnlyDictionary; + if (next == null) return "`null`"; + return raw ? next.ToString() ?? "" : SlackSyntax.Escape(next.ToString() ?? ""); } - return ""; + root = next as IReadOnlyDictionary; } - public static string SubstitutePlaceholders(string messageTemplateToUse, Event evt, bool addLogData = true) - { - var data = evt.Data; - var eventType = evt.EventType; - var level = data.Level; + return ""; + } + + public static string SubstitutePlaceholders(string messageTemplateToUse, Event evt, bool addLogData = true) + { + var data = evt.Data; + var eventType = evt.EventType; + var level = data.Level; - var placeholders = new Dictionary(); - if (data.Properties != null) - foreach (var kvp in data.Properties) - placeholders[kvp.Key.ToLower()] = kvp.Value; + var placeholders = new Dictionary(); + if (data.Properties != null) + foreach (var kvp in data.Properties) + placeholders[kvp.Key.ToLower()] = kvp.Value; - if (addLogData) - { - AddValueIfKeyDoesNotExist(placeholders, "Level", level); - AddValueIfKeyDoesNotExist(placeholders, "EventType", eventType); - AddValueIfKeyDoesNotExist(placeholders, "RenderedMessage", data.RenderedMessage); - AddValueIfKeyDoesNotExist(placeholders, "Exception", data.Exception); - } - return PlaceholdersRegex.Replace(messageTemplateToUse, m => - { - var key = m.Groups["key"].Value.ToLower(); - var format = m.Groups["format"].Value; - return placeholders.TryGetValue(key, out var placeholder) ? FormatValue(placeholder, format) : m.Value; - }); + if (addLogData) + { + AddValueIfKeyDoesNotExist(placeholders, "Level", level); + AddValueIfKeyDoesNotExist(placeholders, "EventType", eventType); + AddValueIfKeyDoesNotExist(placeholders, "RenderedMessage", data.RenderedMessage); + AddValueIfKeyDoesNotExist(placeholders, "Exception", data.Exception); } - - private static string FormatValue(object? value, string format) + return PlaceholdersRegex.Replace(messageTemplateToUse, m => { - var rawValue = value?.ToString() ?? SlackSyntax.Code("null"); - - if (string.IsNullOrWhiteSpace(format)) - return rawValue; + var key = m.Groups["key"].Value.ToLower(); + var format = m.Groups["format"].Value; + return placeholders.TryGetValue(key, out var placeholder) ? FormatValue(placeholder, format) : m.Value; + }); + } - try - { - // Field values can contain formatting. - return SlackSyntax.Escape(string.Format(format, rawValue)); - } - catch (Exception ex) - { - Log.Error(ex, "Could not format Slack message: {Value} {Format}", value, format); - } + private static string FormatValue(object? value, string format) + { + var rawValue = value?.ToString() ?? SlackSyntax.Code("null"); + if (string.IsNullOrWhiteSpace(format)) return rawValue; - } - private static void AddValueIfKeyDoesNotExist(IDictionary placeholders, string key, object value) + try { - var loweredKey = key.ToLower(); - placeholders.TryAdd(loweredKey, value); + // Field values can contain formatting. + return SlackSyntax.Escape(string.Format(format, rawValue)); } - - public static string LinkToId(Host host, string eventId) + catch (Exception ex) { - return $"{host.BaseUri.TrimEnd('/')}/#/events?filter=@Id%20%3D%3D%20%22{eventId}%22&show=expanded"; + Log.Error(ex, "Could not format Slack message: {Value} {Format}", value, format); } + + return rawValue; + } + + private static void AddValueIfKeyDoesNotExist(IDictionary placeholders, string key, object value) + { + var loweredKey = key.ToLower(); + placeholders.TryAdd(loweredKey, value); + } + + public static string LinkToId(Host host, string eventId) + { + return $"{host.BaseUri.TrimEnd('/')}/#/events?filter=@Id%20%3D%3D%20%22{eventId}%22&show=expanded"; } -} +} \ No newline at end of file diff --git a/src/Seq.App.Slack/Formatting/PropertyValueFormatter.cs b/src/Seq.App.Slack/Formatting/PropertyValueFormatter.cs index b02e7e1..78e3130 100644 --- a/src/Seq.App.Slack/Formatting/PropertyValueFormatter.cs +++ b/src/Seq.App.Slack/Formatting/PropertyValueFormatter.cs @@ -1,39 +1,38 @@ using Newtonsoft.Json; -namespace Seq.App.Slack.Formatting +namespace Seq.App.Slack.Formatting; + +class PropertyValueFormatter { - class PropertyValueFormatter - { - private readonly int? _maxPropertyLength; + private readonly int? _maxPropertyLength; - private static readonly JsonSerializerSettings JsonSettings = new() - { - NullValueHandling = NullValueHandling.Ignore - }; + private static readonly JsonSerializerSettings JsonSettings = new() + { + NullValueHandling = NullValueHandling.Ignore + }; - public PropertyValueFormatter(int? maxPropertyLength) - { - _maxPropertyLength = maxPropertyLength; - } + public PropertyValueFormatter(int? maxPropertyLength) + { + _maxPropertyLength = maxPropertyLength; + } - public string ConvertPropertyValueToString(object? propertyValue) - { - if (propertyValue == null) - return string.Empty; + public string ConvertPropertyValueToString(object? propertyValue) + { + if (propertyValue == null) + return string.Empty; - var t = propertyValue.GetType(); - var isDict = t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Dictionary<,>); - var result = isDict ? JsonConvert.SerializeObject(propertyValue, JsonSettings) : propertyValue.ToString(); + var t = propertyValue.GetType(); + var isDict = t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Dictionary<,>); + var result = isDict ? JsonConvert.SerializeObject(propertyValue, JsonSettings) : propertyValue.ToString(); - if (_maxPropertyLength.HasValue) + if (_maxPropertyLength.HasValue) + { + if (result?.Length > _maxPropertyLength) { - if (result?.Length > _maxPropertyLength) - { - result = result[.._maxPropertyLength.Value] + "..."; - } + result = result[.._maxPropertyLength.Value] + "..."; } - - return result ?? ""; } + + return result ?? ""; } } \ No newline at end of file diff --git a/src/Seq.App.Slack/Formatting/SlackSyntax.cs b/src/Seq.App.Slack/Formatting/SlackSyntax.cs index e81753d..1f58bd4 100644 --- a/src/Seq.App.Slack/Formatting/SlackSyntax.cs +++ b/src/Seq.App.Slack/Formatting/SlackSyntax.cs @@ -1,35 +1,34 @@ using System; -namespace Seq.App.Slack.Formatting +namespace Seq.App.Slack.Formatting; + +static class SlackSyntax { - static class SlackSyntax + public static string Escape(string s) { - public static string Escape(string s) - { - if (s == null) throw new ArgumentNullException(nameof(s)); - return s - .Replace("<", "<") - .Replace(">", ">") - .Replace("&", "&"); - } + if (s == null) throw new ArgumentNullException(nameof(s)); + return s + .Replace("<", "<") + .Replace(">", ">") + .Replace("&", "&"); + } - public static string Hyperlink(string url, string caption) - { - if (url == null) throw new ArgumentNullException(nameof(url)); - if (caption == null) throw new ArgumentNullException(nameof(caption)); - return $"<{url}|{caption}>"; - } + public static string Hyperlink(string url, string caption) + { + if (url == null) throw new ArgumentNullException(nameof(url)); + if (caption == null) throw new ArgumentNullException(nameof(caption)); + return $"<{url}|{caption}>"; + } - public static string Preformatted(string s) - { - if (s == null) throw new ArgumentNullException(nameof(s)); - return $"```\n{s.Replace("\r", "")}\n```"; - } + public static string Preformatted(string s) + { + if (s == null) throw new ArgumentNullException(nameof(s)); + return $"```\n{s.Replace("\r", "")}\n```"; + } - public static string Code(string s) - { - if (s == null) throw new ArgumentNullException(nameof(s)); - return $"`{s}`"; - } + public static string Code(string s) + { + if (s == null) throw new ArgumentNullException(nameof(s)); + return $"`{s}`"; } -} +} \ No newline at end of file diff --git a/src/Seq.App.Slack/Messages/AlertV1MessageBuilder.cs b/src/Seq.App.Slack/Messages/AlertV1MessageBuilder.cs index f9a5843..ba9e65d 100644 --- a/src/Seq.App.Slack/Messages/AlertV1MessageBuilder.cs +++ b/src/Seq.App.Slack/Messages/AlertV1MessageBuilder.cs @@ -3,44 +3,43 @@ using Seq.Apps; using Seq.Apps.LogEvents; -namespace Seq.App.Slack.Messages +namespace Seq.App.Slack.Messages; + +class AlertV1MessageBuilder : SlackMessageBuilder { - class AlertV1MessageBuilder : SlackMessageBuilder + private readonly string? _messageTemplate; + + public AlertV1MessageBuilder(Apps.App app, string? channel, string? username, string? messageTemplate, string? iconUrl, bool excludeOptionalAttachments) + : base(app, channel, username, iconUrl, excludeOptionalAttachments) { - private readonly string? _messageTemplate; + _messageTemplate = messageTemplate; + } - public AlertV1MessageBuilder(Apps.App app, string? channel, string? username, string? messageTemplate, string? iconUrl, bool excludeOptionalAttachments) - : base(app, channel, username, iconUrl, excludeOptionalAttachments) + protected override string GenerateMessageText(Event evt) + { + var dashboardUrl = EventFormatting.SafeGetProperty(evt, "DashboardUrl"); + var condition = EventFormatting.SafeGetProperty(evt, "Condition", raw: true); + var dashboardTitle = EventFormatting.SafeGetProperty(evt, "DashboardTitle"); + var chartTitle = EventFormatting.SafeGetProperty(evt, "ChartTitle"); + var ownerNamespace = ""; + if (evt.Data.Properties.TryGetValue("OwnerUsername", out var ownerUsernameProperty) && ownerUsernameProperty is string ownerUsername) { - _messageTemplate = messageTemplate; + if (!string.IsNullOrEmpty(ownerUsername)) + ownerNamespace = SlackSyntax.Escape(ownerUsername) + "/"; } + return $"Alert condition {SlackSyntax.Code(condition)} detected on {SlackSyntax.Hyperlink(dashboardUrl, $"{ownerNamespace}{dashboardTitle}/{chartTitle}")}."; + } - protected override string GenerateMessageText(Event evt) - { - var dashboardUrl = EventFormatting.SafeGetProperty(evt, "DashboardUrl"); - var condition = EventFormatting.SafeGetProperty(evt, "Condition", raw: true); - var dashboardTitle = EventFormatting.SafeGetProperty(evt, "DashboardTitle"); - var chartTitle = EventFormatting.SafeGetProperty(evt, "ChartTitle"); - var ownerNamespace = ""; - if (evt.Data.Properties.TryGetValue("OwnerUsername", out var ownerUsernameProperty) && ownerUsernameProperty is string ownerUsername) - { - if (!string.IsNullOrEmpty(ownerUsername)) - ownerNamespace = SlackSyntax.Escape(ownerUsername) + "/"; - } - return $"Alert condition {SlackSyntax.Code(condition)} detected on {SlackSyntax.Hyperlink(dashboardUrl, $"{ownerNamespace}{dashboardTitle}/{chartTitle}")}."; - } + protected override void AddNecessaryAttachments(SlackMessage message, Event evt, string color) + { + var resultsUrl = EventFormatting.SafeGetProperty(evt, "ResultsUrl"); + var resultsText = SlackSyntax.Hyperlink(resultsUrl, "Explore detected results in Seq"); + var results = new SlackMessageAttachment(color, resultsText); + message.Attachments.Add(results); - protected override void AddNecessaryAttachments(SlackMessage message, Event evt, string color) + if (_messageTemplate != null) { - var resultsUrl = EventFormatting.SafeGetProperty(evt, "ResultsUrl"); - var resultsText = SlackSyntax.Hyperlink(resultsUrl, "Explore detected results in Seq"); - var results = new SlackMessageAttachment(color, resultsText); - message.Attachments.Add(results); - - if (_messageTemplate != null) - { - message.Attachments.Add(new SlackMessageAttachment(color, _messageTemplate, null, true)); - } + message.Attachments.Add(new SlackMessageAttachment(color, _messageTemplate, null, true)); } } } \ No newline at end of file diff --git a/src/Seq.App.Slack/Messages/AlertV2MessageBuilder.cs b/src/Seq.App.Slack/Messages/AlertV2MessageBuilder.cs index 9a1c734..b00c4f6 100644 --- a/src/Seq.App.Slack/Messages/AlertV2MessageBuilder.cs +++ b/src/Seq.App.Slack/Messages/AlertV2MessageBuilder.cs @@ -5,142 +5,141 @@ using Seq.Apps.LogEvents; // ReSharper disable PossibleMultipleEnumeration -namespace Seq.App.Slack.Messages +namespace Seq.App.Slack.Messages; + +class AlertV2MessageBuilder : SlackMessageBuilder { - class AlertV2MessageBuilder : SlackMessageBuilder + private readonly Host _host; + private readonly PropertyValueFormatter _propertyValueFormatter; + private readonly string? _messageTemplate; + private static readonly HashSet SpecialProperties = ["NamespacedAlertTitle", "Alert", "Source", "SuppressedUntil", "Failures"]; + + public AlertV2MessageBuilder(Host host, Apps.App app, PropertyValueFormatter propertyValueFormatter, string? channel, string? username, string? messageTemplate, string? iconUrl, bool excludeOptionalAttachments) + : base(app, channel, username, iconUrl, excludeOptionalAttachments) + { + _host = host; + _propertyValueFormatter = propertyValueFormatter ?? throw new ArgumentNullException(nameof(propertyValueFormatter)); + _messageTemplate = messageTemplate; + } + + protected override string GenerateMessageText(Event evt) { - private readonly Host _host; - private readonly PropertyValueFormatter _propertyValueFormatter; - private readonly string? _messageTemplate; - private static readonly HashSet SpecialProperties = ["NamespacedAlertTitle", "Alert", "Source", "SuppressedUntil", "Failures"]; + var namespacedAlertTitle = EventFormatting.SafeGetProperty(evt, "NamespacedAlertTitle"); + var alertUrl = EventFormatting.SafeGetProperty(evt, "Alert.Url"); + return $"Alert condition triggered by {SlackSyntax.Hyperlink(alertUrl, namespacedAlertTitle)}"; + } + + protected override void AddNecessaryAttachments(SlackMessage message, Event evt, string color) + { + var resultsUrl = EventFormatting.SafeGetProperty(evt, "Source.ResultsUrl"); + var contributingEventsUrl = EventFormatting.SafeGetProperty(evt, "Source.ContributingEventsUrl"); - public AlertV2MessageBuilder(Host host, Apps.App app, PropertyValueFormatter propertyValueFormatter, string? channel, string? username, string? messageTemplate, string? iconUrl, bool excludeOptionalAttachments) - : base(app, channel, username, iconUrl, excludeOptionalAttachments) + if (!string.IsNullOrWhiteSpace(contributingEventsUrl)) { - _host = host; - _propertyValueFormatter = propertyValueFormatter ?? throw new ArgumentNullException(nameof(propertyValueFormatter)); - _messageTemplate = messageTemplate; + // 2026.1+ + var exploreText = + "Explore " + + SlackSyntax.Hyperlink(resultsUrl, "detected results") + + " and " + + SlackSyntax.Hyperlink(contributingEventsUrl, "contributing events") + + " in Seq"; + message.Attachments.Add(new SlackMessageAttachment(color, exploreText)); } - - protected override string GenerateMessageText(Event evt) + else { - var namespacedAlertTitle = EventFormatting.SafeGetProperty(evt, "NamespacedAlertTitle"); - var alertUrl = EventFormatting.SafeGetProperty(evt, "Alert.Url"); - return $"Alert condition triggered by {SlackSyntax.Hyperlink(alertUrl, namespacedAlertTitle)}"; + // 2025.2 and earlier + var resultsText = SlackSyntax.Hyperlink(resultsUrl, "Explore detected results in Seq"); + message.Attachments.Add(new SlackMessageAttachment(color, resultsText)); } - protected override void AddNecessaryAttachments(SlackMessage message, Event evt, string color) + if (_messageTemplate != null) { - var resultsUrl = EventFormatting.SafeGetProperty(evt, "Source.ResultsUrl"); - var contributingEventsUrl = EventFormatting.SafeGetProperty(evt, "Source.ContributingEventsUrl"); - - if (!string.IsNullOrWhiteSpace(contributingEventsUrl)) - { - // 2026.1+ - var exploreText = - "Explore " + - SlackSyntax.Hyperlink(resultsUrl, "detected results") + - " and " + - SlackSyntax.Hyperlink(contributingEventsUrl, "contributing events") + - " in Seq"; - message.Attachments.Add(new SlackMessageAttachment(color, exploreText)); - } - else - { - // 2025.2 and earlier - var resultsText = SlackSyntax.Hyperlink(resultsUrl, "Explore detected results in Seq"); - message.Attachments.Add(new SlackMessageAttachment(color, resultsText)); - } - - if (_messageTemplate != null) - { - message.Attachments.Add(new SlackMessageAttachment(color, _messageTemplate, null, true)); - } + message.Attachments.Add(new SlackMessageAttachment(color, _messageTemplate, null, true)); + } - if (evt.Data.Properties.TryGetValue("Failures", out var f) && - f is IEnumerable failures) + if (evt.Data.Properties.TryGetValue("Failures", out var f) && + f is IEnumerable failures) + { + foreach (var failure in failures) { - foreach (var failure in failures) - { - var failed = new SlackMessageAttachment(color, SlackSyntax.Escape(failure?.ToString() ?? ""), - "Alert Processing Failed"); - message.Attachments.Add(failed); - } + var failed = new SlackMessageAttachment(color, SlackSyntax.Escape(failure?.ToString() ?? ""), + "Alert Processing Failed"); + message.Attachments.Add(failed); } + } - var notificationProperties = new SlackMessageAttachment(color); - foreach (var property in evt.Data.Properties) - { - if (SpecialProperties.Contains(property.Key)) continue; - var value = _propertyValueFormatter.ConvertPropertyValueToString(property.Value); - notificationProperties.Fields.Add(new SlackMessageAttachmentField(property.Key, value, @short: false)); - } + var notificationProperties = new SlackMessageAttachment(color); + foreach (var property in evt.Data.Properties) + { + if (SpecialProperties.Contains(property.Key)) continue; + var value = _propertyValueFormatter.ConvertPropertyValueToString(property.Value); + notificationProperties.Fields.Add(new SlackMessageAttachmentField(property.Key, value, @short: false)); + } - if (notificationProperties.Fields.Count != 0) - message.Attachments.Add(notificationProperties); + if (notificationProperties.Fields.Count != 0) + message.Attachments.Add(notificationProperties); - if (evt.Data.Properties.TryGetValue("Source", out var r) && - r is IReadOnlyDictionary rd) + if (evt.Data.Properties.TryGetValue("Source", out var r) && + r is IReadOnlyDictionary rd) + { + if (rd.TryGetValue("Results", out var rs) && + rs is IEnumerable results && + results.Count() > 1 && + results.First() is IEnumerable labelsRow) { - if (rd.TryGetValue("Results", out var rs) && - rs is IEnumerable results && - results.Count() > 1 && - results.First() is IEnumerable labelsRow) + var labels = labelsRow.ToArray(); + if (labels.Length > 1 && labels[0] is "time") { - var labels = labelsRow.ToArray(); - if (labels.Length > 1 && labels[0] is "time") + var text = new StringBuilder(); + foreach (var result in results.Skip(1).Cast>()) { - var text = new StringBuilder(); - foreach (var result in results.Skip(1).Cast>()) + var values = result.ToArray(); + + var pre = new StringBuilder(); + pre.Append(_propertyValueFormatter.ConvertPropertyValueToString(values[0])); + pre.Append('\n'); + for (var i = 1; i < values.Length; ++i) { - var values = result.ToArray(); - - var pre = new StringBuilder(); - pre.Append(_propertyValueFormatter.ConvertPropertyValueToString(values[0])); - pre.Append('\n'); - for (var i = 1; i < values.Length; ++i) - { - if (i != 1) - pre.Append('\n'); + if (i != 1) + pre.Append('\n'); - var label = labels[i]; - var value = _propertyValueFormatter.ConvertPropertyValueToString(values[i]); - pre.Append($"{label}: {value}"); - } - - text.Append(SlackSyntax.Preformatted(pre.ToString())); - text.Append('\n'); + var label = labels[i]; + var value = _propertyValueFormatter.ConvertPropertyValueToString(values[i]); + pre.Append($"{label}: {value}"); } - message.Attachments.Add(new SlackMessageAttachment(color, text.ToString(), "Results")); + text.Append(SlackSyntax.Preformatted(pre.ToString())); + text.Append('\n'); } + + message.Attachments.Add(new SlackMessageAttachment(color, text.ToString(), "Results")); } + } - // Contributing events are opted-in per notification, so they're considered minimal (the user can configure - // the alert to exclude them if desired). - if (rd.TryGetValue("ContributingEvents", out var ce) && - ce is IEnumerable contributingEvents && - contributingEvents.Count() > 1) + // Contributing events are opted-in per notification, so they're considered minimal (the user can configure + // the alert to exclude them if desired). + if (rd.TryGetValue("ContributingEvents", out var ce) && + ce is IEnumerable contributingEvents && + contributingEvents.Count() > 1) + { + var text = new StringBuilder(); + foreach (var contributing in contributingEvents.Skip(1).Cast>()) { - var text = new StringBuilder(); - foreach (var contributing in contributingEvents.Skip(1).Cast>()) - { - var columns = contributing.Cast().ToArray(); + var columns = contributing.Cast().ToArray(); - // Timestamp as ISO-8601 string - text.Append(SlackSyntax.Code(columns[1])); - text.Append(' '); + // Timestamp as ISO-8601 string + text.Append(SlackSyntax.Code(columns[1])); + text.Append(' '); - // Message, linking to event - text.Append(SlackSyntax.Hyperlink(EventFormatting.LinkToId(_host, columns[0]), - SlackSyntax.Escape(columns[2]))); - text.Append('\n'); - } - - var events = new SlackMessageAttachment(color, text.ToString(), "Contributing Events"); - message.Attachments.Add(events); + // Message, linking to event + text.Append(SlackSyntax.Hyperlink(EventFormatting.LinkToId(_host, columns[0]), + SlackSyntax.Escape(columns[2]))); + text.Append('\n'); } + + var events = new SlackMessageAttachment(color, text.ToString(), "Contributing Events"); + message.Attachments.Add(events); } } } -} +} \ No newline at end of file diff --git a/src/Seq.App.Slack/Messages/DefaultMessageBuilder.cs b/src/Seq.App.Slack/Messages/DefaultMessageBuilder.cs index 97843ba..ed16b3d 100644 --- a/src/Seq.App.Slack/Messages/DefaultMessageBuilder.cs +++ b/src/Seq.App.Slack/Messages/DefaultMessageBuilder.cs @@ -3,82 +3,81 @@ using Seq.Apps; using Seq.Apps.LogEvents; -namespace Seq.App.Slack.Messages +namespace Seq.App.Slack.Messages; + +class DefaultMessageBuilder : SlackMessageBuilder { - class DefaultMessageBuilder : SlackMessageBuilder - { - private readonly Host _host; - private readonly PropertyValueFormatter _propertyValueFormatter; - private readonly string? _messageTemplate; - private readonly HashSet _includedProperties; + private readonly Host _host; + private readonly PropertyValueFormatter _propertyValueFormatter; + private readonly string? _messageTemplate; + private readonly HashSet _includedProperties; - private static readonly IEnumerable SpecialProperties = ["Id", "Host"]; + private static readonly IEnumerable SpecialProperties = ["Id", "Host"]; + + public DefaultMessageBuilder(Host host, Apps.App app, PropertyValueFormatter propertyValueFormatter, string? channel, + string? username, string? iconUrl, string? messageTemplate, bool excludeOptionalAttachments, IEnumerable includedProperties) + : base(app, channel, username, iconUrl, excludeOptionalAttachments) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _propertyValueFormatter = propertyValueFormatter ?? throw new ArgumentNullException(nameof(propertyValueFormatter)); + _messageTemplate = messageTemplate; + _includedProperties = new HashSet(includedProperties ?? throw new ArgumentNullException(nameof(includedProperties))); + } + + protected override string GenerateMessageText(Event evt) + { + var messageTemplateToUse = string.IsNullOrWhiteSpace(_messageTemplate) ? "[RenderedMessage]" : _messageTemplate; + var message = EventFormatting.SubstitutePlaceholders(messageTemplateToUse, evt); + return SlackSyntax.Escape(message); + } + + protected override void AddNecessaryAttachments(SlackMessage message, Event evt, string color) + { + var viewUrl = EventFormatting.LinkToId(_host, evt.Id); + var viewText = SlackSyntax.Hyperlink(viewUrl, "View this event in Seq"); + var view = new SlackMessageAttachment(color, viewText); + message.Attachments.Add(view); + } - public DefaultMessageBuilder(Host host, Apps.App app, PropertyValueFormatter propertyValueFormatter, string? channel, - string? username, string? iconUrl, string? messageTemplate, bool excludeOptionalAttachments, IEnumerable includedProperties) - : base(app, channel, username, iconUrl, excludeOptionalAttachments) + protected override void AddOptionalAttachments(SlackMessage message, Event evt, string color) + { + var special = new SlackMessageAttachment(color); + special.Fields.Add(new SlackMessageAttachmentField("Level", evt.Data.Level.ToString(), @short: true)); + message.Attachments.Add(special); + + foreach (var key in SpecialProperties) { - _host = host ?? throw new ArgumentNullException(nameof(host)); - _propertyValueFormatter = propertyValueFormatter ?? throw new ArgumentNullException(nameof(propertyValueFormatter)); - _messageTemplate = messageTemplate; - _includedProperties = new HashSet(includedProperties ?? throw new ArgumentNullException(nameof(includedProperties))); + if (evt.Data.Properties == null || !evt.Data.Properties.TryGetValue(key, out var property)) continue; + + special.Fields.Add(new SlackMessageAttachmentField(key, property?.ToString() ?? "", @short: true )); } - protected override string GenerateMessageText(Event evt) + if (evt.Data.Exception != null) { - var messageTemplateToUse = string.IsNullOrWhiteSpace(_messageTemplate) ? "[RenderedMessage]" : _messageTemplate; - var message = EventFormatting.SubstitutePlaceholders(messageTemplateToUse, evt); - return SlackSyntax.Escape(message); + message.Attachments.Add(new SlackMessageAttachment(color, SlackSyntax.Preformatted(evt.Data.Exception), "Exception Details", textIsMarkdown: true)); } - protected override void AddNecessaryAttachments(SlackMessage message, Event evt, string color) + if (evt.Data.Properties != null && evt.Data.Properties.TryGetValue("StackTrace", out var st) && st is string stackTrace) { - var viewUrl = EventFormatting.LinkToId(_host, evt.Id); - var viewText = SlackSyntax.Hyperlink(viewUrl, "View this event in Seq"); - var view = new SlackMessageAttachment(color, viewText); - message.Attachments.Add(view); + message.Attachments.Add(new SlackMessageAttachment(color, SlackSyntax.Preformatted(stackTrace), "Stack Trace", textIsMarkdown: true)); } - protected override void AddOptionalAttachments(SlackMessage message, Event evt, string color) + var otherProperties = new SlackMessageAttachment(color); + if (evt.Data.Properties != null) { - var special = new SlackMessageAttachment(color); - special.Fields.Add(new SlackMessageAttachmentField("Level", evt.Data.Level.ToString(), @short: true)); - message.Attachments.Add(special); - - foreach (var key in SpecialProperties) + foreach (var property in evt.Data.Properties) { - if (evt.Data.Properties == null || !evt.Data.Properties.TryGetValue(key, out var property)) continue; - - special.Fields.Add(new SlackMessageAttachmentField(key, property?.ToString() ?? "", @short: true )); - } + if (SpecialProperties.Contains(property.Key)) continue; + if (property.Key == "StackTrace") continue; + if (_includedProperties.Any() && !_includedProperties.Contains(property.Key)) continue; - if (evt.Data.Exception != null) - { - message.Attachments.Add(new SlackMessageAttachment(color, SlackSyntax.Preformatted(evt.Data.Exception), "Exception Details", textIsMarkdown: true)); - } - - if (evt.Data.Properties != null && evt.Data.Properties.TryGetValue("StackTrace", out var st) && st is string stackTrace) - { - message.Attachments.Add(new SlackMessageAttachment(color, SlackSyntax.Preformatted(stackTrace), "Stack Trace", textIsMarkdown: true)); - } - - var otherProperties = new SlackMessageAttachment(color); - if (evt.Data.Properties != null) - { - foreach (var property in evt.Data.Properties) - { - if (SpecialProperties.Contains(property.Key)) continue; - if (property.Key == "StackTrace") continue; - if (_includedProperties.Any() && !_includedProperties.Contains(property.Key)) continue; - - var value = _propertyValueFormatter.ConvertPropertyValueToString(property.Value); + var value = _propertyValueFormatter.ConvertPropertyValueToString(property.Value); - otherProperties.Fields.Add(new SlackMessageAttachmentField(property.Key, value, @short: false)); - } + otherProperties.Fields.Add(new SlackMessageAttachmentField(property.Key, value, @short: false)); } - - if (otherProperties.Fields.Count != 0) - message.Attachments.Add(otherProperties); } + + if (otherProperties.Fields.Count != 0) + message.Attachments.Add(otherProperties); } } \ No newline at end of file diff --git a/src/Seq.App.Slack/Messages/SlackMessageBuilder.cs b/src/Seq.App.Slack/Messages/SlackMessageBuilder.cs index 36a3b33..242e2ca 100644 --- a/src/Seq.App.Slack/Messages/SlackMessageBuilder.cs +++ b/src/Seq.App.Slack/Messages/SlackMessageBuilder.cs @@ -3,63 +3,62 @@ using Seq.Apps; using Seq.Apps.LogEvents; -namespace Seq.App.Slack.Messages +namespace Seq.App.Slack.Messages; + +abstract class SlackMessageBuilder { - abstract class SlackMessageBuilder - { - public const string DefaultIconUrl = "https://datalust.co/images/nuget/seq-apps.png"; + public const string DefaultIconUrl = "https://datalust.co/images/nuget/seq-apps.png"; - private readonly Apps.App _app; - private readonly string? _channel; - private readonly string? _username; - private readonly string? _iconUrl; - private readonly bool _excludeOptionalAttachments; + private readonly Apps.App _app; + private readonly string? _channel; + private readonly string? _username; + private readonly string? _iconUrl; + private readonly bool _excludeOptionalAttachments; - protected SlackMessageBuilder(Apps.App app, string? channel, - string? username, string? iconUrl, bool excludeOptionalAttachments) - { - _app = app ?? throw new ArgumentNullException(nameof(app)); - _channel = channel; - _username = username; - _iconUrl = iconUrl; - _excludeOptionalAttachments = excludeOptionalAttachments; - } + protected SlackMessageBuilder(Apps.App app, string? channel, + string? username, string? iconUrl, bool excludeOptionalAttachments) + { + _app = app ?? throw new ArgumentNullException(nameof(app)); + _channel = channel; + _username = username; + _iconUrl = iconUrl; + _excludeOptionalAttachments = excludeOptionalAttachments; + } - public SlackMessage BuildMessage(Event evt) - { - var message = new SlackMessage("[" + evt.Data.Level + "] " + evt.Data.RenderedMessage, - GenerateMessageText(evt), - string.IsNullOrWhiteSpace(_username) - ? _app.Title - : EventFormatting.SubstitutePlaceholders(_username, evt, false), - string.IsNullOrWhiteSpace(_iconUrl) ? DefaultIconUrl : _iconUrl, - _channel); + public SlackMessage BuildMessage(Event evt) + { + var message = new SlackMessage("[" + evt.Data.Level + "] " + evt.Data.RenderedMessage, + GenerateMessageText(evt), + string.IsNullOrWhiteSpace(_username) + ? _app.Title + : EventFormatting.SubstitutePlaceholders(_username, evt, false), + string.IsNullOrWhiteSpace(_iconUrl) ? DefaultIconUrl : _iconUrl, + _channel); - var color = EventFormatting.LevelToColor(evt.Data.Level); - AddNecessaryAttachments(message, evt, color); + var color = EventFormatting.LevelToColor(evt.Data.Level); + AddNecessaryAttachments(message, evt, color); - if (!_excludeOptionalAttachments) - { - AddOptionalAttachments(message, evt, color); - } - - return message; + if (!_excludeOptionalAttachments) + { + AddOptionalAttachments(message, evt, color); } - protected abstract string GenerateMessageText(Event evt); + return message; + } - /// - /// Add attachments without which the message cannot be reliably interpreted by a user. - /// - protected virtual void AddNecessaryAttachments(SlackMessage message, Event evt, string color) - { - } + protected abstract string GenerateMessageText(Event evt); - /// - /// Add attachments that don't impact the meaning/interpretation of the message. - /// - protected virtual void AddOptionalAttachments(SlackMessage message, Event evt, string color) - { - } + /// + /// Add attachments without which the message cannot be reliably interpreted by a user. + /// + protected virtual void AddNecessaryAttachments(SlackMessage message, Event evt, string color) + { + } + + /// + /// Add attachments that don't impact the meaning/interpretation of the message. + /// + protected virtual void AddOptionalAttachments(SlackMessage message, Event evt, string color) + { } -} +} \ No newline at end of file diff --git a/src/Seq.App.Slack/SlackApp.cs b/src/Seq.App.Slack/SlackApp.cs index 3da1db4..62195b2 100644 --- a/src/Seq.App.Slack/SlackApp.cs +++ b/src/Seq.App.Slack/SlackApp.cs @@ -7,119 +7,118 @@ // ReSharper disable AutoPropertyCanBeMadeGetOnly.Global, UnusedAutoPropertyAccessor.Global, MemberCanBePrivate.Global -namespace Seq.App.Slack +namespace Seq.App.Slack; + +[SeqApp("Slack Notifier", Description = "Sends events to a Slack channel.")] +public class SlackApp : SeqApp, ISubscribeToAsync { - [SeqApp("Slack Notifier", Description = "Sends events to a Slack channel.")] - public class SlackApp : SeqApp, ISubscribeToAsync + private const uint AlertV1EventType = 0xA1E77000, AlertV2EventType = 0xA1E77001; + + private Dictionary? _messageBuilders; + private SlackMessageBuilder? _defaultMessageBuilder; + + [SeqAppSetting( + DisplayName = "Webhook URL", + HelpText = "Add the Incoming WebHooks app to your Slack to get this URL.")] + public required string WebhookUrl { get; init; } + + [SeqAppSetting( + DisplayName = "Channel", + IsOptional = true, + HelpText = "The channel to be used for the Slack notification. If not specified, uses the webhook default.")] + public string? Channel { get; init; } + + [SeqAppSetting( + DisplayName = "App name", + IsOptional = true, + HelpText = "The name that Seq uses when posting to Slack. If not specified, uses the name of the Seq app instance. The name can also be read from a property by using the format [PropertyName].")] + public string? Username { get; init; } + + [SeqAppSetting( + DisplayName = "Suppression time (minutes)", + IsOptional = true, + HelpText = "Once an event type has been sent to Slack, the time to wait before sending again. The default is zero.")] + public int SuppressionMinutes { get; init; } = 0; + + [SeqAppSetting( + DisplayName = "Exclude optional attachments", + IsOptional = true, + HelpText = "Should event property information and other optional attachments be excluded from the message? The default is to attach all properties.")] + public bool ExcludePropertyInformation { get; init; } + + [SeqAppSetting( + DisplayName = "Message", + HelpText = "The message to send to Slack. Refer to https://api.slack.com/docs/formatting for formatting options. Event property values can be added in the format [PropertyName]. The default is \"[RenderedMessage]\". Added as a Markdown attachment for Alerts.", + IsOptional = true)] + public string? MessageTemplate { get; init; } + + [SeqAppSetting( + DisplayName = "Icon URL", + HelpText = "The image to show in the channel for the message. The default is " + SlackMessageBuilder.DefaultIconUrl + ".", + IsOptional = true)] + public string? IconUrl { get; init; } + + [SeqAppSetting( + DisplayName = "Proxy Server", + HelpText = "Proxy server to be used when making HTTPS requests to the Slack API. Uses default credentials.", + IsOptional = true)] + public string? ProxyServer { get; init; } + + [SeqAppSetting( + DisplayName = "Maximum property length", + IsOptional = true, + HelpText = "If a property when converted to a string is longer than this number it will be truncated.")] + public int? MaxPropertyLength { get; init; } + + [SeqAppSetting( + DisplayName = "Included properties", + IsOptional = true, + HelpText = "Comma separated list of properties to include as attachments. The default is to include all properties.")] + public string? IncludedProperties { get; init; } + + private EventTypeSuppressions? _suppressions; + private ISlackApi? _slackApi; + + // Used reflectively by the app host. + // ReSharper disable once UnusedMember.Global + public SlackApp() + { + } + + internal SlackApp(ISlackApi slackApi) + { + _slackApi = slackApi; + } + + protected override void OnAttached() { - private const uint AlertV1EventType = 0xA1E77000, AlertV2EventType = 0xA1E77001; - - private Dictionary? _messageBuilders; - private SlackMessageBuilder? _defaultMessageBuilder; - - [SeqAppSetting( - DisplayName = "Webhook URL", - HelpText = "Add the Incoming WebHooks app to your Slack to get this URL.")] - public required string WebhookUrl { get; init; } - - [SeqAppSetting( - DisplayName = "Channel", - IsOptional = true, - HelpText = "The channel to be used for the Slack notification. If not specified, uses the webhook default.")] - public string? Channel { get; init; } - - [SeqAppSetting( - DisplayName = "App name", - IsOptional = true, - HelpText = "The name that Seq uses when posting to Slack. If not specified, uses the name of the Seq app instance. The name can also be read from a property by using the format [PropertyName].")] - public string? Username { get; init; } - - [SeqAppSetting( - DisplayName = "Suppression time (minutes)", - IsOptional = true, - HelpText = "Once an event type has been sent to Slack, the time to wait before sending again. The default is zero.")] - public int SuppressionMinutes { get; init; } = 0; - - [SeqAppSetting( - DisplayName = "Exclude optional attachments", - IsOptional = true, - HelpText = "Should event property information and other optional attachments be excluded from the message? The default is to attach all properties.")] - public bool ExcludePropertyInformation { get; init; } - - [SeqAppSetting( - DisplayName = "Message", - HelpText = "The message to send to Slack. Refer to https://api.slack.com/docs/formatting for formatting options. Event property values can be added in the format [PropertyName]. The default is \"[RenderedMessage]\". Added as a Markdown attachment for Alerts.", - IsOptional = true)] - public string? MessageTemplate { get; init; } - - [SeqAppSetting( - DisplayName = "Icon URL", - HelpText = "The image to show in the channel for the message. The default is " + SlackMessageBuilder.DefaultIconUrl + ".", - IsOptional = true)] - public string? IconUrl { get; init; } - - [SeqAppSetting( - DisplayName = "Proxy Server", - HelpText = "Proxy server to be used when making HTTPS requests to the Slack API. Uses default credentials.", - IsOptional = true)] - public string? ProxyServer { get; init; } - - [SeqAppSetting( - DisplayName = "Maximum property length", - IsOptional = true, - HelpText = "If a property when converted to a string is longer than this number it will be truncated.")] - public int? MaxPropertyLength { get; init; } - - [SeqAppSetting( - DisplayName = "Included properties", - IsOptional = true, - HelpText = "Comma separated list of properties to include as attachments. The default is to include all properties.")] - public string? IncludedProperties { get; init; } - - private EventTypeSuppressions? _suppressions; - private ISlackApi? _slackApi; - - // Used reflectively by the app host. - // ReSharper disable once UnusedMember.Global - public SlackApp() - { - } - - internal SlackApp(ISlackApi slackApi) - { - _slackApi = slackApi; - } - - protected override void OnAttached() - { - _slackApi ??= new SlackApi(ProxyServer); - - var propertyValueFormatter = new PropertyValueFormatter(MaxPropertyLength); - - _messageBuilders = new Dictionary - { - [AlertV1EventType] = new AlertV1MessageBuilder(App, Channel, Username, MessageTemplate, IconUrl, ExcludePropertyInformation), - [AlertV2EventType] = new AlertV2MessageBuilder(Host, App, propertyValueFormatter, Channel, Username, MessageTemplate, IconUrl, ExcludePropertyInformation) - }; - - var includedProperties = string.IsNullOrWhiteSpace(IncludedProperties) ? Array.Empty() : IncludedProperties.Split(',').Select(x => x.Trim()); + _slackApi ??= new SlackApi(ProxyServer); + + var propertyValueFormatter = new PropertyValueFormatter(MaxPropertyLength); + + _messageBuilders = new Dictionary + { + [AlertV1EventType] = new AlertV1MessageBuilder(App, Channel, Username, MessageTemplate, IconUrl, ExcludePropertyInformation), + [AlertV2EventType] = new AlertV2MessageBuilder(Host, App, propertyValueFormatter, Channel, Username, MessageTemplate, IconUrl, ExcludePropertyInformation) + }; + + var includedProperties = string.IsNullOrWhiteSpace(IncludedProperties) ? Array.Empty() : IncludedProperties.Split(',').Select(x => x.Trim()); - _defaultMessageBuilder = new DefaultMessageBuilder(Host, App, propertyValueFormatter, Channel, Username, - IconUrl, MessageTemplate, ExcludePropertyInformation, includedProperties); - } + _defaultMessageBuilder = new DefaultMessageBuilder(Host, App, propertyValueFormatter, Channel, Username, + IconUrl, MessageTemplate, ExcludePropertyInformation, includedProperties); + } - public async Task OnAsync(Event evt) - { - _suppressions ??= new EventTypeSuppressions(SuppressionMinutes); - if (_suppressions.ShouldSuppressAt(evt.EventType, DateTime.UtcNow)) - return; + public async Task OnAsync(Event evt) + { + _suppressions ??= new EventTypeSuppressions(SuppressionMinutes); + if (_suppressions.ShouldSuppressAt(evt.EventType, DateTime.UtcNow)) + return; - if (!_messageBuilders!.TryGetValue(evt.EventType, out var builder)) - builder = _defaultMessageBuilder!; + if (!_messageBuilders!.TryGetValue(evt.EventType, out var builder)) + builder = _defaultMessageBuilder!; - var message = builder.BuildMessage(evt); + var message = builder.BuildMessage(evt); - await _slackApi!.SendMessageAsync(WebhookUrl, message); - } + await _slackApi!.SendMessageAsync(WebhookUrl, message); } -} +} \ No newline at end of file diff --git a/src/Seq.App.Slack/Suppression/EventTypeSuppressions.cs b/src/Seq.App.Slack/Suppression/EventTypeSuppressions.cs index 33701cb..0125119 100644 --- a/src/Seq.App.Slack/Suppression/EventTypeSuppressions.cs +++ b/src/Seq.App.Slack/Suppression/EventTypeSuppressions.cs @@ -2,44 +2,43 @@ using System.Collections.Generic; using System.Linq; -namespace Seq.App.Slack.Suppression +namespace Seq.App.Slack.Suppression; + +class EventTypeSuppressions { - class EventTypeSuppressions + private readonly Dictionary _suppressions = new Dictionary(); + private readonly int _suppressionMinutes; + + public EventTypeSuppressions(int suppressionMinutes) { - private readonly Dictionary _suppressions = new Dictionary(); - private readonly int _suppressionMinutes; + _suppressionMinutes = suppressionMinutes; + } - public EventTypeSuppressions(int suppressionMinutes) - { - _suppressionMinutes = suppressionMinutes; - } + public bool ShouldSuppressAt(uint eventType, DateTime utcNow) + { + if (_suppressionMinutes == 0) + return false; - public bool ShouldSuppressAt(uint eventType, DateTime utcNow) + if (!_suppressions.TryGetValue(eventType, out var suppressedSince) || + suppressedSince.AddMinutes(_suppressionMinutes) < utcNow) { - if (_suppressionMinutes == 0) - return false; + // Not suppressed, or suppression expired - if (!_suppressions.TryGetValue(eventType, out var suppressedSince) || - suppressedSince.AddMinutes(_suppressionMinutes) < utcNow) + // Clean up old entries + var expired = _suppressions.FirstOrDefault(kvp => kvp.Value.AddMinutes(_suppressionMinutes) < utcNow); + while (expired.Value != default) { - // Not suppressed, or suppression expired - - // Clean up old entries - var expired = _suppressions.FirstOrDefault(kvp => kvp.Value.AddMinutes(_suppressionMinutes) < utcNow); - while (expired.Value != default) - { - _suppressions.Remove(expired.Key); - expired = _suppressions.FirstOrDefault(kvp => kvp.Value.AddMinutes(_suppressionMinutes) < utcNow); - } - - // Start suppression again - suppressedSince = utcNow; - _suppressions[eventType] = suppressedSince; - return false; + _suppressions.Remove(expired.Key); + expired = _suppressions.FirstOrDefault(kvp => kvp.Value.AddMinutes(_suppressionMinutes) < utcNow); } - // Suppressed - return true; + // Start suppression again + suppressedSince = utcNow; + _suppressions[eventType] = suppressedSince; + return false; } + + // Suppressed + return true; } -} +} \ No newline at end of file diff --git a/test/Seq.App.Slack.Tests/EventFormattingTests.cs b/test/Seq.App.Slack.Tests/EventFormattingTests.cs index 0f2bc4f..168281a 100644 --- a/test/Seq.App.Slack.Tests/EventFormattingTests.cs +++ b/test/Seq.App.Slack.Tests/EventFormattingTests.cs @@ -3,96 +3,95 @@ using Seq.Apps.LogEvents; using Xunit; -namespace Seq.App.Slack.Tests +namespace Seq.App.Slack.Tests; + +public class EventFormattingTests { - public class EventFormattingTests + [Fact] + public void SubstitutePlaceholders_ReplacesValue() { - [Fact] - public void SubstitutePlaceholders_ReplacesValue() + var result = ExecuteSubstitutePlaceholders(new Dictionary { - var result = ExecuteSubstitutePlaceholders(new Dictionary - { - {"noun", "force"}, - {"name", "Luke"} - }); + {"noun", "force"}, + {"name", "Luke"} + }); - Assert.Equal("Use the force Luke", result); - } + Assert.Equal("Use the force Luke", result); + } - [Fact] - public void SubstitutePlaceholders_IgnoresCase() + [Fact] + public void SubstitutePlaceholders_IgnoresCase() + { + var result = ExecuteSubstitutePlaceholders(new Dictionary { - var result = ExecuteSubstitutePlaceholders(new Dictionary - { - {"Noun", "force"}, - {"naMe", "Newton"} - }); + {"Noun", "force"}, + {"naMe", "Newton"} + }); - Assert.Equal("Use the force Newton", result); - } + Assert.Equal("Use the force Newton", result); + } - [Fact] - public void SubstitutePlaceholders_IgnoresMissingProperties() + [Fact] + public void SubstitutePlaceholders_IgnoresMissingProperties() + { + var result = ExecuteSubstitutePlaceholders(new Dictionary { - var result = ExecuteSubstitutePlaceholders(new Dictionary - { - {"noun", "spoon"} - }); + {"noun", "spoon"} + }); - Assert.Equal("Use the spoon [name]", result); - } + Assert.Equal("Use the spoon [name]", result); + } - [Fact] - public void SubstitutePlaceholders_AllowsPropertiesThatOnlyDifferByCase() + [Fact] + public void SubstitutePlaceholders_AllowsPropertiesThatOnlyDifferByCase() + { + var result = ExecuteSubstitutePlaceholders(new Dictionary { - var result = ExecuteSubstitutePlaceholders(new Dictionary - { - {"noun", "velcro"}, - {"Noun", "zipper"} - }); + {"noun", "velcro"}, + {"Noun", "zipper"} + }); - Assert.Equal("Use the zipper [name]", result); - } + Assert.Equal("Use the zipper [name]", result); + } - [Fact] - public void SubstitutePlaceholders_SkipsSubstitutionIfPropertiesIsNull() - { - var result = ExecuteSubstitutePlaceholders(null); + [Fact] + public void SubstitutePlaceholders_SkipsSubstitutionIfPropertiesIsNull() + { + var result = ExecuteSubstitutePlaceholders(null); - Assert.Equal("Use the [noun] [name]", result); - } + Assert.Equal("Use the [noun] [name]", result); + } - [Theory] - [InlineData("First", "`null`")] - [InlineData("Second", "20")] - [InlineData("Third", "System.Collections.Generic.Dictionary`2[System.String,System.Object]")] - [InlineData("Third.Fourth", "test")] - [InlineData("Fifth", "")] - public void PropertiesAreRetrievedSafely(string path, string expected) + [Theory] + [InlineData("First", "`null`")] + [InlineData("Second", "20")] + [InlineData("Third", "System.Collections.Generic.Dictionary`2[System.String,System.Object]")] + [InlineData("Third.Fourth", "test")] + [InlineData("Fifth", "")] + public void PropertiesAreRetrievedSafely(string path, string expected) + { + var data = new LogEventData { - var data = new LogEventData + Properties = new Dictionary { - Properties = new Dictionary + ["First"] = null, + ["Second"] = 20, + ["Third"] = new Dictionary { - ["First"] = null, - ["Second"] = 20, - ["Third"] = new Dictionary - { - ["Fourth"] = "test" - } + ["Fourth"] = "test" } - }; + } + }; - var evt = new Event("event-123", 4, DateTime.UtcNow, data); + var evt = new Event("event-123", 4, DateTime.UtcNow, data); - var actual = EventFormatting.SafeGetProperty(evt, path); - Assert.Equal(expected, actual); - } - - private static string ExecuteSubstitutePlaceholders(IReadOnlyDictionary? properties) - => EventFormatting.SubstitutePlaceholders( - "Use the [noun] [name]", - new Event("", 1, DateTime.Now, new LogEventData {Properties = properties}) - ); + var actual = EventFormatting.SafeGetProperty(evt, path); + Assert.Equal(expected, actual); } -} + + private static string ExecuteSubstitutePlaceholders(IReadOnlyDictionary? properties) + => EventFormatting.SubstitutePlaceholders( + "Use the [noun] [name]", + new Event("", 1, DateTime.Now, new LogEventData {Properties = properties}) + ); +} \ No newline at end of file diff --git a/test/Seq.App.Slack.Tests/PropertyFormatterTests.cs b/test/Seq.App.Slack.Tests/PropertyFormatterTests.cs index 87c3f2a..85cdd2d 100644 --- a/test/Seq.App.Slack.Tests/PropertyFormatterTests.cs +++ b/test/Seq.App.Slack.Tests/PropertyFormatterTests.cs @@ -2,52 +2,50 @@ using Seq.App.Slack.Formatting; using Xunit; -namespace Seq.App.Slack.Tests +namespace Seq.App.Slack.Tests; + +public class PropertyValueFormatterTests { - public class PropertyValueFormatterTests + [Fact] + public void IntPropertyFormattedOk() { - [Fact] - public void IntPropertyFormattedOk() - { - var formatter = new PropertyValueFormatter(null); - var result = formatter.ConvertPropertyValueToString(1); - Assert.Equal("1", result); - } + var formatter = new PropertyValueFormatter(null); + var result = formatter.ConvertPropertyValueToString(1); + Assert.Equal("1", result); + } - [Fact] - public void NullPropertyFormattedOk() - { - var formatter = new PropertyValueFormatter(null); - var result = formatter.ConvertPropertyValueToString(null); - Assert.Equal(string.Empty, result); - } + [Fact] + public void NullPropertyFormattedOk() + { + var formatter = new PropertyValueFormatter(null); + var result = formatter.ConvertPropertyValueToString(null); + Assert.Equal(string.Empty, result); + } - [Fact] - public void DictionaryPropertiesSerialisedAsJson() - { - var formatter = new PropertyValueFormatter(null); - - var d = new Dictionary - { - { "test", "value" }, - { "test2", "value2" } - }; - var result = formatter.ConvertPropertyValueToString(d); - const string expected = "{\"test\":\"value\",\"test2\":\"value2\"}"; - Assert.Equal(expected, result); - } - - [Fact] - public void PropertiesShouldTruncateIfDesired() + [Fact] + public void DictionaryPropertiesSerialisedAsJson() + { + var formatter = new PropertyValueFormatter(null); + + var d = new Dictionary { - var formatter = new PropertyValueFormatter(5); + { "test", "value" }, + { "test2", "value2" } + }; + var result = formatter.ConvertPropertyValueToString(d); + const string expected = "{\"test\":\"value\",\"test2\":\"value2\"}"; + Assert.Equal(expected, result); + } - var d = new Dictionary { { "test", "value" } }; - var result = formatter.ConvertPropertyValueToString(d); - const string expected = "{\"tes..."; - Assert.Equal(expected, result); - } + [Fact] + public void PropertiesShouldTruncateIfDesired() + { + var formatter = new PropertyValueFormatter(5); + var d = new Dictionary { { "test", "value" } }; + var result = formatter.ConvertPropertyValueToString(d); + const string expected = "{\"tes..."; + Assert.Equal(expected, result); } -} +} \ No newline at end of file diff --git a/test/Seq.App.Slack.Tests/SlackAppTests.cs b/test/Seq.App.Slack.Tests/SlackAppTests.cs index 8c03b32..67a9b7c 100644 --- a/test/Seq.App.Slack.Tests/SlackAppTests.cs +++ b/test/Seq.App.Slack.Tests/SlackAppTests.cs @@ -4,93 +4,92 @@ using Seq.App.Slack.Api; using Xunit; -namespace Seq.App.Slack.Tests +namespace Seq.App.Slack.Tests; + +public class SlackAppTests { - public class SlackAppTests + private ISlackApi _slackApi = null!; + private IAppHost _appHost = null!; + private Event _event = null!; + + private SlackApp CreateSlackApp(string? includedProperties = null) { - private ISlackApi _slackApi = null!; - private IAppHost _appHost = null!; - private Event _event = null!; + _slackApi = Substitute.For(); + _appHost = Substitute.For(); + _appHost.Host.Returns(new Host("https://listen.example.com", "instance")); + _appHost.App.Returns(new Apps.App("app-id", "App Title", new Dictionary(), "storage-path")); - private SlackApp CreateSlackApp(string? includedProperties = null) + var slackApp = new SlackApp(_slackApi) { - _slackApi = Substitute.For(); - _appHost = Substitute.For(); - _appHost.Host.Returns(new Host("https://listen.example.com", "instance")); - _appHost.App.Returns(new Apps.App("app-id", "App Title", new Dictionary(), "storage-path")); - - var slackApp = new SlackApp(_slackApi) - { - WebhookUrl = "https://webhook.example.com", - IncludedProperties = includedProperties - }; + WebhookUrl = "https://webhook.example.com", + IncludedProperties = includedProperties + }; - slackApp.Attach(_appHost); + slackApp.Attach(_appHost); - _event = new Event("id", 1, DateTime.Now, new LogEventData - { - Id = "111", - Level = LogEventLevel.Information, - Properties = new Dictionary - { - {"Property1", "Value1"}, - {"Property2", "Value2"}, - {"Property3", "Value3"} - } - }); - - return slackApp; - } - - [Fact] - public async Task GivenIncludedPropertiesWithWhitespaceAreSuppliedThenTheyAreRespected() + _event = new Event("id", 1, DateTime.Now, new LogEventData { - var slackApp = CreateSlackApp(" Property1 , Property2 "); + Id = "111", + Level = LogEventLevel.Information, + Properties = new Dictionary + { + {"Property1", "Value1"}, + {"Property2", "Value2"}, + {"Property3", "Value3"} + } + }); - await slackApp.OnAsync(_event); + return slackApp; + } - // Ensure the message we send to slack only includes the properties specified - await _slackApi.Received().SendMessageAsync(slackApp.WebhookUrl, Arg.Is(x => x.Attachments.Any(a => a.Fields.Count == 2 && - a.Fields.Any(f => f.Title == "Property1") && - a.Fields.Any(f => f.Title == "Property2")))); - } + [Fact] + public async Task GivenIncludedPropertiesWithWhitespaceAreSuppliedThenTheyAreRespected() + { + var slackApp = CreateSlackApp(" Property1 , Property2 "); - [Fact] - public async Task GivenIncludedPropertiesAreSuppliedThenTheyAreRespected() - { - var slackApp = CreateSlackApp("Property1,Property3"); + await slackApp.OnAsync(_event); + + // Ensure the message we send to slack only includes the properties specified + await _slackApi.Received().SendMessageAsync(slackApp.WebhookUrl, Arg.Is(x => x.Attachments.Any(a => a.Fields.Count == 2 && + a.Fields.Any(f => f.Title == "Property1") && + a.Fields.Any(f => f.Title == "Property2")))); + } - await slackApp.OnAsync(_event); + [Fact] + public async Task GivenIncludedPropertiesAreSuppliedThenTheyAreRespected() + { + var slackApp = CreateSlackApp("Property1,Property3"); - // Ensure the message we send to slack only includes the properties specified - await _slackApi.Received().SendMessageAsync(slackApp.WebhookUrl, Arg.Is(x => x.Attachments.Any(a => a.Fields.Count == 2 && - a.Fields.Any(f => f.Title == "Property1") && - a.Fields.Any(f => f.Title == "Property3")))); - } + await slackApp.OnAsync(_event); - [Fact] - public async Task GivenIncludedPropertiesNotSetThenAllPropertiesAreIncluded() - { - var slackApp = CreateSlackApp(); - await slackApp.OnAsync(_event); - - // Ensure the message we send to slack only includes the properties specified - await _slackApi.Received().SendMessageAsync(slackApp.WebhookUrl, Arg.Is(x => x.Attachments.Any(a => a.Fields.Count == 3 && - a.Fields.Any(f => f.Title == "Property1") && - a.Fields.Any(f => f.Title == "Property2") && - a.Fields.Any(f => f.Title == "Property3")))); - } - - [Fact] - public async Task GivenIncludedPropertiesContainsPropertiesThatDontExistThenTheyAreIgnored() - { - var slackApp = CreateSlackApp("Property1,PropertyDoesntExist"); + // Ensure the message we send to slack only includes the properties specified + await _slackApi.Received().SendMessageAsync(slackApp.WebhookUrl, Arg.Is(x => x.Attachments.Any(a => a.Fields.Count == 2 && + a.Fields.Any(f => f.Title == "Property1") && + a.Fields.Any(f => f.Title == "Property3")))); + } + + [Fact] + public async Task GivenIncludedPropertiesNotSetThenAllPropertiesAreIncluded() + { + var slackApp = CreateSlackApp(); + await slackApp.OnAsync(_event); + + // Ensure the message we send to slack only includes the properties specified + await _slackApi.Received().SendMessageAsync(slackApp.WebhookUrl, Arg.Is(x => x.Attachments.Any(a => a.Fields.Count == 3 && + a.Fields.Any(f => f.Title == "Property1") && + a.Fields.Any(f => f.Title == "Property2") && + a.Fields.Any(f => f.Title == "Property3")))); + } + + [Fact] + public async Task GivenIncludedPropertiesContainsPropertiesThatDontExistThenTheyAreIgnored() + { + var slackApp = CreateSlackApp("Property1,PropertyDoesntExist"); - await slackApp.OnAsync(_event); + await slackApp.OnAsync(_event); - // Ensure the message we send to slack only includes the properties specified - await _slackApi.Received().SendMessageAsync(slackApp.WebhookUrl, Arg.Is(x => x.Attachments.Any(a => a.Fields.Count == 1 && - a.Fields.Any(f => f.Title == "Property1")))); - } + // Ensure the message we send to slack only includes the properties specified + await _slackApi.Received().SendMessageAsync(slackApp.WebhookUrl, Arg.Is(x => x.Attachments.Any(a => a.Fields.Count == 1 && + a.Fields.Any(f => f.Title == "Property1")))); } -} +} \ No newline at end of file diff --git a/test/Seq.App.Slack.Tests/SlackSyntaxTests.cs b/test/Seq.App.Slack.Tests/SlackSyntaxTests.cs index 1bea85c..053dc5a 100644 --- a/test/Seq.App.Slack.Tests/SlackSyntaxTests.cs +++ b/test/Seq.App.Slack.Tests/SlackSyntaxTests.cs @@ -1,15 +1,14 @@ using Seq.App.Slack.Formatting; using Xunit; -namespace Seq.App.Slack.Tests +namespace Seq.App.Slack.Tests; + +public class SlackSyntaxTests { - public class SlackSyntaxTests + [Fact] + public void HyperlinksAreCorrectlyFormatted() { - [Fact] - public void HyperlinksAreCorrectlyFormatted() - { - var link = SlackSyntax.Hyperlink("http://example.com", "Hello, world!"); - Assert.Equal("", link); - } + var link = SlackSyntax.Hyperlink("http://example.com", "Hello, world!"); + Assert.Equal("", link); } -} +} \ No newline at end of file From 9f94aa4a90ae011e7713107e3479667529c87bd2 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Thu, 4 Jun 2026 13:33:28 +1000 Subject: [PATCH 4/6] Remove duplicate build property --- Directory.Build.props | 1 - 1 file changed, 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 18de150..dc0be9b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,6 +4,5 @@ enable latest true - Apache-2.0 From 531308f4d301af7b363598592e3188936a204be1 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Thu, 4 Jun 2026 13:38:03 +1000 Subject: [PATCH 5/6] Dependency updates --- src/Seq.App.Slack/Seq.App.Slack.csproj | 4 ++-- test/Seq.App.Slack.Tests/Seq.App.Slack.Tests.csproj | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Seq.App.Slack/Seq.App.Slack.csproj b/src/Seq.App.Slack/Seq.App.Slack.csproj index 3336d9e..453bfee 100644 --- a/src/Seq.App.Slack/Seq.App.Slack.csproj +++ b/src/Seq.App.Slack/Seq.App.Slack.csproj @@ -16,8 +16,8 @@ - - + + diff --git a/test/Seq.App.Slack.Tests/Seq.App.Slack.Tests.csproj b/test/Seq.App.Slack.Tests/Seq.App.Slack.Tests.csproj index 952c798..9061d6e 100644 --- a/test/Seq.App.Slack.Tests/Seq.App.Slack.Tests.csproj +++ b/test/Seq.App.Slack.Tests/Seq.App.Slack.Tests.csproj @@ -6,10 +6,13 @@ - - - - + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + From 9aaa1a1279a3d4c6c0cf8328dbdb7012ed7621ad Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Thu, 4 Jun 2026 13:51:21 +1000 Subject: [PATCH 6/6] NuGet typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c83407f..55c91bb 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ An app for [Seq](https://datalust.co/seq) that forwards messages to [Slack](http ### Getting started - 1. Install the app into Seq through the Seq UI: _Settings_ > _Apps_ > _Install from Nuget_; the package id is _Seq.App.Slack_ + 1. Install the app into Seq through the Seq UI: _Settings_ > _Apps_ > _Install from NuGet_; the package id is _Seq.App.Slack_ 2. In Slack, select _Admin_ > _Apps and Workflows_ > _Build_ > _Create new App_ > _From Scratch_ 3. In the app registration, choose _Incoming WebHooks_ (this is the new endpoint, not the legacy one) 4. Add a new incoming webhook configuration and copy the _Webhook URL_