|
| 1 | +// Copyright (c) Microsoft Corporation. |
| 2 | +// Licensed under the MIT License. |
| 3 | + |
| 4 | +using Microsoft.Extensions.Configuration; |
| 5 | +using Microsoft.Graph.DeveloperProxy.Abstractions; |
| 6 | +using System.CommandLine; |
| 7 | +using System.CommandLine.Invocation; |
| 8 | +using System.Text.Json.Serialization; |
| 9 | +using System.Text.RegularExpressions; |
| 10 | + |
| 11 | +namespace Microsoft.Graph.DeveloperProxy.Plugins.RequestLogs; |
| 12 | + |
| 13 | +internal enum SummaryGroupBy |
| 14 | +{ |
| 15 | + [JsonPropertyName("url")] |
| 16 | + Url, |
| 17 | + [JsonPropertyName("messageType")] |
| 18 | + MessageType |
| 19 | +} |
| 20 | + |
| 21 | +internal class ExecutionSummaryPluginConfiguration |
| 22 | +{ |
| 23 | + public string FilePath { get; set; } = ""; |
| 24 | + public SummaryGroupBy GroupBy { get; set; } = SummaryGroupBy.Url; |
| 25 | +} |
| 26 | + |
| 27 | +public class ExecutionSummaryPlugin : BaseProxyPlugin |
| 28 | +{ |
| 29 | + public override string Name => nameof(ExecutionSummaryPlugin); |
| 30 | + private ExecutionSummaryPluginConfiguration _configuration = new(); |
| 31 | + private readonly Option<string?> _filePath; |
| 32 | + private readonly Option<SummaryGroupBy?> _groupBy; |
| 33 | + private const string _requestsInterceptedMessage = "Requests intercepted"; |
| 34 | + private const string _requestsPassedThroughMessage = "Requests passed through"; |
| 35 | + |
| 36 | + public ExecutionSummaryPlugin() |
| 37 | + { |
| 38 | + _filePath = new Option<string?>("--summary-file-path", "Path to the file where the summary should be saved. If not specified, the summary will be printed to the console. Path can be absolute or relative to the current working directory."); |
| 39 | + _filePath.ArgumentHelpName = "summary-file-path"; |
| 40 | + _filePath.AddValidator(input => |
| 41 | + { |
| 42 | + var outputFilePath = input.Tokens.First().Value; |
| 43 | + if (string.IsNullOrEmpty(outputFilePath)) |
| 44 | + { |
| 45 | + return; |
| 46 | + } |
| 47 | + |
| 48 | + var outputDir = Path.GetFullPath(Path.GetDirectoryName(outputFilePath)); |
| 49 | + if (!Directory.Exists(outputDir)) |
| 50 | + { |
| 51 | + input.ErrorMessage = $"The directory {outputDir} does not exist."; |
| 52 | + } |
| 53 | + }); |
| 54 | + |
| 55 | + _groupBy = new Option<SummaryGroupBy?>("--summary-group-by", "Specifies how the information should be grouped in the summary. Available options: `url` (default), `messageType`."); |
| 56 | + _groupBy.ArgumentHelpName = "summary-group-by"; |
| 57 | + _groupBy.AddValidator(input => |
| 58 | + { |
| 59 | + if (!Enum.TryParse<SummaryGroupBy>(input.Tokens.First().Value, true, out var groupBy)) |
| 60 | + { |
| 61 | + input.ErrorMessage = $"{input.Tokens.First().Value} is not a valid option to group by. Allowed values are: {string.Join(", ", Enum.GetNames(typeof(SummaryGroupBy)))}"; |
| 62 | + } |
| 63 | + }); |
| 64 | + } |
| 65 | + |
| 66 | + public override void Register(IPluginEvents pluginEvents, |
| 67 | + IProxyContext context, |
| 68 | + ISet<Regex> urlsToWatch, |
| 69 | + IConfigurationSection? configSection = null) |
| 70 | + { |
| 71 | + base.Register(pluginEvents, context, urlsToWatch, configSection); |
| 72 | + |
| 73 | + configSection?.Bind(_configuration); |
| 74 | + |
| 75 | + pluginEvents.Init += OnInit; |
| 76 | + pluginEvents.OptionsLoaded += OnOptionsLoaded; |
| 77 | + pluginEvents.AfterRecordingStop += AfterRecordingStop; |
| 78 | + } |
| 79 | + |
| 80 | + private void OnInit(object? sender, InitArgs e) |
| 81 | + { |
| 82 | + e.RootCommand.AddOption(_filePath); |
| 83 | + e.RootCommand.AddOption(_groupBy); |
| 84 | + } |
| 85 | + |
| 86 | + private void OnOptionsLoaded(object? sender, OptionsLoadedArgs e) |
| 87 | + { |
| 88 | + InvocationContext context = e.Context; |
| 89 | + |
| 90 | + var filePath = context.ParseResult.GetValueForOption(_filePath); |
| 91 | + if (filePath is not null) |
| 92 | + { |
| 93 | + _configuration.FilePath = filePath; |
| 94 | + } |
| 95 | + |
| 96 | + var groupBy = context.ParseResult.GetValueForOption(_groupBy); |
| 97 | + if (groupBy is not null) |
| 98 | + { |
| 99 | + _configuration.GroupBy = groupBy.Value; |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | + private void AfterRecordingStop(object? sender, RecordingArgs e) |
| 104 | + { |
| 105 | + if (!e.RequestLogs.Any()) |
| 106 | + { |
| 107 | + return; |
| 108 | + } |
| 109 | + |
| 110 | + var report = _configuration.GroupBy switch |
| 111 | + { |
| 112 | + SummaryGroupBy.Url => GetGroupedByUrlReport(e.RequestLogs), |
| 113 | + SummaryGroupBy.MessageType => GetGroupedByMessageTypeReport(e.RequestLogs), |
| 114 | + _ => throw new NotImplementedException() |
| 115 | + }; |
| 116 | + |
| 117 | + if (string.IsNullOrEmpty(_configuration.FilePath)) |
| 118 | + { |
| 119 | + _logger?.LogInfo(string.Join(Environment.NewLine, report)); |
| 120 | + } |
| 121 | + else |
| 122 | + { |
| 123 | + File.WriteAllLines(_configuration.FilePath, report); |
| 124 | + } |
| 125 | + } |
| 126 | + |
| 127 | + private string[] GetGroupedByUrlReport(IEnumerable<RequestLog> requestLogs) |
| 128 | + { |
| 129 | + var report = new List<string>(); |
| 130 | + report.AddRange(GetReportTitle()); |
| 131 | + report.Add("## Requests"); |
| 132 | + |
| 133 | + var data = GetGroupedByUrlData(requestLogs); |
| 134 | + |
| 135 | + var sortedMethodAndUrls = data.Keys.OrderBy(k => k); |
| 136 | + foreach (var methodAndUrl in sortedMethodAndUrls) |
| 137 | + { |
| 138 | + report.AddRange(new[] { |
| 139 | + "", |
| 140 | + $"### {methodAndUrl}", |
| 141 | + }); |
| 142 | + |
| 143 | + var sortedMessageTypes = data[methodAndUrl].Keys.OrderBy(k => k); |
| 144 | + foreach (var messageType in sortedMessageTypes) |
| 145 | + { |
| 146 | + report.AddRange(new [] { |
| 147 | + "", |
| 148 | + $"#### {messageType}", |
| 149 | + "" |
| 150 | + }); |
| 151 | + |
| 152 | + var sortedMessages = data[methodAndUrl][messageType].Keys.OrderBy(k => k); |
| 153 | + foreach (var message in sortedMessages) |
| 154 | + { |
| 155 | + report.Add($"- ({data[methodAndUrl][messageType][message]}) {message}"); |
| 156 | + } |
| 157 | + } |
| 158 | + } |
| 159 | + |
| 160 | + report.AddRange(GetSummary(requestLogs)); |
| 161 | + |
| 162 | + return report.ToArray(); |
| 163 | + } |
| 164 | + |
| 165 | + private string[] GetGroupedByMessageTypeReport(IEnumerable<RequestLog> requestLogs) |
| 166 | + { |
| 167 | + var report = new List<string>(); |
| 168 | + report.AddRange(GetReportTitle()); |
| 169 | + report.Add("## Message types"); |
| 170 | + |
| 171 | + var data = GetGroupedByMessageTypeData(requestLogs); |
| 172 | + |
| 173 | + var sortedMessageTypes = data.Keys.OrderBy(k => k); |
| 174 | + foreach (var messageType in sortedMessageTypes) |
| 175 | + { |
| 176 | + report.AddRange(new[] { |
| 177 | + "", |
| 178 | + $"### {messageType}" |
| 179 | + }); |
| 180 | + |
| 181 | + if (messageType == _requestsInterceptedMessage || |
| 182 | + messageType == _requestsPassedThroughMessage) |
| 183 | + { |
| 184 | + report.Add(""); |
| 185 | + |
| 186 | + var sortedMethodAndUrls = data[messageType][messageType].Keys.OrderBy(k => k); |
| 187 | + foreach (var methodAndUrl in sortedMethodAndUrls) |
| 188 | + { |
| 189 | + report.Add($"- ({data[messageType][messageType][methodAndUrl]}) {methodAndUrl}"); |
| 190 | + } |
| 191 | + } |
| 192 | + else |
| 193 | + { |
| 194 | + var sortedMessages = data[messageType].Keys.OrderBy(k => k); |
| 195 | + foreach (var message in sortedMessages) |
| 196 | + { |
| 197 | + report.AddRange(new[] { |
| 198 | + "", |
| 199 | + $"#### {message}", |
| 200 | + "" |
| 201 | + }); |
| 202 | + |
| 203 | + var sortedMethodAndUrls = data[messageType][message].Keys.OrderBy(k => k); |
| 204 | + foreach (var methodAndUrl in sortedMethodAndUrls) |
| 205 | + { |
| 206 | + report.Add($"- ({data[messageType][message][methodAndUrl]}) {methodAndUrl}"); |
| 207 | + } |
| 208 | + } |
| 209 | + } |
| 210 | + } |
| 211 | + |
| 212 | + report.AddRange(GetSummary(requestLogs)); |
| 213 | + |
| 214 | + return report.ToArray(); |
| 215 | + } |
| 216 | + |
| 217 | + private string[] GetReportTitle() |
| 218 | + { |
| 219 | + return new string[] |
| 220 | + { |
| 221 | + "# Microsoft Graph Developer Proxy execution summary", |
| 222 | + "", |
| 223 | + $"Date: {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}", |
| 224 | + "" |
| 225 | + }; |
| 226 | + } |
| 227 | + |
| 228 | + // in this method we're producing the follow data structure |
| 229 | + // request > message type > (count) message |
| 230 | + private Dictionary<string, Dictionary<string, Dictionary<string, int>>> GetGroupedByUrlData(IEnumerable<RequestLog> requestLogs) |
| 231 | + { |
| 232 | + var data = new Dictionary<string, Dictionary<string, Dictionary<string, int>>>(); |
| 233 | + |
| 234 | + foreach (var log in requestLogs) |
| 235 | + { |
| 236 | + var message = GetRequestMessage(log); |
| 237 | + if (log.MessageType == MessageType.InterceptedRequest) |
| 238 | + { |
| 239 | + var request = message; |
| 240 | + if (!data.ContainsKey(request)) |
| 241 | + { |
| 242 | + data.Add(request, new Dictionary<string, Dictionary<string, int>>()); |
| 243 | + } |
| 244 | + |
| 245 | + continue; |
| 246 | + } |
| 247 | + |
| 248 | + // last line of the message is the method and URL of the request |
| 249 | + var methodAndUrl = GetMethodAndUrl(log); |
| 250 | + var readableMessageType = GetReadableMessageTypeForSummary(log.MessageType); |
| 251 | + if (!data[methodAndUrl].ContainsKey(readableMessageType)) |
| 252 | + { |
| 253 | + data[methodAndUrl].Add(readableMessageType, new Dictionary<string, int>()); |
| 254 | + } |
| 255 | + |
| 256 | + if (data[methodAndUrl][readableMessageType].ContainsKey(message)) |
| 257 | + { |
| 258 | + data[methodAndUrl][readableMessageType][message]++; |
| 259 | + } |
| 260 | + else |
| 261 | + { |
| 262 | + data[methodAndUrl][readableMessageType].Add(message, 1); |
| 263 | + } |
| 264 | + } |
| 265 | + |
| 266 | + return data; |
| 267 | + } |
| 268 | + |
| 269 | + // in this method we're producing the follow data structure |
| 270 | + // message type > message > (count) request |
| 271 | + private Dictionary<string, Dictionary<string, Dictionary<string, int>>> GetGroupedByMessageTypeData(IEnumerable<RequestLog> requestLogs) |
| 272 | + { |
| 273 | + var data = new Dictionary<string, Dictionary<string, Dictionary<string, int>>>(); |
| 274 | + |
| 275 | + foreach (var log in requestLogs) |
| 276 | + { |
| 277 | + var readableMessageType = GetReadableMessageTypeForSummary(log.MessageType); |
| 278 | + if (!data.ContainsKey(readableMessageType)) |
| 279 | + { |
| 280 | + data.Add(readableMessageType, new Dictionary<string, Dictionary<string, int>>()); |
| 281 | + |
| 282 | + if (log.MessageType == MessageType.InterceptedRequest || |
| 283 | + log.MessageType == MessageType.PassedThrough) |
| 284 | + { |
| 285 | + // intercepted and passed through requests don't have |
| 286 | + // a sub-grouping so let's repeat the message type |
| 287 | + // to keep the same data shape |
| 288 | + data[readableMessageType].Add(readableMessageType, new Dictionary<string, int>()); |
| 289 | + } |
| 290 | + } |
| 291 | + |
| 292 | + var message = GetRequestMessage(log); |
| 293 | + if (log.MessageType == MessageType.InterceptedRequest || |
| 294 | + log.MessageType == MessageType.PassedThrough) |
| 295 | + { |
| 296 | + // for passed through requests we need to log the URL rather than the |
| 297 | + // fixed message |
| 298 | + if (log.MessageType == MessageType.PassedThrough) { |
| 299 | + message = GetMethodAndUrl(log); |
| 300 | + } |
| 301 | + |
| 302 | + if (!data[readableMessageType][readableMessageType].ContainsKey(message)) |
| 303 | + { |
| 304 | + data[readableMessageType][readableMessageType].Add(message, 1); |
| 305 | + } |
| 306 | + else |
| 307 | + { |
| 308 | + data[readableMessageType][readableMessageType][message]++; |
| 309 | + } |
| 310 | + continue; |
| 311 | + } |
| 312 | + |
| 313 | + if (!data[readableMessageType].ContainsKey(message)) |
| 314 | + { |
| 315 | + data[readableMessageType].Add(message, new Dictionary<string, int>()); |
| 316 | + } |
| 317 | + var methodAndUrl = GetMethodAndUrl(log); |
| 318 | + if (data[readableMessageType][message].ContainsKey(methodAndUrl)) |
| 319 | + { |
| 320 | + data[readableMessageType][message][methodAndUrl]++; |
| 321 | + } |
| 322 | + else |
| 323 | + { |
| 324 | + data[readableMessageType][message].Add(methodAndUrl, 1); |
| 325 | + } |
| 326 | + } |
| 327 | + |
| 328 | + return data; |
| 329 | + } |
| 330 | + |
| 331 | + private string GetRequestMessage(RequestLog requestLog) |
| 332 | + { |
| 333 | + return String.Join(' ', requestLog.Message); |
| 334 | + } |
| 335 | + |
| 336 | + private string GetMethodAndUrl(RequestLog requestLog) |
| 337 | + { |
| 338 | + if (requestLog.MessageType == MessageType.InterceptedRequest) |
| 339 | + { |
| 340 | + return requestLog.Message.First(); |
| 341 | + } |
| 342 | + else |
| 343 | + { |
| 344 | + if (requestLog.Context is not null) |
| 345 | + { |
| 346 | + return $"{requestLog.Context.Session.HttpClient.Request.Method} {requestLog.Context.Session.HttpClient.Request.RequestUri}"; |
| 347 | + } |
| 348 | + else |
| 349 | + { |
| 350 | + return "Undefined"; |
| 351 | + } |
| 352 | + } |
| 353 | + } |
| 354 | + |
| 355 | + private string[] GetSummary(IEnumerable<RequestLog> requestLogs) |
| 356 | + { |
| 357 | + var data = requestLogs |
| 358 | + .Select(log => GetReadableMessageTypeForSummary(log.MessageType)) |
| 359 | + .OrderBy(log => log) |
| 360 | + .GroupBy(log => log) |
| 361 | + .ToDictionary(group => group.Key, group => group.Count()); |
| 362 | + |
| 363 | + var summary = new List<string> { |
| 364 | + "", |
| 365 | + "## Summary", |
| 366 | + "", |
| 367 | + "Category|Count", |
| 368 | + "--------|----:" |
| 369 | + }; |
| 370 | + |
| 371 | + foreach (var messageType in data.Keys) |
| 372 | + { |
| 373 | + summary.Add($"{messageType}|{data[messageType]}"); |
| 374 | + } |
| 375 | + |
| 376 | + return summary.ToArray(); |
| 377 | + } |
| 378 | + |
| 379 | + private string GetReadableMessageTypeForSummary(MessageType messageType) => messageType switch |
| 380 | + { |
| 381 | + MessageType.Chaos => "Requests with chaos", |
| 382 | + MessageType.Failed => "Failures", |
| 383 | + MessageType.InterceptedRequest => _requestsInterceptedMessage, |
| 384 | + MessageType.Mocked => "Requests mocked", |
| 385 | + MessageType.PassedThrough => _requestsPassedThroughMessage, |
| 386 | + MessageType.Tip => "Tips", |
| 387 | + MessageType.Warning => "Warnings", |
| 388 | + _ => "Unknown" |
| 389 | + }; |
| 390 | +} |
0 commit comments