diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml
index 7cd68cd6..333f5a26 100644
--- a/.github/workflows/release-please.yml
+++ b/.github/workflows/release-please.yml
@@ -22,6 +22,7 @@ jobs:
package-sdk-server-telemetry-released: ${{ steps.release.outputs['pkgs/telemetry--release_created'] }}
package-shared-common-released: ${{ steps.release.outputs['pkgs/shared/common--release_created'] }}
package-shared-common-json-net-released: ${{ steps.release.outputs['pkgs/shared/common-json-net--release_created'] }}
+ package-shared-internal-released: ${{ steps.release.outputs['pkgs/shared/internal--release_created'] }}
steps:
- uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
@@ -145,3 +146,16 @@ jobs:
package_path: pkgs/shared/common-json-net
dry_run: false
generate_provenance: true
+
+ release-shared-internal:
+ needs: ['release-please']
+ permissions:
+ id-token: write
+ contents: write
+ attestations: write
+ if: ${{ needs.release-please.outputs.package-shared-internal-released == 'true'}}
+ uses: ./.github/workflows/release.yml
+ with:
+ package_path: pkgs/shared/internal
+ dry_run: false
+ generate_provenance: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2aebf717..5f414c66 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -16,6 +16,7 @@ on:
- pkgs/telemetry
- pkgs/shared/common
- pkgs/shared/common-json-net
+ - pkgs/shared/internal
dry_run:
description: 'Is this a dry run. If so no package will be published.'
type: boolean
diff --git a/.github/workflows/server-shared-internal.yml b/.github/workflows/server-shared-internal.yml
new file mode 100644
index 00000000..ce812507
--- /dev/null
+++ b/.github/workflows/server-shared-internal.yml
@@ -0,0 +1,48 @@
+name: LaunchDarkly.InternalSdk CI
+
+on:
+ push:
+ branches: [ main, 'feat/**' ]
+ paths:
+ - '.github/**'
+ - 'global.example.json'
+ - 'pkgs/shared/internal/**'
+ - '!**.md'
+ pull_request:
+ branches: [ main, 'feat/**' ]
+ paths:
+ - '.github/**'
+ - 'global.example.json'
+ - 'pkgs/shared/internal/**'
+ - '!**.md'
+
+jobs:
+ build-and-test:
+ strategy:
+ matrix:
+ include:
+ - os: ubuntu-latest
+ framework: netstandard2.0
+ test_framework: net8.0
+ - os: windows-latest
+ framework: net462
+ test_framework: net462
+
+ runs-on: ${{ matrix.os }}
+
+ defaults:
+ run:
+ shell: ${{ matrix.os == 'windows-latest' && 'powershell' || 'bash' }}
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Env from project's Env file
+ shell: bash
+ run: echo "$(cat pkgs/shared/internal/github_actions.env)" >> $GITHUB_ENV
+
+ - uses: ./.github/actions/ci
+ with:
+ project_file: ${{ env.PROJECT_FILE }}
+ test_project_file: ${{ env.TEST_PROJECT_FILE }}
+ target_test_framework: ${{ matrix.test_framework }}
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index c3d9cada..c49aa331 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -7,5 +7,6 @@
"pkgs/telemetry": "1.5.1",
"pkgs/sdk/server-ai": "0.11.0",
"pkgs/shared/common": "7.2.0",
- "pkgs/shared/common-json-net": "7.0.2"
+ "pkgs/shared/common-json-net": "7.0.2",
+ "pkgs/shared/internal": "3.8.0"
}
diff --git a/DotnetCore.sln b/DotnetCore.sln
index e70da71d..5f8efba4 100644
--- a/DotnetCore.sln
+++ b/DotnetCore.sln
@@ -84,6 +84,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "repo-docs", "repo-docs", "{
LICENSE = LICENSE
EndProjectSection
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "internal", "internal", "{B19B9B2A-CCE2-4FA7-824E-7140F2E13E87}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.InternalSdk", "pkgs\shared\internal\src\LaunchDarkly.InternalSdk.csproj", "{BF4400E1-3831-4E28-AB05-F9B93FF9E2B2}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.InternalSdk.Tests", "pkgs\shared\internal\test\LaunchDarkly.InternalSdk.Tests.csproj", "{F66CD4A8-9DE2-498B-BBA2-B24E9955D1F2}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -121,6 +127,9 @@ Global
{561F6F3D-09C5-470A-83AC-F0E97BAD609B} = {EF541D97-2C23-4FB8-BD87-ADFDF0FF83F3}
{DE321396-6459-4C02-AF61-1A2677A7CB65} = {FF3153CA-F727-4666-BA1E-5459BE34FBAB}
{A10B580F-D937-4EA8-8CF8-4F130FBF6C16} = {822D49A6-1DF2-49BB-A3C0-15821ED718D6}
+ {B19B9B2A-CCE2-4FA7-824E-7140F2E13E87} = {E2905C95-49E1-4194-88DD-B30AA68EEF98}
+ {BF4400E1-3831-4E28-AB05-F9B93FF9E2B2} = {B19B9B2A-CCE2-4FA7-824E-7140F2E13E87}
+ {F66CD4A8-9DE2-498B-BBA2-B24E9955D1F2} = {B19B9B2A-CCE2-4FA7-824E-7140F2E13E87}
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{2D61D2EC-8D04-46E9-9E1B-1E029341F318}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
@@ -249,5 +258,17 @@ Global
{A10B580F-D937-4EA8-8CF8-4F130FBF6C16}.Release|Any CPU.Build.0 = Release|Any CPU
{A10B580F-D937-4EA8-8CF8-4F130FBF6C16}.DebugLocalReferences|Any CPU.ActiveCfg = DebugLocalReferences|Any CPU
{A10B580F-D937-4EA8-8CF8-4F130FBF6C16}.DebugLocalReferences|Any CPU.Build.0 = DebugLocalReferences|Any CPU
+ {BF4400E1-3831-4E28-AB05-F9B93FF9E2B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {BF4400E1-3831-4E28-AB05-F9B93FF9E2B2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {BF4400E1-3831-4E28-AB05-F9B93FF9E2B2}.DebugLocalReferences|Any CPU.ActiveCfg = Debug|Any CPU
+ {BF4400E1-3831-4E28-AB05-F9B93FF9E2B2}.DebugLocalReferences|Any CPU.Build.0 = Debug|Any CPU
+ {BF4400E1-3831-4E28-AB05-F9B93FF9E2B2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {BF4400E1-3831-4E28-AB05-F9B93FF9E2B2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F66CD4A8-9DE2-498B-BBA2-B24E9955D1F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F66CD4A8-9DE2-498B-BBA2-B24E9955D1F2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F66CD4A8-9DE2-498B-BBA2-B24E9955D1F2}.DebugLocalReferences|Any CPU.ActiveCfg = Debug|Any CPU
+ {F66CD4A8-9DE2-498B-BBA2-B24E9955D1F2}.DebugLocalReferences|Any CPU.Build.0 = Debug|Any CPU
+ {F66CD4A8-9DE2-498B-BBA2-B24E9955D1F2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F66CD4A8-9DE2-498B-BBA2-B24E9955D1F2}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
diff --git a/pkgs/shared/internal/CHANGELOG.md b/pkgs/shared/internal/CHANGELOG.md
new file mode 100644
index 00000000..a09ecc84
--- /dev/null
+++ b/pkgs/shared/internal/CHANGELOG.md
@@ -0,0 +1,174 @@
+# Change log
+
+All notable changes to `LaunchDarkly.InternalSdk` will be documented in this file. For full release notes for the projects that depend on this project, see their respective changelogs. This file describes changes only to the common code. This project adheres to [Semantic Versioning](http://semver.org).
+
+## [3.8.0](https://github.com/launchdarkly/dotnet-sdk-internal/compare/3.7.0...3.8.0) (2026-06-16)
+
+
+### Features
+
+* Update target frameworks to net8.0 ([#69](https://github.com/launchdarkly/dotnet-sdk-internal/issues/69)) ([235c52f](https://github.com/launchdarkly/dotnet-sdk-internal/commit/235c52f7e83db46590b6d4ac81333e555d7e876d))
+
+## [3.7.0](https://github.com/launchdarkly/dotnet-sdk-internal/compare/3.6.1...3.7.0) (2026-06-08)
+
+
+### Features
+
+* add optional support for per-context summary events ([#65](https://github.com/launchdarkly/dotnet-sdk-internal/issues/65)) ([d835d08](https://github.com/launchdarkly/dotnet-sdk-internal/commit/d835d08d062d6d71acebc6b572d112eed976a508))
+
+## [3.6.1](https://github.com/launchdarkly/dotnet-sdk-internal/compare/3.6.0...3.6.1) (2026-04-09)
+
+
+### Bug Fixes
+
+* Update CommonSdk to 7.2.0 ([#60](https://github.com/launchdarkly/dotnet-sdk-internal/issues/60)) ([047a867](https://github.com/launchdarkly/dotnet-sdk-internal/commit/047a867705a4bb4fd0f9cad99e9a51ed733952ef))
+
+## [3.6.0](https://github.com/launchdarkly/dotnet-sdk-internal/compare/3.5.5...3.6.0) (2025-12-16)
+
+
+### Features
+
+* adds headers to UnsuccessfulResponseException ([#52](https://github.com/launchdarkly/dotnet-sdk-internal/issues/52)) ([3cca2ff](https://github.com/launchdarkly/dotnet-sdk-internal/commit/3cca2ff29e779def2fe75770dfdd13b94433742f))
+
+## [3.5.5](https://github.com/launchdarkly/dotnet-sdk-internal/compare/3.5.4...3.5.5) (2025-09-26)
+
+
+### Bug Fixes
+
+* Update CommonSdk to 7.1.1 ([#50](https://github.com/launchdarkly/dotnet-sdk-internal/issues/50)) ([b8d57d3](https://github.com/launchdarkly/dotnet-sdk-internal/commit/b8d57d30032a26fd3b034dcea00e24af4cc3be68))
+
+## [3.5.4](https://github.com/launchdarkly/dotnet-sdk-internal/compare/3.5.3...3.5.4) (2025-08-22)
+
+
+### Bug Fixes
+
+* Prevent invalid strings from being part of format value ([#46](https://github.com/launchdarkly/dotnet-sdk-internal/issues/46)) ([b4c772e](https://github.com/launchdarkly/dotnet-sdk-internal/commit/b4c772e059a1f67c8c94c5c39dbb44ca31c2850e))
+
+## [3.5.3](https://github.com/launchdarkly/dotnet-sdk-internal/compare/3.5.2...3.5.3) (2025-07-10)
+
+
+### Bug Fixes
+
+* Relax common dependency ([#44](https://github.com/launchdarkly/dotnet-sdk-internal/issues/44)) ([66913b1](https://github.com/launchdarkly/dotnet-sdk-internal/commit/66913b15b173cbb4eb4a55133989a5f3c21c6781))
+
+## [3.5.2](https://github.com/launchdarkly/dotnet-sdk-internal/compare/3.5.1...3.5.2) (2025-06-24)
+
+
+### Bug Fixes
+
+* Address ARM64 optimization throwing exceptions ([#42](https://github.com/launchdarkly/dotnet-sdk-internal/issues/42)) ([bac4833](https://github.com/launchdarkly/dotnet-sdk-internal/commit/bac4833a526b9f63025a0c864ce41514e402718c))
+
+## [3.5.1](https://github.com/launchdarkly/dotnet-sdk-internal/compare/3.5.0...3.5.1) (2025-05-30)
+
+
+### Bug Fixes
+
+* conditionalize immutable dependency ([#40](https://github.com/launchdarkly/dotnet-sdk-internal/issues/40)) ([3ab529d](https://github.com/launchdarkly/dotnet-sdk-internal/commit/3ab529d730fcbcc0fd5614d96997929b2e4644e1))
+
+## [3.5.0](https://github.com/launchdarkly/dotnet-sdk-internal/compare/3.4.0...3.5.0) (2025-05-02)
+
+
+### Features
+
+* Inline context for custom and migrations op events ([#34](https://github.com/launchdarkly/dotnet-sdk-internal/issues/34)) ([7013bbe](https://github.com/launchdarkly/dotnet-sdk-internal/commit/7013bbe95b3be44ca277f311a84e195e1adfd41d))
+
+## [3.4.0] - 2024-03-13
+### Changed:
+- Redact anonymous attributes within feature events
+- Always inline contexts for feature events
+
+## [3.3.1] - 2023-10-17
+### Changed:
+- Updated Dotnet Common to 7.0.0 which contains nullability of IEnvironmentReporter properties.
+
+## [3.3.0] - 2023-10-10
+### Changed:
+- Updated LaunchDarkly.CommonSdk to 6.2.0 to incorporate changes.
+
+## [3.2.0] - 2023-10-10
+### Added:
+- Add common support for technology migrations.
+- HttpProperties now supports WithApplicationTags.
+
+## [3.1.2] - 2023-04-21
+### Changed:
+- Updated `LaunchDarkly.CommonSdk` to `6.0.1`.
+
+## [3.1.1] - 2023-03-08
+### Fixed:
+- Fixed an issue where calling `FlushAndWait` with `TimeSpan.Zero` would never complete if there were no events to flush.
+
+## [3.1.0] - 2022-12-06
+### Added:
+- In `EventProcessor`, `FlushAndWait` and `FlushAndWaitAsync`.
+
+## [3.0.0] - 2022-12-01
+This major version release of `LaunchDarkly.InternalSdk` corresponds to the upcoming v7.0.0 release of the LaunchDarkly server-side .NET SDK (`LaunchDarkly.ServerSdk`) and the v3.0.0 release of the LaunchDarkly client-side .NET SDK (`LaunchDarkly.ClientSdk`), and cannot be used with earlier SDK versions.
+
+### Changed:
+- .NET Core 2.1, .NET Framework 4.5.2, .NET Framework 4.6.1, and .NET 5.0 are now unsupported. The minimum platform versions are now .NET Core 3.1, .NET Framework 4.6.2, .NET 6.0, and .NET Standard 2.0.
+- Events now use the `Context` type rather than `User`.
+- Private attributes can now be designated with the `AttributeRef` type, which allows redaction of either a full attribute or a property within a JSON object value.
+- There is a new JSON schema for analytics events. The HTTP headers for event payloads now report the schema version as 4.
+- There is no longer a dependency on `LaunchDarkly.JsonStream`. This package existed because some platforms did not support the `System.Text.Json` API, but that is no longer the case and the SDK now uses `System.Text.Json` directly for all of its JSON operations.
+- `EventSender` now takes a byte array instead of a string for the event payload, so we can serialize JSON data directly to UTF8.
+
+### Removed:
+- All alias event functionality
+- `EventsConfiguration.InlineUsersInEvents`
+
+## [2.3.2] - 2022-02-02
+### Changed:
+- Updated `LaunchDarkly.CommonSdk` dependency to latest release.
+
+## [2.3.1] - 2022-01-28
+### Fixed:
+- In analytics event data, `index` events were showing a `contextKind` property for anonymous users. That type of event should not have that property; LaunchDarkly would ignore it.
+
+## [2.3.0] - 2021-10-27
+### Added:
+- `HttpProperties.NewHttpMessageHandler()`
+
+## [2.2.0] - 2021-10-25
+### Added:
+- In `TaskExecutor`, there is a new parameter for controlling how events are dispatched that will be used by the client-side .NET SDK.
+- In `LaunchDarkly.Sdk.Internal.Events`, new types `DiagnosticStoreBase` and `DiagnosticConfigProperties` contain logic that was previously only in `LaunchDarkly.ServerSdk` and will now be shared by `LaunchDarkly.ClientSdk`.
+
+### Changed:
+- Updated `LaunchDarkly.CommonSdk` to 5.4.0.
+
+## [2.1.1] - 2021-10-05
+### Changed:
+- Updated dependency versions to keep in sync with dependencies of `LaunchDarkly.ServerSdk`.
+
+## [2.1.0] - 2021-09-21
+### Added:
+- Made `StateMonitor` and `TaskExecutor` public; they had mistakenly been marked internal.
+
+## [2.0.0] - 2021-09-21
+### Added:
+- `StateMonitor`, `TaskExecutor`.
+
+### Changed:
+- Moved `AtomicBoolean` and `AsyncUtils` into new namespace `LaunchDarkly.Sdk.Internal.Concurrent`.
+
+### Removed:
+- `MultiNotifier` (obviated by `StateMonitor`).
+
+## [1.1.2] - 2021-06-07
+### Fixed:
+- Updated minimum `LaunchDarkly.CommonSdk` version to latest patch, to exclude versions of `LaunchDarkly.JsonStream` with a known parsing bug.
+
+## [1.1.1] - 2021-05-07
+### Fixed:
+- `HttpProperties.ConnectTimeout` now really sets the TCP connect timeout, if the platform is .NET Core or .NET 5.0. Other platforms don't support connect timeouts and will continue to ignore this value.
+
+## [1.1.0] - 2021-04-27
+### Added:
+- `UriExtensions`
+
+### Fixed:
+- Improved coverage and reliability of HTTP tests using `LaunchDarkly.TestHelpers`.
+
+## [1.0.0] - 2021-02-08
+Initial release of this package, to be used in `LaunchDarkly.ServerSdk` 6.0 and `LaunchDarkly.XamarinSdk` 2.0.
diff --git a/pkgs/shared/internal/CONTRIBUTING.md b/pkgs/shared/internal/CONTRIBUTING.md
new file mode 100644
index 00000000..a9e36d31
--- /dev/null
+++ b/pkgs/shared/internal/CONTRIBUTING.md
@@ -0,0 +1,41 @@
+# Contributing to the LaunchDarkly SDK .NET Internal Common Code
+
+LaunchDarkly has published an [SDK contributor's guide](https://docs.launchdarkly.com/docs/sdk-contributors-guide) that provides a detailed explanation of how our SDKs work. See below for additional information on how to contribute to this SDK.
+
+## Submitting bug reports and feature requests
+
+In general, issues should be filed in the issue trackers for the [.NET server-side SDK](https://github.com/launchdarkly/dotnet-server-sdk/issues) or the [Xamarin client-side SDK](https://github.com/launchdarkly/xamarin-client-sdk/issues) rather than in this repository, unless you have a specific implementation issue regarding the code in this repository.
+
+## Submitting pull requests
+
+We encourage pull requests and other contributions from the community. Before submitting pull requests, ensure that all temporary or unintended code is removed. Don't worry about adding reviewers to the pull request; the LaunchDarkly SDK team will add themselves. The SDK team will acknowledge all pull requests within two business days.
+
+## Build instructions
+
+### Prerequisites
+
+To set up your build time environment, you must [download .NET and follow the instructions](https://dotnet.microsoft.com/download). Using the highest supported .NET version is recommended, since newer SDKs are capable of building for older target frameworks but not vice versa.
+
+### Building
+
+To install all required packages:
+
+```
+dotnet restore
+```
+
+Then, to build the library without running any tests:
+
+```
+dotnet build src/LaunchDarkly.InternalSdk
+```
+
+### Testing
+
+To run all unit tests:
+
+```
+dotnet test test/LaunchDarkly.InternalSdk.Tests/LaunchDarkly.InternalSdk.Tests.csproj
+```
+
+Note that the unit tests can only be run in Debug configuration. There is an `InternalsVisibleTo` directive that allows the test code to access internal members of the library, and assembly strong-naming in the Release configuration interferes with this.
diff --git a/pkgs/shared/internal/LaunchDarkly.InternalSdk.pk b/pkgs/shared/internal/LaunchDarkly.InternalSdk.pk
new file mode 100644
index 00000000..e3b0c992
Binary files /dev/null and b/pkgs/shared/internal/LaunchDarkly.InternalSdk.pk differ
diff --git a/pkgs/shared/internal/LaunchDarkly.InternalSdk.sln b/pkgs/shared/internal/LaunchDarkly.InternalSdk.sln
new file mode 100644
index 00000000..812ea491
--- /dev/null
+++ b/pkgs/shared/internal/LaunchDarkly.InternalSdk.sln
@@ -0,0 +1,23 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio 15
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.InternalSdk", "src\LaunchDarkly.InternalSdk.csproj", "{E9EA3EA1-D5CD-46CE-9E67-A8CDA4C292F0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LaunchDarkly.InternalSdk.Tests", "test\LaunchDarkly.InternalSdk.Tests.csproj", "{DA1B9706-BD2B-4956-8074-3B4686C008AB}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {E9EA3EA1-D5CD-46CE-9E67-A8CDA4C292F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E9EA3EA1-D5CD-46CE-9E67-A8CDA4C292F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E9EA3EA1-D5CD-46CE-9E67-A8CDA4C292F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E9EA3EA1-D5CD-46CE-9E67-A8CDA4C292F0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {DA1B9706-BD2B-4956-8074-3B4686C008AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {DA1B9706-BD2B-4956-8074-3B4686C008AB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DA1B9706-BD2B-4956-8074-3B4686C008AB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {DA1B9706-BD2B-4956-8074-3B4686C008AB}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/pkgs/shared/internal/PROVENANCE.md b/pkgs/shared/internal/PROVENANCE.md
new file mode 100644
index 00000000..22787e27
--- /dev/null
+++ b/pkgs/shared/internal/PROVENANCE.md
@@ -0,0 +1,49 @@
+## Verifying SDK build provenance with GitHub artifact attestations
+
+LaunchDarkly uses [GitHub artifact attestations](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds) to help developers make their supply chain more secure by ensuring the authenticity and build integrity of our published SDK packages.
+
+LaunchDarkly publishes provenance about our SDK package builds using [GitHub's `actions/attest` action](https://github.com/actions/attest). These attestations are stored in GitHub's attestation API and can be verified using the [GitHub CLI](https://cli.github.com/).
+
+To verify build provenance attestations, we recommend using the [GitHub CLI `attestation verify` command](https://cli.github.com/manual/gh_attestation_verify). Example usage for verifying SDK packages is included below:
+
+
+```
+# Set the version of the SDK to verify
+SDK_VERSION=3.8.0
+```
+
+
+```
+# Download the nupkg from NuGet
+$ nuget install LaunchDarkly.InternalSdk -Version $SDK_VERSION -OutputDirectory ./packages
+
+# Verify provenance using the GitHub CLI
+$ gh attestation verify ./packages/LaunchDarkly.InternalSdk.${SDK_VERSION}/LaunchDarkly.InternalSdk.${SDK_VERSION}.nupkg --owner launchdarkly
+```
+
+Below is a sample of expected output.
+
+```
+Loaded digest sha256:... for file://LaunchDarkly.InternalSdk.3.6.0.nupkg
+Loaded 1 attestation from GitHub API
+
+The following policy criteria will be enforced:
+- Predicate type must match:................ https://slsa.dev/provenance/v1
+- Source Repository Owner URI must match:... https://github.com/launchdarkly
+- Subject Alternative Name must match regex: (?i)^https://github.com/launchdarkly/
+- OIDC Issuer must match:................... https://token.actions.githubusercontent.com
+
+✓ Verification succeeded!
+
+The following 1 attestation matched the policy criteria
+
+- Attestation #1
+ - Build repo:..... launchdarkly/dotnet-core
+ - Build workflow:. .github/workflows/release-please.yml
+ - Signer repo:.... launchdarkly/dotnet-core
+ - Signer workflow: .github/workflows/release-please.yml
+```
+
+For more information, see [GitHub's documentation on verifying artifact attestations](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds#verifying-artifact-attestations-with-the-github-cli).
+
+**Note:** These instructions do not apply when building our SDKs from source.
diff --git a/pkgs/shared/internal/README.md b/pkgs/shared/internal/README.md
new file mode 100644
index 00000000..5fbf5aaa
--- /dev/null
+++ b/pkgs/shared/internal/README.md
@@ -0,0 +1,42 @@
+# LaunchDarkly SDK .NET Internal Common Code
+
+[](https://www.nuget.org/packages/LaunchDarkly.InternalSdk/)
+
+This project contains .NET classes and interfaces that are shared between the LaunchDarkly .NET and Xamarin SDKs. These are internal implementation details that are not part of the supported SDK APIs and should not be used by application code. Code that is specific to one or the other SDK is in [dotnet-server-sdk](https://github.com/launchdarkly/dotnet-server-sdk) or [xamarin-client-sdk](https://github.com/launchdarkly/xamarin-client-sdk), and public APIs that are common to both are in [dotnet-sdk-common](https://github.com/launchdarkly/dotnet-sdk-common).
+
+## Contributing
+
+See [Contributing](./CONTRIBUTING.md).
+
+## Signing
+
+The published version of this assembly is digitally signed with Authenticode and [strong-named](https://docs.microsoft.com/en-us/dotnet/framework/app-domains/strong-named-assemblies). Building the code locally in the default Debug configuration does not use strong-naming and does not require a key file. The public key file is in this repository at `LaunchDarkly.InternalSdk.pk` as well as here:
+
+```
+Public Key:
+0024000004800000940000000602000000240000525341310004000001000100
+c750d2f66590e46bab94497b8df2a773ce941140566e4b4e532e921dd0ccb0c2
+ddc934dc4dbcb14fc48c4d75ff5fc43ef3ed83f67fb20061a5ea83b656eded02
+7f489ca157213634506ed8a5dce2f9582edfc4bb2cbf2a9c61bc78f8aacd4b3c
+79397cddfa058c3b538c294eb29d05f72e710343e714b4d5b3f8f8b12483d68e
+
+Public Key Token: ff53908ab73043b6
+```
+
+## Verifying build provenance with the SLSA framework
+
+LaunchDarkly uses the [SLSA framework](https://slsa.dev/spec/v1.0/about) (Supply-chain Levels for Software Artifacts) to help developers make their supply chain more secure by ensuring the authenticity and build integrity of our published packages. To learn more, see the [provenance guide](PROVENANCE.md).
+
+## About LaunchDarkly
+
+* LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can:
+ * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases.
+ * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?).
+ * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file.
+ * Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline.
+* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Check out [our documentation](https://docs.launchdarkly.com/docs) for a complete list.
+* Explore LaunchDarkly
+ * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information
+ * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides
+ * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation
+ * [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates
diff --git a/pkgs/shared/internal/docfx.json b/pkgs/shared/internal/docfx.json
new file mode 100644
index 00000000..1074adf8
--- /dev/null
+++ b/pkgs/shared/internal/docfx.json
@@ -0,0 +1,48 @@
+{
+ "metadata": [
+ {
+ "src": [
+ {
+ "src": "./src",
+ "files": [
+ "**/*.csproj",
+ "**/bin/**/**LaunchDarkly**.dll"
+ ]
+ }
+ ],
+ "dest": "./api",
+ "properties" : {
+ "Configuration": "Debug"
+ }
+ }
+ ],
+ "build": {
+ "content": [
+ {
+ "files": [
+ "**/*.{md,yml}"
+ ],
+ "exclude": [
+ "docs/**"
+ ]
+ }
+ ],
+ "resource": [
+ {
+ "files": [
+ "images/**"
+ ]
+ }
+ ],
+ "output": "docs",
+ "template": [
+ "default"
+ ],
+ "globalMetadata": {
+ "_appName": "LaunchDarkly.InternalSdk",
+ "_appTitle": "LaunchDarkly internal common code for .NET clients",
+ "_enableSearch": true,
+ "pdf": false
+ }
+ }
+}
diff --git a/pkgs/shared/internal/github_actions.env b/pkgs/shared/internal/github_actions.env
new file mode 100644
index 00000000..f9b775b4
--- /dev/null
+++ b/pkgs/shared/internal/github_actions.env
@@ -0,0 +1,6 @@
+WORKSPACE_PATH=pkgs/shared/internal
+PROJECT_FILE=pkgs/shared/internal/src/LaunchDarkly.InternalSdk.csproj
+BUILD_OUTPUT_PATH=pkgs/shared/internal/src/bin/Release/
+BUILD_OUTPUT_DLL_NAME=LaunchDarkly.InternalSdk.dll
+TEST_PROJECT_FILE=pkgs/shared/internal/test/LaunchDarkly.InternalSdk.Tests.csproj
+ASSEMBLY_KEY_PATH_PAIR=launchdarkly-releaser/dotnet/LaunchDarkly.snk = LaunchDarkly.InternalSdk.snk
diff --git a/pkgs/shared/internal/src/AssemblyInfo.cs b/pkgs/shared/internal/src/AssemblyInfo.cs
new file mode 100644
index 00000000..2a16eaad
--- /dev/null
+++ b/pkgs/shared/internal/src/AssemblyInfo.cs
@@ -0,0 +1,8 @@
+using System.Runtime.CompilerServices;
+
+#if DEBUG
+[assembly: InternalsVisibleTo("LaunchDarkly.InternalSdk.Tests")]
+
+// Allow mock/proxy objects in unit tests to access internal classes
+[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
+#endif
diff --git a/pkgs/shared/internal/src/AssemblyVersions.cs b/pkgs/shared/internal/src/AssemblyVersions.cs
new file mode 100644
index 00000000..8f6d5508
--- /dev/null
+++ b/pkgs/shared/internal/src/AssemblyVersions.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Reflection;
+
+namespace LaunchDarkly.Sdk.Internal
+{
+ ///
+ /// Helper methods for inspecting the version of an SDK assembly.
+ ///
+ ///
+ /// The .NET SDK and Xamarin SDK can discover their own current versions dynamically
+ /// using these methods, by passing in any type that is defined in the SDK assembly.
+ ///
+ public static class AssemblyVersions
+ {
+ ///
+ /// Returns the version string for the assembly that provides the specified type.
+ ///
+ /// a type defined in the assembly you're interested in
+ /// the version string for that assembly
+ public static string GetAssemblyVersionStringForType(Type t) =>
+ ((AssemblyInformationalVersionAttribute)
+ t.GetTypeInfo().Assembly.GetCustomAttribute(typeof(AssemblyInformationalVersionAttribute))
+ ).InformationalVersion;
+
+ ///
+ /// Same as , but returns a
+ /// object rather than a string.
+ ///
+ /// a type defined in the assembly you're interested in
+ /// the version for that assembly
+ public static Version GetAssemblyVersionForType(Type t) =>
+ t.GetTypeInfo().Assembly.GetName().Version;
+ }
+}
diff --git a/pkgs/shared/internal/src/Concurrent/AsyncUtils.cs b/pkgs/shared/internal/src/Concurrent/AsyncUtils.cs
new file mode 100644
index 00000000..56dac501
--- /dev/null
+++ b/pkgs/shared/internal/src/Concurrent/AsyncUtils.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace LaunchDarkly.Sdk.Internal.Concurrent
+{
+ public static class AsyncUtils
+ {
+ private static readonly TaskFactory _taskFactory = new TaskFactory(CancellationToken.None,
+ TaskCreationOptions.None, TaskContinuationOptions.None, TaskScheduler.Default);
+
+ // This procedure for blocking on a Task without using Task.Wait is derived from the MIT-licensed ASP.NET
+ // code here: https://github.com/aspnet/AspNetIdentity/blob/master/src/Microsoft.AspNet.Identity.Core/AsyncHelper.cs
+ // In general, mixing sync and async code is not recommended, and if done in other ways can result in
+ // deadlocks. See: https://stackoverflow.com/questions/9343594/how-to-call-asynchronous-method-from-synchronous-method-in-c
+ // Task.Wait would only be safe if we could guarantee that every intermediate Task within the async
+ // code had been modified with ConfigureAwait(false), but that is very error-prone and we can't depend
+ // on feature store implementors doing so.
+
+ public static void WaitSafely(Func taskFn) =>
+ _taskFactory.StartNew(taskFn)
+ .Unwrap()
+ .GetAwaiter()
+ .GetResult();
+ // Note, GetResult does not throw AggregateException so we don't need to post-process exceptions
+
+ public static bool WaitSafely(Func taskFn, TimeSpan timeout)
+ {
+ try
+ {
+ return _taskFactory.StartNew(taskFn)
+ .Unwrap()
+ .Wait(timeout);
+ }
+ catch (AggregateException e)
+ {
+ throw UnwrapAggregateException(e);
+ }
+ }
+
+ public static T WaitSafely(Func> taskFn) =>
+ _taskFactory.StartNew(taskFn)
+ .Unwrap()
+ .GetAwaiter()
+ .GetResult();
+
+ public static Exception UnwrapAggregateException(AggregateException e) =>
+ e.InnerExceptions.Count == 1 ?
+ e.InnerExceptions[0] : e;
+ }
+}
diff --git a/pkgs/shared/internal/src/Concurrent/AtomicBoolean.cs b/pkgs/shared/internal/src/Concurrent/AtomicBoolean.cs
new file mode 100644
index 00000000..fe54c477
--- /dev/null
+++ b/pkgs/shared/internal/src/Concurrent/AtomicBoolean.cs
@@ -0,0 +1,38 @@
+using System.Threading;
+
+namespace LaunchDarkly.Sdk.Internal.Concurrent
+{
+ ///
+ /// A simple atomic boolean using Interlocked.Exchange.
+ ///
+ public sealed class AtomicBoolean
+ {
+ private volatile int _value;
+
+ ///
+ /// Creates an instance.
+ ///
+ /// the initial value
+ public AtomicBoolean(bool value)
+ {
+ _value = value ? 1 : 0;
+ }
+
+ ///
+ /// Returns the current value.
+ ///
+ /// the current value
+ public bool Get() => _value != 0;
+
+ ///
+ /// Atomically updates the value and returns the previous value.
+ ///
+ /// the new value
+ /// the previous value
+ public bool GetAndSet(bool newValue)
+ {
+ int old = Interlocked.Exchange(ref _value, newValue ? 1 : 0);
+ return old != 0;
+ }
+ }
+}
diff --git a/pkgs/shared/internal/src/Concurrent/StateMonitor.cs b/pkgs/shared/internal/src/Concurrent/StateMonitor.cs
new file mode 100644
index 00000000..d613fe92
--- /dev/null
+++ b/pkgs/shared/internal/src/Concurrent/StateMonitor.cs
@@ -0,0 +1,211 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using LaunchDarkly.Logging;
+
+namespace LaunchDarkly.Sdk.Internal.Concurrent
+{
+ ///
+ /// A generic mechanism for maintaining some kind of synchronized state that multiple
+ /// tasks or threads can wait on.
+ ///
+ /// a value type representing the current state
+ /// either the same as StateT, or any other type that
+ /// you want to use as the parameter for
+ public sealed class StateMonitor : IDisposable where StateT : struct
+ {
+ internal sealed class Awaiter
+ {
+ internal Func TestFn { get; set; }
+ internal TaskCompletionSource Completion { get; set; }
+ }
+
+ private readonly Func _updateFn;
+ private readonly object _stateLock = new object();
+ private readonly Logger _log;
+
+ private StateT _current;
+ private LinkedList _awaiters = new LinkedList();
+
+ ///
+ /// The current state value.
+ ///
+ public StateT Current
+ {
+ get
+ {
+ lock (_stateLock)
+ {
+ return _current;
+ }
+ }
+ }
+
+ ///
+ /// Constructs an instance.
+ ///
+ ///
+ /// The updateFn parameter provides a flexible mechanism for atomically updating
+ /// the state. It will be called under a lock each time
+ /// is called, receiving the current state and the update value as parameters; it returns
+ /// either a new state, or to skip updating the state.
+ ///
+ /// the initial state value
+ /// the state transform function
+ /// optional logger for debug logging of state transitions
+ public StateMonitor(StateT initial, Func updateFn, Logger log)
+ {
+ _current = initial;
+ _updateFn = updateFn;
+ _log = log;
+ }
+
+ ///
+ /// Atomically updates the state and notifies any tasks that were waiting on it, if
+ /// appropriate.
+ ///
+ ///
+ /// This first passes the value to the state transform
+ /// function that was configured in the constructor. If that function returns
+ /// (meaning the state should not be updated), nothing happens
+ /// and this method returns (while also returning the
+ /// current state in ). Otherwise, it atomically
+ /// updates the state, wakes up any tasks that had previously called
+ /// or
+ /// if the new state
+ /// matches their conditions, and returns along with the
+ /// updated state.
+ ///
+ /// a value representing a possible state change
+ /// receives the result state
+ /// if the state was changed
+ public bool Update(UpdateT update, out StateT newState)
+ {
+ List> completed = null;
+ lock (_stateLock)
+ {
+ var maybeNewState = _updateFn(_current, update);
+ if (!maybeNewState.HasValue)
+ {
+ newState = _current;
+ return false;
+ }
+ newState = maybeNewState.Value;
+ _current = newState;
+ if (_awaiters is null)
+ {
+ return true; // we've already shut down
+ }
+ for (var node = _awaiters.First; node != null;)
+ {
+ var next = node.Next;
+ if (node.Value.TestFn(newState))
+ {
+ if (completed is null)
+ {
+ completed = new List>();
+ }
+ completed.Add(node.Value.Completion);
+ _awaiters.Remove(node);
+ }
+ node = next;
+ }
+ }
+ _log?.Debug("Updated state to {0}", newState);
+ if (completed != null)
+ {
+ foreach (var c in completed)
+ {
+ c.TrySetResult(newState);
+ }
+ }
+ return true;
+ }
+
+ internal void Forget(Awaiter awaiter)
+ {
+ lock (_stateLock)
+ {
+ _awaiters?.Remove(awaiter);
+ }
+ }
+
+ ///
+ /// Equivalent to , but uses
+ /// to convert it to a synchronous call.
+ ///
+ /// the test function
+ /// the timeout
+ /// the end state, or null if timed out
+ public StateT? WaitFor(Func testFn, TimeSpan timeout) =>
+ AsyncUtils.WaitSafely(() => WaitForAsync(testFn, timeout));
+
+ ///
+ /// Waits for the state to be updated to some value, defined by a test function.
+ ///
+ ///
+ ///
+ /// This method calls the test function while holding a lock on the lock object specified
+ /// in the constructor. If the return value is , it immediately
+ /// returns whatever the current state value is; otherwise, it sleeps until the next time
+ /// is called and then repeats the check-- unless the
+ /// timeout expires, in which case it returns .
+ ///
+ ///
+ /// the test function
+ /// the timeout
+ /// the end state, or null if timed out
+ public async Task WaitForAsync(Func testFn, TimeSpan timeout)
+ {
+ var completion = new TaskCompletionSource();
+ Awaiter awaiter;
+
+ lock (_stateLock)
+ {
+ if (testFn(_current))
+ {
+ return _current;
+ }
+ if (_awaiters is null) // this means we've been shut down
+ {
+ return null;
+ }
+ awaiter = new Awaiter { TestFn = testFn, Completion = completion };
+ _awaiters.AddFirst(awaiter);
+ }
+
+ var timeoutSignal = timeout > TimeSpan.Zero ? new CancellationTokenSource(timeout) :
+ new CancellationTokenSource();
+ using (timeoutSignal.Token.Register(() => completion.TrySetCanceled()))
+ {
+ try
+ {
+ return await completion.Task;
+ }
+ catch (TaskCanceledException)
+ {
+ Forget(awaiter);
+ return null;
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ LinkedList oldAwaiters;
+ lock (_stateLock)
+ {
+ oldAwaiters = _awaiters;
+ _awaiters = null;
+ }
+ if (oldAwaiters != null)
+ {
+ foreach (var a in oldAwaiters)
+ {
+ a.Completion.TrySetCanceled();
+ }
+ }
+ }
+ }
+}
diff --git a/pkgs/shared/internal/src/Concurrent/TaskExecutor.cs b/pkgs/shared/internal/src/Concurrent/TaskExecutor.cs
new file mode 100644
index 00000000..5050b111
--- /dev/null
+++ b/pkgs/shared/internal/src/Concurrent/TaskExecutor.cs
@@ -0,0 +1,153 @@
+using System;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using LaunchDarkly.Logging;
+
+namespace LaunchDarkly.Sdk.Internal
+{
+ ///
+ /// Abstraction of scheduling infrequent worker tasks.
+ ///
+ ///
+ /// We use this instead of just calling Task.Run() for two reasons. First, the default
+ /// scheduling behavior of Task.Run() may not always be what we want. Second, this provides
+ /// better error logging.
+ ///
+ public sealed class TaskExecutor
+ {
+ private readonly object _eventSender;
+ private readonly Action _eventHandlerDispatcher;
+ private readonly Logger _log;
+
+ ///
+ /// Creates an instance.
+ ///
+ /// object to use as the sender parameter when firing events
+ /// logger for logging errors from worker tasks
+ public TaskExecutor(object eventSender, Logger log) : this(eventSender, null, log) { }
+
+ ///
+ /// Creates an instance, specifying custom event dispatch logic.
+ ///
+ ///
+ /// The parameter, if not null, specifies what to do when
+ /// calling a lambda that executes an application-defined event handler. The default behavior for
+ /// this is that we call `Task.Run` for each handler invocation to run it as a separate background
+ /// task. In the client-side .NET SDK, we may want to use a different approach (such as invoking the
+ /// handler on the UI thread, for mobile devices).
+ ///
+ /// object to use as the sender parameter when firing events
+ /// custom logic to use when executing an event handler,
+ /// or to use the default behavior
+ /// logger for logging errors from worker tasks
+ public TaskExecutor(object eventSender, Action eventHandlerDispatcher, Logger log)
+ {
+ _eventSender = eventSender;
+ _eventHandlerDispatcher = eventHandlerDispatcher ?? DefaultEventHandlerDispatcher;
+ _log = log;
+ }
+
+ ///
+ /// Schedules delivery of an event to some number of event handlers.
+ ///
+ ///
+ /// In the current implementation, each handler call is a separate background task.
+ ///
+ /// the event type
+ /// the event object
+ /// a handler list
+ public void ScheduleEvent(T eventArgs, EventHandler handlers)
+ {
+ if (handlers is null)
+ {
+ return;
+ }
+ var delegates = handlers.GetInvocationList();
+ if (delegates is null || delegates.Length == 0)
+ {
+ return;
+ }
+ _log.Debug("scheduling task to send {0} to {1}", eventArgs, handlers);
+ foreach (var handler in delegates)
+ {
+ _eventHandlerDispatcher(() =>
+ {
+ _log.Debug("sending {0}", eventArgs);
+ try
+ {
+ handler.DynamicInvoke(_eventSender, eventArgs);
+ }
+ catch (Exception e)
+ {
+ if (e is TargetInvocationException wrappedException)
+ {
+ e = wrappedException.InnerException;
+ }
+ LogHelpers.LogException(_log,
+ string.Format("Unexpected exception from event handler for {0}", eventArgs.GetType().Name),
+ e);
+ }
+ });
+ }
+ }
+
+ private static void DefaultEventHandlerDispatcher(Action invokeHandler)
+ {
+ _ = Task.Run(invokeHandler);
+ }
+
+ ///
+ /// Starts a repeating async task.
+ ///
+ /// time to wait before first execution
+ /// interval at which to repeat
+ /// the task to run
+ /// a for stopping the task
+ public CancellationTokenSource StartRepeatingTask(
+ TimeSpan initialDelay,
+ TimeSpan interval,
+ Func taskFn
+ )
+ {
+ var canceller = new CancellationTokenSource();
+ _ = Task.Run(async () =>
+ {
+ if (initialDelay.CompareTo(TimeSpan.Zero) > 0)
+ {
+ try
+ {
+ await Task.Delay(initialDelay, canceller.Token);
+ }
+ catch (TaskCanceledException) { }
+ }
+ while (true)
+ {
+ if (canceller.IsCancellationRequested)
+ {
+ return;
+ }
+ var nextTime = DateTime.Now.Add(interval);
+ try
+ {
+ await taskFn();
+ }
+ catch (Exception e)
+ {
+ LogHelpers.LogException(_log, "Unexpected exception from repeating task", e);
+ }
+ var timeToWait = nextTime.Subtract(DateTime.Now);
+ if (timeToWait.CompareTo(TimeSpan.Zero) > 0)
+ {
+ try
+ {
+ await Task.Delay(timeToWait, canceller.Token);
+ }
+ catch (TaskCanceledException) { }
+ }
+ }
+ });
+ return canceller;
+ }
+ }
+}
diff --git a/pkgs/shared/internal/src/Events/AggregatedEventSummarizer.cs b/pkgs/shared/internal/src/Events/AggregatedEventSummarizer.cs
new file mode 100644
index 00000000..e398183b
--- /dev/null
+++ b/pkgs/shared/internal/src/Events/AggregatedEventSummarizer.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Collections.Generic;
+
+namespace LaunchDarkly.Sdk.Internal.Events
+{
+ ///
+ /// Produces a single aggregated summary event covering all contexts, with no context attached.
+ /// This is the original summarization behavior and the default; it is used by server-side SDKs.
+ ///
+ ///
+ /// Not thread-safe; always invoked from the event processor's single message-processing thread.
+ ///
+ internal sealed class AggregatedEventSummarizer : IEventSummarizer
+ {
+ private readonly EventSummarizer _summarizer = new EventSummarizer();
+
+ public void SummarizeEvent(
+ UnixMillisecondTime timestamp,
+ string flagKey,
+ int? flagVersion,
+ int? variation,
+ in LdValue value,
+ in LdValue defaultValue,
+ in Context context
+ ) =>
+ _summarizer.SummarizeEvent(timestamp, flagKey, flagVersion, variation, value, defaultValue, context);
+
+ public IReadOnlyList GetSummariesAndReset()
+ {
+ EventSummary summary = _summarizer.GetSummaryAndReset();
+ return summary.Empty ? Array.Empty() : new[] { summary };
+ }
+
+ public void Clear() => _summarizer.Clear();
+ }
+}
diff --git a/pkgs/shared/internal/src/Events/DefaultEventSender.cs b/pkgs/shared/internal/src/Events/DefaultEventSender.cs
new file mode 100644
index 00000000..853542c8
--- /dev/null
+++ b/pkgs/shared/internal/src/Events/DefaultEventSender.cs
@@ -0,0 +1,177 @@
+using System;
+using System.Diagnostics;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Encodings;
+using System.Threading;
+using System.Threading.Tasks;
+using LaunchDarkly.Logging;
+using LaunchDarkly.Sdk.Internal.Http;
+
+namespace LaunchDarkly.Sdk.Internal.Events
+{
+ ///
+ /// The default implementation of delivering JSON data to an LaunchDarkly event endpoint.
+ ///
+ ///
+ /// This is the only implementation that is used by the SDKs. It is abstracted out with an
+ /// interface for the sake of testability.
+ ///
+ public sealed class DefaultEventSender : IEventSender
+ {
+ public static readonly TimeSpan DefaultRetryInterval = TimeSpan.FromSeconds(1);
+
+ private const int MaxAttempts = 2;
+ private const string CurrentSchemaVersion = "4";
+
+ private readonly HttpClient _httpClient;
+ private readonly HttpProperties _httpProperties;
+ private readonly Uri _eventsUri;
+ private readonly Uri _diagnosticUri;
+ private readonly TimeSpan _timeout;
+ private readonly TimeSpan _retryInterval;
+ private readonly Logger _logger;
+
+ public DefaultEventSender(HttpProperties httpProperties, EventsConfiguration config, Logger logger)
+ {
+ _httpClient = httpProperties.NewHttpClient();
+ _httpProperties = httpProperties;
+ _eventsUri = config.EventsUri;
+ _diagnosticUri = config.DiagnosticUri;
+ _retryInterval = config.RetryInterval ?? DefaultRetryInterval;
+ _logger = logger;
+
+ // Currently we do not have a good method of setting the connection timeout separately
+ // from the socket read timeout, so the value we're computing here is for the entire
+ // request-response cycle. See comments in HttpProperties.
+ _timeout = httpProperties.ConnectTimeout.Add(httpProperties.ReadTimeout);
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ private void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _httpClient.Dispose();
+ }
+ }
+
+ public async Task SendEventDataAsync(EventDataKind kind, byte[] data, int eventCount)
+ {
+ Uri uri;
+ string description;
+ string payloadId;
+
+ if (kind == EventDataKind.DiagnosticEvent)
+ {
+ uri = _diagnosticUri;
+ description = "diagnostic event";
+ payloadId = null;
+ }
+ else
+ {
+ uri = _eventsUri;
+ description = string.Format("{0} event(s)", eventCount);
+ payloadId = Guid.NewGuid().ToString();
+ }
+
+ _logger.Debug("Submitting {0} to {1} with json: {2}", description, uri.AbsoluteUri,
+ LogValues.Defer(() => Encoding.UTF8.GetString(data)));
+
+ for (var attempt = 0; attempt < MaxAttempts; attempt++)
+ {
+ if (attempt > 0)
+ {
+ await Task.Delay(_retryInterval);
+ }
+
+ using (var cts = new CancellationTokenSource(_timeout))
+ {
+ string errorMessage = null;
+ bool canRetry = false;
+ bool mustShutDown = false;
+
+ try
+ {
+ using (var request = PrepareRequest(uri, payloadId))
+ using (var stringContent = new ByteArrayContent(data))
+ {
+ stringContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
+ request.Content = stringContent;
+ Stopwatch timer = new Stopwatch();
+ using (var response = await _httpClient.SendAsync(request, cts.Token))
+ {
+ timer.Stop();
+ _logger.Debug("Event delivery took {0} ms, response status {1}",
+ timer.ElapsedMilliseconds, (int)response.StatusCode);
+ if (response.IsSuccessStatusCode)
+ {
+ DateTimeOffset? respDate = response.Headers.Date;
+ return new EventSenderResult(DeliveryStatus.Succeeded,
+ respDate.HasValue ? (DateTime?)respDate.Value.DateTime : null);
+ }
+ else
+ {
+ errorMessage = HttpErrors.ErrorMessageBase((int)response.StatusCode);
+ canRetry = HttpErrors.IsRecoverable((int)response.StatusCode);
+ mustShutDown = !canRetry;
+ }
+ }
+ }
+ }
+ catch (TaskCanceledException e)
+ {
+ if (e.CancellationToken == cts.Token)
+ {
+ // Indicates the task was cancelled deliberately somehow; in this case don't retry
+ _logger.Warn("Event sending task was cancelled");
+ return new EventSenderResult(DeliveryStatus.Failed, null);
+ }
+ else
+ {
+ // Otherwise this was a request timeout.
+ errorMessage = "Timed out";
+ canRetry = true;
+ }
+ }
+ catch (Exception e)
+ {
+ errorMessage = string.Format("Error ({0})", LogValues.ExceptionSummary(e));
+ canRetry = true;
+ }
+ string nextStepDesc = canRetry ?
+ (attempt == MaxAttempts - 1 ? "will not retry" : "will retry after one second") :
+ "giving up permanently";
+ _logger.Warn("{0} sending {1}; {2}", errorMessage, description, nextStepDesc);
+ if (mustShutDown)
+ {
+ return new EventSenderResult(DeliveryStatus.FailedAndMustShutDown, null);
+ }
+ if (!canRetry)
+ {
+ break;
+ }
+ }
+ }
+ return new EventSenderResult(DeliveryStatus.Failed, null);
+ }
+
+ private HttpRequestMessage PrepareRequest(Uri uri, string payloadId)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, uri);
+ _httpProperties.AddHeaders(request);
+ if (payloadId != null) // payloadId is provided for regular analytics events payloads, not for diagnostic events
+ {
+ request.Headers.Add("X-LaunchDarkly-Payload-ID", payloadId);
+ request.Headers.Add("X-LaunchDarkly-Event-Schema", CurrentSchemaVersion);
+ }
+ return request;
+ }
+ }
+}
diff --git a/pkgs/shared/internal/src/Events/DiagnosticConfigProperties.cs b/pkgs/shared/internal/src/Events/DiagnosticConfigProperties.cs
new file mode 100644
index 00000000..37160036
--- /dev/null
+++ b/pkgs/shared/internal/src/Events/DiagnosticConfigProperties.cs
@@ -0,0 +1,102 @@
+using System;
+using System.Net;
+using LaunchDarkly.Sdk.Internal.Http;
+
+namespace LaunchDarkly.Sdk.Internal.Events
+{
+ ///
+ /// Helpers for standard properties we include in diagnostic event configuration data,
+ /// ensuring that their names and types are consistent across SDKs. It is up to each SDK
+ /// to call these with values from its own Configuration type. Properties that only exist
+ /// in server-side or in client-side are not included.
+ ///
+ public static class DiagnosticConfigProperties
+ {
+ ///
+ /// Adds the standard properties for events configuration.
+ ///
+ /// the object builder
+ /// the standard event properties
+ /// true if the SDK is using a custom base URI for events
+ /// the builder
+ public static LdValue.ObjectBuilder WithEventProperties(
+ this LdValue.ObjectBuilder builder,
+ EventsConfiguration config,
+ bool customEventsBaseUri
+ ) =>
+ builder.Set("allAttributesPrivate", config.AllAttributesPrivate)
+ .Set("customEventsURI", customEventsBaseUri)
+ .Set("diagnosticRecordingIntervalMillis", config.DiagnosticRecordingInterval.TotalMilliseconds)
+ .Set("eventsCapacity", config.EventCapacity)
+ .Set("eventsFlushIntervalMillis", config.EventFlushInterval.TotalMilliseconds);
+
+ ///
+ /// Adds the standard properties for HTTP configuration.
+ ///
+ /// the object builder
+ /// the standard HTTP properties
+ /// the builder
+ public static LdValue.ObjectBuilder WithHttpProperties(this LdValue.ObjectBuilder builder, HttpProperties props) =>
+ builder.Set("connectTimeoutMillis", props.ConnectTimeout.TotalMilliseconds)
+ .Set("socketTimeoutMillis", props.ReadTimeout.TotalMilliseconds)
+ .Set("usingProxy", DetectProxy(props))
+ .Set("usingProxyAuthenticator", DetectProxyAuth(props));
+
+ ///
+ /// Adds the standard startWaitMillis property.
+ ///
+ /// the object builder
+ /// the value
+ /// the builder
+ public static LdValue.ObjectBuilder WithStartWaitTime(this LdValue.ObjectBuilder builder, TimeSpan value) =>
+ builder.Set("startWaitMillis", value.TotalMilliseconds);
+
+ ///
+ /// Adds the standard properties for streaming.
+ ///
+ /// the object builder
+ /// true if the SDK is using a custom base URI for streaming
+ /// true if the SDK is using a custom base URI for polling
+ /// the initial reconnect delay
+ /// the builder
+ public static LdValue.ObjectBuilder WithStreamingProperties(
+ this LdValue.ObjectBuilder builder,
+ bool customStreamingBaseUri,
+ bool customPollingBaseUri,
+ TimeSpan initialReconnectDelay
+ ) =>
+ builder.Set("streamingDisabled", false)
+ .Set("customBaseURI", customPollingBaseUri)
+ .Set("customStreamURI", customStreamingBaseUri)
+ .Set("reconnectTimeMillis", initialReconnectDelay.TotalMilliseconds);
+
+ ///
+ /// Adds the standard properties for polling.
+ ///
+ /// the object builder
+ /// true if the SDK is using a custom base URI for polling
+ /// the polling interval
+ /// the builder
+ public static LdValue.ObjectBuilder WithPollingProperties(
+ this LdValue.ObjectBuilder builder,
+ bool customPollingBaseUri,
+ TimeSpan pollingInterval
+ ) =>
+ builder.Set("streamingDisabled", true)
+ .Set("customBaseURI", customPollingBaseUri)
+ .Set("pollingIntervalMillis", pollingInterval.TotalMilliseconds);
+
+ // DetectProxy and DetectProxyAuth do not cover every mechanism that could be used to configure
+ // a proxy; for instance, there is HttpClient.DefaultProxy, which only exists in .NET Core 3.x and
+ // .NET 5.x. But since we're only trying to gather diagnostic stats, this doesn't have to be perfect.
+ private static bool DetectProxy(HttpProperties props) =>
+ props.Proxy != null ||
+ !string.IsNullOrEmpty(System.Environment.GetEnvironmentVariable("HTTP_PROXY")) ||
+ !string.IsNullOrEmpty(System.Environment.GetEnvironmentVariable("HTTPS_PROXY")) ||
+ !string.IsNullOrEmpty(System.Environment.GetEnvironmentVariable("ALL_PROXY"));
+
+ private static bool DetectProxyAuth(HttpProperties props) =>
+ props.Proxy is WebProxy wp &&
+ (wp.Credentials != null || wp.UseDefaultCredentials);
+ }
+}
diff --git a/pkgs/shared/internal/src/Events/DiagnosticEvent.cs b/pkgs/shared/internal/src/Events/DiagnosticEvent.cs
new file mode 100644
index 00000000..8a77fd43
--- /dev/null
+++ b/pkgs/shared/internal/src/Events/DiagnosticEvent.cs
@@ -0,0 +1,20 @@
+
+namespace LaunchDarkly.Sdk.Internal.Events
+{
+ ///
+ /// Represents a unit of JSON data that will be sent as a diagnostic event. This is simply a wrapper for
+ /// an immutable and non-nullable , but using the DiagnosticEvent type makes it
+ /// clearer what the purpose of the data is.
+ ///
+ public struct DiagnosticEvent
+ {
+ private readonly LdValue _jsonValue;
+
+ public LdValue JsonValue => _jsonValue;
+
+ public DiagnosticEvent(LdValue jsonValue)
+ {
+ _jsonValue = jsonValue;
+ }
+ }
+}
diff --git a/pkgs/shared/internal/src/Events/DiagnosticId.cs b/pkgs/shared/internal/src/Events/DiagnosticId.cs
new file mode 100644
index 00000000..37a55bef
--- /dev/null
+++ b/pkgs/shared/internal/src/Events/DiagnosticId.cs
@@ -0,0 +1,19 @@
+using System;
+
+namespace LaunchDarkly.Sdk.Internal.Events
+{
+ public sealed class DiagnosticId
+ {
+ public readonly Guid Id;
+ public readonly string SdkKeySuffix;
+
+ public DiagnosticId(string sdkKey, Guid diagnosticId)
+ {
+ if (sdkKey != null)
+ {
+ SdkKeySuffix = sdkKey.Substring(Math.Max(0, sdkKey.Length - 6));
+ }
+ Id = diagnosticId;
+ }
+ }
+}
diff --git a/pkgs/shared/internal/src/Events/DiagnosticStoreBase.cs b/pkgs/shared/internal/src/Events/DiagnosticStoreBase.cs
new file mode 100644
index 00000000..e0f37dbf
--- /dev/null
+++ b/pkgs/shared/internal/src/Events/DiagnosticStoreBase.cs
@@ -0,0 +1,274 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using System.Threading;
+using LaunchDarkly.Sdk.Internal.Http;
+
+namespace LaunchDarkly.Sdk.Internal.Events
+{
+ ///
+ /// Abstract implementation of IDiagnosticStore.
+ ///
+ ///
+ /// Platform-specific behavior is provided by subclass overrides.
+ ///
+ public abstract class DiagnosticStoreBase : IDiagnosticStore
+ {
+ private readonly DateTime _initTime;
+ private DiagnosticId _diagnosticId;
+
+ // _dataSince is stored in the "binary" long format so Interlocked.Exchange can be used
+ private long _dataSince;
+ private long _droppedEvents;
+ private long _deduplicatedUsers;
+ private long _eventsInLastBatch;
+ private readonly object _streamInitsLock = new object();
+ private LdValue.ArrayBuilder _streamInits = LdValue.BuildArray();
+
+ #region IDiagnosticStore properties
+
+ ///
+ public DiagnosticEvent? InitEvent => MakeInitEvent();
+
+ ///
+ public DiagnosticEvent? PersistedUnsentEvent => null;
+
+ ///
+ public DateTime DataSince => DateTime.FromBinary(Interlocked.Read(ref _dataSince));
+
+ #endregion
+
+ #region Abstract properties for SDKs to override
+
+ ///
+ /// Subclasses override this property to return the configured SDK key or mobile key.
+ ///
+ protected abstract string SdkKeyOrMobileKey { get; }
+
+ ///
+ /// Subclasses override this property to return a string such as "dotnet-server-sdk".
+ ///
+ protected abstract string SdkName { get; }
+
+ ///
+ /// Subclasses override this property to return one or more JSON objects which will
+ /// be merged together to form the configuration properties. For convenience, these
+ /// are represented with the type, but any values that are not
+ /// JSON objects will be ignored.
+ ///
+ protected abstract IEnumerable ConfigProperties { get; }
+
+ ///
+ /// Subclasses override this property to return a string such as "netstandard2.0".
+ ///
+ protected abstract string DotNetTargetFramework { get; }
+
+ ///
+ /// Subclasses override this property to return the configured HTTP properties.
+ ///
+ protected abstract HttpProperties HttpProperties { get; }
+
+ ///
+ /// Subclasses override this property to return the type of the SDK's client class,
+ /// which is used to determine the version via reflection.
+ ///
+ protected abstract Type TypeOfLdClient { get; }
+
+ #endregion
+
+ #region Constructor
+
+ ///
+ /// Base class constructor.
+ ///
+ protected DiagnosticStoreBase()
+ {
+ _initTime = DateTime.Now;
+ _dataSince = _initTime.ToBinary();
+ }
+
+ #endregion
+
+ #region Periodic event update and builder methods
+
+ ///
+ public void IncrementDeduplicatedUsers() =>
+ Interlocked.Increment(ref _deduplicatedUsers);
+
+ ///
+ public void IncrementDroppedEvents() =>
+ Interlocked.Increment(ref _droppedEvents);
+
+ ///
+ public void AddStreamInit(DateTime timestamp, TimeSpan duration, bool failed)
+ {
+ var streamInitObject = LdValue.BuildObject();
+ streamInitObject.Add("timestamp", UnixMillisecondTime.FromDateTime(timestamp).Value);
+ streamInitObject.Add("durationMillis", duration.TotalMilliseconds);
+ streamInitObject.Add("failed", failed);
+ lock (_streamInitsLock)
+ {
+ _streamInits.Add(streamInitObject.Build());
+ }
+ }
+
+ ///
+ public void RecordEventsInBatch(long eventsInBatch) =>
+ Interlocked.Exchange(ref _eventsInLastBatch, eventsInBatch);
+
+ ///
+ public DiagnosticEvent CreateEventAndReset()
+ {
+ DateTime currentTime = DateTime.Now;
+ long droppedEvents = Interlocked.Exchange(ref _droppedEvents, 0);
+ long deduplicatedUsers = Interlocked.Exchange(ref _deduplicatedUsers, 0);
+ long eventsInLastBatch = Interlocked.Exchange(ref _eventsInLastBatch, 0);
+ long dataSince = Interlocked.Exchange(ref _dataSince, currentTime.ToBinary());
+
+ var statEvent = LdValue.BuildObject();
+ AddDiagnosticCommonFields(statEvent, "diagnostic", currentTime);
+ statEvent.Add("eventsInLastBatch", eventsInLastBatch);
+ statEvent.Add("dataSinceDate", UnixMillisecondTime.FromDateTime(DateTime.FromBinary(dataSince)).Value);
+ statEvent.Add("droppedEvents", droppedEvents);
+ statEvent.Add("deduplicatedUsers", deduplicatedUsers);
+ lock (_streamInitsLock)
+ {
+ statEvent.Add("streamInits", _streamInits.Build());
+ _streamInits = LdValue.BuildArray();
+ }
+
+ return new DiagnosticEvent(statEvent.Build());
+ }
+
+ #endregion
+
+ #region Private methods for building event data
+
+ private DiagnosticId GetDiagnosticId()
+ {
+ if (_diagnosticId is null)
+ {
+ _diagnosticId = new DiagnosticId(SdkKeyOrMobileKey, Guid.NewGuid());
+ }
+ return _diagnosticId;
+ }
+
+ private void AddDiagnosticCommonFields(LdValue.ObjectBuilder fieldsBuilder, string kind, DateTime creationDate)
+ {
+ fieldsBuilder.Add("kind", kind);
+ fieldsBuilder.Add("id", EncodeDiagnosticId(GetDiagnosticId()));
+ fieldsBuilder.Add("creationDate", UnixMillisecondTime.FromDateTime(creationDate).Value);
+ }
+
+ private LdValue EncodeDiagnosticId(DiagnosticId id)
+ {
+ var o = LdValue.BuildObject().Add("diagnosticId", id.Id.ToString());
+ if (id.SdkKeySuffix != null)
+ {
+ o.Add("sdkKeySuffix", id.SdkKeySuffix);
+ }
+ return o.Build();
+ }
+
+ private DiagnosticEvent MakeInitEvent()
+ {
+ var initEvent = LdValue.BuildObject();
+
+ var configBuilder = LdValue.BuildObject();
+ foreach (var configProps in ConfigProperties)
+ {
+ if (configProps.Type == LdValueType.Object)
+ {
+ foreach (var prop in configProps.AsDictionary(LdValue.Convert.Json))
+ {
+ configBuilder.Add(prop.Key, prop.Value);
+ }
+ }
+ }
+ initEvent.Add("configuration", configBuilder.Build());
+
+ initEvent.Add("sdk", InitEventSdk());
+ initEvent.Add("platform", InitEventPlatform());
+ AddDiagnosticCommonFields(initEvent, "diagnostic-init", _initTime);
+ return new DiagnosticEvent(initEvent.Build());
+ }
+
+ private LdValue InitEventPlatform() =>
+ LdValue.BuildObject()
+ .Add("name", "dotnet")
+ .Add("dotNetTargetFramework", LdValue.Of(DotNetTargetFramework))
+ .Add("osName", LdValue.Of(GetOSName()))
+ .Add("osVersion", LdValue.Of(GetOSVersion()))
+ .Add("osArch", LdValue.Of(GetOSArch()))
+ .Build();
+
+ private LdValue InitEventSdk()
+ {
+ var sdkInfo = LdValue.BuildObject()
+ .Add("name", SdkName)
+ .Add("version", AssemblyVersions.GetAssemblyVersionStringForType(TypeOfLdClient));
+ foreach (var kv in HttpProperties.BaseHeaders)
+ {
+ if (kv.Key.ToLower() == "x-launchdarkly-wrapper")
+ {
+ if (kv.Value.Contains("/"))
+ {
+ sdkInfo.Add("wrapperName", kv.Value.Substring(0, kv.Value.IndexOf("/")));
+ sdkInfo.Add("wrapperVersion", kv.Value.Substring(kv.Value.IndexOf("/") + 1));
+ }
+ else
+ {
+ sdkInfo.Add("wrapperName", kv.Value);
+ }
+ }
+ }
+ return sdkInfo.Build();
+ }
+
+ internal static string GetOSName()
+ {
+ // Environment.OSVersion.Platform is another way to get this information, except that it does not
+ // reliably distinguish between MacOS and Linux.
+
+#if NETFRAMEWORK
+ // .NET Framework 4.6 does not support RuntimeInformation.ISOSPlatform. We could use Environment.OSVersion.Platform
+ // instead (it's similar, except that it can't reliably distinguish between MacOS and Linux)... but .NET 4.5 can't
+ // run on anything but Windows anyway.
+ return "Windows";
+#else
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ return "Linux";
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ return "MacOS";
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return "Windows";
+ }
+ return "unknown";
+#endif
+ }
+
+ internal static string GetOSVersion()
+ {
+ // .NET's way of reporting Windows versions is very idiosyncratic, e.g. Windows 8 is "6.2", but we'll
+ // just report what it says and translate it later when we look at the analytics.
+ return Environment.OSVersion.Version.ToString();
+ }
+
+ internal static string GetOSArch()
+ {
+#if NETFRAMEWORK
+ // .NET Framework 4.6 does not support RuntimeInformation.OSArchitecture
+ return "unknown";
+#else
+ return RuntimeInformation.OSArchitecture.ToString().ToLower(); // "arm", "arm64", "x64", "x86"
+#endif
+ }
+
+ #endregion
+ }
+}
diff --git a/pkgs/shared/internal/src/Events/EventContextFormatter.cs b/pkgs/shared/internal/src/Events/EventContextFormatter.cs
new file mode 100644
index 00000000..884abb0f
--- /dev/null
+++ b/pkgs/shared/internal/src/Events/EventContextFormatter.cs
@@ -0,0 +1,161 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+using static LaunchDarkly.Sdk.Json.LdJsonConverters;
+
+namespace LaunchDarkly.Sdk.Internal.Events
+{
+ internal sealed class EventContextFormatter
+ {
+ private readonly bool _allAttributesPrivate;
+ private readonly IEnumerable _globalPrivateAttributes;
+
+ public EventContextFormatter(EventsConfiguration config)
+ {
+ _allAttributesPrivate = config.AllAttributesPrivate;
+ _globalPrivateAttributes = config.PrivateAttributes ?? Enumerable.Empty();
+ }
+
+ public void Write(in Context c, Utf8JsonWriter w, bool redactAnonymous = false)
+ {
+ if (c.Multiple)
+ {
+ w.WriteStartObject();
+ w.WriteString("kind", "multi");
+ foreach (var mc in c.MultiKindContexts)
+ {
+ w.WritePropertyName(mc.Kind.Value);
+ WriteSingle(mc, w, false, redactAnonymous);
+ }
+ w.WriteEndObject();
+ }
+ else
+ {
+ WriteSingle(c, w, true, redactAnonymous);
+ }
+ }
+
+ private void WriteSingle(in Context c, Utf8JsonWriter w, bool includeKind, bool redactAnonymous)
+ {
+ w.WriteStartObject();
+
+ if (includeKind)
+ {
+ w.WriteString("kind", c.Kind.Value);
+ }
+ w.WriteString("key", c.Key);
+ JsonConverterHelpers.WriteBooleanIfTrue(w, "anonymous", c.Anonymous);
+
+ var redactAll = _allAttributesPrivate || (redactAnonymous && c.Anonymous);
+
+ List redactedList = null;
+ var privateRefs = _globalPrivateAttributes.Concat(c.PrivateAttributes);
+ foreach (var attr in c.OptionalAttributeNames)
+ {
+ if (redactAll)
+ {
+ AddRedacted(ref redactedList, attr); // the entire attribute is redacted
+ continue;
+ }
+ WriteOrRedact(attr, c, w, privateRefs, ref redactedList);
+ }
+
+ if (!(redactedList is null))
+ {
+ w.WriteStartObject("_meta");
+ w.WriteStartArray("redactedAttributes");
+ foreach (var attr in redactedList)
+ {
+ w.WriteStringValue(attr);
+ }
+ w.WriteEndArray();
+ w.WriteEndObject();
+ }
+
+ w.WriteEndObject();
+ }
+
+ // This method implements the context-aware attribute redaction logic, in which an attribute
+ // can be either written as-is, fully redacted, or (for a JSON object) partially redacted.
+ // In the latter two cases, this method returns the redacted attribute reference string;
+ // otherwise it returns null.
+ private void WriteOrRedact(
+ string attrName,
+ in Context c,
+ Utf8JsonWriter obj,
+ IEnumerable privateRefs,
+ ref List redactedList
+ )
+ {
+ // First check if the whole attribute is redacted by name.
+ foreach (var a in privateRefs)
+ {
+ if (a.Depth == 1 && a.GetComponent(0) == attrName)
+ {
+ AddRedacted(ref redactedList, attrName); // the entire attribute is redacted
+ return;
+ }
+ }
+
+ var value = c.GetValue(attrName);
+ if (value.Type != LdValueType.Object)
+ {
+ obj.WritePropertyName(attrName);
+ LdValueConverter.WriteJsonValue(value, obj);
+ return;
+ }
+
+ // The value is a JSON object, and the attribute may need to be partially redacted.
+ WriteRedactedValue(value, obj, privateRefs, 0, attrName, ref redactedList);
+ }
+
+ private void WriteRedactedValue(
+ in LdValue value,
+ Utf8JsonWriter obj,
+ IEnumerable allPrivate,
+ int depth,
+ string pathComponent,
+ ref List redactedList)
+ {
+ IEnumerable filteredPrivate = allPrivate.Where(a =>
+ a.GetComponent(depth) == pathComponent);
+
+ var haveSubpaths = false;
+ foreach (var a in filteredPrivate)
+ {
+ if (a.Depth <= depth + 1)
+ {
+ // exact match for this subpath or a parent - the whole value is redacted
+ AddRedacted(ref redactedList, a.ToString());
+ return;
+ }
+ haveSubpaths = true;
+ }
+
+ if (!haveSubpaths || value.Type != LdValueType.Object)
+ {
+ obj.WritePropertyName(pathComponent);
+ LdValueConverter.WriteJsonValue(value, obj);
+ return;
+ }
+
+ obj.WriteStartObject(pathComponent);
+ foreach (var kv in value.Dictionary)
+ {
+ WriteRedactedValue(kv.Value, obj, filteredPrivate, depth + 1, kv.Key, ref redactedList);
+ }
+ obj.WriteEndObject();
+ }
+
+ private void AddRedacted(ref List redactedList, string attrName)
+ {
+ if (redactedList is null)
+ {
+ redactedList = new List();
+ }
+ redactedList.Add(attrName);
+ }
+ }
+}
diff --git a/pkgs/shared/internal/src/Events/EventOutput.cs b/pkgs/shared/internal/src/Events/EventOutput.cs
new file mode 100644
index 00000000..e1a2f1b9
--- /dev/null
+++ b/pkgs/shared/internal/src/Events/EventOutput.cs
@@ -0,0 +1,328 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Text.Json;
+using static LaunchDarkly.Sdk.Internal.Events.EventTypes;
+using static LaunchDarkly.Sdk.Json.LdJsonConverters;
+
+namespace LaunchDarkly.Sdk.Internal.Events
+{
+ internal sealed class EventOutputFormatter
+ {
+ private readonly EventsConfiguration _config;
+ private readonly EventContextFormatter _contextFormatter;
+
+ public EventOutputFormatter(EventsConfiguration config)
+ {
+ _config = config;
+ _contextFormatter = new EventContextFormatter(config);
+ }
+
+ public byte[] SerializeOutputEvents(object[] events, IReadOnlyList summaries, out int eventCountOut)
+ {
+ using (var stream = new MemoryStream())
+ {
+ using (var jsonWriter = new Utf8JsonWriter(stream))
+ {
+ eventCountOut = WriteOutputEvents(events, summaries, jsonWriter);
+ jsonWriter.Flush();
+ return stream.ToArray();
+ }
+ }
+ }
+
+ private struct MutableKeyValuePair
+ {
+ public A Key { get; set; }
+ public B Value { get; set; }
+
+ public static MutableKeyValuePair FromKeyValue(KeyValuePair kv) =>
+ new MutableKeyValuePair {Key = kv.Key, Value = kv.Value};
+ }
+
+ public int WriteOutputEvents(object[] events, IReadOnlyList summaries, Utf8JsonWriter w)
+ {
+ var eventCount = events.Length;
+ w.WriteStartArray();
+ foreach (var e in events)
+ {
+ WriteOutputEvent(e, w);
+ }
+
+ foreach (var summary in summaries)
+ {
+ if (!summary.Empty)
+ {
+ WriteSummaryEvent(summary, w);
+ eventCount++;
+ }
+ }
+
+ w.WriteEndArray();
+ return eventCount;
+ }
+
+ public void WriteOutputEvent(object e, Utf8JsonWriter w)
+ {
+ w.WriteStartObject();
+ switch (e)
+ {
+ case EvaluationEvent ee:
+ WriteEvaluationEvent(ee, w, false);
+ break;
+ case IdentifyEvent ie:
+ WriteBase("identify", w, ie.Timestamp, null);
+ WriteContext(ie.Context, w);
+ break;
+ case CustomEvent ce:
+ WriteBase("custom", w, ce.Timestamp, ce.EventKey);
+ WriteContext(ce.Context, w);
+ JsonConverterHelpers.WriteLdValueIfNotNull(w, "data", ce.Data);
+ if (ce.MetricValue.HasValue)
+ {
+ w.WriteNumber("metricValue", ce.MetricValue.Value);
+ }
+
+ break;
+ case MigrationOpEvent me:
+ WriteMigrationOpEvent(me, w);
+ break;
+ case EventProcessorInternal.IndexEvent ie:
+ WriteBase("index", w, ie.Timestamp, null);
+ WriteContext(ie.Context, w);
+ break;
+ case EventProcessorInternal.DebugEvent de:
+ WriteEvaluationEvent(de.FromEvent, w, true);
+ break;
+ default:
+ break;
+ }
+
+ w.WriteEndObject();
+ }
+
+ private void WriteEvaluationEvent(in EvaluationEvent ee, Utf8JsonWriter obj, bool debug)
+ {
+ WriteBase(debug ? "debug" : "feature", obj, ee.Timestamp, ee.FlagKey);
+ WriteContext(ee.Context, obj, redactAnonymous: !debug);
+
+ if (ee.SamplingRatio.HasValue && ee.SamplingRatio != 1)
+ {
+ obj.WriteNumber("samplingRatio", ee.SamplingRatio.Value);
+ }
+
+ JsonConverterHelpers.WriteIntIfNotNull(obj, "version", ee.FlagVersion);
+ JsonConverterHelpers.WriteIntIfNotNull(obj, "variation", ee.Variation);
+ JsonConverterHelpers.WriteLdValue(obj, "value", ee.Value);
+ JsonConverterHelpers.WriteLdValueIfNotNull(obj, "default", ee.Default);
+ JsonConverterHelpers.WriteStringIfNotNull(obj, "prereqOf", ee.PrereqOf);
+ WriteReason(ee.Reason, obj);
+ }
+
+ public void WriteSummaryEvent(EventSummary summary, Utf8JsonWriter w)
+ {
+ w.WriteStartObject();
+
+ w.WriteString("kind", "summary");
+ w.WriteNumber("startDate", summary.StartDate.Value);
+ w.WriteNumber("endDate", summary.EndDate.Value);
+
+ // Per-context summaries carry the evaluation context; aggregated summaries leave it
+ // undefined and omit it. The context is filtered the same way as for other events.
+ if (summary.Context.Defined)
+ {
+ WriteContext(summary.Context, w, redactAnonymous: true);
+ }
+
+ w.WriteStartObject("features");
+
+ foreach (var kvFlag in summary.Flags)
+ {
+ w.WriteStartObject(kvFlag.Key);
+
+ var flagSummary = kvFlag.Value;
+
+ JsonConverterHelpers.WriteLdValue(w, "default", flagSummary.Default);
+
+ w.WriteStartArray("contextKinds");
+ foreach (var kind in flagSummary.ContextKinds)
+ {
+ w.WriteStringValue(kind);
+ }
+
+ w.WriteEndArray();
+
+ w.WriteStartArray("counters");
+
+ foreach (var counter in flagSummary.Counters)
+ {
+ w.WriteStartObject();
+ JsonConverterHelpers.WriteIntIfNotNull(w, "variation", counter.Key.Variation);
+ JsonConverterHelpers.WriteLdValue(w, "value", counter.Value.FlagValue);
+ JsonConverterHelpers.WriteIntIfNotNull(w, "version", counter.Key.Version);
+ JsonConverterHelpers.WriteBooleanIfTrue(w, "unknown", !counter.Key.Version.HasValue);
+ w.WriteNumber("count", counter.Value.Count);
+
+ w.WriteEndObject();
+ }
+
+ w.WriteEndArray();
+
+ w.WriteEndObject();
+ }
+
+ w.WriteEndObject();
+ w.WriteEndObject();
+ }
+
+ private void WriteBase(string kind, Utf8JsonWriter obj, UnixMillisecondTime creationDate, string key)
+ {
+ obj.WriteString("kind", kind);
+ obj.WriteNumber("creationDate", creationDate.Value);
+ JsonConverterHelpers.WriteStringIfNotNull(obj, "key", key);
+ }
+
+ private void WriteContext(in Context context, Utf8JsonWriter obj, bool redactAnonymous = false)
+ {
+ obj.WritePropertyName("context");
+ _contextFormatter.Write(context, obj, redactAnonymous);
+ }
+
+ private static void WriteReason(EvaluationReason? reason, Utf8JsonWriter obj)
+ {
+ if (reason.HasValue)
+ {
+ obj.WritePropertyName("reason");
+ EvaluationReasonConverter.WriteJsonValue(reason.Value, obj);
+ }
+ }
+
+ private void WriteMigrationOpEvent(MigrationOpEvent migrationOpEvent, Utf8JsonWriter obj)
+ {
+ WriteBase("migration_op", obj, migrationOpEvent.Timestamp, null);
+ WriteContext(migrationOpEvent.Context, obj);
+ if (migrationOpEvent.SamplingRatio != 1)
+ {
+ obj.WriteNumber("samplingRatio", migrationOpEvent.SamplingRatio);
+ }
+
+ obj.WriteString("operation", migrationOpEvent.Operation);
+ WriteMigrationEvaluation(migrationOpEvent, obj);
+ WriteMeasurements(migrationOpEvent, obj);
+ }
+
+ private static void WriteMigrationEvaluation(MigrationOpEvent migrationOpEvent, Utf8JsonWriter obj)
+ {
+ obj.WritePropertyName("evaluation");
+ obj.WriteStartObject();
+ obj.WriteString("key", migrationOpEvent.FlagKey);
+ JsonConverterHelpers.WriteIntIfNotNull(obj, "version", migrationOpEvent.FlagVersion);
+ JsonConverterHelpers.WriteIntIfNotNull(obj, "variation", migrationOpEvent.Variation);
+ JsonConverterHelpers.WriteLdValue(obj, "value", migrationOpEvent.Value);
+ JsonConverterHelpers.WriteLdValueIfNotNull(obj, "default", migrationOpEvent.Default);
+ WriteReason(migrationOpEvent.Reason, obj);
+ obj.WriteEndObject();
+ }
+
+ private static void WriteMeasurements(MigrationOpEvent migrationOpEvent, Utf8JsonWriter obj)
+ {
+ obj.WritePropertyName("measurements");
+ obj.WriteStartArray();
+
+ WriteInvokedMeasurement(migrationOpEvent, obj);
+ WriteErrorMeasurement(migrationOpEvent, obj);
+ WriteLatencyMeasurement(migrationOpEvent, obj);
+ WriteConsistentMeasurement(migrationOpEvent, obj);
+
+ obj.WriteEndArray();
+ }
+
+ private static void WriteInvokedMeasurement(MigrationOpEvent migrationOpEvent, Utf8JsonWriter obj)
+ {
+ obj.WriteStartObject();
+
+ obj.WriteString("key", "invoked");
+ obj.WritePropertyName("values");
+
+ obj.WriteStartObject();
+ if (migrationOpEvent.Invoked.Old)
+ {
+ obj.WriteBoolean("old", true);
+ }
+
+ if (migrationOpEvent.Invoked.New)
+ {
+ obj.WriteBoolean("new", true);
+ }
+
+ obj.WriteEndObject(); // end values
+
+ obj.WriteEndObject(); // end measurement
+ }
+
+ private static void WriteLatencyMeasurement(MigrationOpEvent migrationOpEvent, Utf8JsonWriter obj)
+ {
+ if (!migrationOpEvent.Latency.HasValue ||
+ (!migrationOpEvent.Latency.Value.Old.HasValue && !migrationOpEvent.Latency.Value.New.HasValue)) return;
+
+ obj.WriteStartObject();
+
+ obj.WriteString("key", "latency_ms");
+ obj.WritePropertyName("values");
+
+ obj.WriteStartObject();
+ if (migrationOpEvent.Latency.Value.Old.HasValue)
+ {
+ obj.WriteNumber("old", migrationOpEvent.Latency.Value.Old.Value);
+ }
+
+ if (migrationOpEvent.Latency.Value.New.HasValue)
+ {
+ obj.WriteNumber("new", migrationOpEvent.Latency.Value.New.Value);
+ }
+
+ obj.WriteEndObject(); // end values
+
+ obj.WriteEndObject(); // end measurement
+ }
+
+ private static void WriteErrorMeasurement(MigrationOpEvent migrationOpEvent, Utf8JsonWriter obj)
+ {
+ if (!migrationOpEvent.Error.HasValue ||
+ (!migrationOpEvent.Error.Value.Old && !migrationOpEvent.Error.Value.New)) return;
+
+ obj.WriteStartObject();
+ obj.WriteString("key", "error");
+ obj.WritePropertyName("values");
+
+ obj.WriteStartObject();
+ if (migrationOpEvent.Error.Value.Old)
+ {
+ obj.WriteBoolean("old", true);
+ }
+
+ if (migrationOpEvent.Error.Value.New)
+ {
+ obj.WriteBoolean("new", true);
+ }
+
+ obj.WriteEndObject(); // end values
+
+ obj.WriteEndObject(); // end measurement
+ }
+
+ private static void WriteConsistentMeasurement(MigrationOpEvent migrationOpEvent, Utf8JsonWriter obj)
+ {
+ if (!migrationOpEvent.Consistent.HasValue) return;
+
+ obj.WriteStartObject();
+ obj.WriteString("key", "consistent");
+ obj.WriteBoolean("value", migrationOpEvent.Consistent.Value.IsConsistent);
+ if (migrationOpEvent.Consistent.Value.SamplingRatio != 1)
+ {
+ obj.WriteNumber("samplingRatio", migrationOpEvent.Consistent.Value.SamplingRatio);
+ }
+
+ obj.WriteEndObject();
+ }
+ }
+}
diff --git a/pkgs/shared/internal/src/Events/EventProcessor.cs b/pkgs/shared/internal/src/Events/EventProcessor.cs
new file mode 100644
index 00000000..5e021cdd
--- /dev/null
+++ b/pkgs/shared/internal/src/Events/EventProcessor.cs
@@ -0,0 +1,316 @@
+using System;
+using System.Collections.Concurrent;
+using System.Threading;
+using System.Threading.Tasks;
+using LaunchDarkly.Logging;
+using LaunchDarkly.Sdk.Internal.Concurrent;
+
+using static LaunchDarkly.Sdk.Internal.Events.EventTypes;
+
+namespace LaunchDarkly.Sdk.Internal.Events
+{
+ ///
+ /// The internal component that processes and delivers analytics events.
+ ///
+ ///
+ ///
+ /// This component is not visible to application code; the SDKs may choose to expose an
+ /// interface for customizing event behavior, but if so, their default implementations of
+ /// the interface will delegate to this component rather than this component implementing
+ /// the interface itself. This allows us to make changes as needed to the internal interface
+ /// and event parameters without disrupting application code, and also to provide internal
+ /// features that may not be relevant to some SDKs (for instance, offline mode is only for
+ /// use by Xamarin).
+ ///
+ ///
+ /// The current implementation is really three components. EventProcessor is a simple
+ /// facade that accepts event parameters (from SDK activity that might be happening on many
+ /// threads) and pushes the events onto a queue. The queue is consumed by a single-threaded
+ /// task run by EventProcessorInternal, which performs any necessary processing such as
+ /// incrementing summary counters. When events are ready to deliver, it uses an
+ /// implementation of IEventSender (normally DefaultEventSender) to deliver the JSON data.
+ ///
+ ///
+ public sealed class EventProcessor : IDisposable
+ {
+ #region Private fields
+
+ private readonly BlockingCollection _messageQueue;
+ private readonly EventProcessorInternal _processorInternal;
+ private readonly IDiagnosticStore _diagnosticStore;
+ private readonly Timer _flushTimer;
+ private readonly Timer _flushUsersTimer;
+ private readonly TimeSpan _diagnosticRecordingInterval;
+ private readonly Object _diagnosticTimerLock = new Object();
+ private readonly Logger _logger;
+ private Timer _diagnosticTimer;
+ private AtomicBoolean _stopped;
+ private AtomicBoolean _offline;
+ private AtomicBoolean _sentInitialDiagnostics;
+ private AtomicBoolean _inputCapacityExceeded;
+
+ #endregion
+
+ #region Constructor
+
+ public EventProcessor(
+ EventsConfiguration config,
+ IEventSender eventSender,
+ IContextDeduplicator contextDeduplicator,
+ IDiagnosticStore diagnosticStore,
+ IDiagnosticDisabler diagnosticDisabler,
+ Logger logger,
+ Action testActionOnDiagnosticSend
+ )
+ {
+ _logger = logger;
+ _stopped = new AtomicBoolean(false);
+ _offline = new AtomicBoolean(false);
+ _sentInitialDiagnostics = new AtomicBoolean(false);
+ _inputCapacityExceeded = new AtomicBoolean(false);
+ _messageQueue = new BlockingCollection(
+ config.EventCapacity > 0 ? config.EventCapacity : 1);
+
+ _processorInternal = new EventProcessorInternal(
+ config,
+ _messageQueue,
+ eventSender,
+ contextDeduplicator,
+ diagnosticStore,
+ _logger,
+ testActionOnDiagnosticSend
+ );
+
+ if (config.EventFlushInterval > TimeSpan.Zero)
+ {
+ _flushTimer = new Timer(DoBackgroundFlush, null, config.EventFlushInterval,
+ config.EventFlushInterval);
+ }
+ _diagnosticStore = diagnosticStore;
+ _diagnosticRecordingInterval = config.DiagnosticRecordingInterval;
+ if (contextDeduplicator != null && contextDeduplicator.FlushInterval.HasValue)
+ {
+ _flushUsersTimer = new Timer(DoUserKeysFlush, null, contextDeduplicator.FlushInterval.Value,
+ contextDeduplicator.FlushInterval.Value);
+ }
+ else
+ {
+ _flushUsersTimer = null;
+ }
+
+ if (diagnosticStore != null)
+ {
+ SetupDiagnosticInit(diagnosticDisabler == null || !diagnosticDisabler.Disabled);
+
+ if (diagnosticDisabler != null)
+ {
+ diagnosticDisabler.DisabledChanged += ((sender, args) => SetupDiagnosticInit(!args.Disabled));
+ }
+ }
+ }
+
+ #endregion
+
+ #region Public methods
+
+ public void RecordEvaluationEvent(in EvaluationEvent e) =>
+ SubmitMessage(new EventProcessorInternal.EventMessage(e));
+
+ public void RecordIdentifyEvent(in IdentifyEvent e) =>
+ SubmitMessage(new EventProcessorInternal.EventMessage(e));
+
+ public void RecordCustomEvent(in CustomEvent e) =>
+ SubmitMessage(new EventProcessorInternal.EventMessage(e));
+
+ public void RecordMigrationOpEvent(in MigrationOpEvent e) =>
+ SubmitMessage(new EventProcessorInternal.EventMessage(e));
+
+ public void SetOffline(bool offline)
+ {
+ _offline.GetAndSet(offline);
+ // Note that the offline state is known only to DefaultEventProcessor, not to EventDispatcher. We will
+ // simply avoid sending any flush messages to EventDispatcher if we're offline. EventDispatcher will
+ // never initiate a flush on its own.
+ }
+
+ ///
+ /// Triggers an asynchronous event flush.
+ ///
+ public void Flush()
+ {
+ if (!_offline.Get())
+ {
+ SubmitMessage(new EventProcessorInternal.FlushMessage());
+ }
+ }
+
+ ///
+ /// Blocking version of .
+ ///
+ /// maximum time to wait; zero or negative timeout means indefinitely
+ /// true if completed, false if timed out
+ public bool FlushAndWait(TimeSpan timeout)
+ {
+ if (_offline.Get())
+ {
+ return false;
+ }
+ var message = new EventProcessorInternal.FlushMessage();
+ SubmitMessage(message);
+ return message.WaitForCompletion(timeout);
+ }
+
+ ///
+ /// Asynchronous version of .
+ ///
+ ///
+ /// The difference between this and is that you can await the task to simulate
+ /// blocking behavior.
+ ///
+ /// maximum time to wait; zero or negative timeout means indefinitely
+ /// a task that resolves to true if completed, false if timed out
+ public Task FlushAndWaitAsync(TimeSpan timeout)
+ {
+ if (_offline.Get())
+ {
+ return Task.FromResult(false);
+ }
+ var message = new EventProcessorInternal.FlushMessage();
+ SubmitMessage(message);
+ return message.WaitForCompletionAsync(timeout);
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ #endregion
+
+ #region Private methods
+
+ private void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ if (!_stopped.GetAndSet(true))
+ {
+ _flushTimer?.Dispose();
+ _flushUsersTimer?.Dispose();
+
+ SubmitMessage(new EventProcessorInternal.FlushMessage());
+ var message = new EventProcessorInternal.ShutdownMessage();
+ SubmitMessage(message);
+ message.WaitForCompletion();
+
+ _processorInternal.Dispose();
+ _messageQueue.CompleteAdding();
+ _messageQueue.Dispose();
+ }
+ }
+ }
+
+ private bool SubmitMessage(EventProcessorInternal.IEventMessage message)
+ {
+ try
+ {
+ if (_messageQueue.TryAdd(message))
+ {
+ _inputCapacityExceeded.GetAndSet(false);
+ }
+ else
+ {
+ // This doesn't mean that the output event buffer is full, but rather that the main thread is
+ // seriously backed up with not-yet-processed events. We shouldn't see this.
+ if (!_inputCapacityExceeded.GetAndSet(true))
+ {
+ _logger.Warn("Events are being produced faster than they can be processed");
+ }
+ // If the message is a flush message, then it could never be completed if we cannot
+ // add it to the queue. So we are going to complete it here to prevent the calling
+ // code from hanging indefinitely.
+ switch (message)
+ {
+ case EventProcessorInternal.FlushMessage fm:
+ fm.Completed();
+ break;
+ }
+ }
+ }
+ catch (InvalidOperationException)
+ {
+ // queue has been shut down
+ return false;
+ }
+ return true;
+ }
+
+ private void SetupDiagnosticInit(bool enabled)
+ {
+ lock (_diagnosticTimerLock)
+ {
+ _diagnosticTimer?.Dispose();
+ _diagnosticTimer = null;
+ if (enabled)
+ {
+ TimeSpan initialDelay = _diagnosticRecordingInterval - (DateTime.Now - _diagnosticStore.DataSince);
+ TimeSpan safeDelay =
+ (initialDelay < TimeSpan.Zero) ?
+ TimeSpan.Zero :
+ ((initialDelay > _diagnosticRecordingInterval) ? _diagnosticRecordingInterval : initialDelay);
+ _diagnosticTimer = new Timer(DoDiagnosticSend, null, safeDelay, _diagnosticRecordingInterval);
+ }
+ }
+ // Send initial and persisted unsent event the first time diagnostics are started
+ if (enabled && !_sentInitialDiagnostics.GetAndSet(true))
+ {
+ var unsent = _diagnosticStore.PersistedUnsentEvent;
+ var init = _diagnosticStore.InitEvent;
+ if (unsent.HasValue || init.HasValue)
+ {
+ Task.Run(async () => // do these in a single task for test determinacy
+ {
+ if (unsent.HasValue)
+ {
+ await _processorInternal.SendDiagnosticEventAsync(unsent.Value);
+ }
+ if (init.HasValue)
+ {
+ await _processorInternal.SendDiagnosticEventAsync(init.Value);
+ }
+ });
+ }
+ }
+ }
+
+ // exposed for testing
+ internal void WaitUntilInactive()
+ {
+ var message = new EventProcessorInternal.TestSyncMessage();
+ SubmitMessage(message);
+ message.WaitForCompletion();
+ }
+
+ private void DoBackgroundFlush(object stateInfo)
+ {
+ if (!_offline.Get())
+ {
+ SubmitMessage(new EventProcessorInternal.FlushMessage());
+ }
+ }
+
+ private void DoUserKeysFlush(object stateInfo)
+ {
+ SubmitMessage(new EventProcessorInternal.FlushContextsMessage());
+ }
+
+ // exposed for testing
+ internal void DoDiagnosticSend(object stateInfo)
+ {
+ SubmitMessage(new EventProcessorInternal.DiagnosticMessage());
+ }
+
+ #endregion
+ }
+}
diff --git a/pkgs/shared/internal/src/Events/EventProcessorInternal.cs b/pkgs/shared/internal/src/Events/EventProcessorInternal.cs
new file mode 100644
index 00000000..bbcbacd1
--- /dev/null
+++ b/pkgs/shared/internal/src/Events/EventProcessorInternal.cs
@@ -0,0 +1,470 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using LaunchDarkly.Logging;
+
+using static LaunchDarkly.Sdk.Internal.Events.EventTypes;
+
+namespace LaunchDarkly.Sdk.Internal.Events
+{
+ internal class EventProcessorInternal
+ {
+ #region Inner types
+
+ // These types are used only for communication between EventProcessor and
+ // EventProcessorInternal on their shared queue.
+
+ internal interface IEventMessage { }
+
+ internal class EventMessage : IEventMessage
+ {
+ internal object Event { get; }
+
+ internal EventMessage(object e)
+ {
+ Event = e;
+ }
+ }
+
+ internal class FlushMessage : SynchronousMessage { }
+
+ internal class FlushContextsMessage : IEventMessage { }
+
+ internal class DiagnosticMessage : IEventMessage { }
+
+ internal class SynchronousMessage : IEventMessage
+ {
+ internal readonly Semaphore _reply;
+ internal readonly TaskCompletionSource _asyncReply;
+
+ internal SynchronousMessage()
+ {
+ _reply = new Semaphore(0, 1);
+ _asyncReply = new TaskCompletionSource();
+ }
+
+ internal void WaitForCompletion()
+ {
+ _reply.WaitOne();
+ }
+
+ internal bool WaitForCompletion(TimeSpan timeout)
+ {
+ if (timeout <= TimeSpan.Zero)
+ {
+ WaitForCompletion();
+ return true;
+ }
+ return _reply.WaitOne(timeout);
+ }
+
+ internal Task WaitForCompletionAsync(TimeSpan timeout)
+ {
+ if (timeout <= TimeSpan.Zero)
+ {
+ return _asyncReply.Task;
+ }
+ var timeoutTask = Task.Delay(timeout).ContinueWith(t => false);
+ return Task.WhenAny(
+ _asyncReply.Task,
+ timeoutTask
+ ).Result;
+ }
+
+ internal void Completed()
+ {
+ _reply.Release();
+ _asyncReply.TrySetResult(true);
+ }
+ }
+
+ internal class TestSyncMessage : SynchronousMessage { }
+
+ internal class ShutdownMessage : SynchronousMessage { }
+
+ // DebugEvent and IndexEvent are defined here, instead of publicly in EventTypes, because they
+ // are never passed in via the public API; they're only created internally by EventProcessorInternal.
+
+ internal struct DebugEvent
+ {
+ public EvaluationEvent FromEvent;
+ }
+
+ internal struct IndexEvent
+ {
+ public UnixMillisecondTime Timestamp;
+ public Context Context;
+ }
+
+ internal struct FlushPayload
+ {
+ internal object[] Events { get; set; }
+ internal IReadOnlyList Summaries { get; set; }
+ }
+
+ internal sealed class EventBuffer
+ {
+ private readonly List