diff --git a/Bottomly.ServiceDefaults/Extensions.cs b/Bottomly.ServiceDefaults/Extensions.cs index 7fc0291..edba4e0 100644 --- a/Bottomly.ServiceDefaults/Extensions.cs +++ b/Bottomly.ServiceDefaults/Extensions.cs @@ -63,6 +63,7 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) .WithTracing(tracing => { tracing.AddSource(builder.Environment.ApplicationName) + .AddSource("MongoDB.Driver.Core.Extensions.DiagnosticSources") .AddAspNetCoreInstrumentation(tracing => // Exclude health check requests from tracing tracing.Filter = context => diff --git a/Bottomly.Tests/LlmBot/LlmBotTests.cs b/Bottomly.Tests/LlmBot/LlmBotTests.cs index cd500cc..256bee7 100644 --- a/Bottomly.Tests/LlmBot/LlmBotTests.cs +++ b/Bottomly.Tests/LlmBot/LlmBotTests.cs @@ -20,12 +20,15 @@ public void Create_SetsUsernameAndText() public class BottomlyUserNoteTests { [Fact] - public void Create_SetsUsernameAndNote() + public void Create_SetsAllProperties() { - var note = BottomlyUserNote.Create("bob", "likes cats"); + var note = BottomlyUserNote.Create("bob", "Bob Jones", Gender.Male, SassLevel.Frequent, "likes cats"); note.Username.ShouldBe("bob"); - note.Note.ShouldBe("likes cats"); + note.FullName.ShouldBe("Bob Jones"); + note.Gender.ShouldBe(Gender.Male); + note.SassLevel.ShouldBe(SassLevel.Frequent); + note.MiscInfo.ShouldBe("likes cats"); } } @@ -35,7 +38,7 @@ public class MessageHistoryContextTests public void Create_SetsMessageHistoryAndUserNotes() { var messages = new List { BottomlyInputMessage.Create("alice", "hi") }; - var notes = new List { BottomlyUserNote.Create("alice", "some note") }; + var notes = new List { BottomlyUserNote.Create("alice", "Alice Smith", Gender.Female, SassLevel.Moderate, "some note") }; var ctx = MessageHistoryContext.Create(messages, notes); @@ -126,7 +129,7 @@ public void ToChatContext_IncludesMessageHistoryAndUserInfo() }; var notes = new List { - BottomlyUserNote.Create("alice", "likes tea") + BottomlyUserNote.Create("alice", "Alice Smith", Gender.Female, SassLevel.Moderate, "likes tea") }; var ctx = MessageHistoryContext.Create(messages, notes); @@ -138,6 +141,8 @@ public void ToChatContext_IncludesMessageHistoryAndUserInfo() result.ShouldContain("hello"); result.ShouldContain("bob"); result.ShouldContain("world"); + result.ShouldContain("\"full_name\""); + result.ShouldContain("Alice Smith"); result.ShouldContain("likes tea"); } @@ -186,24 +191,4 @@ public void ToArray_ContainsThreeMessages() [Fact] public void SystemPrompt_MentionsBottomly() => FullPromptContext.SystemPrompt.Text.ShouldContain("Bottomly"); -} - -public class MemberNoteTests -{ - [Fact] - public void Note_ContainsAllMemberInfo() - { - var member = new Member - { - FullName = "Alice Smith", - Gender = Gender.Female, - SassLevel = SassLevel.Frequent, - MiscInfo = "Drinks tea" - }; - - member.Note.ShouldContain("Alice Smith"); - member.Note.ShouldContain("Female"); - member.Note.ShouldContain("Frequent"); - member.Note.ShouldContain("Drinks tea"); - } } \ No newline at end of file diff --git a/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/MessageContextExtensionsTests.cs b/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/MessageContextExtensionsTests.cs index 21f9eb0..e53d27c 100644 --- a/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/MessageContextExtensionsTests.cs +++ b/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/MessageContextExtensionsTests.cs @@ -14,7 +14,7 @@ public class MessageContextExtensionsTests private readonly SlackParser _parser = new(new Mock().Object); [Fact] - public void CreateFromMember_MapsUsernameAndNote() + public void CreateFromMember_MapsAllProperties() { var member = new Member { @@ -28,7 +28,10 @@ public void CreateFromMember_MapsUsernameAndNote() var note = BottomlyUserNote.CreateFromMember(member); note.Username.ShouldBe("alice"); - note.Note.ShouldContain("Alice Smith"); + note.FullName.ShouldBe("Alice Smith"); + note.Gender.ShouldBe(Gender.Female); + note.SassLevel.ShouldBe(SassLevel.Moderate); + note.MiscInfo.ShouldBe("Loves gardening"); } [Fact] diff --git a/Bottomly/HostBuilderExtensions.cs b/Bottomly/HostBuilderExtensions.cs index dd7d2fa..15f9a70 100644 --- a/Bottomly/HostBuilderExtensions.cs +++ b/Bottomly/HostBuilderExtensions.cs @@ -1,6 +1,7 @@ using System.Reflection; using Bottomly.Commands; using Bottomly.Slack.MessageEventHandlers; +using Bottomly.Slack.Telemetry; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -17,7 +18,13 @@ public void RegisterEventHandlers(Assembly assembly, Type[] exclude) => .Where(t => t.Name != nameof(HelpHandler)) .Where(t => !exclude.Contains(t)) .ToList() - .ForEach(t => builder.Services.AddSingleton(typeof(IMessageEventHandler), t)); + .ForEach(t => + { + builder.Services.AddSingleton(t); + builder.Services.AddSingleton(sp => + new TracingMessageEventHandlerDecorator( + (IMessageEventHandler)sp.GetRequiredService(t))); + }); public void RegisterCommands(Assembly assembly) => assembly.GetTypes() diff --git a/Bottomly/LlmBot/BottomlyUserNote.cs b/Bottomly/LlmBot/BottomlyUserNote.cs index fff1da2..f0008b8 100644 --- a/Bottomly/LlmBot/BottomlyUserNote.cs +++ b/Bottomly/LlmBot/BottomlyUserNote.cs @@ -1,10 +1,19 @@ +using Bottomly.Models; + namespace Bottomly.LlmBot; public record BottomlyUserNote { - private BottomlyUserNote(string username, string note) => (Username, Note) = (username, note); + private BottomlyUserNote(string username, string fullName, Gender gender, SassLevel sassLevel, string miscInfo) => + (Username, FullName, Gender, SassLevel, MiscInfo) = (username, fullName, gender, sassLevel, miscInfo); + public string Username { get; private init; } = ""; - public string Note { get; private init; } = ""; + public string FullName { get; private init; } = ""; + public Gender Gender { get; private init; } = Gender.Unknown; + public SassLevel SassLevel { get; private init; } = SassLevel.Moderate; + public string MiscInfo { get; private init; } = ""; - public static BottomlyUserNote Create(string username, string note) => new(username, note); + public static BottomlyUserNote Create(string username, string fullName, Gender gender, SassLevel sassLevel, + string miscInfo) => + new(username, fullName, gender, sassLevel, miscInfo); } \ No newline at end of file diff --git a/Bottomly/LlmBot/FullPromptContext.cs b/Bottomly/LlmBot/FullPromptContext.cs index 3ab0066..6bc2313 100644 --- a/Bottomly/LlmBot/FullPromptContext.cs +++ b/Bottomly/LlmBot/FullPromptContext.cs @@ -41,7 +41,8 @@ public static string ToChatContext(this MessageHistoryContext historyContext) { var payload = new PromptContextPayload( historyContext.MessageHistory.Select(m => new MessageHistoryEntry(m.Username, m.Text)).ToList(), - historyContext.UserNotes.Select(n => new UserInfoEntry(n.Username, n.Note)).ToList() + historyContext.UserNotes.Select(n => new UserInfoEntry(n.Username, n.FullName, n.Gender.ToString(), + n.SassLevel.ToString(), n.MiscInfo)).ToList() ); return JsonSerializer.Serialize(payload, JsonOptions); } @@ -64,5 +65,11 @@ private record MessageHistoryEntry( private record UserInfoEntry( [property: JsonPropertyName("username")] string Username, - [property: JsonPropertyName("note")] string Note); + [property: JsonPropertyName("full_name")] + string FullName, + [property: JsonPropertyName("gender")] string Gender, + [property: JsonPropertyName("sass_level")] + string SassLevel, + [property: JsonPropertyName("misc_info")] + string MiscInfo); } \ No newline at end of file diff --git a/Bottomly/Models/Member.cs b/Bottomly/Models/Member.cs index eae6c81..1a7b0d9 100644 --- a/Bottomly/Models/Member.cs +++ b/Bottomly/Models/Member.cs @@ -21,11 +21,4 @@ public class Member /// [BsonElement("_cls")] public string Cls { get; set; } = string.Empty; - - [BsonIgnore] - public string Note => - $"FullName: {FullName}\n" + - $"Gender: {Gender}\n" + - $"SassLevel: {SassLevel}\n" + - $"MiscInfo: {MiscInfo}"; } \ No newline at end of file diff --git a/Bottomly/Program.cs b/Bottomly/Program.cs index 824fc30..5736793 100644 --- a/Bottomly/Program.cs +++ b/Bottomly/Program.cs @@ -19,11 +19,16 @@ builder.AddMongoDBClient("mongodb"); builder.Services.AddSingleton(sp => { - var client = sp.GetRequiredService(); var configuration = sp.GetRequiredService(); var connectionString = configuration.GetConnectionString("mongodb")!; - var databaseName = MongoUrl.Create(connectionString).DatabaseName ?? "bottomly"; - return client.GetDatabase(databaseName); + var mongoUrl = MongoUrl.Create(connectionString); + var settings = MongoClientSettings.FromUrl(mongoUrl); + settings.ClusterConfigurator = cb => + cb.Subscribe(new MongoDB.Driver.Core.Extensions.DiagnosticSources.DiagnosticsActivityEventSubscriber( + new MongoDB.Driver.Core.Extensions.DiagnosticSources.InstrumentationOptions { CaptureCommandText = true })); + var instrumentedClient = new MongoClient(settings); + var databaseName = mongoUrl.DatabaseName ?? "bottomly"; + return instrumentedClient.GetDatabase(databaseName); }); builder.Services.AddBottomlyConfiguration(builder.Configuration); diff --git a/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/MessageContextExtensions.cs b/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/MessageContextExtensions.cs index 4fd3a9c..12151cd 100644 --- a/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/MessageContextExtensions.cs +++ b/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/MessageContextExtensions.cs @@ -9,7 +9,7 @@ internal static class MessageContextExtensions extension(BottomlyUserNote bottomlyUserNote) { public static BottomlyUserNote CreateFromMember(Member member) => - BottomlyUserNote.Create(member.Username, member.Note); + BottomlyUserNote.Create(member.Username, member.FullName, member.Gender, member.SassLevel, member.MiscInfo); } extension(BottomlyInputMessage bottomlyInputMessage) diff --git a/Bottomly/Slack/ServiceCollectionExtensions.cs b/Bottomly/Slack/ServiceCollectionExtensions.cs index 45ea9a7..297ecd3 100644 --- a/Bottomly/Slack/ServiceCollectionExtensions.cs +++ b/Bottomly/Slack/ServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using Bottomly.Slack.MembershipEventHandlers; using Bottomly.Slack.MessageEventHandlers; using Bottomly.Slack.ReactionHandlers; +using Bottomly.Slack.Telemetry; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SlackNet.Events; @@ -28,12 +29,16 @@ public static IServiceCollection AddBottomlySlack(this IServiceCollection servic .RegisterEventHandler() .RegisterEventHandler()); - // Help handler (also registered as singleton for direct injection into SlackWorker) + // Help handler — concrete type for its own resolution, decorated version keyed for SlackWorker injection services.AddSingleton(); + services.AddKeyedSingleton(SlackWorker.HelpHandlerKey, + (sp, _) => new TracingMessageEventHandlerDecorator(sp.GetRequiredService())); - // Reaction handlers + // Reaction handlers (wrapped in tracing decorator) services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(sp => + new TracingReactionHandlerDecorator( + ActivatorUtilities.CreateInstance(sp))); // Membership handlers services.AddSingleton(); diff --git a/Bottomly/Slack/SlackEventDispatchers.cs b/Bottomly/Slack/SlackEventDispatchers.cs index 49dd968..9d441d3 100644 --- a/Bottomly/Slack/SlackEventDispatchers.cs +++ b/Bottomly/Slack/SlackEventDispatchers.cs @@ -1,6 +1,8 @@ using Bottomly.Slack.MembershipEventHandlers; +using Bottomly.Telemetry; using SlackNet; using SlackNet.Events; +using System.Diagnostics; namespace Bottomly.Slack; @@ -9,7 +11,23 @@ namespace Bottomly.Slack; /// public class SlackMessageEventDispatcher(SlackWorker worker) : IEventHandler { - public Task Handle(MessageEvent slackEvent) => worker.ProcessMessageAsync(slackEvent); + public async Task Handle(MessageEvent slackEvent) + { + using var activity = BottomlyActivitySource.Instance.StartActivity("slack.message.process"); + activity?.SetTag("slack.channel", slackEvent.Channel); + activity?.SetTag("slack.user", slackEvent.User); + activity?.SetTag("slack.text_preview", slackEvent.Text?[..Math.Min(slackEvent.Text.Length, 100)]); + + try + { + await worker.ProcessMessageAsync(slackEvent); + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + } } /// @@ -17,7 +35,23 @@ public class SlackMessageEventDispatcher(SlackWorker worker) : IEventHandler public class SlackReactionEventDispatcher(SlackWorker worker) : IEventHandler { - public Task Handle(ReactionAdded slackEvent) => worker.ProcessReactionAsync(slackEvent); + public async Task Handle(ReactionAdded slackEvent) + { + using var activity = BottomlyActivitySource.Instance.StartActivity("slack.reaction.process"); + activity?.SetTag("slack.reaction", slackEvent.Reaction); + if (slackEvent.Item is ReactionMessage reactionMessage) + activity?.SetTag("slack.channel", reactionMessage.Channel); + + try + { + await worker.ProcessReactionAsync(slackEvent); + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + } } public class SlackMemberAddedEventDispatcher(MemberJoinedEventHandler handler) diff --git a/Bottomly/Slack/SlackWorker.cs b/Bottomly/Slack/SlackWorker.cs index 39cffb6..cb7b90a 100644 --- a/Bottomly/Slack/SlackWorker.cs +++ b/Bottomly/Slack/SlackWorker.cs @@ -1,6 +1,7 @@ using Bottomly.Repositories; using Bottomly.Slack.MessageEventHandlers; using Bottomly.Slack.ReactionHandlers; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using SlackNet; @@ -12,12 +13,14 @@ namespace Bottomly.Slack; public class SlackWorker( ISlackSocketModeClient socketClient, IEnumerable eventHandlers, - HelpHandler helpMessageHandler, + [FromKeyedServices("help")] IMessageEventHandler helpMessageHandler, IEnumerable reactionHandlers, IMemberRepository memberRepository, ILogger logger) : BackgroundService { + internal const string HelpHandlerKey = "help"; + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { logger.LogInformation("Starting Slack Socket Mode connection..."); diff --git a/Bottomly/Slack/Telemetry/SlackTracingDecorators.cs b/Bottomly/Slack/Telemetry/SlackTracingDecorators.cs new file mode 100644 index 0000000..7efbbe4 --- /dev/null +++ b/Bottomly/Slack/Telemetry/SlackTracingDecorators.cs @@ -0,0 +1,53 @@ +using System.Diagnostics; +using Bottomly.Slack.MessageEventHandlers; +using Bottomly.Slack.ReactionHandlers; +using Bottomly.Telemetry; +using SlackNet.Events; + +namespace Bottomly.Slack.Telemetry; + +internal sealed class TracingMessageEventHandlerDecorator(IMessageEventHandler inner) : IMessageEventHandler +{ + public bool CanHandle(MessageEvent message) => inner.CanHandle(message); + + public string BuildHelpMessage() => inner.BuildHelpMessage(); + + public async Task HandleAsync(MessageEvent message) + { + using var activity = BottomlyActivitySource.Instance.StartActivity("slack.handler.invoke"); + activity?.SetTag("slack.handler", inner.GetType().Name); + activity?.SetTag("slack.text_preview", message.Text?[..Math.Min(message.Text.Length, 100)]); + + try + { + await inner.HandleAsync(message); + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + } +} + +internal sealed class TracingReactionHandlerDecorator(IReactionHandler inner) : IReactionHandler +{ + public bool CanHandle(ReactionAdded reaction) => inner.CanHandle(reaction); + + public async Task HandleAsync(ReactionAdded reaction) + { + using var activity = BottomlyActivitySource.Instance.StartActivity("slack.reaction.handler.invoke"); + activity?.SetTag("slack.handler", inner.GetType().Name); + activity?.SetTag("slack.reaction", reaction.Reaction); + + try + { + await inner.HandleAsync(reaction); + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + throw; + } + } +} diff --git a/Bottomly/Telemetry/BottomlyActivitySource.cs b/Bottomly/Telemetry/BottomlyActivitySource.cs new file mode 100644 index 0000000..e83c35e --- /dev/null +++ b/Bottomly/Telemetry/BottomlyActivitySource.cs @@ -0,0 +1,9 @@ +using System.Diagnostics; + +namespace Bottomly.Telemetry; + +internal static class BottomlyActivitySource +{ + internal const string Name = "Bottomly"; + internal static readonly ActivitySource Instance = new(Name); +}