Skip to content

Commit d543f28

Browse files
authored
feat: add before send callback (#260)
* feat: add before send callback * test: cover before send edge cases * fix: keep before send out of batch handler * chore: fix before send changeset packages * fix: avoid logger event id collision
1 parent 1d74b32 commit d543f28

5 files changed

Lines changed: 153 additions & 12 deletions

File tree

.changeset/bright-owls-filter.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"PostHog": minor
3+
"PostHog.AspNetCore": minor
4+
---
5+
6+
Add a before send callback for modifying or dropping fully enriched events.

src/PostHog/Config/PostHogOptions.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Microsoft.Extensions.Options;
2+
using PostHog.Api;
23
using PostHog.Library;
34

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

105+
/// <summary>
106+
/// Optional callback invoked after an event is fully enriched and before it is serialized for upload.
107+
/// Return the event (mutated or unchanged) to continue, or <c>null</c> to drop it.
108+
/// </summary>
109+
public Func<CapturedEvent, CapturedEvent?>? BeforeSend { get; set; }
110+
104111
/// <summary>
105112
/// When <see cref="PersonalApiKey"/> is set, this is the interval to poll for feature flags used in
106113
/// local evaluation. Default is 30 seconds.

src/PostHog/PostHogClient.cs

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public PostHogClient(
6868
loggerFactory.CreateLogger<PostHogApiClient>()
6969
);
7070
_asyncBatchHandler = new AsyncBatchHandler<CapturedEvent, CapturedEventBatchContext>(
71-
batch => _apiClient.CaptureBatchAsync(batch, CancellationToken.None),
71+
CaptureBatchAsync,
7272
batchContextFunc: () => new CapturedEventBatchContext(
7373
new FallbackFeatureFlagCache(
7474
new MemoryFeatureFlagCache(_timeProvider, 10000, 0.2),
@@ -331,27 +331,83 @@ bool CaptureCore(
331331
_logger.LogWarnCaptureFailed(eventName, capturedEvent.Properties.Count, _asyncBatchHandler.Count);
332332
return false;
333333

334-
Task<CapturedEvent> BatchTask(CapturedEventBatchContext context)
334+
async Task<CapturedEvent> BatchTask(CapturedEventBatchContext context)
335335
{
336+
CapturedEvent enrichedEvent;
336337
if (flags is not null)
337338
{
338-
AddFeatureFlagsToCapturedEvent(capturedEvent, flags);
339-
return Task.FromResult(capturedEvent);
339+
enrichedEvent = AddFeatureFlagsToCapturedEvent(capturedEvent, flags);
340340
}
341-
342-
if (!sendFeatureFlags)
341+
else if (!sendFeatureFlags)
342+
{
343+
enrichedEvent = capturedEvent;
344+
}
345+
else if (_featureFlagsLoader.IsLoaded)
346+
{
347+
// Prefer local evaluation when available
348+
enrichedEvent = await AddLocalFeatureFlagDataAsync(captureContext.DistinctId, groups, capturedEvent);
349+
}
350+
else
343351
{
344-
return Task.FromResult(capturedEvent);
352+
// Otherwise we fall back to remote /flags call
353+
enrichedEvent = await AddFreshFeatureFlagDataAsync(
354+
context.FeatureFlagCache,
355+
captureContext.DistinctId,
356+
groups,
357+
capturedEvent);
345358
}
346359

347-
// Prefer local evaluation when available
348-
if (_featureFlagsLoader.IsLoaded)
360+
return enrichedEvent;
361+
}
362+
}
363+
364+
async Task CaptureBatchAsync(IEnumerable<CapturedEvent> batch)
365+
{
366+
var beforeSend = _options.Value.BeforeSend;
367+
if (beforeSend is null)
368+
{
369+
await _apiClient.CaptureBatchAsync(batch, CancellationToken.None);
370+
return;
371+
}
372+
373+
var events = batch
374+
.Select(ApplyBeforeSend)
375+
.Where(capturedEvent => capturedEvent is not null)
376+
.Select(capturedEvent => capturedEvent!)
377+
.ToArray();
378+
379+
if (events.Length is 0)
380+
{
381+
return;
382+
}
383+
384+
await _apiClient.CaptureBatchAsync(events, CancellationToken.None);
385+
}
386+
387+
CapturedEvent? ApplyBeforeSend(CapturedEvent capturedEvent)
388+
{
389+
var beforeSend = _options.Value.BeforeSend;
390+
if (beforeSend is null)
391+
{
392+
return capturedEvent;
393+
}
394+
395+
try
396+
{
397+
var result = beforeSend(capturedEvent);
398+
if (result is null)
349399
{
350-
return AddLocalFeatureFlagDataAsync(captureContext.DistinctId, groups, capturedEvent);
400+
_logger.LogDebugBeforeSendDropped(capturedEvent.EventName);
351401
}
352402

353-
// Otherwise we fall back to remote /flags call
354-
return AddFreshFeatureFlagDataAsync(context.FeatureFlagCache, captureContext.DistinctId, groups, capturedEvent);
403+
return result;
404+
}
405+
#pragma warning disable CA1031 // Customer callbacks can throw any exception; drop just this event.
406+
catch (Exception ex)
407+
#pragma warning restore CA1031
408+
{
409+
_logger.LogErrorBeforeSendException(ex, capturedEvent.EventName);
410+
return null;
355411
}
356412
}
357413

@@ -1435,4 +1491,16 @@ public static partial void LogErrorUnableToGetRemoteConfigPayload(
14351491
Level = LogLevel.Error,
14361492
Message = "PostHog API call failed in {MethodName}; returning a no-op result.")]
14371493
public static partial void LogErrorApiCallFailed(this ILogger<PostHogClient> logger, Exception exception, string methodName);
1494+
1495+
[LoggerMessage(
1496+
EventId = 25,
1497+
Level = LogLevel.Debug,
1498+
Message = "Event {EventName} was dropped by the before send callback")]
1499+
public static partial void LogDebugBeforeSendDropped(this ILogger<PostHogClient> logger, string eventName);
1500+
1501+
[LoggerMessage(
1502+
EventId = 26,
1503+
Level = LogLevel.Error,
1504+
Message = "Error in before send callback for event {EventName}; dropping event")]
1505+
public static partial void LogErrorBeforeSendException(this ILogger<PostHogClient> logger, Exception exception, string eventName);
14381506
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#nullable enable
22
PostHog.AllFeatureFlagsOptions.DisableGeoIp.get -> bool
33
PostHog.AllFeatureFlagsOptions.DisableGeoIp.init -> void
4+
PostHog.PostHogOptions.BeforeSend.get -> System.Func<PostHog.Api.CapturedEvent!, PostHog.Api.CapturedEvent?>?
5+
PostHog.PostHogOptions.BeforeSend.set -> void
46
PostHog.PostHogOptions.FeatureFlagRequestMaxRetries.get -> int
57
PostHog.PostHogOptions.FeatureFlagRequestMaxRetries.set -> void

tests/UnitTests/PostHogClientTests.cs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,64 @@ static JsonElement GetOnlyBatchItem(FakeHttpMessageHandler.RequestHandler batchH
415415

416416
public class TheCaptureMethod
417417
{
418+
[Fact]
419+
public async Task BeforeSendCanModifyFullyEnrichedEventBeforeUpload()
420+
{
421+
var sawFullyEnrichedEvent = false;
422+
var container = new TestContainer(services => services.Configure<PostHogOptions>(options =>
423+
{
424+
options.SuperProperties["source"] = "super";
425+
options.BeforeSend = capturedEvent =>
426+
{
427+
sawFullyEnrichedEvent = capturedEvent.Properties.ContainsKey("$lib")
428+
&& capturedEvent.Properties.ContainsKey("$lib_version")
429+
&& capturedEvent.Properties.ContainsKey("$is_server")
430+
&& capturedEvent.Properties.TryGetValue("source", out var source)
431+
&& (string)source == "super";
432+
capturedEvent.Properties.Remove("secret");
433+
capturedEvent.Properties["before_send"] = true;
434+
return capturedEvent;
435+
};
436+
}));
437+
var requestHandler = container.FakeHttpMessageHandler.AddBatchResponse();
438+
var client = container.Activate<PostHogClient>();
439+
440+
client.Capture(
441+
"test-user",
442+
"before-send-event",
443+
new Dictionary<string, object> { ["secret"] = "remove-me" });
444+
await client.FlushAsync();
445+
446+
using var document = JsonDocument.Parse(requestHandler.GetReceivedRequestBody(indented: false));
447+
var properties = document.RootElement
448+
.GetProperty("batch")[0]
449+
.GetProperty("properties");
450+
451+
Assert.True(sawFullyEnrichedEvent);
452+
Assert.True(properties.GetProperty("before_send").GetBoolean());
453+
Assert.False(properties.TryGetProperty("secret", out _));
454+
}
455+
456+
[Theory]
457+
[InlineData(false)]
458+
[InlineData(true)]
459+
public async Task BeforeSendCanDropEventBeforeUpload(bool throws)
460+
{
461+
var container = new TestContainer(services => services.Configure<PostHogOptions>(options =>
462+
{
463+
options.BeforeSend = throws
464+
? _ => throw new InvalidOperationException("before send failed")
465+
: _ => null;
466+
}));
467+
var requestHandler = container.FakeHttpMessageHandler.AddBatchResponse();
468+
var client = container.Activate<PostHogClient>();
469+
470+
Assert.True(client.Capture("test-user", "drop-me"));
471+
await client.FlushAsync();
472+
473+
Assert.Empty(requestHandler.ReceivedRequests);
474+
}
475+
418476
[Theory]
419477
[InlineData(true, false)]
420478
[InlineData(false, true)]

0 commit comments

Comments
 (0)