diff --git a/.changeset/is-server-property.md b/.changeset/is-server-property.md new file mode 100644 index 0000000..4645ba5 --- /dev/null +++ b/.changeset/is-server-property.md @@ -0,0 +1,5 @@ +--- +"PostHog": minor +--- + +Add a configurable `$is_server` event property (default `true`) so PostHog can identify server-side events. Set `PostHogOptions.IsServer` to `false` when using the SDK as a client/CLI so the device OS is attributed normally. diff --git a/src/PostHog/Api/PostHogApiClient.cs b/src/PostHog/Api/PostHogApiClient.cs index 1f1a5e2..f188d50 100644 --- a/src/PostHog/Api/PostHogApiClient.cs +++ b/src/PostHog/Api/PostHogApiClient.cs @@ -285,6 +285,12 @@ void PrepareAndMutatePayload(Dictionary payload) properties.Merge(_options.Value.SuperProperties); + // Stamp $is_server last so a super property can't override the SDK's server/client classification. + if (_options.Value.IsServer) + { + properties[PostHogProperties.IsServer] = true; + } + payload["properties"] = properties; // Only set timestamp if one isn't already provided in properties diff --git a/src/PostHog/Api/PostHogProperties.cs b/src/PostHog/Api/PostHogProperties.cs index 9f178a1..b295765 100644 --- a/src/PostHog/Api/PostHogProperties.cs +++ b/src/PostHog/Api/PostHogProperties.cs @@ -15,6 +15,11 @@ public static class PostHogProperties /// public const string Lib = "$lib"; + /// + /// The property name indicating that the event was captured by a server-side SDK. + /// + public const string IsServer = "$is_server"; + /// /// The property name for the client library version. /// diff --git a/src/PostHog/Config/PostHogOptions.cs b/src/PostHog/Config/PostHogOptions.cs index b8c143e..c6a92e6 100644 --- a/src/PostHog/Config/PostHogOptions.cs +++ b/src/PostHog/Config/PostHogOptions.cs @@ -67,6 +67,20 @@ public string? ProjectApiKey /// public IReadOnlyList? EvaluationContexts { get; set; } + /// + /// Whether captured events are attributed to a server. (Default: true) + /// + /// + /// When true (the default), every captured event includes the $is_server property set to + /// true so PostHog identifies the event as server-side. Set this to false when using the SDK + /// as a client or CLI so the device's operating system is attributed normally instead of being treated as a + /// server. When false, the $is_server property is omitted entirely. + /// + /// var options = new PostHogOptions { ProjectToken = "phc_...", IsServer = false }; + /// + /// + public bool IsServer { get; set; } = true; + /// /// Whether this client is disabled and should no-op instead of sending data to PostHog. (Default: false) /// diff --git a/src/PostHog/PostHogClient.cs b/src/PostHog/PostHogClient.cs index 68370e1..ad9475e 100644 --- a/src/PostHog/PostHogClient.cs +++ b/src/PostHog/PostHogClient.cs @@ -313,6 +313,12 @@ bool CaptureCore( capturedEvent.Properties.Merge(_options.Value.SuperProperties); + // Stamp $is_server last so a super property can't override the SDK's server/client classification. + if (_options.Value.IsServer) + { + capturedEvent.Properties[PostHogProperties.IsServer] = true; + } + var batchItem = new BatchItem(BatchTask); if (_asyncBatchHandler.Enqueue(batchItem)) diff --git a/tests/UnitTests/Features/FeatureFlagsTests.cs b/tests/UnitTests/Features/FeatureFlagsTests.cs index b05fffa..5ce3977 100644 --- a/tests/UnitTests/Features/FeatureFlagsTests.cs +++ b/tests/UnitTests/Features/FeatureFlagsTests.cs @@ -197,7 +197,8 @@ public async Task CapturesFeatureFlagCalledEventOnlyOncePerDistinctIdFlagKeyAndR "distinct_id": "a-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:23\u002B00:00" }, @@ -215,7 +216,8 @@ public async Task CapturesFeatureFlagCalledEventOnlyOncePerDistinctIdFlagKeyAndR "distinct_id": "another-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:23\u002B00:00" }, @@ -233,7 +235,8 @@ public async Task CapturesFeatureFlagCalledEventOnlyOncePerDistinctIdFlagKeyAndR "distinct_id": "another-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:23\u002B00:00" } @@ -290,7 +293,8 @@ public async Task CapturesFeatureFlagCalledEventOnlyOncePerDistinctIdFlagKeyAndR "distinct_id": "a-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:23\u002B00:00" }, @@ -306,7 +310,8 @@ public async Task CapturesFeatureFlagCalledEventOnlyOncePerDistinctIdFlagKeyAndR "distinct_id": "another-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:23\u002B00:00" }, @@ -322,7 +327,8 @@ public async Task CapturesFeatureFlagCalledEventOnlyOncePerDistinctIdFlagKeyAndR "distinct_id": "another-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:23\u002B00:00" } @@ -498,7 +504,8 @@ await client.IsFeatureEnabledAsync( "distinct_id": "659df793-429a-4517-84ff-747dfc103e6c", "$lib": "posthog-dotnet", "$lib_version": "{{VersionConstants.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:23\u002B00:00" } @@ -549,7 +556,8 @@ public async Task CapturesFeatureFlagCalledEventWithRequestIdWhenPresent() "distinct_id": "a-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:23\u002B00:00" } @@ -601,7 +609,8 @@ public async Task CapturesFeatureFlagCalledEventWithoutEvaluatedAtWhenNotPresent "distinct_id": "a-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:23\u002B00:00" } @@ -729,7 +738,8 @@ public async Task CapturesFeatureFlagCalledEventWithAdditionalMetadataIdWhenPres "distinct_id": "a-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:23\u002B00:00" } @@ -2455,7 +2465,8 @@ public async Task CapturesFeatureFlagCalledEvent() "distinct_id": "a-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:23\u002B00:00" } @@ -2508,7 +2519,8 @@ public async Task CapturesFeatureFlagCalledEventWithGroupInformation() "$groups": { "company": "id:5", "department": "id:3" - } + }, + "$is_server": true }, "timestamp": "2024-01-21T19:08:23\u002B00:00" } @@ -2569,7 +2581,8 @@ await client.GetFeatureFlagAsync("another-flag-key", "distinct_id": "a-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:23\u002B00:00" }, @@ -2585,7 +2598,8 @@ await client.GetFeatureFlagAsync("another-flag-key", "distinct_id": "another-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:24\u002B00:00" }, @@ -2601,7 +2615,8 @@ await client.GetFeatureFlagAsync("another-flag-key", "distinct_id": "another-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:25\u002B00:00" }, @@ -2617,7 +2632,8 @@ await client.GetFeatureFlagAsync("another-flag-key", "distinct_id": "a-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:27\u002B00:00" } @@ -2673,7 +2689,8 @@ public async Task CapturesFeatureFlagCalledEventAgainIfCacheSlidingWindowExpirat "distinct_id": "a-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:23\u002B00:00" }, @@ -2689,7 +2706,8 @@ public async Task CapturesFeatureFlagCalledEventAgainIfCacheSlidingWindowExpirat "distinct_id": "another-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:24\u002B00:00" }, @@ -2705,7 +2723,8 @@ public async Task CapturesFeatureFlagCalledEventAgainIfCacheSlidingWindowExpirat "distinct_id": "another-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:25\u002B00:00" }, @@ -2721,7 +2740,8 @@ public async Task CapturesFeatureFlagCalledEventAgainIfCacheSlidingWindowExpirat "distinct_id": "a-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{client.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:27\u002B00:00" } diff --git a/tests/UnitTests/PostHogClientTests.cs b/tests/UnitTests/PostHogClientTests.cs index b2241a6..4009ae0 100644 --- a/tests/UnitTests/PostHogClientTests.cs +++ b/tests/UnitTests/PostHogClientTests.cs @@ -33,6 +33,43 @@ public async Task SendsCorrectPayload() Assert.Equal(1, result.Status); var received = requestHandler.GetReceivedRequestBody(indented: true); + Assert.Equal($$""" + { + "event": "$identify", + "distinct_id": "some-distinct-id", + "properties": { + "$lib": "posthog-dotnet", + "$lib_version": "{{VersionConstants.Version}}", + "$os": "{{RuntimeInformation.OSDescription}}", + "$framework": "{{RuntimeInformation.FrameworkDescription}}", + "$arch": "{{RuntimeInformation.ProcessArchitecture}}", + "$geoip_disable": true, + "$is_server": true + }, + "api_key": "fake-project-token", + "timestamp": "2024-01-21T19:08:23\u002B00:00" + } + """, received); + } + + [Fact] + public async Task OmitsIsServerWhenIsServerOptionIsFalse() + { + var container = new TestContainer(services => services.Configure(options => + { + options.ProjectToken = "fake-project-token"; + options.EnableCompression = false; + options.IsServer = false; + })); + container.FakeTimeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 21, 19, 08, 23, TimeSpan.Zero)); + var requestHandler = container.FakeHttpMessageHandler.AddCaptureResponse(); + var client = container.Activate(); + + var result = await client.IdentifyAsync("some-distinct-id"); + + Assert.Equal(1, result.Status); + var received = requestHandler.GetReceivedRequestBody(indented: true); + Assert.DoesNotContain("$is_server", received, StringComparison.Ordinal); Assert.Equal($$""" { "event": "$identify", @@ -73,7 +110,8 @@ public async Task SendsCorrectPayloadWithGeoIpEnabled() "$os": "{{RuntimeInformation.OSDescription}}", "$framework": "{{RuntimeInformation.FrameworkDescription}}", "$arch": "{{RuntimeInformation.ProcessArchitecture}}", - "$geoip_disable": false + "$geoip_disable": false, + "$is_server": true }, "api_key": "fake-project-token", "timestamp": "2024-01-21T19:08:23\u002B00:00" @@ -117,7 +155,8 @@ public async Task SendsCorrectPayloadWithPersonProperties() "$os": "{{RuntimeInformation.OSDescription}}", "$framework": "{{RuntimeInformation.FrameworkDescription}}", "$arch": "{{RuntimeInformation.ProcessArchitecture}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "api_key": "fake-project-token", "timestamp": "2024-01-21T19:08:23\u002B00:00" @@ -156,7 +195,8 @@ public async Task SendsCorrectPayloadWithSuperProperties() "$framework": "{{RuntimeInformation.FrameworkDescription}}", "$arch": "{{RuntimeInformation.ProcessArchitecture}}", "$geoip_disable": true, - "source": "repo-name" + "source": "repo-name", + "$is_server": true }, "api_key": "fake-project-token", "timestamp": "2024-01-21T19:08:23\u002B00:00" @@ -194,7 +234,8 @@ public async Task SendsCorrectPayload() "$os": "{{RuntimeInformation.OSDescription}}", "$framework": "{{RuntimeInformation.FrameworkDescription}}", "$arch": "{{RuntimeInformation.ProcessArchitecture}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "api_key": "fake-project-token", "timestamp": "2024-01-21T19:08:23\u002B00:00" @@ -229,7 +270,8 @@ public async Task SendsCorrectPayloadWithUserProvidedDistinctId() "$os": "{{RuntimeInformation.OSDescription}}", "$framework": "{{RuntimeInformation.FrameworkDescription}}", "$arch": "{{RuntimeInformation.ProcessArchitecture}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "api_key": "fake-project-token", "timestamp": "2024-01-21T19:08:23\u002B00:00" @@ -278,6 +320,7 @@ public async Task SendsEnrichedCapturedEventsWhenSendFeatureFlagsTrueButDoesNotM "$lib": "posthog-dotnet", "$lib_version": "{{VersionConstants.Version}}", "$geoip_disable": true, + "$is_server": true, "$feature/flag1": true, "$feature/flag2": false, "$feature/flag3": "variant-0", @@ -297,6 +340,7 @@ public async Task SendsEnrichedCapturedEventsWhenSendFeatureFlagsTrueButDoesNotM "$lib": "posthog-dotnet", "$lib_version": "{{VersionConstants.Version}}", "$geoip_disable": true, + "$is_server": true, "$feature/flag1": true, "$feature/flag2": false, "$feature/flag3": "variant-0", @@ -316,6 +360,7 @@ public async Task SendsEnrichedCapturedEventsWhenSendFeatureFlagsTrueButDoesNotM "$lib": "posthog-dotnet", "$lib_version": "{{VersionConstants.Version}}", "$geoip_disable": true, + "$is_server": true, "$feature/flag1": true, "$feature/flag2": false, "$feature/flag3": "variant-1", @@ -335,6 +380,7 @@ public async Task SendsEnrichedCapturedEventsWhenSendFeatureFlagsTrueButDoesNotM "$lib": "posthog-dotnet", "$lib_version": "{{VersionConstants.Version}}", "$geoip_disable": true, + "$is_server": true, "$feature/flag1": true, "$feature/flag2": false, "$feature/flag3": "variant-0", @@ -354,6 +400,7 @@ public async Task SendsEnrichedCapturedEventsWhenSendFeatureFlagsTrueButDoesNotM "$lib": "posthog-dotnet", "$lib_version": "{{VersionConstants.Version}}", "$geoip_disable": true, + "$is_server": true, "$feature/flag1": true, "$feature/flag2": false, "$feature/flag3": "variant-0", @@ -373,6 +420,7 @@ public async Task SendsEnrichedCapturedEventsWhenSendFeatureFlagsTrueButDoesNotM "$lib": "posthog-dotnet", "$lib_version": "{{VersionConstants.Version}}", "$geoip_disable": true, + "$is_server": true, "$feature/flag1": true, "$feature/flag2": false, "$feature/flag3": "variant-1", @@ -392,6 +440,7 @@ public async Task SendsEnrichedCapturedEventsWhenSendFeatureFlagsTrueButDoesNotM "$lib": "posthog-dotnet", "$lib_version": "{{VersionConstants.Version}}", "$geoip_disable": true, + "$is_server": true, "$feature/flag1": true, "$feature/flag2": false, "$feature/flag3": "variant-2", @@ -435,7 +484,8 @@ public async Task CaptureWithCustomTimestampUsesProvidedTimestamp() "distinct_id": "test-user", "$lib": "posthog-dotnet", "$lib_version": "{{VersionConstants.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2023-12-25T10:30:45\u002B00:00" } @@ -473,7 +523,8 @@ public async Task CaptureWithTimestampAndPropertiesUsesProvidedTimestamp() "distinct_id": "test-user", "$lib": "posthog-dotnet", "$lib_version": "{{VersionConstants.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2023-12-25T10:30:45\u002B00:00" } @@ -513,7 +564,8 @@ public async Task CaptureWithTimestampAndGroupsUsesProvidedTimestamp() "$geoip_disable": true, "$groups": { "company": "acme-corp" - } + }, + "$is_server": true }, "timestamp": "2023-12-25T10:30:45\u002B00:00" } @@ -551,6 +603,7 @@ public async Task CaptureWithTimestampAndFeatureFlagsUsesProvidedTimestamp() "$lib": "posthog-dotnet", "$lib_version": "{{VersionConstants.Version}}", "$geoip_disable": true, + "$is_server": true, "$feature/test-flag": true, "$active_feature_flags": [ "test-flag" @@ -596,7 +649,8 @@ public async Task CaptureWithTimestampPropertiesAndGroupsUsesProvidedTimestamp() "$geoip_disable": true, "$groups": { "company": "acme-corp" - } + }, + "$is_server": true }, "timestamp": "2023-12-25T10:30:45\u002B00:00" } @@ -640,6 +694,7 @@ public async Task CaptureWithAllParametersAndTimestampUsesProvidedTimestamp() "$groups": { "company": "acme-corp" }, + "$is_server": true, "$feature/test-flag": true, "$active_feature_flags": [ "test-flag" @@ -686,7 +741,8 @@ public async Task CaptureWithTimestampParameterOverridesTimestampInProperties() "distinct_id": "test-user", "$lib": "posthog-dotnet", "$lib_version": "{{VersionConstants.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2023-12-25T10:30:45\u002B00:00" } @@ -762,6 +818,7 @@ public async Task CaptureWithSendFeatureFlagsTrueAndLocalEvaluationEnabledUsesLo "$lib": "posthog-dotnet", "$lib_version": "{{VersionConstants.Version}}", "$geoip_disable": true, + "$is_server": true, "$feature/local-flag": true, "$feature/another-local-flag": true, "$active_feature_flags": [ @@ -828,7 +885,8 @@ public async Task CaptureWithSendFeatureFlagsFalseDoesNotAddFeatureFlagsEvenWhen "distinct_id": "test-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{VersionConstants.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:23\u002B00:00" } @@ -890,7 +948,8 @@ public async Task CaptureDefaultsToNotSendingFeatureFlagsEvenWhenLocalEvaluation "distinct_id": "test-distinct-id", "$lib": "posthog-dotnet", "$lib_version": "{{VersionConstants.Version}}", - "$geoip_disable": true + "$geoip_disable": true, + "$is_server": true }, "timestamp": "2024-01-21T19:08:23\u002B00:00" } @@ -925,6 +984,7 @@ public async Task CaptureExceptionWithDivideByZeroException() // based on PostHo Assert.Equal("System.DivideByZeroException", props.GetProperty("$exception_type").GetString()); Assert.Contains("divide by zero", props.GetProperty("$exception_message").GetString(), StringComparison.OrdinalIgnoreCase); Assert.Equal("posthog-dotnet", props.GetProperty("$lib").GetString()); + Assert.True(props.GetProperty("$is_server").GetBoolean()); Assert.Equal(VersionConstants.Version, props.GetProperty("$lib_version").GetString()); var firstException = GetFirstException(props);