diff --git a/Build.ps1 b/Build.ps1 index 34a2dc5..dc24499 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -14,7 +14,7 @@ if($LASTEXITCODE -ne 0) { exit 1 } $branch = @{ $true = $env:APPVEYOR_REPO_BRANCH; $false = $(git symbolic-ref --short -q HEAD) }[$env:APPVEYOR_REPO_BRANCH -ne $NULL]; $revision = @{ $true = "{0:00000}" -f [convert]::ToInt32("0" + $env:APPVEYOR_BUILD_NUMBER, 10); $false = "local" }[$env:APPVEYOR_BUILD_NUMBER -ne $NULL]; -$suffix = @{ $true = ""; $false = "$($branch.Substring(0, [math]::Min(10,$branch.Length)))-$revision"}[$branch -eq "master" -and $revision -ne "local"] +$suffix = @{ $true = ""; $false = "$($branch.Substring(0, [math]::Min(10,$branch.Length)))-$revision"}[$branch -eq "main" -and $revision -ne "local"] echo "build: Version suffix is $suffix" diff --git a/appveyor.yml b/appveyor.yml index 9931ad4..26b17bc 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,8 +1,6 @@ version: '{build}' skip_tags: true -image: Visual Studio 2017 -configuration: Release -install: +image: Visual Studio 2022 build_script: - ps: ./Build.ps1 test: off @@ -11,10 +9,10 @@ artifacts: deploy: - provider: NuGet api_key: - secure: ABsZ0uLbAvoUFXnkUJ/DZwQwGQ1EIKzvSOm3aFSUZ0kY4lTBIQhI38KxrNFahQRB + secure: bkES4Ho0Cs/3Ws1PP7fYfSZop6K8VfqAAEFUR25eQRCJXVDPa+atx3alsaMhzVdo skip_symbols: true on: - branch: /^(master|dev)$/ + branch: /^(main|dev)$/ - provider: GitHub auth_token: secure: hX+cZmW+9BCXy7vyH8myWsYdtQHyzzil9K5yvjJv7dK9XmyrGYYDj/DPzMqsXSjo diff --git a/asset/seq-input-healthcheck-2.png b/asset/seq-input-healthcheck-2.png deleted file mode 100644 index 9f2fcf4..0000000 Binary files a/asset/seq-input-healthcheck-2.png and /dev/null differ diff --git a/src/Seq.Input.HealthCheck/JsonDataExtractor.cs b/src/Seq.Input.HealthCheck/Data/JsonDataExtractor.cs similarity index 77% rename from src/Seq.Input.HealthCheck/JsonDataExtractor.cs rename to src/Seq.Input.HealthCheck/Data/JsonDataExtractor.cs index 73c8260..3927344 100644 --- a/src/Seq.Input.HealthCheck/JsonDataExtractor.cs +++ b/src/Seq.Input.HealthCheck/Data/JsonDataExtractor.cs @@ -16,34 +16,33 @@ using System.IO; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using Serilog.Events; -using Serilog.Filters.Expressions; +using Serilog.Expressions; using Serilog.Formatting.Compact.Reader; using Serilog.Formatting.Json; -namespace Seq.Input.HealthCheck +namespace Seq.Input.HealthCheck.Data { public class JsonDataExtractor { - static readonly JsonValueFormatter ValueFormatter = new JsonValueFormatter("$type"); + static readonly JsonValueFormatter ValueFormatter = new("$type"); static readonly JsonSerializer Serializer = JsonSerializer.Create(new JsonSerializerSettings { DateParseHandling = DateParseHandling.None }); - readonly Func _extract; + readonly Func _extract; public JsonDataExtractor(string expression) { if (expression == "@Properties") { - _extract = v => v; + _extract = v => v ?? JValue.CreateNull(); } else { - var expr = FilterLanguage.CreateFilter(expression); + var expr = SerilogExpression.Compile(expression, nameResolver: new SeqSyntaxNameResolver()); _extract = v => { - if (!(v is JObject obj)) + if (v is not JObject obj) throw new ArgumentException("Data value extraction requires a JSON object response."); if (!obj.ContainsKey("@t")) @@ -52,16 +51,15 @@ public JsonDataExtractor(string expression) var le = LogEventReader.ReadFromJObject(obj); var value = expr(le); + + // `null` here means "undefined", but for most purposes this substitution is convenient. if (value == null) return JValue.CreateNull(); - if (!(value is LogEventPropertyValue lepv)) - return JToken.FromObject(value); - var sw = new StringWriter(); - ValueFormatter.Format(lepv, sw); + ValueFormatter.Format(value, sw); return Serializer.Deserialize( - new JsonTextReader(new StringReader(sw.ToString()))); + new JsonTextReader(new StringReader(sw.ToString())))!; }; } } diff --git a/src/Seq.Input.HealthCheck/Data/SeqSyntaxNameResolver.cs b/src/Seq.Input.HealthCheck/Data/SeqSyntaxNameResolver.cs new file mode 100644 index 0000000..503b81c --- /dev/null +++ b/src/Seq.Input.HealthCheck/Data/SeqSyntaxNameResolver.cs @@ -0,0 +1,46 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Serilog.Events; +using Serilog.Expressions; + +namespace Seq.Input.HealthCheck.Data +{ + public class SeqSyntaxNameResolver: NameResolver + { + // ReSharper disable once UnusedMember.Global + // ReSharper disable once ReturnTypeCanBeNotNullable + public static LogEventPropertyValue? Has(LogEventPropertyValue? value) + { + return new ScalarValue(value != null); + } + + public override bool TryResolveFunctionName(string name, [NotNullWhen(true)] out MethodInfo? implementation) + { + if ("Has".Equals(name, StringComparison.OrdinalIgnoreCase)) + { + implementation = GetType().GetMethod("Has", BindingFlags.Static | BindingFlags.Public)!; + return true; + } + + implementation = null; + return false; + } + + public override bool TryResolveBuiltInPropertyName(string alias, [NotNullWhen(true)] out string? target) + { + target = alias switch + { + "Exception" => "x", + "Level" => "l", + "Message" => "m", + "MessageTemplate" => "mt", + "Properties" => "p", + "Timestamp" => "t", + _ => null + }; + + return target != null; + } + } +} \ No newline at end of file diff --git a/src/Seq.Input.HealthCheck/HealthCheckInput.cs b/src/Seq.Input.HealthCheck/HealthCheckInput.cs index bcc8437..fe08f39 100644 --- a/src/Seq.Input.HealthCheck/HealthCheckInput.cs +++ b/src/Seq.Input.HealthCheck/HealthCheckInput.cs @@ -17,6 +17,11 @@ using System.IO; using System.Net.Http; using Seq.Apps; +using Seq.Input.HealthCheck.Data; +using Seq.Input.HealthCheck.Util; +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable AutoPropertyCanBeMadeGetOnly.Global namespace Seq.Input.HealthCheck { @@ -25,14 +30,30 @@ namespace Seq.Input.HealthCheck public class HealthCheckInput : SeqApp, IPublishJson, IDisposable { readonly List _healthCheckTasks = new List(); - HttpClient _httpClient; + HttpClient? _httpClient; [SeqAppSetting( DisplayName = "Target URLs", HelpText = "The HTTP or HTTPS URL that the health check will periodically GET. Multiple URLs " + "can be checked; enter one per line.", InputType = SettingInputType.LongText)] - public string TargetUrl { get; set; } + public string TargetUrl { get; set; } = null!; + + [SeqAppSetting(InputType = SettingInputType.Password, IsOptional = true, DisplayName = "Authentication Header", + HelpText = "An optional `Name: Value` header, stored as sensitive data, for authentication purposes.")] + public string? AuthenticationHeader { get; set; } + + [SeqAppSetting(InputType = SettingInputType.LongText, IsOptional = true, DisplayName = "Other Headers", + HelpText = "Additional headers to send with the request, one per line in `Name: Value` format.")] + public string? OtherHeaders { get; set; } + + [SeqAppSetting( + DisplayName = "Bypass HTTP caching", + IsOptional = true, + HelpText = "If selected, the unique probe id will be appended to the target URL query string as " + + "`" + HttpHealthCheck.ProbeIdParameterName + "`, in order to disable any " + + "intermediary HTTP caching. The `Cache-Control: no-store` header will also be sent.")] + public bool BypassHttpCaching { get; set; } [SeqAppSetting( DisplayName = "Interval (seconds)", @@ -47,22 +68,14 @@ public class HealthCheckInput : SeqApp, IPublishJson, IDisposable "The expression will be evaluated against the response to produce a `Data` property" + " on the resulting event. Use the special value `@Properties` to capture the whole " + "response. The response must be UTF-8 `application/json` for this to be applied.")] - public string DataExtractionExpression { get; set; } - - [SeqAppSetting( - DisplayName = "Bypass HTTP caching", - IsOptional = true, - HelpText = "If selected, the unique probe id will be appended to the target URL query string as " + - "`" + HttpHealthCheck.ProbeIdParameterName + "`, in order to disable any " + - "intermediary HTTP caching. The `Cache-Control: no-store` header will also be sent.")] - public bool BypassHttpCaching { get; set; } - + public string? DataExtractionExpression { get; set; } + public void Start(TextWriter inputWriter) { _httpClient = HttpHealthCheckClient.Create(); var reporter = new HealthCheckReporter(inputWriter); - JsonDataExtractor extractor = null; + JsonDataExtractor? extractor = null; if (!string.IsNullOrWhiteSpace(DataExtractionExpression)) extractor = new JsonDataExtractor(DataExtractionExpression); @@ -73,6 +86,7 @@ public void Start(TextWriter inputWriter) _httpClient, App.Title, targetUrl, + HeaderSettingFormat.FromSettings(AuthenticationHeader, OtherHeaders), extractor, BypassHttpCaching); diff --git a/src/Seq.Input.HealthCheck/HealthCheckResult.cs b/src/Seq.Input.HealthCheck/HealthCheckResult.cs index c3bf3a2..2296935 100644 --- a/src/Seq.Input.HealthCheck/HealthCheckResult.cs +++ b/src/Seq.Input.HealthCheck/HealthCheckResult.cs @@ -16,6 +16,8 @@ using System.Globalization; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable MemberCanBePrivate.Global namespace Seq.Input.HealthCheck { @@ -25,14 +27,14 @@ class HealthCheckResult public DateTime UtcTimestamp { get; } [JsonProperty("@x", DefaultValueHandling = DefaultValueHandling.Ignore)] - public string Exception { get; } + public string? Exception { get; } [JsonProperty("@mt")] public string MessageTemplate { get; } = "Health check {Method} {TargetUrl} {Outcome} with status code {StatusCode} in {Elapsed:0.000} ms"; [JsonProperty("@l", DefaultValueHandling = DefaultValueHandling.Ignore)] - public string Level { get; } + public string? Level { get; } [JsonProperty("@r")] public string[] Renderings => new[] {Elapsed.ToString("0.000", CultureInfo.InvariantCulture)}; @@ -43,18 +45,18 @@ class HealthCheckResult public string Outcome { get; } public double Elapsed { get; } public int? StatusCode { get; } - public string ContentType { get; } + public string? ContentType { get; } public long? ContentLength { get; } public string ProbeId { get; } [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public string InitialContent { get; } + public string? InitialContent { get; } [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public JToken Data { get; } + public JToken? Data { get; } [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public string ProbedUrl { get; } + public string? ProbedUrl { get; } public HealthCheckResult( DateTime utcTimestamp, @@ -63,15 +65,15 @@ public HealthCheckResult( string targetUrl, string outcome, string probeId, - string level, + string? level, double elapsed, int? statusCode, - string contentType, + string? contentType, long? contentLength, - string initialContent, - Exception exception, - JToken data, - string probedUrl) + string? initialContent, + Exception? exception, + JToken? data, + string? probedUrl) { if (utcTimestamp.Kind != DateTimeKind.Utc) throw new ArgumentException("The timestamp must be UTC.", nameof(utcTimestamp)); diff --git a/src/Seq.Input.HealthCheck/HttpHealthCheck.cs b/src/Seq.Input.HealthCheck/HttpHealthCheck.cs index 8e6998d..c6e6915 100644 --- a/src/Seq.Input.HealthCheck/HttpHealthCheck.cs +++ b/src/Seq.Input.HealthCheck/HttpHealthCheck.cs @@ -13,6 +13,7 @@ // limitations under the License. using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Net.Http; @@ -21,6 +22,7 @@ using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json.Linq; +using Seq.Input.HealthCheck.Data; using Seq.Input.HealthCheck.Util; namespace Seq.Input.HealthCheck @@ -29,36 +31,38 @@ class HttpHealthCheck { readonly string _title; readonly string _targetUrl; - readonly JsonDataExtractor _extractor; + readonly List<(string, string)> _headers; + readonly JsonDataExtractor? _extractor; readonly bool _bypassHttpCaching; readonly HttpClient _httpClient; readonly byte[] _buffer = new byte[2048]; public const string ProbeIdParameterName = "__probe"; - static readonly UTF8Encoding ForgivingEncoding = new UTF8Encoding(false, false); + static readonly UTF8Encoding ForgivingEncoding = new(false, false); const int InitialContentChars = 16; const string OutcomeSucceeded = "succeeded", OutcomeFailed = "failed"; - public HttpHealthCheck(HttpClient httpClient, string title, string targetUrl, JsonDataExtractor extractor, bool bypassHttpCaching) + public HttpHealthCheck(HttpClient httpClient, string title, string targetUrl, List<(string, string)> headers, JsonDataExtractor? extractor, bool bypassHttpCaching) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _title = title ?? throw new ArgumentNullException(nameof(title)); _targetUrl = targetUrl ?? throw new ArgumentNullException(nameof(targetUrl)); + _headers = headers; _extractor = extractor; _bypassHttpCaching = bypassHttpCaching; } - + public async Task CheckNow(CancellationToken cancel) { string outcome; - Exception exception = null; + Exception? exception = null; int? statusCode = null; - string contentType = null; + string? contentType = null; long? contentLength = null; - string initialContent = null; - JToken data = null; + string? initialContent = null; + JToken? data = null; var probeId = Nonce.Generate(12); var probedUrl = _bypassHttpCaching ? @@ -73,6 +77,12 @@ public async Task CheckNow(CancellationToken cancel) var request = new HttpRequestMessage(HttpMethod.Get, probedUrl); request.Headers.Add("X-Correlation-ID", probeId); + foreach (var (name, value) in _headers) + { + // This will throw if a header is duplicated (better for the user to detect this configuration problem). + request.Headers.Add(name, value); + } + if (_bypassHttpCaching) request.Headers.CacheControl = new CacheControlHeaderValue { NoStore = true }; @@ -82,7 +92,7 @@ public async Task CheckNow(CancellationToken cancel) contentType = response.Content.Headers.ContentType?.ToString(); contentLength = response.Content.Headers.ContentLength; - var content = await response.Content.ReadAsStreamAsync(); + var content = await response.Content.ReadAsStreamAsync(cancel); (initialContent, data) = await DownloadContent(content, contentType, contentLength); outcome = response.IsSuccessStatusCode ? OutcomeSucceeded : OutcomeFailed; @@ -118,18 +128,18 @@ public async Task CheckNow(CancellationToken cancel) } // Either initial content, or extracted data - async Task<(string initialContent, JToken data)> DownloadContent(Stream body, string contentType, long? contentLength) + async Task<(string? initialContent, JToken? data)> DownloadContent(Stream body, string? contentType, long? contentLength) { if (_extractor == null || contentLength == 0 || contentType != "application/json; charset=utf-8" && contentType != "application/json") { - var read = await body.ReadAsync(_buffer, 0, _buffer.Length); + var read = await body.ReadAsync(_buffer); var initial = ForgivingEncoding.GetString(_buffer, 0, Math.Min(read, InitialContentChars)); // Drain the response to avoid dropped connection errors on the server. while (read > 0) - read = await body.ReadAsync(_buffer, 0, _buffer.Length); + read = await body.ReadAsync(_buffer); return (initial, null); } diff --git a/src/Seq.Input.HealthCheck/Properties/AssemblyInfo.cs b/src/Seq.Input.HealthCheck/Properties/AssemblyInfo.cs deleted file mode 100644 index 07b8df9..0000000 --- a/src/Seq.Input.HealthCheck/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,3 +0,0 @@ -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("Seq.Input.HealthCheck.Tests")] diff --git a/src/Seq.Input.HealthCheck/Seq.Input.HealthCheck.csproj b/src/Seq.Input.HealthCheck/Seq.Input.HealthCheck.csproj index f794197..262635f 100644 --- a/src/Seq.Input.HealthCheck/Seq.Input.HealthCheck.csproj +++ b/src/Seq.Input.HealthCheck/Seq.Input.HealthCheck.csproj @@ -1,30 +1,37 @@ - netstandard2.0 - 1.2.1 + net5.0 + 2.0.0 Seq Health Check: periodically GET an HTTP resource and publish response metrics to Seq. Datalust and Contributors seq-app - https://raw.githubusercontent.com/datalust/seq-input-healthcheck/dev/asset/seq-input-healthcheck-2.png https://github.com/datalust/seq-input-healthcheck https://github.com/datalust/seq-input-healthcheck git True + seq-input-healthcheck.png LICENSE + 9 + enable + + + + - - - - - + + + + + + diff --git a/src/Seq.Input.HealthCheck/Settings/HeaderSettingFormat.cs b/src/Seq.Input.HealthCheck/Settings/HeaderSettingFormat.cs new file mode 100644 index 0000000..b794e6f --- /dev/null +++ b/src/Seq.Input.HealthCheck/Settings/HeaderSettingFormat.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.IO; +namespace Seq.Input.HealthCheck.Util +{ + static class HeaderSettingFormat + { + public static List<(string, string)> FromSettings(string? authenticationHeader, string? otherHeaders) + { + var headers = new List<(string, string)>(); + + if (!string.IsNullOrWhiteSpace(authenticationHeader)) + headers.Add(Parse(authenticationHeader)); + + if (!string.IsNullOrWhiteSpace(otherHeaders)) + { + var reader = new StringReader(otherHeaders); + var line = reader.ReadLine(); + while (!string.IsNullOrWhiteSpace(line)) + { + headers.Add(Parse(line)); + line = reader.ReadLine(); + } + } + + return headers; + } + + internal static (string, string) Parse(string header) + { + var colon = header.IndexOf(":", StringComparison.Ordinal); + if (colon is 0 or -1) + throw new ArgumentException("The header must be specified in `Name: Value` format."); + return (header[..colon].Trim(), header[(colon + 1)..].Trim()); + } + } +} diff --git a/src/Seq.Input.HealthCheck/Util/Nonce.cs b/src/Seq.Input.HealthCheck/Util/Nonce.cs index 7b68d77..4aa5e18 100644 --- a/src/Seq.Input.HealthCheck/Util/Nonce.cs +++ b/src/Seq.Input.HealthCheck/Util/Nonce.cs @@ -31,20 +31,18 @@ static IEnumerable GenerateChars(int count) { var rem = count; var buf = new byte[128]; - using (var rng = RandomNumberGenerator.Create()) + using var rng = RandomNumberGenerator.Create(); + while (rem > 0) { - while (rem > 0) + rng.GetBytes(buf); + var b64 = Convert.ToBase64String(buf); + for (var i = 0; i < b64.Length && rem > 0; ++i) { - rng.GetBytes(buf); - var b64 = Convert.ToBase64String(buf); - for (var i = 0; i < b64.Length && rem > 0; ++i) + var c = b64[i]; + if (char.IsLetterOrDigit(c)) { - var c = b64[i]; - if (char.IsLetterOrDigit(c)) - { - yield return c; - rem--; - } + yield return c; + rem--; } } } diff --git a/test/Seq.Input.HealthCheck.Tests/JsonDataExtractorTests.cs b/test/Seq.Input.HealthCheck.Tests/Data/JsonDataExtractorTests.cs similarity index 97% rename from test/Seq.Input.HealthCheck.Tests/JsonDataExtractorTests.cs rename to test/Seq.Input.HealthCheck.Tests/Data/JsonDataExtractorTests.cs index eeabdee..eca4e33 100644 --- a/test/Seq.Input.HealthCheck.Tests/JsonDataExtractorTests.cs +++ b/test/Seq.Input.HealthCheck.Tests/Data/JsonDataExtractorTests.cs @@ -1,6 +1,7 @@ using System.IO; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Seq.Input.HealthCheck.Data; using Xunit; namespace Seq.Input.HealthCheck.Tests diff --git a/test/Seq.Input.HealthCheck.Tests/Seq.Input.HealthCheck.Tests.csproj b/test/Seq.Input.HealthCheck.Tests/Seq.Input.HealthCheck.Tests.csproj index 3d785ea..103c03e 100644 --- a/test/Seq.Input.HealthCheck.Tests/Seq.Input.HealthCheck.Tests.csproj +++ b/test/Seq.Input.HealthCheck.Tests/Seq.Input.HealthCheck.Tests.csproj @@ -1,7 +1,7 @@ - netcoreapp2.1 + net5.0 false diff --git a/test/Seq.Input.HealthCheck.Tests/Settings/HeaderSettingFormatTests.cs b/test/Seq.Input.HealthCheck.Tests/Settings/HeaderSettingFormatTests.cs new file mode 100644 index 0000000..e9d1126 --- /dev/null +++ b/test/Seq.Input.HealthCheck.Tests/Settings/HeaderSettingFormatTests.cs @@ -0,0 +1,34 @@ +using System; +using Seq.Input.HealthCheck.Util; +using Xunit; + +namespace Seq.Input.HealthCheck.Tests.Settings +{ + + public class HeaderSettingFormatTests + { + [Theory] + [InlineData("")] + [InlineData("Name")] + [InlineData("Name Value")] + [InlineData(":Value")] + public void InvalidHeaderSettingsAreNotParsed(string setting) + { + Assert.Throws(() => HeaderSettingFormat.Parse(setting)); + } + + [Theory] + [InlineData("Name: Value", "Name", "Value")] + [InlineData("Name:Value", "Name", "Value")] + [InlineData("Name: Value ", "Name", "Value")] + [InlineData("Name:", "Name", "")] + [InlineData("Dashed-Name: Value", "Dashed-Name", "Value")] + [InlineData("Name: Value: Value", "Name", "Value: Value")] + public void ValidHeaderSettingsAreParsed(string setting, string expectedName, string expectedValue) + { + var (name, value) = HeaderSettingFormat.Parse(setting); + Assert.Equal(expectedName, name); + Assert.Equal(expectedValue, value); + } + } +} \ No newline at end of file diff --git a/test/Seq.Input.HealthCheck.Tests/Util/NonceTests.cs b/test/Seq.Input.HealthCheck.Tests/Util/NonceTests.cs index 00ee0cd..845e11c 100644 --- a/test/Seq.Input.HealthCheck.Tests/Util/NonceTests.cs +++ b/test/Seq.Input.HealthCheck.Tests/Util/NonceTests.cs @@ -7,7 +7,7 @@ namespace Seq.Input.HealthCheck.Tests.Util { public class NonceTests { - public static IEnumerable CharacterCounts = Enumerable.Range(0, 100).Select(n => new [] { (object)n }); + public static readonly IEnumerable CharacterCounts = Enumerable.Range(0, 100).Select(n => new [] { (object)n }); [Theory] [MemberData(nameof(CharacterCounts))]