2021-09-22 17:48:34 +00:00
|
|
|
using System;
|
2020-08-26 22:07:00 +00:00
|
|
|
using System.Collections.Generic;
|
|
|
|
using System.Diagnostics;
|
2020-08-27 20:33:50 +00:00
|
|
|
using System.Linq;
|
2020-08-26 22:07:00 +00:00
|
|
|
using System.Net.Http;
|
|
|
|
using System.Runtime.CompilerServices;
|
|
|
|
using System.Text.RegularExpressions;
|
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
|
|
|
using App.Metrics;
|
|
|
|
|
|
|
|
using Autofac;
|
|
|
|
|
|
|
|
using Serilog;
|
|
|
|
using Serilog.Context;
|
|
|
|
|
|
|
|
namespace PluralKit.Bot
|
|
|
|
{
|
2021-06-10 12:21:05 +00:00
|
|
|
// TODO: phase this out; it currently still handles metrics but that needs to be moved to Myriad probably?
|
2020-08-26 22:07:00 +00:00
|
|
|
public class DiscordRequestObserver: IObserver<KeyValuePair<string, object>>
|
|
|
|
{
|
|
|
|
private readonly IMetrics _metrics;
|
|
|
|
private readonly ILogger _logger;
|
|
|
|
|
2020-08-27 20:33:50 +00:00
|
|
|
private bool ShouldLogHeader(string name) =>
|
|
|
|
name.StartsWith("x-ratelimit");
|
|
|
|
|
2020-08-26 22:07:00 +00:00
|
|
|
public DiscordRequestObserver(ILogger logger, IMetrics metrics)
|
|
|
|
{
|
|
|
|
_metrics = metrics;
|
|
|
|
_logger = logger.ForContext<DiscordRequestObserver>();
|
|
|
|
}
|
2021-08-27 15:03:47 +00:00
|
|
|
|
2020-08-26 22:07:00 +00:00
|
|
|
public void OnCompleted() { }
|
|
|
|
|
|
|
|
public void OnError(Exception error) { }
|
|
|
|
|
2020-08-27 16:20:20 +00:00
|
|
|
private string NormalizeRoutePath(string url)
|
2020-08-26 22:07:00 +00:00
|
|
|
{
|
2021-05-03 07:24:18 +00:00
|
|
|
url = Regex.Replace(url, @"/channels/\d+", "/channels/{channel_id}");
|
|
|
|
url = Regex.Replace(url, @"/messages/\d+", "/messages/{message_id}");
|
|
|
|
url = Regex.Replace(url, @"/members/\d+", "/members/{user_id}");
|
|
|
|
url = Regex.Replace(url, @"/webhooks/\d+/[^/]+", "/webhooks/{webhook_id}/{webhook_token}");
|
|
|
|
url = Regex.Replace(url, @"/webhooks/\d+", "/webhooks/{webhook_id}");
|
|
|
|
url = Regex.Replace(url, @"/users/\d+", "/users/{user_id}");
|
|
|
|
url = Regex.Replace(url, @"/bans/\d+", "/bans/{user_id}");
|
|
|
|
url = Regex.Replace(url, @"/roles/\d+", "/roles/{role_id}");
|
|
|
|
url = Regex.Replace(url, @"/pins/\d+", "/pins/{message_id}");
|
|
|
|
url = Regex.Replace(url, @"/emojis/\d+", "/emojis/{emoji_id}");
|
|
|
|
url = Regex.Replace(url, @"/guilds/\d+", "/guilds/{guild_id}");
|
|
|
|
url = Regex.Replace(url, @"/integrations/\d+", "/integrations/{integration_id}");
|
|
|
|
url = Regex.Replace(url, @"/permissions/\d+", "/permissions/{overwrite_id}");
|
|
|
|
url = Regex.Replace(url, @"/reactions/[^{/]+/\d+", "/reactions/{emoji}/{user_id}");
|
2020-08-26 22:07:00 +00:00
|
|
|
url = Regex.Replace(url, @"/reactions/[^{/]+", "/reactions/{emoji}");
|
|
|
|
url = Regex.Replace(url, @"/invites/[^{/]+", "/invites/{invite_code}");
|
2021-09-06 22:11:57 +00:00
|
|
|
url = Regex.Replace(url, @"/interactions/\d+/[^{/]+", "/interactions/{interaction_id}/{interaction_token}");
|
|
|
|
url = Regex.Replace(url, @"/interactions/\d+", "/interactions/{interaction_id}");
|
2021-08-27 15:03:47 +00:00
|
|
|
|
2020-11-19 10:43:05 +00:00
|
|
|
// catch-all for missed IDs
|
|
|
|
url = Regex.Replace(url, @"\d{17,19}", "{snowflake}");
|
2021-08-27 15:03:47 +00:00
|
|
|
|
2020-08-26 22:07:00 +00:00
|
|
|
return url;
|
|
|
|
}
|
|
|
|
|
2020-11-19 10:43:05 +00:00
|
|
|
private string GetEndpointName(HttpRequestMessage req)
|
2020-08-27 16:20:20 +00:00
|
|
|
{
|
2020-11-19 10:43:05 +00:00
|
|
|
var localPath = Regex.Replace(req.RequestUri.LocalPath, @"/api/v\d+", "");
|
|
|
|
var routePath = NormalizeRoutePath(localPath);
|
2020-08-27 16:20:20 +00:00
|
|
|
return $"{req.Method} {routePath}";
|
|
|
|
}
|
2021-08-27 15:03:47 +00:00
|
|
|
|
2020-08-27 16:20:20 +00:00
|
|
|
private void HandleException(Exception exc, HttpRequestMessage req)
|
|
|
|
{
|
|
|
|
_logger
|
2020-11-19 10:43:05 +00:00
|
|
|
.ForContext("RequestUrlRoute", GetEndpointName(req))
|
2020-08-27 16:20:20 +00:00
|
|
|
.Error(exc, "HTTP error: {RequestMethod} {RequestUrl}", req.Method, req.RequestUri);
|
|
|
|
}
|
|
|
|
|
2021-07-14 23:50:10 +00:00
|
|
|
private void HandleResponse(HttpResponseMessage response, Activity activity)
|
2020-08-26 22:07:00 +00:00
|
|
|
{
|
2020-11-19 10:43:05 +00:00
|
|
|
var endpoint = GetEndpointName(response.RequestMessage);
|
2020-08-27 14:26:37 +00:00
|
|
|
|
2021-06-10 12:21:05 +00:00
|
|
|
// (see phase-out notice at top of file)
|
|
|
|
/*
|
2020-08-26 22:07:00 +00:00
|
|
|
using (LogContext.PushProperty("Elastic", "yes?"))
|
|
|
|
{
|
2020-08-27 19:54:33 +00:00
|
|
|
if ((int) response.StatusCode >= 400)
|
2020-08-26 22:07:00 +00:00
|
|
|
{
|
|
|
|
var content = await response.Content.ReadAsStringAsync();
|
|
|
|
LogContext.PushProperty("ResponseBody", content);
|
|
|
|
}
|
2020-08-27 20:33:50 +00:00
|
|
|
|
|
|
|
var headers = response.Headers
|
|
|
|
.Where(header => ShouldLogHeader(header.Key.ToLowerInvariant()))
|
|
|
|
.ToDictionary(k => k.Key.ToLowerInvariant(),
|
|
|
|
v => string.Join(';', v.Value));
|
2020-08-26 22:07:00 +00:00
|
|
|
|
2020-08-27 16:20:20 +00:00
|
|
|
_logger
|
|
|
|
.ForContext("RequestUrlRoute", endpoint)
|
2020-08-27 20:33:50 +00:00
|
|
|
.ForContext("ResponseHeaders", headers)
|
2020-08-27 19:28:36 +00:00
|
|
|
.Debug(
|
2020-08-27 16:20:20 +00:00
|
|
|
"HTTP: {RequestMethod} {RequestUrl} -> {ResponseStatusCode} {ResponseStatusString} (in {RequestDurationMs:F1} ms)",
|
2020-08-27 12:38:11 +00:00
|
|
|
response.RequestMessage.Method,
|
2020-08-26 22:07:00 +00:00
|
|
|
response.RequestMessage.RequestUri,
|
|
|
|
(int) response.StatusCode,
|
|
|
|
response.ReasonPhrase,
|
|
|
|
activity.Duration.TotalMilliseconds);
|
|
|
|
}
|
2021-06-10 12:21:05 +00:00
|
|
|
*/
|
2020-08-26 22:07:00 +00:00
|
|
|
|
2020-11-19 10:43:05 +00:00
|
|
|
if (IsDiscordApiRequest(response))
|
|
|
|
{
|
|
|
|
var timer = _metrics.Provider.Timer.Instance(BotMetrics.DiscordApiRequests, new MetricTags(
|
2021-08-27 15:03:47 +00:00
|
|
|
new[] { "endpoint", "status_code" },
|
|
|
|
new[] { endpoint, ((int)response.StatusCode).ToString() }
|
2020-11-19 10:43:05 +00:00
|
|
|
));
|
|
|
|
timer.Record(activity.Duration.Ticks / 10, TimeUnit.Microseconds);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private static bool IsDiscordApiRequest(HttpResponseMessage response)
|
|
|
|
{
|
|
|
|
// Assume any properly authorized request is coming from D#+ and not some sort of user
|
|
|
|
var authHeader = response.RequestMessage.Headers.Authorization;
|
|
|
|
if (authHeader == null || authHeader.Scheme != "Bot")
|
|
|
|
return false;
|
|
|
|
|
|
|
|
return response.RequestMessage.RequestUri.AbsoluteUri.StartsWith("https://discord.com/api/");
|
2020-08-26 22:07:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public void OnNext(KeyValuePair<string, object> value)
|
|
|
|
{
|
2020-08-27 16:20:20 +00:00
|
|
|
switch (value.Key)
|
2020-08-26 22:07:00 +00:00
|
|
|
{
|
2020-08-27 16:20:20 +00:00
|
|
|
case "System.Net.Http.HttpRequestOut.Stop":
|
2021-08-27 15:03:47 +00:00
|
|
|
{
|
|
|
|
var data = Unsafe.As<ActivityStopData>(value.Value);
|
|
|
|
if (data.Response != null)
|
|
|
|
HandleResponse(data.Response, Activity.Current);
|
2020-08-27 16:20:20 +00:00
|
|
|
|
2021-08-27 15:03:47 +00:00
|
|
|
break;
|
|
|
|
}
|
2020-08-27 16:20:20 +00:00
|
|
|
case "System.Net.Http.Exception":
|
2021-08-27 15:03:47 +00:00
|
|
|
{
|
|
|
|
var data = Unsafe.As<ExceptionData>(value.Value);
|
|
|
|
HandleException(data.Exception, data.Request);
|
|
|
|
break;
|
|
|
|
}
|
2020-08-26 22:07:00 +00:00
|
|
|
}
|
|
|
|
}
|
2020-08-27 16:20:20 +00:00
|
|
|
|
2020-08-26 22:07:00 +00:00
|
|
|
public static void Install(IComponentContext services)
|
|
|
|
{
|
|
|
|
DiagnosticListener.AllListeners.Subscribe(new ListenerObserver(services));
|
|
|
|
}
|
2021-08-27 15:03:47 +00:00
|
|
|
|
2020-08-27 16:20:20 +00:00
|
|
|
#pragma warning disable 649
|
|
|
|
private class ActivityStopData
|
2020-08-26 22:07:00 +00:00
|
|
|
{
|
|
|
|
// Field order here matters!
|
|
|
|
public HttpResponseMessage Response;
|
|
|
|
public HttpRequestMessage Request;
|
|
|
|
public TaskStatus RequestTaskStatus;
|
|
|
|
}
|
2021-08-27 15:03:47 +00:00
|
|
|
|
2020-08-27 16:20:20 +00:00
|
|
|
private class ExceptionData
|
|
|
|
{
|
|
|
|
// Field order here matters!
|
|
|
|
public Exception Exception;
|
|
|
|
public HttpRequestMessage Request;
|
|
|
|
}
|
|
|
|
#pragma warning restore 649
|
2020-08-26 22:07:00 +00:00
|
|
|
|
|
|
|
public class ListenerObserver: IObserver<DiagnosticListener>
|
|
|
|
{
|
|
|
|
private readonly IComponentContext _services;
|
|
|
|
private DiscordRequestObserver _observer;
|
|
|
|
|
|
|
|
public ListenerObserver(IComponentContext services)
|
|
|
|
{
|
|
|
|
_services = services;
|
|
|
|
}
|
|
|
|
|
|
|
|
public void OnCompleted() { }
|
|
|
|
|
|
|
|
public void OnError(Exception error) { }
|
|
|
|
|
|
|
|
public void OnNext(DiagnosticListener value)
|
|
|
|
{
|
|
|
|
if (value.Name != "HttpHandlerDiagnosticListener")
|
|
|
|
return;
|
|
|
|
|
|
|
|
_observer ??= _services.Resolve<DiscordRequestObserver>();
|
|
|
|
value.Subscribe(_observer);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|