From 68d8ee4dfad3fd876c3bbd9cb0e89bb19df0b76b Mon Sep 17 00:00:00 2001 From: Roshan George Date: Wed, 5 Mar 2025 19:22:47 -0800 Subject: [PATCH 1/9] Fix not-async warning --- src/Clerk/BackendAPI/Hooks/ClerkBeforeRequestHook.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Clerk/BackendAPI/Hooks/ClerkBeforeRequestHook.cs b/src/Clerk/BackendAPI/Hooks/ClerkBeforeRequestHook.cs index 265c6b2..f06feb6 100644 --- a/src/Clerk/BackendAPI/Hooks/ClerkBeforeRequestHook.cs +++ b/src/Clerk/BackendAPI/Hooks/ClerkBeforeRequestHook.cs @@ -5,10 +5,10 @@ namespace Clerk.BackendAPI.Hooks public class ClerkBeforeRequestHook : IBeforeRequestHook { - public async Task BeforeRequestAsync(BeforeRequestContext hookCtx, HttpRequestMessage request) + public Task BeforeRequestAsync(BeforeRequestContext hookCtx, HttpRequestMessage request) { request.Headers.Add("Clerk-API-Version", "2024-10-01"); - return request; + return Task.FromResult(request); } } } \ No newline at end of file From c84ff05330358de87d38b39d5b25ed314e7da1e4 Mon Sep 17 00:00:00 2001 From: Roshan George Date: Thu, 6 Mar 2025 12:49:35 -0800 Subject: [PATCH 2/9] Initial commit --- .../BackendAPI/Hooks/HookRegistration.cs | 19 ++- .../Hooks/Telemetry/PreparedEvent.cs | 42 +++++ .../BackendAPI/Hooks/Telemetry/SdkInfo.cs | 50 ++++++ .../Telemetry/TelemetryAfterErrorHook.cs | 47 ++++++ .../Telemetry/TelemetryAfterSuccessHook.cs | 39 +++++ .../Telemetry/TelemetryBeforeRequestHook.cs | 35 ++++ .../Hooks/Telemetry/TelemetryCollector.cs | 159 ++++++++++++++++++ .../Hooks/Telemetry/TelemetryEvent.cs | 99 +++++++++++ .../Hooks/Telemetry/TelemetrySampler.cs | 69 ++++++++ 9 files changed, 555 insertions(+), 4 deletions(-) create mode 100644 src/Clerk/BackendAPI/Hooks/Telemetry/PreparedEvent.cs create mode 100644 src/Clerk/BackendAPI/Hooks/Telemetry/SdkInfo.cs create mode 100644 src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryAfterErrorHook.cs create mode 100644 src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryAfterSuccessHook.cs create mode 100644 src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryBeforeRequestHook.cs create mode 100644 src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs create mode 100644 src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryEvent.cs create mode 100644 src/Clerk/BackendAPI/Hooks/Telemetry/TelemetrySampler.cs diff --git a/src/Clerk/BackendAPI/Hooks/HookRegistration.cs b/src/Clerk/BackendAPI/Hooks/HookRegistration.cs index b47dbce..a32c385 100644 --- a/src/Clerk/BackendAPI/Hooks/HookRegistration.cs +++ b/src/Clerk/BackendAPI/Hooks/HookRegistration.cs @@ -1,6 +1,10 @@ namespace Clerk.BackendAPI.Hooks { + using System; + using System.Collections.Generic; + using Clerk.BackendAPI.Hooks.Telemetry; + /// /// Hook Registration File. /// @@ -28,10 +32,17 @@ public static void InitHooks(IHooks hooks) var clerkBeforeRequestHook = new ClerkBeforeRequestHook(); hooks.RegisterBeforeRequestHook(clerkBeforeRequestHook); - // hooks.RegisterSDKInitHook(myHook); - // hooks.RegisterBeforeRequestHook(myHook); - // hooks.RegisterAfterSuccessHook(myHook); - // hooks.RegisterAfterErrorHook(myHook; + // Register telemetry hooks + var telemetryCollectors = new List { new DebugTelemetryCollector(), LiveTelemetryCollector.Standard() }; + + var telemetryBeforeRequestHook = new TelemetryBeforeRequestHook(telemetryCollectors); + hooks.RegisterBeforeRequestHook(telemetryBeforeRequestHook); + + var telemetryAfterSuccessHook = new TelemetryAfterSuccessHook(telemetryCollectors); + hooks.RegisterAfterSuccessHook(telemetryAfterSuccessHook); + + var telemetryAfterErrorHook = new TelemetryAfterErrorHook(telemetryCollectors); + hooks.RegisterAfterErrorHook(telemetryAfterErrorHook); } } } \ No newline at end of file diff --git a/src/Clerk/BackendAPI/Hooks/Telemetry/PreparedEvent.cs b/src/Clerk/BackendAPI/Hooks/Telemetry/PreparedEvent.cs new file mode 100644 index 0000000..0100d78 --- /dev/null +++ b/src/Clerk/BackendAPI/Hooks/Telemetry/PreparedEvent.cs @@ -0,0 +1,42 @@ +namespace Clerk.BackendAPI.Hooks.Telemetry +{ + using System.Collections.Generic; + + public class PreparedEvent + { + public string Event { get; } + public string It { get; } + public string Sdk { get; } + public string Sdkv { get; } + public string Sk { get; } + public Dictionary Payload { get; } + + public PreparedEvent(string @event, string it, string sdk, string sdkv, string sk, Dictionary payload) + { + Event = @event; + It = it; + Sdk = sdk; + Sdkv = sdkv; + Sk = sk; + Payload = payload; + } + + public SortedDictionary Sanitize() + { + var sanitizedEvent = new SortedDictionary + { + ["event"] = Event, + ["it"] = It, + ["sdk"] = Sdk, + ["sdkv"] = Sdkv + }; + + foreach (var item in Payload) + { + sanitizedEvent[item.Key] = item.Value; + } + + return sanitizedEvent; + } + } +} \ No newline at end of file diff --git a/src/Clerk/BackendAPI/Hooks/Telemetry/SdkInfo.cs b/src/Clerk/BackendAPI/Hooks/Telemetry/SdkInfo.cs new file mode 100644 index 0000000..68664e5 --- /dev/null +++ b/src/Clerk/BackendAPI/Hooks/Telemetry/SdkInfo.cs @@ -0,0 +1,50 @@ +namespace Clerk.BackendAPI.Hooks.Telemetry +{ + using System; + using System.IO; + using System.Reflection; + using System.Xml; + + public class SdkInfo + { + public string Version { get; } + public string Name { get; } + public string GroupId { get; } + + public SdkInfo(string version, string name, string groupId) + { + Version = version; + Name = name; + GroupId = groupId; + } + + public override string ToString() + { + return $"{{\"version\":\"{Version}\",\"name\":\"{Name}\",\"groupId\":\"{GroupId}\"}}"; + } + + public static SdkInfo? LoadFromAssembly() + { + try + { + var assembly = Assembly.GetExecutingAssembly(); + var assemblyName = assembly.GetName(); + + // Get the version from the assembly + var version = assemblyName.Version?.ToString() ?? "unknown"; + + // Get the name (without namespace) + var name = assemblyName.Name ?? "unknown"; + + // For .NET we'll use the first part of the namespace as groupId + var groupId = name.Contains('.') ? name.Substring(0, name.IndexOf('.')) : "Clerk"; + + return new SdkInfo(version, name, groupId); + } + catch + { + return null; + } + } + } +} \ No newline at end of file diff --git a/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryAfterErrorHook.cs b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryAfterErrorHook.cs new file mode 100644 index 0000000..c6a8530 --- /dev/null +++ b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryAfterErrorHook.cs @@ -0,0 +1,47 @@ +namespace Clerk.BackendAPI.Hooks.Telemetry +{ + using System; + using System.Collections.Generic; + using System.Net.Http; + using System.Threading.Tasks; + + public class TelemetryAfterErrorHook : IAfterErrorHook + { + // Visible for testing + public readonly List Collectors; + + public TelemetryAfterErrorHook(List collectors) + { + Collectors = collectors; + } + + public Task<(HttpResponseMessage?, Exception?)> AfterErrorAsync(AfterErrorContext context, HttpResponseMessage? response, Exception? error) + { + var additionalPayload = new Dictionary(); + + if (response != null) + { + additionalPayload["status_code"] = ((int)response.StatusCode).ToString(); + } + + if (error != null) + { + additionalPayload["error_message"] = error.Message; + } + + TelemetryEvent @event = TelemetryEvent.FromContext( + context, + TelemetryEvent.EVENT_METHOD_FAILED, + 0.1f, + additionalPayload + ); + + foreach (var collector in Collectors) + { + collector.Collect(@event); + } + + return Task.FromResult((response, error)); + } + } +} \ No newline at end of file diff --git a/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryAfterSuccessHook.cs b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryAfterSuccessHook.cs new file mode 100644 index 0000000..c96f4a1 --- /dev/null +++ b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryAfterSuccessHook.cs @@ -0,0 +1,39 @@ +namespace Clerk.BackendAPI.Hooks.Telemetry +{ + using System.Collections.Generic; + using System.Net.Http; + using System.Threading.Tasks; + + public class TelemetryAfterSuccessHook : IAfterSuccessHook + { + // Visible for testing + public readonly List Collectors; + + public TelemetryAfterSuccessHook(List collectors) + { + Collectors = collectors; + } + + public Task AfterSuccessAsync(AfterSuccessContext context, HttpResponseMessage response) + { + var additionalPayload = new Dictionary + { + ["status_code"] = ((int)response.StatusCode).ToString() + }; + + TelemetryEvent @event = TelemetryEvent.FromContext( + context, + TelemetryEvent.EVENT_METHOD_SUCCEEDED, + 0.1f, + additionalPayload + ); + + foreach (var collector in Collectors) + { + collector.Collect(@event); + } + + return Task.FromResult(response); + } + } +} \ No newline at end of file diff --git a/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryBeforeRequestHook.cs b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryBeforeRequestHook.cs new file mode 100644 index 0000000..bc608bf --- /dev/null +++ b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryBeforeRequestHook.cs @@ -0,0 +1,35 @@ +namespace Clerk.BackendAPI.Hooks.Telemetry +{ + using System; + using System.Collections.Generic; + using System.Net.Http; + using System.Threading.Tasks; + + public class TelemetryBeforeRequestHook : IBeforeRequestHook + { + // Visible for testing + public readonly List Collectors; + + public TelemetryBeforeRequestHook(List collectors) + { + Collectors = collectors; + } + + public Task BeforeRequestAsync(BeforeRequestContext context, HttpRequestMessage request) + { + TelemetryEvent @event = TelemetryEvent.FromContext( + context, + TelemetryEvent.EVENT_METHOD_CALLED, + 0.1f, + new Dictionary() + ); + + foreach (var collector in Collectors) + { + collector.Collect(@event); + } + + return Task.FromResult(request); + } + } +} \ No newline at end of file diff --git a/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs new file mode 100644 index 0000000..c4c3515 --- /dev/null +++ b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs @@ -0,0 +1,159 @@ +namespace Clerk.BackendAPI.Hooks.Telemetry +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Net.Http; + using System.Text; + using System.Text.Json; + using System.Threading.Tasks; + + public interface ITelemetryCollector + { + void Collect(TelemetryEvent @event); + } + + public abstract class BaseTelemetryCollector : ITelemetryCollector + { + protected readonly string Sdkv; + protected readonly string Sdk; + + protected BaseTelemetryCollector() + { + var sdkInfo = SdkInfo.LoadFromAssembly(); + Sdkv = sdkInfo?.Version ?? "unknown"; + Sdk = sdkInfo != null ? $"{sdkInfo.GroupId}:{sdkInfo.Name}" : "csharp:unknown"; + } + + public void Collect(TelemetryEvent @event) + { + if (@event.It == "development") + { + CollectInternal(@event); + } + } + + protected string SerializeToJson(PreparedEvent preparedEvent) + { + return JsonSerializer.Serialize(preparedEvent); + } + + protected PreparedEvent PrepareEvent(TelemetryEvent @event) + { + return new PreparedEvent( + @event.Event, + @event.It, + Sdk, + Sdkv, + @event.Sk, + new Dictionary(@event.Payload) + ); + } + + protected abstract void CollectInternal(TelemetryEvent @event); + } + + public class DebugTelemetryCollector : BaseTelemetryCollector + { + protected override void CollectInternal(TelemetryEvent @event) + { + try + { + Console.Error.WriteLine(SerializeToJson(PrepareEvent(@event))); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to serialize event: {ex.Message}"); + } + } + } + + public class LiveTelemetryCollector : BaseTelemetryCollector + { + private const string Endpoint = "http://localhost:3000/"; + private readonly List _samplers; + private readonly HttpClient _httpClient; + private static readonly object _consoleLock = new object(); + + public LiveTelemetryCollector(List samplers) + { + _samplers = samplers; + _httpClient = new HttpClient(); + } + + protected override void CollectInternal(TelemetryEvent @event) + { + PreparedEvent preparedEvent = PrepareEvent(@event); + foreach (var sampler in _samplers) + { + if (!sampler.Test(preparedEvent, @event)) + { + return; + } + } + + Task.Run(() => SendEventAsync(@event)); + } + + private async Task SendEventAsync(TelemetryEvent @event) + { + try + { + PreparedEvent preparedEvent = PrepareEvent(@event); + string eventJson = SerializeToJson(preparedEvent); + + var content = new StringContent(eventJson, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync(Endpoint, content); + + string responseContent = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + LogWarning($"Failed to send telemetry event. Response code: {(int)response.StatusCode}, error: {responseContent}"); + } + else + { + LogDebug($"Telemetry event sent successfully. Response: {responseContent}"); + } + } + catch (Exception ex) + { + LogWarning($"Error sending telemetry event: {ex.Message}"); + } + } + + private void LogWarning(string message) + { + lock (_consoleLock) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Error.WriteLine(message); + Console.ResetColor(); + } + } + + private void LogDebug(string message) + { + // Only log in debug mode or controlled by environment variable + if (Environment.GetEnvironmentVariable("CLERK_DEBUG") == "1") + { + lock (_consoleLock) + { + Console.ForegroundColor = ConsoleColor.Gray; + Console.Error.WriteLine(message); + Console.ResetColor(); + } + } + } + + public static LiveTelemetryCollector Standard() + { + return new LiveTelemetryCollector(new List + { + // RandomSampler.Standard(), + DeduplicatingSampler.Standard() + }); + } + } +} \ No newline at end of file diff --git a/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryEvent.cs b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryEvent.cs new file mode 100644 index 0000000..0581f40 --- /dev/null +++ b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryEvent.cs @@ -0,0 +1,99 @@ +namespace Clerk.BackendAPI.Hooks.Telemetry +{ + using System; + using System.Collections.Generic; + using System.Reflection; + using System.Text.Json; + using Clerk.BackendAPI.Models.Components; + + public class TelemetryEvent + { + public const string EVENT_METHOD_CALLED = "METHOD_CALLED"; + public const string EVENT_METHOD_SUCCEEDED = "METHOD_SUCCEEDED"; + public const string EVENT_METHOD_FAILED = "METHOD_FAILED"; + + public string Sk { get; } + public string It { get; } + public string Event { get; } + public Dictionary Payload { get; } + public float SamplingRate { get; } + + public TelemetryEvent( + string sk, + string @event, + Dictionary payload, + float samplingRate) + { + Sk = sk; + It = sk != null && sk.StartsWith("sk_test") ? "development" : "production"; + Event = @event; + Payload = payload; + SamplingRate = samplingRate; + } + + public static TelemetryEvent FromContext( + HookContext ctx, + string @event, + float samplingRate, + Dictionary additionalPayload) + { + string sk = "unknown"; + + // Extract bearer token from security source if available + if (ctx.SecuritySource != null) + { + Security securityObj = (Security)ctx.SecuritySource(); + sk = securityObj?.BearerAuth ?? "unknown"; + } + + var payload = new Dictionary + { + ["method"] = ctx.OperationID + }; + + foreach (var item in additionalPayload) + { + payload[item.Key] = item.Value; + } + + return new TelemetryEvent( + sk, + @event, + payload, + samplingRate + ); + } + + private static string ExtractBearerToken(object securityObj) + { + try + { + // Since we know the implementation, we can check if the object has headerParams dictionary + var type = securityObj.GetType(); + var headerParamsField = type.GetField("headerParams", BindingFlags.Instance | BindingFlags.NonPublic); + + if (headerParamsField != null) + { + var headerParams = headerParamsField.GetValue(securityObj) as Dictionary; + + if (headerParams != null && headerParams.TryGetValue("Authorization", out string authHeader)) + { + // If it starts with "Bearer ", extract the token + if (authHeader != null && authHeader.StartsWith("Bearer ")) + { + return authHeader.Substring(7); + } + + return authHeader ?? "unknown"; + } + } + } + catch + { + // Ignore errors in extraction + } + + return "unknown"; + } + } +} \ No newline at end of file diff --git a/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetrySampler.cs b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetrySampler.cs new file mode 100644 index 0000000..a2b307c --- /dev/null +++ b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetrySampler.cs @@ -0,0 +1,69 @@ +namespace Clerk.BackendAPI.Hooks.Telemetry +{ + using System; + using System.Collections.Generic; + using System.Text.Json; + + public interface ITelemetrySampler + { + bool Test(PreparedEvent preparedEvent, TelemetryEvent telemetryEvent); + } + + public class RandomSampler : ITelemetrySampler + { + private readonly Random _random; + + public RandomSampler(Random random) + { + _random = random; + } + + public bool Test(PreparedEvent preparedEvent, TelemetryEvent telemetryEvent) + { + return _random.NextDouble() < telemetryEvent.SamplingRate; + } + + public static RandomSampler Standard() + { + return new RandomSampler(new Random(1)); + } + } + + public class DeduplicatingSampler : ITelemetrySampler + { + private readonly Dictionary _cache = new Dictionary(); + private readonly TimeSpan _window; + private readonly Func _nowProvider; + + public DeduplicatingSampler(TimeSpan window, Func nowProvider) + { + _window = window; + _nowProvider = nowProvider; + } + + public bool Test(PreparedEvent preparedEvent, TelemetryEvent telemetryEvent) + { + try + { + string key = JsonSerializer.Serialize(preparedEvent.Sanitize()); + DateTime now = _nowProvider(); + + if (!_cache.TryGetValue(key, out DateTime lastSampled) || now - lastSampled > _window) + { + _cache[key] = now; + return true; + } + } + catch + { + // Ignore serialization errors + } + return false; + } + + public static DeduplicatingSampler Standard() + { + return new DeduplicatingSampler(TimeSpan.FromDays(1), () => DateTime.UtcNow); + } + } +} \ No newline at end of file From e4760961009e7fbcdff8d795199e42cfda1546ae Mon Sep 17 00:00:00 2001 From: Roshan George Date: Thu, 6 Mar 2025 12:51:55 -0800 Subject: [PATCH 3/9] Type-check before casting --- .../Hooks/Telemetry/TelemetryEvent.cs | 35 ++----------------- 1 file changed, 2 insertions(+), 33 deletions(-) diff --git a/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryEvent.cs b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryEvent.cs index 0581f40..f123aee 100644 --- a/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryEvent.cs +++ b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryEvent.cs @@ -42,7 +42,8 @@ public static TelemetryEvent FromContext( // Extract bearer token from security source if available if (ctx.SecuritySource != null) { - Security securityObj = (Security)ctx.SecuritySource(); + var securitySource = ctx.SecuritySource(); + Security? securityObj = securitySource is Security ? (Security)securitySource : null; sk = securityObj?.BearerAuth ?? "unknown"; } @@ -63,37 +64,5 @@ public static TelemetryEvent FromContext( samplingRate ); } - - private static string ExtractBearerToken(object securityObj) - { - try - { - // Since we know the implementation, we can check if the object has headerParams dictionary - var type = securityObj.GetType(); - var headerParamsField = type.GetField("headerParams", BindingFlags.Instance | BindingFlags.NonPublic); - - if (headerParamsField != null) - { - var headerParams = headerParamsField.GetValue(securityObj) as Dictionary; - - if (headerParams != null && headerParams.TryGetValue("Authorization", out string authHeader)) - { - // If it starts with "Bearer ", extract the token - if (authHeader != null && authHeader.StartsWith("Bearer ")) - { - return authHeader.Substring(7); - } - - return authHeader ?? "unknown"; - } - } - } - catch - { - // Ignore errors in extraction - } - - return "unknown"; - } } } \ No newline at end of file From 238bd9a4c402774f749264c0caea8c836637cbd0 Mon Sep 17 00:00:00 2001 From: Roshan George Date: Thu, 6 Mar 2025 15:46:59 -0800 Subject: [PATCH 4/9] Respect CLERK_TELEMETRY_DEBUG=1 --- src/Clerk/BackendAPI/Hooks/HookRegistration.cs | 5 ++++- src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Clerk/BackendAPI/Hooks/HookRegistration.cs b/src/Clerk/BackendAPI/Hooks/HookRegistration.cs index a32c385..f23f471 100644 --- a/src/Clerk/BackendAPI/Hooks/HookRegistration.cs +++ b/src/Clerk/BackendAPI/Hooks/HookRegistration.cs @@ -33,7 +33,10 @@ public static void InitHooks(IHooks hooks) hooks.RegisterBeforeRequestHook(clerkBeforeRequestHook); // Register telemetry hooks - var telemetryCollectors = new List { new DebugTelemetryCollector(), LiveTelemetryCollector.Standard() }; + var telemetryCollectors = new List { LiveTelemetryCollector.Standard() }; + if (Environment.GetEnvironmentVariable("CLERK_TELEMETRY_DEBUG") == "1") { + telemetryCollectors.Add(new DebugTelemetryCollector()); + } var telemetryBeforeRequestHook = new TelemetryBeforeRequestHook(telemetryCollectors); hooks.RegisterBeforeRequestHook(telemetryBeforeRequestHook); diff --git a/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs index c4c3515..8cc7c5a 100644 --- a/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs +++ b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs @@ -33,7 +33,7 @@ public void Collect(TelemetryEvent @event) } } - protected string SerializeToJson(PreparedEvent preparedEvent) + protected virtual string SerializeToJson(PreparedEvent preparedEvent) { return JsonSerializer.Serialize(preparedEvent); } From f30638577e9114c53bb2893823fb5f29471e3803 Mon Sep 17 00:00:00 2001 From: Roshan George Date: Thu, 6 Mar 2025 15:52:32 -0800 Subject: [PATCH 5/9] Support CLARK_TELEMETRY_DISABLED=1 --- src/Clerk/BackendAPI/Hooks/HookRegistration.cs | 15 ++++++++++++++- .../Hooks/Telemetry/TelemetryCollector.cs | 2 +- .../Hooks/Telemetry/TelemetrySampler.cs | 6 +++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/Clerk/BackendAPI/Hooks/HookRegistration.cs b/src/Clerk/BackendAPI/Hooks/HookRegistration.cs index f23f471..c03a3eb 100644 --- a/src/Clerk/BackendAPI/Hooks/HookRegistration.cs +++ b/src/Clerk/BackendAPI/Hooks/HookRegistration.cs @@ -1,4 +1,3 @@ - namespace Clerk.BackendAPI.Hooks { using System; @@ -33,6 +32,20 @@ public static void InitHooks(IHooks hooks) hooks.RegisterBeforeRequestHook(clerkBeforeRequestHook); // Register telemetry hooks + RegisterTelemetryHooks(hooks); + } + + /// + /// Registers telemetry hooks for collecting usage data. + /// + /// The hooks registry to add telemetry hooks to. + private static void RegisterTelemetryHooks(IHooks hooks) + { + + if (Environment.GetEnvironmentVariable("CLERK_TELEMETRY_DISABLED") == "1") { + return; + } + var telemetryCollectors = new List { LiveTelemetryCollector.Standard() }; if (Environment.GetEnvironmentVariable("CLERK_TELEMETRY_DEBUG") == "1") { telemetryCollectors.Add(new DebugTelemetryCollector()); diff --git a/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs index 8cc7c5a..9480cf1 100644 --- a/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs +++ b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs @@ -86,7 +86,7 @@ protected override void CollectInternal(TelemetryEvent @event) PreparedEvent preparedEvent = PrepareEvent(@event); foreach (var sampler in _samplers) { - if (!sampler.Test(preparedEvent, @event)) + if (!sampler.shouldSample(preparedEvent, @event)) { return; } diff --git a/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetrySampler.cs b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetrySampler.cs index a2b307c..9a32570 100644 --- a/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetrySampler.cs +++ b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetrySampler.cs @@ -6,7 +6,7 @@ namespace Clerk.BackendAPI.Hooks.Telemetry public interface ITelemetrySampler { - bool Test(PreparedEvent preparedEvent, TelemetryEvent telemetryEvent); + bool shouldSample(PreparedEvent preparedEvent, TelemetryEvent telemetryEvent); } public class RandomSampler : ITelemetrySampler @@ -18,7 +18,7 @@ public RandomSampler(Random random) _random = random; } - public bool Test(PreparedEvent preparedEvent, TelemetryEvent telemetryEvent) + public bool shouldSample(PreparedEvent preparedEvent, TelemetryEvent telemetryEvent) { return _random.NextDouble() < telemetryEvent.SamplingRate; } @@ -41,7 +41,7 @@ public DeduplicatingSampler(TimeSpan window, Func nowProvider) _nowProvider = nowProvider; } - public bool Test(PreparedEvent preparedEvent, TelemetryEvent telemetryEvent) + public bool shouldSample(PreparedEvent preparedEvent, TelemetryEvent telemetryEvent) { try { From 78e14453e8f65ca0cd25491da12f42da76272b6f Mon Sep 17 00:00:00 2001 From: Roshan George Date: Thu, 6 Mar 2025 16:02:59 -0800 Subject: [PATCH 6/9] Add tests --- tests/Telemetry/PreparedEventTests.cs | 143 +++++++++++++++ tests/Telemetry/TelemetryCollectorTests.cs | 178 ++++++++++++++++++ tests/Telemetry/TelemetryEventTests.cs | 115 ++++++++++++ tests/Telemetry/TelemetryHooksTests.cs | 104 +++++++++++ tests/Telemetry/TelemetrySamplerTests.cs | 200 +++++++++++++++++++++ 5 files changed, 740 insertions(+) create mode 100644 tests/Telemetry/PreparedEventTests.cs create mode 100644 tests/Telemetry/TelemetryCollectorTests.cs create mode 100644 tests/Telemetry/TelemetryEventTests.cs create mode 100644 tests/Telemetry/TelemetryHooksTests.cs create mode 100644 tests/Telemetry/TelemetrySamplerTests.cs diff --git a/tests/Telemetry/PreparedEventTests.cs b/tests/Telemetry/PreparedEventTests.cs new file mode 100644 index 0000000..010e27c --- /dev/null +++ b/tests/Telemetry/PreparedEventTests.cs @@ -0,0 +1,143 @@ +using System.Collections.Generic; +using Clerk.BackendAPI.Hooks.Telemetry; +using Xunit; + +namespace Tests.Telemetry +{ + public class PreparedEventTests + { + [Fact] + public void Sanitize_ContainsAllFields() + { + // Arrange + string @event = "test-event"; + string it = "test-it"; + string sdk = "csharp"; + string sdkv = "1.0.0"; + string sk = "sk_test_123"; + var payload = new Dictionary + { + { "key1", "value1" }, + { "key2", "value2" } + }; + + var preparedEvent = new PreparedEvent(@event, it, sdk, sdkv, sk, payload); + + // Act + var sanitized = preparedEvent.Sanitize(); + + // Assert + Assert.Equal(@event, sanitized["event"]); + Assert.Equal(it, sanitized["it"]); + Assert.Equal(sdk, sanitized["sdk"]); + Assert.Equal(sdkv, sanitized["sdkv"]); + Assert.Equal("value1", sanitized["key1"]); + Assert.Equal("value2", sanitized["key2"]); + } + + [Fact] + public void Sanitize_DoesNotIncludeSk() + { + // Arrange + var preparedEvent = new PreparedEvent( + "test-event", + "test-it", + "csharp", + "1.0.0", + "sk_test_123", + new Dictionary() + ); + + // Act + var sanitized = preparedEvent.Sanitize(); + + // Assert + Assert.False(sanitized.ContainsKey("sk"), "SK should not be included in sanitized output"); + } + + [Fact] + public void Sanitize_PayloadOverridesDefaultFields() + { + // This is a negative test + // It's not that we want this behavior so much as we want to document it + // Arrange + var payload = new Dictionary + { + { "event", "overridden-event" }, + { "sdk", "overridden-sdk" } + }; + + var preparedEvent = new PreparedEvent( + "original-event", + "test-it", + "original-sdk", + "1.0.0", + "sk_test_123", + payload + ); + + // Act + var sanitized = preparedEvent.Sanitize(); + + // Assert + Assert.Equal("overridden-event", sanitized["event"]); + Assert.Equal("overridden-sdk", sanitized["sdk"]); + } + + [Fact] + public void Sanitize_HandlesEmptyPayload() + { + // Arrange + var preparedEvent = new PreparedEvent( + "test-event", + "test-it", + "csharp", + "1.0.0", + "sk_test_123", + new Dictionary() + ); + + // Act + var sanitized = preparedEvent.Sanitize(); + + // Assert + Assert.Equal(4, sanitized.Count); + Assert.Equal("test-event", sanitized["event"]); + Assert.Equal("test-it", sanitized["it"]); + Assert.Equal("csharp", sanitized["sdk"]); + Assert.Equal("1.0.0", sanitized["sdkv"]); + } + + [Fact] + public void Sanitize_SortsKeys() + { + // Arrange + var payload = new Dictionary + { + { "z-key", "z-value" }, + { "a-key", "a-value" }, + { "m-key", "m-value" } + }; + + var preparedEvent = new PreparedEvent( + "test-event", + "test-it", + "csharp", + "1.0.0", + "sk_test_123", + payload + ); + + // Act + var sanitized = preparedEvent.Sanitize(); + + // Assert + // SortedDictionary sorts keys alphabetically + var expectedOrder = new[] { "a-key", "event", "it", "m-key", "sdk", "sdkv", "z-key" }; + var actualKeys = new string[sanitized.Count]; + sanitized.Keys.CopyTo(actualKeys, 0); + + Assert.Equal(expectedOrder, actualKeys); + } + } +} \ No newline at end of file diff --git a/tests/Telemetry/TelemetryCollectorTests.cs b/tests/Telemetry/TelemetryCollectorTests.cs new file mode 100644 index 0000000..99a3b82 --- /dev/null +++ b/tests/Telemetry/TelemetryCollectorTests.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Clerk.BackendAPI.Hooks.Telemetry; +using Xunit; + +namespace Tests.Telemetry +{ + public class TelemetryCollectorTests + { + [Fact] + public void BaseCollector_CollectIgnoresProductionEvents() + { + // Arrange + var prodEvent = new TelemetryEvent( + "sk_live_123", + TelemetryEvent.EVENT_METHOD_CALLED, + new Dictionary { { "method", "test-method" } }, + 1.0f + ); + + var collector = new TestCollector(); + + // Act + collector.Collect(prodEvent); + + // Assert + Assert.False(collector.WasCollectInternalCalled, "collectInternal should not be called for production events"); + } + + [Fact] + public void BaseCollector_CollectCallsCollectInternalForDevelopment() + { + // Arrange + var devEvent = new TelemetryEvent( + "sk_test_123", + TelemetryEvent.EVENT_METHOD_CALLED, + new Dictionary { { "method", "test-method" } }, + 1.0f + ); + + var collector = new TestCollector(); + + // Act + collector.Collect(devEvent); + + // Assert + Assert.True(collector.WasCollectInternalCalled, "collectInternal should be called for development events"); + Assert.Equal(devEvent, collector.LastEvent); + } + + [Fact] + public void BaseCollector_PrepareEventPopulatesAllFields() + { + // Arrange + var event1 = new TelemetryEvent( + "sk_test_123", + TelemetryEvent.EVENT_METHOD_CALLED, + new Dictionary { { "key1", "value1" } }, + 1.0f + ); + + var collector = new TestCollector(); + + // Act + PreparedEvent prepared = collector.PrepareEventForTest(event1); + + // Assert + Assert.Equal(event1.Event, prepared.Event); + Assert.Equal(event1.It, prepared.It); + Assert.NotNull(prepared.Sdk); + Assert.NotNull(prepared.Sdkv); + Assert.Equal(event1.Sk, prepared.Sk); + Assert.IsType>(prepared.Payload); + Assert.Equal("value1", prepared.Payload["key1"]); + } + + [Fact] + public void DebugCollector_OutputsToConsole() + { + // Arrange + var event1 = new TelemetryEvent( + "sk_test_123", + TelemetryEvent.EVENT_METHOD_CALLED, + new Dictionary { { "method", "test-method" } }, + 1.0f + ); + + var collector = new TestDebugCollector(); + var consoleOutput = new StringWriter(); + Console.SetError(consoleOutput); + + try + { + // Act + collector.CollectInternalForTest(event1); + + // Assert + string output = consoleOutput.ToString(); + Assert.Contains(collector.SerializedOutput, output); + } + finally + { + Console.SetError(Console.Error); + } + } + + [Fact] + public void DebugCollector_HandlesSerializationError() + { + // Arrange + var event1 = new TelemetryEvent( + "sk_test_123", + TelemetryEvent.EVENT_METHOD_CALLED, + new Dictionary { { "method", "test-method" } }, + 1.0f + ); + + var collector = new TestDebugCollector { ThrowOnSerialize = true }; + var consoleOutput = new StringWriter(); + Console.SetError(consoleOutput); + + try + { + // Act + collector.CollectInternalForTest(event1); + + // Assert + string output = consoleOutput.ToString(); + Assert.Contains("Failed to serialize event", output); + } + finally + { + Console.SetError(Console.Error); + } + } + + // Helper test classes + private class TestCollector : BaseTelemetryCollector + { + public bool WasCollectInternalCalled { get; private set; } + public TelemetryEvent LastEvent { get; private set; } + + protected override void CollectInternal(TelemetryEvent @event) + { + WasCollectInternalCalled = true; + LastEvent = @event; + } + + public PreparedEvent PrepareEventForTest(TelemetryEvent @event) + { + return PrepareEvent(@event); + } + } + + private class TestDebugCollector : DebugTelemetryCollector + { + public bool ThrowOnSerialize { get; set; } + public string SerializedOutput { get; } = "{\"test\":\"json\"}"; + + protected override string SerializeToJson(PreparedEvent preparedEvent) + { + if (ThrowOnSerialize) + { + throw new Exception("Test error"); + } + return SerializedOutput; + } + + public void CollectInternalForTest(TelemetryEvent @event) + { + CollectInternal(@event); + } + } + } +} \ No newline at end of file diff --git a/tests/Telemetry/TelemetryEventTests.cs b/tests/Telemetry/TelemetryEventTests.cs new file mode 100644 index 0000000..b96cc3c --- /dev/null +++ b/tests/Telemetry/TelemetryEventTests.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using Clerk.BackendAPI.Hooks; +using Clerk.BackendAPI.Hooks.Telemetry; +using Clerk.BackendAPI.Utils; +using Xunit; + +namespace Tests.Telemetry +{ + public class TelemetryEventTests + { + [Fact] + public void Constructor_InitializesAllFields() + { + // Arrange + string sk = "sk_test_123"; + string @event = TelemetryEvent.EVENT_METHOD_CALLED; + var payload = new Dictionary + { + { "key1", "value1" }, + { "key2", "value2" } + }; + float samplingRate = 0.5f; + + // Act + var telemetryEvent = new TelemetryEvent(sk, @event, payload, samplingRate); + + // Assert + Assert.Equal(sk, telemetryEvent.Sk); + Assert.Equal("development", telemetryEvent.It); + Assert.Equal(@event, telemetryEvent.Event); + Assert.Equal(payload, telemetryEvent.Payload); + Assert.Equal(samplingRate, telemetryEvent.SamplingRate); + } + + [Theory] + [InlineData("sk_test_123", "development")] + [InlineData("sk_test_abc", "development")] + [InlineData("sk_live_456", "production")] + [InlineData("sk_123", "production")] + [InlineData(null, "production")] + public void Constructor_SetsItBasedOnSk(string sk, string expectedIt) + { + // Arrange & Act + var telemetryEvent = new TelemetryEvent( + sk, + TelemetryEvent.EVENT_METHOD_CALLED, + new Dictionary(), + 0.5f + ); + + // Assert + Assert.Equal(expectedIt, telemetryEvent.It); + } + + [Fact] + public void FromContext_EmptyAdditionalPayload() + { + // Arrange + string operationId = "testOperation"; + var emptyPayload = new Dictionary(); + float samplingRate = 0.1f; + + var context = new TestHookContext(operationId, null); + + // Act + var @event = TelemetryEvent.FromContext( + context, + TelemetryEvent.EVENT_METHOD_CALLED, + samplingRate, + emptyPayload + ); + + // Assert + Assert.Single(@event.Payload); + Assert.Equal("testOperation", @event.Payload["method"]); + } + + [Fact] + public void FromContext_AdditionalPayloadOverridesMethod() + { + // Not so much behavior we desire as documentation that this occurs + // Arrange + string operationId = "testOperation"; + var overridingPayload = new Dictionary + { + { "method", "overridden-method" } + }; + float samplingRate = 0.5f; + + var context = new TestHookContext(operationId, null); + + // Act + var @event = TelemetryEvent.FromContext( + context, + TelemetryEvent.EVENT_METHOD_CALLED, + samplingRate, + overridingPayload + ); + + // Assert + Assert.Single(@event.Payload); + Assert.Equal("overridden-method", @event.Payload["method"]); + } + + // Test implementation for HookContext + private class TestHookContext : HookContext + { + public TestHookContext(string operationId, Func securitySource) + : base(operationId, null, securitySource) + { + } + } + } +} \ No newline at end of file diff --git a/tests/Telemetry/TelemetryHooksTests.cs b/tests/Telemetry/TelemetryHooksTests.cs new file mode 100644 index 0000000..ea6e374 --- /dev/null +++ b/tests/Telemetry/TelemetryHooksTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Clerk.BackendAPI.Hooks; +using Clerk.BackendAPI.Hooks.Telemetry; +using Xunit; + +namespace Tests.Telemetry +{ + public class TelemetryHooksTests + { + [Fact] + public async Task TelemetryBeforeRequestHook_CollectsEvent() + { + // Arrange + var collector = new TestCollector(); + var hook = new TelemetryBeforeRequestHook(new List { collector }); + + var context = new BeforeRequestContext(new HookContext("testOperation", null, null)); + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com"); + + // Act + await hook.BeforeRequestAsync(context, request); + + // Assert + Assert.Equal(1, collector.Events.Count); + Assert.Equal(TelemetryEvent.EVENT_METHOD_CALLED, collector.Events[0].Event); + Assert.Equal("testOperation", collector.Events[0].Payload["method"]); + } + + [Fact] + public async Task TelemetryAfterSuccessHook_CollectsEventWithStatusCode() + { + // Arrange + var collector = new TestCollector(); + var hook = new TelemetryAfterSuccessHook(new List { collector }); + + var context = new AfterSuccessContext(new HookContext("testOperation", null, null)); + var response = new HttpResponseMessage(HttpStatusCode.OK); + + // Act + await hook.AfterSuccessAsync(context, response); + + // Assert + Assert.Equal(1, collector.Events.Count); + Assert.Equal(TelemetryEvent.EVENT_METHOD_SUCCEEDED, collector.Events[0].Event); + Assert.Equal("testOperation", collector.Events[0].Payload["method"]); + Assert.Equal("200", collector.Events[0].Payload["status_code"]); + } + + [Fact] + public async Task TelemetryAfterErrorHook_CollectsEventWithStatusCode() + { + // Arrange + var collector = new TestCollector(); + var hook = new TelemetryAfterErrorHook(new List { collector }); + + var context = new AfterErrorContext(new HookContext("testOperation", null, null)); + var response = new HttpResponseMessage(HttpStatusCode.BadRequest); + + // Act + await hook.AfterErrorAsync(context, response, null); + + // Assert + Assert.Equal(1, collector.Events.Count); + Assert.Equal(TelemetryEvent.EVENT_METHOD_FAILED, collector.Events[0].Event); + Assert.Equal("testOperation", collector.Events[0].Payload["method"]); + Assert.Equal("400", collector.Events[0].Payload["status_code"]); + } + + [Fact] + public async Task TelemetryAfterErrorHook_CollectsEventWithErrorMessage() + { + // Arrange + var collector = new TestCollector(); + var hook = new TelemetryAfterErrorHook(new List { collector }); + + var context = new AfterErrorContext(new HookContext("testOperation", null, null)); + var error = new Exception("Test error message"); + + // Act + await hook.AfterErrorAsync(context, null, error); + + // Assert + Assert.Equal(1, collector.Events.Count); + Assert.Equal(TelemetryEvent.EVENT_METHOD_FAILED, collector.Events[0].Event); + Assert.Equal("testOperation", collector.Events[0].Payload["method"]); + Assert.Equal("Test error message", collector.Events[0].Payload["error_message"]); + } + + // Helper test class + private class TestCollector : ITelemetryCollector + { + public List Events { get; } = new List(); + + public void Collect(TelemetryEvent @event) + { + Events.Add(@event); + } + } + } +} \ No newline at end of file diff --git a/tests/Telemetry/TelemetrySamplerTests.cs b/tests/Telemetry/TelemetrySamplerTests.cs new file mode 100644 index 0000000..ee9a6c6 --- /dev/null +++ b/tests/Telemetry/TelemetrySamplerTests.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using Clerk.BackendAPI.Hooks.Telemetry; +using Xunit; + +namespace Tests.Telemetry +{ + public class TelemetrySamplerTests + { + private static TelemetryEvent CreateTestEvent(float samplingRate) + { + return new TelemetryEvent( + "sk_test_123", + TelemetryEvent.EVENT_METHOD_CALLED, + new Dictionary { { "method", "test-method" } }, + samplingRate + ); + } + + private static PreparedEvent CreateTestPreparedEvent(TelemetryEvent @event) + { + return new PreparedEvent(@event.Event, @event.It, "sdk", "sdkv", @event.Sk, @event.Payload); + } + + [Fact] + public void RandomSampler_WithSeedWorks() + { + // Arrange + var fixedRandom = new Random(1); + var sampler = new RandomSampler(fixedRandom); + + var @event = CreateTestEvent(0.5f); + var preparedEvent = CreateTestPreparedEvent(@event); + + // Act - with a fixed seed, we should get deterministic results + bool firstResult = sampler.Test(preparedEvent, @event); + bool secondResult = sampler.Test(preparedEvent, @event); + + // The exact results will depend on the random seed, but we can at least verify they're different + // With seed 1, these should be predictable + Assert.NotEqual(firstResult, secondResult); + } + + [Fact] + public void RandomSampler_SamplingRateZero_AlwaysFalse() + { + // Arrange + var sampler = new RandomSampler(new Random()); + + // Act & Assert + for (int i = 0; i < 100; i++) + { + var @event = CreateTestEvent(0.0f); + var preparedEvent = CreateTestPreparedEvent(@event); + Assert.False(sampler.Test(preparedEvent, @event), "Should always return false with 0.0 sampling rate"); + } + } + + [Fact] + public void RandomSampler_SamplingRateOne_AlwaysTrue() + { + // Arrange + var sampler = new RandomSampler(new Random()); + + + // Act & Assert + for (int i = 0; i < 100; i++) + { + var @event = CreateTestEvent(1.0f); + var preparedEvent = CreateTestPreparedEvent(@event); + Assert.True(sampler.Test(preparedEvent, @event), "Should always return true with 1.0 sampling rate"); + } + } + + [Fact] + public void DeduplicatingSampler_FirstEventAccepted() + { + // Arrange + DateTime fixedTime = new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var sampler = new DeduplicatingSampler( + TimeSpan.FromDays(1), + () => fixedTime + ); + + var @event = CreateTestEvent(0.5f); + var preparedEvent = CreateTestPreparedEvent(@event); + + // Act & Assert + Assert.True(sampler.Test(preparedEvent, @event), "First event should be accepted"); + } + + [Fact] + public void DeduplicatingSampler_DuplicateEventWithinWindowRejected() + { + // Arrange + var testClock = new TestClock(new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc)); + var sampler = new DeduplicatingSampler( + TimeSpan.FromDays(1), + testClock.GetCurrentTime + ); + + var @event = CreateTestEvent(0.5f); + var preparedEvent = CreateTestPreparedEvent(@event); + + // Act + bool firstResult = sampler.Test(preparedEvent, @event); + + // Move time forward, but still within window + testClock.SetCurrentTime(testClock.CurrentTime.AddHours(23)); + + bool secondResult = sampler.Test(preparedEvent, @event); + + // Assert + Assert.True(firstResult, "First event should be accepted"); + Assert.False(secondResult, "Duplicate event within window should be rejected"); + } + + [Fact] + public void DeduplicatingSampler_EventAfterWindowAccepted() + { + // Arrange + var testClock = new TestClock(new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc)); + var sampler = new DeduplicatingSampler( + TimeSpan.FromDays(1), + testClock.GetCurrentTime + ); + + var @event = CreateTestEvent(0.5f); + var preparedEvent = CreateTestPreparedEvent(@event); + + // Act + bool firstResult = sampler.Test(preparedEvent, @event); + + // Move time forward beyond window + testClock.SetCurrentTime(testClock.CurrentTime.AddHours(25)); + + bool secondResult = sampler.Test(preparedEvent, @event); + + // Assert + Assert.True(firstResult, "First event should be accepted"); + Assert.True(secondResult, "Event after window should be accepted"); + } + + [Fact] + public void DeduplicatingSampler_DifferentEventsAccepted() + { + // Arrange + DateTime fixedTime = new DateTime(2023, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var sampler = new DeduplicatingSampler( + TimeSpan.FromDays(1), + () => fixedTime + ); + + var firstEvent = new TelemetryEvent( + "sk_test_123", + TelemetryEvent.EVENT_METHOD_CALLED, + new Dictionary { { "method", "test-method-1" } }, + 0.5f + ); + var firstPreparedEvent = CreateTestPreparedEvent(firstEvent); + + var secondEvent = new TelemetryEvent( + "sk_test_123", + TelemetryEvent.EVENT_METHOD_CALLED, + new Dictionary { { "method", "test-method-2" } }, + 0.5f + ); + var secondPreparedEvent = CreateTestPreparedEvent(secondEvent); + + // Act + bool firstResult = sampler.Test(firstPreparedEvent, firstEvent); + bool secondResult = sampler.Test(secondPreparedEvent, secondEvent); + + // Assert + Assert.True(firstResult, "First event should be accepted"); + Assert.True(secondResult, "Different event should be accepted"); + } + + // A test clock implementation that allows changing the time + private class TestClock + { + public DateTime CurrentTime { get; private set; } + + public TestClock(DateTime initialTime) + { + CurrentTime = initialTime; + } + + public void SetCurrentTime(DateTime newTime) + { + CurrentTime = newTime; + } + + public DateTime GetCurrentTime() + { + return CurrentTime; + } + } + } +} \ No newline at end of file From 79f2d41c502e473ca3023be448caad7ffeed6ed0 Mon Sep 17 00:00:00 2001 From: Roshan George Date: Thu, 6 Mar 2025 16:10:59 -0800 Subject: [PATCH 7/9] Fix casing on messages --- src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs index 9480cf1..bf2e441 100644 --- a/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs +++ b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs @@ -35,7 +35,9 @@ public void Collect(TelemetryEvent @event) protected virtual string SerializeToJson(PreparedEvent preparedEvent) { - return JsonSerializer.Serialize(preparedEvent); + // Convert to sanitized dictionary with lowercase keys then serialize + var sanitizedEvent = preparedEvent.Sanitize(); + return JsonSerializer.Serialize(sanitizedEvent); } protected PreparedEvent PrepareEvent(TelemetryEvent @event) From 69a732ed2fd456532df39d09da03a7437c26c311 Mon Sep 17 00:00:00 2001 From: Roshan George Date: Thu, 6 Mar 2025 16:11:50 -0800 Subject: [PATCH 8/9] Reduce logging to only on debug --- .../Hooks/Telemetry/TelemetryCollector.cs | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs index bf2e441..ec756e8 100644 --- a/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs +++ b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs @@ -112,33 +112,18 @@ private async Task SendEventAsync(TelemetryEvent @event) if (!response.IsSuccessStatusCode) { - LogWarning($"Failed to send telemetry event. Response code: {(int)response.StatusCode}, error: {responseContent}"); - } - else - { - LogDebug($"Telemetry event sent successfully. Response: {responseContent}"); + LogDebug($"Failed to send telemetry event. Response code: {(int)response.StatusCode}, error: {responseContent}"); } } catch (Exception ex) { - LogWarning($"Error sending telemetry event: {ex.Message}"); - } - } - - private void LogWarning(string message) - { - lock (_consoleLock) - { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.Error.WriteLine(message); - Console.ResetColor(); + LogDebug($"Error sending telemetry event: {ex.Message}"); } } - private void LogDebug(string message) { // Only log in debug mode or controlled by environment variable - if (Environment.GetEnvironmentVariable("CLERK_DEBUG") == "1") + if (Environment.GetEnvironmentVariable("CLERK_TELEMETRY_DEBUG") == "1") { lock (_consoleLock) { From 93fc7ce17c693e2f45af8da3b21583e1b8b275a5 Mon Sep 17 00:00:00 2001 From: Roshan George Date: Thu, 6 Mar 2025 16:19:57 -0800 Subject: [PATCH 9/9] Default SDK name: C# We already know we're Clerk --- src/Clerk/BackendAPI/Hooks/Telemetry/SdkInfo.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Clerk/BackendAPI/Hooks/Telemetry/SdkInfo.cs b/src/Clerk/BackendAPI/Hooks/Telemetry/SdkInfo.cs index 68664e5..7d25d42 100644 --- a/src/Clerk/BackendAPI/Hooks/Telemetry/SdkInfo.cs +++ b/src/Clerk/BackendAPI/Hooks/Telemetry/SdkInfo.cs @@ -37,7 +37,7 @@ public override string ToString() var name = assemblyName.Name ?? "unknown"; // For .NET we'll use the first part of the namespace as groupId - var groupId = name.Contains('.') ? name.Substring(0, name.IndexOf('.')) : "Clerk"; + var groupId = name.Contains('.') ? name.Substring(0, name.IndexOf('.')) : "C#"; return new SdkInfo(version, name, groupId); }