diff --git a/Analytics-CSharp/Analytics-CSharp.csproj b/Analytics-CSharp/Analytics-CSharp.csproj
index 00c904c..5485aa4 100644
--- a/Analytics-CSharp/Analytics-CSharp.csproj
+++ b/Analytics-CSharp/Analytics-CSharp.csproj
@@ -10,7 +10,7 @@
Segment, Inc
The hassle-free way to add analytics to your C# app.
- 2.5.0
+ 2.5.2
MIT
https://github.com/segmentio/Analytics-CSharp
git
diff --git a/Analytics-CSharp/Segment/Analytics/Analytics.cs b/Analytics-CSharp/Segment/Analytics/Analytics.cs
index 17d9f9b..1ed41d9 100644
--- a/Analytics-CSharp/Segment/Analytics/Analytics.cs
+++ b/Analytics-CSharp/Segment/Analytics/Analytics.cs
@@ -86,10 +86,10 @@ public void Process(RawEvent incomingEvent, Func enrichment
{
if (!Enable) return;
- incomingEvent.ApplyRawEventData(_userInfo);
+ incomingEvent.ApplyRawEventData(_userInfo, enrichment);
AnalyticsScope.Launch(AnalyticsDispatcher, () =>
{
- Timeline.Process(incomingEvent, enrichment);
+ Timeline.Process(incomingEvent);
});
}
diff --git a/Analytics-CSharp/Segment/Analytics/Plugins/StartupQueue.cs b/Analytics-CSharp/Segment/Analytics/Plugins/StartupQueue.cs
index 6e69655..fe6dc9e 100644
--- a/Analytics-CSharp/Segment/Analytics/Plugins/StartupQueue.cs
+++ b/Analytics-CSharp/Segment/Analytics/Plugins/StartupQueue.cs
@@ -58,7 +58,7 @@ private void ReplayEvents()
{
if (_queuedEvents.TryDequeue(out RawEvent e))
{
- Analytics.Process(e);
+ Analytics.Process(e, e.Enrichment);
}
}
}
diff --git a/Analytics-CSharp/Segment/Analytics/Timeline.cs b/Analytics-CSharp/Segment/Analytics/Timeline.cs
index e0f7e70..ccfe86c 100644
--- a/Analytics-CSharp/Segment/Analytics/Timeline.cs
+++ b/Analytics-CSharp/Segment/Analytics/Timeline.cs
@@ -30,15 +30,15 @@ public class Timeline
/// event to be processed
/// a closure that enables enrichment on the generated event
/// event after processing
- internal RawEvent Process(RawEvent incomingEvent, Func enrichment = default)
+ internal RawEvent Process(RawEvent incomingEvent)
{
// Apply before and enrichment types first to start the timeline processing.
RawEvent beforeResult = ApplyPlugins(PluginType.Before, incomingEvent);
// Enrichment is like middleware, a chance to update the event across the board before going to destinations.
RawEvent enrichmentResult = ApplyPlugins(PluginType.Enrichment, beforeResult);
- if (enrichment != null)
+ if (enrichmentResult != null && enrichmentResult.Enrichment != null)
{
- enrichmentResult = enrichment(enrichmentResult);
+ enrichmentResult = enrichmentResult.Enrichment(enrichmentResult);
}
// Make sure not to update the events during this next cycle. Since each destination may want different
diff --git a/Analytics-CSharp/Segment/Analytics/Types.cs b/Analytics-CSharp/Segment/Analytics/Types.cs
index ae8baae..5a67838 100644
--- a/Analytics-CSharp/Segment/Analytics/Types.cs
+++ b/Analytics-CSharp/Segment/Analytics/Types.cs
@@ -18,6 +18,8 @@ public abstract class RawEvent
public virtual string UserId { get; set; }
public virtual string Timestamp { get; set; }
+ public Func Enrichment { get; set; }
+
// JSON types
public JsonObject Context { get; set; }
public JsonObject Integrations { get; set; }
@@ -36,8 +38,9 @@ internal void ApplyRawEventData(RawEvent rawEvent)
Integrations = rawEvent.Integrations;
}
- internal void ApplyRawEventData(UserInfo userInfo)
+ internal void ApplyRawEventData(UserInfo userInfo, Func enrichment)
{
+ Enrichment = enrichment;
MessageId = Guid.NewGuid().ToString();
Context = new JsonObject();
Timestamp = DateTime.UtcNow.ToString("o"); // iso8601
diff --git a/Analytics-CSharp/Segment/Analytics/Version.cs b/Analytics-CSharp/Segment/Analytics/Version.cs
index d637cf4..ba62e54 100644
--- a/Analytics-CSharp/Segment/Analytics/Version.cs
+++ b/Analytics-CSharp/Segment/Analytics/Version.cs
@@ -2,6 +2,6 @@ namespace Segment.Analytics
{
internal static class Version
{
- internal const string SegmentVersion = "2.5.0";
+ internal const string SegmentVersion = "2.5.2";
}
}
diff --git a/README.md b/README.md
index 8616a67..cc777ef 100644
--- a/README.md
+++ b/README.md
@@ -14,12 +14,14 @@ This library is one of Segment’s most popular Flagship libraries. It is active
- [Plugin Architecture](#plugin-architecture)
- [Adding a plugin](#adding-a-plugin)
- [Utility Methods](#utility-methods)
+ - [Enrichment Closure](#enrichment-closure)
- [Controlling Upload With Flush Policies](#controlling-upload-with-flush-policies)
- [Handling Errors](#handling-errors)
- [Customize HTTP Client](#customize-http-client)
- [Customize Storage](#customize-storage)
- [Json Library](#json-library)
- [Samples](#samples)
+ - [FAQs](#faqs)
- [Compatibility](#compatibility)
- [Changelog](#changelog)
- [Contributing](#contributing)
@@ -90,6 +92,7 @@ To get started with the Analytics-CSharp library:
| `storageProvider` | Default set to `DefaultStorageProvider`.
This set how you want your data to be stored.
`DefaultStorageProvider` is used by default which stores data to local storage. `InMemoryStorageProvider` is also provided in the library.
You can also write your own storage solution by implementing `IStorageProvider` and `IStorage` |
| `httpClientProvider` | Default set to `DefaultHTTPClientProvider`.
This set a http client provider for analytics use to do network activities. The default provider uses System.Net.Http for network activities. |
| `flushPolicies` | Default set to `null`.
This set custom flush policies to tell analytics when and how to flush. By default, it converts `flushAt` and `flushInterval` to `CountFlushPolicy` and `FrequencyFlushPolicy`. If a value is given, it overwrites `flushAt` and `flushInterval`. |
+| `eventPipelineProvider` | The default is `EventPipelineProvider`.
This sets a custom event pipeline to define how Analytics handles events. The default `EventPipelineProvider` processes events asynchronously. Use `SyncEventPipelineProvider` to make manual flush operations synchronous. |
## Tracking Methods
@@ -361,6 +364,22 @@ The `reset` method clears the SDK’s internal stores for the current user and g
analytics.Reset()
```
+## Enrichment Closure
+To modify the properties of an event, you can either write an enrichment plugin that applies changes to all events, or pass an enrichment closure to the analytics call to apply changes to a specific event.
+
+```c#
+ analytics.Track("MyEvent", properties, @event =>
+ {
+ if (@event is TrackEvent trackEvent)
+ {
+ // update properties of this event
+ trackEvent.UserId = "foo";
+ }
+
+ return @event;
+ });
+```
+
## Controlling Upload With Flush Policies
To more granularly control when events are uploaded you can use `FlushPolicies`. **This will override any setting on `flushAt` and `flushInterval`, but you can use `CountFlushPolicy` and `FrequencyFlushPolicy` to have the same behaviour respectively.**
@@ -584,6 +603,92 @@ For sample usages of the SDK in specific platforms, checkout the following:
| | [Custom Logger](https://github.com/segmentio/Analytics-CSharp/tree/main/Samples/ConsoleSample/SegmentLogger.cs) |
| | [Custom Error Handler](https://github.com/segmentio/Analytics-CSharp/tree/main/Samples/ConsoleSample/NetworkErrorHandler.cs) |
+## FAQs
+
+### Should I make Analytics a singleton or scoped in .NET?
+
+The SDK supports both, but be aware of the implications of choosing one over the other:
+
+| Feature | Singleton | Scoped |
+|--|--|--|
+| **Fetch Settings** | Settings are fetched only once at application startup. | Settings are fetched on every request. |
+| **Flush** | Supports both async and sync flush. | Requires sync flush. Should flush per event or on page redirect/close to avoid data loss. |
+| **Internal State** | The internal state (`userId`, `anonId`, etc.) is shared across sessions and cannot be used. (*This is an overhead we are working to minimize*.) | The internal state is safe to use since a new instance is created per request. |
+| **UserId for Events** | Requires adding `UserIdPlugin` and calling analytics APIs with `userId` to associate the correct `userId` with events. | No need for `UserIdPlugin` or passing `userId` in API calls. Instead, call `analytics.Identify()` to update the internal state with the `userId`. Successive events are auto-stamped with that `userId`. |
+| **Storage** | Supports both local storage and in-memory storage. | Requires in-memory storage. (*Support for local storage is in progress*.) |
+
+
+In a nutshell, to register Analytics as singleton:
+
+```c#
+var configuration = new Configuration(
+ writeKey: "YOUR_WRITE_KEY",
+ // Use in-memory storage to keep the SDK stateless.
+ // The default storage also works if you want to persist events.
+ storageProvider: new InMemoryStorageProvider(),
+ // Use a synchronous pipeline to make manual flush operations synchronized.
+ eventPipelineProvider: new SyncEventPipelineProvider()
+);
+
+var analytics = new Analytics(configuration);
+
+// Add UserIdPlugin to associate events with the provided userId.
+analytics.Add(new UserIdPlugin());
+
+// Call analytics APIs with a userId. The UserIdPlugin will update the event with the provided userId.
+analytics.Track("user123", "foo", properties);
+
+// This is a blocking call due to SyncEventPipelineProvider.
+// Use the default EventPipelineProvider for asynchronous flush.
+analytics.Flush();
+
+// Register Analytics as a singleton.
+```
+
+To register Analytics as scoped:
+
+```c#
+var configuration = new Configuration(
+ writeKey: "YOUR_WRITE_KEY",
+ // Requires in-memory storage.
+ storageProvider: new InMemoryStorageProvider(),
+ // Flush per event to prevent data loss in case of a page close.
+ // Alternatively, manually flush on page close.
+ flushAt: 1,
+ // Requires a synchronous flush.
+ eventPipelineProvider: new SyncEventPipelineProvider()
+);
+
+var analytics = new Analytics(configuration);
+
+// Update the internal state with a userId.
+analytics.Identify("user123");
+
+// Subsequent events are auto-stamped with the userId from the internal state.
+analytics.Track("foo", properties);
+
+// This is a blocking call due to SyncEventPipelineProvider.
+analytics.Flush();
+
+// Register Analytics as scoped.
+```
+
+### Which JSON library does this SDK use?
+
+The SDK supports `.netstandard 1.3` and `.netstandard 2.0` and automatically selects the internal JSON library based on the target framework:
+
+* In `.netstandard 1.3`, the SDK uses `Newtonsoft Json.NET`
+* In `.netstandard 2.0`, the SDK uses `System.Text.Json`
+
+Be ware that both Analytics.NET and Analytics.Xamarin use `Newtonsoft Json.NET`. If you encounter issues where JSON dictionary values are turned into empty arrays, it is likely that:
+
+1. You are targeting `.netstandard 2.0`.
+2. Your properties use`Newtonsoft Json.NET` objects or arrays.
+
+To resolve this, you can:
+* Option 1: Target `.netstandard 1.3`
+* Option 2: Upgrade your JSON library to `System.Text.Json`
+
## Compatibility
This library targets `.NET Standard 1.3` and `.NET Standard 2.0`. Checkout [here](https://www.nuget.org/packages/Segment.Analytics.CSharp/#supportedframeworks-body-tab) for compatible platforms.
diff --git a/Tests/EventsTest.cs b/Tests/EventsTest.cs
index 25e9387..86dac8e 100644
--- a/Tests/EventsTest.cs
+++ b/Tests/EventsTest.cs
@@ -1,5 +1,6 @@
using System.Collections.Generic;
-using System.Threading.Tasks;
+using System.Diagnostics;
+using System.Threading;
using Moq;
using Segment.Analytics;
using Segment.Analytics.Utilities;
@@ -690,4 +691,260 @@ public void TestAliasEnrichment()
Assert.Equal("test", actual[0].AnonymousId);
}
}
+
+ public class DelayedEventsTest
+ {
+ private readonly Analytics _analytics;
+
+ private Settings? _settings;
+
+ private readonly Mock _plugin;
+
+ private readonly Mock _afterPlugin;
+
+ private readonly SemaphoreSlim _httpSemaphore;
+ private readonly SemaphoreSlim _assertSemaphore;
+ private readonly List _actual;
+
+ public DelayedEventsTest()
+ {
+ _httpSemaphore = new SemaphoreSlim(0);
+ _assertSemaphore = new SemaphoreSlim(0);
+ _settings = JsonUtility.FromJson(
+ "{\"integrations\":{\"Segment.io\":{\"apiKey\":\"1vNgUqwJeCHmqgI9S1sOm9UHCyfYqbaQ\"}},\"plan\":{},\"edgeFunction\":{}}");
+
+ var mockHttpClient = new Mock(null, null, null);
+ mockHttpClient
+ .Setup(httpClient => httpClient.Settings())
+ .Returns(async () =>
+ {
+ // suspend http calls until we tracked events
+ // this will force events get into startup queue
+ await _httpSemaphore.WaitAsync();
+ return _settings;
+ });
+
+ _plugin = new Mock
+ {
+ CallBase = true
+ };
+
+ _afterPlugin = new Mock { CallBase = true };
+ _actual = new List();
+ _afterPlugin.Setup(o => o.Execute(Capture.In(_actual)))
+ .Returns((RawEvent e) =>
+ {
+ // since this is an after plugin, when its execute function is called,
+ // it is guaranteed that the enrichment closure has been called.
+ // so we can release the semaphore on assertions.
+ _assertSemaphore.Release();
+ return e;
+ });
+
+ var config = new Configuration(
+ writeKey: "123",
+ storageProvider: new DefaultStorageProvider("tests"),
+ autoAddSegmentDestination: false,
+ useSynchronizeDispatcher: false, // we need async analytics to buildup events on start queue
+ httpClientProvider: new MockHttpClientProvider(mockHttpClient)
+ );
+ _analytics = new Analytics(config);
+ }
+
+ [Fact]
+ public void TestTrackEnrichment()
+ {
+ string expectedEvent = "foo";
+ string expectedAnonymousId = "bar";
+
+ _analytics.Add(_afterPlugin.Object);
+ _analytics.Track(expectedEvent, enrichment: @event =>
+ {
+ @event.AnonymousId = expectedAnonymousId;
+ return @event;
+ });
+
+ // now we have tracked event, i.e. event added to startup queue
+ // release the semaphore put on http client, so we startup queue will replay the events
+ _httpSemaphore.Release();
+ // now we need to wait for events being fully replayed before making assertions
+ _assertSemaphore.Wait();
+
+ Assert.NotEmpty(_actual);
+ Assert.IsType(_actual[0]);
+ var actual = _actual[0] as TrackEvent;
+ Debug.Assert(actual != null, nameof(actual) + " != null");
+ Assert.True(actual.Properties.Count == 0);
+ Assert.Equal(expectedEvent, actual.Event);
+ Assert.Equal(expectedAnonymousId, actual.AnonymousId);
+ }
+
+ [Fact]
+ public void TestIdentifyEnrichment()
+ {
+ var expected = new JsonObject
+ {
+ ["foo"] = "bar"
+ };
+ string expectedUserId = "newUserId";
+
+ _analytics.Add(_afterPlugin.Object);
+ _analytics.Identify(expectedUserId, expected, @event =>
+ {
+ if (@event is IdentifyEvent identifyEvent)
+ {
+ identifyEvent.Traits["foo"] = "baz";
+ }
+
+ return @event;
+ });
+
+ // now we have tracked event, i.e. event added to startup queue
+ // release the semaphore put on http client, so we startup queue will replay the events
+ _httpSemaphore.Release();
+ // now we need to wait for events being fully replayed before making assertions
+ _assertSemaphore.Wait();
+
+ string actualUserId = _analytics.UserId();
+
+ Assert.NotEmpty(_actual);
+ var actual = _actual[0] as IdentifyEvent;
+ Debug.Assert(actual != null, nameof(actual) + " != null");
+ Assert.Equal(expected, actual.Traits);
+ Assert.Equal(expectedUserId, actualUserId);
+ }
+
+ [Fact]
+ public void TestScreenEnrichment()
+ {
+ var expected = new JsonObject
+ {
+ ["foo"] = "bar"
+ };
+ string expectedTitle = "foo";
+ string expectedCategory = "bar";
+
+ _analytics.Add(_afterPlugin.Object);
+ _analytics.Screen(expectedTitle, expected, expectedCategory, @event =>
+ {
+ if (@event is ScreenEvent screenEvent)
+ {
+ screenEvent.Properties["foo"] = "baz";
+ }
+
+ return @event;
+ });
+
+ // now we have tracked event, i.e. event added to startup queue
+ // release the semaphore put on http client, so we startup queue will replay the events
+ _httpSemaphore.Release();
+ // now we need to wait for events being fully replayed before making assertions
+ _assertSemaphore.Wait();
+
+ Assert.NotEmpty(_actual);
+ var actual = _actual[0] as ScreenEvent;
+ Debug.Assert(actual != null, nameof(actual) + " != null");
+ Assert.Equal(expected, actual.Properties);
+ Assert.Equal(expectedTitle, actual.Name);
+ Assert.Equal(expectedCategory, actual.Category);
+ }
+
+ [Fact]
+ public void TestPageEnrichment()
+ {
+ var expected = new JsonObject
+ {
+ ["foo"] = "bar"
+ };
+ string expectedTitle = "foo";
+ string expectedCategory = "bar";
+
+ _analytics.Add(_afterPlugin.Object);
+ _analytics.Page(expectedTitle, expected, expectedCategory, @event =>
+ {
+ if (@event is PageEvent pageEvent)
+ {
+ pageEvent.Properties["foo"] = "baz";
+ }
+
+ return @event;
+ });
+
+ // now we have tracked event, i.e. event added to startup queue
+ // release the semaphore put on http client, so we startup queue will replay the events
+ _httpSemaphore.Release();
+ // now we need to wait for events being fully replayed before making assertions
+ _assertSemaphore.Wait();
+
+ Assert.NotEmpty(_actual);
+ var actual = _actual[0] as PageEvent;
+ Debug.Assert(actual != null, nameof(actual) + " != null");
+ Assert.Equal(expected, actual.Properties);
+ Assert.Equal(expectedTitle, actual.Name);
+ Assert.Equal(expectedCategory, actual.Category);
+ Assert.Equal("page", actual.Type);
+ }
+
+ [Fact]
+ public void TestGroupEnrichment()
+ {
+ var expected = new JsonObject
+ {
+ ["foo"] = "bar"
+ };
+ string expectedGroupId = "foo";
+
+ _analytics.Add(_afterPlugin.Object);
+ _analytics.Group(expectedGroupId, expected, @event =>
+ {
+ if (@event is GroupEvent groupEvent)
+ {
+ groupEvent.Traits["foo"] = "baz";
+ }
+
+ return @event;
+ });
+
+ // now we have tracked event, i.e. event added to startup queue
+ // release the semaphore put on http client, so we startup queue will replay the events
+ _httpSemaphore.Release();
+ // now we need to wait for events being fully replayed before making assertions
+ _assertSemaphore.Wait();
+
+ Assert.NotEmpty(_actual);
+ var actual = _actual[0] as GroupEvent;
+ Debug.Assert(actual != null, nameof(actual) + " != null");
+ Assert.Equal(expected, actual.Traits);
+ Assert.Equal(expectedGroupId, actual.GroupId);
+ }
+
+ [Fact]
+ public void TestAliasEnrichment()
+ {
+ string expected = "bar";
+
+ _analytics.Add(_afterPlugin.Object);
+ _analytics.Alias(expected, @event =>
+ {
+ if (@event is AliasEvent aliasEvent)
+ {
+ aliasEvent.AnonymousId = "test";
+ }
+
+ return @event;
+ });
+
+ // now we have tracked event, i.e. event added to startup queue
+ // release the semaphore put on http client, so we startup queue will replay the events
+ _httpSemaphore.Release();
+ // now we need to wait for events being fully replayed before making assertions
+ _assertSemaphore.Wait();
+
+ Assert.NotEmpty(_actual);
+ var actual = _actual.Find(o => o is AliasEvent) as AliasEvent;
+ Debug.Assert(actual != null, nameof(actual) + " != null");
+ Assert.Equal(expected, actual.UserId);
+ Assert.Equal("test", actual.AnonymousId);
+ }
+ }
}