From ef4e59bf672ff76c66ba113ddf685ded48dc7611 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Thu, 25 Jun 2026 16:12:33 -0500 Subject: [PATCH 1/3] chore: Migrate dotnet-sdk-internal into dotnet-core Relocate the LaunchDarkly.InternalSdk package from the standalone dotnet-sdk-internal repo into pkgs/shared/internal, following the pattern used for dotnet-sdk-common (#105) and its follow-up corrections (#117/#121/#123/#124). - Copy src/ and test/ verbatim. Preserve the existing strong-name signing key: the same S3 source (launchdarkly-releaser/dotnet/LaunchDarkly.snk) is used, so the assembly public-key token is unchanged. - Add monorepo release wiring: github_actions.env, docfx.json, CI workflow (server-shared-internal.yml), release-please config + manifest (seeded at the current version 3.8.0), and release.yml / release-please.yml entries. - No consumer changes; the server/client SDKs keep their published LaunchDarkly.InternalSdk PackageReference (bumping that is a follow-up). SDK-2572 --- .github/workflows/release-please.yml | 14 + .github/workflows/release.yml | 1 + .github/workflows/server-shared-internal.yml | 48 + .release-please-manifest.json | 3 +- DotnetCore.sln | 21 + pkgs/shared/internal/CHANGELOG.md | 174 +++ pkgs/shared/internal/CONTRIBUTING.md | 41 + .../internal/LaunchDarkly.InternalSdk.pk | Bin 0 -> 160 bytes .../internal/LaunchDarkly.InternalSdk.sln | 23 + pkgs/shared/internal/PROVENANCE.md | 49 + pkgs/shared/internal/README.md | 42 + pkgs/shared/internal/docfx.json | 48 + pkgs/shared/internal/github_actions.env | 6 + pkgs/shared/internal/src/AssemblyInfo.cs | 8 + pkgs/shared/internal/src/AssemblyVersions.cs | 34 + .../internal/src/Concurrent/AsyncUtils.cs | 51 + .../internal/src/Concurrent/AtomicBoolean.cs | 38 + .../internal/src/Concurrent/StateMonitor.cs | 211 ++++ .../internal/src/Concurrent/TaskExecutor.cs | 153 +++ .../src/Events/AggregatedEventSummarizer.cs | 36 + .../internal/src/Events/DefaultEventSender.cs | 177 +++ .../src/Events/DiagnosticConfigProperties.cs | 102 ++ .../internal/src/Events/DiagnosticEvent.cs | 20 + .../internal/src/Events/DiagnosticId.cs | 19 + .../src/Events/DiagnosticStoreBase.cs | 274 +++++ .../src/Events/EventContextFormatter.cs | 161 +++ .../shared/internal/src/Events/EventOutput.cs | 328 +++++ .../internal/src/Events/EventProcessor.cs | 316 +++++ .../src/Events/EventProcessorInternal.cs | 470 ++++++++ .../internal/src/Events/EventSummarizer.cs | 198 +++ pkgs/shared/internal/src/Events/EventTypes.cs | 138 +++ .../src/Events/EventsConfiguration.cs | 55 + .../src/Events/IContextDeduplicator.cs | 30 + .../src/Events/IDiagnosticDisabler.cs | 32 + .../internal/src/Events/IDiagnosticStore.cs | 62 + .../internal/src/Events/IEventSender.cs | 42 + .../internal/src/Events/IEventSummarizer.cs | 41 + .../src/Events/PerContextEventSummarizer.cs | 60 + pkgs/shared/internal/src/Events/Sampler.cs | 39 + pkgs/shared/internal/src/HashCodeBuilder.cs | 41 + pkgs/shared/internal/src/Http/HttpErrors.cs | 37 + .../internal/src/Http/HttpProperties.cs | 320 +++++ .../src/Http/UnsuccessfulResponseException.cs | 36 + .../internal/src/JsonConverterHelpers.cs | 416 +++++++ .../src/LaunchDarkly.InternalSdk.csproj | 49 + pkgs/shared/internal/src/LogHelpers.cs | 26 + pkgs/shared/internal/src/UriExtensions.cs | 46 + .../internal/test/AssemblyVersionsTest.cs | 29 + .../test/Concurrent/AtomicBooleanTest.cs | 25 + .../test/Concurrent/StateMonitorTest.cs | 116 ++ .../test/Concurrent/TaskExecutorTest.cs | 163 +++ .../Events/AggregatedEventSummarizerTest.cs | 44 + .../test/Events/DefaultEventSenderTest.cs | 179 +++ .../Events/DiagnosticConfigPropertiesTest.cs | 204 ++++ .../internal/test/Events/DiagnosticIdTest.cs | 37 + .../test/Events/DiagnosticStoreBaseTest.cs | 245 ++++ .../test/Events/EventContextFormatterTest.cs | 211 ++++ .../internal/test/Events/EventOutputTest.cs | 767 ++++++++++++ .../test/Events/EventProcessorTest.cs | 1058 +++++++++++++++++ .../test/Events/EventSummarizerTest.cs | 82 ++ .../Events/PerContextEventSummarizerTest.cs | 106 ++ .../internal/test/Events/SamplerTest.cs | 47 + .../internal/test/Events/TestProperties.cs | 32 + .../internal/test/HashCodeBuilderTest.cs | 31 + .../test/Http/HttpPropertiesBuildingTest.cs | 150 +++ .../test/Http/HttpPropertiesHttpClientTest.cs | 131 ++ .../internal/test/JsonConverterHelpersTest.cs | 338 ++++++ .../LaunchDarkly.InternalSdk.Tests.csproj | 27 + pkgs/shared/internal/test/LogHelpersTest.cs | 29 + pkgs/shared/internal/test/TestUtil.cs | 39 + .../shared/internal/test/UriExtensionsTest.cs | 28 + release-please-config.json | 8 + 72 files changed, 8661 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/server-shared-internal.yml create mode 100644 pkgs/shared/internal/CHANGELOG.md create mode 100644 pkgs/shared/internal/CONTRIBUTING.md create mode 100644 pkgs/shared/internal/LaunchDarkly.InternalSdk.pk create mode 100644 pkgs/shared/internal/LaunchDarkly.InternalSdk.sln create mode 100644 pkgs/shared/internal/PROVENANCE.md create mode 100644 pkgs/shared/internal/README.md create mode 100644 pkgs/shared/internal/docfx.json create mode 100644 pkgs/shared/internal/github_actions.env create mode 100644 pkgs/shared/internal/src/AssemblyInfo.cs create mode 100644 pkgs/shared/internal/src/AssemblyVersions.cs create mode 100644 pkgs/shared/internal/src/Concurrent/AsyncUtils.cs create mode 100644 pkgs/shared/internal/src/Concurrent/AtomicBoolean.cs create mode 100644 pkgs/shared/internal/src/Concurrent/StateMonitor.cs create mode 100644 pkgs/shared/internal/src/Concurrent/TaskExecutor.cs create mode 100644 pkgs/shared/internal/src/Events/AggregatedEventSummarizer.cs create mode 100644 pkgs/shared/internal/src/Events/DefaultEventSender.cs create mode 100644 pkgs/shared/internal/src/Events/DiagnosticConfigProperties.cs create mode 100644 pkgs/shared/internal/src/Events/DiagnosticEvent.cs create mode 100644 pkgs/shared/internal/src/Events/DiagnosticId.cs create mode 100644 pkgs/shared/internal/src/Events/DiagnosticStoreBase.cs create mode 100644 pkgs/shared/internal/src/Events/EventContextFormatter.cs create mode 100644 pkgs/shared/internal/src/Events/EventOutput.cs create mode 100644 pkgs/shared/internal/src/Events/EventProcessor.cs create mode 100644 pkgs/shared/internal/src/Events/EventProcessorInternal.cs create mode 100644 pkgs/shared/internal/src/Events/EventSummarizer.cs create mode 100644 pkgs/shared/internal/src/Events/EventTypes.cs create mode 100644 pkgs/shared/internal/src/Events/EventsConfiguration.cs create mode 100644 pkgs/shared/internal/src/Events/IContextDeduplicator.cs create mode 100644 pkgs/shared/internal/src/Events/IDiagnosticDisabler.cs create mode 100644 pkgs/shared/internal/src/Events/IDiagnosticStore.cs create mode 100644 pkgs/shared/internal/src/Events/IEventSender.cs create mode 100644 pkgs/shared/internal/src/Events/IEventSummarizer.cs create mode 100644 pkgs/shared/internal/src/Events/PerContextEventSummarizer.cs create mode 100644 pkgs/shared/internal/src/Events/Sampler.cs create mode 100644 pkgs/shared/internal/src/HashCodeBuilder.cs create mode 100644 pkgs/shared/internal/src/Http/HttpErrors.cs create mode 100644 pkgs/shared/internal/src/Http/HttpProperties.cs create mode 100644 pkgs/shared/internal/src/Http/UnsuccessfulResponseException.cs create mode 100644 pkgs/shared/internal/src/JsonConverterHelpers.cs create mode 100644 pkgs/shared/internal/src/LaunchDarkly.InternalSdk.csproj create mode 100644 pkgs/shared/internal/src/LogHelpers.cs create mode 100644 pkgs/shared/internal/src/UriExtensions.cs create mode 100644 pkgs/shared/internal/test/AssemblyVersionsTest.cs create mode 100644 pkgs/shared/internal/test/Concurrent/AtomicBooleanTest.cs create mode 100644 pkgs/shared/internal/test/Concurrent/StateMonitorTest.cs create mode 100644 pkgs/shared/internal/test/Concurrent/TaskExecutorTest.cs create mode 100644 pkgs/shared/internal/test/Events/AggregatedEventSummarizerTest.cs create mode 100644 pkgs/shared/internal/test/Events/DefaultEventSenderTest.cs create mode 100644 pkgs/shared/internal/test/Events/DiagnosticConfigPropertiesTest.cs create mode 100644 pkgs/shared/internal/test/Events/DiagnosticIdTest.cs create mode 100644 pkgs/shared/internal/test/Events/DiagnosticStoreBaseTest.cs create mode 100644 pkgs/shared/internal/test/Events/EventContextFormatterTest.cs create mode 100644 pkgs/shared/internal/test/Events/EventOutputTest.cs create mode 100644 pkgs/shared/internal/test/Events/EventProcessorTest.cs create mode 100644 pkgs/shared/internal/test/Events/EventSummarizerTest.cs create mode 100644 pkgs/shared/internal/test/Events/PerContextEventSummarizerTest.cs create mode 100644 pkgs/shared/internal/test/Events/SamplerTest.cs create mode 100644 pkgs/shared/internal/test/Events/TestProperties.cs create mode 100644 pkgs/shared/internal/test/HashCodeBuilderTest.cs create mode 100644 pkgs/shared/internal/test/Http/HttpPropertiesBuildingTest.cs create mode 100644 pkgs/shared/internal/test/Http/HttpPropertiesHttpClientTest.cs create mode 100644 pkgs/shared/internal/test/JsonConverterHelpersTest.cs create mode 100644 pkgs/shared/internal/test/LaunchDarkly.InternalSdk.Tests.csproj create mode 100644 pkgs/shared/internal/test/LogHelpersTest.cs create mode 100644 pkgs/shared/internal/test/TestUtil.cs create mode 100644 pkgs/shared/internal/test/UriExtensionsTest.cs 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 0000000000000000000000000000000000000000..e3b0c9921a9af49dedf585cf250793e7d25676f9 GIT binary patch literal 160 zcmV;R0AK$ABme*efB*oL000060ssI2Bme+XQ$aBR1ONa50098UP}25gkmPHtlu3Jy z@~3molo3EyZc9#6E|MM4%&@}U$u!(eys=Njj7@d_U&KE1?SuAzvH)SF>Vvjc?d<}8 zNSvWpAvQEnZrG*V;`vxE-^9Btzbc$zym +``` +# 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-sdk-internal + - Build workflow:. .github/workflows/release-please.yml + - Signer repo:.... launchdarkly/dotnet-sdk-internal + - 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..975b1cb0 --- /dev/null +++ b/pkgs/shared/internal/README.md @@ -0,0 +1,42 @@ +# LaunchDarkly SDK .NET Internal Common Code + +[![NuGet](https://img.shields.io/nuget/v/LaunchDarkly.InternalSdk.svg?style=flat-square)](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](https://github.com/launchdarkly/dotnet-sdk-internal/blob/main/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..76b93105 --- /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 and Xamarin 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 _events; + private readonly IEventSummarizer _summarizer; + private readonly IDiagnosticStore _diagnosticStore; + private readonly int _capacity; + private readonly Logger _logger; + private bool _exceededCapacity; + + internal EventBuffer(int capacity, bool perContextSummaries, IDiagnosticStore diagnosticStore, Logger logger) + { + _capacity = capacity; + _events = new List(); + _summarizer = perContextSummaries + ? (IEventSummarizer)new PerContextEventSummarizer() + : new AggregatedEventSummarizer(); + _diagnosticStore = diagnosticStore; + _logger = logger; + } + + internal void AddEvent(object e) + { + if (_events.Count >= _capacity) + { + _diagnosticStore?.IncrementDroppedEvents(); + if (!_exceededCapacity) + { + _logger.Warn("Exceeded event queue capacity. Increase capacity to avoid dropping events."); + _exceededCapacity = true; + } + } + else + { + _events.Add(e); + _exceededCapacity = false; + } + } + + internal void AddToSummary(EvaluationEvent ee) => + _summarizer.SummarizeEvent(ee.Timestamp, ee.FlagKey, ee.FlagVersion, ee.Variation, ee.Value, ee.Default, + ee.Context); + + internal FlushPayload GetPayload() => + new FlushPayload { Events = _events.ToArray(), Summaries = _summarizer.GetSummariesAndReset() }; + + internal void Clear() + { + _events.Clear(); + _summarizer.Clear(); + } + } + + #endregion + + #region Private fields + + private static readonly int MaxFlushWorkers = 5; + + private readonly EventsConfiguration _config; + private readonly IDiagnosticStore _diagnosticStore; + private readonly IContextDeduplicator _contextDeduplicator; + private readonly CountdownEvent _flushWorkersCounter; + private readonly Action _testActionOnDiagnosticSend; + private readonly IEventSender _eventSender; + private readonly Logger _logger; + private long _lastKnownPastTime; + private volatile bool _disabled; + + #endregion + + #region Constructor + + internal EventProcessorInternal( + EventsConfiguration config, + BlockingCollection messageQueue, + IEventSender eventSender, + IContextDeduplicator contextDeduplicator, + IDiagnosticStore diagnosticStore, + Logger logger, + Action testActionOnDiagnosticSend + ) + { + _config = config; + _diagnosticStore = diagnosticStore; + _contextDeduplicator = contextDeduplicator; + _testActionOnDiagnosticSend = testActionOnDiagnosticSend; + _flushWorkersCounter = new CountdownEvent(1); + _eventSender = eventSender; + _logger = logger; + + EventBuffer buffer = new EventBuffer(config.EventCapacity > 0 ? config.EventCapacity : 1, + config.PerContextSummaries, _diagnosticStore, _logger); + + // Here we use TaskFactory.StartNew instead of Task.Run() because that allows us to specify the + // LongRunning option. This option tells the task scheduler that the task is likely to hang on + // to a thread for a long time, so it should consider growing the thread pool. + Task.Factory.StartNew( + () => RunMainLoop(messageQueue, buffer), + TaskCreationOptions.LongRunning + ); + } + + #endregion + + #region Public methods + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + #endregion + + #region Private methods + + private void Dispose(bool disposing) + { + if (disposing) + { + _eventSender.Dispose(); + } + } + + private void RunMainLoop(BlockingCollection messageQueue, EventBuffer buffer) + { + bool running = true; + while (running) + { + try + { + IEventMessage message = messageQueue.Take(); + switch (message) + { + case EventMessage em: + ProcessEvent(em.Event, buffer); + break; + case FlushMessage fm: + StartFlush(buffer, fm); + break; + case FlushContextsMessage fm: + if (_contextDeduplicator != null) + { + _contextDeduplicator.Flush(); + } + break; + case DiagnosticMessage dm: + SendAndResetDiagnostics(buffer); + break; + case TestSyncMessage tm: + WaitForFlushes(); + tm.Completed(); + break; + case ShutdownMessage sm: + WaitForFlushes(); + running = false; + sm.Completed(); + break; + } + } + catch (Exception e) + { + LogHelpers.LogException(_logger, "Unexpected error in event dispatcher thread", e); + } + } + } + + private void SendAndResetDiagnostics(EventBuffer buffer) + { + if (_diagnosticStore != null) + { + Task.Run(() => SendDiagnosticEventAsync(_diagnosticStore.CreateEventAndReset())); + } + } + + private void WaitForFlushes() + { + // Our CountdownEvent was initialized with a count of 1, so that's the lowest it can be at this point. + _flushWorkersCounter.Signal(); // Drop the count to zero if there are no active flush tasks. + _flushWorkersCounter.Wait(); // Wait until it is zero. + _flushWorkersCounter.Reset(1); + } + + private void ProcessEvent(object e, EventBuffer buffer) + { + if (_disabled) + { + return; + } + + // Decide whether to add the event to the payload. Feature events may be added twice, once for + // the event (if tracked) and once for debugging. + bool willAddFullEvent = true; + DebugEvent? debugEvent = null; + UnixMillisecondTime timestamp; + Context context = new Context(); + switch (e) + { + case EvaluationEvent ee: + if (!ee.ExcludeFromSummaries) + { + buffer.AddToSummary(ee); // only evaluation events go into the summarizer + } + + timestamp = ee.Timestamp; + context = ee.Context; + var samplingRatio = ee.SamplingRatio ?? 1; + willAddFullEvent = ee.TrackEvents && Sampler.Sample(samplingRatio); + if (ShouldDebugEvent(ee) && Sampler.Sample(samplingRatio)) + { + debugEvent = new DebugEvent { FromEvent = ee }; + } + break; + case IdentifyEvent ie: + timestamp = ie.Timestamp; + context = ie.Context; + break; + case CustomEvent ce: + timestamp = ce.Timestamp; + context = ce.Context; + break; + case MigrationOpEvent me: + if (Sampler.Sample(me.SamplingRatio)) + { + buffer.AddEvent(e); + } + + // Migration events do not need to generate index events, so we can just return here. + return; + default: + timestamp = new UnixMillisecondTime(); + break; + } + + // Tell the context deduplicator, if any, about this user; this may produce an index event. + // We only need to do this if there is *not* already going to be a full-fidelity event + // containing an inline user. + if (context.Defined && _contextDeduplicator != null) + { + bool needUserEvent = _contextDeduplicator.ProcessContext(context); + if (needUserEvent && !(e is IdentifyEvent)) + { + IndexEvent ie = new IndexEvent { Timestamp = timestamp, Context = context }; + buffer.AddEvent(ie); + } + else if (!(e is IdentifyEvent)) + { + _diagnosticStore?.IncrementDeduplicatedUsers(); + } + } + + if (willAddFullEvent) + { + buffer.AddEvent(e); + } + if (debugEvent != null) + { + buffer.AddEvent(debugEvent); + } + } + + private bool ShouldDebugEvent(EvaluationEvent fe) + { + if (fe.DebugEventsUntilDate != null) + { + long lastPast = Interlocked.Read(ref _lastKnownPastTime); + if (fe.DebugEventsUntilDate.Value.Value > lastPast && + fe.DebugEventsUntilDate.Value.Value > UnixMillisecondTime.Now.Value) + { + return true; + } + } + return false; + } + + // Grabs a snapshot of the current internal state, and starts a new task to send it to the server. + private void StartFlush(EventBuffer buffer, SynchronousMessage message) + { + if (_disabled) + { + message.Completed(); + return; + } + FlushPayload payload = buffer.GetPayload(); + if (_diagnosticStore != null) + { + _diagnosticStore.RecordEventsInBatch(payload.Events.Length); + } + if (payload.Events.Length > 0 || payload.Summaries.Count > 0) + { + lock (_flushWorkersCounter) + { + // Note that this counter will be 1, not 0, when there are no active flush workers. + // This is because a .NET CountdownEvent can't be reused without explicitly resetting + // it once it has gone to zero. + if (_flushWorkersCounter.CurrentCount >= MaxFlushWorkers + 1) + { + // We already have too many workers, so just leave the events as is + message.Completed(); + return; + } + // We haven't hit the limit, we'll go ahead and start a flush task + _flushWorkersCounter.AddCount(1); + } + buffer.Clear(); + Task.Run(async () => { + try + { + await FlushEventsAsync(payload); + } + finally + { + _flushWorkersCounter.Signal(); + message.Completed(); + } + }); + } + else + { + // There are no events to flush. If we don't complete the message, then the async task may never + // complete (if it had a non-zero positive timeout, then it would complete after the timeout). + message.Completed(); + } + } + + private async Task FlushEventsAsync(FlushPayload payload) + { + EventOutputFormatter formatter = new EventOutputFormatter(_config); + byte[] jsonEvents; + int eventCount; + try + { + jsonEvents = formatter.SerializeOutputEvents(payload.Events, payload.Summaries, out eventCount); + } + catch (Exception e) + { + LogHelpers.LogException(_logger, "Error preparing events, will not send", e); + return; + } + + var result = await _eventSender.SendEventDataAsync(EventDataKind.AnalyticsEvents, + jsonEvents, eventCount); + if (result.Status == DeliveryStatus.FailedAndMustShutDown) + { + _disabled = true; + } + if (result.TimeFromServer.HasValue) + { + Interlocked.Exchange(ref _lastKnownPastTime, + UnixMillisecondTime.FromDateTime(result.TimeFromServer.Value).Value); + } + } + + internal async Task SendDiagnosticEventAsync(DiagnosticEvent diagnostic) + { + var jsonDiagnostic = JsonSerializer.SerializeToUtf8Bytes(diagnostic.JsonValue); + await _eventSender.SendEventDataAsync(EventDataKind.DiagnosticEvent, jsonDiagnostic, 1); + _testActionOnDiagnosticSend?.Invoke(); + } + + #endregion + } +} diff --git a/pkgs/shared/internal/src/Events/EventSummarizer.cs b/pkgs/shared/internal/src/Events/EventSummarizer.cs new file mode 100644 index 00000000..684e0a4a --- /dev/null +++ b/pkgs/shared/internal/src/Events/EventSummarizer.cs @@ -0,0 +1,198 @@ +using System.Collections.Generic; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + // Accumulates summary counters for a single context. The aggregated and per-context + // summarizers are both built on top of this. For the aggregated case the context is + // undefined and is not included in the output; for the per-context case each instance + // owns the context whose evaluations it summarizes. + internal sealed class EventSummarizer + { + private readonly Context _context; + private EventSummary _eventsState; + + public EventSummarizer() : this(default) { } + + public EventSummarizer(Context context) + { + _context = context; + _eventsState = new EventSummary(context); + } + + // Adds this event to our counters, if it is a type of event we need to count. + public void SummarizeEvent( + UnixMillisecondTime timestamp, + string flagKey, + int? flagVersion, + int? variation, + in LdValue value, + in LdValue defaultValue, + in Context context + ) + { + _eventsState.IncrementCounter(flagKey, variation, flagVersion, value, defaultValue, context); + _eventsState.NoteTimestamp(timestamp); + } + + public bool Empty => _eventsState.Empty; + + // Returns the current summarized event data and resets the state to empty. + public EventSummary GetSummaryAndReset() + { + EventSummary ret = _eventsState; + _eventsState = new EventSummary(_context); + return ret; + } + + public void Clear() + { + _eventsState = new EventSummary(_context); + } + } + + internal sealed class EventSummary + { + public readonly Dictionary Flags = + new Dictionary(); + + // The context whose evaluations this summary describes, or an undefined context for an + // aggregated summary. When defined, it is serialized onto the summary event. + public Context Context { get; } + + public UnixMillisecondTime StartDate { get; private set; } + public UnixMillisecondTime EndDate { get; private set; } + + public bool Empty => Flags.Count == 0; + + public EventSummary() { } + + public EventSummary(Context context) + { + Context = context; + } + + public void IncrementCounter(string key, int? variation, int? version, LdValue flagValue, LdValue defaultVal, in Context context) + { + if (!Flags.TryGetValue(key, out var flagSummary)) + { + flagSummary = new FlagSummary(key, defaultVal); + Flags[key] = flagSummary; + } + + var contextKinds = flagSummary.ContextKinds; + if (context.Multiple) + { + foreach (var mc in context.MultiKindContexts) + { + contextKinds.Add(mc.Kind.Value); + } + } + else + { + contextKinds.Add(context.Kind.Value); + } + + EventsCounterKey counterKey = new EventsCounterKey(version, variation); + if (flagSummary.Counters.TryGetValue(counterKey, out EventsCounterValue value)) + { + value.Increment(); + } + else + { + flagSummary.Counters[counterKey] = new EventsCounterValue(1, flagValue); + } + } + + public void NoteTimestamp(UnixMillisecondTime timestamp) + { + if (StartDate.Value == 0 || timestamp.Value < StartDate.Value) + { + StartDate = timestamp; + } + if (timestamp.Value > EndDate.Value) + { + EndDate = timestamp; + } + } + } + + internal sealed class FlagSummary + { + public readonly string Key; + public readonly LdValue Default; + public readonly HashSet ContextKinds = new HashSet(); + public readonly Dictionary Counters = + new Dictionary(); + + public FlagSummary(string key, LdValue defaultVal) + { + Key = key; + Default = defaultVal; + } + } + + internal sealed class EventsCounterKey + { + public readonly int? Version; + public readonly int? Variation; + + public EventsCounterKey(int? version, int? variation) + { + Version = version; + Variation = variation; + } + + // Required because we use this class as a dictionary key + public override bool Equals(object obj) + { + if (obj is EventsCounterKey o) + { + return Variation == o.Variation && Version == o.Version; + } + return false; + } + + // Required because we use this class as a dictionary key + public override int GetHashCode() => + (Variation ?? -1) * 17 + (Version ?? -1); + } + + internal sealed class EventsCounterValue + { + public int Count; + public readonly LdValue FlagValue; + + public EventsCounterValue(int count, in LdValue flagValue) + { + Count = count; + FlagValue = flagValue; + } + + public void Increment() + { + Count++; + } + + // Used only in tests + public override bool Equals(object obj) + { + if (obj is EventsCounterValue o) + { + return Count == o.Count && object.Equals(FlagValue, o.FlagValue); + } + return false; + } + + // Used only in tests + public override int GetHashCode() + { + return HashCodeBuilder.New().With(Count).With(FlagValue).Value; + } + + // Used only in tests + public override string ToString() + { + return "{" + Count + ", " + FlagValue + "}"; + } + } +} diff --git a/pkgs/shared/internal/src/Events/EventTypes.cs b/pkgs/shared/internal/src/Events/EventTypes.cs new file mode 100644 index 00000000..be776455 --- /dev/null +++ b/pkgs/shared/internal/src/Events/EventTypes.cs @@ -0,0 +1,138 @@ +namespace LaunchDarkly.Sdk.Internal.Events +{ + /// + /// This class contains inner types that are used as parameter types for the EventProcessor + /// methods for recording different kinds of events. + /// + /// + /// + /// The reason we define these as types, rather than just having those methods take a bunch + /// of individual parameters like timestamp and key, is to make it as easy as possible to add + /// new optional properties: adding a parameter to a method is a backward-incompatible change + /// (unless we add overloads, in which case we'll keep accumulating overloads) whereas adding + /// a property to a struct means it'll just have its default value if the caller doesn't set + /// it. We could get a similar effect by using named method parameters with defaults, but + /// that only ensures compile-time compatibility - at runtime the method still has a + /// different signature - so this way is a bit cleaner. + /// + /// + /// Note that these are declared as structs rather than classes so that, at least when + /// they're originally created, they can live on the stack instead of the heap. They will + /// still eventually end up getting treated as class instances and moved to the heap, because + /// of .NET's boxing rules: whenever a struct is treated as a dynamically-typed object (i.e. + /// when we wrap it in an EventMessage to put it on our queue), it is boxed. But until that + /// point, application code can freely create these and pass them around without incurring + /// any allocations, right up until the moment when EventProcessor decides to put the event + /// onto a queue (if it does). This is also why there's a separate EventProcessor method + /// for each type, rather than having a single RecordEvent that takes a base type: using + /// polymorphism in that way would cause boxing to always happen. + /// + /// + public static class EventTypes + { + /// + /// Parameters for . + /// Note that the "kind" string identifying this type of event in JSON data is "feature", + /// not "evaluation". + /// + public struct EvaluationEvent + { + public UnixMillisecondTime Timestamp; + public Context Context; + public string FlagKey; + public int? FlagVersion; + public int? Variation; + public LdValue Value; + public LdValue Default; + public EvaluationReason? Reason; + public string PrereqOf; + public bool TrackEvents; + public UnixMillisecondTime? DebugEventsUntilDate; + // This is optional so that the default value can be disambiguated from 0. + // Allowing libraries to update to the version where this was introduced without inadvertently + // disabling events. + public long? SamplingRatio; + public bool ExcludeFromSummaries; + } + + /// + /// Parameters for . + /// + public struct IdentifyEvent + { + public UnixMillisecondTime Timestamp; + public Context Context; + } + + /// + /// Parameters for . + /// + public struct CustomEvent + { + public UnixMillisecondTime Timestamp; + public Context Context; + public string EventKey; + public LdValue Data; + public double? MetricValue; + } + + /// + /// Parameters for + /// + public struct MigrationOpEvent + { + #region Measurement Types + + public struct InvokedMeasurement + { + public bool Old; + public bool New; + } + + public struct LatencyMeasurement + { + public long? Old; + public long? New; + } + + public struct ErrorMeasurement + { + public bool Old; + public bool New; + } + + public struct ConsistentMeasurement + { + public bool IsConsistent; + public long SamplingRatio; + } + + #endregion + + public UnixMillisecondTime Timestamp; + public Context Context; + public string Operation; + public long SamplingRatio; + + #region Evaluation Detail + + public string FlagKey; + public int? FlagVersion; + public int? Variation; + public LdValue Value; + public LdValue Default; + public EvaluationReason? Reason; + + #endregion + + #region Measurements + + public InvokedMeasurement Invoked; + public LatencyMeasurement? Latency; + public ErrorMeasurement? Error; + public ConsistentMeasurement? Consistent; + + #endregion + } + } +} diff --git a/pkgs/shared/internal/src/Events/EventsConfiguration.cs b/pkgs/shared/internal/src/Events/EventsConfiguration.cs new file mode 100644 index 00000000..122d719a --- /dev/null +++ b/pkgs/shared/internal/src/Events/EventsConfiguration.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Immutable; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + /// + /// Internal configuration properties for the events system. + /// + /// + /// + /// The corresponding properties may or may not be configurable in the public SDK APIs. + /// + /// + /// For simplicity in construction, this is a mutable class, but components should not + /// modify its properties after passing it to another component. This is not a major + /// risk since we do not expose this object in the public API and the SDKs have no + /// reason to retain it after creating the event components. + /// + /// + /// Only options that affect the common events implementation code in + /// LaunchDarkly.InternalSdk are included here. Anything that is specific to + /// LaunchDarkly.ServerSdk or LaunchDarkly.ClientSdk is not included: for instance, + /// the cache settings for context deduplication are not included, because that + /// behavior is implemented only in the server-side SDK. + /// + /// + public sealed class EventsConfiguration + { + public bool AllAttributesPrivate { get; set; } + + public TimeSpan DiagnosticRecordingInterval { get; set; } + + public Uri DiagnosticUri { get; set; } + + public int EventCapacity { get; set; } + + public TimeSpan EventFlushInterval { get; set; } + + public Uri EventsUri { get; set; } + + public IImmutableSet PrivateAttributes { get; set; } + + public TimeSpan? RetryInterval { get; set; } + + /// + /// True if the events system should emit a separate summary event for each evaluation + /// context, with the context attached, instead of a single aggregated summary. + /// + /// + /// Client-side SDKs enable this. Server-side SDKs leave it at the default of false, which + /// preserves the original single aggregated summary behavior. + /// + public bool PerContextSummaries { get; set; } + } +} diff --git a/pkgs/shared/internal/src/Events/IContextDeduplicator.cs b/pkgs/shared/internal/src/Events/IContextDeduplicator.cs new file mode 100644 index 00000000..f5c3162b --- /dev/null +++ b/pkgs/shared/internal/src/Events/IContextDeduplicator.cs @@ -0,0 +1,30 @@ +using System; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + /// + /// Interface for a strategy for removing duplicate contexts from the event stream. This has + /// been factored out of because the client-side and + /// server-side clients behave differently (client-side does not send index events). + /// + public interface IContextDeduplicator + { + /// + /// The interval, if any, at which the event processor should call Flush. + /// + TimeSpan? FlushInterval { get; } + + /// + /// Updates the internal state if necessary to reflect that we have seen the given context. + /// Returns true if it is time to insert an index event for this context into the event output. + /// + /// a context object + /// true if an index event should be emitted + bool ProcessContext(in Context context); + + /// + /// Forgets any cached context information, so all subsequent contexs will be treated as new. + /// + void Flush(); + } +} diff --git a/pkgs/shared/internal/src/Events/IDiagnosticDisabler.cs b/pkgs/shared/internal/src/Events/IDiagnosticDisabler.cs new file mode 100644 index 00000000..bdc0a6ef --- /dev/null +++ b/pkgs/shared/internal/src/Events/IDiagnosticDisabler.cs @@ -0,0 +1,32 @@ +using System; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + public class DisabledChangedArgs : EventArgs + { + public bool Disabled { get; } + + public DisabledChangedArgs(bool disabled) + { + Disabled = disabled; + } + } + + /// + /// An interface provided to DefaultEventProcessor to indicate when the sending of diagnostic + /// events should be disabled. This is used for mobile platforms to disable diagnostic events + /// when the application is in the background. + /// + public interface IDiagnosticDisabler + { + /// + /// An event listener that can be called to switch the diagnostics feature of the event + /// processor from disabled to enabled or vice versa. + /// + event EventHandler DisabledChanged; + /// + /// Whether the DefaultEventProcessor should currently send any diagnostic events. + /// + bool Disabled { get; } + } +} diff --git a/pkgs/shared/internal/src/Events/IDiagnosticStore.cs b/pkgs/shared/internal/src/Events/IDiagnosticStore.cs new file mode 100644 index 00000000..4ccd2fc3 --- /dev/null +++ b/pkgs/shared/internal/src/Events/IDiagnosticStore.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + /// + /// This interface is for providing to the DefaultEventProcessor and StreamManager. It is + /// responsible for providing diagnostic data specific to the platform implementation for the + /// DefaultEventManager to send to LaunchDarkly. Periodic diagnostic events include data + /// collected during operation (stream initializations and counters) that the IDiagnosticStore + /// implementation stores until CreateEventAndReset is called to retrieve a full event including + /// the collected data and diagnostic session identifier. + /// + public interface IDiagnosticStore + { + /// + /// The last time the periodic data was reset for the current diagnosticId. This may be from + /// before the SDK initialized if periodic diagnostic data has been persisted from a + /// previous initialization, but the session was not considered ended (meaning the same + /// diagnostic id) when the current initialization occurred. For the server SDK this will be + /// the time of initialization. + /// + DateTime DataSince { get; } + /// + /// An event to be sent for a new diagnostic id, if the initialization represented a new + /// diagnostic id. + /// + DiagnosticEvent? InitEvent { get; } + /// + /// Persisted periodic diagnostic data from a previous initialization. This should be set + /// with the data from the previous diagnostic id if the initialization caused a switch of + /// diagnostic id and there is periodic diagnostics data available for the previous id. + /// + DiagnosticEvent? PersistedUnsentEvent { get; } + /// + /// Called when the user deduplicator prevents a user from being indexed. + /// + void IncrementDeduplicatedUsers(); + /// + /// Called when an event is dropped due to a full event buffer. + /// + void IncrementDroppedEvents(); + /// + /// Called when a stream init completes with details of the initialization. + /// + /// The time at which the stream began attempted initialization. + /// The duration of the stream initialization attempt. + /// True if the initialization failed, false otherwise. + void AddStreamInit(DateTime timestamp, TimeSpan duration, bool failed); + /// + /// Called when flushing events, recording how many events were contained in the flush payload. + /// + /// The number of events that were flushed. + void RecordEventsInBatch(long eventsInBatch); + /// + /// Called to generate a periodic diagnostic event, resetting the store counts and stream + /// initializations. + /// + /// A dictionary representing the periodic diagnostic event + DiagnosticEvent CreateEventAndReset(); + } +} diff --git a/pkgs/shared/internal/src/Events/IEventSender.cs b/pkgs/shared/internal/src/Events/IEventSender.cs new file mode 100644 index 00000000..2ca7a65f --- /dev/null +++ b/pkgs/shared/internal/src/Events/IEventSender.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + /// + /// An abstraction of the mechanism for sending event JSON data. + /// + /// + /// The only implementation the SDKs use is , but the interface + /// allows us to use a test fixture in tests. + /// + public interface IEventSender : IDisposable + { + Task SendEventDataAsync(EventDataKind kind, byte[] data, int eventCount); + } + + public enum EventDataKind + { + AnalyticsEvents, + DiagnosticEvent + }; + + public enum DeliveryStatus + { + Succeeded, + Failed, + FailedAndMustShutDown + }; + + public struct EventSenderResult + { + public DeliveryStatus Status { get; private set; } + public DateTime? TimeFromServer { get; private set; } + + public EventSenderResult(DeliveryStatus status, DateTime? timeFromServer) + { + Status = status; + TimeFromServer = timeFromServer; + } + } +} diff --git a/pkgs/shared/internal/src/Events/IEventSummarizer.cs b/pkgs/shared/internal/src/Events/IEventSummarizer.cs new file mode 100644 index 00000000..4b9c30d6 --- /dev/null +++ b/pkgs/shared/internal/src/Events/IEventSummarizer.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + /// + /// Strategy for summarizing evaluation events into summary events. + /// + /// + /// + /// Implementations provide either a single aggregated summary covering all contexts, or one + /// summary per context with the context attached. The behavior is selected by the events + /// configuration so that client-side and server-side SDKs can differ without changing the + /// rest of the event pipeline. + /// + /// + /// Implementations are deliberately not thread-safe; they are always invoked from the event + /// processor's single message-processing thread. + /// + /// + internal interface IEventSummarizer + { + // Adds information about an evaluation to the summary. + void SummarizeEvent( + UnixMillisecondTime timestamp, + string flagKey, + int? flagVersion, + int? variation, + in LdValue value, + in LdValue defaultValue, + in Context context + ); + + // Returns the current summary data and resets the state to empty. The aggregated + // implementation returns at most one summary; the per-context implementation returns one + // per context that had events. Empty summaries are not included. + IReadOnlyList GetSummariesAndReset(); + + // Discards all accumulated summary data. + void Clear(); + } +} diff --git a/pkgs/shared/internal/src/Events/PerContextEventSummarizer.cs b/pkgs/shared/internal/src/Events/PerContextEventSummarizer.cs new file mode 100644 index 00000000..19f5afc1 --- /dev/null +++ b/pkgs/shared/internal/src/Events/PerContextEventSummarizer.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + /// + /// Produces a separate summary event for each context, with the context attached, so that + /// analytics can attribute evaluations to specific contexts. This is used by client-side SDKs. + /// + /// + /// + /// A separate is maintained per context. Contexts are used + /// directly as dictionary keys; provides value-based equality and hashing + /// over all of its attributes, so two contexts map to the same summary only when every attribute + /// matches. + /// + /// + /// Not thread-safe; always invoked from the event processor's single message-processing thread. + /// + /// + internal sealed class PerContextEventSummarizer : IEventSummarizer + { + private readonly Dictionary _summarizersByContext = + new Dictionary(); + + public void SummarizeEvent( + UnixMillisecondTime timestamp, + string flagKey, + int? flagVersion, + int? variation, + in LdValue value, + in LdValue defaultValue, + in Context context + ) + { + if (!_summarizersByContext.TryGetValue(context, out var summarizer)) + { + summarizer = new EventSummarizer(context); + _summarizersByContext[context] = summarizer; + } + summarizer.SummarizeEvent(timestamp, flagKey, flagVersion, variation, value, defaultValue, context); + } + + public IReadOnlyList GetSummariesAndReset() + { + var summaries = new List(); + foreach (var summarizer in _summarizersByContext.Values) + { + EventSummary summary = summarizer.GetSummaryAndReset(); + if (!summary.Empty) + { + summaries.Add(summary); + } + } + _summarizersByContext.Clear(); + return summaries; + } + + public void Clear() => _summarizersByContext.Clear(); + } +} diff --git a/pkgs/shared/internal/src/Events/Sampler.cs b/pkgs/shared/internal/src/Events/Sampler.cs new file mode 100644 index 00000000..e512d2cd --- /dev/null +++ b/pkgs/shared/internal/src/Events/Sampler.cs @@ -0,0 +1,39 @@ +using System; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + /// + /// Class used for event sampling. + /// + public static class Sampler + { + private static readonly Random Rand = new Random(); + private static readonly object RandLock = new object(); + + /// + /// Given a ratio determine if an event should be sampled. + /// + /// This function is thread-safe. + /// 0 means never sample and 1 means always sample + /// the sampling ratio + /// true if it should be sampled + public static bool Sample(long samplingRatio) + { + if (samplingRatio <= 0) return false; + if (samplingRatio == 1) return true; + // Random instances are not thread-safe. + // https://learn.microsoft.com/en-us/dotnet/api/system.random?view=net-6.0#ThreadSafety + lock (RandLock) + { + #if NET6_0_OR_GREATER + return Rand.NextInt64(samplingRatio) == 0; + #else + // Prior to .Net 6 there was not a 64 bit rand method. + // So this caps it to int.MaxValue. + return Rand.Next( + (int)Math.Min(int.MaxValue, samplingRatio)) == 0; + #endif + } + } + } +} diff --git a/pkgs/shared/internal/src/HashCodeBuilder.cs b/pkgs/shared/internal/src/HashCodeBuilder.cs new file mode 100644 index 00000000..e9bfa3a0 --- /dev/null +++ b/pkgs/shared/internal/src/HashCodeBuilder.cs @@ -0,0 +1,41 @@ + +namespace LaunchDarkly.Sdk.Internal +{ + /// + /// Helper type for building implementations of object.GetHashCode(). + /// + /// + /// + /// var hashValue = HashCodeBuilder.New() + /// .With(someValue1) + /// .With(someValue2) + /// .Value; + /// + /// + public struct HashCodeBuilder + { + /// + /// The result value. + /// + public int Value { get; } + + private HashCodeBuilder(int value) + { + Value = value; + } + + /// + /// Creates a new builder. + /// + /// + public static HashCodeBuilder New() => new HashCodeBuilder(0); + + /// + /// Returns an updated builder with the hash value for the given object added in. + /// + /// any object; may be null + /// an updated builder + public HashCodeBuilder With(object o) => + new HashCodeBuilder(Value * 17 + (o == null ? 0 : o.GetHashCode())); + } +} diff --git a/pkgs/shared/internal/src/Http/HttpErrors.cs b/pkgs/shared/internal/src/Http/HttpErrors.cs new file mode 100644 index 00000000..7a3bad27 --- /dev/null +++ b/pkgs/shared/internal/src/Http/HttpErrors.cs @@ -0,0 +1,37 @@ + +namespace LaunchDarkly.Sdk.Internal.Http +{ + /// + /// Helper methods to provide standardized HTTP error-handling behavior in the SDKs. + /// + public static class HttpErrors + { + /// + /// Returns true if this type of error could be expected to eventually resolve itself, + /// or false if it indicates a configuration problem or client logic error such that the + /// client should give up on making any further requests. + /// + /// a status code + /// true if retrying is appropriate + public static bool IsRecoverable(int status) + { + if (status >= 400 && status <= 499) + { + return (status == 400) || (status == 408) || (status == 429); + } + return true; + } + + public static string ErrorMessage(int status, string context, string recoverableMessage) => + string.Format("{0} for {1} - {2}", + ErrorMessageBase(status), + context, + IsRecoverable(status) ? recoverableMessage : "giving up permanently" + ); + + public static string ErrorMessageBase(int status) => + string.Format("HTTP error {0}{1}", + status, + (status == 401 || status == 403) ? " (invalid SDK key)" : ""); + } +} diff --git a/pkgs/shared/internal/src/Http/HttpProperties.cs b/pkgs/shared/internal/src/Http/HttpProperties.cs new file mode 100644 index 00000000..c2e3ce7b --- /dev/null +++ b/pkgs/shared/internal/src/Http/HttpProperties.cs @@ -0,0 +1,320 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Net; +using System.Net.Http; +using LaunchDarkly.Sdk.Helpers; + +namespace LaunchDarkly.Sdk.Internal.Http +{ + /// + /// Internal representation of HTTP options that are supported by both .NET and Xamarin SDKs, + /// including the logic for constructing the standard set of headers for HTTP requests. + /// + /// + /// This is an immutable struct. The "With" methods for setting properties will return a new + /// struct based on the current instance. + /// + public struct HttpProperties + { + /// + /// An arbitrary default for ConnectTimeout. SDKs should define their own defaults. + /// + public static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(1); + + /// + /// An arbitrary default for ReadTimeout. SDKs should define their own defaults. + /// + public static readonly TimeSpan DefaultReadTimeout = TimeSpan.FromSeconds(1); + + /// + /// Headers that should be included in every request. + /// + public ImmutableList> BaseHeaders { get; } + + /// + /// The configured TCP connection timeout. + /// + /// + /// This is used supported in .NET Core and .NET 5+, where SocketsHttpHandler is available. + /// It is ignored in other platforms, and it is ignored if a custom HTTP handler is specified. + /// + public TimeSpan ConnectTimeout { get; } + + /// + /// A function that transforms platform-specific exceptions if necessary. + /// + /// + /// For mobile platforms where HTTP requests might throw platform-specific exceptions, + /// you can provide a function to translate them to standard .NET exceptions. By default, + /// exceptions are not changed. + /// + public Func HttpExceptionConverter { get; } + + /// + /// A function to create an HTTP handler, or null for the standard one. + /// + /// + /// If specified, this factory will be called with the other properties as a parameter. This + /// may be necessary on platforms like Xamarin where the SDK may want to use a platform-specific + /// class that needs to be configured at the handler level. + /// + public Func HttpMessageHandlerFactory { get; } + + /// + /// The proxy configuration, if any. + /// + /// + /// This is only present if a proxy was specified programmatically, not if it was + /// specified with an environment variable. + /// + public IWebProxy Proxy { get; } + + /// + /// The configured TCP socket read timeout. + /// + /// + /// See comments on regarding timeouts. + /// + public TimeSpan ReadTimeout { get; } + + private HttpProperties( + ImmutableList> baseHeaders, + TimeSpan connectTimeout, + Func httpExceptionConverter, + Func httpMessageHandlerFactory, + IWebProxy proxy, + TimeSpan readTimeout + ) + { + BaseHeaders = baseHeaders; + ConnectTimeout = connectTimeout; + HttpExceptionConverter = httpExceptionConverter; + HttpMessageHandlerFactory = httpMessageHandlerFactory; + Proxy = proxy; + ReadTimeout = readTimeout; + } + + /// + /// An instance with default properties. + /// + public static HttpProperties Default => + new HttpProperties( + ImmutableList.Create>(), + DefaultConnectTimeout, + e => e, + null, + null, + DefaultReadTimeout + ); + + public HttpProperties WithConnectTimeout(TimeSpan newConnectTimeout) => + new HttpProperties( + BaseHeaders, + newConnectTimeout, + HttpExceptionConverter, + HttpMessageHandlerFactory, + Proxy, + ReadTimeout + ); + + public HttpProperties WithHttpMessageHandlerFactory(Func factory) => + new HttpProperties( + BaseHeaders, + ConnectTimeout, + HttpExceptionConverter, + factory, + Proxy, + ReadTimeout + ); + + public HttpProperties WithHttpExceptionConverter(Func newHttpExceptionConverter) => + new HttpProperties( + BaseHeaders, + ConnectTimeout, + newHttpExceptionConverter, + HttpMessageHandlerFactory, + Proxy, + ReadTimeout + ); + + public HttpProperties WithProxy(IWebProxy newProxy) => + new HttpProperties( + BaseHeaders, + ConnectTimeout, + HttpExceptionConverter, + HttpMessageHandlerFactory, + newProxy, + ReadTimeout + ); + + public HttpProperties WithReadTimeout(TimeSpan newReadTimeout) => + new HttpProperties( + BaseHeaders, + ConnectTimeout, + HttpExceptionConverter, + HttpMessageHandlerFactory, + Proxy, + newReadTimeout + ); + + public HttpProperties WithAuthorizationKey(string key) => + string.IsNullOrEmpty(key) ? this : WithHeader("Authorization", key); + + public HttpProperties WithUserAgent(string userAgent) => + string.IsNullOrEmpty(userAgent) ? this : WithHeader("User-Agent", userAgent); + + public HttpProperties WithUserAgent(string userAgentName, string userAgentVersion) => + string.IsNullOrEmpty(userAgentName) + ? this + : WithHeader("User-Agent", userAgentName + "/" + userAgentVersion); + + public HttpProperties WithWrapper(string wrapperName, string wrapperVersion) => + string.IsNullOrEmpty(wrapperName) + ? this + : WithHeader("X-LaunchDarkly-Wrapper", + string.IsNullOrEmpty(wrapperVersion) ? wrapperName : wrapperName + "/" + wrapperVersion); + + public HttpProperties WithApplicationTags(ApplicationInfo applicationInfo) + { + string headerValue = ApplicationTagHeaderValue(applicationInfo); + if (string.IsNullOrEmpty(headerValue)) + { + return this; + } + + return WithHeader("X-LaunchDarkly-Tags", headerValue); + } + + public HttpProperties WithHeader(string name, string value) + { + // Avoiding IEnumberable as it fails to properly optimize for ARM64 + var headers = new List>(); + foreach (var header in BaseHeaders) + { + if (!string.Equals(header.Key, name, StringComparison.OrdinalIgnoreCase)) + { + headers.Add(header); + } + } + headers.Add(new KeyValuePair(name, value)); + + return new HttpProperties( + headers.ToImmutableList(), + ConnectTimeout, + HttpExceptionConverter, + HttpMessageHandlerFactory, + Proxy, + ReadTimeout + ); + } + + /// + /// Adds BaseHeaders to a request. + /// + /// the HTTP request + public void AddHeaders(HttpRequestMessage req) + { + var rh = req.Headers; + foreach (var h in BaseHeaders) + { + rh.Add(h.Key, h.Value); + } + } + + /// + /// Creates an HttpClient instance based on this configuration. + /// + /// + /// + /// The client's HttpMessageHandler will be set as follows: + /// + /// + /// If HttpMessageHandlerFactory was set, it will be called, passing the + /// other properties as a parameter. + /// Otherwise, in .NET Core and .NET 5.0+, the handler will be set to a + /// SocketsHttpHandler, with the specified ConnectTimeout and Proxy + /// settings. + /// Or, in .NET Framework and .NET Standard, the handler will be left null (to + /// use the platform default handler) unless Proxy was set, in which case an + /// HttpClientHandler instance is used. + /// + /// + /// The client will not be configured to send BaseHeaders automatically; + /// headers must still be added to each request. This is because we may want to support + /// having an application specify its own HTTP client instance. + /// + /// + /// The ReadTimeout property is not part of the HttpClient; it must be + /// implemented separately by the caller. + /// + /// + /// an HTTP client instance + public HttpClient NewHttpClient() + { + var handler = (HttpMessageHandlerFactory ?? DefaultHttpMessageHandlerFactory)(this); + return handler is null ? new HttpClient() : new HttpClient(handler, false); + } + + /// + /// Applies the HttpMessageHandler, Proxy, and ConnectTimeout settings + /// as appropriate to create a message handler. + /// + /// the fully configured handler + public HttpMessageHandler NewHttpMessageHandler() => + (HttpMessageHandlerFactory ?? DefaultHttpMessageHandlerFactory)(this); + + private static HttpMessageHandler DefaultHttpMessageHandlerFactory(HttpProperties props) + { +#if NETCOREAPP + return new SocketsHttpHandler + { + ConnectTimeout = props.ConnectTimeout, + Proxy = props.Proxy + }; +#else + if (props.Proxy != null) + { + return new HttpClientHandler { Proxy = props.Proxy }; + } + + return null; +#endif + } + + /// + /// Creates the header tag value for the provided . Omits properties + /// that are invalid. + /// + /// The header tag value. Possibly empty string if no valid properties exist. + private static string ApplicationTagHeaderValue(ApplicationInfo applicationInfo) + { + var tags = new List<(string, string)> + { + // Note these must be in alphabetical order + ("application-id", applicationInfo.ApplicationId), + ("application-name", applicationInfo.ApplicationName), + ("application-version", applicationInfo.ApplicationVersion), + ("application-version-name", applicationInfo.ApplicationVersionName) + }; + var parts = new List(); + foreach (var (tagKey, tagVal) in tags) + { + if (tagVal == null) + { + continue; + } + + var error = ValidationUtils.ValidateStringValue(tagVal); + if (error != null) + { + continue; + } + parts.Add($"{tagKey}/{tagVal}"); + } + + return string.Join(" ", parts); + } + } +} diff --git a/pkgs/shared/internal/src/Http/UnsuccessfulResponseException.cs b/pkgs/shared/internal/src/Http/UnsuccessfulResponseException.cs new file mode 100644 index 00000000..e910d839 --- /dev/null +++ b/pkgs/shared/internal/src/Http/UnsuccessfulResponseException.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; + +namespace LaunchDarkly.Sdk.Internal.Http +{ + public sealed class UnsuccessfulResponseException : Exception + { + public int StatusCode + { + get; + private set; + } + + /// + /// The HTTP headers from the response. + /// + public IEnumerable>> Headers { get; } + + public UnsuccessfulResponseException(int statusCode) : + this(statusCode, new Dictionary>()) + { + } + + /// + /// Creates a new instance with headers. + /// + /// the HTTP status code of the response + /// the HTTP headers from the response + public UnsuccessfulResponseException(int statusCode, IEnumerable>> headers) : + base(string.Format("HTTP status {0}", statusCode)) + { + StatusCode = statusCode; + Headers = headers ?? new Dictionary>(); + } + } +} diff --git a/pkgs/shared/internal/src/JsonConverterHelpers.cs b/pkgs/shared/internal/src/JsonConverterHelpers.cs new file mode 100644 index 00000000..0004b1dc --- /dev/null +++ b/pkgs/shared/internal/src/JsonConverterHelpers.cs @@ -0,0 +1,416 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using LdJson = LaunchDarkly.Sdk.Json; + +namespace LaunchDarkly.Sdk.Internal +{ + /// + /// Helper methods for using the System.Text.Json API more conveniently in custom converters. + /// + public static class JsonConverterHelpers + { + /// + /// Throws an exception if the current JSON token is not of the expected type. + /// + /// the JSON reader + /// the expected token type + /// if the token type is wrong + public static void RequireTokenType(ref Utf8JsonReader reader, JsonTokenType expectedType) + { + if (reader.TokenType != expectedType) + { + throw new JsonException("Expected " + expectedType + ", got " + reader.TokenType); + } + } + + /// + /// Reads either a numeric value or null. + /// + /// the JSON reader + /// a nullable int + /// if the token type is neither null or number + public static int? GetIntOrNull(ref Utf8JsonReader reader) + { + if (reader.TokenType == JsonTokenType.Null) + { + reader.Skip(); + return null; + } + return reader.GetInt32(); + } + + /// + /// Reads either a numeric value or null. + /// + /// the JSON reader + /// a nullable long + /// if the token type is neither null or number + public static long? GetLongOrNull(ref Utf8JsonReader reader) + { + if (reader.TokenType == JsonTokenType.Null) + { + reader.Skip(); + return null; + } + return reader.GetInt64(); + } + + /// + /// Reads either a millisecond timestamp value or null. + /// + /// the JSON reader + /// a nullable timestamp + /// if the token type is neither null or number + public static UnixMillisecondTime? GetTimeOrNull(ref Utf8JsonReader reader) + { + var n = GetLongOrNull(ref reader); + return n.HasValue ? UnixMillisecondTime.OfMillis(n.Value) : (UnixMillisecondTime?)null; + } + + /// + /// Shortcut for creating a JSON writer, performing some actions on it, and then getting its output as a + /// string. + /// + /// the action to do with the JSON writer + /// the string output + public static string WriteJsonAsString(Action action) + { + var stream = new MemoryStream(); + var w = new Utf8JsonWriter(stream); + action(w); + w.Flush(); + return Encoding.UTF8.GetString(stream.ToArray()); + } + + /// + /// Writes a numeric property value if it is not null; if null, omits the property entirely. + /// + /// the JSON writer + /// the property name + /// a nullable int + public static void WriteIntIfNotNull(Utf8JsonWriter w, string name, int? value) + { + if (value.HasValue) + { + w.WriteNumber(name, value.Value); + } + } + + /// + /// Writes a numeric value if it is not null; if null, omits the + /// property entirely. + /// + /// the JSON writer + /// the property name + /// a nullable timestamp + public static void WriteTimeIfNotNull(Utf8JsonWriter w, string name, UnixMillisecondTime? value) + { + if (value.HasValue) + { + w.WriteNumber(name, value.Value.Value); + } + } + + /// + /// Writes a string property value if it is not null; if null, omits the property entirely. + /// + /// the JSON writer + /// the property name + /// a nullable string + public static void WriteStringIfNotNull(Utf8JsonWriter w, string name, string value) + { + if (!(value is null)) + { + w.WriteString(name, value); + } + } + + /// + /// Writes a boolean property value if it is true; if false, omits the property entirely. + /// + /// the JSON writer + /// the property name + /// a boolean + public static void WriteBooleanIfTrue(Utf8JsonWriter w, string name, bool value) + { + if (value) + { + w.WriteBoolean(name, true); + } + } + + /// + /// Writes an as a property value. + /// + /// the JSON writer + /// the property name + /// the value + public static void WriteLdValue(Utf8JsonWriter w, string name, LdValue value) + { + w.WritePropertyName(name); + LdJson.LdJsonConverters.LdValueConverter.WriteJsonValue(value, w); + } + + /// + /// Writes an as a property value if it is not ; + /// if it is, omits the property entirely. + /// + /// the JSON writer + /// the property name + /// the value + public static void WriteLdValueIfNotNull(Utf8JsonWriter w, string name, LdValue value) + { + if (!value.IsNull) + { + WriteLdValue(w, name, value); + } + } + + /// + /// Starts consuming a JSON array. Throws an exception if the next token is not an array. + /// + /// + /// var arrayOfStrings = RequireArray(ref reader); + /// while (arrayOfStrings.Next(ref reader)) + /// { + /// DoSomething(reader.GetString()); + /// } + /// + /// the JSON reader + /// an + /// if the next token is not the beginning of an array + public static ArrayHelper RequireArray(scoped ref Utf8JsonReader reader) => + new ArrayHelper(ref reader, false); + + /// + /// Same as , except that if the next token is + /// a null, it behaves the same as an empty array. + /// + /// the JSON reader + /// an + /// if the next token is not the beginning of an array or null + public static ArrayHelper RequireArrayOrNull(scoped ref Utf8JsonReader reader) => + new ArrayHelper(ref reader, true); + + /// + /// Starts consuming a JSON object. Throws an exception if the next token is not an object. + /// See for usage. + /// + /// the JSON reader + /// an + /// if the next token is not the beginning of an object + public static ObjectHelper RequireObject(scoped ref Utf8JsonReader reader) + { + if (reader.TokenType == JsonTokenType.None) + { + reader.Read(); + } + RequireTokenType(ref reader, JsonTokenType.StartObject); + return new ObjectHelper(true, null); + } + + /// + /// Same as , except that if the next token is + /// a null, it behaves the same as an empty object. + /// + /// the JSON reader + /// an + /// if the next token is not the beginning of an object or null + public static ObjectHelper RequireObjectOrNull(scoped ref Utf8JsonReader reader) + { + if (reader.TokenType == JsonTokenType.None) + { + reader.Read(); + } + if (reader.TokenType == JsonTokenType.Null) + { + reader.Skip(); + return new ObjectHelper(false, null); + } + return RequireObject(ref reader); + } + + /// + /// A helper for reading JSON arrays. + /// + /// + /// + public ref struct ArrayHelper + { + private readonly bool _empty; + + internal ArrayHelper(scoped ref Utf8JsonReader reader, bool allowNull) + { + if (reader.TokenType == JsonTokenType.None) + { + reader.Read(); + } + if (allowNull && reader.TokenType == JsonTokenType.Null) + { + reader.Skip(); + _empty = true; + } + else + { + RequireTokenType(ref reader, JsonTokenType.StartArray); + _empty = false; + } + } + + /// + /// Tries to read the next array element. + /// + /// the JSON reader (this must be passed again due to ref struct rules) + /// true if there is an element, false if this is the end + public bool Next(scoped ref Utf8JsonReader reader) => + !_empty && reader.Read() && reader.TokenType != JsonTokenType.EndArray; + } + + /// + /// A helper for reading JSON objects. Note that this implementation always returns the property name + /// as a string. If you require very high efficiency and don't want to allocate strings for property + /// names, use the lower-level methods instead. + /// + /// + /// for (var obj = RequireObject(ref reader); obj.Next(ref reader);) + /// { + /// DoSomething(objWithIntValues.Name, reader.GetString()); + /// } + /// + /// + /// + public ref struct ObjectHelper + { + private readonly bool _defined; + private readonly string[] _requiredPropertyNames; + private UInt64 _requiredPropertyBits; + private string _name; + private SequencePosition? _valueStartPos; + + /// + /// The name of the last property that was read, or null if none. + /// + public string Name => _name; + + internal ObjectHelper(bool defined, string[] requiredProperties) + { + _defined = defined; + _requiredPropertyNames = requiredProperties; + if (requiredProperties != null && requiredProperties.Length != 0) + { + if (requiredProperties.Length > 63) + { + throw new ArgumentException("can't specify more than 63 required properties"); + } + _requiredPropertyBits = (((UInt64)1 << requiredProperties.Length) - 1); + } + else + { + _requiredPropertyBits = 0; + } + _name = null; + _valueStartPos = null; + } + + /// + /// Adds a requirement that the specified JSON property name(s) must appear in the JSON object at + /// some point before it ends. + /// + /// + /// + /// This method returns a new, modified . It should be called before + /// the first time you call . For instance: + /// + /// + /// var requiredProps = new string[] { "key", "name" }; + /// for (var obj = RequireObject(ref reader).WithRequiredProperties(requiredProps); + /// obj.Next(ref reader);) + /// { + /// switch (obj.Name) { ... } + /// } + /// + /// + /// When the end of the object is reached, if one of the required properties has not yet been + /// seen, will throw a . + /// + /// + /// For efficiency, it is best to preallocate the list of property names globally rather than + /// creating it inline. + /// + /// + /// The current implementation does not allow more than 63 required properties. + /// + /// + /// the required property names + /// an updated + public ObjectHelper WithRequiredProperties(params string[] propertyNames) => + new ObjectHelper(_defined, propertyNames); + + /// + /// Tries to read the next object property. If successful, it sets , and + /// then calls Read again so that the parser is now at the property value. If the consumer + /// did not consume the previous property value, it calls Skip first (so you do not need + /// to remember to skip values of unrecognized properties when iterating over an object). + /// + /// the JSON reader (this must be passed again due to ref struct rules) + /// true if there is a property, false if this is the end + public bool Next(scoped ref Utf8JsonReader reader) + { + if (!_defined) + { + return false; + } + if (_valueStartPos.HasValue) + { + if (reader.Position.Equals(_valueStartPos.Value)) + { + reader.Skip(); + } + _valueStartPos = null; + } + + if (!reader.Read() || reader.TokenType == JsonTokenType.EndObject) + { + _name = null; + + if (_requiredPropertyBits != 0) + { + for (int i = 0; i < _requiredPropertyNames.Length; i++) + { + if ((_requiredPropertyBits & ((UInt64)1 << i)) != 0) + { + throw new JsonException("Missing required property: " + _requiredPropertyNames[i]); + } + } + } + + return false; + } + _name = reader.GetString(); + reader.Read(); + _valueStartPos = reader.Position; + + if (_requiredPropertyBits != 0) + { + for (int i = 0; i < _requiredPropertyNames.Length; i++) + { + if (_name.Equals(_requiredPropertyNames[i])) + { + _requiredPropertyBits &= ~((UInt64)1 << i); + break; + } + } + } + + return true; + } + } + } +} + diff --git a/pkgs/shared/internal/src/LaunchDarkly.InternalSdk.csproj b/pkgs/shared/internal/src/LaunchDarkly.InternalSdk.csproj new file mode 100644 index 00000000..4e3538df --- /dev/null +++ b/pkgs/shared/internal/src/LaunchDarkly.InternalSdk.csproj @@ -0,0 +1,49 @@ + + + + 3.8.0 + + + netstandard2.0;net462;net8.0 + $(BUILDFRAMEWORKS) + portable + LaunchDarkly.InternalSdk + Library + 11 + LaunchDarkly.InternalSdk + LaunchDarkly.Sdk.Internal + LaunchDarkly internal common code for .NET and Xamarin clients + LaunchDarkly + LaunchDarkly + LaunchDarkly + Copyright 2020 LaunchDarkly + Apache-2.0 + https://github.com/launchdarkly/dotnet-core + https://github.com/launchdarkly/dotnet-core + main + + + + + + + + + + + + + + + + + + + ../../../../LaunchDarkly.InternalSdk.snk + true + + diff --git a/pkgs/shared/internal/src/LogHelpers.cs b/pkgs/shared/internal/src/LogHelpers.cs new file mode 100644 index 00000000..0386cca4 --- /dev/null +++ b/pkgs/shared/internal/src/LogHelpers.cs @@ -0,0 +1,26 @@ +using System; +using LaunchDarkly.Logging; + +namespace LaunchDarkly.Sdk.Internal +{ + public static class LogHelpers + { + /// + /// Logs an exception using the standard pattern for the LaunchDarkly SDKs. + /// + /// + /// The exception summary is logged at Error level. Then the stacktrace is logged at + /// Debug level, using lazy evaluation to avoid computing it if Debug loggins is disabled. + /// + /// the logger instance + /// a descriptive prefix ("Unexpected error" if null or empty) + /// the exception + public static void LogException(Logger logger, string message, Exception e) + { + logger.Error("{0}: {1}", + string.IsNullOrEmpty(message) ? "Unexpected error" : message, + LogValues.ExceptionSummary(e)); + logger.Debug(LogValues.ExceptionTrace(e)); + } + } +} diff --git a/pkgs/shared/internal/src/UriExtensions.cs b/pkgs/shared/internal/src/UriExtensions.cs new file mode 100644 index 00000000..4bc206b3 --- /dev/null +++ b/pkgs/shared/internal/src/UriExtensions.cs @@ -0,0 +1,46 @@ +using System; + +namespace LaunchDarkly.Sdk.Internal +{ + /// + /// Extension methods for URIs. + /// + public static class UriExtensions + { + /// + /// Returns a new URI with the specified path appended to the original path, adding a "/" + /// separator if the original path did not end in one. + /// + /// + /// This behaves differently from new Uri(baseUri, path), which instead follows + /// relative URI rules: that is, new Uri("/hostname/basepath", "relativepath") + /// would return /hostname/relativepath because the original URI did not end in + /// a slash. We should always use AddPath in any context where the caller has + /// specified a base URL that might or might not have a path prefix already. + /// + /// the original URI + /// the path to append + /// a new URI + public static Uri AddPath(this Uri baseUri, string path) + { + var ub = new UriBuilder(baseUri); + ub.Path = ub.Path.TrimEnd('/') + "/" + path.TrimStart('/'); + return ub.Uri; + } + + /// + /// Returns a new URI with the specified query string appended, adding a "?" first if the + /// original URI had no query or a "&" if it had one. + /// + /// the original URI + /// the query string to add (not including "?") + /// a new URI + public static Uri AddQuery(this Uri baseUri, string query) + { + var ub = new UriBuilder(baseUri); + ub.Query = string.IsNullOrEmpty(ub.Query) ? query : + ub.Query.TrimStart('?') + "&" + query; + return ub.Uri; + } + } +} diff --git a/pkgs/shared/internal/test/AssemblyVersionsTest.cs b/pkgs/shared/internal/test/AssemblyVersionsTest.cs new file mode 100644 index 00000000..8cafc8ff --- /dev/null +++ b/pkgs/shared/internal/test/AssemblyVersionsTest.cs @@ -0,0 +1,29 @@ +using System; +using Xunit; + +namespace LaunchDarkly.Sdk.Internal +{ + public class AssemblyVersionsTest + { + [Fact] + public void GetVersionString() + { + // Starting in .NET 8, the commit sha is appended to the version string. + var versionString = AssemblyVersions.GetAssemblyVersionStringForType(typeof(AssemblyVersionsTest)).Split('+')[0]; + + Assert.Equal( + "1.2.3", // this is hard-coded in LaunchDarkly.InternalSdk.Tests.csproj + versionString + ); + } + + [Fact] + public void GetVersion() + { + Assert.Equal( + new Version("1.2.3.0"), + AssemblyVersions.GetAssemblyVersionForType(typeof(AssemblyVersionsTest)) + ); + } + } +} diff --git a/pkgs/shared/internal/test/Concurrent/AtomicBooleanTest.cs b/pkgs/shared/internal/test/Concurrent/AtomicBooleanTest.cs new file mode 100644 index 00000000..a107949f --- /dev/null +++ b/pkgs/shared/internal/test/Concurrent/AtomicBooleanTest.cs @@ -0,0 +1,25 @@ +using Xunit; + +namespace LaunchDarkly.Sdk.Internal.Concurrent +{ + public class AtomicBooleanTest + { + [Fact] + public void InitialValue() + { + Assert.False(new AtomicBoolean(false).Get()); + Assert.True(new AtomicBoolean(true).Get()); + } + + [Fact] + public void GetAndSet() + { + var ab = new AtomicBoolean(false); + Assert.False(ab.GetAndSet(false)); + Assert.False(ab.GetAndSet(true)); + Assert.True(ab.Get()); + Assert.True(ab.GetAndSet(false)); + Assert.False(ab.Get()); + } + } +} diff --git a/pkgs/shared/internal/test/Concurrent/StateMonitorTest.cs b/pkgs/shared/internal/test/Concurrent/StateMonitorTest.cs new file mode 100644 index 00000000..edf063bd --- /dev/null +++ b/pkgs/shared/internal/test/Concurrent/StateMonitorTest.cs @@ -0,0 +1,116 @@ +using System; +using System.Threading; +using LaunchDarkly.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace LaunchDarkly.Sdk.Internal.Concurrent +{ + public class StateMonitorTest + { + private static readonly object[] Immediately = new object[] { 0, 0, 0 }; + private const string OnFirstTry = "on first try"; + private const string AfterSeveralTries = "after several tries"; + + private readonly StateMonitor _monitor; + private readonly Logger _log; + + private struct MyStateType + { + public int Counter { get; set; } + } + + private struct MyUpdateType + { + public bool ShouldIncrement { get; set; } + } + + public StateMonitorTest(ITestOutputHelper testOutput) + { + _log = Logs.ToMethod(line => + { + try + { + testOutput.WriteLine("{0}: {1}", DateTime.Now, line); + } + catch { } + }).Logger(""); + var initialState = new MyStateType { Counter = 0 }; + _monitor = new StateMonitor(initialState, MaybeUpdate, _log); + } + + private static MyStateType? MaybeUpdate(MyStateType oldState, MyUpdateType update) => + update.ShouldIncrement ? new MyStateType { Counter = oldState.Counter + 1 } : (MyStateType?)null; + + [Fact] + public void CanGetAndUpdateState() + { + Assert.Equal(new MyStateType { Counter = 0 }, _monitor.Current); + + Assert.True(_monitor.Update(new MyUpdateType { ShouldIncrement = true }, out var state1)); + Assert.Equal(new MyStateType { Counter = 1 }, state1); + Assert.Equal(_monitor.Current, state1); + + Assert.False(_monitor.Update(new MyUpdateType { ShouldIncrement = false }, out var state2)); + Assert.Equal(state1, state2); + Assert.Equal(_monitor.Current, state2); + } + + [Theory] + [InlineData(0, 0, 0, 0)] + [InlineData(1, 1, 50, 200)] + [InlineData(3, 3, 40, 200)] + public void WaitForSucceeds(int targetState, int numberOfUpdates, int updateDelayMs, int timeoutMs) + { + StartUpdating(numberOfUpdates, TimeSpan.FromMilliseconds(updateDelayMs)); + var result = _monitor.WaitFor(state => state.Counter == targetState, TimeSpan.FromMilliseconds(timeoutMs)); + Assert.NotNull(result); + Assert.Equal(targetState, result.Value.Counter); + Assert.Equal(targetState, _monitor.Current.Counter); + } + + [Theory] + [InlineData(0, 0, 0, 0)] + [InlineData(1, 1, 50, 200)] + [InlineData(3, 3, 40, 200)] + public async void WaitForAsyncSucceeds(int targetState, int numberOfUpdates, int updateDelayMs, int timeoutMs) + { + StartUpdating(numberOfUpdates, TimeSpan.FromMilliseconds(updateDelayMs)); + var result = await _monitor.WaitForAsync(state => state.Counter == targetState, TimeSpan.FromMilliseconds(timeoutMs)); + Assert.NotNull(result); + Assert.Equal(targetState, result.Value.Counter); + Assert.Equal(targetState, _monitor.Current.Counter); + } + + [Fact] + public void WaitForTimesOut() + { + StartUpdating(10, TimeSpan.FromMilliseconds(50)); + var result = _monitor.WaitFor(state => state.Counter == 10, TimeSpan.FromMilliseconds(200)); + Assert.Null(result); + } + + [Fact] + public async void WaitForAsyncTimesOut() + { + StartUpdating(10, TimeSpan.FromMilliseconds(50)); + var result = await _monitor.WaitForAsync(state => state.Counter == 10, TimeSpan.FromMilliseconds(200)); + Assert.Null(result); + } + + private void StartUpdating(int numberOfUpdates, TimeSpan updateDelay) + { + if (numberOfUpdates > 0) + { + new Thread(() => + { + for (int i = 0; i < numberOfUpdates; i++) + { + Thread.Sleep(updateDelay); + _monitor.Update(new MyUpdateType { ShouldIncrement = true }, out _); + } + }).Start(); + } + } + } +} diff --git a/pkgs/shared/internal/test/Concurrent/TaskExecutorTest.cs b/pkgs/shared/internal/test/Concurrent/TaskExecutorTest.cs new file mode 100644 index 00000000..6b017b79 --- /dev/null +++ b/pkgs/shared/internal/test/Concurrent/TaskExecutorTest.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.Logging; +using LaunchDarkly.TestHelpers; +using Xunit; +using Xunit.Abstractions; + +using static LaunchDarkly.TestHelpers.Assertions; + +namespace LaunchDarkly.Sdk.Internal.Concurrent +{ + public class TaskExecutorTest + { + private static readonly object MyEventSender = "this is the sender"; + + private readonly TaskExecutor executor; + private readonly LogCapture logCapture; + private readonly Logger testLogger; + private event EventHandler myEvent; + + public TaskExecutorTest(ITestOutputHelper testOutput) + { + logCapture = Logs.Capture(); + testLogger = Logs.ToMultiple( + logCapture, + Logs.ToMethod(testOutput.WriteLine) + ).Logger(""); + executor = new TaskExecutor(MyEventSender, testLogger); + } + + [Fact] + public void SendsEvent() + { + var values1 = new EventSink(); + var values2 = new EventSink(); + myEvent += values1.Add; + myEvent += values2.Add; + + executor.ScheduleEvent("hello", myEvent); + + Assert.Equal("hello", values1.ExpectValue()); + Assert.Equal("hello", values2.ExpectValue()); + } + + [Fact] + public void PassesConfiguredEventSenderToEventHandler() + { + var gotSender = new EventSink(); + myEvent += (sender, args) => gotSender.Enqueue(sender); + + executor.ScheduleEvent("hello", myEvent); + + Assert.Equal(MyEventSender, gotSender.ExpectValue()); + } + + [Fact] + public void ExceptionFromEventHandlerIsLoggedAndDoesNotStopOtherHandlers() + { + var values1 = new EventSink(); + myEvent += (sender, args) => throw new Exception("sorry"); + myEvent += values1.Add; + + executor.ScheduleEvent("hello", myEvent); + + Assert.Equal("hello", values1.ExpectValue()); + + AssertEventually(TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(20), () => + logCapture.HasMessageWithText(LogLevel.Error, "Unexpected exception from event handler for String: System.Exception: sorry") && + logCapture.HasMessageWithRegex(LogLevel.Debug, "at LaunchDarkly.Sdk.Internal.Concurrent.TaskExecutorTest")); + } + + [Fact] + public void CanUseCustomEventDispatcher() + { + var actions = new EventSink(); + var customExecutor = new TaskExecutor(MyEventSender, actions.Enqueue, testLogger); + + var values1 = new EventSink(); + var values2 = new EventSink(); + myEvent += values1.Add; + myEvent += values2.Add; + + customExecutor.ScheduleEvent("hello", myEvent); + + values1.ExpectNoValue(); + values2.ExpectNoValue(); + + var action1 = actions.ExpectValue(); + var action2 = actions.ExpectValue(); + actions.ExpectNoValue(); + + action1(); + action2(); + Assert.Equal("hello", values1.ExpectValue()); + Assert.Equal("hello", values2.ExpectValue()); + } + + [Fact] + public void RepeatingTask() + { + var values = new BlockingCollection(); + var testGate = new EventWaitHandle(false, EventResetMode.AutoReset); + var nextValue = 1; + var canceller = executor.StartRepeatingTask(TimeSpan.Zero, TimeSpan.FromMilliseconds(100), async () => + { + testGate.WaitOne(); + values.Add(nextValue++); + await Task.FromResult(true); // an arbitrary await just to make this function async + }); + + testGate.Set(); + Assert.True(values.TryTake(out var value1, TimeSpan.FromSeconds(2))); + Assert.Equal(1, value1); + + testGate.Set(); + Assert.True(values.TryTake(out var value2, TimeSpan.FromSeconds(2))); + Assert.Equal(2, value2); + + canceller.Cancel(); + testGate.Set(); + Assert.False(values.TryTake(out _, TimeSpan.FromMilliseconds(200))); + } + + [Fact] + public void ExceptionFromRepeatingTaskIsLoggedAndDoesNotStopTask() + { + var values = new BlockingCollection(); + var testGate = new EventWaitHandle(false, EventResetMode.AutoReset); + var nextValue = 1; +#pragma warning disable 1998 + var canceller = executor.StartRepeatingTask(TimeSpan.Zero, TimeSpan.FromMilliseconds(100), async () => +#pragma warning restore 1998 + { + testGate.WaitOne(); + var valueWas = nextValue++; + if (valueWas == 1) + { + throw new Exception("sorry"); + } + else + { + values.Add(valueWas++); + } + }); + + testGate.Set(); + Assert.False(values.TryTake(out _, TimeSpan.FromMilliseconds(100))); + + AssertEventually(TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(20), () => + logCapture.HasMessageWithText(LogLevel.Error, "Unexpected exception from repeating task: System.Exception: sorry") && + logCapture.HasMessageWithRegex(LogLevel.Debug, "at LaunchDarkly.Sdk.Internal.Concurrent.TaskExecutorTest")); + + testGate.Set(); + Assert.True(values.TryTake(out var value2, TimeSpan.FromSeconds(2))); + Assert.Equal(2, value2); + + canceller.Cancel(); + testGate.Set(); + } + } +} diff --git a/pkgs/shared/internal/test/Events/AggregatedEventSummarizerTest.cs b/pkgs/shared/internal/test/Events/AggregatedEventSummarizerTest.cs new file mode 100644 index 00000000..3e5038aa --- /dev/null +++ b/pkgs/shared/internal/test/Events/AggregatedEventSummarizerTest.cs @@ -0,0 +1,44 @@ +using Xunit; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + public class AggregatedEventSummarizerTest + { + private static readonly UnixMillisecondTime Time = UnixMillisecondTime.OfMillis(1000); + private static readonly Context ContextA = Context.New(ContextKind.Of("user"), "a"); + private static readonly Context ContextB = Context.New(ContextKind.Of("user"), "b"); + + [Fact] + public void EmptySummarizerReturnsNoSummaries() + { + var summarizer = new AggregatedEventSummarizer(); + Assert.Empty(summarizer.GetSummariesAndReset()); + } + + [Fact] + public void AllContextsAreAggregatedIntoASingleContextlessSummary() + { + var summarizer = new AggregatedEventSummarizer(); + summarizer.SummarizeEvent(Time, "flag", 1, 0, LdValue.Of("a"), LdValue.Null, ContextA); + summarizer.SummarizeEvent(Time, "flag", 1, 0, LdValue.Of("b"), LdValue.Null, ContextB); + + var summaries = summarizer.GetSummariesAndReset(); + + Assert.Single(summaries); + // No context is attached to an aggregated summary. + Assert.False(summaries[0].Context.Defined); + // Both evaluations are merged under the same flag/variation/version counter. + Assert.Equal(2, summaries[0].Flags["flag"].Counters[new EventsCounterKey(1, 0)].Count); + } + + [Fact] + public void GetSummariesAndResetClearsState() + { + var summarizer = new AggregatedEventSummarizer(); + summarizer.SummarizeEvent(Time, "flag", 1, 0, LdValue.Of("a"), LdValue.Null, ContextA); + + Assert.Single(summarizer.GetSummariesAndReset()); + Assert.Empty(summarizer.GetSummariesAndReset()); + } + } +} diff --git a/pkgs/shared/internal/test/Events/DefaultEventSenderTest.cs b/pkgs/shared/internal/test/Events/DefaultEventSenderTest.cs new file mode 100644 index 00000000..6d3c8ddf --- /dev/null +++ b/pkgs/shared/internal/test/Events/DefaultEventSenderTest.cs @@ -0,0 +1,179 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using LaunchDarkly.Sdk.Internal.Http; +using LaunchDarkly.TestHelpers.HttpTest; +using Xunit; + +using static LaunchDarkly.Sdk.TestUtil; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + public class DefaultEventSenderTest + { + private const string AuthKey = "fake-sdk-key"; + private const string EventsUriPath = "/post-events-here"; + private const string DiagnosticUriPath = "/post-diagnostic-here"; + private const string FakeData = "{\"things\":[]}"; + private static readonly byte[] FakeDataBytes = Encoding.UTF8.GetBytes(FakeData); + + private async Task WithServerAndSender(Handler handler, Func a) + { + using (var server = HttpServer.Start(handler)) + { + using (var es = MakeSender(server)) + { + await a(server, es); + } + } + } + + private DefaultEventSender MakeSender(HttpServer server) + { + var config = new EventsConfiguration + { + DiagnosticUri = server.Uri.AddPath(DiagnosticUriPath), + EventsUri = server.Uri.AddPath(EventsUriPath), + RetryInterval = TimeSpan.FromMilliseconds(10) + }; + var httpProps = HttpProperties.Default.WithAuthorizationKey(AuthKey); + return new DefaultEventSender(httpProps, config, NullLogger); + } + + [Fact] + public async void AnalyticsEventDataIsSentSuccessfully() => + await WithServerAndSender(Handlers.Status(202), async (server, es) => + { + var result = await es.SendEventDataAsync(EventDataKind.AnalyticsEvents, FakeDataBytes, 1); + + Assert.Equal(DeliveryStatus.Succeeded, result.Status); + Assert.NotNull(result.TimeFromServer); + + var request = server.Recorder.RequireRequest(); + Assert.Equal("POST", request.Method); + Assert.Equal(EventsUriPath, request.Path); + Assert.Equal(AuthKey, request.Headers.Get("Authorization")); + Assert.NotNull(request.Headers.Get("X-LaunchDarkly-Payload-ID")); + Assert.Equal("4", request.Headers.Get("X-LaunchDarkly-Event-Schema")); + }); + +#if !NETFRAMEWORK + // .NET Framework's implementation of HttpListener, which is used by LaunchDarkly.TestHelpers, + // doesn't allow setting a custom value for the Date response header. So even though the + // parsing of this header by DefaultEventSender should still work the same in .NET Framework, + // we can't test it in this way. + [Fact] + public async void EventSenderReadsResponseDateTime() => + await WithServerAndSender(Handlers.Status(202). + Then(Handlers.Header("Date", "Mon, 24 Mar 2014 12:00:00 GMT")), async (server, es) => + { + var result = await es.SendEventDataAsync(EventDataKind.AnalyticsEvents, FakeDataBytes, 1); + + Assert.Equal(DeliveryStatus.Succeeded, result.Status); + Assert.Equal(new DateTime(2014, 03, 24, 12, 00, 00), result.TimeFromServer); + }); +#endif + + [Fact] + public async void NewPayloadIdIsGeneratedForEachPayload() => + await WithServerAndSender(Handlers.Status(202), async (server, es) => + { + var result1 = await es.SendEventDataAsync(EventDataKind.AnalyticsEvents, FakeDataBytes, 1); + var result2 = await es.SendEventDataAsync(EventDataKind.AnalyticsEvents, FakeDataBytes, 1); + + Assert.Equal(DeliveryStatus.Succeeded, result1.Status); + Assert.Equal(DeliveryStatus.Succeeded, result2.Status); + + var req1 = server.Recorder.RequireRequest(); + var req2 = server.Recorder.RequireRequest(); + Assert.NotEqual( + req1.Headers.Get("X-LaunchDarkly-Payload-ID"), + req2.Headers.Get("X-LaunchDarkly-Payload-ID")); + }); + + [Fact] + public async void DiagnosticEventDataIsSentSuccessfully() => + await WithServerAndSender(Handlers.Status(202), async (server, es) => + { + var result = await es.SendEventDataAsync(EventDataKind.DiagnosticEvent, FakeDataBytes, 1); + + Assert.Equal(DeliveryStatus.Succeeded, result.Status); + Assert.NotNull(result.TimeFromServer); + + var request = server.Recorder.RequireRequest(); + Assert.Equal("POST", request.Method); + Assert.Equal(DiagnosticUriPath, request.Path); + Assert.Equal(AuthKey, request.Headers.Get("Authorization")); + Assert.Null(request.Headers.Get("X-LaunchDarkly-Payload-ID")); + Assert.Null(request.Headers.Get("X-LaunchDarkly-Event-Schema")); + }); + + [Theory] + [InlineData(400)] + [InlineData(408)] + [InlineData(429)] + [InlineData(500)] + public async void VerifyRecoverableHttpError(int status) + { + var handler = Handlers.Sequential( + Handlers.Status(status), // initial request gets error + Handlers.Status(202) // second request gets success + ); + await WithServerAndSender(handler, async (server, es) => + { + var result = await es.SendEventDataAsync(EventDataKind.AnalyticsEvents, FakeDataBytes, 1); + Assert.Equal(DeliveryStatus.Succeeded, result.Status); + Assert.NotNull(result.TimeFromServer); + + var req1 = server.Recorder.RequireRequest(); + var req2 = server.Recorder.RequireRequest(); + Assert.Equal(req1.Body, req2.Body); + Assert.Equal(req1.Headers.Get("X-LaunchDarkly-Payload-ID"), + req2.Headers.Get("X-LaunchDarkly-Payload-ID")); + }); + } + + [Theory] + [InlineData(400)] + [InlineData(408)] + [InlineData(429)] + [InlineData(500)] + public async void VerifyRecoverableHttpErrorIsOnlyRetriedOnce(int status) + { + var handler = Handlers.Sequential( + Handlers.Status(status), // initial request gets error + Handlers.Status(status), // second request also gets error + Handlers.Status(202) // third request would succeed if it got that far + ); + + await WithServerAndSender(handler, async (server, es) => + { + var result = await es.SendEventDataAsync(EventDataKind.AnalyticsEvents, FakeDataBytes, 1); + Assert.Equal(DeliveryStatus.Failed, result.Status); + Assert.Null(result.TimeFromServer); + + var req1 = server.Recorder.RequireRequest(); + var req2 = server.Recorder.RequireRequest(); + Assert.Equal(req1.Body, req2.Body); + Assert.Equal(req1.Headers.Get("X-LaunchDarkly-Payload-ID"), + req2.Headers.Get("X-LaunchDarkly-Payload-ID")); + + server.Recorder.RequireNoRequests(TimeSpan.FromMilliseconds(100)); + }); + } + + [Theory] + [InlineData(401)] + [InlineData(403)] + public async void VerifyUnrecoverableHttpError(int status) => + await WithServerAndSender(Handlers.Status(status), async (server, es) => + { + var result = await es.SendEventDataAsync(EventDataKind.AnalyticsEvents, FakeDataBytes, 1); + Assert.Equal(DeliveryStatus.FailedAndMustShutDown, result.Status); + Assert.Null(result.TimeFromServer); + + var request = server.Recorder.RequireRequest(); + server.Recorder.RequireNoRequests(TimeSpan.FromMilliseconds(100)); + }); + } +} diff --git a/pkgs/shared/internal/test/Events/DiagnosticConfigPropertiesTest.cs b/pkgs/shared/internal/test/Events/DiagnosticConfigPropertiesTest.cs new file mode 100644 index 00000000..d969b7e1 --- /dev/null +++ b/pkgs/shared/internal/test/Events/DiagnosticConfigPropertiesTest.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections; +using System.Net; +using LaunchDarkly.Sdk.Internal.Http; +using Xunit; + +using static LaunchDarkly.TestHelpers.JsonAssertions; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + public class DiagnosticConfigPropertiesTest : IDisposable + { + private readonly IDictionary _oldEnvVars; + + public DiagnosticConfigPropertiesTest() + { + _oldEnvVars = Environment.GetEnvironmentVariables(); + } + + public void Dispose() + { + foreach (var key in _oldEnvVars.Keys) + { + Environment.SetEnvironmentVariable(key.ToString(), _oldEnvVars[key]?.ToString()); + } + foreach (var key in Environment.GetEnvironmentVariables().Keys) + { + if (!_oldEnvVars.Contains(key.ToString())) + { + Environment.SetEnvironmentVariable(key.ToString(), null); + } + } + } + + [Fact] + public void WithEventProperties() + { + foreach (var customEventsBasUri in new bool[] { false, true }) + { + foreach (var allAttributesPrivate in new bool[] { false, true }) + { + var eventsConfig = new EventsConfiguration + { + AllAttributesPrivate = allAttributesPrivate, + DiagnosticRecordingInterval = TimeSpan.FromMilliseconds(11111), + EventCapacity = 22222, + EventFlushInterval = TimeSpan.FromMilliseconds(33333) + }; + string expected = LdValue.BuildObject() + .Add("allAttributesPrivate", allAttributesPrivate) + .Add("customEventsURI", customEventsBasUri) + .Add("diagnosticRecordingIntervalMillis", 11111) + .Add("eventsCapacity", 22222) + .Add("eventsFlushIntervalMillis", 33333) + .Build().ToJsonString(); + string actual = LdValue.BuildObject().WithEventProperties(eventsConfig, customEventsBasUri) + .Build().ToJsonString(); + AssertJsonEqual(expected, actual); + } + } + } + + [Fact] + public void WithHttpProperties() + { + Environment.SetEnvironmentVariable("HTTP_PROXY", null); + Environment.SetEnvironmentVariable("HTTPS_PROXY", null); + Environment.SetEnvironmentVariable("ALL_PROXY", null); + + string expected = LdValue.BuildObject() + .Add("connectTimeoutMillis", 11111) + .Add("socketTimeoutMillis", 22222) + .Add("usingProxy", false) + .Add("usingProxyAuthenticator", false) + .Build().ToJsonString(); + var httpProps = HttpProperties.Default + .WithConnectTimeout(TimeSpan.FromMilliseconds(11111)) + .WithReadTimeout(TimeSpan.FromMilliseconds(22222)); + string actual = LdValue.BuildObject().WithHttpProperties(httpProps).Build().ToJsonString(); + AssertJsonEqual(expected, actual); + } + + [Fact] + public void WithHttpPropertiesWithConfiguredProxy() + { + Environment.SetEnvironmentVariable("HTTP_PROXY", null); + Environment.SetEnvironmentVariable("HTTPS_PROXY", null); + Environment.SetEnvironmentVariable("ALL_PROXY", null); + + string expected = LdValue.BuildObject() + .Add("connectTimeoutMillis", HttpProperties.DefaultConnectTimeout.TotalMilliseconds) + .Add("socketTimeoutMillis", HttpProperties.DefaultReadTimeout.TotalMilliseconds) + .Add("usingProxy", true) + .Add("usingProxyAuthenticator", false) + .Build().ToJsonString(); + var httpProps = HttpProperties.Default + .WithProxy(new WebProxy("http://example")); + string actual = LdValue.BuildObject().WithHttpProperties(httpProps).Build().ToJsonString(); + AssertJsonEqual(expected, actual); + } + + [Fact] + public void WithHttpPropertiesWithConfiguredProxyWithAuthentication() + { + Environment.SetEnvironmentVariable("HTTP_PROXY", null); + Environment.SetEnvironmentVariable("HTTPS_PROXY", null); + Environment.SetEnvironmentVariable("ALL_PROXY", null); + + string expected = LdValue.BuildObject() + .Add("connectTimeoutMillis", HttpProperties.DefaultConnectTimeout.TotalMilliseconds) + .Add("socketTimeoutMillis", HttpProperties.DefaultReadTimeout.TotalMilliseconds) + .Add("usingProxy", true) + .Add("usingProxyAuthenticator", true) + .Build().ToJsonString(); + + var credentials = new CredentialCache(); + credentials.Add(new Uri("http://example"), "Basic", new NetworkCredential("user", "pass")); + var proxyWithAuth = new WebProxy(new Uri("http://example")); + proxyWithAuth.Credentials = credentials; + var httpProps = HttpProperties.Default + .WithProxy(proxyWithAuth); + + string actual = LdValue.BuildObject().WithHttpProperties(httpProps).Build().ToJsonString(); + AssertJsonEqual(expected, actual); + } + + [Fact] + public void WithHttpPropertiesWithProxyFromEnvVar() + { + string expected = LdValue.BuildObject() + .Add("connectTimeoutMillis", HttpProperties.DefaultConnectTimeout.TotalMilliseconds) + .Add("socketTimeoutMillis", HttpProperties.DefaultReadTimeout.TotalMilliseconds) + .Add("usingProxy", true) + .Add("usingProxyAuthenticator", false) + .Build().ToJsonString(); + var httpProps = HttpProperties.Default; + + Environment.SetEnvironmentVariable("HTTP_PROXY", "http://example"); + string actual1 = LdValue.BuildObject().WithHttpProperties(httpProps).Build().ToJsonString(); + AssertJsonEqual(expected, actual1); + + Environment.SetEnvironmentVariable("HTTP_PROXY", null); + Environment.SetEnvironmentVariable("HTTPS_PROXY", "http://example"); + string actual2 = LdValue.BuildObject().WithHttpProperties(httpProps).Build().ToJsonString(); + AssertJsonEqual(expected, actual2); + + Environment.SetEnvironmentVariable("HTTPS_PROXY", null); + Environment.SetEnvironmentVariable("ALL_PROXY", "http://example"); + string actual3 = LdValue.BuildObject().WithHttpProperties(httpProps).Build().ToJsonString(); + AssertJsonEqual(expected, actual3); + } + + [Fact] + public void WithStartWaitTime() + { + string expected = LdValue.BuildObject().Add("startWaitMillis", 11111) + .Build().ToJsonString(); + string actual = LdValue.BuildObject().WithStartWaitTime(TimeSpan.FromMilliseconds(11111)) + .Build().ToJsonString(); + AssertJsonEqual(expected, actual); + } + + [Fact] + public void WithStreamingProperties() + { + foreach (var customStreamingBasUri in new bool[] { false, true }) + { + foreach (var customPollingBaseUri in new bool[] { false, true }) + { + string expected = LdValue.BuildObject() + .Add("streamingDisabled", false) + .Add("customBaseURI", customPollingBaseUri) + .Add("customStreamURI", customStreamingBasUri) + .Add("reconnectTimeMillis", 11111) + .Build().ToJsonString(); + string actual = LdValue.BuildObject().WithStreamingProperties( + customStreamingBasUri, + customPollingBaseUri, + TimeSpan.FromMilliseconds(11111) + ).Build().ToJsonString(); + AssertJsonEqual(expected, actual); + } + } + } + + [Fact] + public void WithPollingProperties() + { + foreach (var customPollingBaseUri in new bool[] { false, true }) + { + string expected = LdValue.BuildObject() + .Add("streamingDisabled", true) + .Add("customBaseURI", customPollingBaseUri) + .Add("pollingIntervalMillis", 11111) + .Build().ToJsonString(); + string actual = LdValue.BuildObject().WithPollingProperties( + customPollingBaseUri, + TimeSpan.FromMilliseconds(11111) + ).Build().ToJsonString(); + AssertJsonEqual(expected, actual); + } + } + } +} diff --git a/pkgs/shared/internal/test/Events/DiagnosticIdTest.cs b/pkgs/shared/internal/test/Events/DiagnosticIdTest.cs new file mode 100644 index 00000000..1a35681b --- /dev/null +++ b/pkgs/shared/internal/test/Events/DiagnosticIdTest.cs @@ -0,0 +1,37 @@ +using System; +using Xunit; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + public class DiagnosticIdTest + { + + [Fact] + public void DiagnosticIdTakesKeySuffix() + { + DiagnosticId id = new DiagnosticId("suffix-of-sdkkey", Guid.NewGuid()); + Assert.Equal("sdkkey", id.SdkKeySuffix); + } + + [Fact] + public void DiagnosticIdTakesKeySuffixOfShortKey() + { + DiagnosticId id = new DiagnosticId("abc", Guid.NewGuid()); + Assert.Equal("abc", id.SdkKeySuffix); + } + + [Fact] + public void DiagnosticIdTakesKeySuffixOfEmptyKey() + { + DiagnosticId id = new DiagnosticId("", Guid.NewGuid()); + Assert.Equal("", id.SdkKeySuffix); + } + + [Fact] + public void DiagnosticIdDoesNotCrashWithNullKey() + { + DiagnosticId id = new DiagnosticId(null, Guid.NewGuid()); + Assert.Null(id.SdkKeySuffix); + } + } +} diff --git a/pkgs/shared/internal/test/Events/DiagnosticStoreBaseTest.cs b/pkgs/shared/internal/test/Events/DiagnosticStoreBaseTest.cs new file mode 100644 index 00000000..c7186c65 --- /dev/null +++ b/pkgs/shared/internal/test/Events/DiagnosticStoreBaseTest.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections.Generic; +using LaunchDarkly.Sdk.Internal.Http; +using Xunit; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + public class DiagnosticStoreBaseTest + { + const string FakeKey = "secret-example"; + const string FakeKeySuffix = "xample"; + const string FakeSdkName = "my-sdk-name"; + const string FakeTargetFramework = "example-framework"; + + class DiagnosticStoreImpl : DiagnosticStoreBase + { + public IEnumerable _configProperties = new List(); + public HttpProperties _httpProperties = HttpProperties.Default; + + protected override string SdkKeyOrMobileKey => FakeKey; + protected override string SdkName => FakeSdkName; + protected override IEnumerable ConfigProperties => _configProperties; + protected override string DotNetTargetFramework => FakeTargetFramework; + protected override HttpProperties HttpProperties => _httpProperties; + protected override Type TypeOfLdClient => typeof(DiagnosticStoreBaseTest); + } + + [Fact] + public void PersistedEventIsNullInitially() + { + var store = new DiagnosticStoreImpl(); + var persistedEvent = store.PersistedUnsentEvent; + Assert.Null(persistedEvent); + } + + [Fact] + public void PeriodicEventDefaultValuesAreCorrect() + { + var store = new DiagnosticStoreImpl(); + DateTime dataSince = store.DataSince; + LdValue periodicEvent = store.CreateEventAndReset().JsonValue; + + Assert.Equal("diagnostic", periodicEvent.Get("kind").AsString); + Assert.Equal(UnixMillisecondTime.FromDateTime(dataSince).Value, periodicEvent.Get("dataSinceDate").AsLong); + Assert.Equal(0, periodicEvent.Get("eventsInLastBatch").AsInt); + Assert.Equal(0, periodicEvent.Get("droppedEvents").AsInt); + Assert.Equal(0, periodicEvent.Get("deduplicatedUsers").AsInt); + + LdValue streamInits = periodicEvent.Get("streamInits"); + Assert.Equal(0, streamInits.Count); + } + + [Fact] + public void PeriodicEventUsesIdFromInit() + { + var store = new DiagnosticStoreImpl(); + DiagnosticEvent? initEvent = store.InitEvent; + Assert.True(initEvent.HasValue); + DiagnosticEvent periodicEvent = store.CreateEventAndReset(); + Assert.Equal(initEvent.Value.JsonValue.Get("id"), periodicEvent.JsonValue.Get("id")); + } + + [Fact] + public void CanIncrementDeduplicateUsers() + { + var store = new DiagnosticStoreImpl(); + store.IncrementDeduplicatedUsers(); + DiagnosticEvent periodicEvent = store.CreateEventAndReset(); + Assert.Equal(1, periodicEvent.JsonValue.Get("deduplicatedUsers").AsInt); + } + + [Fact] + public void CanIncrementDroppedEvents() + { + var store = new DiagnosticStoreImpl(); + store.IncrementDroppedEvents(); + DiagnosticEvent periodicEvent = store.CreateEventAndReset(); + Assert.Equal(1, periodicEvent.JsonValue.Get("droppedEvents").AsInt); + } + + [Fact] + public void CanRecordEventsInBatch() + { + var store = new DiagnosticStoreImpl(); + store.RecordEventsInBatch(4); + DiagnosticEvent periodicEvent = store.CreateEventAndReset(); + Assert.Equal(4, periodicEvent.JsonValue.Get("eventsInLastBatch").AsInt); + } + + [Fact] + public void CanAddStreamInit() + { + var store = new DiagnosticStoreImpl(); + DateTime timestamp = DateTime.Now; + store.AddStreamInit(timestamp, TimeSpan.FromMilliseconds(200.0), true); + DiagnosticEvent periodicEvent = store.CreateEventAndReset(); + + LdValue streamInits = periodicEvent.JsonValue.Get("streamInits"); + Assert.Equal(1, streamInits.Count); + + LdValue streamInit = streamInits.Get(0); + Assert.Equal(UnixMillisecondTime.FromDateTime(timestamp).Value, streamInit.Get("timestamp").AsLong); + Assert.Equal(200, streamInit.Get("durationMillis").AsInt); + Assert.True(streamInit.Get("failed").AsBool); + } + + [Fact] + public void DataSinceFromLastDiagnostic() + { + var store = new DiagnosticStoreImpl(); + DiagnosticEvent periodicEvent = store.CreateEventAndReset(); + Assert.Equal(periodicEvent.JsonValue.Get("creationDate").AsLong, + UnixMillisecondTime.FromDateTime(store.DataSince).Value); + } + + [Fact] + public void CreatingEventResetsFields() + { + var store = new DiagnosticStoreImpl(); + store.IncrementDroppedEvents(); + store.IncrementDeduplicatedUsers(); + store.RecordEventsInBatch(10); + store.AddStreamInit(DateTime.Now, TimeSpan.FromMilliseconds(200.0), true); + LdValue firstPeriodicEvent = store.CreateEventAndReset().JsonValue; + LdValue nextPeriodicEvent = store.CreateEventAndReset().JsonValue; + + Assert.Equal(firstPeriodicEvent.Get("creationDate"), nextPeriodicEvent.Get("dataSinceDate")); + Assert.Equal(0, nextPeriodicEvent.Get("eventsInLastBatch").AsInt); + Assert.Equal(0, nextPeriodicEvent.Get("droppedEvents").AsInt); + Assert.Equal(0, nextPeriodicEvent.Get("deduplicatedUsers").AsInt); + Assert.Equal(0, nextPeriodicEvent.Get("eventsInLastBatch").AsInt); + LdValue streamInits = nextPeriodicEvent.Get("streamInits"); + Assert.Equal(0, streamInits.Count); + } + + [Fact] + public void InitEventBaseProperties() + { + var store = new DiagnosticStoreImpl(); + var e = store.InitEvent.Value.JsonValue; + Assert.Equal(LdValue.Of("diagnostic-init"), e.Get("kind")); + Assert.Equal(LdValueType.Number, e.Get("creationDate").Type); + + var idProps = e.Get("id"); + Assert.NotEqual(LdValue.Null, idProps); + Assert.Equal(LdValue.Of(FakeKeySuffix), idProps.Get("sdkKeySuffix")); + Assert.NotEqual(LdValue.Null, idProps.Get("diagnosticId")); + } + + [Fact] + public void InitEventSdkProperties() + { + var store = new DiagnosticStoreImpl(); + var e = store.InitEvent.Value.JsonValue; + var sdkProps = e.Get("sdk"); + Assert.NotEqual(LdValue.Null, sdkProps); + Assert.Equal(LdValue.Of(FakeSdkName), sdkProps.Get("name")); + Assert.Equal(LdValueType.String, sdkProps.Get("version").Type); + Assert.Equal(LdValue.Null, sdkProps.Get("wrapperName")); + Assert.Equal(LdValue.Null, sdkProps.Get("wrapperVersion")); + } + + [Fact] + public void InitEventSdkPropertiesWithWrapperName() + { + var store = new DiagnosticStoreImpl(); + store._httpProperties = store._httpProperties.WithWrapper("my-wrapper-name", null); + + var e = store.InitEvent.Value.JsonValue; + var sdkProps = e.Get("sdk"); + Assert.NotEqual(LdValue.Null, sdkProps); + Assert.Equal(LdValue.Of("my-wrapper-name"), sdkProps.Get("wrapperName")); + Assert.Equal(LdValue.Null, sdkProps.Get("wrapperVersion")); + } + + [Fact] + public void InitEventSdkPropertiesWithWrapperNameAndVersion() + { + var store = new DiagnosticStoreImpl(); + store._httpProperties = store._httpProperties.WithWrapper("my-wrapper-name", "my-version"); + + var e = store.InitEvent.Value.JsonValue; + var sdkProps = e.Get("sdk"); + Assert.NotEqual(LdValue.Null, sdkProps); + Assert.Equal(LdValue.Of("my-wrapper-name"), sdkProps.Get("wrapperName")); + Assert.Equal(LdValue.Of("my-version"), sdkProps.Get("wrapperVersion")); + } + + [Fact] + public void InitEventPlatformProperties() + { + var store = new DiagnosticStoreImpl(); + var e = store.InitEvent.Value.JsonValue; + var platformProps = e.Get("platform"); + Assert.NotEqual(LdValue.Null, platformProps); + Assert.Equal(LdValue.Of("dotnet"), platformProps.Get("name")); + Assert.Equal(LdValue.Of(FakeTargetFramework), platformProps.Get("dotNetTargetFramework")); + Assert.Equal(LdValueType.String, platformProps.Get("osName").Type); + Assert.Equal(LdValueType.String, platformProps.Get("osVersion").Type); + Assert.Equal(LdValueType.String, platformProps.Get("osArch").Type); + } + + [Fact] + public void InitEventConfigPropertiesWithSingleObject() + { + var props = LdValue.BuildObject() + .Add("property1", true) + .Add("property2", "yes") + .Build(); + + var store = new DiagnosticStoreImpl(); + store._configProperties = new List { props }; + + var e = store.InitEvent.Value.JsonValue; + var configProps = e.Get("configuration"); + Assert.Equal(props, configProps); + } + + [Fact] + public void InitEventConfigPropertiesWithMergedObjects() + { + var props1 = LdValue.BuildObject() + .Add("property1", true) + .Add("property2", "yes") + .Build(); + + var props2 = LdValue.BuildObject() + .Add("property3", 3) + .Build(); + + var allProps = LdValue.BuildObject() + .Add("property1", true) + .Add("property2", "yes") + .Add("property3", 3) + .Build(); + + var store = new DiagnosticStoreImpl(); + store._configProperties = new List { props1, LdValue.Null, props2 }; + + var e = store.InitEvent.Value.JsonValue; + var configProps = e.Get("configuration"); + Assert.Equal(allProps, configProps); + } + } +} diff --git a/pkgs/shared/internal/test/Events/EventContextFormatterTest.cs b/pkgs/shared/internal/test/Events/EventContextFormatterTest.cs new file mode 100644 index 00000000..cdab714f --- /dev/null +++ b/pkgs/shared/internal/test/Events/EventContextFormatterTest.cs @@ -0,0 +1,211 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Xunit; + +using static LaunchDarkly.TestHelpers.JsonAssertions; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + public class EventContextFormatterTest + { + private struct Params + { + public string name; + public Context context; + public EventsConfiguration config; + public string json; + } + + private static List TestCases = new List + { + new Params + { + name = "no attributes private, single kind", + context = Context.Builder("my-key").Kind("org"). + Name("my-name"). + Set("attr1", "value1"). + Build(), + json = @"{""kind"": ""org"", ""key"": ""my-key"", ""name"": ""my-name"", ""attr1"": ""value1""}" + }, + new Params + { + name = "no attributes private, multi-kind", + context = Context.NewMulti( + Context.Builder("org-key").Kind("org"). + Name("org-name"). + Build(), + Context.Builder("user-key"). + Name("user-name"). + Set("attr1", "value1"). + Build() + ), + json = @"{ + ""kind"": ""multi"", + ""org"": {""key"": ""org-key"", ""name"": ""org-name""}, + ""user"": {""key"": ""user-key"", ""name"": ""user-name"", ""attr1"": ""value1""} + }" + }, + new Params + { + name = "anonymous", + context = Context.Builder("my-key").Kind("org").Anonymous(true).Build(), + json = @"{""kind"": ""org"", ""key"": ""my-key"", ""anonymous"": true}" + }, + new Params + { + name = "all attributes private globally", + context = Context.Builder("my-key").Kind("org"). + Name("my-name"). + Set("attr1", "value1"). + Build(), + config = new EventsConfiguration { AllAttributesPrivate = true }, + json = @"{ + ""kind"": ""org"", + ""key"": ""my-key"", + ""_meta"": { + ""redactedAttributes"": [""attr1"", ""name""] + } + }" + }, + new Params + { + name = "some top-level attributes private", + context = Context.Builder("my-key").Kind("org"). + Name("my-name"). + Set("attr1", "value1"). + Set("attr2", "value2"). + Private("attr2"). + Build(), + config = new EventsConfiguration { + PrivateAttributes = ImmutableHashSet.Create( + AttributeRef.FromLiteral("name") + ) + }, + json = @"{ + ""kind"": ""org"", + ""key"": ""my-key"", + ""attr1"": ""value1"", + ""_meta"": { + ""redactedAttributes"": [""attr2"", ""name""] + } + }" + }, + new Params + { + name = "partially redacting object attributes", + context = Context.Builder("my-key"). + Set("address", LdValue.Parse(@"{""street"": ""17 Highbrow St."", ""city"": ""London""}")). + Set("complex", LdValue.Parse(@"{""a"": {""b"": {""c"": 1, ""d"": 2}, ""e"": 3}, ""f"": 4, ""g"": 5}")). + Private("/complex/a/b/d", "/complex/a/b/nonexistent-prop", "/complex/f", "/complex/g/g-is-not-an-object"). + Build(), + config = new EventsConfiguration { + PrivateAttributes = ImmutableHashSet.Create( + AttributeRef.FromPath("/address/street") + ) + }, + json = @"{ + ""kind"": ""user"", + ""key"": ""my-key"", + ""address"": {""city"": ""London""}, + ""complex"": {""a"": {""b"": {""c"": 1}, ""e"": 3}, ""g"": 5}, + ""_meta"": { + ""redactedAttributes"": [""/address/street"", ""/complex/a/b/d"", ""/complex/f""] + } + }" + }, + }; + + public static IEnumerable TestCaseNames => TestCases.Select(p => new object[] { p.name }); + + [Theory] + [MemberData(nameof(TestCaseNames))] + public void TestOutput(string testCaseName) + { + // This somewhat indirect way of doing a parameterized test is necessary because Xunit has trouble + // dealing with complex types as test parameters. + var p = TestCases.Find(c => c.name == testCaseName); + + var stream = new MemoryStream(); + var w = new Utf8JsonWriter(stream); + new EventContextFormatter(p.config ?? new EventsConfiguration()).Write(p.context, w); + w.Flush(); + var json = Encoding.UTF8.GetString(stream.ToArray()); + + LdValue parsedJson = TestUtil.TryParseJson(json); + AssertJsonEqual(p.json, ValueWithRedactedAttributesSorted(parsedJson).ToJsonString()); + } + + [Fact] + public void TestSingleKindAnonymousContextsAreRedactedAppropriately() + { + var context = Context.Builder("my-key").Kind("org"). + Anonymous(true). + Name("my-name"). + Set("attr1", "value1"). + Build(); + + var stream = new MemoryStream(); + var w = new Utf8JsonWriter(stream); + new EventContextFormatter(new EventsConfiguration()).Write(context, w, redactAnonymous: true); + w.Flush(); + var json = Encoding.UTF8.GetString(stream.ToArray()); + + var expectedJson = @"{""kind"": ""org"", ""key"": ""my-key"", ""anonymous"": true, ""_meta"": { ""redactedAttributes"": [""attr1"", ""name""]}}"; + + LdValue parsedJson = TestUtil.TryParseJson(json); + AssertJsonEqual(expectedJson, ValueWithRedactedAttributesSorted(parsedJson).ToJsonString()); + } + + [Fact] + public void TestMultiKindAnonymousContextsAreRedactedAppropriately() + { + var userContext = Context.Builder("user-key").Kind("user"). + Anonymous(true). + Name("Example user name"). + Set("attr1", "value1"). + Build(); + var orgContext = Context.Builder("org-key").Kind("org"). + Anonymous(false). + Name("Example org name"). + Set("attr1", "value1"). + Build(); + var multi = Context.NewMulti(userContext, orgContext); + + var stream = new MemoryStream(); + var w = new Utf8JsonWriter(stream); + new EventContextFormatter(new EventsConfiguration()).Write(multi, w, redactAnonymous: true); + w.Flush(); + var json = Encoding.UTF8.GetString(stream.ToArray()); + + var expectedJson = @"{""kind"": ""multi"", ""org"": {""key"": ""org-key"", ""name"": ""Example org name"", ""attr1"": ""value1""}, ""user"": {""key"": ""user-key"", ""anonymous"": true, ""_meta"": { ""redactedAttributes"": [""attr1"", ""name""]}}}"; + + LdValue parsedJson = TestUtil.TryParseJson(json); + AssertJsonEqual(expectedJson, ValueWithRedactedAttributesSorted(parsedJson).ToJsonString()); + } + + private static string JsonWithRedactedAttributesSorted(string input) => + ValueWithRedactedAttributesSorted(LdValue.Parse(input)).ToJsonString(); + + private static LdValue ValueWithRedactedAttributesSorted(LdValue value) + { + switch (value.Type) + { + case LdValueType.Array: + return LdValue.ArrayFrom(value.List.Select(ValueWithRedactedAttributesSorted)); + case LdValueType.Object: + return LdValue.ObjectFrom(value.Dictionary.ToDictionary( + kv => kv.Key, + kv => kv.Key == "redactedAttributes" ? + LdValue.Convert.String.ArrayFrom(kv.Value.AsList(LdValue.Convert.String).OrderBy(s => s)) + : ValueWithRedactedAttributesSorted(kv.Value))); + default: + return value; + } + } + } +} diff --git a/pkgs/shared/internal/test/Events/EventOutputTest.cs b/pkgs/shared/internal/test/Events/EventOutputTest.cs new file mode 100644 index 00000000..10ed382f --- /dev/null +++ b/pkgs/shared/internal/test/Events/EventOutputTest.cs @@ -0,0 +1,767 @@ +using System; +using Xunit; +using static LaunchDarkly.Sdk.Internal.Events.EventProcessorInternal; +using static LaunchDarkly.Sdk.Internal.Events.EventTypes; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + public class EventOutputTest + { + private static readonly UnixMillisecondTime _fixedTimestamp = UnixMillisecondTime.OfMillis(100000); + private static readonly Context SimpleContext = Context.Builder("userkey").Name("me").Build(); + private const string SimpleContextJson = @"{""kind"": ""user"", ""key"":""userkey"", ""name"": ""me""}"; + + [Fact] + public void EvaluationEventIsSerialized() + { + Func MakeBasicEvent = () => new EvaluationEvent + { + Timestamp = _fixedTimestamp, + FlagKey = "flag", + FlagVersion = 11, + Context = SimpleContext, + Value = LdValue.Of("flagvalue"), + Default = LdValue.Of("defaultvalue") + }; + var fe = MakeBasicEvent(); + TestEventSerialization(fe, LdValue.Parse(@"{ + ""kind"":""feature"", + ""creationDate"":100000, + ""key"":""flag"", + ""version"":11, + ""context"":" + SimpleContextJson + @", + ""value"":""flagvalue"", + ""default"":""defaultvalue"" + }")); + + var feWithVariation = MakeBasicEvent(); + feWithVariation.Variation = 1; + TestEventSerialization(feWithVariation, LdValue.Parse(@"{ + ""kind"":""feature"", + ""creationDate"":100000, + ""key"":""flag"", + ""version"":11, + ""context"":" + SimpleContextJson + @", + ""value"":""flagvalue"", + ""variation"":1, + ""default"":""defaultvalue"" + }")); + + var feWithReason = MakeBasicEvent(); + feWithReason.Variation = 1; + feWithReason.Reason = EvaluationReason.RuleMatchReason(1, "id"); + TestEventSerialization(feWithReason, LdValue.Parse(@"{ + ""kind"":""feature"", + ""creationDate"":100000, + ""key"":""flag"", + ""version"":11, + ""context"":" + SimpleContextJson + @", + ""value"":""flagvalue"", + ""variation"":1, + ""default"":""defaultvalue"", + ""reason"":{""kind"":""RULE_MATCH"",""ruleIndex"":1,""ruleId"":""id""} + }")); + + var feUnknownFlag = new EvaluationEvent + { + Timestamp = fe.Timestamp, + FlagKey = "flag", + Context = SimpleContext, + Value = LdValue.Of("defaultvalue"), + Default = LdValue.Of("defaultvalue") + }; + TestEventSerialization(feUnknownFlag, LdValue.Parse(@"{ + ""kind"":""feature"", + ""creationDate"":100000, + ""key"":""flag"", + ""context"":" + SimpleContextJson + @", + ""value"":""defaultvalue"", + ""default"":""defaultvalue"" + }")); + + var debugEvent = new DebugEvent {FromEvent = feWithVariation}; + TestEventSerialization(debugEvent, LdValue.Parse(@"{ + ""kind"":""debug"", + ""creationDate"":100000, + ""key"":""flag"", + ""version"":11, + ""context"":" + SimpleContextJson + @", + ""value"":""flagvalue"", + ""variation"":1, + ""default"":""defaultvalue"" + }")); + } + + [Fact] + public void ItSerializesTheSamplingRatioForFeatureEventsWhenNotOne() + { + TestEventSerialization(new EvaluationEvent + { + Timestamp = _fixedTimestamp, + FlagKey = "flag", + FlagVersion = 11, + Context = SimpleContext, + Value = LdValue.Of("flagvalue"), + Default = LdValue.Of("defaultvalue"), + SamplingRatio = 2 + }, LdValue.Parse(@"{ + ""kind"":""feature"", + ""creationDate"":100000, + ""key"":""flag"", + ""version"":11, + ""context"":" + SimpleContextJson + @", + ""value"":""flagvalue"", + ""default"":""defaultvalue"", + ""samplingRatio"": 2 + }")); + } + + [Fact] + public void IdentifyEventIsSerialized() + { + var user = User.Builder("userkey").Name("me").Build(); + var ie = new IdentifyEvent {Timestamp = _fixedTimestamp, Context = SimpleContext}; + TestEventSerialization(ie, LdValue.Parse(@"{ + ""kind"":""identify"", + ""creationDate"":100000, + ""context"":" + SimpleContextJson + @" + }")); + } + + [Fact] + public void CustomEventIsSerialized() + { + Func MakeBasicEvent = () => new CustomEvent + { + Timestamp = _fixedTimestamp, + EventKey = "customkey", + Context = SimpleContext + }; + var ceWithoutData = MakeBasicEvent(); + TestEventSerialization(ceWithoutData, LdValue.Parse(@"{ + ""kind"":""custom"", + ""creationDate"":100000, + ""key"":""customkey"", + ""context"":" + SimpleContextJson + @" + }")); + + var ceWithData = MakeBasicEvent(); + ceWithData.Data = LdValue.Of("thing"); + TestEventSerialization(ceWithData, LdValue.Parse(@"{ + ""kind"":""custom"", + ""creationDate"":100000, + ""key"":""customkey"", + ""context"":" + SimpleContextJson + @", + ""data"":""thing"" + }")); + + var ceWithMetric = MakeBasicEvent(); + ceWithMetric.MetricValue = 2.5; + TestEventSerialization(ceWithMetric, LdValue.Parse(@"{ + ""kind"":""custom"", + ""creationDate"":100000, + ""key"":""customkey"", + ""context"":" + SimpleContextJson + @", + ""metricValue"":2.5 + }")); + + var ceWithDataAndMetric = MakeBasicEvent(); + ceWithDataAndMetric.Data = ceWithData.Data; + ceWithDataAndMetric.MetricValue = ceWithMetric.MetricValue; + TestEventSerialization(ceWithDataAndMetric, LdValue.Parse(@"{ + ""kind"":""custom"", + ""creationDate"":100000, + ""key"":""customkey"", + ""context"":" + SimpleContextJson + @", + ""data"":""thing"", + ""metricValue"":2.5 + }")); + } + + [Fact] + public void SummaryEventIsSerialized() + { + var context1 = Context.New(ContextKind.Of("kind1"), "key1"); + var context2 = Context.New(ContextKind.Of("kind2"), "key2"); + + var summary = new EventSummary(); + summary.NoteTimestamp(UnixMillisecondTime.OfMillis(1001)); + + summary.IncrementCounter("first", 1, 11, LdValue.Of("value1a"), LdValue.Of("default1"), context1); + + summary.IncrementCounter("second", 1, 21, LdValue.Of("value2a"), LdValue.Of("default2"), context1); + + summary.IncrementCounter("first", 1, 11, LdValue.Of("value1a"), LdValue.Of("default1"), context1); + summary.IncrementCounter("first", 1, 12, LdValue.Of("value1a"), LdValue.Of("default1"), context1); + + summary.IncrementCounter("second", 2, 21, LdValue.Of("value2b"), LdValue.Of("default2"), context2); + summary.IncrementCounter("second", null, 21, LdValue.Of("default2"), LdValue.Of("default2"), + context2); // flag exists (has version), but eval failed (no variation) + + summary.IncrementCounter("third", null, null, LdValue.Of("default3"), LdValue.Of("default3"), + context2); // flag doesn't exist (no version) + + summary.NoteTimestamp(UnixMillisecondTime.OfMillis(1000)); + summary.NoteTimestamp(UnixMillisecondTime.OfMillis(1002)); + + var f = new EventOutputFormatter(new EventsConfiguration()); + var outputEvent = TestUtil.TryParseJson(f.SerializeOutputEvents(new object[0], new[] { summary }, out var count)) + .Get(0); + Assert.Equal(1, count); + + Assert.Equal("summary", outputEvent.Get("kind").AsString); + Assert.Equal(1000, outputEvent.Get("startDate").AsInt); + Assert.Equal(1002, outputEvent.Get("endDate").AsInt); + + // An aggregated summary (undefined context) does not include a context. + Assert.Equal(LdValue.Null, outputEvent.Get("context")); + + var featuresJson = outputEvent.Get("features"); + Assert.Equal(3, featuresJson.Count); + + var firstJson = featuresJson.Get("first"); + Assert.Equal("default1", firstJson.Get("default").AsString); + Assert.Equal(LdValue.ArrayOf(LdValue.Of("kind1")), + firstJson.Get("contextKinds")); // we evaluated this flag with only context1 + TestUtil.AssertContainsInAnyOrder(firstJson.Get("counters").List, + LdValue.Parse(@"{""value"":""value1a"",""variation"":1,""version"":11,""count"":2}"), + LdValue.Parse(@"{""value"":""value1a"",""variation"":1,""version"":12,""count"":1}")); + + var secondJson = featuresJson.Get("second"); + Assert.Equal("default2", secondJson.Get("default").AsString); + TestUtil.AssertContainsInAnyOrder(secondJson.Get("contextKinds").List, + LdValue.Of("kind1"), LdValue.Of("kind2")); // we evaluated this flag with both context1 and context2 + TestUtil.AssertContainsInAnyOrder(secondJson.Get("counters").List, + LdValue.Parse(@"{""value"":""value2a"",""variation"":1,""version"":21,""count"":1}"), + LdValue.Parse(@"{""value"":""value2b"",""variation"":2,""version"":21,""count"":1}"), + LdValue.Parse(@"{""value"":""default2"",""version"":21,""count"":1}")); + + var thirdJson = featuresJson.Get("third"); + Assert.Equal("default3", thirdJson.Get("default").AsString); + Assert.Equal(LdValue.ArrayOf(LdValue.Of("kind2")), + thirdJson.Get("contextKinds")); // we evaluated this flag with only context2 + TestUtil.AssertContainsInAnyOrder(thirdJson.Get("counters").AsList(LdValue.Convert.Json), + LdValue.Parse(@"{""unknown"":true,""value"":""default3"",""count"":1}")); + } + + [Fact] + public void PerContextSummaryEventIncludesTheContext() + { + var summary = new EventSummary(SimpleContext); + summary.NoteTimestamp(UnixMillisecondTime.OfMillis(1000)); + summary.IncrementCounter("flag", 1, 11, LdValue.Of("value"), LdValue.Of("default"), SimpleContext); + + var f = new EventOutputFormatter(new EventsConfiguration()); + var outputEvent = TestUtil.TryParseJson(f.SerializeOutputEvents(new object[0], new[] { summary }, out var count)) + .Get(0); + + Assert.Equal(1, count); + Assert.Equal("summary", outputEvent.Get("kind").AsString); + Assert.Equal(LdValue.Parse(SimpleContextJson), outputEvent.Get("context")); + Assert.NotEqual(LdValue.Null, outputEvent.Get("features").Get("flag")); + } + + [Fact] + public void ItSerializesMigrationOpEvents() + { + var context = Context.New(ContextKind.Of("user"), "userKey"); + var migrationOpEvent = new MigrationOpEvent + { + Timestamp = UnixMillisecondTime.OfMillis(100), + Context = context, + Operation = "read", + SamplingRatio = 2, + // Evaluation detail + FlagKey = "my-migration", + FlagVersion = 12, + Variation = 2, + Value = LdValue.Of("live"), + Default = LdValue.Of("off"), + Reason = EvaluationReason.FallthroughReason, + // Measurements + Invoked = new MigrationOpEvent.InvokedMeasurement + { + Old = true, + New = true + }, + Latency = new MigrationOpEvent.LatencyMeasurement + { + Old = 200, + New = 300 + }, + Error = new MigrationOpEvent.ErrorMeasurement + { + Old = true, + New = true + }, + Consistent = new MigrationOpEvent.ConsistentMeasurement() + { + IsConsistent = true, + SamplingRatio = 3 + } + }; + + TestEventSerialization(migrationOpEvent, LdValue.Parse(@"{ + ""kind"":""migration_op"", + ""creationDate"":100, + ""samplingRatio"": 2, + ""context"": {""kind"":""user"", ""key"":""userKey""}, + ""operation"": ""read"", + ""evaluation"": { + ""key"":""my-migration"", + ""version"":12, + ""value"":""live"", + ""variation"":2, + ""default"":""off"", + ""reason"":{""kind"":""FALLTHROUGH""} + }, + ""measurements"": [ + { + ""key"": ""invoked"", + ""values"": { + ""old"": true, + ""new"": true + } + }, + { + ""key"": ""error"", + ""values"": { + ""old"": true, + ""new"": true + } + }, + { + ""key"": ""latency_ms"", + ""values"": { + ""old"": 200, + ""new"": 300 + } + }, + { + ""key"": ""consistent"", + ""value"": true, + ""samplingRatio"": 3 + } + ] + }")); + } + + [Fact] + public void ItCanOmitOptionalMeasurements() + { + var context = Context.New(ContextKind.Of("user"), "userKey"); + var migrationOpEvent = new MigrationOpEvent + { + Timestamp = UnixMillisecondTime.OfMillis(100), + Context = context, + Operation = "read", + SamplingRatio = 2, + // Evaluation detail + FlagKey = "my-migration", + FlagVersion = 12, + Variation = 2, + Value = LdValue.Of("live"), + Default = LdValue.Of("off"), + Reason = EvaluationReason.FallthroughReason, + // Measurements + Invoked = new MigrationOpEvent.InvokedMeasurement + { + Old = true, + New = true + }, + }; + + TestEventSerialization(migrationOpEvent, LdValue.Parse(@"{ + ""kind"":""migration_op"", + ""creationDate"":100, + ""samplingRatio"": 2, + ""context"": {""kind"":""user"", ""key"":""userKey""}, + ""operation"": ""read"", + ""evaluation"": { + ""key"":""my-migration"", + ""version"":12, + ""value"":""live"", + ""variation"":2, + ""default"":""off"", + ""reason"":{""kind"":""FALLTHROUGH""} + }, + ""measurements"": [ + { + ""key"": ""invoked"", + ""values"": { + ""old"": true, + ""new"": true + } + } + ] + }")); + } + + [Fact] + public void ItCanOmitOptionalEvaluationDetailFields() + { + var context = Context.New(ContextKind.Of("user"), "userKey"); + var migrationOpEvent = new MigrationOpEvent + { + Timestamp = UnixMillisecondTime.OfMillis(100), + Context = context, + Operation = "read", + SamplingRatio = 2, + // Evaluation detail + FlagKey = "my-migration", + Value = LdValue.Of("off"), + Default = LdValue.Of("off"), + // Measurements + Invoked = new MigrationOpEvent.InvokedMeasurement + { + Old = true, + New = true + }, + }; + + TestEventSerialization(migrationOpEvent, LdValue.Parse(@"{ + ""kind"":""migration_op"", + ""creationDate"":100, + ""samplingRatio"": 2, + ""context"": {""kind"":""user"", ""key"":""userKey""}, + ""operation"": ""read"", + ""evaluation"": { + ""key"":""my-migration"", + ""value"":""off"", + ""default"":""off"" + }, + ""measurements"": [ + { + ""key"": ""invoked"", + ""values"": { + ""old"": true, + ""new"": true + } + } + ] + }")); + } + + [Fact] + public void ItCanHandleOnlyOldInvoked() + { + var context = Context.New(ContextKind.Of("user"), "userKey"); + var migrationOpEvent = new MigrationOpEvent + { + Timestamp = UnixMillisecondTime.OfMillis(100), + Context = context, + Operation = "read", + SamplingRatio = 2, + // Evaluation detail + FlagKey = "my-migration", + Value = LdValue.Of("off"), + Default = LdValue.Of("off"), + // Measurements + Invoked = new MigrationOpEvent.InvokedMeasurement + { + Old = true, + }, + }; + + TestEventSerialization(migrationOpEvent, LdValue.Parse(@"{ + ""kind"":""migration_op"", + ""creationDate"":100, + ""samplingRatio"": 2, + ""context"": {""kind"":""user"", ""key"":""userKey""}, + ""operation"": ""read"", + ""evaluation"": { + ""key"":""my-migration"", + ""value"":""off"", + ""default"":""off"" + }, + ""measurements"": [ + { + ""key"": ""invoked"", + ""values"": { + ""old"": true + } + } + ] + }")); + } + + [Fact] + public void ItCanHandleOnlyNewInvoked() + { + var context = Context.New(ContextKind.Of("user"), "userKey"); + var migrationOpEvent = new MigrationOpEvent + { + Timestamp = UnixMillisecondTime.OfMillis(100), + Context = context, + Operation = "read", + SamplingRatio = 2, + // Evaluation detail + FlagKey = "my-migration", + Value = LdValue.Of("off"), + Default = LdValue.Of("off"), + // Measurements + Invoked = new MigrationOpEvent.InvokedMeasurement + { + New = true, + }, + }; + + TestEventSerialization(migrationOpEvent, LdValue.Parse(@"{ + ""kind"":""migration_op"", + ""creationDate"":100, + ""samplingRatio"": 2, + ""context"": {""kind"":""user"", ""key"":""userKey""}, + ""operation"": ""read"", + ""evaluation"": { + ""key"":""my-migration"", + ""value"":""off"", + ""default"":""off"" + }, + ""measurements"": [ + { + ""key"": ""invoked"", + ""values"": { + ""new"": true + } + } + ] + }")); + } + + [Fact] + public void ItCanHandleOnlyOldLatency() + { + var context = Context.New(ContextKind.Of("user"), "userKey"); + var migrationOpEvent = new MigrationOpEvent + { + Timestamp = UnixMillisecondTime.OfMillis(100), + Context = context, + Operation = "read", + SamplingRatio = 2, + // Evaluation detail + FlagKey = "my-migration", + Value = LdValue.Of("off"), + Default = LdValue.Of("off"), + // Measurements + Invoked = new MigrationOpEvent.InvokedMeasurement + { + New = true, + Old = true + }, + Latency = new MigrationOpEvent.LatencyMeasurement + { + Old = 200 + } + }; + + TestEventSerialization(migrationOpEvent, LdValue.Parse(@"{ + ""kind"":""migration_op"", + ""creationDate"":100, + ""samplingRatio"": 2, + ""context"": {""kind"":""user"", ""key"":""userKey""}, + ""operation"": ""read"", + ""evaluation"": { + ""key"":""my-migration"", + ""value"":""off"", + ""default"":""off"" + }, + ""measurements"": [ + { + ""key"": ""invoked"", + ""values"": { + ""new"": true, + ""old"": true + } + }, + { + ""key"": ""latency_ms"", + ""values"": { + ""old"": 200 + } + } + ] + }")); + } + + [Fact] + public void ItCanHandleOnlyNewLatency() + { + var context = Context.New(ContextKind.Of("user"), "userKey"); + var migrationOpEvent = new MigrationOpEvent + { + Timestamp = UnixMillisecondTime.OfMillis(100), + Context = context, + Operation = "read", + SamplingRatio = 2, + // Evaluation detail + FlagKey = "my-migration", + Value = LdValue.Of("off"), + Default = LdValue.Of("off"), + // Measurements + Invoked = new MigrationOpEvent.InvokedMeasurement + { + New = true, + Old = true + }, + Latency = new MigrationOpEvent.LatencyMeasurement + { + New = 200 + } + }; + + TestEventSerialization(migrationOpEvent, LdValue.Parse(@"{ + ""kind"":""migration_op"", + ""creationDate"":100, + ""samplingRatio"": 2, + ""context"": {""kind"":""user"", ""key"":""userKey""}, + ""operation"": ""read"", + ""evaluation"": { + ""key"":""my-migration"", + ""value"":""off"", + ""default"":""off"" + }, + ""measurements"": [ + { + ""key"": ""invoked"", + ""values"": { + ""new"": true, + ""old"": true + } + }, + { + ""key"": ""latency_ms"", + ""values"": { + ""new"": 200 + } + } + ] + }")); + } + + [Fact] + public void ItCanHandleOnlyOldError() + { + var context = Context.New(ContextKind.Of("user"), "userKey"); + var migrationOpEvent = new MigrationOpEvent + { + Timestamp = UnixMillisecondTime.OfMillis(100), + Context = context, + Operation = "read", + SamplingRatio = 2, + // Evaluation detail + FlagKey = "my-migration", + Value = LdValue.Of("off"), + Default = LdValue.Of("off"), + // Measurements + Invoked = new MigrationOpEvent.InvokedMeasurement + { + New = true, + Old = true + }, + Error = new MigrationOpEvent.ErrorMeasurement + { + Old = true + } + }; + + TestEventSerialization(migrationOpEvent, LdValue.Parse(@"{ + ""kind"":""migration_op"", + ""creationDate"":100, + ""samplingRatio"": 2, + ""context"": {""kind"":""user"", ""key"":""userKey""}, + ""operation"": ""read"", + ""evaluation"": { + ""key"":""my-migration"", + ""value"":""off"", + ""default"":""off"" + }, + ""measurements"": [ + { + ""key"": ""invoked"", + ""values"": { + ""new"": true, + ""old"": true + } + }, + { + ""key"": ""error"", + ""values"": { + ""old"": true + } + } + ] + }")); + } + + [Fact] + public void ItCanHandleOnlyNewError() + { + var context = Context.New(ContextKind.Of("user"), "userKey"); + var migrationOpEvent = new MigrationOpEvent + { + Timestamp = UnixMillisecondTime.OfMillis(100), + Context = context, + Operation = "read", + SamplingRatio = 2, + // Evaluation detail + FlagKey = "my-migration", + Value = LdValue.Of("off"), + Default = LdValue.Of("off"), + // Measurements + Invoked = new MigrationOpEvent.InvokedMeasurement + { + New = true, + Old = true + }, + Error = new MigrationOpEvent.ErrorMeasurement + { + New = true + } + }; + + TestEventSerialization(migrationOpEvent, LdValue.Parse(@"{ + ""kind"":""migration_op"", + ""creationDate"":100, + ""samplingRatio"": 2, + ""context"": {""kind"":""user"", ""key"":""userKey""}, + ""operation"": ""read"", + ""evaluation"": { + ""key"":""my-migration"", + ""value"":""off"", + ""default"":""off"" + }, + ""measurements"": [ + { + ""key"": ""invoked"", + ""values"": { + ""new"": true, + ""old"": true + } + }, + { + ""key"": ""error"", + ""values"": { + ""new"": true + } + } + ] + }")); + } + + + private LdValue SerializeOneEvent(EventOutputFormatter f, object e) + { + var json = f.SerializeOutputEvents(new object[] {e}, new EventSummary[0], out var count); + var outputEvent = TestUtil.TryParseJson(json).Get(0); + Assert.Equal(1, count); + return outputEvent; + } + + private void TestEventSerialization(object e, LdValue expectedJsonValue) + { + var f = new EventOutputFormatter(new EventsConfiguration()); + var outputEvent = SerializeOneEvent(f, e); + Assert.Equal(expectedJsonValue, outputEvent); + } + } +} diff --git a/pkgs/shared/internal/test/Events/EventProcessorTest.cs b/pkgs/shared/internal/test/Events/EventProcessorTest.cs new file mode 100644 index 00000000..9cdb86b6 --- /dev/null +++ b/pkgs/shared/internal/test/Events/EventProcessorTest.cs @@ -0,0 +1,1058 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.Sdk.Json; +using Moq; +using Xunit; + +using static LaunchDarkly.Sdk.Internal.Events.EventTypes; +using static LaunchDarkly.Sdk.TestUtil; +using static LaunchDarkly.TestHelpers.JsonAssertions; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + public class EventProcessorTest + { + private static readonly UnixMillisecondTime _fixedTimestamp = UnixMillisecondTime.OfMillis(10000); + + private static readonly Context _context = Context.Builder("userKey").Name("Red").Build(); + + private static readonly EvaluationReason _irrelevantReason = EvaluationReason.OffReason; + + private static TestFlagProperties BasicFlag => new TestFlagProperties + { + Key = "flagkey", + Version = 11 + }; + + private static TestFlagProperties BasicFlagWithTracking => new TestFlagProperties + { + Key = "flagkey", + Version = 11, + TrackEvents = true + }; + + private static TestEvalProperties BasicEval => new TestEvalProperties + { + Timestamp = _fixedTimestamp, + Context = _context, + Variation = 1, + Value = LdValue.Of("value") + }; + + private static TestCustomEventProperties BasicCustom => new TestCustomEventProperties + { + Timestamp = _fixedTimestamp, + Context = _context, + Key = "eventkey", + Data = LdValue.Of(3) + }; + + private EventsConfiguration _config = new EventsConfiguration(); + private readonly LdValue _contextJson = LdValue.Parse("{\"kind\":\"user\",\"key\":\"userKey\",\"name\":\"Red\"}"); + + public EventProcessorTest() + { + _config.EventCapacity = 100; + _config.EventFlushInterval = TimeSpan.FromMilliseconds(-1); + _config.DiagnosticRecordingInterval = TimeSpan.FromMinutes(5); + } + + private Mock MakeMockSender() + { + var mockSender = new Mock(MockBehavior.Strict); + mockSender.Setup(s => s.Dispose()); + return mockSender; + } + + private Mock MakeDiagnosticStore( + DiagnosticEvent? persistedUnsentEvent, + DiagnosticEvent? initEvent, + DiagnosticEvent statsEvent + ) + { + var mockDiagnosticStore = new Mock(MockBehavior.Strict); + mockDiagnosticStore.Setup(diagStore => diagStore.PersistedUnsentEvent).Returns(persistedUnsentEvent); + mockDiagnosticStore.Setup(diagStore => diagStore.InitEvent).Returns(initEvent); + mockDiagnosticStore.Setup(diagStore => diagStore.DataSince).Returns(DateTime.Now); + mockDiagnosticStore.Setup(diagStore => diagStore.RecordEventsInBatch(It.IsAny())); + mockDiagnosticStore.Setup(diagStore => diagStore.CreateEventAndReset()).Returns(statsEvent); + return mockDiagnosticStore; + } + + private EventProcessor MakeProcessor(EventsConfiguration config, Mock mockSender) + { + return MakeProcessor(config, mockSender, null, null, null); + } + + private EventProcessor MakeProcessor(EventsConfiguration config, Mock mockSender, + IDiagnosticStore diagnosticStore, IDiagnosticDisabler diagnosticDisabler, CountdownEvent diagnosticCountdown) + { + return new EventProcessor(config, mockSender.Object, new TestContextDeduplicator(), + diagnosticStore, diagnosticDisabler, NullLogger, () => { diagnosticCountdown.Signal(); }); + } + + private void RecordEval(EventProcessor ep, TestFlagProperties f, TestEvalProperties e) + { + ep.RecordEvaluationEvent(new EvaluationEvent + { + Timestamp = e.Timestamp, + Context = e.Context, + FlagKey = f.Key, + FlagVersion = f.Version, + Variation = e.Variation, + Value = e.Value, + Default = e.DefaultValue, + Reason = e.Reason, + PrereqOf = e.PrereqOf, + TrackEvents = f.TrackEvents, + DebugEventsUntilDate = f.DebugEventsUntilDate + }); + } + + private void RecordIdentify(EventProcessor ep, UnixMillisecondTime time, Context context) => + ep.RecordIdentifyEvent(new IdentifyEvent { Timestamp = time, Context = context}); + + private void RecordCustom(EventProcessor ep, TestCustomEventProperties e) + { + ep.RecordCustomEvent(new CustomEvent + { + Timestamp = e.Timestamp, + Context = e.Context, + EventKey = e.Key, + Data = e.Data, + MetricValue = e.MetricValue + }); + } + + private void FlushAndWait(EventProcessor ep, EventCapture captured) + { + ep.Flush(); + captured.AwaitPayload(); + } + + [Fact] + public void FlushingNoEventsCompletes() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + + using (var ep = MakeProcessor(_config, mockSender)) + { + var start = DateTime.Now; + // This should complete immediately. If it doesn't then that is a problem. + // If this is broken, and you do not provide a timeout, then it could wait forever. + ep.FlushAndWait(TimeSpan.FromSeconds(10)); + var diff = DateTime.Now - start; + if (diff.Seconds > 5) + { + Assert.Fail("Flushing without events did not complete immediately."); + } + Assert.Empty(captured.Events); + } + } + + [Fact] + public void IdentifyEventIsQueued() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + + using (var ep = MakeProcessor(_config, mockSender)) + { + RecordIdentify(ep, _fixedTimestamp, _context); + FlushAndWait(ep, captured); + + Assert.Collection(captured.Events, + item => CheckIdentifyEvent(item, _fixedTimestamp, _contextJson)); + } + } + + [Fact] + public void IndividualFeatureEventIsQueuedWithIndexEvent() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + + using (var ep = MakeProcessor(_config, mockSender)) + { + RecordEval(ep, BasicFlagWithTracking, BasicEval); + FlushAndWait(ep, captured); + + Assert.Collection(captured.Events, + item => CheckIndexEvent(item, BasicEval.Timestamp, _contextJson), + item => CheckFeatureEvent(item, BasicFlagWithTracking, BasicEval, _contextJson), + item => CheckSummaryEvent(item)); + } + } + + [Fact] + public void ItDoesNotQueueAFeatureEventWithSamplingRatioOfZero() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + + using (var ep = MakeProcessor(_config, mockSender)) + { + ep.RecordEvaluationEvent(new EvaluationEvent + { + FlagKey = "the-flag", + Value = LdValue.Of("value"), + Default = LdValue.Of("default"), + SamplingRatio = 0, + Context = _context, + Timestamp = BasicEval.Timestamp + }); + FlushAndWait(ep, captured); + + Assert.Collection(captured.Events, + item => CheckIndexEvent(item, BasicEval.Timestamp, _contextJson), + item => CheckSummaryEvent(item)); + } + } + + [Fact] + public void ItDoesNotSummarizeAFeatureEventThatIsExcludedFromSummaries() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + + using (var ep = MakeProcessor(_config, mockSender)) + { + ep.RecordEvaluationEvent(new EvaluationEvent + { + FlagKey = "the-flag", + Value = LdValue.Of("value"), + Default = LdValue.Of("default"), + SamplingRatio = 0, + ExcludeFromSummaries = true, + Context = _context, + Timestamp = BasicEval.Timestamp + }); + FlushAndWait(ep, captured); + + Assert.Collection(captured.Events, + item => CheckIndexEvent(item, BasicEval.Timestamp, _contextJson)); + } + } + + [Fact] + public void FeatureEventCanHaveReason() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + + using (var ep = MakeProcessor(_config, mockSender)) + { + var reasons = new EvaluationReason[] + { + _irrelevantReason, + EvaluationReason.FallthroughReason, + EvaluationReason.TargetMatchReason, + EvaluationReason.RuleMatchReason(1, "id"), + EvaluationReason.PrerequisiteFailedReason("key"), + EvaluationReason.ErrorReason(EvaluationErrorKind.WrongType) + }; + var userCounter = 0; + foreach (var reason in reasons) + { + captured.Events.Clear(); + + var userKey = "user" + (++userCounter); + var contextJson = LdValue.Parse("{\"kind\":\"user\",\"key\":\"" + userKey + "\"}"); + var context = Context.New(userKey); + var eval = BasicEval; + eval.Context = context; + eval.Reason = reason; + RecordEval(ep, BasicFlagWithTracking, eval); + FlushAndWait(ep, captured); + + Assert.Collection(captured.Events, + item => CheckIndexEvent(item), + item => CheckFeatureEvent(item, BasicFlagWithTracking, eval, contextJson), + item => CheckSummaryEvent(item)); + } + } + } + + [Fact] + public void EventKindIsDebugIfFlagIsTemporarilyInDebugMode() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + + using (var ep = MakeProcessor(_config, mockSender)) + { + var flag = BasicFlag; + flag.DebugEventsUntilDate = UnixMillisecondTime.Now.PlusMillis(1000000); + RecordEval(ep, flag, BasicEval); + FlushAndWait(ep, captured); + + Assert.Collection(captured.Events, + item => CheckIndexEvent(item, BasicEval.Timestamp, _contextJson), + item => CheckDebugEvent(item, flag, BasicEval, _contextJson), + item => CheckSummaryEvent(item)); + } + } + + [Fact] + public void EventCanBeBothTrackedAndDebugged() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + + using (var ep = MakeProcessor(_config, mockSender)) + { + var flag = BasicFlagWithTracking; + flag.DebugEventsUntilDate = UnixMillisecondTime.Now.PlusMillis(1000000); + RecordEval(ep, flag, BasicEval); + FlushAndWait(ep, captured); + + Assert.Collection(captured.Events, + item => CheckIndexEvent(item, BasicEval.Timestamp, _contextJson), + item => CheckFeatureEvent(item, flag, BasicEval, _contextJson), + item => CheckDebugEvent(item, flag, BasicEval, _contextJson), + item => CheckSummaryEvent(item)); + } + } + + [Fact] + public void DebugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() + { + // Pick a server time that is somewhat behind the client time + var serverTime = DateTime.Now.Subtract(TimeSpan.FromSeconds(20)); + + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender, + new EventSenderResult(DeliveryStatus.Succeeded, serverTime)); + + using (var ep = MakeProcessor(_config, mockSender)) + { + // Send and flush an event we don't care about, just to set the last server time + RecordIdentify(ep, _fixedTimestamp, Context.New("otherUser")); + FlushAndWait(ep, captured); + captured.Events.Clear(); + ep.WaitUntilInactive(); // this waits till flush tasks have completed, so the time has been updated from the response + + // Now send an event with debug mode on, with a "debug until" time that is further in + // the future than the server time, but in the past compared to the client. + var flag = BasicFlag; + flag.DebugEventsUntilDate = UnixMillisecondTime.FromDateTime(serverTime).PlusMillis(1000); + RecordEval(ep, flag, BasicEval); + FlushAndWait(ep, captured); + + // Should get a summary event only, not a full feature event + Assert.Collection(captured.Events, + item => CheckIndexEvent(item, BasicEval.Timestamp, _contextJson), + item => CheckSummaryEvent(item)); + } + } + + [Fact] + public void DebugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() + { + // Pick a server time that is somewhat ahead of the client time + var serverTime = DateTime.Now.Add(TimeSpan.FromSeconds(20)); + + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender, + new EventSenderResult(DeliveryStatus.Succeeded, serverTime)); + + using (var ep = MakeProcessor(_config, mockSender)) + { + // Send and flush an event we don't care about, just to set the last server time + RecordIdentify(ep, _fixedTimestamp, Context.New("otherUser")); + FlushAndWait(ep, captured); + captured.Events.Clear(); + ep.WaitUntilInactive(); // this waits till flush tasks have completed, so the time has been updated from the response + + // Now send an event with debug mode on, with a "debug until" time that is further in + // the future than the client time, but in the past compared to the server. + var flag = BasicFlag; + flag.DebugEventsUntilDate = UnixMillisecondTime.FromDateTime(serverTime).PlusMillis(-1000); + RecordEval(ep, flag, BasicEval); + FlushAndWait(ep, captured); + + // Should get a summary event only, not a full feature event + Assert.Collection(captured.Events, + item => CheckIndexEvent(item, BasicEval.Timestamp, _contextJson), + item => CheckSummaryEvent(item)); + } + } + + [Fact] + public void TwoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + + using (var ep = MakeProcessor(_config, mockSender)) + { + var flag1 = new TestFlagProperties { Key = "flagkey1", Version = 11, TrackEvents = true }; + var flag2 = new TestFlagProperties { Key = "flagkey2", Version = 22, TrackEvents = true }; + var value = LdValue.Of("value"); + RecordEval(ep, flag1, BasicEval); + RecordEval(ep, flag2, BasicEval); + FlushAndWait(ep, captured); + + Assert.Collection(captured.Events, + item => CheckIndexEvent(item, BasicEval.Timestamp, _contextJson), + item => CheckFeatureEvent(item, flag1, BasicEval, _contextJson), + item => CheckFeatureEvent(item, flag2, BasicEval, _contextJson), + item => CheckSummaryEvent(item)); + } + } + + [Fact] + public void NonTrackedEventsAreSummarized() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + + using (var ep = MakeProcessor(_config, mockSender)) + { + var flag1 = new TestFlagProperties { Key = "flagkey1", Version = 11 }; + var flag2 = new TestFlagProperties { Key = "flagkey2", Version = 22 }; + var value1 = LdValue.Of("value1"); + var value2 = LdValue.Of("value2"); + var default1 = LdValue.Of("default1"); + var default2 = LdValue.Of("default2"); + var earliestTime = UnixMillisecondTime.OfMillis(10000); + var latestTime = UnixMillisecondTime.OfMillis(20000); + RecordEval(ep, flag1, new TestEvalProperties + { + Timestamp = earliestTime, + Context = _context, + Variation = 1, + Value = value1, + DefaultValue = default1 + }); + RecordEval(ep, flag1, new TestEvalProperties + { + Timestamp = UnixMillisecondTime.OfMillis(earliestTime.Value + 10), + Context = _context, + Variation = 1, + Value = value1, + DefaultValue = default1 + }); + RecordEval(ep, flag1, new TestEvalProperties + { + Timestamp = UnixMillisecondTime.OfMillis(earliestTime.Value + 20), + Context = _context, + Variation = 2, + Value = value2, + DefaultValue = default1 + }); + RecordEval(ep, flag2, new TestEvalProperties + { + Timestamp = latestTime, + Context = _context, + Variation = 2, + Value = value2, + DefaultValue = default2 + }); + FlushAndWait(ep, captured); + + Assert.Collection(captured.Events, + item => CheckIndexEvent(item, earliestTime, _contextJson), + item => CheckSummaryEventDetails(item, + earliestTime, + latestTime, + MustHaveFlagSummary(flag1.Key, default1, + MustHaveFlagSummaryCounter(value1, 1, flag1.Version, 2), + MustHaveFlagSummaryCounter(value2, 2, flag1.Version, 1) + ), + MustHaveFlagSummary(flag2.Key, default2, + MustHaveFlagSummaryCounter(value2, 2, flag2.Version, 1) + ) + ) + ); + } + } + + [Fact] + public void CustomEventIsQueuedWithUser() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + + using (var ep = MakeProcessor(_config, mockSender)) + { + var ce = new TestCustomEventProperties + { + Timestamp = _fixedTimestamp, + Context = _context, + Key = "eventkey", + Data = LdValue.Of(3), + MetricValue = 1.5 + }; + RecordCustom(ep, ce); + FlushAndWait(ep, captured); + + Assert.Collection(captured.Events, + item => CheckIndexEvent(item, ce.Timestamp, _contextJson), + item => CheckCustomEvent(item, ce, _contextJson)); + } + } + + [Fact] + public void AsyncFlushWithNoWait() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + captured.Delay = TimeSpan.FromMilliseconds(200); + + using (var ep = MakeProcessor(_config, mockSender)) + { + RecordIdentify(ep, _fixedTimestamp, _context); + ep.Flush(); + + Assert.Empty(captured.Events); // Flush() returns before flush has actually happened + + captured.AwaitPayload(); // but it does happen if we wait + Assert.Collection(captured.Events, + item => CheckIdentifyEvent(item, _fixedTimestamp, _contextJson)); + }; + } + + [Fact] + public void FlushAndWaitSucceeds() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + captured.Delay = TimeSpan.FromMilliseconds(100); + + using (var ep = MakeProcessor(_config, mockSender)) + { + RecordIdentify(ep, _fixedTimestamp, _context); + bool sent = ep.FlushAndWait(TimeSpan.FromMilliseconds(500)); // allow for random execution delays + + Assert.True(sent); + Assert.Collection(captured.Events, + item => CheckIdentifyEvent(item, _fixedTimestamp, _contextJson)); + }; + } + + [Fact] + public void FlushAndWaitTimesOut() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + captured.Delay = TimeSpan.FromMilliseconds(200); + + using (var ep = MakeProcessor(_config, mockSender)) + { + RecordIdentify(ep, _fixedTimestamp, _context); + bool sent = ep.FlushAndWait(TimeSpan.FromMilliseconds(10)); + + Assert.False(sent); + Assert.Empty(captured.Events); // flush hasn't happened yet + captured.AwaitPayload(); // but it does happen if we wait + }; + } + + [Fact] + public async Task AsyncFlushAndWaitSucceeds() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + captured.Delay = TimeSpan.FromMilliseconds(100); + + using (var ep = MakeProcessor(_config, mockSender)) + { + RecordIdentify(ep, _fixedTimestamp, _context); + bool sent = await ep.FlushAndWaitAsync(TimeSpan.FromMilliseconds(500)); // allow for random execution delays + + Assert.True(sent); + Assert.Collection(captured.Events, + item => CheckIdentifyEvent(item, _fixedTimestamp, _contextJson)); + }; + } + + [Fact] + public async Task AsyncFlushAndWaitTimesOut() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + captured.Delay = TimeSpan.FromMilliseconds(200); + + using (var ep = MakeProcessor(_config, mockSender)) + { + RecordIdentify(ep, _fixedTimestamp, _context); + bool sent = await ep.FlushAndWaitAsync(TimeSpan.FromMilliseconds(10)); + + Assert.False(sent); + Assert.Empty(captured.Events); // flush hasn't happened yet + captured.AwaitPayload(); // but it does happen if we wait + }; + } + + [Fact] + public void FinalFlushIsDoneOnDispose() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + + using (var ep = MakeProcessor(_config, mockSender)) + { + RecordIdentify(ep, _fixedTimestamp, _context); + + ep.Dispose(); + + Assert.Collection(captured.Events, + item => CheckIdentifyEvent(item, _fixedTimestamp, _contextJson)); + mockSender.Verify(s => s.Dispose(), Times.Once()); + } + } + + [Fact] + public void FlushDoesNothingIfThereAreNoEvents() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + + using (var ep = MakeProcessor(_config, mockSender)) + { + ep.Flush(); + captured.ExpectNoMorePayloads(); + } + } + + [Fact] + public void FlushDoesNothingWhenOffline() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + + using (var ep = MakeProcessor(_config, mockSender)) + { + ep.SetOffline(true); + RecordIdentify(ep, _fixedTimestamp, _context); + ep.Flush(); + captured.ExpectNoMorePayloads(); + + // We should have still held on to that event, so if we go online again and flush, it is sent. + ep.SetOffline(false); + FlushAndWait(ep, captured); + + Assert.Collection(captured.Events, + item => CheckIdentifyEvent(item, _fixedTimestamp, _contextJson)); + }; + } + + [Fact] + public void EventsAreStillPostedAfterRecoverableFailure() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender, + new EventSenderResult(DeliveryStatus.Failed, null)); + + using (var ep = MakeProcessor(_config, mockSender)) + { + RecordIdentify(ep, _fixedTimestamp, _context); + FlushAndWait(ep, captured); + + Assert.Collection(captured.Events, + item => CheckIdentifyEvent(item, _fixedTimestamp, _contextJson)); + captured.Events.Clear(); + + RecordCustom(ep, BasicCustom); + FlushAndWait(ep, captured); + + Assert.Collection(captured.Events, + item => CheckCustomEvent(item, BasicCustom, _contextJson)); + } + } + + [Fact] + public void EventsAreNotPostedAfterUnrecoverableFailure() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender, + new EventSenderResult(DeliveryStatus.FailedAndMustShutDown, null)); + + using (var ep = MakeProcessor(_config, mockSender)) + { + RecordIdentify(ep, _fixedTimestamp, _context); + FlushAndWait(ep, captured); + + Assert.Collection(captured.Events, + item => CheckIdentifyEvent(item, _fixedTimestamp, _contextJson)); + + RecordCustom(ep, BasicCustom); + ep.Flush(); + captured.ExpectNoMorePayloads(); + } + } + + [Fact] + public void EventsInBatchRecorded() + { + var expectedStats = LdValue.BuildObject().Add("stats", "testValue").Build(); + var mockDiagnosticStore = MakeDiagnosticStore(null, null, new DiagnosticEvent(expectedStats)); + + var mockSender = MakeMockSender(); + var eventCapture = EventCapture.From(mockSender); + var diagnosticCapture = EventCapture.DiagnosticsFrom(mockSender); + CountdownEvent diagnosticCountdown = new CountdownEvent(1); + + using (var ep = MakeProcessor(_config, mockSender, mockDiagnosticStore.Object, null, diagnosticCountdown)) + { + RecordEval(ep, BasicFlagWithTracking, BasicEval); + FlushAndWait(ep, eventCapture); + + mockDiagnosticStore.Verify(diagStore => diagStore.RecordEventsInBatch(2), Times.Once(), + "Diagnostic store's RecordEventsInBatch should be called with the number of events in last flush"); + + ep.DoDiagnosticSend(null); + diagnosticCountdown.Wait(); + mockDiagnosticStore.Verify(diagStore => diagStore.CreateEventAndReset(), Times.Once()); + + Assert.Equal(expectedStats, diagnosticCapture.EventsQueue.Take()); + } + } + + [Fact] + public void DiagnosticStorePersistedUnsentEventSentToDiagnosticUri() + { + var expected = LdValue.BuildObject().Add("testKey", "testValue").Build(); + var mockDiagnosticStore = MakeDiagnosticStore(new DiagnosticEvent(expected), null, + new DiagnosticEvent(LdValue.Null)); + + var mockSender = MakeMockSender(); + var eventCapture = EventCapture.From(mockSender); + var diagnosticCapture = EventCapture.DiagnosticsFrom(mockSender); + var diagnosticCountdown = new CountdownEvent(1); + + using (var ep = MakeProcessor(_config, mockSender, mockDiagnosticStore.Object, null, diagnosticCountdown)) + { + diagnosticCountdown.Wait(); + + Assert.Equal(expected, diagnosticCapture.EventsQueue.Take()); + } + } + + [Fact] + public void DiagnosticStoreInitEventSentToDiagnosticUri() + { + var expected = LdValue.BuildObject().Add("testKey", "testValue").Build(); + var mockDiagnosticStore = MakeDiagnosticStore(null, new DiagnosticEvent(expected), + new DiagnosticEvent(LdValue.Null)); + + var mockSender = MakeMockSender(); + var eventCapture = EventCapture.From(mockSender); + var diagnosticCapture = EventCapture.DiagnosticsFrom(mockSender); + var diagnosticCountdown = new CountdownEvent(1); + + using (var ep = MakeProcessor(_config, mockSender, mockDiagnosticStore.Object, null, diagnosticCountdown)) + { + diagnosticCountdown.Wait(); + + Assert.Equal(expected, diagnosticCapture.EventsQueue.Take()); + } + } + + [Fact] + public void DiagnosticDisablerDisablesInitialDiagnostics() + { + var testDiagnostic = LdValue.BuildObject().Add("testKey", "testValue").Build(); + var mockDiagnosticStore = MakeDiagnosticStore(new DiagnosticEvent(testDiagnostic), + new DiagnosticEvent(testDiagnostic), new DiagnosticEvent(LdValue.Null)); + + var mockDiagnosticDisabler = new Mock(MockBehavior.Strict); + mockDiagnosticDisabler.Setup(diagDisabler => diagDisabler.Disabled).Returns(true); + + var mockSender = MakeMockSender(); + var eventCapture = EventCapture.From(mockSender); + var diagnosticCapture = EventCapture.DiagnosticsFrom(mockSender); + + using (var ep = MakeProcessor(_config, mockSender, mockDiagnosticStore.Object, mockDiagnosticDisabler.Object, null)) + { + } + mockDiagnosticStore.Verify(diagStore => diagStore.InitEvent, Times.Never()); + mockDiagnosticStore.Verify(diagStore => diagStore.PersistedUnsentEvent, Times.Never()); + Assert.Empty(diagnosticCapture.Events); + } + + [Fact] + public void DiagnosticDisablerEnabledInitialDiagnostics() + { + var expectedStats = LdValue.BuildObject().Add("stats", "testValue").Build(); + var expectedInit = LdValue.BuildObject().Add("init", "testValue").Build(); + var mockDiagnosticStore = MakeDiagnosticStore(new DiagnosticEvent(expectedStats), + new DiagnosticEvent(expectedInit), new DiagnosticEvent(LdValue.Null)); + + var mockDiagnosticDisabler = new Mock(MockBehavior.Strict); + mockDiagnosticDisabler.Setup(diagDisabler => diagDisabler.Disabled).Returns(false); + + var mockSender = MakeMockSender(); + var eventCapture = EventCapture.From(mockSender); + var diagnosticCapture = EventCapture.DiagnosticsFrom(mockSender); + var diagnosticCountdown = new CountdownEvent(1); + + using (var ep = MakeProcessor(_config, mockSender, mockDiagnosticStore.Object, mockDiagnosticDisabler.Object, diagnosticCountdown)) + { + diagnosticCountdown.Wait(); + + Assert.Equal(expectedStats, diagnosticCapture.EventsQueue.Take()); + Assert.Equal(expectedInit, diagnosticCapture.EventsQueue.Take()); + } + } + + [Fact] + public void ItCanQueueAMigrationOpEvent() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + + using (var ep = MakeProcessor(_config, mockSender)) + { + ep.RecordMigrationOpEvent(new MigrationOpEvent + { + Context = _context, + FlagKey = "flag-key", + Value = LdValue.Of("live"), + Default = LdValue.Of("off"), + SamplingRatio = 1, + Invoked = new MigrationOpEvent.InvokedMeasurement + { + New = true + } + }); + FlushAndWait(ep, captured); + + Assert.Collection(captured.Events, AssertMigrationEvent); + } + } + + [Fact] + public void ItDoesNotQueueAMigrationOpEventWithASamplingRatioOfZero() + { + var mockSender = MakeMockSender(); + var captured = EventCapture.From(mockSender); + + using (var ep = MakeProcessor(_config, mockSender)) + { + ep.RecordMigrationOpEvent(new MigrationOpEvent + { + Context = _context, + FlagKey = "flag-key", + Value = LdValue.Of("live"), + Default = LdValue.Of("off"), + SamplingRatio = 0, + Invoked = new MigrationOpEvent.InvokedMeasurement + { + New = true + } + }); + + Assert.True(ep.FlushAndWait(TimeSpan.Zero)); + captured.ExpectNoMorePayloads(); + Assert.Empty(captured.Events); + } + } + + private void AssertMigrationEvent(LdValue item) + { + // For most tests we just need to check that there is a migration event. + // The output formatting test verify the contents of the events. + Assert.Equal("migration_op", item.Get("kind").AsString); + } + + + private void CheckIdentifyEvent(LdValue t, UnixMillisecondTime timestamp, LdValue contextJson) => + AssertJsonEqual( + LdValue.BuildObject().Set("kind", "identify"). + Set("creationDate", timestamp.Value). + Set("context", contextJson).Build().ToJsonString(), + t.ToJsonString()); + + private void CheckIndexEvent(LdValue t) + { + Assert.Equal(LdValue.Of("index"), t.Get("kind")); + } + + private void CheckIndexEvent(LdValue t, UnixMillisecondTime timestamp, LdValue contextJson) => + AssertJsonEqual( + LdValue.BuildObject().Set("kind", "index"). + Set("creationDate", timestamp.Value). + Set("context", contextJson).Build().ToJsonString(), + t.ToJsonString()); + + private void CheckFeatureEvent(LdValue t, TestFlagProperties f, TestEvalProperties e, LdValue userJson) + { + CheckFeatureOrDebugEvent("feature", t, f, e, userJson); + } + + private void CheckDebugEvent(LdValue t, TestFlagProperties f, TestEvalProperties e, + LdValue userJson) + { + CheckFeatureOrDebugEvent("debug", t, f, e, userJson); + } + + private void CheckFeatureOrDebugEvent(string kind, LdValue t, TestFlagProperties f, TestEvalProperties e, + LdValue userJson) + { + Assert.Equal(LdValue.Of(kind), t.Get("kind")); + Assert.Equal(LdValue.Of(e.Timestamp.Value), t.Get("creationDate")); + Assert.Equal(LdValue.Of(f.Key), t.Get("key")); + Assert.Equal(LdValue.Of(f.Version), t.Get("version")); + Assert.Equal(e.Variation.HasValue ? LdValue.Of(e.Variation.Value) : LdValue.Null, t.Get("variation")); + Assert.Equal(e.Value, t.Get("value")); + Assert.Equal(userJson, t.Get("context")); + Assert.Equal(e.Reason.HasValue ? + LdValue.Parse(LdJsonSerialization.SerializeObject(e.Reason.Value)) : LdValue.Null, + t.Get("reason")); + } + + private void CheckCustomEvent(LdValue t, TestCustomEventProperties e, LdValue contextJson) + { + Assert.Equal(LdValue.Of("custom"), t.Get("kind")); + Assert.Equal(LdValue.Of(e.Key), t.Get("key")); + Assert.Equal(e.Data, t.Get("data")); + Assert.Equal(contextJson, t.Get("context")); + Assert.Equal(e.MetricValue.HasValue ? LdValue.Of(e.MetricValue.Value) : LdValue.Null, t.Get("metricValue")); + } + + private void CheckSummaryEvent(LdValue t) + { + Assert.Equal(LdValue.Of("summary"), t.Get("kind")); + } + + private void CheckSummaryEventDetails(LdValue o, UnixMillisecondTime startDate, UnixMillisecondTime endDate, params Action[] flagChecks) + { + CheckSummaryEvent(o); + Assert.Equal(LdValue.Of(startDate.Value), o.Get("startDate")); + Assert.Equal(LdValue.Of(endDate.Value), o.Get("endDate")); + var features = o.Get("features"); + Assert.Equal(flagChecks.Length, features.Count); + foreach (var flagCheck in flagChecks) + { + flagCheck(features); + } + } + + private Action MustHaveFlagSummary(string flagKey, LdValue defaultVal, params Action[] counterChecks) + { + return o => + { + var fo = o.Get(flagKey); + if (fo.IsNull) + { + Assert.True(false, "could not find flag '" + flagKey + "' in: " + fo.ToString()); + } + LdValue cs = fo.Get("counters"); + Assert.True(defaultVal.Equals(fo.Get("default")), + "default should be " + defaultVal + " in " + fo); + if (counterChecks.Length != cs.Count) + { + Assert.True(false, "number of counters should be " + counterChecks.Length + " in " + fo + " for flag " + flagKey); + } + foreach (var counterCheck in counterChecks) + { + counterCheck(flagKey, cs); + } + }; + } + + private Action MustHaveFlagSummaryCounter(LdValue value, int? variation, int? version, int count) + { + return (flagKey, items) => + { + if (!items.AsList(LdValue.Convert.Json).Any(o => + { + return o.Get("value").Equals(value) + && o.Get("version").Equals(version.HasValue ? LdValue.Of(version.Value) : LdValue.Null) + && o.Get("variation").Equals(variation.HasValue ? LdValue.Of(variation.Value) : LdValue.Null) + && o.Get("count").Equals(LdValue.Of(count)); + })) + { + Assert.True(false, "could not find counter for (" + value + ", " + version + ", " + variation + ", " + count + + ") in: " + items.ToString() + " for flag " + flagKey); + } + }; + } + } + + class EventCapture + { + public readonly List Events = new List(); + public readonly BlockingCollection Payloads = new BlockingCollection(); + public readonly BlockingCollection EventsQueue = new BlockingCollection(); + + public TimeSpan? Delay; + + public LdValue AwaitPayload(TimeSpan? timeout = null) + { + if (!Payloads.TryTake(out var p, timeout ?? TimeSpan.FromSeconds(5))) + { + throw new Exception("timed out waiting for event payload"); + } + return p; + } + + public void ExpectNoMorePayloads(TimeSpan? timeout = null) + { + if (Payloads.TryTake(out _, timeout ?? TimeSpan.FromMilliseconds(100))) + { + throw new Exception("received unexpected event payload"); + } + } + + internal static EventCapture From(Mock mockSender) => + From(mockSender, new EventSenderResult(DeliveryStatus.Succeeded, null)); + + internal static EventCapture From(Mock mockSender, EventSenderResult result) => + From(mockSender, EventDataKind.AnalyticsEvents, result); + + internal static EventCapture DiagnosticsFrom(Mock mockSender) => + From(mockSender, EventDataKind.DiagnosticEvent, new EventSenderResult(DeliveryStatus.Succeeded, null)); + + internal static EventCapture From(Mock mockSender, EventDataKind forKind, EventSenderResult result) + { + var ec = new EventCapture(); + mockSender.Setup( + s => s.SendEventDataAsync(forKind, It.IsAny(), It.IsAny()) + ).Callback((kind, data, count) => + { + if (ec.Delay.HasValue) + { + Thread.Sleep(ec.Delay.Value); + } + var parsed = TestUtil.TryParseJson(data); + var events = kind == EventDataKind.DiagnosticEvent ? new List { parsed } : + parsed.AsList(LdValue.Convert.Json); + ec.Events.AddRange(events); + foreach (var e in events) + { + ec.EventsQueue.Add(e); + } + ec.Payloads.Add(parsed); + }).Returns(Task.FromResult(result)); + return ec; + } + } + + class TestContextDeduplicator : IContextDeduplicator + { + private HashSet _contextKeys = new HashSet(); + public TimeSpan? FlushInterval => null; + + public void Flush() + { + } + + public bool ProcessContext(in Context context) + { + if (!_contextKeys.Contains(context.FullyQualifiedKey)) + { + _contextKeys.Add(context.FullyQualifiedKey); + return true; + } + return false; + } + } +} diff --git a/pkgs/shared/internal/test/Events/EventSummarizerTest.cs b/pkgs/shared/internal/test/Events/EventSummarizerTest.cs new file mode 100644 index 00000000..e8f76cc0 --- /dev/null +++ b/pkgs/shared/internal/test/Events/EventSummarizerTest.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using Xunit; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + public class EventSummarizerTest + { + private static Context _context = Context.New("key"); + + [Fact] + public void SummarizeEventSetsStartAndEndDates() + { + var time1 = UnixMillisecondTime.OfMillis(1000); + var time2 = UnixMillisecondTime.OfMillis(2000); + var time3 = UnixMillisecondTime.OfMillis(3000); + EventSummarizer es = new EventSummarizer(); + es.SummarizeEvent(time2, "flag", null, null, LdValue.Null, LdValue.Null, _context); + es.SummarizeEvent(time1, "flag", null, null, LdValue.Null, LdValue.Null, _context); + es.SummarizeEvent(time3, "flag", null, null, LdValue.Null, LdValue.Null, _context); + EventSummary data = es.GetSummaryAndReset(); + + Assert.Equal(time1, data.StartDate); + Assert.Equal(time3, data.EndDate); + } + + [Fact] + public void SummarizeEventIncrementsCounters() + { + var time = UnixMillisecondTime.OfMillis(1000); + string flag1Key = "flag1", flag2Key = "flag2", unknownFlagKey = "badkey"; + int flag1Version = 100, flag2Version = 200; + int variation1 = 1, variation2 = 2; + LdValue value1 = LdValue.Of("value1"), value2 = LdValue.Of("value2"), + value99 = LdValue.Of("value99"), + default1 = LdValue.Of("default1"), default2 = LdValue.Of("default2"), + default3 = LdValue.Of("default3"); + EventSummarizer es = new EventSummarizer(); + es.SummarizeEvent(time, flag1Key, flag1Version, variation1, value1, default1, _context); + es.SummarizeEvent(time, flag1Key, flag1Version, variation2, value2, default1, _context); + es.SummarizeEvent(time, flag2Key, flag2Version, variation1, value99, default2, _context); + es.SummarizeEvent(time, flag1Key, flag1Version, variation1, value1, default1, _context); + es.SummarizeEvent(time, unknownFlagKey, null, null, default3, default3, _context); + EventSummary data = es.GetSummaryAndReset(); + + Dictionary expected = new Dictionary(); + Assert.Equal(new EventsCounterValue(2, value1), + data.Flags[flag1Key].Counters[new EventsCounterKey(flag1Version, variation1)]); + Assert.Equal(new EventsCounterValue(1, value2), + data.Flags[flag1Key].Counters[new EventsCounterKey(flag1Version, variation2)]); + Assert.Equal(new EventsCounterValue(1, value99), + data.Flags[flag2Key].Counters[new EventsCounterKey(flag2Version, variation1)]); + Assert.Equal(new EventsCounterValue(1, default3), + data.Flags[unknownFlagKey].Counters[new EventsCounterKey(null, null)]); + } + + [Fact] + public void SummarizeEventRemembersContextKinds() + { + var time = UnixMillisecondTime.OfMillis(1000); + string flag1Key = "flag1", flag2Key = "flag2", flag3Key = "flag3"; + int version = 100, variation = 1; + LdValue value = LdValue.Of("irrelevant"); + + var c1 = Context.New(ContextKind.Of("kind1"), "key1"); + var c2 = Context.New(ContextKind.Of("kind2"), "key2"); + var c3 = Context.New(ContextKind.Of("kind3"), "key3"); + var multi = Context.NewMulti(c1, c2, c3); + + EventSummarizer es = new EventSummarizer(); + // flag1 gets only kind1; flag2 gets kind1 and kind2; flag3 gets kind1, kind2, and kind3 + es.SummarizeEvent(time, flag1Key, version, variation, value, value, c1); + es.SummarizeEvent(time, flag2Key, version, variation, value, value, c1); + es.SummarizeEvent(time, flag2Key, version, variation, value, value, c2); + es.SummarizeEvent(time, flag3Key, version, variation, value, value, multi); + EventSummary data = es.GetSummaryAndReset(); + + Assert.Equal(new HashSet { "kind1" }, data.Flags[flag1Key].ContextKinds); + Assert.Equal(new HashSet { "kind1", "kind2" }, data.Flags[flag2Key].ContextKinds); + Assert.Equal(new HashSet { "kind1", "kind2", "kind3" }, data.Flags[flag3Key].ContextKinds); + } + } +} diff --git a/pkgs/shared/internal/test/Events/PerContextEventSummarizerTest.cs b/pkgs/shared/internal/test/Events/PerContextEventSummarizerTest.cs new file mode 100644 index 00000000..8583b2df --- /dev/null +++ b/pkgs/shared/internal/test/Events/PerContextEventSummarizerTest.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + public class PerContextEventSummarizerTest + { + private static readonly UnixMillisecondTime Time = UnixMillisecondTime.OfMillis(1000); + private static readonly Context ContextA = Context.New(ContextKind.Of("user"), "a"); + private static readonly Context ContextB = Context.New(ContextKind.Of("user"), "b"); + + private static EventSummary SummaryFor(IReadOnlyList summaries, Context context) => + summaries.Single(s => s.Context.Equals(context)); + + [Fact] + public void EmptySummarizerReturnsNoSummaries() + { + var summarizer = new PerContextEventSummarizer(); + Assert.Empty(summarizer.GetSummariesAndReset()); + } + + [Fact] + public void EachContextGetsItsOwnSummaryWithTheContextAttached() + { + var summarizer = new PerContextEventSummarizer(); + summarizer.SummarizeEvent(Time, "flag", 1, 0, LdValue.Of("a"), LdValue.Null, ContextA); + summarizer.SummarizeEvent(Time, "flag", 1, 0, LdValue.Of("b"), LdValue.Null, ContextB); + + var summaries = summarizer.GetSummariesAndReset(); + + Assert.Equal(2, summaries.Count); + Assert.Equal(ContextA, SummaryFor(summaries, ContextA).Context); + Assert.Equal(ContextB, SummaryFor(summaries, ContextB).Context); + } + + [Fact] + public void EvaluationsForEqualContextsAreMergedIntoOneSummary() + { + // Two distinct Context instances that are value-equal must map to the same accumulator. + var contextA1 = Context.New(ContextKind.Of("user"), "a"); + var contextA2 = Context.New(ContextKind.Of("user"), "a"); + + var summarizer = new PerContextEventSummarizer(); + summarizer.SummarizeEvent(Time, "flag", 1, 0, LdValue.Of("v"), LdValue.Null, contextA1); + summarizer.SummarizeEvent(Time, "flag", 1, 0, LdValue.Of("v"), LdValue.Null, contextA2); + + var summaries = summarizer.GetSummariesAndReset(); + + Assert.Single(summaries); + var counter = summaries[0].Flags["flag"].Counters[new EventsCounterKey(1, 0)]; + Assert.Equal(2, counter.Count); + } + + [Fact] + public void ContextsDifferingByAttributeAreTrackedSeparately() + { + // Same key/kind but different attributes must be treated as different contexts. + var plain = Context.New(ContextKind.Of("user"), "a"); + var withName = Context.Builder("a").Kind("user").Name("Pat").Build(); + + var summarizer = new PerContextEventSummarizer(); + summarizer.SummarizeEvent(Time, "flag", 1, 0, LdValue.Of("v"), LdValue.Null, plain); + summarizer.SummarizeEvent(Time, "flag", 1, 0, LdValue.Of("v"), LdValue.Null, withName); + + var summaries = summarizer.GetSummariesAndReset(); + + Assert.Equal(2, summaries.Count); + } + + [Fact] + public void CountersAreAccumulatedPerContext() + { + var summarizer = new PerContextEventSummarizer(); + summarizer.SummarizeEvent(Time, "flag", 1, 0, LdValue.Of("a"), LdValue.Null, ContextA); + summarizer.SummarizeEvent(Time, "flag", 1, 0, LdValue.Of("a"), LdValue.Null, ContextA); + summarizer.SummarizeEvent(Time, "flag", 1, 0, LdValue.Of("b"), LdValue.Null, ContextB); + + var summaries = summarizer.GetSummariesAndReset(); + + Assert.Equal(2, SummaryFor(summaries, ContextA).Flags["flag"].Counters[new EventsCounterKey(1, 0)].Count); + Assert.Equal(1, SummaryFor(summaries, ContextB).Flags["flag"].Counters[new EventsCounterKey(1, 0)].Count); + } + + [Fact] + public void GetSummariesAndResetClearsState() + { + var summarizer = new PerContextEventSummarizer(); + summarizer.SummarizeEvent(Time, "flag", 1, 0, LdValue.Of("a"), LdValue.Null, ContextA); + + Assert.Single(summarizer.GetSummariesAndReset()); + Assert.Empty(summarizer.GetSummariesAndReset()); + } + + [Fact] + public void ClearDiscardsAccumulatedData() + { + var summarizer = new PerContextEventSummarizer(); + summarizer.SummarizeEvent(Time, "flag", 1, 0, LdValue.Of("a"), LdValue.Null, ContextA); + + summarizer.Clear(); + + Assert.Empty(summarizer.GetSummariesAndReset()); + } + } +} diff --git a/pkgs/shared/internal/test/Events/SamplerTest.cs b/pkgs/shared/internal/test/Events/SamplerTest.cs new file mode 100644 index 00000000..c5e90c78 --- /dev/null +++ b/pkgs/shared/internal/test/Events/SamplerTest.cs @@ -0,0 +1,47 @@ +using Xunit; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + public class SamplerTest + { + [Fact] + public void ItDoesNotSampleARatioOfZero() + { + Assert.False(Sampler.Sample(0)); + } + + [Fact] + public void ItDoesSampleARatioOfOne() + { + Assert.True(Sampler.Sample(1)); + } + + [Fact] + public void ItProbabilisticallySamples() + { + var sampledCount = 0; + const int trialCount = 10000; + for (int i = 0; i < trialCount; i++) + { + if (Sampler.Sample(2)) + { + sampledCount++; + } + } + + Assert.Equal(0.5, (double) sampledCount / trialCount, 0.2); + } + + [Fact] + public void ItHandlesMaxRange() + { + Sampler.Sample(long.MaxValue); + } + + [Fact] + public void ItTreatsNegativeValuesLikeZero() + { + Assert.False(Sampler.Sample(-10)); + } + } +} diff --git a/pkgs/shared/internal/test/Events/TestProperties.cs b/pkgs/shared/internal/test/Events/TestProperties.cs new file mode 100644 index 00000000..9c00cba3 --- /dev/null +++ b/pkgs/shared/internal/test/Events/TestProperties.cs @@ -0,0 +1,32 @@ +using System; + +namespace LaunchDarkly.Sdk.Internal.Events +{ + public struct TestFlagProperties + { + public string Key; + public int Version; + public bool TrackEvents; + public UnixMillisecondTime? DebugEventsUntilDate; + } + + public struct TestEvalProperties + { + public UnixMillisecondTime Timestamp; + public Context Context; + public int? Variation; + public LdValue Value; + public LdValue DefaultValue; + public EvaluationReason? Reason; + public string PrereqOf; + } + + public struct TestCustomEventProperties + { + public UnixMillisecondTime Timestamp; + public string Key; + public Context Context; + public LdValue Data; + public double? MetricValue; + } +} diff --git a/pkgs/shared/internal/test/HashCodeBuilderTest.cs b/pkgs/shared/internal/test/HashCodeBuilderTest.cs new file mode 100644 index 00000000..3f377692 --- /dev/null +++ b/pkgs/shared/internal/test/HashCodeBuilderTest.cs @@ -0,0 +1,31 @@ +using Xunit; + +namespace LaunchDarkly.Sdk.Internal +{ + public class HashCodeBuilderTest + { + [Fact] + public void DefaultIsZero() + { + Assert.Equal(0, HashCodeBuilder.New().Value); + } + + [Fact] + public void BuildValues() + { + int value1 = 123; + string value2 = "xyz"; + Assert.Equal(value1.GetHashCode() * 17 + value2.GetHashCode(), + HashCodeBuilder.New().With(value1).With(value2).Value); + } + + [Fact] + public void ValuesCanBeNull() + { + int value1 = 123; + string value3 = "xyz"; + Assert.Equal(value1.GetHashCode() * 17 * 17 + value3.GetHashCode(), + HashCodeBuilder.New().With(value1).With(null).With(value3).Value); + } + } +} diff --git a/pkgs/shared/internal/test/Http/HttpPropertiesBuildingTest.cs b/pkgs/shared/internal/test/Http/HttpPropertiesBuildingTest.cs new file mode 100644 index 00000000..765e7c9d --- /dev/null +++ b/pkgs/shared/internal/test/Http/HttpPropertiesBuildingTest.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using Xunit; + +namespace LaunchDarkly.Sdk.Internal.Http +{ + public class HttpPropertiesBuildingTest + { + // These tests don't do any HTTP, they just verify that the methods for setting properties + // work as intended. + + [Fact] + public void AuthorizationKey() + { + Assert.Empty(HttpProperties.Default.WithAuthorizationKey(null).BaseHeaders); + + ExpectSingleHeader(HttpProperties.Default.WithAuthorizationKey("sdk-key"), + "Authorization", "sdk-key"); + } + + [Fact] + public void ConnectTimeout() + { + Assert.Equal(HttpProperties.DefaultConnectTimeout, + HttpProperties.Default.ConnectTimeout); + + Assert.Equal(TimeSpan.FromSeconds(7), + HttpProperties.Default.WithConnectTimeout(TimeSpan.FromSeconds(7)) + .ConnectTimeout); + } + + + [Fact] + public void Header() + { + Assert.Empty(HttpProperties.Default.BaseHeaders); + + var hp = HttpProperties.Default + .WithHeader("name1", "value1") + .WithHeader("Name2", "value2-will-be-replaced") + .WithHeader("name2", "value2"); + Assert.Equal( + new List> + { + new KeyValuePair("name1", "value1"), + new KeyValuePair("name2", "value2") + }, + hp.BaseHeaders + ); + } + + [Fact] + public void HttpExceptionConverter() + { + var e = new Exception(); + Assert.NotNull(HttpProperties.Default.HttpExceptionConverter); + Assert.Same(e, HttpProperties.Default.HttpExceptionConverter(e)); + + Func hec = ex => ex; + Assert.Same( + hec, + HttpProperties.Default.WithHttpExceptionConverter(hec).HttpExceptionConverter + ); + } + + [Fact] + public void HttpMessageHandlerFactory() + { + Assert.Null(HttpProperties.Default.HttpMessageHandlerFactory); + + HttpMessageHandler hmh = new HttpClientHandler(); + Func hmhf = _ => hmh; + + var hp = HttpProperties.Default.WithHttpMessageHandlerFactory(hmhf); + Assert.Same(hmhf, hp.HttpMessageHandlerFactory); + } + + [Fact] + public void ReadTimeout() + { + Assert.Equal(HttpProperties.DefaultReadTimeout, + HttpProperties.Default.ReadTimeout); + + Assert.Equal(TimeSpan.FromSeconds(7), + HttpProperties.Default.WithReadTimeout(TimeSpan.FromSeconds(7)) + .ReadTimeout); + } + + [Fact] + public void UserAgent() + { + Assert.Empty(HttpProperties.Default.WithUserAgent(null).BaseHeaders); + + ExpectSingleHeader(HttpProperties.Default.WithUserAgent("x"), + "User-Agent", "x"); + ExpectSingleHeader(HttpProperties.Default.WithUserAgent("x", "1.0.0"), + "User-Agent", "x/1.0.0"); + } + + [Fact] + public void Wrapper() + { + Assert.Empty(HttpProperties.Default.WithWrapper(null, null).BaseHeaders); + Assert.Empty(HttpProperties.Default.WithWrapper("", "").BaseHeaders); + + ExpectSingleHeader(HttpProperties.Default.WithWrapper("x", null), + "X-LaunchDarkly-Wrapper", "x"); + ExpectSingleHeader(HttpProperties.Default.WithWrapper("x", "1.0.0"), + "X-LaunchDarkly-Wrapper", "x/1.0.0"); + } + + [Fact] + public void ApplicationTags() + { + ExpectSingleHeader(HttpProperties.Default.WithApplicationTags(new ApplicationInfo("mockId", null, null, null)), + "X-LaunchDarkly-Tags", "application-id/mockId"); + + ExpectSingleHeader(HttpProperties.Default.WithApplicationTags(new ApplicationInfo("mockId", "mockName", null, null)), + "X-LaunchDarkly-Tags", "application-id/mockId application-name/mockName"); + + ExpectSingleHeader(HttpProperties.Default.WithApplicationTags(new ApplicationInfo("mockId", "mockName", "mockVersion", null)), + "X-LaunchDarkly-Tags", "application-id/mockId application-name/mockName application-version/mockVersion"); + + ExpectSingleHeader(HttpProperties.Default.WithApplicationTags(new ApplicationInfo("mockId", "mockName", "mockVersion", "mockVersionName")), + "X-LaunchDarkly-Tags", "application-id/mockId application-name/mockName application-version/mockVersion application-version-name/mockVersionName"); + + ExpectSingleHeader(HttpProperties.Default.WithApplicationTags(new ApplicationInfo("ok", null, "bad-\n", null)), + "X-LaunchDarkly-Tags", "application-id/ok"); + + var props = HttpProperties.Default.WithApplicationTags(new ApplicationInfo(null, null, null, null)); + Assert.Equal( + Enumerable.Empty>(), + props.BaseHeaders + ); + } + + private void ExpectSingleHeader(HttpProperties hp, string name, string value) + { + Assert.Equal( + new List> + { + new KeyValuePair(name, value) + }, + hp.BaseHeaders + ); + } + } +} diff --git a/pkgs/shared/internal/test/Http/HttpPropertiesHttpClientTest.cs b/pkgs/shared/internal/test/Http/HttpPropertiesHttpClientTest.cs new file mode 100644 index 00000000..c74ae25f --- /dev/null +++ b/pkgs/shared/internal/test/Http/HttpPropertiesHttpClientTest.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using LaunchDarkly.TestHelpers.HttpTest; +using Xunit; + +namespace LaunchDarkly.Sdk.Internal.Http +{ + public class HttpPropertiesHttpTest + { + // These tests verify that an HTTP client created from HttpProperties has the expected behavior. + + [Fact] + public async Task ClientUsesCustomHandler() + { + HttpResponseMessage testResp = new HttpResponseMessage(); + HttpMessageHandler hmh = new TestHandler(testResp); + Func hmhf = _ => hmh; + + var hp = HttpProperties.Default.WithHttpMessageHandlerFactory(hmhf); + Assert.Same(hmhf, hp.HttpMessageHandlerFactory); + + using (var client = hp.NewHttpClient()) + { + var resp = await client.GetAsync("http://fake"); + Assert.Same(testResp, resp); + } + } + + [Fact] + public async Task ClientUsesConfiguredProxy() + { + using (var fakeProxyServer = HttpServer.Start(Handlers.Status(200))) + { + var proxy = new WebProxy(fakeProxyServer.Uri); + var hp = HttpProperties.Default.WithProxy(proxy); + + using (var client = hp.NewHttpClient()) + { + var resp = await client.GetAsync("http://fake/"); + Assert.Equal(200, (int)resp.StatusCode); + + var request = fakeProxyServer.Recorder.RequireRequest(); + Assert.Equal(new Uri("http://fake/"), request.Uri); + } + } + } + + [Fact] + public async Task MessageHandlerUsesConfiguredProxy() + { + using (var fakeProxyServer = HttpServer.Start(Handlers.Status(200))) + { + var proxy = new WebProxy(fakeProxyServer.Uri); + var hp = HttpProperties.Default.WithProxy(proxy); + var handler = hp.NewHttpMessageHandler(); + + using (var client = new HttpClient(handler)) + { + var resp = await client.GetAsync("http://fake/"); + Assert.Equal(200, (int)resp.StatusCode); + + var request = fakeProxyServer.Recorder.RequireRequest(); + Assert.Equal(new Uri("http://fake/"), request.Uri); + } + } + } + +#if NETCOREAPP || NET6_0 + [Fact] + public async Task ClientUsesConnectTimeout() + { + // There doesn't seem to be a way, in .NET's low-level socket API, to set up a TCP listener that + // doesn't immediately accept incoming connections-- so, we can't directly test what happens if + // for instance the timeout is 100ms and the connection takes 200ms. We'll do the next best things: + // 1. verify that it times out if we set a ridiculously low timeout... + using (var server = HttpServer.Start(Handlers.Status(200))) + { + var hp = HttpProperties.Default.WithConnectTimeout(TimeSpan.FromTicks(1)); + using (var client = hp.NewHttpClient()) + { + // The exact type of exception thrown by SocketsHttpHandler for a connection timeout is + // unfortunately neither consistent nor documented. In practice, it's a TaskCanceledException + // in .NET Core 3.1 and .NET 5.0, whereas in .NET Core 2.1 it's an HttpRequestException that + // wraps a TaskCanceledException. + var ex = await Assert.ThrowsAnyAsync(async () => + await client.GetAsync(server.Uri)); + if (ex is HttpRequestException) + { + Assert.IsType(ex.InnerException); + } + else + { + Assert.IsType(ex); + } + } + } + + // ...and 2. verify that if we set a connection timeout that's long enough that the connection + // is very unlikely to take that long, but still shorter than how long the *response* will take, + // it does *not* time out. + using (var server = HttpServer.Start(Handlers.Delay(TimeSpan.FromMilliseconds(400)).Then(Handlers.Status(200)))) + { + var hp = HttpProperties.Default.WithConnectTimeout(TimeSpan.FromMilliseconds(200)); + using (var client = hp.NewHttpClient()) + { + await client.GetAsync(server.Uri); + } + } + } +#endif + + private class TestHandler : HttpMessageHandler + { + private readonly HttpResponseMessage _resp; + + public TestHandler(HttpResponseMessage resp) + { + _resp = resp; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(_resp); + } + } + } +} diff --git a/pkgs/shared/internal/test/JsonConverterHelpersTest.cs b/pkgs/shared/internal/test/JsonConverterHelpersTest.cs new file mode 100644 index 00000000..3908d126 --- /dev/null +++ b/pkgs/shared/internal/test/JsonConverterHelpersTest.cs @@ -0,0 +1,338 @@ +using System; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Xunit; + +using static LaunchDarkly.Sdk.Internal.JsonConverterHelpers; + +namespace LaunchDarkly.Sdk.Internal +{ + public class JsonConverterHelpersTest + { + private static byte[] BytesOf(string s) => Encoding.UTF8.GetBytes(s); + private static Utf8JsonReader PrepareReader(string s) + { + var r = new Utf8JsonReader(BytesOf(s)); + r.Read(); + return r; + } + + [Fact] + public void TestGetIntOrNull() + { + var r1 = PrepareReader("3"); + Assert.Equal(3, GetIntOrNull(ref r1)); + + var r2 = PrepareReader("null"); + Assert.Null(GetIntOrNull(ref r2)); + + var r3 = PrepareReader("true"); + try + { + GetIntOrNull(ref r3); + Assert.Fail("expected exception"); + } + catch (InvalidOperationException) { } + } + + [Fact] + public void TestGetLongOrNull() + { + var r1 = PrepareReader("300000000000000"); + Assert.Equal(300000000000000, GetLongOrNull(ref r1)); + + var r2 = PrepareReader("null"); + Assert.Null(GetLongOrNull(ref r2)); + + var r3 = PrepareReader("true"); + try + { + GetLongOrNull(ref r3); + Assert.Fail("expected exception"); + } + catch (InvalidOperationException) { } + } + + [Fact] + public void TestGetTimeOrNull() + { + var r1 = PrepareReader("300000000000000"); + Assert.Equal(UnixMillisecondTime.OfMillis(300000000000000), GetTimeOrNull(ref r1)); + + var r2 = PrepareReader("null"); + Assert.Null(GetTimeOrNull(ref r2)); + + var r3 = PrepareReader("true"); + try + { + GetTimeOrNull(ref r3); + Assert.Fail("expected exception"); + } + catch (InvalidOperationException) { } + } + + [Fact] + public void TestWriteJsonAsString() + { + Assert.Equal("true", WriteJsonAsString(w => w.WriteBooleanValue(true))); + } + + [Fact] + public void TestWriteIntIfNotNull() + { + Assert.Equal(@"{""a"":3}", WriteJsonAsString(w => + { + w.WriteStartObject(); + WriteIntIfNotNull(w, "a", 3); + w.WriteEndObject(); + })); + Assert.Equal(@"{}", WriteJsonAsString(w => + { + w.WriteStartObject(); + WriteIntIfNotNull(w, "a", null); + w.WriteEndObject(); + })); + } + + [Fact] + public void TestWriteTimeIfNotNull() + { + Assert.Equal(@"{""a"":300000000000000}", WriteJsonAsString(w => + { + w.WriteStartObject(); + WriteTimeIfNotNull(w, "a", UnixMillisecondTime.OfMillis(300000000000000)); + w.WriteEndObject(); + })); + Assert.Equal(@"{}", WriteJsonAsString(w => + { + w.WriteStartObject(); + WriteTimeIfNotNull(w, "a", null); + w.WriteEndObject(); + })); + } + + [Fact] + public void TestWriteStringIfNotNull() + { + Assert.Equal(@"{""a"":""""}", WriteJsonAsString(w => + { + w.WriteStartObject(); + WriteStringIfNotNull(w, "a", ""); + w.WriteEndObject(); + })); + Assert.Equal(@"{}", WriteJsonAsString(w => + { + w.WriteStartObject(); + WriteStringIfNotNull(w, "a", null); + w.WriteEndObject(); + })); + } + + [Fact] + public void TestWriteBooleanIfTrue() + { + Assert.Equal(@"{""a"":true}", WriteJsonAsString(w => + { + w.WriteStartObject(); + WriteBooleanIfTrue(w, "a", true); + w.WriteEndObject(); + })); + Assert.Equal(@"{}", WriteJsonAsString(w => + { + w.WriteStartObject(); + WriteBooleanIfTrue(w, "a", false); + w.WriteEndObject(); + })); + } + + [Fact] + public void TestWriteLdValue() + { + Assert.Equal(@"{""a"":true,""b"":null}", WriteJsonAsString(w => + { + w.WriteStartObject(); + WriteLdValue(w, "a", LdValue.Of(true)); + WriteLdValue(w, "b", LdValue.Null); + w.WriteEndObject(); + })); + } + + [Fact] + public void TestWriteLdValueIfNotNull() + { + Assert.Equal(@"{""a"":true}", WriteJsonAsString(w => + { + w.WriteStartObject(); + WriteLdValueIfNotNull(w, "a", LdValue.Of(true)); + WriteLdValueIfNotNull(w, "b", LdValue.Null); + w.WriteEndObject(); + })); + } + + [Fact] + public void NonEmptyArray() + { + var bytes = BytesOf(@"[10,20]"); + + var r1 = new Utf8JsonReader(bytes); + var a1 = RequireArray(ref r1); + VerifyNonEmptyArray(ref r1, ref a1); + + var r2 = new Utf8JsonReader(bytes); + var a2 = RequireArrayOrNull(ref r2); + VerifyNonEmptyArray(ref r2, ref a2); + } + + private void VerifyNonEmptyArray(scoped ref Utf8JsonReader r, ref ArrayHelper a) + { + Assert.True(a.Next(ref r)); + Assert.Equal(10, r.GetInt32()); + Assert.True(a.Next(ref r)); + Assert.Equal(20, r.GetInt32()); + Assert.False(a.Next(ref r)); + } + + [Fact] + public void EmptyArray() + { + var bytes = BytesOf(@"[]"); + + var r1 = new Utf8JsonReader(bytes); + var a1 = RequireArray(ref r1); + VerifyEmptyArray(ref r1, ref a1); + + var r2 = new Utf8JsonReader(bytes); + var a2 = RequireArrayOrNull(ref r2); + VerifyEmptyArray(ref r2, ref a2); + } + + private void VerifyEmptyArray(scoped ref Utf8JsonReader r, ref ArrayHelper a) => + Assert.False(a.Next(ref r)); + + [Fact] + public void NullInsteadOfArray() + { + var bytes = BytesOf(@"null"); + + var r1 = new Utf8JsonReader(bytes); + try + { + RequireArray(ref r1); + Assert.Fail("expected exception"); + } + catch (JsonException) { } + + var r2 = new Utf8JsonReader(bytes); + var a2 = RequireArrayOrNull(ref r2); + VerifyEmptyArray(ref r2, ref a2); + } + + [Fact] + public void NonEmptyObject() + { + var bytes = BytesOf(@"{""a"":10,""b"":20}"); + + var r1 = new Utf8JsonReader(bytes); + var o1 = RequireObject(ref r1); + VerifyNonEmptyObject(ref r1, ref o1); + + var r2 = new Utf8JsonReader(bytes); + var o2 = RequireObjectOrNull(ref r2); + VerifyNonEmptyObject(ref r2, ref o2); + } + + private void VerifyNonEmptyObject(scoped ref Utf8JsonReader r, ref ObjectHelper o) + { + Assert.True(o.Next(ref r)); + Assert.Equal("a", o.Name); + Assert.Equal(10, r.GetInt32()); + Assert.True(o.Next(ref r)); + Assert.Equal("b", o.Name); + Assert.Equal(20, r.GetInt32()); + Assert.False(o.Next(ref r)); + } + + [Fact] + public void EmptyObject() + { + var bytes = BytesOf(@"{}"); + + var r1 = new Utf8JsonReader(bytes); + var o1 = RequireObject(ref r1); + VerifyEmptyObject(ref r1, ref o1); + + var r2 = new Utf8JsonReader(bytes); + var o2 = RequireObjectOrNull(ref r2); + VerifyEmptyObject(ref r2, ref o2); + } + + private void VerifyEmptyObject(scoped ref Utf8JsonReader r, ref ObjectHelper o) => + Assert.False(o.Next(ref r)); + + [Fact] + public void NullInsteadOfObject() + { + var bytes = BytesOf("null"); + + var r1 = new Utf8JsonReader(bytes); + try + { + RequireObject(ref r1); + Assert.Fail("expected exception"); + } + catch (JsonException) { } + + var r2 = new Utf8JsonReader(bytes); + var o2 = RequireObjectOrNull(ref r2); + VerifyEmptyObject(ref r2, ref o2); + } + + [Fact] + public void ObjectPropertyValueIsSkippedIfNotConsumed() + { + var bytes = BytesOf(@"{""a"":1,""b"":{""x"":99},""c"":2}"); + + var r = new Utf8JsonReader(bytes); + var obj = RequireObject(ref r); + Assert.True(obj.Next(ref r)); + Assert.Equal("a", obj.Name); + Assert.Equal(1, r.GetInt32()); + Assert.True(obj.Next(ref r)); + Assert.Equal("b", obj.Name); + // deliberately do not consume the value for b; it should be automatically skipped by the next Next + Assert.True(obj.Next(ref r)); + Assert.Equal("c", obj.Name); + Assert.Equal(2, r.GetInt32()); + } + + [Fact] + public void RequiredPropertiesAreAllFound() + { + var bytes = BytesOf(@"{""a"":1, ""b"":2, ""c"":3}"); + + var r = new Utf8JsonReader(bytes); + var obj = RequireObject(ref r).WithRequiredProperties("c", "b", "a"); + while (obj.Next(ref r)) { } // no error is thrown + } + + [Fact] + public void RequiredPropertyIsNotFound() + { + var bytes = BytesOf(@"{""b"":2, ""c"":3}"); + + var r = new Utf8JsonReader(bytes); + var obj = RequireObject(ref r).WithRequiredProperties("c", "b", "a"); + try + { + while (obj.Next(ref r)) { } + Assert.Fail("expected exception"); + } + catch (JsonException e) + { + Assert.Matches(".*required property: a", e.Message); + } + } + } +} + diff --git a/pkgs/shared/internal/test/LaunchDarkly.InternalSdk.Tests.csproj b/pkgs/shared/internal/test/LaunchDarkly.InternalSdk.Tests.csproj new file mode 100644 index 00000000..d7ba7e17 --- /dev/null +++ b/pkgs/shared/internal/test/LaunchDarkly.InternalSdk.Tests.csproj @@ -0,0 +1,27 @@ + + + net462;net8.0 + $(TESTFRAMEWORK) + LaunchDarkly.InternalSdk.Tests + LaunchDarkly.Sdk.Internal + + 11 + 1.2.3 + 1.2.3 + false + + + + + + + + + + + + + + + + diff --git a/pkgs/shared/internal/test/LogHelpersTest.cs b/pkgs/shared/internal/test/LogHelpersTest.cs new file mode 100644 index 00000000..848a5073 --- /dev/null +++ b/pkgs/shared/internal/test/LogHelpersTest.cs @@ -0,0 +1,29 @@ +using System; +using LaunchDarkly.Logging; +using Xunit; + +namespace LaunchDarkly.Sdk.Internal +{ + public class LogHelpersTest + { + [Fact] + public void LogException() + { + var logCapture = Logs.Capture(); + var logger = logCapture.Logger("logname"); + try + { + throw new ArgumentException("sorry"); + } + catch (Exception e) + { + LogHelpers.LogException(logger, "Problem", e); + } + Assert.Equal(2, logCapture.GetMessages().Count); + Assert.True(logCapture.HasMessageWithText(LogLevel.Error, + "Problem: System.ArgumentException: sorry"), logCapture.ToString()); + Assert.True(logCapture.HasMessageWithRegex(LogLevel.Debug, + "at LaunchDarkly\\.Sdk\\.Internal\\.LogHelpersTest\\.LogException"), logCapture.ToString()); + } + } +} diff --git a/pkgs/shared/internal/test/TestUtil.cs b/pkgs/shared/internal/test/TestUtil.cs new file mode 100644 index 00000000..faf43d59 --- /dev/null +++ b/pkgs/shared/internal/test/TestUtil.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using LaunchDarkly.Logging; +using Xunit; + +namespace LaunchDarkly.Sdk +{ + public class TestUtil + { + public static Logger NullLogger = Logs.None.Logger(""); + + public static void AssertContainsInAnyOrder(IEnumerable items, params T[] expectedItems) + { + Assert.Equal(expectedItems.Length, items.Count()); + foreach (var e in expectedItems) + { + Assert.Contains(e, items); + } + } + + public static LdValue TryParseJson(string json) => + TryParseJson(Encoding.UTF8.GetBytes(json)); + + public static LdValue TryParseJson(byte[] json) + { + try + { + return JsonSerializer.Deserialize(json); + } + catch (Exception e) + { + throw new Exception(string.Format("received invalid JSON ({0}): {1}", e.Message, json)); + } + } + } +} diff --git a/pkgs/shared/internal/test/UriExtensionsTest.cs b/pkgs/shared/internal/test/UriExtensionsTest.cs new file mode 100644 index 00000000..0871a7e1 --- /dev/null +++ b/pkgs/shared/internal/test/UriExtensionsTest.cs @@ -0,0 +1,28 @@ +using System; +using Xunit; + +namespace LaunchDarkly.Sdk.Internal +{ + public class UriExtensionsTest + { + [Theory] + [InlineData("http://hostname", "/a/b", "http://hostname/a/b")] + [InlineData("http://hostname", "a/b", "http://hostname/a/b")] + [InlineData("http://hostname:8080", "/a/b", "http://hostname:8080/a/b")] + [InlineData("http://hostname:8080", "a/b", "http://hostname:8080/a/b")] + [InlineData("http://hostname/", "/a/b", "http://hostname/a/b")] + [InlineData("http://hostname/", "a/b", "http://hostname/a/b")] + [InlineData("http://hostname/a", "/b", "http://hostname/a/b")] + [InlineData("http://hostname/a", "b", "http://hostname/a/b")] + [InlineData("http://hostname/a/", "/b", "http://hostname/a/b")] + [InlineData("http://hostname/a/", "b", "http://hostname/a/b")] + public void AddPath(string originalUri, string addedPath, string expectedResult) => + Assert.Equal(new Uri(expectedResult), new Uri(originalUri).AddPath(addedPath)); + + [Theory] + [InlineData("http://hostname/a", "b", "http://hostname/a?b")] + [InlineData("http://hostname/a?c", "b", "http://hostname/a?c&b")] + public void AddQuery(string originalUri, string addedQuery, string expectedResult) => + Assert.Equal(new Uri(expectedResult), new Uri(originalUri).AddQuery(addedQuery)); + } +} diff --git a/release-please-config.json b/release-please-config.json index 5e12179f..4758066f 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -76,6 +76,14 @@ "src/LaunchDarkly.CommonSdk.JsonNet.csproj", "PROVENANCE.md" ] + }, + "pkgs/shared/internal": { + "release-type": "simple", + "package-name": "LaunchDarkly.InternalSdk", + "extra-files": [ + "src/LaunchDarkly.InternalSdk.csproj", + "PROVENANCE.md" + ] } } } From bcaa186932911b98fb50f42699bf024f9ccbaad8 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Tue, 7 Jul 2026 12:36:59 -0500 Subject: [PATCH 2/3] chore: Update internal README and PROVENANCE repo references to dotnet-core --- pkgs/shared/internal/PROVENANCE.md | 4 ++-- pkgs/shared/internal/README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkgs/shared/internal/PROVENANCE.md b/pkgs/shared/internal/PROVENANCE.md index a2033d52..22787e27 100644 --- a/pkgs/shared/internal/PROVENANCE.md +++ b/pkgs/shared/internal/PROVENANCE.md @@ -38,9 +38,9 @@ The following policy criteria will be enforced: The following 1 attestation matched the policy criteria - Attestation #1 - - Build repo:..... launchdarkly/dotnet-sdk-internal + - Build repo:..... launchdarkly/dotnet-core - Build workflow:. .github/workflows/release-please.yml - - Signer repo:.... launchdarkly/dotnet-sdk-internal + - Signer repo:.... launchdarkly/dotnet-core - Signer workflow: .github/workflows/release-please.yml ``` diff --git a/pkgs/shared/internal/README.md b/pkgs/shared/internal/README.md index 975b1cb0..5fbf5aaa 100644 --- a/pkgs/shared/internal/README.md +++ b/pkgs/shared/internal/README.md @@ -6,7 +6,7 @@ This project contains .NET classes and interfaces that are shared between the La ## Contributing -See [Contributing](https://github.com/launchdarkly/dotnet-sdk-internal/blob/main/CONTRIBUTING.md). +See [Contributing](./CONTRIBUTING.md). ## Signing From bfa34b0ff6e8ae777ffa3c0c2a583a1131fa580b Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Tue, 7 Jul 2026 14:21:10 -0500 Subject: [PATCH 3/3] chore: Drop stale Xamarin reference from internal package metadata --- pkgs/shared/internal/docfx.json | 2 +- pkgs/shared/internal/src/LaunchDarkly.InternalSdk.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/shared/internal/docfx.json b/pkgs/shared/internal/docfx.json index 76b93105..1074adf8 100644 --- a/pkgs/shared/internal/docfx.json +++ b/pkgs/shared/internal/docfx.json @@ -40,7 +40,7 @@ ], "globalMetadata": { "_appName": "LaunchDarkly.InternalSdk", - "_appTitle": "LaunchDarkly internal common code for .NET and Xamarin clients", + "_appTitle": "LaunchDarkly internal common code for .NET clients", "_enableSearch": true, "pdf": false } diff --git a/pkgs/shared/internal/src/LaunchDarkly.InternalSdk.csproj b/pkgs/shared/internal/src/LaunchDarkly.InternalSdk.csproj index 4e3538df..74925419 100644 --- a/pkgs/shared/internal/src/LaunchDarkly.InternalSdk.csproj +++ b/pkgs/shared/internal/src/LaunchDarkly.InternalSdk.csproj @@ -16,7 +16,7 @@ 11 LaunchDarkly.InternalSdk LaunchDarkly.Sdk.Internal - LaunchDarkly internal common code for .NET and Xamarin clients + LaunchDarkly internal common code for .NET clients LaunchDarkly LaunchDarkly LaunchDarkly