Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/bright-owls-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"PostHog": minor
"PostHog.AspNetCore": minor
---

Add a before send callback for modifying or dropping fully enriched events.
7 changes: 7 additions & 0 deletions src/PostHog/Config/PostHogOptions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Options;
using PostHog.Api;
using PostHog.Library;

namespace PostHog;
Expand Down Expand Up @@ -101,6 +102,12 @@ public string? ProjectApiKey
/// </summary>
public Dictionary<string, object> SuperProperties { get; init; } = new();

/// <summary>
/// Optional callback invoked after an event is fully enriched and before it is serialized for upload.
/// Return the event (mutated or unchanged) to continue, or <c>null</c> to drop it.
/// </summary>
public Func<CapturedEvent, CapturedEvent?>? BeforeSend { get; set; }

/// <summary>
/// When <see cref="PersonalApiKey"/> is set, this is the interval to poll for feature flags used in
/// local evaluation. Default is 30 seconds.
Expand Down
92 changes: 80 additions & 12 deletions src/PostHog/PostHogClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public PostHogClient(
loggerFactory.CreateLogger<PostHogApiClient>()
);
_asyncBatchHandler = new AsyncBatchHandler<CapturedEvent, CapturedEventBatchContext>(
batch => _apiClient.CaptureBatchAsync(batch, CancellationToken.None),
CaptureBatchAsync,
batchContextFunc: () => new CapturedEventBatchContext(
new FallbackFeatureFlagCache(
new MemoryFeatureFlagCache(_timeProvider, 10000, 0.2),
Expand Down Expand Up @@ -331,27 +331,83 @@ bool CaptureCore(
_logger.LogWarnCaptureFailed(eventName, capturedEvent.Properties.Count, _asyncBatchHandler.Count);
return false;

Task<CapturedEvent> BatchTask(CapturedEventBatchContext context)
async Task<CapturedEvent> BatchTask(CapturedEventBatchContext context)
{
CapturedEvent enrichedEvent;
if (flags is not null)
{
AddFeatureFlagsToCapturedEvent(capturedEvent, flags);
return Task.FromResult(capturedEvent);
enrichedEvent = AddFeatureFlagsToCapturedEvent(capturedEvent, flags);
}

if (!sendFeatureFlags)
else if (!sendFeatureFlags)
{
enrichedEvent = capturedEvent;
}
else if (_featureFlagsLoader.IsLoaded)
{
// Prefer local evaluation when available
enrichedEvent = await AddLocalFeatureFlagDataAsync(captureContext.DistinctId, groups, capturedEvent);
}
else
{
return Task.FromResult(capturedEvent);
// Otherwise we fall back to remote /flags call
enrichedEvent = await AddFreshFeatureFlagDataAsync(
context.FeatureFlagCache,
captureContext.DistinctId,
groups,
capturedEvent);
}

// Prefer local evaluation when available
if (_featureFlagsLoader.IsLoaded)
return enrichedEvent;
}
}

async Task CaptureBatchAsync(IEnumerable<CapturedEvent> batch)
{
var beforeSend = _options.Value.BeforeSend;
if (beforeSend is null)
{
await _apiClient.CaptureBatchAsync(batch, CancellationToken.None);
return;
}

var events = batch
.Select(ApplyBeforeSend)
.Where(capturedEvent => capturedEvent is not null)
.Select(capturedEvent => capturedEvent!)
.ToArray();

if (events.Length is 0)
{
return;
}

await _apiClient.CaptureBatchAsync(events, CancellationToken.None);
}

CapturedEvent? ApplyBeforeSend(CapturedEvent capturedEvent)
{
var beforeSend = _options.Value.BeforeSend;
if (beforeSend is null)
{
return capturedEvent;
}

try
{
var result = beforeSend(capturedEvent);
if (result is null)
{
return AddLocalFeatureFlagDataAsync(captureContext.DistinctId, groups, capturedEvent);
_logger.LogDebugBeforeSendDropped(capturedEvent.EventName);
}

// Otherwise we fall back to remote /flags call
return AddFreshFeatureFlagDataAsync(context.FeatureFlagCache, captureContext.DistinctId, groups, capturedEvent);
return result;
}
#pragma warning disable CA1031 // Customer callbacks can throw any exception; drop just this event.
catch (Exception ex)
#pragma warning restore CA1031
{
_logger.LogErrorBeforeSendException(ex, capturedEvent.EventName);
return null;
}
}

Expand Down Expand Up @@ -1435,4 +1491,16 @@ public static partial void LogErrorUnableToGetRemoteConfigPayload(
Level = LogLevel.Error,
Message = "PostHog API call failed in {MethodName}; returning a no-op result.")]
public static partial void LogErrorApiCallFailed(this ILogger<PostHogClient> logger, Exception exception, string methodName);

[LoggerMessage(
EventId = 25,
Level = LogLevel.Debug,
Message = "Event {EventName} was dropped by the before send callback")]
public static partial void LogDebugBeforeSendDropped(this ILogger<PostHogClient> logger, string eventName);

[LoggerMessage(
EventId = 26,
Level = LogLevel.Error,
Message = "Error in before send callback for event {EventName}; dropping event")]
public static partial void LogErrorBeforeSendException(this ILogger<PostHogClient> logger, Exception exception, string eventName);
}
2 changes: 2 additions & 0 deletions src/PostHog/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#nullable enable
PostHog.AllFeatureFlagsOptions.DisableGeoIp.get -> bool
PostHog.AllFeatureFlagsOptions.DisableGeoIp.init -> void
PostHog.PostHogOptions.BeforeSend.get -> System.Func<PostHog.Api.CapturedEvent!, PostHog.Api.CapturedEvent?>?
PostHog.PostHogOptions.BeforeSend.set -> void
PostHog.PostHogOptions.FeatureFlagRequestMaxRetries.get -> int
PostHog.PostHogOptions.FeatureFlagRequestMaxRetries.set -> void
58 changes: 58 additions & 0 deletions tests/UnitTests/PostHogClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,64 @@ static JsonElement GetOnlyBatchItem(FakeHttpMessageHandler.RequestHandler batchH

public class TheCaptureMethod
{
[Fact]
public async Task BeforeSendCanModifyFullyEnrichedEventBeforeUpload()
{
var sawFullyEnrichedEvent = false;
var container = new TestContainer(services => services.Configure<PostHogOptions>(options =>
{
options.SuperProperties["source"] = "super";
options.BeforeSend = capturedEvent =>
{
sawFullyEnrichedEvent = capturedEvent.Properties.ContainsKey("$lib")
&& capturedEvent.Properties.ContainsKey("$lib_version")
&& capturedEvent.Properties.ContainsKey("$is_server")
&& capturedEvent.Properties.TryGetValue("source", out var source)
&& (string)source == "super";
capturedEvent.Properties.Remove("secret");
capturedEvent.Properties["before_send"] = true;
return capturedEvent;
};
}));
var requestHandler = container.FakeHttpMessageHandler.AddBatchResponse();
var client = container.Activate<PostHogClient>();

client.Capture(
"test-user",
"before-send-event",
new Dictionary<string, object> { ["secret"] = "remove-me" });
await client.FlushAsync();

using var document = JsonDocument.Parse(requestHandler.GetReceivedRequestBody(indented: false));
var properties = document.RootElement
.GetProperty("batch")[0]
.GetProperty("properties");

Assert.True(sawFullyEnrichedEvent);
Assert.True(properties.GetProperty("before_send").GetBoolean());
Assert.False(properties.TryGetProperty("secret", out _));
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task BeforeSendCanDropEventBeforeUpload(bool throws)
{
var container = new TestContainer(services => services.Configure<PostHogOptions>(options =>
{
options.BeforeSend = throws
? _ => throw new InvalidOperationException("before send failed")
: _ => null;
}));
var requestHandler = container.FakeHttpMessageHandler.AddBatchResponse();
var client = container.Activate<PostHogClient>();

Assert.True(client.Capture("test-user", "drop-me"));
await client.FlushAsync();

Assert.Empty(requestHandler.ReceivedRequests);
}

[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
Expand Down
Loading