diff --git a/README.md b/README.md index 3a06a11..a01db21 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ speak the same wire format and make the same promises. ```csharp using StephensonSoftware.Trace; -var trace = new TraceClient("https://trace.danielstephenson.dev", "my-game", +var trace = new TraceClient("https://trace.danielstephenson.dev", "my-game", "1.4.0", key: settings.UsageReportingKey, enabled: settings.UsageReportingEnabled, log: message => Debug.WriteLine(message)); // optional @@ -31,13 +31,30 @@ else Console.WriteLine("Usage reporting is off (" + trace.DisabledReason + ")."); } -trace.Report("startup", tags: new Dictionary { { "version", "1.4.0" } }); +trace.Report("startup"); trace.Report("level-complete", 42.0); // on shutdown -- also before a short-lived program exits, so the event is sent trace.Dispose(); // same as trace.Close() ``` +## Every event carries the program's version + +The third constructor argument is the program's own version, and it is +required: a null or blank one, or one over 255 characters after trimming, +throws `ArgumentException`. Every event the client sends — `startup`, +`level-complete`, anything else — carries it as the tag `version`, so every +event can be tied to a release, not just `startup`. An event that passes its +own `version` tag keeps it, and the dictionary passed to `Report` is never +modified. There is no need to tag `startup` by hand any more. The `version` tag +counts toward the server's 32-tag limit, so an event may carry 31 of its own. + +Before 0.2.0, the constructor took no version and only events tagged by hand +carried one. Upgrading is one argument after the application name — for +example `Application.version` in Unity, or the assembly's informational +version elsewhere. Pass `key`, `enabled` and `log` by name, as above, so a +key can never land in the version's place. + ## What `Report` promises | Property | Meaning | @@ -87,14 +104,15 @@ distribution. ## The wire format `POST {baseUrl}/api/metrics` with `Authorization: Bearer `, -`User-Agent: trace-client/0.1.0 ()` and a body of +`User-Agent: trace-client/0.2.0 ()` and a body of ```json -{"application":"my-game","name":"startup","tags":{"version":"1.4.0"}} +{"application":"my-game","name":"level-complete","value":42,"tags":{"version":"1.4.0"}} ``` -`value` and `tags` are omitted when not given; `value` is written with the -invariant culture, so a German locale still sends `2.5`. The server assigns +`value` is omitted when not given, and `tags` always holds at least +`version`. `value` is written with the invariant culture, so a German locale +still sends `2.5`. The server assigns the timestamp. A `201` is success; anything else is logged and dropped. ## Keys diff --git a/src/TraceClient/TraceClient.cs b/src/TraceClient/TraceClient.cs index 88844db..7bf075c 100644 --- a/src/TraceClient/TraceClient.cs +++ b/src/TraceClient/TraceClient.cs @@ -1,5 +1,5 @@ /* - * trace-client 0.1.0 -- https://github.com/Stephenson-Software/trace-client-csharp + * trace-client 0.2.0 -- https://github.com/Stephenson-Software/trace-client-csharp * * One call to report that a program was used. Copy this file into a project as * is, or reference the project; either way there is nothing else to add. @@ -41,11 +41,15 @@ namespace StephensonSoftware.Trace /// (TRACE_USAGE_REPORTING=off or DO_NOT_TRACK=1) -- reason /// environment; the program's own setting, enabled: false -- /// reason config; no key -- reason no key. + /// Every event carries the program's own version as the tag + /// version -- the third constructor argument, required, so a + /// command event can be tied to a release as well as a + /// startup one. An event's own version tag wins over it. /// - /// var trace = new TraceClient("https://trace.example.org", "my-game", + /// var trace = new TraceClient("https://trace.example.org", "my-game", ProgramVersion, /// key: settings.UsageReportingKey, /// enabled: settings.UsageReportingEnabled); - /// trace.Report("startup", tags: new Dictionary<string, string> { { "version", Version } }); + /// trace.Report("startup"); /// ... /// trace.Dispose(); // on shutdown: sends what is queued, bounded by the timeout /// @@ -53,7 +57,7 @@ namespace StephensonSoftware.Trace public sealed class TraceClient : IDisposable { /// The client version, as sent in the User-Agent header. - public const string Version = "0.1.0"; + public const string Version = "0.2.0"; /// How many reports may wait to be sent before new ones are dropped. public const int QueueCapacity = 256; @@ -89,6 +93,7 @@ public sealed class TraceClient : IDisposable private readonly Uri _endpoint; private readonly string _application; + private readonly string _version; private readonly string _key; private readonly Action _log; private readonly BlockingCollection _queue; // null when disabled @@ -98,18 +103,23 @@ public sealed class TraceClient : IDisposable private int _closed; /// - /// A client for the program named , reporting - /// to the trace server at . Throws only for a - /// missing or malformed or a missing - /// -- programming errors, not runtime ones. + /// A client for the program named , at + /// , reporting to the trace server at + /// . Throws only for a + /// missing or malformed , a missing + /// , or a missing or + /// one longer than characters -- programming errors, + /// not runtime ones. /// /// The trace server, e.g. https://trace.danielstephenson.dev. /// The program's name, exactly as its key was issued for. + /// The program's own version, trimmed. Sent as the tag version + /// on every event unless the event carries its own. /// The program's write key. Without one the client is a no-op. /// The program's own opt-out. false yields a client that reports nothing. /// Where dropped reports are mentioned. Optional; treat as debug-level. - public TraceClient(string baseUrl, string application, string key = null, bool enabled = true, - Action log = null) + public TraceClient(string baseUrl, string application, string version, string key = null, + bool enabled = true, Action log = null) { if (string.IsNullOrWhiteSpace(baseUrl)) { @@ -119,8 +129,17 @@ public TraceClient(string baseUrl, string application, string key = null, bool e { throw new ArgumentException("application is required", "application"); } + if (string.IsNullOrWhiteSpace(version)) + { + throw new ArgumentException("version is required", "version"); + } + if (version.Trim().Length > MaxLength) + { + throw new ArgumentException("version is longer than " + MaxLength + " characters", "version"); + } _endpoint = new Uri(baseUrl.Trim().TrimEnd('/') + "/api/metrics"); _application = application.Trim(); + _version = version.Trim(); _key = key == null ? "" : key.Trim(); _log = log; @@ -151,7 +170,7 @@ public TraceClient(string baseUrl, string application, string key = null, bool e /// A client that reports nothing. Useful as a default before settings are read. public static TraceClient Disabled() { - return new TraceClient("http://disabled.invalid", "disabled", enabled: false); + return new TraceClient("http://disabled.invalid", "disabled", "disabled", enabled: false); } /// @@ -209,7 +228,9 @@ private static bool IsYes(string value) /// /// Reports that happened, with an optional numeric /// value and optional string tags. Returns immediately and never throws. - /// A report the server would reject for its size -- more than + /// The program's version is added as the tag version unless + /// already has one; itself is + /// never modified. A report the server would reject for its size -- more than /// tags, or a string longer than -- /// is dropped here instead of being sent. /// @@ -222,7 +243,7 @@ public void Report(string name, double? value = null, IEnumerable + /// The event's own tags plus version, unless the event already + /// carries one. A copy; the caller's collection is never modified. + /// + internal static List> WithVersion( + IEnumerable> tags, string version) + { + var merged = new List>(); + bool hasVersion = false; + if (tags != null) + { + foreach (KeyValuePair tag in tags) + { + if (tag.Key == null || tag.Value == null) + { + continue; + } + if (tag.Key == "version") + { + hasVersion = true; + } + merged.Add(tag); + } + } + if (!hasVersion) + { + merged.Add(new KeyValuePair("version", version)); + } + return merged; + } + // JSON is written by hand so this file has no dependencies. The shape is // fixed and small -- three scalars and a flat string map. Returns null // and sets body when the report is sendable, otherwise the reason it is not. diff --git a/src/TraceClient/TraceClient.csproj b/src/TraceClient/TraceClient.csproj index 8997ec3..fedffa5 100644 --- a/src/TraceClient/TraceClient.csproj +++ b/src/TraceClient/TraceClient.csproj @@ -10,7 +10,7 @@ 7.3 StephensonSoftware.Trace StephensonSoftware.Trace - 0.1.0 + 0.2.0 true true diff --git a/tests/TraceClient.Tests/TraceClientTest.cs b/tests/TraceClient.Tests/TraceClientTest.cs index 594dc0f..582ac10 100644 --- a/tests/TraceClient.Tests/TraceClientTest.cs +++ b/tests/TraceClient.Tests/TraceClientTest.cs @@ -41,7 +41,7 @@ public void Dispose() public void Report_PostsTheEventToTheMetricsEndpointWithTheKey() { // Arrange - var client = new TraceClient(_server.BaseUrl + "/", "MyGame", key: "k-123"); + var client = new TraceClient(_server.BaseUrl + "/", "MyGame", "1.2.3", key: "k-123"); // Act client.Report("startup"); @@ -53,14 +53,14 @@ public void Report_PostsTheEventToTheMetricsEndpointWithTheKey() Assert.Equal("/api/metrics", request.Path); // a trailing slash on the base URL must not double up Assert.Equal("Bearer k-123", request.Authorization); Assert.StartsWith("application/json", request.ContentType); - Assert.Equal("{\"application\":\"MyGame\",\"name\":\"startup\"}", request.Body); + Assert.Equal("{\"application\":\"MyGame\",\"name\":\"startup\",\"tags\":{\"version\":\"1.2.3\"}}", request.Body); client.Close(); } [Fact] public void Report_CarriesValueAndTagsWhenGiven() { - var client = new TraceClient(_server.BaseUrl, "MyGame", key: "k"); + var client = new TraceClient(_server.BaseUrl, "MyGame", "1.2.3", key: "k"); var tags = new List> { new KeyValuePair("command", "home"), @@ -72,7 +72,7 @@ public void Report_CarriesValueAndTagsWhenGiven() Assert.True(_server.WaitFor(1, TimeSpan.FromSeconds(5))); Assert.Equal( "{\"application\":\"MyGame\",\"name\":\"command\",\"value\":2.5," - + "\"tags\":{\"command\":\"home\",\"world\":\"the \\\"end\\\"\"}}", + + "\"tags\":{\"command\":\"home\",\"world\":\"the \\\"end\\\"\",\"version\":\"1.2.3\"}}", _server.Received.Single().Body); client.Close(); } @@ -80,7 +80,7 @@ public void Report_CarriesValueAndTagsWhenGiven() [Fact] public void UserAgent_NamesTheClientVersion() { - var client = new TraceClient(_server.BaseUrl, "MyGame", key: "k"); + var client = new TraceClient(_server.BaseUrl, "MyGame", "1.2.3", key: "k"); client.Report("startup"); client.Close(); @@ -96,7 +96,7 @@ public void Report_ReturnsBeforeTheServerAnswers() var release = new ManualResetEventSlim(false); using (var slow = new StubServer(beforeReply: () => release.Wait(TimeSpan.FromSeconds(10)))) { - var client = new TraceClient(slow.BaseUrl, "MyGame", key: "k"); + var client = new TraceClient(slow.BaseUrl, "MyGame", "1.2.3", key: "k"); var watch = Stopwatch.StartNew(); client.Report("startup"); @@ -114,7 +114,7 @@ public void Report_DoesNotThrowWhenNothingIsListening() { int deadPort = StubServer.FreePort(); var log = new ConcurrentQueue(); - var client = new TraceClient("http://127.0.0.1:" + deadPort, "MyGame", key: "k", log: log.Enqueue); + var client = new TraceClient("http://127.0.0.1:" + deadPort, "MyGame", "1.2.3", key: "k", log: log.Enqueue); client.Report("startup"); // must not throw client.Close(); // waits for the in-flight attempt to fail @@ -128,7 +128,7 @@ public void Report_DoesNotThrowWhenTheServerRejectsTheKey() using (var rejecting = new StubServer(status: () => 401)) { var log = new ConcurrentQueue(); - var client = new TraceClient(rejecting.BaseUrl, "MyGame", key: "revoked", log: log.Enqueue); + var client = new TraceClient(rejecting.BaseUrl, "MyGame", "1.2.3", key: "revoked", log: log.Enqueue); client.Report("startup"); client.Close(); @@ -141,7 +141,7 @@ public void Report_DoesNotThrowWhenTheServerRejectsTheKey() public void Report_NeverThrowsEvenWhenTheLoggerDoes() { int deadPort = StubServer.FreePort(); - var client = new TraceClient("http://127.0.0.1:" + deadPort, "MyGame", key: "k", + var client = new TraceClient("http://127.0.0.1:" + deadPort, "MyGame", "1.2.3", key: "k", log: m => throw new InvalidOperationException("bad logger")); client.Report("startup"); @@ -154,9 +154,9 @@ public void DisabledClient_SendsNothing() { var clients = new[] { - new TraceClient(_server.BaseUrl, "MyGame", key: "k", enabled: false), - new TraceClient(_server.BaseUrl, "MyGame"), - new TraceClient(_server.BaseUrl, "MyGame", key: " "), + new TraceClient(_server.BaseUrl, "MyGame", "1.2.3", key: "k", enabled: false), + new TraceClient(_server.BaseUrl, "MyGame", "1.2.3"), + new TraceClient(_server.BaseUrl, "MyGame", "1.2.3", key: " "), TraceClient.Disabled(), }; @@ -174,7 +174,7 @@ public void DisabledClient_SendsNothing() [Fact] public void DisabledReason_IsNullWhenTheClientReports() { - var client = new TraceClient(_server.BaseUrl, "MyGame", key: "k"); + var client = new TraceClient(_server.BaseUrl, "MyGame", "1.2.3", key: "k"); Assert.True(client.IsEnabled); Assert.Null(client.DisabledReason); @@ -184,9 +184,9 @@ public void DisabledReason_IsNullWhenTheClientReports() [Fact] public void DisabledReason_NamesTheConfigFlagOrTheMissingKey() { - Assert.Equal("config", new TraceClient(_server.BaseUrl, "MyGame", key: "k", enabled: false).DisabledReason); - Assert.Equal("config", new TraceClient(_server.BaseUrl, "MyGame", enabled: false).DisabledReason); - Assert.Equal("no key", new TraceClient(_server.BaseUrl, "MyGame").DisabledReason); + Assert.Equal("config", new TraceClient(_server.BaseUrl, "MyGame", "1.2.3", key: "k", enabled: false).DisabledReason); + Assert.Equal("config", new TraceClient(_server.BaseUrl, "MyGame", "1.2.3", enabled: false).DisabledReason); + Assert.Equal("no key", new TraceClient(_server.BaseUrl, "MyGame", "1.2.3").DisabledReason); Assert.Equal("config", TraceClient.Disabled().DisabledReason); } @@ -201,7 +201,7 @@ public void TraceUsageReporting_DisablesReportingForEveryAcceptedValue(string va { _environment[TraceClient.EnvUsageReporting] = value; - var client = new TraceClient(_server.BaseUrl, "MyGame", key: "k"); + var client = new TraceClient(_server.BaseUrl, "MyGame", "1.2.3", key: "k"); client.Report("startup"); client.Close(); @@ -220,7 +220,7 @@ public void DoNotTrack_DisablesReportingForEveryAcceptedValue(string value) { _environment[TraceClient.EnvDoNotTrack] = value; - var client = new TraceClient(_server.BaseUrl, "MyGame", key: "k"); + var client = new TraceClient(_server.BaseUrl, "MyGame", "1.2.3", key: "k"); Assert.False(client.IsEnabled); Assert.Equal("environment", client.DisabledReason); @@ -237,7 +237,7 @@ public void OtherEnvironmentValues_LeaveTheProgramSettingInCharge(string variabl { _environment[variable] = value; - var client = new TraceClient(_server.BaseUrl, "MyGame", key: "k"); + var client = new TraceClient(_server.BaseUrl, "MyGame", "1.2.3", key: "k"); Assert.True(client.IsEnabled); client.Close(); @@ -248,15 +248,15 @@ public void Environment_WinsOverTheConfigFlagAndOverTheKey() { _environment[TraceClient.EnvDoNotTrack] = "1"; - Assert.Equal("environment", new TraceClient(_server.BaseUrl, "MyGame", enabled: false).DisabledReason); - Assert.Equal("environment", new TraceClient(_server.BaseUrl, "MyGame").DisabledReason); + Assert.Equal("environment", new TraceClient(_server.BaseUrl, "MyGame", "1.2.3", enabled: false).DisabledReason); + Assert.Equal("environment", new TraceClient(_server.BaseUrl, "MyGame", "1.2.3").DisabledReason); Assert.True(TraceClient.EnvironmentDisables()); } [Fact] public void Report_IgnoresABlankName() { - var client = new TraceClient(_server.BaseUrl, "MyGame", key: "k"); + var client = new TraceClient(_server.BaseUrl, "MyGame", "1.2.3", key: "k"); client.Report(null); client.Report(" "); @@ -268,10 +268,78 @@ public void Report_IgnoresABlankName() [Fact] public void Constructor_RejectsAMissingBaseUrlOrApplication() { - Assert.Throws(() => new TraceClient(null, "MyGame")); - Assert.Throws(() => new TraceClient(" ", "MyGame")); - Assert.Throws(() => new TraceClient("http://x", null)); - Assert.Throws(() => new TraceClient("http://x", "")); + Assert.Throws(() => new TraceClient(null, "MyGame", "1.2.3")); + Assert.Throws(() => new TraceClient(" ", "MyGame", "1.2.3")); + Assert.Throws(() => new TraceClient("http://x", null, "1.2.3")); + Assert.Throws(() => new TraceClient("http://x", "", "1.2.3")); + } + + [Fact] + public void Constructor_RejectsAMissingOrOverlongVersion() + { + Assert.Throws(() => new TraceClient("http://x", "MyGame", null)); + Assert.Throws(() => new TraceClient("http://x", "MyGame", "")); + Assert.Throws(() => new TraceClient("http://x", "MyGame", " ")); + Assert.Throws( + () => new TraceClient("http://x", "MyGame", new string('9', TraceClient.MaxLength + 1))); + // Trimmed before measuring: surrounding whitespace does not count. + new TraceClient("http://x", "MyGame", " " + new string('9', TraceClient.MaxLength) + " ").Close(); + } + + [Fact] + public void Report_TagsACommandWithTheProgramVersionTrimmed() + { + var client = new TraceClient(_server.BaseUrl, "MyGame", " 2.0.0-SNAPSHOT ", key: "k"); + + client.Report("command", tags: new Dictionary { { "name", "home" } }); + + Assert.True(_server.WaitFor(1, TimeSpan.FromSeconds(5))); + Assert.Equal("{\"application\":\"MyGame\",\"name\":\"command\"," + + "\"tags\":{\"name\":\"home\",\"version\":\"2.0.0-SNAPSHOT\"}}", + _server.Received.Single().Body); + client.Close(); + } + + [Fact] + public void Report_AnEventsOwnVersionTagWinsOverTheProgramVersion() + { + var client = new TraceClient(_server.BaseUrl, "MyGame", "1.2.3", key: "k"); + var tags = new Dictionary { { "version", "9.9.9" } }; + + client.Report("startup", tags: tags); + + Assert.True(_server.WaitFor(1, TimeSpan.FromSeconds(5))); + Assert.Equal("{\"application\":\"MyGame\",\"name\":\"startup\",\"tags\":{\"version\":\"9.9.9\"}}", + _server.Received.Single().Body); + Assert.Equal(new Dictionary { { "version", "9.9.9" } }, tags); // the caller's dictionary is not modified + client.Close(); + } + + [Fact] + public void Report_NeverModifiesTheCallersDictionary() + { + var client = new TraceClient(_server.BaseUrl, "MyGame", "1.2.3", key: "k"); + var tags = new Dictionary { { "name", "home" } }; + + client.Report("command", tags: tags); + client.Close(); + + Assert.Equal(new Dictionary { { "name", "home" } }, tags); + Assert.Equal("{\"application\":\"MyGame\",\"name\":\"command\",\"tags\":{\"name\":\"home\",\"version\":\"1.2.3\"}}", + _server.Received.Single().Body); + } + + [Fact] + public void WithVersion_AddsTheVersionToACopyOnly() + { + var tags = new Dictionary { { "name", "home" } }; + + List> merged = TraceClient.WithVersion(tags, "1.2.3"); + + Assert.Equal(new Dictionary { { "name", "home" } }, tags); + Assert.Contains(new KeyValuePair("version", "1.2.3"), merged); + Assert.Equal(new[] { new KeyValuePair("version", "1.2.3") }, + TraceClient.WithVersion(null, "1.2.3")); } [Fact] @@ -390,7 +458,7 @@ public void EnvironmentDisables_IsFalseWhenTheEnvironmentCannotBeRead() TraceClient.EnvironmentSource = name => throw new System.Security.SecurityException("sandboxed"); Assert.False(TraceClient.EnvironmentDisables()); - var client = new TraceClient(_server.BaseUrl, "MyGame", key: "k"); + var client = new TraceClient(_server.BaseUrl, "MyGame", "1.2.3", key: "k"); Assert.True(client.IsEnabled); Assert.Null(client.DisabledReason); client.Close(); @@ -400,14 +468,16 @@ public void EnvironmentDisables_IsFalseWhenTheEnvironmentCannotBeRead() public void Report_DropsWhatTheServerWouldRejectForItsSize() { var log = new ConcurrentQueue(); - var client = new TraceClient(_server.BaseUrl, "MyGame", key: "k", log: log.Enqueue); + var client = new TraceClient(_server.BaseUrl, "MyGame", "1.2.3", key: "k", log: log.Enqueue); var tooMany = Enumerable.Range(0, TraceClient.MaxTags + 1) .Select(i => new KeyValuePair("t" + i, "v")).ToList(); - var exactlyTheLimit = tooMany.Take(TraceClient.MaxTags).ToList(); + // The program version is one of the tags, so the event itself may carry one fewer. + var exactlyTheLimit = tooMany.Take(TraceClient.MaxTags - 1).ToList(); string longest = new string('v', TraceClient.MaxLength); string tooLong = new string('v', TraceClient.MaxLength + 1); client.Report("too-many-tags", tags: tooMany); + client.Report("too-many-with-version", tags: tooMany.Take(TraceClient.MaxTags).ToList()); client.Report("tag-value-too-long", tags: new Dictionary { { "k", tooLong } }); client.Report(tooLong); client.Report("at-the-limit", tags: exactlyTheLimit); @@ -416,7 +486,7 @@ public void Report_DropsWhatTheServerWouldRejectForItsSize() Assert.Equal(new[] { "at-the-limit", "longest" }, _server.Received.Select(r => r.Body.Split('"')[7]).OrderBy(n => n).ToArray()); - Assert.Equal(3, log.Count(m => m.Contains("dropped"))); + Assert.Equal(4, log.Count(m => m.Contains("dropped"))); } [Fact] @@ -427,7 +497,7 @@ public void Queue_IsBoundedAndDropsRatherThanGrows() var release = new ManualResetEventSlim(false); using (var slow = new StubServer(beforeReply: () => release.Wait(TimeSpan.FromSeconds(10)))) { - var client = new TraceClient(slow.BaseUrl, "MyGame", key: "k"); + var client = new TraceClient(slow.BaseUrl, "MyGame", "1.2.3", key: "k"); int flood = TraceClient.QueueCapacity * 3; for (int i = 0; i < flood; i++) @@ -464,7 +534,7 @@ public void Close_SendsWhatWasJustQueuedBeforeStopping() // back-to-back Report()+Close() pairs make that fraction visible. for (int i = 0; i < 30; i++) { - var client = new TraceClient(_server.BaseUrl, "MyCli", key: "k"); + var client = new TraceClient(_server.BaseUrl, "MyCli", "1.2.3", key: "k"); client.Report("startup", tags: new Dictionary { { "run", i.ToString() } }); client.Close(); } @@ -475,7 +545,7 @@ public void Close_SendsWhatWasJustQueuedBeforeStopping() [Fact] public void Dispose_DrainsLikeClose() { - using (var client = new TraceClient(_server.BaseUrl, "MyCli", key: "k")) + using (var client = new TraceClient(_server.BaseUrl, "MyCli", "1.2.3", key: "k")) { client.Report("startup"); } @@ -489,7 +559,7 @@ public void Close_StillReturnsWithinTheTimeoutWhenTheServerHangs() var release = new ManualResetEventSlim(false); using (var slow = new StubServer(beforeReply: () => release.Wait(TimeSpan.FromSeconds(15)))) { - var client = new TraceClient(slow.BaseUrl, "MyCli", key: "k"); + var client = new TraceClient(slow.BaseUrl, "MyCli", "1.2.3", key: "k"); client.Report("startup"); client.Report("second"); @@ -506,7 +576,7 @@ public void Close_StillReturnsWithinTheTimeoutWhenTheServerHangs() [Fact] public void Close_IsIdempotentAndReportAfterCloseIsANoOp() { - var client = new TraceClient(_server.BaseUrl, "MyCli", key: "k"); + var client = new TraceClient(_server.BaseUrl, "MyCli", "1.2.3", key: "k"); client.Close(); client.Close();