From 31ed6c1025a2b543b1dd8a69deb81a58f18d9e67 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Wed, 30 Sep 2026 16:12:26 +0200 Subject: [PATCH 1/6] Add failing tests: bad sourceToken and endpoint values are not reported A missing source token is caught by [RequiredParameter] on NLog 4 and 5 only, NLog 6 no longer checks it and sends the logs with an empty token. An empty endpoint is reported as a UriFormatException, which NLog 4 swallows even with throwConfigExceptions. A bare ingesting host, as shown in the source settings, is not reached at all. Co-Authored-By: Claude Opus 5.5 --- .../BetterStackLogsTargetTests.cs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/BetterStack.Logs.NLog.Tests/BetterStackLogsTargetTests.cs b/tests/BetterStack.Logs.NLog.Tests/BetterStackLogsTargetTests.cs index a7de84a..44bb5a2 100644 --- a/tests/BetterStack.Logs.NLog.Tests/BetterStackLogsTargetTests.cs +++ b/tests/BetterStack.Logs.NLog.Tests/BetterStackLogsTargetTests.cs @@ -78,6 +78,63 @@ public void SendsMessageRenderedByLayout() Assert.Equal("ERROR|TestLogger|Order 75423 failed", (string)log["message"]); } + [Fact] + public void ReportsMissingSourceTokenAsConfigurationError() + { + logFactory.ThrowConfigExceptions = true; + var target = Target(); + target.SourceToken = null; + + var exception = Assert.Throws(() => LoggerFor(target)); + Assert.Equal("BetterStack.Logs: sourceToken is not set. Set it to the source token of your Better Stack source.", exception.Message); + } + + [Fact] + public void ReportsEmptyEndpointAsConfigurationError() + { + logFactory.ThrowConfigExceptions = true; + var target = Target(); + target.Endpoint = ""; + + var exception = Assert.Throws(() => LoggerFor(target)); + Assert.Equal("BetterStack.Logs: endpoint is empty. Set it to the ingesting host of your Better Stack source.", exception.Message); + } + + [Fact] + public void SendsToBareIngestingHostOverHttps() + { + // Stands in for the ingesting host: the start of a TLS handshake is all this test needs to see + var server = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + server.Start(); + try { + var target = Target(); + // Uri parses "localhost" of "localhost:1234" as the scheme + target.Endpoint = $"localhost:{((System.Net.IPEndPoint)server.LocalEndpoint).Port}"; + target.Retries = 1; + LoggerFor(target).Info("Hello"); + + var connection = server.AcceptTcpClientAsync(); + Assert.True(connection.Wait(TimeSpan.FromSeconds(30)), "No connection arrived within 30 seconds."); + using (var client = connection.Result) { + client.ReceiveTimeout = 30000; + // A TLS handshake record starts with 0x16, a plain http request with "POST" + Assert.Equal(0x16, client.GetStream().ReadByte()); + } + } finally { + server.Stop(); + } + } + + [Fact] + public void KeepsSchemeOfEndpointInAnyCase() + { + var target = Target(); + target.Endpoint = ingestion.Endpoint.Replace("http://", "HTTP://"); + LoggerFor(target).Info("Hello"); + + Assert.Equal("Hello", (string)Assert.Single(ingestion.NextRequest().Logs)["message"]); + } + [Fact] public void SendsExceptionOfTheLogEvent() { From 7b609fd08ddeb912069a7ff62cfdfb9c703f89d2 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Wed, 30 Sep 2026 16:14:41 +0200 Subject: [PATCH 2/6] Validate sourceToken and endpoint, accept a bare ingesting host [RequiredParameter] is obsolete in NLog 6 and no longer checked there. InitializeTarget now checks the rendered sourceToken and endpoint itself and throws NLogConfigurationException with a message that says what to set, the same on NLog 4.7, 5 and 6. An endpoint without an http:// or https:// scheme gets https:// in front, so the ingesting host works as the source settings show it. The example nlog.config and README spell out https:// all the same. Based on the configuration checks in #7. Co-authored-by: Rolf Kristensen <11509660+snakefoot@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- BetterStack.Logs.NLog/BetterStackLogsTarget.cs | 15 +++++++++++++-- example-project/README.md | 8 ++++---- example-project/nlog.config | 2 +- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/BetterStack.Logs.NLog/BetterStackLogsTarget.cs b/BetterStack.Logs.NLog/BetterStackLogsTarget.cs index 4071b2d..5c744b6 100644 --- a/BetterStack.Logs.NLog/BetterStackLogsTarget.cs +++ b/BetterStack.Logs.NLog/BetterStackLogsTarget.cs @@ -18,11 +18,11 @@ public sealed class BetterStackLogsTarget : TargetWithContext /// Gets or sets the Better Stack Logs source token. /// /// The source token. - [RequiredParameter] public Layout SourceToken { get; set; } /// - /// The Better Stack Logs endpoint. + /// The Better Stack Logs endpoint: the ingesting host of your source. https:// is added when it has no + /// http:// or https:// scheme. /// public Layout Endpoint { get; set; } = "https://in.logs.betterstack.com"; @@ -98,6 +98,17 @@ protected override void InitializeTarget() var sourceToken = RenderLogEvent(SourceToken, LogEventInfo.CreateNullEvent()); var endpoint = RenderLogEvent(Endpoint, LogEventInfo.CreateNullEvent()); + if (string.IsNullOrWhiteSpace(sourceToken)) { + throw new NLogConfigurationException("BetterStack.Logs: sourceToken is not set. Set it to the source token of your Better Stack source."); + } + if (string.IsNullOrWhiteSpace(endpoint)) { + throw new NLogConfigurationException("BetterStack.Logs: endpoint is empty. Set it to the ingesting host of your Better Stack source."); + } + // The source settings show the ingesting host without a scheme + if (!endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { + endpoint = "https://" + endpoint; + } + var client = new Client( sourceToken, endpoint: endpoint, diff --git a/example-project/README.md b/example-project/README.md index a0c9a2a..9d724c6 100644 --- a/example-project/README.md +++ b/example-project/README.md @@ -58,7 +58,7 @@ This file is used to configure NLog using XML syntax. The content of the file sh - + @@ -226,7 +226,7 @@ You can adjust this behavior by setting the `maxBatchSize`, `flushPeriodMillisec name="mybetterstack" layout="${message}" sourceToken="" - endpoint="" + endpoint="https://" maxBatchSize="200" flushPeriodMilliseconds="1000" retries="3" /> @@ -276,7 +276,7 @@ Properties pushed to NLog's `ScopeContext` are sent with every log written insid name="mybetterstack" layout="${message}" sourceToken="" - endpoint="" + endpoint="https://" includeScopeProperties="true" /> ``` @@ -292,7 +292,7 @@ Both `requestId` and `user` end up in `context.properties`. On NLog 4, which has To attach a fixed property to every log, add a `` to the target: ```xml - + ``` diff --git a/example-project/nlog.config b/example-project/nlog.config index d8c731d..60cf816 100644 --- a/example-project/nlog.config +++ b/example-project/nlog.config @@ -10,7 +10,7 @@ - + From dab27836623757e5215be67ecda10020110d519c Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Wed, 30 Sep 2026 16:17:03 +0200 Subject: [PATCH 3/6] Add test for endpoint without host configuration error --- .../BetterStackLogsTargetTests.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/BetterStack.Logs.NLog.Tests/BetterStackLogsTargetTests.cs b/tests/BetterStack.Logs.NLog.Tests/BetterStackLogsTargetTests.cs index 44bb5a2..60b695f 100644 --- a/tests/BetterStack.Logs.NLog.Tests/BetterStackLogsTargetTests.cs +++ b/tests/BetterStack.Logs.NLog.Tests/BetterStackLogsTargetTests.cs @@ -100,6 +100,17 @@ public void ReportsEmptyEndpointAsConfigurationError() Assert.Equal("BetterStack.Logs: endpoint is empty. Set it to the ingesting host of your Better Stack source.", exception.Message); } + [Fact] + public void ReportsEndpointWithoutHostAsConfigurationError() + { + logFactory.ThrowConfigExceptions = true; + var target = Target(); + target.Endpoint = "https://"; + + var exception = Assert.Throws(() => LoggerFor(target)); + Assert.Equal("BetterStack.Logs: endpoint is \"https://\". Set it to the ingesting host of your Better Stack source.", exception.Message); + } + [Fact] public void SendsToBareIngestingHostOverHttps() { From cfc26ebdb5791a60f812dff8243ef9b2c7bc562d Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Wed, 30 Sep 2026 16:18:41 +0200 Subject: [PATCH 4/6] Report an endpoint that is not a valid URL An endpoint like "https://" has a scheme but no host, so new Uri() threw a UriFormatException in the Client, which NLog 4.7 only writes to the internal log and NLog 5 and 6 wrap in a generic initialization error. It is now reported as NLogConfigurationException that quotes the value as configured. Co-Authored-By: Claude Opus 5.5 --- BetterStack.Logs.NLog/BetterStackLogsTarget.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/BetterStack.Logs.NLog/BetterStackLogsTarget.cs b/BetterStack.Logs.NLog/BetterStackLogsTarget.cs index 5c744b6..acef4c3 100644 --- a/BetterStack.Logs.NLog/BetterStackLogsTarget.cs +++ b/BetterStack.Logs.NLog/BetterStackLogsTarget.cs @@ -105,13 +105,17 @@ protected override void InitializeTarget() throw new NLogConfigurationException("BetterStack.Logs: endpoint is empty. Set it to the ingesting host of your Better Stack source."); } // The source settings show the ingesting host without a scheme + var endpointUrl = endpoint; if (!endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { - endpoint = "https://" + endpoint; + endpointUrl = "https://" + endpoint; + } + if (!Uri.TryCreate(endpointUrl, UriKind.Absolute, out _)) { + throw new NLogConfigurationException($"BetterStack.Logs: endpoint is \"{endpoint}\". Set it to the ingesting host of your Better Stack source."); } var client = new Client( sourceToken, - endpoint: endpoint, + endpoint: endpointUrl, retries: Retries ); From 27aba1775290358ce875d7b77c61cc2079d07f31 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Wed, 30 Sep 2026 17:09:13 +0200 Subject: [PATCH 5/6] Add failing tests: bad numeric settings are not reported maxBatchSize and flushPeriodMilliseconds of 0 and negative retries are accepted without a word. retries="0" stays valid. Co-Authored-By: Claude Opus 5.5 --- .../BetterStackLogsTargetTests.cs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/BetterStack.Logs.NLog.Tests/BetterStackLogsTargetTests.cs b/tests/BetterStack.Logs.NLog.Tests/BetterStackLogsTargetTests.cs index 60b695f..9415898 100644 --- a/tests/BetterStack.Logs.NLog.Tests/BetterStackLogsTargetTests.cs +++ b/tests/BetterStack.Logs.NLog.Tests/BetterStackLogsTargetTests.cs @@ -449,5 +449,48 @@ public void ConfiguresFromXml() Assert.Equal("Tracing the code!", (string)log["message"]); Assert.Equal("Trace", (string)log["level"]); } + + [Fact] + public void ReportsMaxBatchSizeBelowOneAsConfigurationError() + { + logFactory.ThrowConfigExceptions = true; + var target = Target(); + target.MaxBatchSize = 0; + + var exception = Assert.Throws(() => LoggerFor(target)); + Assert.Equal("BetterStack.Logs: maxBatchSize is 0. Set it to 1 or more.", exception.Message); + } + + [Fact] + public void ReportsFlushPeriodBelowOneMillisecondAsConfigurationError() + { + logFactory.ThrowConfigExceptions = true; + var target = Target(); + target.FlushPeriodMilliseconds = 0; + + var exception = Assert.Throws(() => LoggerFor(target)); + Assert.Equal("BetterStack.Logs: flushPeriodMilliseconds is 0. Set it to 1 or more.", exception.Message); + } + + [Fact] + public void ReportsNegativeRetriesAsConfigurationError() + { + logFactory.ThrowConfigExceptions = true; + var target = Target(); + target.Retries = -1; + + var exception = Assert.Throws(() => LoggerFor(target)); + Assert.Equal("BetterStack.Logs: retries is -1. Set it to 0 or more.", exception.Message); + } + + [Fact] + public void AcceptsZeroRetries() + { + logFactory.ThrowConfigExceptions = true; + var target = Target(); + target.Retries = 0; + + Assert.Null(Record.Exception(() => LoggerFor(target))); + } } } From 16cb08842ea10ad58aca4fab82161eda4b98d587 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Wed, 30 Sep 2026 17:09:46 +0200 Subject: [PATCH 6/6] Reject maxBatchSize and flushPeriodMilliseconds below 1 and negative retries InitializeTarget reports them as NLogConfigurationException, after the sourceToken and endpoint checks. maxBatchSize="0" pinned a core without sending anything and flushPeriodMilliseconds="0" pinned one while idle. retries="0" stays valid. Co-Authored-By: Claude Opus 5.5 --- BetterStack.Logs.NLog/BetterStackLogsTarget.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/BetterStack.Logs.NLog/BetterStackLogsTarget.cs b/BetterStack.Logs.NLog/BetterStackLogsTarget.cs index acef4c3..f099e16 100644 --- a/BetterStack.Logs.NLog/BetterStackLogsTarget.cs +++ b/BetterStack.Logs.NLog/BetterStackLogsTarget.cs @@ -112,6 +112,16 @@ protected override void InitializeTarget() if (!Uri.TryCreate(endpointUrl, UriKind.Absolute, out _)) { throw new NLogConfigurationException($"BetterStack.Logs: endpoint is \"{endpoint}\". Set it to the ingesting host of your Better Stack source."); } + // 0 would leave the drain spinning on a core: without taking any logs, or without waiting between flushes + if (MaxBatchSize < 1) { + throw new NLogConfigurationException($"BetterStack.Logs: maxBatchSize is {MaxBatchSize}. Set it to 1 or more."); + } + if (FlushPeriodMilliseconds < 1) { + throw new NLogConfigurationException($"BetterStack.Logs: flushPeriodMilliseconds is {FlushPeriodMilliseconds}. Set it to 1 or more."); + } + if (Retries < 0) { + throw new NLogConfigurationException($"BetterStack.Logs: retries is {Retries}. Set it to 0 or more."); + } var client = new Client( sourceToken,