diff --git a/src/Clerk/BackendAPI/Hooks/ClerkBeforeRequestHook.cs b/src/Clerk/BackendAPI/Hooks/ClerkBeforeRequestHook.cs index 265c6b29..f06feb69 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 diff --git a/src/Clerk/BackendAPI/Hooks/HookRegistration.cs b/src/Clerk/BackendAPI/Hooks/HookRegistration.cs index b47dbcef..c03a3eb9 100644 --- a/src/Clerk/BackendAPI/Hooks/HookRegistration.cs +++ b/src/Clerk/BackendAPI/Hooks/HookRegistration.cs @@ -1,6 +1,9 @@ - namespace Clerk.BackendAPI.Hooks { + using System; + using System.Collections.Generic; + using Clerk.BackendAPI.Hooks.Telemetry; + /// /// Hook Registration File. /// @@ -28,10 +31,34 @@ 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 + 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()); + } + + 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 00000000..0100d787 --- /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 00000000..7d25d42a --- /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('.')) : "C#"; + + 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 00000000..c6a8530d --- /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 00000000..c96f4a14 --- /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 00000000..bc608bf6 --- /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 00000000..ec756e84 --- /dev/null +++ b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryCollector.cs @@ -0,0 +1,146 @@ +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 virtual string SerializeToJson(PreparedEvent preparedEvent) + { + // Convert to sanitized dictionary with lowercase keys then serialize + var sanitizedEvent = preparedEvent.Sanitize(); + return JsonSerializer.Serialize(sanitizedEvent); + } + + 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.shouldSample(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) + { + LogDebug($"Failed to send telemetry event. Response code: {(int)response.StatusCode}, error: {responseContent}"); + } + } + catch (Exception ex) + { + 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_TELEMETRY_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 00000000..f123aee3 --- /dev/null +++ b/src/Clerk/BackendAPI/Hooks/Telemetry/TelemetryEvent.cs @@ -0,0 +1,68 @@ +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) + { + var securitySource = ctx.SecuritySource(); + Security? securityObj = securitySource is Security ? (Security)securitySource : null; + 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 + ); + } + } +} \ 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 00000000..9a32570e --- /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 shouldSample(PreparedEvent preparedEvent, TelemetryEvent telemetryEvent); + } + + public class RandomSampler : ITelemetrySampler + { + private readonly Random _random; + + public RandomSampler(Random random) + { + _random = random; + } + + public bool shouldSample(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 shouldSample(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 diff --git a/tests/Telemetry/PreparedEventTests.cs b/tests/Telemetry/PreparedEventTests.cs new file mode 100644 index 00000000..010e27c9 --- /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 00000000..99a3b82f --- /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 00000000..b96cc3c9 --- /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 00000000..ea6e3744 --- /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 00000000..ee9a6c69 --- /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