From d90b2bd4e3071fbde1a5d09ad67b855d762f7dce Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 30 Apr 2026 14:43:17 +0200 Subject: [PATCH] feat: add default PostHog SDK client facade --- src/PostHog/NoOpPostHogClient.cs | 109 ++++++++++++ src/PostHog/PostHogSdk.cs | 270 +++++++++++++++++++++++++++++ src/PostHog/README.md | 27 ++- tests/UnitTests/PostHogSdkTests.cs | 93 ++++++++++ 4 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 src/PostHog/NoOpPostHogClient.cs create mode 100644 src/PostHog/PostHogSdk.cs create mode 100644 tests/UnitTests/PostHogSdkTests.cs diff --git a/src/PostHog/NoOpPostHogClient.cs b/src/PostHog/NoOpPostHogClient.cs new file mode 100644 index 0000000..d4c09aa --- /dev/null +++ b/src/PostHog/NoOpPostHogClient.cs @@ -0,0 +1,109 @@ +using System.Diagnostics; +using System.Text.Json; +using PostHog.Api; +using PostHog.Features; +using PostHog.Json; + +namespace PostHog; + +internal sealed class NoOpPostHogClient : IPostHogClient +{ + static readonly IReadOnlyDictionary EmptyFeatureFlags = new Dictionary(); + static int _loggedNoDefaultClient; + + NoOpPostHogClient() + { + } + + internal static NoOpPostHogClient Instance { get; } = new(); + + internal static void LogNoDefaultClient() + { + if (Interlocked.Exchange(ref _loggedNoDefaultClient, 1) == 0) + { + Trace.TraceWarning( + "PostHogSdk.DefaultClient is not configured. PostHogSdk calls will be ignored until a default client is configured."); + } + } + + public Task AliasAsync(string previousId, string newId, CancellationToken cancellationToken) + => Task.FromResult(new ApiResult(0)); + + public Task IdentifyAsync( + string distinctId, + Dictionary? personPropertiesToSet, + Dictionary? personPropertiesToSetOnce, + CancellationToken cancellationToken) + => Task.FromResult(new ApiResult(0)); + + public Task GroupIdentifyAsync( + string type, + StringOrValue key, + Dictionary? properties, + CancellationToken cancellationToken) + => Task.FromResult(new ApiResult(0)); + + public Task GroupIdentifyAsync( + string distinctId, + string type, + StringOrValue key, + Dictionary? properties, + CancellationToken cancellationToken) + => Task.FromResult(new ApiResult(0)); + + public bool Capture( + string distinctId, + string eventName, + Dictionary? properties, + GroupCollection? groups, + bool sendFeatureFlags, + DateTimeOffset? timestamp = null) + => false; + + public bool CaptureException( + Exception exception, + string distinctId, + Dictionary? properties, + GroupCollection? groups, + bool sendFeatureFlags, + DateTimeOffset? timestamp = null) + => false; + + public Task IsFeatureEnabledAsync( + string featureKey, + string distinctId, + FeatureFlagOptions? options, + CancellationToken cancellationToken) + => Task.FromResult(false); + + public Task GetFeatureFlagAsync( + string featureKey, + string distinctId, + FeatureFlagOptions? options, + CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task GetRemoteConfigPayloadAsync(string key, CancellationToken cancellationToken) + => Task.FromResult(null); + + public Task> GetAllFeatureFlagsAsync( + string distinctId, + AllFeatureFlagsOptions? options, + CancellationToken cancellationToken) + => Task.FromResult(EmptyFeatureFlags); + + public Task LoadFeatureFlagsAsync(CancellationToken cancellationToken) + => Task.CompletedTask; + + public Task FlushAsync() + => Task.CompletedTask; + + public string Version => Versioning.VersionConstants.Version; + + public void Dispose() + { + } + + public ValueTask DisposeAsync() + => default; +} diff --git a/src/PostHog/PostHogSdk.cs b/src/PostHog/PostHogSdk.cs new file mode 100644 index 0000000..c2bdfb5 --- /dev/null +++ b/src/PostHog/PostHogSdk.cs @@ -0,0 +1,270 @@ +using System.Text.Json; +using PostHog.Api; +using PostHog.Features; +using PostHog.Json; + +namespace PostHog; + +/// +/// Static convenience facade that delegates calls to a process-wide default . +/// +/// +/// Prefer dependency injection and for applications that already use DI. This facade is +/// intended for console apps, scripts, and other places where passing a client instance around is inconvenient. +/// +public static class PostHogSdk +{ + static IPostHogClient? _defaultClient; + + /// + /// Gets or sets the process-wide default PostHog client used by the static facade methods. + /// + /// + /// Setting this property does not dispose the previous client. Use to flush, dispose, + /// and clear the current default client when the application is shutting down. + /// + public static IPostHogClient? DefaultClient + { + get => Volatile.Read(ref _defaultClient); + set => Volatile.Write(ref _defaultClient, value); + } + + /// + /// Creates a , stores it as , and returns it. + /// + /// The options used to configure the client. + /// The created . + public static IPostHogClient Init(PostHogOptions options) + { + var client = new PostHogClient(options); + DefaultClient = client; + return client; + } + + /// + /// Captures an event using the default client. + /// + /// The identifier you use for the user. + /// Human friendly name of the event. + /// true if the event was successfully enqueued; otherwise false. + public static bool Capture(string distinctId, string eventName) + => Current.Capture(distinctId, eventName); + + /// + /// Captures an event with additional properties using the default client. + /// + /// The identifier you use for the user. + /// Human friendly name of the event. + /// Optional properties to send along with the event. + /// true if the event was successfully enqueued; otherwise false. + public static bool Capture(string distinctId, string eventName, Dictionary? properties) + => Current.Capture(distinctId, eventName, properties); + + /// + /// Captures an event using the default client. + /// + /// The identifier you use for the user. + /// Human friendly name of the event. + /// Optional properties to send along with the event. + /// Optional groups related to this event. + /// Whether to send feature flag data with the event. + /// Optional timestamp when the event occurred. + /// true if the event was successfully enqueued; otherwise false. + public static bool Capture( + string distinctId, + string eventName, + Dictionary? properties, + GroupCollection? groups, + bool sendFeatureFlags, + DateTimeOffset? timestamp = null) + => Current.Capture(distinctId, eventName, properties, groups, sendFeatureFlags, timestamp); + + /// + /// Captures an exception using the default client. + /// + /// The exception to capture. + /// The identifier you use for the user. + /// true if the exception event was successfully enqueued; otherwise false. + public static bool CaptureException(Exception exception, string distinctId) + => Current.CaptureException(exception, distinctId); + + /// + /// Captures an exception using the default client. + /// + /// The exception to capture. + /// The identifier you use for the user. + /// Optional properties to send along with the event. + /// Optional groups related to this event. + /// Whether to send feature flag data with the event. + /// Optional timestamp when the event occurred. + /// true if the exception event was successfully enqueued; otherwise false. + public static bool CaptureException( + Exception exception, + string distinctId, + Dictionary? properties, + GroupCollection? groups, + bool sendFeatureFlags, + DateTimeOffset? timestamp = null) + => Current.CaptureException(exception, distinctId, properties, groups, sendFeatureFlags, timestamp); + + /// + /// Identifies a user using the default client. + /// + /// The identifier you use for the user. + /// Properties to set on the user profile. + /// Properties to set only once on the user profile. + /// The cancellation token that can be used to cancel the operation. + /// An with the result of the operation. + public static Task IdentifyAsync( + string distinctId, + Dictionary? personPropertiesToSet = null, + Dictionary? personPropertiesToSetOnce = null, + CancellationToken cancellationToken = default) + => Current.IdentifyAsync(distinctId, personPropertiesToSet, personPropertiesToSetOnce, cancellationToken); + + /// + /// Creates an alias using the default client. + /// + /// The anonymous or temporary identifier you were using for the user. + /// The identifier for the known user. + /// The cancellation token that can be used to cancel the operation. + /// An with the result of the operation. + public static Task AliasAsync( + string previousId, + string newId, + CancellationToken cancellationToken = default) + => Current.AliasAsync(previousId, newId, cancellationToken); + + /// + /// Sets group properties using the default client. + /// + /// Type of group. + /// Unique identifier for that type of group. + /// Additional information about the group. + /// The cancellation token that can be used to cancel the operation. + /// An with the result of the operation. + public static Task GroupIdentifyAsync( + string type, + StringOrValue key, + Dictionary? properties = null, + CancellationToken cancellationToken = default) + => Current.GroupIdentifyAsync(type, key, properties, cancellationToken); + + /// + /// Sets group properties using the default client. + /// + /// The identifier you use for the current user. + /// Type of group. + /// Unique identifier for that type of group. + /// Additional information about the group. + /// The cancellation token that can be used to cancel the operation. + /// An with the result of the operation. + public static Task GroupIdentifyAsync( + string distinctId, + string type, + StringOrValue key, + Dictionary? properties = null, + CancellationToken cancellationToken = default) + => Current.GroupIdentifyAsync(distinctId, type, key, properties, cancellationToken); + + /// + /// Determines whether a feature is enabled using the default client. + /// + /// The name of the feature flag. + /// The identifier you use for the user. + /// Optional options used to control feature flag evaluation. + /// The cancellation token that can be used to cancel the operation. + /// true if the feature is enabled for the user; otherwise false. + public static Task IsFeatureEnabledAsync( + string featureKey, + string distinctId, + FeatureFlagOptions? options = null, + CancellationToken cancellationToken = default) + => Current.IsFeatureEnabledAsync(featureKey, distinctId, options, cancellationToken); + + /// + /// Retrieves a feature flag using the default client. + /// + /// The name of the feature flag. + /// The identifier you use for the user. + /// Optional options used to control feature flag evaluation. + /// The cancellation token that can be used to cancel the operation. + /// The feature flag or null if it does not exist or is not enabled. + public static Task GetFeatureFlagAsync( + string featureKey, + string distinctId, + FeatureFlagOptions? options = null, + CancellationToken cancellationToken = default) + => Current.GetFeatureFlagAsync(featureKey, distinctId, options, cancellationToken); + + /// + /// Retrieves all feature flags using the default client. + /// + /// The identifier you use for the user. + /// Optional options used to control feature flag evaluation. + /// The cancellation token that can be used to cancel the operation. + /// A dictionary containing all feature flags. + public static Task> GetAllFeatureFlagsAsync( + string distinctId, + AllFeatureFlagsOptions? options = null, + CancellationToken cancellationToken = default) + => Current.GetAllFeatureFlagsAsync(distinctId, options, cancellationToken); + + /// + /// Retrieves a remote config payload using the default client. + /// + /// The remote config key. + /// The cancellation token that can be used to cancel the operation. + /// The remote config payload or null. + public static Task GetRemoteConfigPayloadAsync( + string key, + CancellationToken cancellationToken = default) + => Current.GetRemoteConfigPayloadAsync(key, cancellationToken); + + /// + /// Loads or reloads feature flag definitions for local evaluation using the default client. + /// + /// The cancellation token that can be used to cancel the operation. + /// A representing the asynchronous operation. + public static Task LoadFeatureFlagsAsync(CancellationToken cancellationToken = default) + => Current.LoadFeatureFlagsAsync(cancellationToken); + + /// + /// Flushes the event queue on the default client. + /// + /// A representing the asynchronous operation. + public static Task FlushAsync() + => Current.FlushAsync(); + + /// + /// Flushes, disposes, and clears the current default client if one is configured. + /// + /// A representing the asynchronous operation. + public static async ValueTask ShutdownAsync() + { + var client = Interlocked.Exchange(ref _defaultClient, null) ?? Current; + try + { + await client.FlushAsync(); + } + finally + { + await client.DisposeAsync(); + } + } + + static IPostHogClient Current + { + get + { + var client = DefaultClient; + if (client is not null) + { + return client; + } + + NoOpPostHogClient.LogNoDefaultClient(); + return NoOpPostHogClient.Instance; + } + } +} diff --git a/src/PostHog/README.md b/src/PostHog/README.md index c1759e8..61546b0 100644 --- a/src/PostHog/README.md +++ b/src/PostHog/README.md @@ -17,7 +17,7 @@ More detailed docs for using this library can be found at [PostHog Docs for the ## Usage -To use this package, you need to create an instance of `PostHogClient` and call the appropriate methods. Here's an example: +To use this package, create an instance of `PostHogClient` and call the appropriate methods. Here's an example: ```csharp using PostHog; @@ -25,3 +25,28 @@ using PostHog; var client = new PostHogClient(new PostHogOptions { ProjectToken = "YOUR_PROJECT_TOKEN" }); client.Capture("user-123", "Test Event"); ``` + +For console apps, scripts, or other places where passing a client instance around is inconvenient, you can configure a process-wide default client and use the `PostHogSdk` facade: + +```csharp +using PostHog; + +PostHogSdk.Init(new PostHogOptions { ProjectToken = "YOUR_PROJECT_TOKEN" }); +PostHogSdk.Capture("user-123", "Test Event"); + +await PostHogSdk.FlushAsync(); +await PostHogSdk.ShutdownAsync(); +``` + +You can also assign an existing client: + +```csharp +using PostHog; + +var client = new PostHogClient(new PostHogOptions { ProjectToken = "YOUR_PROJECT_TOKEN" }); +PostHogSdk.DefaultClient = client; + +PostHogSdk.Capture("user-123", "Test Event"); +``` + +If no default client is configured, `PostHogSdk` methods are no-ops and emit a warning once. diff --git a/tests/UnitTests/PostHogSdkTests.cs b/tests/UnitTests/PostHogSdkTests.cs new file mode 100644 index 0000000..8173b76 --- /dev/null +++ b/tests/UnitTests/PostHogSdkTests.cs @@ -0,0 +1,93 @@ +using NSubstitute; +using PostHog; +using PostHog.Api; + +namespace UnitTests; + +[CollectionDefinition(nameof(PostHogSdkTestScope), DisableParallelization = true)] +public sealed class PostHogSdkTestScope +{ +} + +[Collection(nameof(PostHogSdkTestScope))] +public sealed class PostHogSdkTests : IDisposable +{ + public PostHogSdkTests() + { + PostHogSdk.DefaultClient = null; + } + + [Fact] + public void CaptureDelegatesToDefaultClient() + { + var client = Substitute.For(); + client.Capture("user-123", "Test Event", null, null, false, null).Returns(true); + PostHogSdk.DefaultClient = client; + + var captured = PostHogSdk.Capture("user-123", "Test Event"); + + Assert.True(captured); + client.Received(1).Capture("user-123", "Test Event", null, null, false, null); + } + + [Fact] + public async Task IdentifyAsyncDelegatesToDefaultClient() + { + var client = Substitute.For(); + client.IdentifyAsync("user-123", null, null, CancellationToken.None) + .Returns(Task.FromResult(new ApiResult(1))); + PostHogSdk.DefaultClient = client; + + var result = await PostHogSdk.IdentifyAsync("user-123"); + + Assert.Equal(1, result.Status); + await client.Received(1).IdentifyAsync("user-123", null, null, CancellationToken.None); + } + + [Fact] + public void CaptureIsNoOpWithoutDefaultClient() + { + var captured = PostHogSdk.Capture("user-123", "Test Event"); + + Assert.False(captured); + } + + [Fact] + public async Task AsyncCallsAreNoOpWithoutDefaultClient() + { + var identifyResult = await PostHogSdk.IdentifyAsync("user-123"); + var enabled = await PostHogSdk.IsFeatureEnabledAsync("beta-feature", "user-123"); + var flag = await PostHogSdk.GetFeatureFlagAsync("beta-feature", "user-123"); + + Assert.Equal(0, identifyResult.Status); + Assert.False(enabled); + Assert.Null(flag); + } + + [Fact] + public void InitCreatesAndStoresDefaultClient() + { + var client = PostHogSdk.Init(new PostHogOptions { ProjectToken = "test-token" }); + + Assert.Same(client, PostHogSdk.DefaultClient); + } + + [Fact] + public async Task ShutdownAsyncDisposesAndClearsDefaultClient() + { + var client = Substitute.For(); + client.FlushAsync().Returns(Task.CompletedTask); + PostHogSdk.DefaultClient = client; + + await PostHogSdk.ShutdownAsync(); + + Assert.Null(PostHogSdk.DefaultClient); + await client.Received(1).FlushAsync(); + await client.Received(1).DisposeAsync(); + } + + public void Dispose() + { + PostHogSdk.ShutdownAsync().AsTask().GetAwaiter().GetResult(); + } +}