From dbe8b082c28739a1b81b74b29ed28fbccc94f7bc Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 26 May 2025 15:36:06 +0100
Subject: [PATCH 01/90] chore(deps): update spec digest to f014806 (#479)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR contains the following updates:
| Package | Update | Change |
|---|---|---|
| spec | digest | `edf0deb` -> `f014806` |
---
### Configuration
π
**Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
π¦ **Automerge**: Enabled.
β» **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.
π **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/dotnet-sdk).
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
spec | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/spec b/spec
index edf0debe0..f0148060e 160000
--- a/spec
+++ b/spec
@@ -1 +1 @@
-Subproject commit edf0debe0b4547d1f13e49f8e58a6d182237b43b
+Subproject commit f0148060e6c125ffa95c161b984efda012084c1a
From 520d38305c6949c88b057f28e5dfe3305257e437 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Wed, 28 May 2025 09:24:08 +0100
Subject: [PATCH 02/90] chore(deps): update dependency microsoft.net.test.sdk
to 17.14.0 (#482)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR contains the following updates:
| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [Microsoft.NET.Test.Sdk](https://github.com/microsoft/vstest)
| `17.13.0` -> `17.14.0` |
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
---
### Release Notes
microsoft/vstest (Microsoft.NET.Test.Sdk)
###
[`v17.14.0`](https://github.com/microsoft/vstest/releases/tag/v17.14.0)
#### What's Changed
##### .NET versions updated
This version of VS Test upgraded .NET to net8 and net9. All projects
targeting net6.0 (or other end-of-life .NET target frameworks) should
pin their version of Microsoft.NET.Test.SDK to 17.13.0, or update the
projects to net8 or newer. We remain backwards compatible with previous
versions of Microsoft.NET.Test.SDK. This change does **NOT** prevent you
from:
- Updating to the latest VS, and running tests from net6.0 test
projects.
- Updating to the latest .NET SDK, and running tests from net6.0 test
projects.
It also has no impact on .NET Framework projects, where we continue
targeting .NET Framework 4.6.2.
- Drop unsupported frameworks by
[@nohwnd](https://github.com/nohwnd) in
[https://github.com/microsoft/vstest/pull/10565](https://github.com/microsoft/vstest/pull/10565)
##### Changes
- Adding Process Query Flag For UWP .NET 9 Support by
[@adstep](https://github.com/adstep) in
[https://github.com/microsoft/vstest/pull/15003](https://github.com/microsoft/vstest/pull/15003)
- Fix builds on WinUI and UWP .NET 9 projects by
[@Sergio0694](https://github.com/Sergio0694) in
[https://github.com/microsoft/vstest/pull/15004](https://github.com/microsoft/vstest/pull/15004)
- don't report communication error on discovery abort by
[@nohwnd](https://github.com/nohwnd) in
[https://github.com/microsoft/vstest/pull/14992](https://github.com/microsoft/vstest/pull/14992)
- Add dump minitool to vsix by
[@nohwnd](https://github.com/nohwnd) in
[https://github.com/microsoft/vstest/pull/14707](https://github.com/microsoft/vstest/pull/14707)
- Make test runners long-path aware
([#5179](https://github.com/microsoft/vstest/issues/5179))
by [@peetw](https://github.com/peetw) in
[https://github.com/microsoft/vstest/pull/15014](https://github.com/microsoft/vstest/pull/15014)
- Fix trace in DataCollectionRequestSender.cs by
[@stan-sz](https://github.com/stan-sz) in
[https://github.com/microsoft/vstest/pull/15025](https://github.com/microsoft/vstest/pull/15025)
- Fix/readme grammar parallelism by
[@dellch](https://github.com/dellch) in
[https://github.com/microsoft/vstest/pull/15030](https://github.com/microsoft/vstest/pull/15030)
- Add binding redirects by
[@nohwnd](https://github.com/nohwnd) in
[https://github.com/microsoft/vstest/pull/15041](https://github.com/microsoft/vstest/pull/15041)
- Write props of tests into trx by
[@nohwnd](https://github.com/nohwnd) in
[https://github.com/microsoft/vstest/pull/14905](https://github.com/microsoft/vstest/pull/14905)
##### Internal version updates and fixes
- Update io.redist by
[@nohwnd](https://github.com/nohwnd) in
[https://github.com/microsoft/vstest/pull/13872](https://github.com/microsoft/vstest/pull/13872)
- Use preview image for public build by
[@nohwnd](https://github.com/nohwnd) in
[https://github.com/microsoft/vstest/pull/13888](https://github.com/microsoft/vstest/pull/13888)
- Remove xcopy-msbuild by
[@nohwnd](https://github.com/nohwnd) in
[https://github.com/microsoft/vstest/pull/14138](https://github.com/microsoft/vstest/pull/14138)
- Move to macos14 by
[@nohwnd](https://github.com/nohwnd) in
[https://github.com/microsoft/vstest/pull/14137](https://github.com/microsoft/vstest/pull/14137)
- Update diagnose.md by
[@nohwnd](https://github.com/nohwnd) in
[https://github.com/microsoft/vstest/pull/14776](https://github.com/microsoft/vstest/pull/14776)
- hash with sha2 for mutex lock by
[@nohwnd](https://github.com/nohwnd) in
[https://github.com/microsoft/vstest/pull/14777](https://github.com/microsoft/vstest/pull/14777)
- Update test projects for vmr by
[@nohwnd](https://github.com/nohwnd) in
[https://github.com/microsoft/vstest/pull/14894](https://github.com/microsoft/vstest/pull/14894)
- 17.14 branding by [@nohwnd](https://github.com/nohwnd)
in
[https://github.com/microsoft/vstest/pull/14903](https://github.com/microsoft/vstest/pull/14903)
- Update filter.md for NUnit by
[@OsirisTerje](https://github.com/OsirisTerje) in
[https://github.com/microsoft/vstest/pull/14987](https://github.com/microsoft/vstest/pull/14987)
- Flag netstandard1.x dependencies in source-build by
[@ViktorHofer](https://github.com/ViktorHofer) in
[https://github.com/microsoft/vstest/pull/14986](https://github.com/microsoft/vstest/pull/14986)
- Use VS dependencies versions from release VS to have archived symbols
by [@nohwnd](https://github.com/nohwnd) in
[https://github.com/microsoft/vstest/pull/14991](https://github.com/microsoft/vstest/pull/14991)
- Remove extra ; by [@nohwnd](https://github.com/nohwnd)
in
[https://github.com/microsoft/vstest/pull/14995](https://github.com/microsoft/vstest/pull/14995)
- Use dependencymodel 6.0.2 by
[@nohwnd](https://github.com/nohwnd) in
[https://github.com/microsoft/vstest/pull/14996](https://github.com/microsoft/vstest/pull/14996)
- Make Testhost packable only on Windows by
[@mmitche](https://github.com/mmitche) in
[https://github.com/microsoft/vstest/pull/15001](https://github.com/microsoft/vstest/pull/15001)
- Add system text json to vsix by
[@nohwnd](https://github.com/nohwnd) in
[https://github.com/microsoft/vstest/pull/15034](https://github.com/microsoft/vstest/pull/15034)
- Add more files to vsix by
[@nohwnd](https://github.com/nohwnd) in
[https://github.com/microsoft/vstest/pull/15038](https://github.com/microsoft/vstest/pull/15038)
- Remove unnecessary CA2022 suppressions by
[@Winniexu01](https://github.com/Winniexu01) in
[https://github.com/microsoft/vstest/pull/15035](https://github.com/microsoft/vstest/pull/15035)
- Update package project url by
[@mmitche](https://github.com/mmitche) in
[https://github.com/microsoft/vstest/pull/15040](https://github.com/microsoft/vstest/pull/15040)
#### New Contributors
- [@OsirisTerje](https://github.com/OsirisTerje) made
their first contribution in
[https://github.com/microsoft/vstest/pull/14987](https://github.com/microsoft/vstest/pull/14987)
- [@adstep](https://github.com/adstep) made their first
contribution in
[https://github.com/microsoft/vstest/pull/15003](https://github.com/microsoft/vstest/pull/15003)
- [@Sergio0694](https://github.com/Sergio0694) made
their first contribution in
[https://github.com/microsoft/vstest/pull/15004](https://github.com/microsoft/vstest/pull/15004)
- [@peetw](https://github.com/peetw) made their first
contribution in
[https://github.com/microsoft/vstest/pull/15014](https://github.com/microsoft/vstest/pull/15014)
- [@dellch](https://github.com/dellch) made their first
contribution in
[https://github.com/microsoft/vstest/pull/15030](https://github.com/microsoft/vstest/pull/15030)
- [@Winniexu01](https://github.com/Winniexu01) made
their first contribution in
[https://github.com/microsoft/vstest/pull/15035](https://github.com/microsoft/vstest/pull/15035)
**Full Changelog**:
https://github.com/microsoft/vstest/compare/v17.13.0...v17.14.0
---
### Configuration
π
**Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
π¦ **Automerge**: Enabled.
β» **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.
π **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/dotnet-sdk).
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
Directory.Packages.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 6bdfa4553..b2851b3c9 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -27,7 +27,7 @@
-
+
From 99f7584c91882ba59412e2306167172470cd4677 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Wed, 28 May 2025 09:24:31 +0100
Subject: [PATCH 03/90] chore(deps): update dependency reqnroll.xunit to 2.4.1
(#483)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR contains the following updates:
| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [Reqnroll.xUnit](https://www.reqnroll.net/)
([source](https://github.com/reqnroll/Reqnroll)) | `2.4.0` ->
`2.4.1` |
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
---
### Release Notes
reqnroll/Reqnroll (Reqnroll.xUnit)
###
[`v2.4.1`](https://github.com/reqnroll/Reqnroll/blob/HEAD/CHANGELOG.md#v241---2025-04-29)
#### Bug fixes:
- Fix: xUnit async `[AfterTestRun]` hook might not execute fully
([#530](https://github.com/reqnroll/Reqnroll/issues/530))
- Fix: Scenario, feature and test run finished event is not published
when the related "after" hook fails
([#560](https://github.com/reqnroll/Reqnroll/issues/560))
- Fix: Inconsistent hook execution (duble execution, before/after hook
skipped, infrastructure errors) when before or after hooks fail
([#526](https://github.com/reqnroll/Reqnroll/issues/526))
- Fix: Namespace collisions in generated code when Reqnroll project
namespace contains "System"
([#583](https://github.com/reqnroll/Reqnroll/issues/583))
- Fix: InvalidOperationException when calling test teardown method after
the Reqnroll test runner has been released
([#387](https://github.com/reqnroll/Reqnroll/issues/387))
*Contributors of this release (in alphabetical order):*
[@304NotModified](https://github.com/304NotModified),
[@clrudolphi](https://github.com/clrudolphi),
[@gasparnagy](https://github.com/gasparnagy),
[@obligaron](https://github.com/obligaron)
---
### Configuration
π
**Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
π¦ **Automerge**: Enabled.
β» **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.
π **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/dotnet-sdk).
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
Directory.Packages.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index b2851b3c9..ac8b0e3a1 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -29,7 +29,7 @@
-
+
From 714425d405a33231e85b1e62019fc678b2e883ef Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Wed, 28 May 2025 20:26:12 +0400
Subject: [PATCH 04/90] chore(deps): update dependency benchmarkdotnet to
0.15.0 (#481)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR contains the following updates:
| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [BenchmarkDotNet](https://github.com/dotnet/BenchmarkDotNet)
| `0.14.0` -> `0.15.0` |
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
---
### Release Notes
dotnet/BenchmarkDotNet (BenchmarkDotNet)
###
[`v0.15.0`](https://github.com/dotnet/BenchmarkDotNet/releases/tag/v0.15.0):
0.15.0
Full changelog: https://benchmarkdotnet.org/changelog/v0.15.0.html
---
### Configuration
π
**Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
π¦ **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.
β» **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.
π **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/dotnet-sdk).
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
Directory.Packages.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index ac8b0e3a1..89518bb55 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -22,7 +22,7 @@
-
+
From 8435bf7d8131307e627e59453008124ac4c71906 Mon Sep 17 00:00:00 2001
From: Michael Beemer
Date: Mon, 2 Jun 2025 13:41:01 -0400
Subject: [PATCH 05/90] docs: updated contributing link on the README
Resolves an lint issue in the docs pipeline.
https://github.com/open-feature/openfeature.dev/actions/runs/15387227215/job/43288342049?pr=1174
Signed-off-by: Michael Beemer
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 5f1b725b5..9288edfd6 100644
--- a/README.md
+++ b/README.md
@@ -97,7 +97,7 @@ The [`samples/`](./samples) folder contains example applications demonstrating h
dotnet run
```
-Want to contribute a new sample? See our [CONTRIBUTING](#-contributing) guide!
+Want to contribute a new sample? See our [CONTRIBUTING](CONTRIBUTING.md) guide!
## π Features
From 78bfdbf0850e2d5eb80cfbae3bfac8208f6c45b1 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Wed, 4 Jun 2025 15:15:27 +0100
Subject: [PATCH 06/90] chore(deps): update dependency microsoft.net.test.sdk
to 17.14.1 (#485)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR contains the following updates:
| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [Microsoft.NET.Test.Sdk](https://github.com/microsoft/vstest)
| `17.14.0` -> `17.14.1` |
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
---
### Release Notes
microsoft/vstest (Microsoft.NET.Test.Sdk)
###
[`v17.14.1`](https://github.com/microsoft/vstest/releases/tag/v17.14.1)
#### What's Changed
- Error on unsupported target frameworks to prevent silently not running
tests by [@nohwnd](https://github.com/nohwnd) in
[https://github.com/microsoft/vstest/pull/15072](https://github.com/microsoft/vstest/pull/15072)
and
[https://github.com/microsoft/vstest/pull/15078](https://github.com/microsoft/vstest/pull/15078)
- Revert writing additional properties to TRX by
[@nohwnd](https://github.com/nohwnd) in
https://github.com/microsoft/vstest/commit/47eb51b15ad8ca4a84ad7be5881fcd1713a0f68a
**Full Changelog**:
https://github.com/microsoft/vstest/compare/v17.14.0...v17.14.1
---
### Configuration
π
**Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
π¦ **Automerge**: Enabled.
β» **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.
π **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/dotnet-sdk).
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
Directory.Packages.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 89518bb55..87a661c7d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -27,7 +27,7 @@
-
+
From e18ad50e3298cb0dd19143678c3ef0fdcb4484d9 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Wed, 4 Jun 2025 14:18:12 +0000
Subject: [PATCH 07/90] chore(deps): update github/codeql-action digest to
fca7ace (#486)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR contains the following updates:
| Package | Type | Update | Change |
|---|---|---|---|
|
[github/codeql-action](https://github.com/github/codeql-action)
| action | digest | `ff0a06e` -> `fca7ace` |
---
### Configuration
π
**Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
π¦ **Automerge**: Enabled.
β» **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.
π **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/dotnet-sdk).
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/workflows/codeql-analysis.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 2f4645ece..f918dc183 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -42,7 +42,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3
+ uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -56,7 +56,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3
+ uses: github/codeql-action/autobuild@fca7ace96b7d713c7035871441bd52efbe39e27e # v3
# βΉοΈ Command-line programs to run using the OS shell.
# π See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -69,4 +69,4 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3
+ uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3
From cce224fcf81aede5a626936a26546fe710fbcc30 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 23 Jun 2025 16:38:30 +0100
Subject: [PATCH 08/90] chore(deps): update github/codeql-action digest to
ce28f5b (#492)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR contains the following updates:
| Package | Type | Update | Change |
|---|---|---|---|
|
[github/codeql-action](https://github.com/github/codeql-action)
| action | digest | `fca7ace` -> `ce28f5b` |
---
### Configuration
π
**Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
π¦ **Automerge**: Enabled.
β» **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.
π **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/dotnet-sdk).
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/workflows/codeql-analysis.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index f918dc183..6e8d1e32b 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -42,7 +42,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3
+ uses: github/codeql-action/init@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -56,7 +56,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@fca7ace96b7d713c7035871441bd52efbe39e27e # v3
+ uses: github/codeql-action/autobuild@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3
# βΉοΈ Command-line programs to run using the OS shell.
# π See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -69,4 +69,4 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3
+ uses: github/codeql-action/analyze@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3
From 909c51d4e25917d6a9a5ae9bb04cfe48665186ba Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 23 Jun 2025 16:38:44 +0100
Subject: [PATCH 09/90] chore(deps): update spec digest to 42340bb (#493)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR contains the following updates:
| Package | Update | Change |
|---|---|---|
| spec | digest | `f014806` -> `42340bb` |
---
### Configuration
π
**Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
π¦ **Automerge**: Enabled.
β» **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.
π **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/dotnet-sdk).
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
spec | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/spec b/spec
index f0148060e..42340bb9f 160000
--- a/spec
+++ b/spec
@@ -1 +1 @@
-Subproject commit f0148060e6c125ffa95c161b984efda012084c1a
+Subproject commit 42340bb9f56ccd8f98f90ac3baca52758f5a5859
From f7ca4163e0ce549a015a7a27cb184fb76a199a04 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 23 Jun 2025 15:39:05 +0000
Subject: [PATCH 10/90] chore(deps): update actions/attest-sbom action to
v2.4.0 (#496)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR contains the following updates:
| Package | Type | Update | Change |
|---|---|---|---|
| [actions/attest-sbom](https://github.com/actions/attest-sbom)
| action | minor | `v2.2.0` -> `v2.4.0` |
---
### Release Notes
actions/attest-sbom (actions/attest-sbom)
###
[`v2.4.0`](https://github.com/actions/attest-sbom/releases/tag/v2.4.0)
[Compare
Source](https://github.com/actions/attest-sbom/compare/v2.2.0...v2.4.0)
#### What's Changed
- Bump actions/attest from 2.2.1 to 2.3.0 in the actions-minor group by
[@dependabot](https://github.com/dependabot) in
[https://github.com/actions/attest-sbom/pull/169](https://github.com/actions/attest-sbom/pull/169)
- Bump undici from 5.28.5 to 5.29.0 by
[@dependabot](https://github.com/dependabot) in
[https://github.com/actions/attest-sbom/pull/172](https://github.com/actions/attest-sbom/pull/172)
- Bump actions/attest from 2.3.0 to 2.4.0 by
[@bdehamer](https://github.com/bdehamer) in
[https://github.com/actions/attest-sbom/pull/178](https://github.com/actions/attest-sbom/pull/178)
- Includes support for the new well-known summary file which will
accumulate paths to all attestations generated in a given workflow run
**Full Changelog**:
https://github.com/actions/attest-sbom/compare/v2.2.0...v2.4.0
---
### Configuration
π
**Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
π¦ **Automerge**: Enabled.
β» **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.
π **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/dotnet-sdk).
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/actions/sbom-generator/action.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/actions/sbom-generator/action.yml b/.github/actions/sbom-generator/action.yml
index 0af8c2c47..7573150b5 100644
--- a/.github/actions/sbom-generator/action.yml
+++ b/.github/actions/sbom-generator/action.yml
@@ -35,7 +35,7 @@ runs:
gh release upload ${{ inputs.release-tag }} ./artifacts/sboms/${{ inputs.project-name }}.bom.json
- name: Attest package
- uses: actions/attest-sbom@115c3be05ff3974bcbd596578934b3f9ce39bf68 # v2.2.0
+ uses: actions/attest-sbom@bd218ad0dbcb3e146bd073d1d9c6d78e08aa8a0b # v2.4.0
with:
subject-path: src/**/${{ inputs.project-name }}.*.nupkg
sbom-path: ./artifacts/sboms/${{ inputs.project-name }}.bom.json
From 349c07301d0ff97c759417344eef74a00b06edbc Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 23 Jun 2025 15:39:42 +0000
Subject: [PATCH 11/90] chore(deps): update actions/attest-build-provenance
action to v2.4.0 (#495)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR contains the following updates:
| Package | Type | Update | Change |
|---|---|---|---|
|
[actions/attest-build-provenance](https://github.com/actions/attest-build-provenance)
| action | minor | `v2.3.0` -> `v2.4.0` |
---
### Release Notes
actions/attest-build-provenance
(actions/attest-build-provenance)
###
[`v2.4.0`](https://github.com/actions/attest-build-provenance/releases/tag/v2.4.0)
[Compare
Source](https://github.com/actions/attest-build-provenance/compare/v2.3.0...v2.4.0)
#### What's Changed
- Bump undici from 5.28.5 to 5.29.0 by
[@dependabot](https://github.com/dependabot) in
[https://github.com/actions/attest-build-provenance/pull/633](https://github.com/actions/attest-build-provenance/pull/633)
- Bump actions/attest from 2.3.0 to
[2.4.0](https://github.com/actions/attest/releases/tag/v2.4.0)
by [@bdehamer](https://github.com/bdehamer) in
[https://github.com/actions/attest-build-provenance/pull/654](https://github.com/actions/attest-build-provenance/pull/654)
- Includes support for the new well-known summary file which will
accumulate paths to all attestations generated in a given workflow run
**Full Changelog**:
https://github.com/actions/attest-build-provenance/compare/v2.3.0...v2.4.0
---
### Configuration
π
**Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
π¦ **Automerge**: Enabled.
β» **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.
π **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/dotnet-sdk).
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/workflows/release.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 47898d387..725957a75 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -61,7 +61,7 @@ jobs:
run: dotnet nuget push "src/**/*.nupkg" --api-key "${{ secrets.NUGET_TOKEN }}" --source https://api.nuget.org/v3/index.json
- name: Generate artifact attestation
- uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0
+ uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
with:
subject-path: "src/**/*.nupkg"
From cab380727fe95b941384ae71f022626cdf23db53 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 23 Jun 2025 16:40:04 +0100
Subject: [PATCH 12/90] chore(deps): update dependency benchmarkdotnet to
0.15.2 (#494)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR contains the following updates:
| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [BenchmarkDotNet](https://github.com/dotnet/BenchmarkDotNet)
| `0.15.0` -> `0.15.2` |
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
---
### Release Notes
dotnet/BenchmarkDotNet (BenchmarkDotNet)
###
[`v0.15.2`](https://github.com/dotnet/BenchmarkDotNet/releases/tag/v0.15.2):
0.15.2
Full changelog: https://benchmarkdotnet.org/changelog/v0.15.2.html
#### Highlights
- The most significant update in this release is the enhanced accuracy
of the memory diagnoser
([#2562](https://github.com/dotnet/BenchmarkDotNet/pull/2562)).
This improvement resolves the issue of incorrectly reported memory
allocations
([#1542](https://github.com/dotnet/BenchmarkDotNet/issues/1542),
[#2582](https://github.com/dotnet/BenchmarkDotNet/issues/2582)).
- We have introduced a new feature that allows users to sort benchmark
jobs in numerical order
([#2768](https://github.com/dotnet/BenchmarkDotNet/issues/2768),
[#2770](https://github.com/dotnet/BenchmarkDotNet/pull/2770)).
- Benchmark validation has been improved
([#2771](https://github.com/dotnet/BenchmarkDotNet/pull/2771)).
- An issue with non-persistent auto-generated JobId has been fixed
([#2777](https://github.com/dotnet/BenchmarkDotNet/pull/2777)).
###
[`v0.15.1`](https://github.com/dotnet/BenchmarkDotNet/releases/tag/v0.15.1):
0.15.1
Full changelog: https://benchmarkdotnet.org/changelog/v0.15.1.html
#### Highlights
- Added support for \*.slnx
([#2763](https://github.com/dotnet/BenchmarkDotNet/issues/2763),
[#2764](https://github.com/dotnet/BenchmarkDotNet/pull/2764))
- Enabled ArgumentsSource to reference methods in other types
([#2744](https://github.com/dotnet/BenchmarkDotNet/issues/2744),
[#2748](https://github.com/dotnet/BenchmarkDotNet/pull/2748))
- Resolved fatal errors for ARM CPUs
([#2745](https://github.com/dotnet/BenchmarkDotNet/issues/2745),
[#2756](https://github.com/dotnet/BenchmarkDotNet/pull/2756))
- Fixed bugs related to support for Android, browser, iOS, and tvOS
([#2739](https://github.com/dotnet/BenchmarkDotNet/issues/2739),
[#2741](https://github.com/dotnet/BenchmarkDotNet/pull/2741),
[#2740](https://github.com/dotnet/BenchmarkDotNet/issues/2740),
[#2742](https://github.com/dotnet/BenchmarkDotNet/pull/2742))
---
### Configuration
π
**Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
π¦ **Automerge**: Enabled.
β» **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.
π **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/dotnet-sdk).
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
Directory.Packages.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 87a661c7d..442e43fd7 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -22,7 +22,7 @@
-
+
From 08a00e1d35834635ca296fe8a13507001ad25c57 Mon Sep 17 00:00:00 2001
From: Weihan Li
Date: Tue, 24 Jun 2025 15:35:46 +0800
Subject: [PATCH 13/90] chore: fix sample build warning (#498)
## This PR
- fixes the sample build warning
### Notes
the `builder` name override the `WebApplicationBuilder`
Signed-off-by: Weihan Li
---
samples/AspNetCore/Program.cs | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs
index 462370861..a9c2cd508 100644
--- a/samples/AspNetCore/Program.cs
+++ b/samples/AspNetCore/Program.cs
@@ -9,11 +9,11 @@
// Add services to the container.
builder.Services.AddProblemDetails();
-builder.Services.AddOpenFeature(builder =>
+builder.Services.AddOpenFeature(featureBuilder =>
{
- builder.AddHostedFeatureLifecycle()
+ featureBuilder.AddHostedFeatureLifecycle()
.AddHook(sp => new LoggingHook(sp.GetRequiredService>()))
- .AddInMemoryProvider("InMemory", provider => new Dictionary()
+ .AddInMemoryProvider("InMemory", _ => new Dictionary()
{
{
"welcome-message", new Flag(
From 2e3dffd0ebbba4a9d95763e2ce9f3e2ac051a317 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Wed, 25 Jun 2025 07:49:59 +0100
Subject: [PATCH 14/90] chore(deps): update spec digest to 1965aae (#499)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR contains the following updates:
| Package | Update | Change |
|---|---|---|
| spec | digest | `42340bb` -> `1965aae` |
---
### Configuration
π
**Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
π¦ **Automerge**: Enabled.
β» **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.
π **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/dotnet-sdk).
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
spec | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/spec b/spec
index 42340bb9f..1965aae81 160000
--- a/spec
+++ b/spec
@@ -1 +1 @@
-Subproject commit 42340bb9f56ccd8f98f90ac3baca52758f5a5859
+Subproject commit 1965aae810d9b77bc76c797448a84df108bb56ec
From 68af6493b09d29be5d4cdda9e6f792ee8667bf4f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Silva?=
<2493377+askpt@users.noreply.github.com>
Date: Wed, 25 Jun 2025 18:07:59 +0100
Subject: [PATCH 15/90] fix: Add generic to evaluation event builder (#500)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
---
.../Telemetry/EvaluationEventBuilder.cs | 8 ++++----
.../Telemetry/EvaluationEventBuilderTests.cs | 16 +++++++---------
2 files changed, 11 insertions(+), 13 deletions(-)
diff --git a/src/OpenFeature/Telemetry/EvaluationEventBuilder.cs b/src/OpenFeature/Telemetry/EvaluationEventBuilder.cs
index d9520c124..b17565ce3 100644
--- a/src/OpenFeature/Telemetry/EvaluationEventBuilder.cs
+++ b/src/OpenFeature/Telemetry/EvaluationEventBuilder.cs
@@ -6,14 +6,14 @@ namespace OpenFeature.Telemetry;
///
/// Class for creating evaluation events for feature flags.
///
-public sealed class EvaluationEventBuilder
+public sealed class EvaluationEventBuilder
{
private const string EventName = "feature_flag.evaluation";
///
- /// Gets the default instance of the .
+ /// Gets the default instance of the .
///
- public static EvaluationEventBuilder Default { get; } = new();
+ public static EvaluationEventBuilder Default { get; } = new();
///
/// Creates an evaluation event based on the provided hook context and flag evaluation details.
@@ -21,7 +21,7 @@ public sealed class EvaluationEventBuilder
/// The context of the hook containing flag key and provider metadata.
/// The details of the flag evaluation including reason, variant, and metadata.
/// An instance of containing the event name, attributes, and body.
- public EvaluationEvent Build(HookContext hookContext, FlagEvaluationDetails details)
+ public static EvaluationEvent Build(HookContext hookContext, FlagEvaluationDetails details)
{
var attributes = new Dictionary
{
diff --git a/test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs b/test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs
index 8bebd3f5d..79e31df08 100644
--- a/test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs
+++ b/test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs
@@ -6,8 +6,6 @@ namespace OpenFeature.Tests.Telemetry;
public class EvaluationEventBuilderTests
{
- private readonly EvaluationEventBuilder _builder = EvaluationEventBuilder.Default;
-
[Fact]
public void Build_ShouldReturnEventWithCorrectAttributes()
{
@@ -25,7 +23,7 @@ public void Build_ShouldReturnEventWithCorrectAttributes()
reason: "reason", variant: "variant", flagMetadata: flagMetadata);
// Act
- var evaluationEvent = _builder.Build(hookContext, details);
+ var evaluationEvent = EvaluationEventBuilder.Build(hookContext, details);
// Assert
Assert.Equal("feature_flag.evaluation", evaluationEvent.Name);
@@ -55,7 +53,7 @@ public void Build_ShouldHandleErrorDetails()
errorMessage: "errorMessage", reason: "reason", variant: "variant", flagMetadata: flagMetadata);
// Act
- var evaluationEvent = _builder.Build(hookContext, details);
+ var evaluationEvent = EvaluationEventBuilder.Build(hookContext, details);
// Assert
Assert.Equal("general", evaluationEvent.Attributes[TelemetryConstants.ErrorCode]);
@@ -79,7 +77,7 @@ public void Build_ShouldHandleMissingVariant()
reason: "reason", variant: null, flagMetadata: flagMetadata);
// Act
- var evaluationEvent = _builder.Build(hookContext, details);
+ var evaluationEvent = EvaluationEventBuilder.Build(hookContext, details);
// Assert
Assert.Null(evaluationEvent.Attributes[TelemetryConstants.Variant]);
@@ -98,7 +96,7 @@ public void Build_ShouldHandleMissingFlagMetadata()
reason: "reason", variant: "", flagMetadata: flagMetadata);
// Act
- var evaluationEvent = _builder.Build(hookContext, details);
+ var evaluationEvent = EvaluationEventBuilder.Build(hookContext, details);
// Assert
Assert.Null(evaluationEvent.Attributes[TelemetryConstants.ContextId]);
@@ -122,7 +120,7 @@ public void Build_ShouldHandleMissingReason(string? reason)
reason: reason, variant: "", flagMetadata: flagMetadata);
// Act
- var evaluationEvent = _builder.Build(hookContext, details);
+ var evaluationEvent = EvaluationEventBuilder.Build(hookContext, details);
// Assert
Assert.Equal(Reason.Unknown.ToLowerInvariant(), evaluationEvent.Attributes[TelemetryConstants.Reason]);
@@ -144,7 +142,7 @@ public void Build_ShouldHandleErrorWithEmptyErrorMessage(string? errorMessage)
errorMessage: errorMessage, reason: "reason", variant: "", flagMetadata: flagMetadata);
// Act
- var evaluationEvent = _builder.Build(hookContext, details);
+ var evaluationEvent = EvaluationEventBuilder.Build(hookContext, details);
// Assert
Assert.Equal("general", evaluationEvent.Attributes[TelemetryConstants.ErrorCode]);
@@ -164,7 +162,7 @@ public void Build_ShouldIncludeValueAttributeInEvent()
reason: "reason", variant: "variant", flagMetadata: new ImmutableMetadata());
// Act
- var evaluationEvent = _builder.Build(hookContext, details);
+ var evaluationEvent = EvaluationEventBuilder.Build(hookContext, details);
// Assert
Assert.Equal(testValue, evaluationEvent.Attributes[TelemetryConstants.Value]);
From 38f63fceb5516cd474fd0e867aa25eae252cf2c1 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Fri, 27 Jun 2025 07:15:16 +0000
Subject: [PATCH 16/90] chore(deps): update spec digest to c37ac17 (#502)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR contains the following updates:
| Package | Update | Change |
|---|---|---|
| spec | digest | `1965aae` -> `c37ac17` |
---
### Configuration
π
**Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
π¦ **Automerge**: Enabled.
β» **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.
π **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/dotnet-sdk).
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
spec | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/spec b/spec
index 1965aae81..c37ac17c8 160000
--- a/spec
+++ b/spec
@@ -1 +1 @@
-Subproject commit 1965aae810d9b77bc76c797448a84df108bb56ec
+Subproject commit c37ac17c80410de1a2c6c6f061386001c838cb40
From 39f884df420f1a9346852159948c288e728672b8 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Fri, 27 Jun 2025 07:15:59 +0000
Subject: [PATCH 17/90] chore(deps): update dependency system.valuetuple to
4.6.1 (#503)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR contains the following updates:
| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
|
[System.ValueTuple](https://github.com/dotnet/maintenance-packages)
| `4.6.0` -> `4.6.1` |
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
---
### Configuration
π
**Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
π¦ **Automerge**: Enabled.
β» **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.
π **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/dotnet-sdk).
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
Directory.Packages.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 442e43fd7..3e870319d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -17,7 +17,7 @@
-
+
From 77f6e1bbb76973e078c1999ad0784c9edc9def96 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Silva?=
<2493377+askpt@users.noreply.github.com>
Date: Fri, 27 Jun 2025 15:33:32 +0100
Subject: [PATCH 18/90] feat: Move OTEL hooks to the SDK (#338)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Move OTEL hooks to the SDK
This pull request introduces telemetry enhancements to the OpenFeature
.NET SDK by adding new hooks for tracing and metrics, updating
dependencies, and providing examples and tests. The most significant
changes include the addition of the `TraceEnricherHook` and
`MetricsHook` classes, integration of OpenTelemetry in the ASP.NET Core
sample, and updates to dependencies to support telemetry features.
### Telemetry Enhancements
* **Trace Enricher Hook**: Added `TraceEnricherHook` to enrich telemetry
traces with feature flag evaluation details, including tags and events
for tracing purposes. This hook integrates with the current `Activity`
and supports error handling.
* **Metrics Hook**: Introduced `MetricsHook` for capturing metrics such
as evaluation requests, successes, errors, and active evaluations.
Metrics are collected using OpenTelemetry's `Meter` API.
[[1]](diffhunk://#diff-912b71a06f9a65012af403b04269f68f3cb9ee580d0dd62e1d6afc99bd433d31R1-R100)
[[2]](diffhunk://#diff-9c7590e55694b19483ea6d802e6a1bb34418366a8f66d6f35b57ae81adb5bf17R1-R16)
### Dependency Updates
* **OpenTelemetry Dependencies**: Added
`OpenTelemetry.Extensions.Hosting`,
`OpenTelemetry.Instrumentation.AspNetCore`, and
`OpenTelemetry.Exporter.OpenTelemetryProtocol` to the ASP.NET Core
sample project to support telemetry features.
[[1]](diffhunk://#diff-40299025547d304b834a53acda1eb9a8a6f49f2ec679b6bcebd3449211497c56R3-R18)
[[2]](diffhunk://#diff-711ea17cbdebe419375c7684c8c39a1423d2bebcf8976ddd7bdd78deaab65b21R12)
* **DiagnosticSource Dependency**: Included
`System.Diagnostics.DiagnosticSource` in the main project and
centralized dependency management.
[[1]](diffhunk://#diff-5baf5f9e448ad54ab25a091adee0da05d4d228481c9200518fcb1b53a65d4156R19)
[[2]](diffhunk://#diff-5baf5f9e448ad54ab25a091adee0da05d4d228481c9200518fcb1b53a65d4156R33-R34)
### Documentation and Examples
* **README Updates**: Added documentation for `TraceEnricherHook` and
`MetricsHook`, including detailed descriptions, examples, and usage
instructions for integrating these hooks with OpenTelemetry.
* **ASP.NET Core Sample**: Updated the sample application to demonstrate
the use of `TraceEnricherHook` and `MetricsHook` with OpenTelemetry
tracing and metrics.
[[1]](diffhunk://#diff-41550b31d77b5898b38a3280f8ffbc5d2531fc4d4884079ebf3c5e953a85075dR6-R32)
[[2]](diffhunk://#diff-40299025547d304b834a53acda1eb9a8a6f49f2ec679b6bcebd3449211497c56R3-R18)
### Testing
* **MetricsHook Tests**: Added unit tests for `MetricsHook` to verify
metrics collection during different stages of feature flag evaluation
(e.g., `BeforeAsync`, `AfterAsync`, `ErrorAsync`, `FinallyAsync`).
### Related Issues
Fixes #175
### Notes
* In this PR I made some changes to the `samples` application. This
allow us to see the metrics and traces in any OTEL tool.
Check the screenshots below:

### Follow-up Tasks
- We should remove any reference in the other repository and mark the
Nuget package as deprecated.
---------
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
---
Directory.Packages.props | 5 +-
README.md | 147 ++++++++++++++++--
samples/AspNetCore/Program.cs | 17 ++
samples/AspNetCore/Samples.AspNetCore.csproj | 10 ++
src/OpenFeature/Hooks/MetricsConstants.cs | 16 ++
src/OpenFeature/Hooks/MetricsHook.cs | 100 ++++++++++++
src/OpenFeature/Hooks/TraceEnricherHook.cs | 38 +++++
src/OpenFeature/OpenFeature.csproj | 1 +
.../Hooks/MetricsHookTests.cs | 141 +++++++++++++++++
.../Hooks/TraceEnricherHookTests.cs | 91 +++++++++++
.../OpenFeature.Tests.csproj | 2 +
11 files changed, 558 insertions(+), 10 deletions(-)
create mode 100644 src/OpenFeature/Hooks/MetricsConstants.cs
create mode 100644 src/OpenFeature/Hooks/MetricsHook.cs
create mode 100644 src/OpenFeature/Hooks/TraceEnricherHook.cs
create mode 100644 test/OpenFeature.Tests/Hooks/MetricsHookTests.cs
create mode 100644 test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 3e870319d..9b184d3fc 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -16,6 +16,7 @@
+
@@ -29,6 +30,8 @@
+
+
@@ -39,4 +42,4 @@
-
\ No newline at end of file
+
diff --git a/README.md b/README.md
index 9288edfd6..e87f2584c 100644
--- a/README.md
+++ b/README.md
@@ -79,23 +79,23 @@ public async Task Example()
The [`samples/`](./samples) folder contains example applications demonstrating how to use OpenFeature in different .NET scenarios.
-| Sample Name | Description |
-|---------------------------------------------------|----------------------------------------------------------------|
-| [AspNetCore](/samples/AspNetCore/README.md) | Feature flags in an ASP.NET Core Web API. |
+| Sample Name | Description |
+| ------------------------------------------- | ----------------------------------------- |
+| [AspNetCore](/samples/AspNetCore/README.md) | Feature flags in an ASP.NET Core Web API. |
**Getting Started with a Sample:**
1. Navigate to the sample directory
- ```shell
- cd samples/AspNetCore
- ```
+ ```shell
+ cd samples/AspNetCore
+ ```
2. Restore dependencies and run
- ```shell
- dotnet run
- ```
+ ```shell
+ dotnet run
+ ```
Want to contribute a new sample? See our [CONTRIBUTING](CONTRIBUTING.md) guide!
@@ -534,6 +534,135 @@ services.AddOpenFeature(builder =>
});
```
+### Trace Enricher Hook
+
+The `TraceEnricherHook` enriches telemetry traces with additional information during the feature flag evaluation lifecycle. This hook adds relevant flag evaluation details as tags and events to the current `Activity` for tracing purposes.
+
+For this hook to function correctly, an active span must be set in the current `Activity`, otherwise the hook will no-op.
+
+Below are the tags added to the trace event:
+
+| Tag Name | Description | Source |
+| --------------------------- | ---------------------------------------------------------------------------- | ----------------------------- |
+| feature_flag.key | The lookup key of the feature flag | Hook context flag key |
+| feature_flag.provider.name | The name of the feature flag provider | Provider metadata |
+| feature_flag.result.reason | The reason code which shows how a feature flag value was determined | Evaluation details |
+| feature_flag.result.variant | A semantic identifier for an evaluated flag value | Evaluation details |
+| feature_flag.result.value | The evaluated value of the feature flag | Evaluation details |
+| feature_flag.context.id | The unique identifier for the flag evaluation context | Flag metadata (if available) |
+| feature_flag.set.id | The identifier of the flag set to which the feature flag belongs | Flag metadata (if available) |
+| feature_flag.version | The version of the ruleset used during the evaluation | Flag metadata (if available) |
+| error.type | Describes a class of error the operation ended with | Evaluation details (if error) |
+| error.message | A message explaining the nature of an error occurring during flag evaluation | Evaluation details (if error) |
+
+#### Example
+
+The following example demonstrates the use of the `TraceEnricherHook` with the `OpenFeature dotnet-sdk`. The traces are sent to a `jaeger` OTLP collector running at `localhost:4317`.
+
+```csharp
+using OpenFeature.Contrib.Providers.Flagd;
+using OpenFeature.Hooks;
+using OpenTelemetry.Exporter;
+using OpenTelemetry.Resources;
+using OpenTelemetry;
+using OpenTelemetry.Trace;
+
+namespace OpenFeatureTestApp
+{
+ class Hello {
+ static void Main(string[] args) {
+
+ // set up the OpenTelemetry OTLP exporter
+ var tracerProvider = Sdk.CreateTracerProviderBuilder()
+ .AddSource("my-tracer")
+ .ConfigureResource(r => r.AddService("jaeger-test"))
+ .AddOtlpExporter(o =>
+ {
+ o.ExportProcessorType = ExportProcessorType.Simple;
+ })
+ .Build();
+
+ // add the TraceEnricherHook to the OpenFeature instance
+ OpenFeature.Api.Instance.AddHooks(new TraceEnricherHook());
+
+ var flagdProvider = new FlagdProvider(new Uri("http://localhost:8013"));
+
+ // Set the flagdProvider as the provider for the OpenFeature SDK
+ OpenFeature.Api.Instance.SetProvider(flagdProvider);
+
+ var client = OpenFeature.Api.Instance.GetClient("my-app");
+
+ var val = client.GetBooleanValueAsync("myBoolFlag", false, null);
+
+ // Print the value of the 'myBoolFlag' feature flag
+ System.Console.WriteLine(val.Result.ToString());
+ }
+ }
+}
+```
+
+After running this example, you will be able to see the traces, including the events sent by the hook in your Jaeger UI.
+
+### Metrics Hook
+
+For this hook to function correctly a global `MeterProvider` must be set.
+`MetricsHook` performs metric collection by tapping into various hook stages.
+
+Below are the metrics extracted by this hook and dimensions they carry:
+
+| Metric key | Description | Unit | Dimensions |
+| -------------------------------------- | ------------------------------- | ------------ | ----------------------------- |
+| feature_flag.evaluation_requests_total | Number of evaluation requests | {request} | key, provider name |
+| feature_flag.evaluation_success_total | Flag evaluation successes | {impression} | key, provider name, reason |
+| feature_flag.evaluation_error_total | Flag evaluation errors | 1 | key, provider name, exception |
+| feature_flag.evaluation_active_count | Active flag evaluations counter | 1 | key, provider name |
+
+Consider the following code example for usage.
+
+#### Example
+
+The following example demonstrates the use of the `MetricsHook` with the `OpenFeature dotnet-sdk`. The metrics are sent to the `console`.
+
+```csharp
+using OpenFeature.Contrib.Providers.Flagd;
+using OpenFeature;
+using OpenFeature.Hooks;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+
+namespace OpenFeatureTestApp
+{
+ class Hello {
+ static void Main(string[] args) {
+
+ // set up the OpenTelemetry OTLP exporter
+ var meterProvider = Sdk.CreateMeterProviderBuilder()
+ .AddMeter("OpenFeature")
+ .ConfigureResource(r => r.AddService("openfeature-test"))
+ .AddConsoleExporter()
+ .Build();
+
+ // add the MetricsHook to the OpenFeature instance
+ OpenFeature.Api.Instance.AddHooks(new MetricsHook());
+
+ var flagdProvider = new FlagdProvider(new Uri("http://localhost:8013"));
+
+ // Set the flagdProvider as the provider for the OpenFeature SDK
+ OpenFeature.Api.Instance.SetProvider(flagdProvider);
+
+ var client = OpenFeature.Api.Instance.GetClient("my-app");
+
+ var val = client.GetBooleanValueAsync("myBoolFlag", false, null);
+
+ // Print the value of the 'myBoolFlag' feature flag
+ System.Console.WriteLine(val.Result.ToString());
+ }
+ }
+}
+```
+
+After running this example, you should be able to see some metrics being generated into the console.
+
## βοΈ Support the project
diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs
index a9c2cd508..5f4f01461 100644
--- a/samples/AspNetCore/Program.cs
+++ b/samples/AspNetCore/Program.cs
@@ -3,16 +3,33 @@
using OpenFeature.DependencyInjection.Providers.Memory;
using OpenFeature.Hooks;
using OpenFeature.Providers.Memory;
+using OpenTelemetry.Logs;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddProblemDetails();
+// Configure OpenTelemetry
+builder.Services.AddOpenTelemetry()
+ .ConfigureResource(resource => resource.AddService("openfeature-aspnetcore-sample"))
+ .WithTracing(tracing => tracing
+ .AddAspNetCoreInstrumentation()
+ .AddOtlpExporter())
+ .WithMetrics(metrics => metrics
+ .AddAspNetCoreInstrumentation()
+ .AddMeter("OpenFeature")
+ .AddOtlpExporter());
+
builder.Services.AddOpenFeature(featureBuilder =>
{
featureBuilder.AddHostedFeatureLifecycle()
.AddHook(sp => new LoggingHook(sp.GetRequiredService>()))
+ .AddHook()
+ .AddHook()
.AddInMemoryProvider("InMemory", _ => new Dictionary()
{
{
diff --git a/samples/AspNetCore/Samples.AspNetCore.csproj b/samples/AspNetCore/Samples.AspNetCore.csproj
index 01e452d77..3dd554a78 100644
--- a/samples/AspNetCore/Samples.AspNetCore.csproj
+++ b/samples/AspNetCore/Samples.AspNetCore.csproj
@@ -1,9 +1,19 @@
ο»Ώ
+
+ false
+
+
+
+
+
+
+
+
diff --git a/src/OpenFeature/Hooks/MetricsConstants.cs b/src/OpenFeature/Hooks/MetricsConstants.cs
new file mode 100644
index 000000000..e54dd61cf
--- /dev/null
+++ b/src/OpenFeature/Hooks/MetricsConstants.cs
@@ -0,0 +1,16 @@
+namespace OpenFeature.Hooks;
+
+internal static class MetricsConstants
+{
+ internal const string ActiveCountName = "feature_flag.evaluation_active_count";
+ internal const string RequestsTotalName = "feature_flag.evaluation_requests_total";
+ internal const string SuccessTotalName = "feature_flag.evaluation_success_total";
+ internal const string ErrorTotalName = "feature_flag.evaluation_error_total";
+
+ internal const string ActiveDescription = "active flag evaluations counter";
+ internal const string RequestsDescription = "feature flag evaluation request counter";
+ internal const string SuccessDescription = "feature flag evaluation success counter";
+ internal const string ErrorDescription = "feature flag evaluation error counter";
+
+ internal const string ExceptionAttr = "exception";
+}
diff --git a/src/OpenFeature/Hooks/MetricsHook.cs b/src/OpenFeature/Hooks/MetricsHook.cs
new file mode 100644
index 000000000..2f2314f0d
--- /dev/null
+++ b/src/OpenFeature/Hooks/MetricsHook.cs
@@ -0,0 +1,100 @@
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+using System.Reflection;
+using OpenFeature.Constant;
+using OpenFeature.Model;
+using OpenFeature.Telemetry;
+
+namespace OpenFeature.Hooks;
+
+///
+/// Represents a hook for capturing metrics related to flag evaluations.
+/// The meter instrumentation name is "OpenFeature".
+///
+/// This is still experimental and subject to change.
+public class MetricsHook : Hook
+{
+ private static readonly AssemblyName AssemblyName = typeof(MetricsHook).Assembly.GetName();
+ private static readonly string InstrumentationName = AssemblyName.Name ?? "OpenFeature";
+ private static readonly string InstrumentationVersion = AssemblyName.Version?.ToString() ?? "1.0.0";
+ private static readonly Meter Meter = new(InstrumentationName, InstrumentationVersion);
+
+ private readonly UpDownCounter _evaluationActiveUpDownCounter;
+ private readonly Counter _evaluationRequestCounter;
+ private readonly Counter _evaluationSuccessCounter;
+ private readonly Counter _evaluationErrorCounter;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public MetricsHook()
+ {
+ this._evaluationActiveUpDownCounter = Meter.CreateUpDownCounter(MetricsConstants.ActiveCountName, description: MetricsConstants.ActiveDescription);
+ this._evaluationRequestCounter = Meter.CreateCounter(MetricsConstants.RequestsTotalName, "{request}", MetricsConstants.RequestsDescription);
+ this._evaluationSuccessCounter = Meter.CreateCounter(MetricsConstants.SuccessTotalName, "{impression}", MetricsConstants.SuccessDescription);
+ this._evaluationErrorCounter = Meter.CreateCounter(MetricsConstants.ErrorTotalName, description: MetricsConstants.ErrorDescription);
+ }
+
+ ///
+ public override ValueTask BeforeAsync(HookContext context, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default)
+ {
+ var tagList = new TagList
+ {
+ { TelemetryConstants.Key, context.FlagKey },
+ { TelemetryConstants.Provider, context.ProviderMetadata.Name }
+ };
+
+ this._evaluationActiveUpDownCounter.Add(1, tagList);
+ this._evaluationRequestCounter.Add(1, tagList);
+
+ return base.BeforeAsync(context, hints, cancellationToken);
+ }
+
+
+ ///
+ public override ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default)
+ {
+ var tagList = new TagList
+ {
+ { TelemetryConstants.Key, context.FlagKey },
+ { TelemetryConstants.Provider, context.ProviderMetadata.Name },
+ { TelemetryConstants.Reason, details.Reason ?? Reason.Unknown.ToString() }
+ };
+
+ this._evaluationSuccessCounter.Add(1, tagList);
+
+ return base.AfterAsync(context, details, hints, cancellationToken);
+ }
+
+ ///
+ public override ValueTask ErrorAsync(HookContext context, Exception error, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default)
+ {
+ var tagList = new TagList
+ {
+ { TelemetryConstants.Key, context.FlagKey },
+ { TelemetryConstants.Provider, context.ProviderMetadata.Name },
+ { MetricsConstants.ExceptionAttr, error.Message }
+ };
+
+ this._evaluationErrorCounter.Add(1, tagList);
+
+ return base.ErrorAsync(context, error, hints, cancellationToken);
+ }
+
+ ///
+ public override ValueTask FinallyAsync(HookContext context,
+ FlagEvaluationDetails evaluationDetails,
+ IReadOnlyDictionary? hints = null,
+ CancellationToken cancellationToken = default)
+ {
+ var tagList = new TagList
+ {
+ { TelemetryConstants.Key, context.FlagKey },
+ { TelemetryConstants.Provider, context.ProviderMetadata.Name }
+ };
+
+ this._evaluationActiveUpDownCounter.Add(-1, tagList);
+
+ return base.FinallyAsync(context, evaluationDetails, hints, cancellationToken);
+ }
+}
diff --git a/src/OpenFeature/Hooks/TraceEnricherHook.cs b/src/OpenFeature/Hooks/TraceEnricherHook.cs
new file mode 100644
index 000000000..08914b1ca
--- /dev/null
+++ b/src/OpenFeature/Hooks/TraceEnricherHook.cs
@@ -0,0 +1,38 @@
+using System.Diagnostics;
+using OpenFeature.Model;
+using OpenFeature.Telemetry;
+
+namespace OpenFeature.Hooks;
+
+///
+/// A hook that enriches telemetry traces with additional information during the feature flag evaluation lifecycle.
+/// This hook adds relevant flag evaluation details as tags and events to the current for tracing purposes.
+/// On error, it attaches exception information to the trace, using the appropriate API depending on the .NET version.
+///
+/// This is still experimental and subject to change.
+public class TraceEnricherHook : Hook
+{
+ ///
+ /// Adds tags and events to the current for tracing purposes.
+ ///
+ /// The type of the flag value being evaluated.
+ /// The hook context containing metadata about the evaluation.
+ /// Details about the flag evaluation including the key, value, and variant.
+ /// Optional dictionary of hints that can modify hook behavior.
+ /// A token to cancel the operation.
+ /// A completed representing the asynchronous operation.
+ public override ValueTask FinallyAsync(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default)
+ {
+ var evaluationEvent = EvaluationEventBuilder.Build(context, details);
+
+ var tags = new ActivityTagsCollection();
+ foreach (var kvp in evaluationEvent.Attributes)
+ {
+ tags[kvp.Key] = kvp.Value;
+ }
+
+ Activity.Current?.AddEvent(new ActivityEvent(evaluationEvent.Name, tags: tags));
+
+ return base.FinallyAsync(context, details, hints, cancellationToken);
+ }
+}
diff --git a/src/OpenFeature/OpenFeature.csproj b/src/OpenFeature/OpenFeature.csproj
index c47b109d0..3d81a99eb 100644
--- a/src/OpenFeature/OpenFeature.csproj
+++ b/src/OpenFeature/OpenFeature.csproj
@@ -9,6 +9,7 @@
+
diff --git a/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs b/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs
new file mode 100644
index 000000000..54f6e19cc
--- /dev/null
+++ b/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs
@@ -0,0 +1,141 @@
+using OpenFeature.Hooks;
+using OpenFeature.Model;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Resources;
+
+namespace OpenFeature.Tests.Hooks;
+
+[CollectionDefinition(nameof(MetricsHookTest), DisableParallelization = true)]
+public class MetricsHookTest : IDisposable
+{
+ private readonly List _exportedItems;
+ private readonly MeterProvider _meterProvider;
+
+ public MetricsHookTest()
+ {
+ // Arrange metrics collector
+ this._exportedItems = [];
+ this._meterProvider = Sdk.CreateMeterProviderBuilder()
+ .AddMeter("OpenFeature")
+ .ConfigureResource(r => r.AddService("open-feature"))
+ .AddInMemoryExporter(this._exportedItems,
+ option => option.PeriodicExportingMetricReaderOptions =
+ new PeriodicExportingMetricReaderOptions { ExportIntervalMilliseconds = 100 })
+ .Build();
+ }
+
+#pragma warning disable CA1816
+ public void Dispose()
+ {
+ this._meterProvider.Shutdown();
+ }
+#pragma warning restore CA1816
+
+ [Fact]
+ public async Task After_Test()
+ {
+ // Arrange
+ const string metricName = "feature_flag.evaluation_success_total";
+ var metricsHook = new MetricsHook();
+ var evaluationContext = EvaluationContext.Empty;
+ var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String,
+ new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
+
+ // Act
+ await metricsHook.AfterAsync(ctx,
+ new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"),
+ new Dictionary()).ConfigureAwait(true);
+ this._meterProvider.ForceFlush();
+
+ // Assert metrics
+ Assert.NotEmpty(this._exportedItems);
+
+ // check if the metric is present in the exported items
+ var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName);
+ Assert.NotNull(metric);
+
+ var noOtherMetric = this._exportedItems.All(m => m.Name == metricName);
+ Assert.True(noOtherMetric);
+ }
+
+ [Fact]
+ public async Task Error_Test()
+ {
+ // Arrange
+ const string metricName = "feature_flag.evaluation_error_total";
+ var metricsHook = new MetricsHook();
+ var evaluationContext = EvaluationContext.Empty;
+ var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String,
+ new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
+
+ // Act
+ await metricsHook.ErrorAsync(ctx, new Exception(), new Dictionary()).ConfigureAwait(true);
+ this._meterProvider.ForceFlush();
+
+ // Assert metrics
+ Assert.NotEmpty(this._exportedItems);
+
+ // check if the metric is present in the exported items
+ var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName);
+ Assert.NotNull(metric);
+
+ var noOtherMetric = this._exportedItems.All(m => m.Name == metricName);
+ Assert.True(noOtherMetric);
+ }
+
+ [Fact]
+ public async Task Finally_Test()
+ {
+ // Arrange
+ const string metricName = "feature_flag.evaluation_active_count";
+ var metricsHook = new MetricsHook();
+ var evaluationContext = EvaluationContext.Empty;
+ var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String,
+ new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
+ var evaluationDetails = new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default");
+
+ // Act
+ await metricsHook.FinallyAsync(ctx, evaluationDetails, new Dictionary()).ConfigureAwait(true);
+ this._meterProvider.ForceFlush();
+
+ // Assert metrics
+ Assert.NotEmpty(this._exportedItems);
+
+ // check if the metric feature_flag.evaluation_success_total is present in the exported items
+ var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName);
+ Assert.NotNull(metric);
+
+ var noOtherMetric = this._exportedItems.All(m => m.Name == metricName);
+ Assert.True(noOtherMetric);
+ }
+
+ [Fact]
+ public async Task Before_Test()
+ {
+ // Arrange
+ const string metricName1 = "feature_flag.evaluation_active_count";
+ const string metricName2 = "feature_flag.evaluation_requests_total";
+ var metricsHook = new MetricsHook();
+ var evaluationContext = EvaluationContext.Empty;
+ var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String,
+ new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
+
+ // Act
+ await metricsHook.BeforeAsync(ctx, new Dictionary()).ConfigureAwait(true);
+ this._meterProvider.ForceFlush();
+
+ // Assert metrics
+ Assert.NotEmpty(this._exportedItems);
+
+ // check if the metric is present in the exported items
+ var metric1 = this._exportedItems.FirstOrDefault(m => m.Name == metricName1);
+ Assert.NotNull(metric1);
+
+ var metric2 = this._exportedItems.FirstOrDefault(m => m.Name == metricName2);
+ Assert.NotNull(metric2);
+
+ var noOtherMetric = this._exportedItems.All(m => m.Name == metricName1 || m.Name == metricName2);
+ Assert.True(noOtherMetric);
+ }
+}
diff --git a/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs b/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs
new file mode 100644
index 000000000..f73d36200
--- /dev/null
+++ b/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs
@@ -0,0 +1,91 @@
+using System.Diagnostics;
+using OpenFeature.Hooks;
+using OpenFeature.Model;
+using OpenTelemetry;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+
+namespace OpenFeature.Tests.Hooks;
+
+[CollectionDefinition(nameof(TraceEnricherHookTests), DisableParallelization = true)]
+public class TraceEnricherHookTests : IDisposable
+{
+ private readonly List _exportedItems;
+ private readonly TracerProvider _tracerProvider;
+ private readonly Tracer _tracer;
+
+ public TraceEnricherHookTests()
+ {
+ // List that will be populated with the traces by InMemoryExporter
+ this._exportedItems = [];
+
+ // Create a new in-memory exporter
+ this._tracerProvider = Sdk.CreateTracerProviderBuilder()
+ .AddSource("my-tracer")
+ .ConfigureResource(r => r.AddService("in-memory-test"))
+ .AddInMemoryExporter(this._exportedItems)
+ .Build();
+
+ this._tracer = this._tracerProvider.GetTracer("my-tracer");
+ }
+
+#pragma warning disable CA1816
+ public void Dispose()
+ {
+ this._tracerProvider.Shutdown();
+ }
+#pragma warning restore CA1816
+
+ [Fact]
+ public async Task TestFinally()
+ {
+ // Arrange
+ var traceEnricherHook = new TraceEnricherHook();
+ var evaluationContext = EvaluationContext.Empty;
+ var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String,
+ new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
+
+ // Act
+ var span = this._tracer.StartActiveSpan("my-span");
+ await traceEnricherHook.FinallyAsync(ctx,
+ new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"),
+ new Dictionary()).ConfigureAwait(true);
+ span.End();
+
+ this._tracerProvider.ForceFlush();
+
+ // Assert
+ Assert.Single(this._exportedItems);
+ var rootSpan = this._exportedItems.First();
+
+ Assert.Single(rootSpan.Events);
+ ActivityEvent ev = rootSpan.Events.First();
+ Assert.Equal("feature_flag.evaluation", ev.Name);
+
+ Assert.Contains(new KeyValuePair("feature_flag.key", "my-flag"), ev.Tags);
+ Assert.Contains(new KeyValuePair("feature_flag.result.variant", "default"), ev.Tags);
+ Assert.Contains(new KeyValuePair("feature_flag.provider.name", "my-provider"), ev.Tags);
+ Assert.Contains(new KeyValuePair("feature_flag.result.reason", "static"), ev.Tags);
+ Assert.Contains(new KeyValuePair("feature_flag.result.value", "foo"), ev.Tags);
+ }
+
+ [Fact]
+ public async Task TestFinally_NoSpan()
+ {
+ // Arrange
+ var traceEnricherHook = new TraceEnricherHook();
+ var evaluationContext = EvaluationContext.Empty;
+ var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String,
+ new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
+
+ // Act
+ await traceEnricherHook.FinallyAsync(ctx,
+ new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"),
+ new Dictionary()).ConfigureAwait(true);
+
+ this._tracerProvider.ForceFlush();
+
+ // Assert
+ Assert.Empty(this._exportedItems);
+ }
+}
diff --git a/test/OpenFeature.Tests/OpenFeature.Tests.csproj b/test/OpenFeature.Tests/OpenFeature.Tests.csproj
index a556655a4..8abb4891f 100644
--- a/test/OpenFeature.Tests/OpenFeature.Tests.csproj
+++ b/test/OpenFeature.Tests/OpenFeature.Tests.csproj
@@ -19,6 +19,8 @@
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
From 08ff43ce3426c8bb9f24446bdf62e56b10534c1f Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Fri, 27 Jun 2025 15:38:46 +0100
Subject: [PATCH 19/90] chore(deps): update github/codeql-action digest to
39edc49 (#504)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR contains the following updates:
| Package | Type | Update | Change |
|---|---|---|---|
|
[github/codeql-action](https://github.com/github/codeql-action)
| action | digest | `ce28f5b` -> `39edc49` |
---
### Configuration
π
**Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
π¦ **Automerge**: Enabled.
β» **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.
π **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/dotnet-sdk).
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/workflows/codeql-analysis.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 6e8d1e32b..c1785d201 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -42,7 +42,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3
+ uses: github/codeql-action/init@39edc492dbe16b1465b0cafca41432d857bdb31a # v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -56,7 +56,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3
+ uses: github/codeql-action/autobuild@39edc492dbe16b1465b0cafca41432d857bdb31a # v3
# βΉοΈ Command-line programs to run using the OS shell.
# π See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -69,4 +69,4 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3
+ uses: github/codeql-action/analyze@39edc492dbe16b1465b0cafca41432d857bdb31a # v3
From 69dc18611399ab5e573268c35d414a028c77f0ff Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Fri, 27 Jun 2025 15:39:36 +0100
Subject: [PATCH 20/90] chore(deps): update opentelemetry-dotnet monorepo to
1.12.0 (#506)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR contains the following updates:
| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [OpenTelemetry](https://opentelemetry.io/)
([source](https://github.com/open-telemetry/opentelemetry-dotnet))
| `1.11.2` -> `1.12.0` |
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
| [OpenTelemetry.Exporter.InMemory](https://opentelemetry.io/)
([source](https://github.com/open-telemetry/opentelemetry-dotnet))
| `1.11.2` -> `1.12.0` |
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
|
[OpenTelemetry.Exporter.OpenTelemetryProtocol](https://opentelemetry.io/)
([source](https://github.com/open-telemetry/opentelemetry-dotnet))
| `1.9.0` -> `1.12.0` |
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
| [OpenTelemetry.Extensions.Hosting](https://opentelemetry.io/)
([source](https://github.com/open-telemetry/opentelemetry-dotnet))
| `1.9.0` -> `1.12.0` |
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
---
### Release Notes
open-telemetry/opentelemetry-dotnet (OpenTelemetry)
###
[`v1.12.0`](https://github.com/open-telemetry/opentelemetry-dotnet/blob/HEAD/RELEASENOTES.md#1120)
Release details:
[1.12.0](https://github.com/open-telemetry/opentelemetry-dotnet/releases/tag/core-1.12.0)
- **Breaking Change**: `OpenTelemetry.Exporter.OpenTelemetryProtocol`
now
defaults to using OTLP/HTTP instead of OTLP/gRPC when targeting .NET
Framework
and .NET Standard. This change may cause telemetry export to fail unless
appropriate adjustments are made. Explicitly setting OTLP/gRPC may
result in a
`NotSupportedException` unless further configuration is applied. See
[#6209](https://github.com/open-telemetry/opentelemetry-dotnet/issues/6209)
for
full details and mitigation guidance.
[#6229](https://github.com/open-telemetry/opentelemetry-dotnet/pull/6229)
---
### Configuration
π
**Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
π¦ **Automerge**: Enabled.
β» **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.
π **Ignore**: Close this PR and you won't be reminded about these
updates again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/dotnet-sdk).
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
Directory.Packages.props | 4 ++--
samples/AspNetCore/Samples.AspNetCore.csproj | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 9b184d3fc..041434a47 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -30,8 +30,8 @@
-
-
+
+
diff --git a/samples/AspNetCore/Samples.AspNetCore.csproj b/samples/AspNetCore/Samples.AspNetCore.csproj
index 3dd554a78..cc5ffba4d 100644
--- a/samples/AspNetCore/Samples.AspNetCore.csproj
+++ b/samples/AspNetCore/Samples.AspNetCore.csproj
@@ -11,9 +11,9 @@
-
+
-
+
From 241d88024ff13ddd57f4e9c5719add95b5864043 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Sat, 28 Jun 2025 13:54:49 +0100
Subject: [PATCH 21/90] chore(deps): update dependency
opentelemetry.instrumentation.aspnetcore to 1.12.0 (#505)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR contains the following updates:
| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [OpenTelemetry.Instrumentation.AspNetCore](https://opentelemetry.io/)
([source](https://github.com/open-telemetry/opentelemetry-dotnet-contrib))
| `1.9.0` -> `1.12.0` |
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
[](https://docs.renovatebot.com/merge-confidence/)
|
---
### Configuration
π
**Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
π¦ **Automerge**: Enabled.
β» **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.
π **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/dotnet-sdk).
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
samples/AspNetCore/Samples.AspNetCore.csproj | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/samples/AspNetCore/Samples.AspNetCore.csproj b/samples/AspNetCore/Samples.AspNetCore.csproj
index cc5ffba4d..cd249ab3e 100644
--- a/samples/AspNetCore/Samples.AspNetCore.csproj
+++ b/samples/AspNetCore/Samples.AspNetCore.csproj
@@ -12,7 +12,7 @@
-
+
From f923cea14eb552098edb987950ad4bc82bbadab1 Mon Sep 17 00:00:00 2001
From: Kyle <38759683+kylejuliandev@users.noreply.github.com>
Date: Wed, 2 Jul 2025 21:15:59 +0100
Subject: [PATCH 22/90] docs: add XML comment on FeatureClient (#507)
## This PR
- Adds XML comment on FeatureClient class.
### Related Issues
Fixes #497
### Notes
### Follow-up Tasks
### How to test
Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com>
---
src/OpenFeature/OpenFeatureClient.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/OpenFeature/OpenFeatureClient.cs b/src/OpenFeature/OpenFeatureClient.cs
index 02acde07c..cc81f8838 100644
--- a/src/OpenFeature/OpenFeatureClient.cs
+++ b/src/OpenFeature/OpenFeatureClient.cs
@@ -10,7 +10,7 @@
namespace OpenFeature;
///
-///
+/// OpenFeature Client implementation for resolving feature flags and tracking user interactions.
///
public sealed partial class FeatureClient : IFeatureClient
{
From 9151dcdf2cecde9b4b01f06c73d149e0ad3bb539 Mon Sep 17 00:00:00 2001
From: Kyle <38759683+kylejuliandev@users.noreply.github.com>
Date: Thu, 3 Jul 2025 15:45:08 +0100
Subject: [PATCH 23/90] fix: ArgumentNullException when creating a client with
optional name (#508)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## This PR
- Adds a default placeholder name that gets generated when a Client is
generated. This is inspired from the Java SDK (see
[EventSource.java](https://github.com/open-feature/java-sdk/blob/0515ad54c4f71863373eb1b7f429393923b27d90/src/main/java/dev/openfeature/sdk/EventSupport.java#L40)).
The exception is thrown when an optional client name is specified when
retrieving a client from `Api.Instance`. When no name is specified we
use a generated one.
### Related Issues
Fixes #491
### Notes
### Follow-up Tasks
### How to test
---------
Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com>
Co-authored-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
---
src/OpenFeature/EventExecutor.cs | 36 ++++++++++++++-----
.../OpenFeatureClientTests.cs | 15 ++++++++
2 files changed, 42 insertions(+), 9 deletions(-)
diff --git a/src/OpenFeature/EventExecutor.cs b/src/OpenFeature/EventExecutor.cs
index db2b6fb10..f65f41a1a 100644
--- a/src/OpenFeature/EventExecutor.cs
+++ b/src/OpenFeature/EventExecutor.cs
@@ -14,6 +14,9 @@ internal sealed partial class EventExecutor : IAsyncDisposable
private readonly Dictionary _namedProviderReferences = [];
private readonly List _activeSubscriptions = [];
+ /// placeholder for anonymous clients
+ private static Guid _defaultClientName = Guid.NewGuid();
+
private readonly Dictionary> _apiHandlers = [];
private readonly Dictionary>> _clientHandlers = [];
@@ -58,25 +61,27 @@ internal void RemoveApiLevelHandler(ProviderEventTypes type, EventHandlerDelegat
internal void AddClientHandler(string client, ProviderEventTypes eventType, EventHandlerDelegate handler)
{
+ var clientName = GetClientName(client);
+
lock (this._lockObj)
{
// check if there is already a list of handlers for the given client and event type
- if (!this._clientHandlers.TryGetValue(client, out var registry))
+ if (!this._clientHandlers.TryGetValue(clientName, out var registry))
{
registry = [];
- this._clientHandlers[client] = registry;
+ this._clientHandlers[clientName] = registry;
}
- if (!this._clientHandlers[client].TryGetValue(eventType, out var eventHandlers))
+ if (!this._clientHandlers[clientName].TryGetValue(eventType, out var eventHandlers))
{
eventHandlers = [];
- this._clientHandlers[client][eventType] = eventHandlers;
+ this._clientHandlers[clientName][eventType] = eventHandlers;
}
- this._clientHandlers[client][eventType].Add(handler);
+ this._clientHandlers[clientName][eventType].Add(handler);
this.EmitOnRegistration(
- this._namedProviderReferences.TryGetValue(client, out var clientProviderReference)
+ this._namedProviderReferences.TryGetValue(clientName, out var clientProviderReference)
? clientProviderReference
: this._defaultProvider, eventType, handler);
}
@@ -84,9 +89,11 @@ internal void AddClientHandler(string client, ProviderEventTypes eventType, Even
internal void RemoveClientHandler(string client, ProviderEventTypes type, EventHandlerDelegate handler)
{
+ var clientName = GetClientName(client);
+
lock (this._lockObj)
{
- if (this._clientHandlers.TryGetValue(client, out var clientEventHandlers))
+ if (this._clientHandlers.TryGetValue(clientName, out var clientEventHandlers))
{
if (clientEventHandlers.TryGetValue(type, out var eventHandlers))
{
@@ -118,15 +125,18 @@ internal void RegisterClientFeatureProvider(string client, FeatureProvider? prov
{
return;
}
+
+ var clientName = GetClientName(client);
+
lock (this._lockObj)
{
FeatureProvider? oldProvider = null;
- if (this._namedProviderReferences.TryGetValue(client, out var foundOldProvider))
+ if (this._namedProviderReferences.TryGetValue(clientName, out var foundOldProvider))
{
oldProvider = foundOldProvider;
}
- this._namedProviderReferences[client] = provider;
+ this._namedProviderReferences[clientName] = provider;
this.StartListeningAndShutdownOld(provider, oldProvider);
}
@@ -303,6 +313,14 @@ private void ProcessDefaultProviderHandlers(Event e)
}
}
+ private static string GetClientName(string client)
+ {
+ if (string.IsNullOrWhiteSpace(client))
+ {
+ return _defaultClientName.ToString();
+ }
+ return client;
+ }
// map events to provider status as per spec: https://openfeature.dev/specification/sections/events/#requirement-535
private static void UpdateProviderStatus(FeatureProvider provider, ProviderEventPayload eventPayload)
diff --git a/test/OpenFeature.Tests/OpenFeatureClientTests.cs b/test/OpenFeature.Tests/OpenFeatureClientTests.cs
index cbecddc28..9ea3f0dc7 100644
--- a/test/OpenFeature.Tests/OpenFeatureClientTests.cs
+++ b/test/OpenFeature.Tests/OpenFeatureClientTests.cs
@@ -597,6 +597,21 @@ public async Task PassingABlankStringAsTrackingEventName_ThrowsArgumentException
Assert.Throws(() => client.Track(" \n "));
}
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task PassingBlankClientName_DoesNotThrowArgumentNullException(string? clientName)
+ {
+ var provider = new TestProvider();
+ await Api.Instance.SetProviderAsync(provider);
+ var client = Api.Instance.GetClient(clientName);
+
+ var ex = Record.Exception(() => client.AddHandler(ProviderEventTypes.ProviderReady, (args) => { }));
+
+ Assert.Null(ex);
+ }
+
public static TheoryData GenerateMergeEvaluationContextTestData()
{
const string key = "key";
From 075695f9c9dc69aeacf03275600ce599d76d2451 Mon Sep 17 00:00:00 2001
From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>
Date: Thu, 3 Jul 2025 11:04:18 -0400
Subject: [PATCH 24/90] chore(main): release 2.7.0 (#480)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
:robot: I have created a release *beep* *boop*
---
##
[2.7.0](https://github.com/open-feature/dotnet-sdk/compare/v2.6.0...v2.7.0)
(2025-07-03)
### π Bug Fixes
* Add generic to evaluation event builder
([#500](https://github.com/open-feature/dotnet-sdk/issues/500))
([68af649](https://github.com/open-feature/dotnet-sdk/commit/68af6493b09d29be5d4cdda9e6f792ee8667bf4f))
* ArgumentNullException when creating a client with optional name
([#508](https://github.com/open-feature/dotnet-sdk/issues/508))
([9151dcd](https://github.com/open-feature/dotnet-sdk/commit/9151dcdf2cecde9b4b01f06c73d149e0ad3bb539))
### β¨ New Features
* Move OTEL hooks to the SDK
([#338](https://github.com/open-feature/dotnet-sdk/issues/338))
([77f6e1b](https://github.com/open-feature/dotnet-sdk/commit/77f6e1bbb76973e078c1999ad0784c9edc9def96))
### π§Ή Chore
* **deps:** update actions/attest-build-provenance action to v2.4.0
([#495](https://github.com/open-feature/dotnet-sdk/issues/495))
([349c073](https://github.com/open-feature/dotnet-sdk/commit/349c07301d0ff97c759417344eef74a00b06edbc))
* **deps:** update actions/attest-sbom action to v2.4.0
([#496](https://github.com/open-feature/dotnet-sdk/issues/496))
([f7ca416](https://github.com/open-feature/dotnet-sdk/commit/f7ca4163e0ce549a015a7a27cb184fb76a199a04))
* **deps:** update dependency benchmarkdotnet to 0.15.0
([#481](https://github.com/open-feature/dotnet-sdk/issues/481))
([714425d](https://github.com/open-feature/dotnet-sdk/commit/714425d405a33231e85b1e62019fc678b2e883ef))
* **deps:** update dependency benchmarkdotnet to 0.15.2
([#494](https://github.com/open-feature/dotnet-sdk/issues/494))
([cab3807](https://github.com/open-feature/dotnet-sdk/commit/cab380727fe95b941384ae71f022626cdf23db53))
* **deps:** update dependency microsoft.net.test.sdk to 17.14.0
([#482](https://github.com/open-feature/dotnet-sdk/issues/482))
([520d383](https://github.com/open-feature/dotnet-sdk/commit/520d38305c6949c88b057f28e5dfe3305257e437))
* **deps:** update dependency microsoft.net.test.sdk to 17.14.1
([#485](https://github.com/open-feature/dotnet-sdk/issues/485))
([78bfdbf](https://github.com/open-feature/dotnet-sdk/commit/78bfdbf0850e2d5eb80cfbae3bfac8208f6c45b1))
* **deps:** update dependency opentelemetry.instrumentation.aspnetcore
to 1.12.0
([#505](https://github.com/open-feature/dotnet-sdk/issues/505))
([241d880](https://github.com/open-feature/dotnet-sdk/commit/241d88024ff13ddd57f4e9c5719add95b5864043))
* **deps:** update dependency reqnroll.xunit to 2.4.1
([#483](https://github.com/open-feature/dotnet-sdk/issues/483))
([99f7584](https://github.com/open-feature/dotnet-sdk/commit/99f7584c91882ba59412e2306167172470cd4677))
* **deps:** update dependency system.valuetuple to 4.6.1
([#503](https://github.com/open-feature/dotnet-sdk/issues/503))
([39f884d](https://github.com/open-feature/dotnet-sdk/commit/39f884df420f1a9346852159948c288e728672b8))
* **deps:** update github/codeql-action digest to 39edc49
([#504](https://github.com/open-feature/dotnet-sdk/issues/504))
([08ff43c](https://github.com/open-feature/dotnet-sdk/commit/08ff43ce3426c8bb9f24446bdf62e56b10534c1f))
* **deps:** update github/codeql-action digest to ce28f5b
([#492](https://github.com/open-feature/dotnet-sdk/issues/492))
([cce224f](https://github.com/open-feature/dotnet-sdk/commit/cce224fcf81aede5a626936a26546fe710fbcc30))
* **deps:** update github/codeql-action digest to fca7ace
([#486](https://github.com/open-feature/dotnet-sdk/issues/486))
([e18ad50](https://github.com/open-feature/dotnet-sdk/commit/e18ad50e3298cb0dd19143678c3ef0fdcb4484d9))
* **deps:** update opentelemetry-dotnet monorepo to 1.12.0
([#506](https://github.com/open-feature/dotnet-sdk/issues/506))
([69dc186](https://github.com/open-feature/dotnet-sdk/commit/69dc18611399ab5e573268c35d414a028c77f0ff))
* **deps:** update spec digest to 1965aae
([#499](https://github.com/open-feature/dotnet-sdk/issues/499))
([2e3dffd](https://github.com/open-feature/dotnet-sdk/commit/2e3dffd0ebbba4a9d95763e2ce9f3e2ac051a317))
* **deps:** update spec digest to 42340bb
([#493](https://github.com/open-feature/dotnet-sdk/issues/493))
([909c51d](https://github.com/open-feature/dotnet-sdk/commit/909c51d4e25917d6a9a5ae9bb04cfe48665186ba))
* **deps:** update spec digest to c37ac17
([#502](https://github.com/open-feature/dotnet-sdk/issues/502))
([38f63fc](https://github.com/open-feature/dotnet-sdk/commit/38f63fceb5516cd474fd0e867aa25eae252cf2c1))
* **deps:** update spec digest to f014806
([#479](https://github.com/open-feature/dotnet-sdk/issues/479))
([dbe8b08](https://github.com/open-feature/dotnet-sdk/commit/dbe8b082c28739a1b81b74b29ed28fbccc94f7bc))
* fix sample build warning
([#498](https://github.com/open-feature/dotnet-sdk/issues/498))
([08a00e1](https://github.com/open-feature/dotnet-sdk/commit/08a00e1d35834635ca296fe8a13507001ad25c57))
### π Documentation
* add XML comment on FeatureClient
([#507](https://github.com/open-feature/dotnet-sdk/issues/507))
([f923cea](https://github.com/open-feature/dotnet-sdk/commit/f923cea14eb552098edb987950ad4bc82bbadab1))
* updated contributing link on the README
([8435bf7](https://github.com/open-feature/dotnet-sdk/commit/8435bf7d8131307e627e59453008124ac4c71906))
---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>
---
.release-please-manifest.json | 2 +-
CHANGELOG.md | 41 +++++++++++++++++++++++++++++++++++
README.md | 4 ++--
build/Common.prod.props | 2 +-
version.txt | 2 +-
5 files changed, 46 insertions(+), 5 deletions(-)
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 69e82f12f..6ed9c8012 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "2.6.0"
+ ".": "2.7.0"
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e06ef65bb..7a176c613 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,46 @@
# Changelog
+## [2.7.0](https://github.com/open-feature/dotnet-sdk/compare/v2.6.0...v2.7.0) (2025-07-03)
+
+
+### π Bug Fixes
+
+* Add generic to evaluation event builder ([#500](https://github.com/open-feature/dotnet-sdk/issues/500)) ([68af649](https://github.com/open-feature/dotnet-sdk/commit/68af6493b09d29be5d4cdda9e6f792ee8667bf4f))
+* ArgumentNullException when creating a client with optional name ([#508](https://github.com/open-feature/dotnet-sdk/issues/508)) ([9151dcd](https://github.com/open-feature/dotnet-sdk/commit/9151dcdf2cecde9b4b01f06c73d149e0ad3bb539))
+
+
+### β¨ New Features
+
+* Move OTEL hooks to the SDK ([#338](https://github.com/open-feature/dotnet-sdk/issues/338)) ([77f6e1b](https://github.com/open-feature/dotnet-sdk/commit/77f6e1bbb76973e078c1999ad0784c9edc9def96))
+
+
+### π§Ή Chore
+
+* **deps:** update actions/attest-build-provenance action to v2.4.0 ([#495](https://github.com/open-feature/dotnet-sdk/issues/495)) ([349c073](https://github.com/open-feature/dotnet-sdk/commit/349c07301d0ff97c759417344eef74a00b06edbc))
+* **deps:** update actions/attest-sbom action to v2.4.0 ([#496](https://github.com/open-feature/dotnet-sdk/issues/496)) ([f7ca416](https://github.com/open-feature/dotnet-sdk/commit/f7ca4163e0ce549a015a7a27cb184fb76a199a04))
+* **deps:** update dependency benchmarkdotnet to 0.15.0 ([#481](https://github.com/open-feature/dotnet-sdk/issues/481)) ([714425d](https://github.com/open-feature/dotnet-sdk/commit/714425d405a33231e85b1e62019fc678b2e883ef))
+* **deps:** update dependency benchmarkdotnet to 0.15.2 ([#494](https://github.com/open-feature/dotnet-sdk/issues/494)) ([cab3807](https://github.com/open-feature/dotnet-sdk/commit/cab380727fe95b941384ae71f022626cdf23db53))
+* **deps:** update dependency microsoft.net.test.sdk to 17.14.0 ([#482](https://github.com/open-feature/dotnet-sdk/issues/482)) ([520d383](https://github.com/open-feature/dotnet-sdk/commit/520d38305c6949c88b057f28e5dfe3305257e437))
+* **deps:** update dependency microsoft.net.test.sdk to 17.14.1 ([#485](https://github.com/open-feature/dotnet-sdk/issues/485)) ([78bfdbf](https://github.com/open-feature/dotnet-sdk/commit/78bfdbf0850e2d5eb80cfbae3bfac8208f6c45b1))
+* **deps:** update dependency opentelemetry.instrumentation.aspnetcore to 1.12.0 ([#505](https://github.com/open-feature/dotnet-sdk/issues/505)) ([241d880](https://github.com/open-feature/dotnet-sdk/commit/241d88024ff13ddd57f4e9c5719add95b5864043))
+* **deps:** update dependency reqnroll.xunit to 2.4.1 ([#483](https://github.com/open-feature/dotnet-sdk/issues/483)) ([99f7584](https://github.com/open-feature/dotnet-sdk/commit/99f7584c91882ba59412e2306167172470cd4677))
+* **deps:** update dependency system.valuetuple to 4.6.1 ([#503](https://github.com/open-feature/dotnet-sdk/issues/503)) ([39f884d](https://github.com/open-feature/dotnet-sdk/commit/39f884df420f1a9346852159948c288e728672b8))
+* **deps:** update github/codeql-action digest to 39edc49 ([#504](https://github.com/open-feature/dotnet-sdk/issues/504)) ([08ff43c](https://github.com/open-feature/dotnet-sdk/commit/08ff43ce3426c8bb9f24446bdf62e56b10534c1f))
+* **deps:** update github/codeql-action digest to ce28f5b ([#492](https://github.com/open-feature/dotnet-sdk/issues/492)) ([cce224f](https://github.com/open-feature/dotnet-sdk/commit/cce224fcf81aede5a626936a26546fe710fbcc30))
+* **deps:** update github/codeql-action digest to fca7ace ([#486](https://github.com/open-feature/dotnet-sdk/issues/486)) ([e18ad50](https://github.com/open-feature/dotnet-sdk/commit/e18ad50e3298cb0dd19143678c3ef0fdcb4484d9))
+* **deps:** update opentelemetry-dotnet monorepo to 1.12.0 ([#506](https://github.com/open-feature/dotnet-sdk/issues/506)) ([69dc186](https://github.com/open-feature/dotnet-sdk/commit/69dc18611399ab5e573268c35d414a028c77f0ff))
+* **deps:** update spec digest to 1965aae ([#499](https://github.com/open-feature/dotnet-sdk/issues/499)) ([2e3dffd](https://github.com/open-feature/dotnet-sdk/commit/2e3dffd0ebbba4a9d95763e2ce9f3e2ac051a317))
+* **deps:** update spec digest to 42340bb ([#493](https://github.com/open-feature/dotnet-sdk/issues/493)) ([909c51d](https://github.com/open-feature/dotnet-sdk/commit/909c51d4e25917d6a9a5ae9bb04cfe48665186ba))
+* **deps:** update spec digest to c37ac17 ([#502](https://github.com/open-feature/dotnet-sdk/issues/502)) ([38f63fc](https://github.com/open-feature/dotnet-sdk/commit/38f63fceb5516cd474fd0e867aa25eae252cf2c1))
+* **deps:** update spec digest to f014806 ([#479](https://github.com/open-feature/dotnet-sdk/issues/479)) ([dbe8b08](https://github.com/open-feature/dotnet-sdk/commit/dbe8b082c28739a1b81b74b29ed28fbccc94f7bc))
+* fix sample build warning ([#498](https://github.com/open-feature/dotnet-sdk/issues/498)) ([08a00e1](https://github.com/open-feature/dotnet-sdk/commit/08a00e1d35834635ca296fe8a13507001ad25c57))
+
+
+### π Documentation
+
+* add XML comment on FeatureClient ([#507](https://github.com/open-feature/dotnet-sdk/issues/507)) ([f923cea](https://github.com/open-feature/dotnet-sdk/commit/f923cea14eb552098edb987950ad4bc82bbadab1))
+* updated contributing link on the README ([8435bf7](https://github.com/open-feature/dotnet-sdk/commit/8435bf7d8131307e627e59453008124ac4c71906))
+
## [2.6.0](https://github.com/open-feature/dotnet-sdk/compare/v2.5.0...v2.6.0) (2025-05-23)
diff --git a/README.md b/README.md
index e87f2584c..66a3d620c 100644
--- a/README.md
+++ b/README.md
@@ -10,8 +10,8 @@
[](https://github.com/open-feature/spec/releases/tag/v0.8.0)
[
-
-](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.6.0)
+
+](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.7.0)
[](https://cloud-native.slack.com/archives/C0344AANLA1)
[](https://codecov.io/gh/open-feature/dotnet-sdk)
diff --git a/build/Common.prod.props b/build/Common.prod.props
index 55264b3c0..0a05126f5 100644
--- a/build/Common.prod.props
+++ b/build/Common.prod.props
@@ -9,7 +9,7 @@
- 2.6.0
+ 2.7.0
git
https://github.com/open-feature/dotnet-sdk
OpenFeature is an open standard for feature flag management, created to support a robust feature flag ecosystem using cloud native technologies. OpenFeature will provide a unified API and SDK, and a developer-first, cloud-native implementation, with extensibility for open source and commercial offerings.
diff --git a/version.txt b/version.txt
index e70b4523a..24ba9a38d 100644
--- a/version.txt
+++ b/version.txt
@@ -1 +1 @@
-2.6.0
+2.7.0
From fa1ad7ef5471c39624cbb0ee28d86cd7488efc58 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Silva?=
<2493377+askpt@users.noreply.github.com>
Date: Thu, 3 Jul 2025 17:48:23 +0100
Subject: [PATCH 25/90] ci: update renovate configuration to include package
rules for security updates (#510)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
## This PR
This pull request updates the `renovate.json` configuration to enhance
dependency management rules. The most notable change introduces a new
`packageRules` section to handle security updates differently than other
dependencies.
Dependency management improvements:
*
[`renovate.json`](diffhunk://#diff-7b5c8955fc544a11b4b74eddb4115f9cc51c9cf162dbffa60d37eeed82a55a57L7-R17):
Added a `packageRules` section to create pull requests for security
updates without requiring dashboard approval. These updates will not be
automatically merged, ensuring manual review.
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
---
renovate.json | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/renovate.json b/renovate.json
index 151c402c1..11a40704b 100644
--- a/renovate.json
+++ b/renovate.json
@@ -4,5 +4,15 @@
"github>open-feature/community-tooling"
],
"dependencyDashboardApproval": true,
- "recreateWhen": "never"
-}
+ "recreateWhen": "never",
+ "packageRules": [
+ {
+ "description": "Create PRs for security updates without dashboard approval",
+ "matchCategories": [
+ "security"
+ ],
+ "dependencyDashboardApproval": false,
+ "automerge": false
+ }
+ ]
+}
\ No newline at end of file
From 929fa7497197214d385eeaa40aba008932d00896 Mon Sep 17 00:00:00 2001
From: Todd Baert
Date: Fri, 4 Jul 2025 11:37:11 -0400
Subject: [PATCH 26/90] chore: remove redundant rule (now in parent)
Signed-off-by: Todd Baert
---
renovate.json | 14 ++------------
1 file changed, 2 insertions(+), 12 deletions(-)
diff --git a/renovate.json b/renovate.json
index 11a40704b..151c402c1 100644
--- a/renovate.json
+++ b/renovate.json
@@ -4,15 +4,5 @@
"github>open-feature/community-tooling"
],
"dependencyDashboardApproval": true,
- "recreateWhen": "never",
- "packageRules": [
- {
- "description": "Create PRs for security updates without dashboard approval",
- "matchCategories": [
- "security"
- ],
- "dependencyDashboardApproval": false,
- "automerge": false
- }
- ]
-}
\ No newline at end of file
+ "recreateWhen": "never"
+}
From 40bec0d51b6fa782a8b6d90a3d84463f9fb73c1b Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Fri, 11 Jul 2025 06:46:38 +0100
Subject: [PATCH 27/90] chore(deps): update github/codeql-action digest to
181d5ee (#520)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR contains the following updates:
| Package | Type | Update | Change |
|---|---|---|---|
|
[github/codeql-action](https://github.com/github/codeql-action)
| action | digest | `39edc49` -> `181d5ee` |
---
### Configuration
π
**Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
π¦ **Automerge**: Enabled.
β» **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.
π **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/dotnet-sdk).
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/workflows/codeql-analysis.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index c1785d201..9ad67c477 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -42,7 +42,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@39edc492dbe16b1465b0cafca41432d857bdb31a # v3
+ uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -56,7 +56,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@39edc492dbe16b1465b0cafca41432d857bdb31a # v3
+ uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3
# βΉοΈ Command-line programs to run using the OS shell.
# π See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -69,4 +69,4 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@39edc492dbe16b1465b0cafca41432d857bdb31a # v3
+ uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3
From fbc2645efd649c0c37bd1a1cf473fbd98d920948 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Fri, 11 Jul 2025 06:46:54 +0100
Subject: [PATCH 28/90] chore(deps): update spec digest to 224b26e (#521)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR contains the following updates:
| Package | Update | Change |
|---|---|---|
| spec | digest | `c37ac17` -> `224b26e` |
---
### Configuration
π
**Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).
π¦ **Automerge**: Enabled.
β» **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.
π **Ignore**: Close this PR and you won't be reminded about this update
again.
---
- [ ] If you want to rebase/retry this PR, check
this box
---
This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/open-feature/dotnet-sdk).
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
spec | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/spec b/spec
index c37ac17c8..224b26e44 160000
--- a/spec
+++ b/spec
@@ -1 +1 @@
-Subproject commit c37ac17c80410de1a2c6c6f061386001c838cb40
+Subproject commit 224b26e44ebfe21d1110d5b64d740c8a3055d398
From f6ae8ddfa08f4a8ab54748077e7844a72ad7b4b6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Silva?=
<2493377+askpt@users.noreply.github.com>
Date: Fri, 11 Jul 2025 20:31:55 +0100
Subject: [PATCH 29/90] ci: add caching for NuGet packages in CI workflows
(#522)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
## This PR
This pull request enhances the CI/CD workflows by adding caching for
NuGet packages across multiple workflow files. This change aims to
improve build performance by avoiding repeated downloads of
dependencies.
### Workflow Improvements:
*
[`.github/workflows/ci.yml`](diffhunk://#diff-b803fcb7f17ed9235f1e5cb1fcd2f5d3b2838429d4368ae4c57ce4436577f03fR39-R46):
Added a step to cache NuGet packages in both the main CI job and another
job within the same file. This uses the `actions/cache` action to store
packages in `~/.nuget/packages` and leverages file hashing for cache
keys.
[[1]](diffhunk://#diff-b803fcb7f17ed9235f1e5cb1fcd2f5d3b2838429d4368ae4c57ce4436577f03fR39-R46)
[[2]](diffhunk://#diff-b803fcb7f17ed9235f1e5cb1fcd2f5d3b2838429d4368ae4c57ce4436577f03fR82-R89)
*
[`.github/workflows/code-coverage.yml`](diffhunk://#diff-49708f979e226a1e7bd7a68d71b2e91aae8114dd3e9254d9830cd3b4d62d4303R37-R44):
Introduced NuGet package caching to the code coverage workflow,
improving efficiency during the `dotnet test` step.
*
[`.github/workflows/e2e.yml`](diffhunk://#diff-3e103440521ada06efd263ae09b259e5507e4b8f7408308dc227621ad9efa31eR32-R39):
Added caching for NuGet packages in the end-to-end testing workflow,
reducing setup time for dependencies.
*
[`.github/workflows/release.yml`](diffhunk://#diff-87db21a973eed4fef5f32b267aa60fcee5cbdf03c67fafdc2a9b553bb0b15f34R54-R61):
Implemented NuGet package caching in the release workflow to optimize
dependency installation during the `dotnet restore` step.
### Notes
This is similar to
https://github.com/open-feature/dotnet-sdk-contrib/pull/453
---------
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
---
.github/workflows/ci.yml | 16 ++++++++++++++++
.github/workflows/code-coverage.yml | 8 ++++++++
.github/workflows/e2e.yml | 8 ++++++++
.github/workflows/release.yml | 8 ++++++++
4 files changed, 40 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bb1c72273..c5b5edb77 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -36,6 +36,14 @@ jobs:
global-json-file: global.json
source-url: https://nuget.pkg.github.com/open-feature/index.json
+ - name: Cache NuGet packages
+ uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
+
- name: Restore
run: dotnet restore
@@ -71,6 +79,14 @@ jobs:
global-json-file: global.json
source-url: https://nuget.pkg.github.com/open-feature/index.json
+ - name: Cache NuGet packages
+ uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
+
- name: Restore
run: dotnet restore
diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml
index fc7c37f5c..4a8e7d05f 100644
--- a/.github/workflows/code-coverage.yml
+++ b/.github/workflows/code-coverage.yml
@@ -34,6 +34,14 @@ jobs:
global-json-file: global.json
source-url: https://nuget.pkg.github.com/open-feature/index.json
+ - name: Cache NuGet packages
+ uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
+
- name: Run Test
run: dotnet test --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=opencover
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index ae0ca8391..e1e577385 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -29,6 +29,14 @@ jobs:
global-json-file: global.json
source-url: https://nuget.pkg.github.com/open-feature/index.json
+ - name: Cache NuGet packages
+ uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
+
- name: Initialize Tests
run: |
git submodule update --init --recursive
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 725957a75..727f5c9b7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -51,6 +51,14 @@ jobs:
global-json-file: global.json
source-url: https://nuget.pkg.github.com/open-feature/index.json
+ - name: Cache NuGet packages
+ uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
+
- name: Install dependencies
run: dotnet restore
From 2e7007277e19a0fbc4c4c3944d24eea1608712e6 Mon Sep 17 00:00:00 2001
From: Kyle <38759683+kylejuliandev@users.noreply.github.com>
Date: Fri, 11 Jul 2025 20:32:08 +0100
Subject: [PATCH 30/90] feat: Add Track method to IFeatureClient (#519)
## This PR
Adds Track method to `IFeatureClient` so developers dependent on this
abstraction can track user interactions.
### Related Issues
Fixes #518
### Notes
### Follow-up Tasks
### How to test
Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com>
---
src/OpenFeature/IFeatureClient.cs | 13 +++++++++++--
src/OpenFeature/OpenFeatureClient.cs | 8 +-------
2 files changed, 12 insertions(+), 9 deletions(-)
diff --git a/src/OpenFeature/IFeatureClient.cs b/src/OpenFeature/IFeatureClient.cs
index acf38804f..5ea19458a 100644
--- a/src/OpenFeature/IFeatureClient.cs
+++ b/src/OpenFeature/IFeatureClient.cs
@@ -55,8 +55,8 @@ public interface IFeatureClient : IEventBus
/// Returns the current status of the associated provider.
///
///
- ProviderStatus ProviderStatus { get; }
-
+ ProviderStatus ProviderStatus { get; }
+
///
/// Resolves a boolean feature flag
///
@@ -166,4 +166,13 @@ public interface IFeatureClient : IEventBus
/// The .
/// Resolved flag details
Task> GetObjectDetailsAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Use this method to track user interactions and the application state.
+ ///
+ /// The name associated with this tracking event
+ /// The evaluation context used in the evaluation of the flag (optional)
+ /// Data pertinent to the tracking event (Optional)
+ /// When trackingEventName is null or empty
+ void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default);
}
diff --git a/src/OpenFeature/OpenFeatureClient.cs b/src/OpenFeature/OpenFeatureClient.cs
index cc81f8838..c99f4f5c9 100644
--- a/src/OpenFeature/OpenFeatureClient.cs
+++ b/src/OpenFeature/OpenFeatureClient.cs
@@ -302,13 +302,7 @@ await hookRunner.TriggerFinallyHooksAsync(evaluation, options?.HookHints, cancel
return evaluation;
}
- ///
- /// Use this method to track user interactions and the application state.
- ///
- /// The name associated with this tracking event
- /// The evaluation context used in the evaluation of the flag (optional)
- /// Data pertinent to the tracking event (Optional)
- /// When trackingEventName is null or empty
+ ///
public void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default)
{
if (string.IsNullOrWhiteSpace(trackingEventName))
From 883f4f3c8b553dc01b5accdbae2782ca7805e8ed Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Silva?=
<2493377+askpt@users.noreply.github.com>
Date: Mon, 14 Jul 2025 18:54:41 +0100
Subject: [PATCH 31/90] chore: Add comparison to Value (#523)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
## This PR
This pull request introduces enhancements to the `Value` class in the
`OpenFeature.Model` namespace, ensuring better equality handling, and
updates dependencies to include `Microsoft.Bcl.HashCode`. The most
significant changes include implementing equality comparison for
`Value`, adding hash code generation, and updating project files to
include the new dependency.
### Enhancements to `Value` class:
*
[`src/OpenFeature/Model/Value.cs`](diffhunk://#diff-336aacd3c42458899187108a2064648ae21e439b2b11e6ca7f25b7b7fef00609L9-R9):
The `Value` class now implements `IEquatable` and includes
methods for equality comparison (`Equals`, `==`, `!=`), hash code
generation (`GetHashCode`), and internal helpers for comparing complex
types like structures and lists. This ensures more robust and consistent
equality checks.
[[1]](diffhunk://#diff-336aacd3c42458899187108a2064648ae21e439b2b11e6ca7f25b7b7fef00609L9-R9)
[[2]](diffhunk://#diff-336aacd3c42458899187108a2064648ae21e439b2b11e6ca7f25b7b7fef00609R187-R378)
### Dependency updates:
*
[`Directory.Packages.props`](diffhunk://#diff-5baf5f9e448ad54ab25a091adee0da05d4d228481c9200518fcb1b53a65d4156L13-R25):
Added `Microsoft.Bcl.HashCode` as a dependency for hash code generation.
Other package references were reformatted for consistency.
*
[`src/OpenFeature/OpenFeature.csproj`](diffhunk://#diff-711ea17cbdebe419375c7684c8c39a1423d2bebcf8976ddd7bdd78deaab65b21R11):
Included `Microsoft.Bcl.HashCode` for specific target frameworks
(`net462` and `netstandard2.0`).
### Notes
This implementation is necessary for the comparison in the
MultiProvider. See:
https://github.com/open-feature/dotnet-sdk/pull/488#discussion_r2201848459
---------
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
---
Directory.Packages.props | 18 +-
src/OpenFeature/Model/Value.cs | 194 +++++++++-
src/OpenFeature/OpenFeature.csproj | 1 +
test/OpenFeature.Tests/ValueTests.cs | 557 +++++++++++++++++++++++++++
4 files changed, 763 insertions(+), 7 deletions(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 041434a47..194441b93 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -10,13 +10,19 @@
-
-
-
-
+
+
+
+
+
-
+
@@ -42,4 +48,4 @@
-
+
\ No newline at end of file
diff --git a/src/OpenFeature/Model/Value.cs b/src/OpenFeature/Model/Value.cs
index f09a24667..41b15246b 100644
--- a/src/OpenFeature/Model/Value.cs
+++ b/src/OpenFeature/Model/Value.cs
@@ -6,7 +6,7 @@ namespace OpenFeature.Model;
/// Values serve as a return type for provider objects. Providers may deal in JSON, protobuf, XML or some other data-interchange format.
/// This intermediate representation provides a good medium of exchange.
///
-public sealed class Value
+public sealed class Value : IEquatable
{
private readonly object? _innerValue;
@@ -184,4 +184,196 @@ public Value(Object value)
///
/// Value as DateTime
public DateTime? AsDateTime => this.IsDateTime ? (DateTime?)this._innerValue : null;
+
+ ///
+ /// Determines whether the specified is equal to the current .
+ ///
+ /// The to compare with the current .
+ /// true if the specified is equal to the current ; otherwise, false.
+ public bool Equals(Value? other)
+ {
+ if (other is null) return false;
+ if (ReferenceEquals(this, other)) return true;
+
+ // Both are null
+ if (this.IsNull && other.IsNull) return true;
+
+ // One is null, the other is not
+ if (this.IsNull != other.IsNull) return false;
+
+ // Different types
+ if (this.GetValueType() != other.GetValueType()) return false;
+
+ // Compare based on type
+ return this.GetValueType() switch
+ {
+ ValueType.Boolean => this.AsBoolean == other.AsBoolean,
+ ValueType.Number => this.AsDouble == other.AsDouble,
+ ValueType.String => this.AsString == other.AsString,
+ ValueType.DateTime => this.AsDateTime == other.AsDateTime,
+ ValueType.Structure => this.StructureEquals(other),
+ ValueType.List => this.ListEquals(other),
+ _ => false
+ };
+ }
+
+ ///
+ /// Determines whether the specified object is equal to the current .
+ ///
+ /// The object to compare with the current .
+ /// true if the specified object is equal to the current ; otherwise, false.
+ public override bool Equals(object? obj) => this.Equals(obj as Value);
+
+ ///
+ /// Returns the hash code for this .
+ ///
+ /// A hash code for the current .
+ public override int GetHashCode()
+ {
+ if (this.IsNull) return 0;
+
+ return this.GetValueType() switch
+ {
+ ValueType.Boolean => this.AsBoolean!.GetHashCode(),
+ ValueType.Number => this.AsDouble!.GetHashCode(),
+ ValueType.String => this.AsString!.GetHashCode(),
+ ValueType.DateTime => this.AsDateTime!.GetHashCode(),
+ ValueType.Structure => this.GetStructureHashCode(),
+ ValueType.List => this.GetListHashCode(),
+ _ => 0
+ };
+ }
+
+ ///
+ /// Determines whether two instances are equal.
+ ///
+ /// The first to compare.
+ /// The second to compare.
+ /// true if the values are equal; otherwise, false.
+ public static bool operator ==(Value? left, Value? right)
+ {
+ if (left is null && right is null) return true;
+ if (left is null || right is null) return false;
+ return left.Equals(right);
+ }
+
+ ///
+ /// Determines whether two instances are not equal.
+ ///
+ /// The first to compare.
+ /// The second to compare.
+ /// true if the values are not equal; otherwise, false.
+ public static bool operator !=(Value? left, Value? right) => !(left == right);
+
+ ///
+ /// Gets the type of the current value.
+ ///
+ /// The of the current value.
+ private ValueType GetValueType()
+ {
+ if (this.IsNull) return ValueType.Null;
+ if (this.IsBoolean) return ValueType.Boolean;
+ if (this.IsNumber) return ValueType.Number;
+ if (this.IsString) return ValueType.String;
+ if (this.IsDateTime) return ValueType.DateTime;
+ if (this.IsStructure) return ValueType.Structure;
+ if (this.IsList) return ValueType.List;
+ return ValueType.Unknown;
+ }
+
+ ///
+ /// Compares two Structure values for equality.
+ ///
+ /// The other to compare.
+ /// true if the structures are equal; otherwise, false.
+ private bool StructureEquals(Value other)
+ {
+ var thisStructure = this.AsStructure!;
+ var otherStructure = other.AsStructure!;
+
+ if (thisStructure.Count != otherStructure.Count) return false;
+
+ foreach (var kvp in thisStructure)
+ {
+ if (!otherStructure.TryGetValue(kvp.Key, out var otherValue) || !kvp.Value.Equals(otherValue))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Compares two List values for equality.
+ ///
+ /// The other to compare.
+ /// true if the lists are equal; otherwise, false.
+ private bool ListEquals(Value other)
+ {
+ var thisList = this.AsList!;
+ var otherList = other.AsList!;
+
+ if (thisList.Count != otherList.Count) return false;
+
+ for (int i = 0; i < thisList.Count; i++)
+ {
+ if (!thisList[i].Equals(otherList[i]))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Gets the hash code for a Structure value.
+ ///
+ /// The hash code of the structure.
+ private int GetStructureHashCode()
+ {
+ var structure = this.AsStructure!;
+ var hash = new HashCode();
+
+ foreach (var kvp in structure)
+ {
+ hash.Add(kvp.Key);
+ hash.Add(kvp.Value);
+ }
+
+ return hash.ToHashCode();
+ }
+
+ ///
+ /// Gets the hash code for a List value.
+ ///
+ /// The hash code of the list.
+ private int GetListHashCode()
+ {
+ var list = this.AsList!;
+ var hash = new HashCode();
+
+ foreach (var item in list)
+ {
+ hash.Add(item);
+ }
+
+ return hash.ToHashCode();
+ }
+
+ ///
+ /// Represents the different types that a can contain.
+ ///
+ private enum ValueType
+ {
+ Null,
+ Boolean,
+ Number,
+ String,
+ DateTime,
+ Structure,
+ List,
+ Unknown
+ }
}
diff --git a/src/OpenFeature/OpenFeature.csproj b/src/OpenFeature/OpenFeature.csproj
index 3d81a99eb..732c92f3b 100644
--- a/src/OpenFeature/OpenFeature.csproj
+++ b/src/OpenFeature/OpenFeature.csproj
@@ -8,6 +8,7 @@
+
diff --git a/test/OpenFeature.Tests/ValueTests.cs b/test/OpenFeature.Tests/ValueTests.cs
index da76f29ad..9f94b5eaf 100644
--- a/test/OpenFeature.Tests/ValueTests.cs
+++ b/test/OpenFeature.Tests/ValueTests.cs
@@ -230,4 +230,561 @@ public void AsDateTime_WhenCalledWithNonDateTimeInnerValue_ReturnsNull()
// Assert
Assert.Null(actualValue);
}
+
+ #region Equality Tests
+
+ [Fact]
+ public void Equals_WithNullValue_ReturnsFalse()
+ {
+ // Arrange
+ var value = new Value("test");
+
+ // Act & Assert
+ Assert.False(value.Equals(null));
+ }
+
+ [Fact]
+ public void Equals_WithSameReference_ReturnsTrue()
+ {
+ // Arrange
+ var value = new Value("test");
+
+ // Act & Assert
+ Assert.True(value.Equals(value));
+ }
+
+ [Fact]
+ public void Equals_WithBothNull_ReturnsTrue()
+ {
+ // Arrange
+ var value1 = new Value();
+ var value2 = new Value();
+
+ // Act & Assert
+ Assert.True(value1.Equals(value2));
+ }
+
+ [Fact]
+ public void Equals_WithOneNullOneNotNull_ReturnsFalse()
+ {
+ // Arrange
+ var nullValue = new Value();
+ var stringValue = new Value("test");
+
+ // Act & Assert
+ Assert.False(nullValue.Equals(stringValue));
+ Assert.False(stringValue.Equals(nullValue));
+ }
+
+ [Fact]
+ public void Equals_WithDifferentTypes_ReturnsFalse()
+ {
+ // Arrange
+ var stringValue = new Value("test");
+ var intValue = new Value(42);
+ var boolValue = new Value(true);
+
+ // Act & Assert
+ Assert.False(stringValue.Equals(intValue));
+ Assert.False(stringValue.Equals(boolValue));
+ Assert.False(intValue.Equals(boolValue));
+ }
+
+ [Fact]
+ public void Equals_WithSameStringValues_ReturnsTrue()
+ {
+ // Arrange
+ var value1 = new Value("test");
+ var value2 = new Value("test");
+
+ // Act & Assert
+ Assert.True(value1.Equals(value2));
+ }
+
+ [Fact]
+ public void Equals_WithDifferentStringValues_ReturnsFalse()
+ {
+ // Arrange
+ var value1 = new Value("test1");
+ var value2 = new Value("test2");
+
+ // Act & Assert
+ Assert.False(value1.Equals(value2));
+ }
+
+ [Fact]
+ public void Equals_WithSameBooleanValues_ReturnsTrue()
+ {
+ // Arrange
+ var value1 = new Value(true);
+ var value2 = new Value(true);
+ var value3 = new Value(false);
+ var value4 = new Value(false);
+
+ // Act & Assert
+ Assert.True(value1.Equals(value2));
+ Assert.True(value3.Equals(value4));
+ }
+
+ [Fact]
+ public void Equals_WithDifferentBooleanValues_ReturnsFalse()
+ {
+ // Arrange
+ var value1 = new Value(true);
+ var value2 = new Value(false);
+
+ // Act & Assert
+ Assert.False(value1.Equals(value2));
+ }
+
+ [Fact]
+ public void Equals_WithSameNumberValues_ReturnsTrue()
+ {
+ // Arrange
+ var value1 = new Value(42.5);
+ var value2 = new Value(42.5);
+ var intValue1 = new Value(42);
+ var intValue2 = new Value(42);
+
+ // Act & Assert
+ Assert.True(value1.Equals(value2));
+ Assert.True(intValue1.Equals(intValue2));
+ }
+
+ [Fact]
+ public void Equals_WithDifferentNumberValues_ReturnsFalse()
+ {
+ // Arrange
+ var value1 = new Value(42.5);
+ var value2 = new Value(42.6);
+
+ // Act & Assert
+ Assert.False(value1.Equals(value2));
+ }
+
+ [Fact]
+ public void Equals_WithSameDateTimeValues_ReturnsTrue()
+ {
+ // Arrange
+ var dateTime = DateTime.Now;
+ var value1 = new Value(dateTime);
+ var value2 = new Value(dateTime);
+
+ // Act & Assert
+ Assert.True(value1.Equals(value2));
+ }
+
+ [Fact]
+ public void Equals_WithDifferentDateTimeValues_ReturnsFalse()
+ {
+ // Arrange
+ var value1 = new Value(DateTime.Now);
+ var value2 = new Value(DateTime.Now.AddDays(1));
+
+ // Act & Assert
+ Assert.False(value1.Equals(value2));
+ }
+
+ [Fact]
+ public void Equals_WithSameStructureValues_ReturnsTrue()
+ {
+ // Arrange
+ var structure1 = Structure.Builder()
+ .Set("key1", new Value("value1"))
+ .Set("key2", new Value(42))
+ .Build();
+ var structure2 = Structure.Builder()
+ .Set("key1", new Value("value1"))
+ .Set("key2", new Value(42))
+ .Build();
+ var value1 = new Value(structure1);
+ var value2 = new Value(structure2);
+
+ // Act & Assert
+ Assert.True(value1.Equals(value2));
+ }
+
+ [Fact]
+ public void Equals_WithDifferentStructureValues_ReturnsFalse()
+ {
+ // Arrange
+ var structure1 = Structure.Builder()
+ .Set("key1", new Value("value1"))
+ .Build();
+ var structure2 = Structure.Builder()
+ .Set("key1", new Value("value2"))
+ .Build();
+ var value1 = new Value(structure1);
+ var value2 = new Value(structure2);
+
+ // Act & Assert
+ Assert.False(value1.Equals(value2));
+ }
+
+ [Fact]
+ public void Equals_WithStructuresDifferentKeyCounts_ReturnsFalse()
+ {
+ // Arrange
+ var structure1 = Structure.Builder()
+ .Set("key1", new Value("value1"))
+ .Build();
+ var structure2 = Structure.Builder()
+ .Set("key1", new Value("value1"))
+ .Set("key2", new Value("value2"))
+ .Build();
+ var value1 = new Value(structure1);
+ var value2 = new Value(structure2);
+
+ // Act & Assert
+ Assert.False(value1.Equals(value2));
+ }
+
+ [Fact]
+ public void Equals_WithSameListValues_ReturnsTrue()
+ {
+ // Arrange
+ var list1 = new List { new("test"), new(42), new(true) };
+ var list2 = new List { new("test"), new(42), new(true) };
+ var value1 = new Value(list1);
+ var value2 = new Value(list2);
+
+ // Act & Assert
+ Assert.True(value1.Equals(value2));
+ }
+
+ [Fact]
+ public void Equals_WithDifferentListValues_ReturnsFalse()
+ {
+ // Arrange
+ var list1 = new List { new("test1"), new(42) };
+ var list2 = new List { new("test2"), new(42) };
+ var value1 = new Value(list1);
+ var value2 = new Value(list2);
+
+ // Act & Assert
+ Assert.False(value1.Equals(value2));
+ }
+
+ [Fact]
+ public void Equals_WithListsDifferentLengths_ReturnsFalse()
+ {
+ // Arrange
+ var list1 = new List { new("test") };
+ var list2 = new List { new("test"), new(42) };
+ var value1 = new Value(list1);
+ var value2 = new Value(list2);
+
+ // Act & Assert
+ Assert.False(value1.Equals(value2));
+ }
+
+ [Fact]
+ public void Equals_WithObject_CallsTypedEquals()
+ {
+ // Arrange
+ var value1 = new Value("test");
+ var value2 = new Value("test");
+ object obj = value2;
+
+ // Act & Assert
+ Assert.True(value1.Equals(obj));
+ }
+
+ [Fact]
+ public void Equals_WithNonValueObject_ReturnsFalse()
+ {
+ // Arrange
+ var value = new Value("test");
+ object obj = "test";
+
+ // Act & Assert
+ Assert.False(value.Equals(obj));
+ }
+
+ #endregion
+
+ #region Operator Tests
+
+ [Fact]
+ public void OperatorEquals_WithBothNull_ReturnsTrue()
+ {
+ // Arrange
+ Value? value1 = null;
+ Value? value2 = null;
+
+ // Act & Assert
+ Assert.True(value1 == value2);
+ }
+
+ [Fact]
+ public void OperatorEquals_WithOneNull_ReturnsFalse()
+ {
+ // Arrange
+ Value? value1 = null;
+ Value value2 = new Value("test");
+
+ // Act & Assert
+ Assert.False(value1 == value2);
+ Assert.False(value2 == value1);
+ }
+
+ [Fact]
+ public void OperatorEquals_WithEqualValues_ReturnsTrue()
+ {
+ // Arrange
+ var value1 = new Value("test");
+ var value2 = new Value("test");
+
+ // Act & Assert
+ Assert.True(value1 == value2);
+ }
+
+ [Fact]
+ public void OperatorEquals_WithDifferentValues_ReturnsFalse()
+ {
+ // Arrange
+ var value1 = new Value("test1");
+ var value2 = new Value("test2");
+
+ // Act & Assert
+ Assert.False(value1 == value2);
+ }
+
+ [Fact]
+ public void OperatorNotEquals_WithEqualValues_ReturnsFalse()
+ {
+ // Arrange
+ var value1 = new Value("test");
+ var value2 = new Value("test");
+
+ // Act & Assert
+ Assert.False(value1 != value2);
+ }
+
+ [Fact]
+ public void OperatorNotEquals_WithDifferentValues_ReturnsTrue()
+ {
+ // Arrange
+ var value1 = new Value("test1");
+ var value2 = new Value("test2");
+
+ // Act & Assert
+ Assert.True(value1 != value2);
+ }
+
+ #endregion
+
+ #region GetHashCode Tests
+
+ [Fact]
+ public void GetHashCode_WithNullValue_ReturnsZero()
+ {
+ // Arrange
+ var value = new Value();
+
+ // Act
+ var hashCode = value.GetHashCode();
+
+ // Assert
+ Assert.Equal(0, hashCode);
+ }
+
+ [Fact]
+ public void GetHashCode_WithEqualValues_ReturnsSameHashCode()
+ {
+ // Arrange
+ var value1 = new Value("test");
+ var value2 = new Value("test");
+
+ // Act
+ var hashCode1 = value1.GetHashCode();
+ var hashCode2 = value2.GetHashCode();
+
+ // Assert
+ Assert.Equal(hashCode1, hashCode2);
+ }
+
+ [Fact]
+ public void GetHashCode_WithBooleanValues_ReturnsConsistentHashCode()
+ {
+ // Arrange
+ var value1 = new Value(true);
+ var value2 = new Value(true);
+ var value3 = new Value(false);
+
+ // Act
+ var hashCode1 = value1.GetHashCode();
+ var hashCode2 = value2.GetHashCode();
+ var hashCode3 = value3.GetHashCode();
+
+ // Assert
+ Assert.Equal(hashCode1, hashCode2);
+ Assert.NotEqual(hashCode1, hashCode3);
+ }
+
+ [Fact]
+ public void GetHashCode_WithNumberValues_ReturnsConsistentHashCode()
+ {
+ // Arrange
+ var value1 = new Value(42.5);
+ var value2 = new Value(42.5);
+ var value3 = new Value(42.6);
+
+ // Act
+ var hashCode1 = value1.GetHashCode();
+ var hashCode2 = value2.GetHashCode();
+ var hashCode3 = value3.GetHashCode();
+
+ // Assert
+ Assert.Equal(hashCode1, hashCode2);
+ Assert.NotEqual(hashCode1, hashCode3);
+ }
+
+ [Fact]
+ public void GetHashCode_WithStringValues_ReturnsConsistentHashCode()
+ {
+ // Arrange
+ var value1 = new Value("test");
+ var value2 = new Value("test");
+ var value3 = new Value("different");
+
+ // Act
+ var hashCode1 = value1.GetHashCode();
+ var hashCode2 = value2.GetHashCode();
+ var hashCode3 = value3.GetHashCode();
+
+ // Assert
+ Assert.Equal(hashCode1, hashCode2);
+ Assert.NotEqual(hashCode1, hashCode3);
+ }
+
+ [Fact]
+ public void GetHashCode_WithDateTimeValues_ReturnsConsistentHashCode()
+ {
+ // Arrange
+ var dateTime = DateTime.Now;
+ var value1 = new Value(dateTime);
+ var value2 = new Value(dateTime);
+ var value3 = new Value(dateTime.AddDays(1));
+
+ // Act
+ var hashCode1 = value1.GetHashCode();
+ var hashCode2 = value2.GetHashCode();
+ var hashCode3 = value3.GetHashCode();
+
+ // Assert
+ Assert.Equal(hashCode1, hashCode2);
+ Assert.NotEqual(hashCode1, hashCode3);
+ }
+
+ [Fact]
+ public void GetHashCode_WithStructureValues_ReturnsConsistentHashCode()
+ {
+ // Arrange
+ var structure1 = Structure.Builder()
+ .Set("key1", new Value("value1"))
+ .Set("key2", new Value(42))
+ .Build();
+ var structure2 = Structure.Builder()
+ .Set("key1", new Value("value1"))
+ .Set("key2", new Value(42))
+ .Build();
+ var value1 = new Value(structure1);
+ var value2 = new Value(structure2);
+
+ // Act
+ var hashCode1 = value1.GetHashCode();
+ var hashCode2 = value2.GetHashCode();
+
+ // Assert
+ Assert.Equal(hashCode1, hashCode2);
+ }
+
+ [Fact]
+ public void GetHashCode_WithListValues_ReturnsConsistentHashCode()
+ {
+ // Arrange
+ var list1 = new List { new("test"), new(42) };
+ var list2 = new List { new("test"), new(42) };
+ var value1 = new Value(list1);
+ var value2 = new Value(list2);
+
+ // Act
+ var hashCode1 = value1.GetHashCode();
+ var hashCode2 = value2.GetHashCode();
+
+ // Assert
+ Assert.Equal(hashCode1, hashCode2);
+ }
+
+ #endregion
+
+ #region Complex Nested Tests
+
+ [Fact]
+ public void Equals_WithNestedStructuresAndLists_ReturnsTrue()
+ {
+ // Arrange
+ var innerList1 = new List { new("nested"), new(123) };
+ var innerList2 = new List { new("nested"), new(123) };
+
+ var innerStructure1 = Structure.Builder()
+ .Set("nested_key", new Value("nested_value"))
+ .Set("nested_list", new Value(innerList1))
+ .Build();
+ var innerStructure2 = Structure.Builder()
+ .Set("nested_key", new Value("nested_value"))
+ .Set("nested_list", new Value(innerList2))
+ .Build();
+
+ var outerStructure1 = Structure.Builder()
+ .Set("outer_key", new Value("outer_value"))
+ .Set("inner", new Value(innerStructure1))
+ .Build();
+ var outerStructure2 = Structure.Builder()
+ .Set("outer_key", new Value("outer_value"))
+ .Set("inner", new Value(innerStructure2))
+ .Build();
+
+ var value1 = new Value(outerStructure1);
+ var value2 = new Value(outerStructure2);
+
+ // Act & Assert
+ Assert.True(value1.Equals(value2));
+ Assert.Equal(value1.GetHashCode(), value2.GetHashCode());
+ }
+
+ [Fact]
+ public void Equals_WithDeeplyNestedDifferences_ReturnsFalse()
+ {
+ // Arrange
+ var innerList1 = new List { new("nested"), new(123) };
+ var innerList2 = new List { new("nested"), new(124) }; // Different value
+
+ var innerStructure1 = Structure.Builder()
+ .Set("nested_key", new Value("nested_value"))
+ .Set("nested_list", new Value(innerList1))
+ .Build();
+ var innerStructure2 = Structure.Builder()
+ .Set("nested_key", new Value("nested_value"))
+ .Set("nested_list", new Value(innerList2))
+ .Build();
+
+ var outerStructure1 = Structure.Builder()
+ .Set("outer_key", new Value("outer_value"))
+ .Set("inner", new Value(innerStructure1))
+ .Build();
+ var outerStructure2 = Structure.Builder()
+ .Set("outer_key", new Value("outer_value"))
+ .Set("inner", new Value(innerStructure2))
+ .Build();
+
+ var value1 = new Value(outerStructure1);
+ var value2 = new Value(outerStructure2);
+
+ // Act & Assert
+ Assert.False(value1.Equals(value2));
+ }
+
+ #endregion
}
From 12396b7872a2db6533b33267cf9c299248c41472 Mon Sep 17 00:00:00 2001
From: Kyle <38759683+kylejuliandev@users.noreply.github.com>
Date: Mon, 14 Jul 2025 20:50:26 +0100
Subject: [PATCH 32/90] feat: Add Hook Dependency Injection extension method
with Hook instance (#513)
## This PR
In #512 I needed to tweak the sample AspNetCore application to pass Hook
options to the MetricsHook. I noticed that in order to pass the options
I needed to use the delegate based approach, while discarding the
`serviceProvider`, like:
```csharp
featureBuilder.AddHostedFeatureLifecycle()
.AddHook(_ => new MetricsHook(metricsHookOptions))
```
It would be simpler and easier for devs to interact with a third
overload that allows them to pass an instance of the hook that they want
to interact with, like so:
```csharp
featureBuilder.AddHostedFeatureLifecycle()
.AddHook(new MetricsHook())
```
### Related Issues
### Notes
### Follow-up Tasks
### How to test
---------
Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com>
---
README.md | 1 +
.../OpenFeatureBuilderExtensions.cs | 27 +++++++++++++++
.../OpenFeatureBuilderExtensionsTests.cs | 34 +++++++++++++++++++
3 files changed, 62 insertions(+)
diff --git a/README.md b/README.md
index 66a3d620c..d51bfdc4d 100644
--- a/README.md
+++ b/README.md
@@ -481,6 +481,7 @@ builder.Services.AddOpenFeature(featureBuilder => {
.AddHostedFeatureLifecycle()
.AddContext((contextBuilder, serviceProvider) => { /* Custom context configuration */ })
.AddHook((serviceProvider) => new LoggingHook( /* Custom configuration */ ))
+ .AddHook(new MetricsHook())
.AddInMemoryProvider("name1")
.AddInMemoryProvider("name2")
.AddPolicyName(options => {
diff --git a/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs
index 317589606..01a535e04 100644
--- a/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs
+++ b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs
@@ -278,6 +278,33 @@ public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder,
return builder.AddHook(typeof(THook).Name, implementationFactory);
}
+ ///
+ /// Adds a feature hook to the service collection. Hooks added here are not domain-bound.
+ ///
+ /// The type of to be added.
+ /// The instance.
+ /// Instance of Hook to inject into the OpenFeature context.
+ /// The instance.
+ public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, THook hook)
+ where THook : Hook
+ {
+ return builder.AddHook(typeof(THook).Name, hook);
+ }
+
+ ///
+ /// Adds a feature hook to the service collection with a specified name. Hooks added here are not domain-bound.
+ ///
+ /// The type of to be added.
+ /// The instance.
+ /// The name of the that is being added.
+ /// Instance of Hook to inject into the OpenFeature context.
+ /// The instance.
+ public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, string hookName, THook hook)
+ where THook : Hook
+ {
+ return builder.AddHook(hookName, _ => hook);
+ }
+
///
/// Adds a feature hook to the service collection using a factory method and specified name. Hooks added here are not domain-bound.
///
diff --git a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs
index f1edca4c4..f7cce0dfc 100644
--- a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs
+++ b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs
@@ -302,6 +302,40 @@ public void AddHook_WithSpecifiedNameAndImplementationFactory_AsKeyedService()
Assert.NotNull(hook);
}
+ [Fact]
+ public void AddHook_WithInstance_AddsHookAsKeyedService()
+ {
+ // Arrange
+ var expectedHook = new NoOpHook();
+ _systemUnderTest.AddHook(expectedHook);
+
+ var serviceProvider = _services.BuildServiceProvider();
+
+ // Act
+ var actualHook = serviceProvider.GetKeyedService("NoOpHook");
+
+ // Assert
+ Assert.NotNull(actualHook);
+ Assert.Equal(expectedHook, actualHook);
+ }
+
+ [Fact]
+ public void AddHook_WithSpecifiedNameAndInstance_AddsHookAsKeyedService()
+ {
+ // Arrange
+ var expectedHook = new NoOpHook();
+ _systemUnderTest.AddHook("custom-hook", expectedHook);
+
+ var serviceProvider = _services.BuildServiceProvider();
+
+ // Act
+ var actualHook = serviceProvider.GetKeyedService("custom-hook");
+
+ // Assert
+ Assert.NotNull(actualHook);
+ Assert.Equal(expectedHook, actualHook);
+ }
+
[Fact]
public void AddHandler_AddsEventHandlerDelegateWrapperAsKeyedService()
{
From 8c92524edbf4579d4ad62c699b338b9811a783fd Mon Sep 17 00:00:00 2001
From: Michael Beemer
Date: Tue, 15 Jul 2025 14:28:29 -0400
Subject: [PATCH 33/90] docs: remove curly brace from readme
The curly brace breaks the Docusaurus complication.
Signed-off-by: Michael Beemer
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index d51bfdc4d..a5c06efaa 100644
--- a/README.md
+++ b/README.md
@@ -613,8 +613,8 @@ Below are the metrics extracted by this hook and dimensions they carry:
| Metric key | Description | Unit | Dimensions |
| -------------------------------------- | ------------------------------- | ------------ | ----------------------------- |
-| feature_flag.evaluation_requests_total | Number of evaluation requests | {request} | key, provider name |
-| feature_flag.evaluation_success_total | Flag evaluation successes | {impression} | key, provider name, reason |
+| feature_flag.evaluation_requests_total | Number of evaluation requests | request | key, provider name |
+| feature_flag.evaluation_success_total | Flag evaluation successes | impression | key, provider name, reason |
| feature_flag.evaluation_error_total | Flag evaluation errors | 1 | key, provider name, exception |
| feature_flag.evaluation_active_count | Active flag evaluations counter | 1 | key, provider name |
From 18705c7338a0c89f163f808c81e513a029c95239 Mon Sep 17 00:00:00 2001
From: Michael Beemer
Date: Tue, 15 Jul 2025 17:05:26 -0400
Subject: [PATCH 34/90] docs: fix anchor link in readme (#525)
This is causing an issue when attempting to update the .NET SDK docs on
openfeature.dev.
https://github.com/open-feature/openfeature.dev/actions/runs/16301698184/job/46037647418?pr=1248
Signed-off-by: Michael Beemer
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index a5c06efaa..1484f7310 100644
--- a/README.md
+++ b/README.md
@@ -113,7 +113,7 @@ Want to contribute a new sample? See our [CONTRIBUTING](CONTRIBUTING.md) guide!
| β
| [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
| β
| [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread). |
| β
| [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
-| π¬ | [DependencyInjection](#DependencyInjection) | Integrate OpenFeature with .NET's dependency injection for streamlined provider setup. |
+| π¬ | [DependencyInjection](#dependency-injection) | Integrate OpenFeature with .NET's dependency injection for streamlined provider setup. |
> Implemented: β
| In-progress: β οΈ | Not implemented yet: β | Experimental: π¬
@@ -433,7 +433,7 @@ Hooks support passing per-evaluation data between that stages using `hook data`.
Built a new hook? [Let us know](https://github.com/open-feature/openfeature.dev/issues/new?assignees=&labels=hook&projects=&template=document-hook.yaml&title=%5BHook%5D%3A+) so we can add it to the docs!
-### DependencyInjection
+### Dependency Injection
> [!NOTE]
> The OpenFeature.DependencyInjection and OpenFeature.Hosting packages are currently experimental. They streamline the integration of OpenFeature within .NET applications, allowing for seamless configuration and lifecycle management of feature flag providers using dependency injection and hosting services.
From 8c05d1d7363db89b8379e1a4e46e455f210888e2 Mon Sep 17 00:00:00 2001
From: Kyle <38759683+kylejuliandev@users.noreply.github.com>
Date: Sat, 19 Jul 2025 15:19:50 +0100
Subject: [PATCH 35/90] feat: Add Metric Hook Custom Attributes (#512)
## This PR
Adds new `MetricsHookOptions` and `MetricsHookOptionsBuilder` to
optionally configure custom attributes that can be tagged on the
`feature_flag.evaluation_success_total` metric. Example usage:
```csharp
var options = MetricsHookOptions.CreateBuilder()
.WithCustomDimension("custom_dimension_key", "custom_dimension_value")
.WithFlagEvaluationMetadata("boolean", s => s.GetBool("boolean"))
.Build();
OpenFeature.Api.Instance.AddHooks(new MetricsHook(options));
```
Screenshot below shows the AspNetCore sample application tagging the
`feature_flag.evaluation_success_total` counter with the specified
dimensions.

### Related Issues
Fixes #509
Fixes #514
### Notes
### Follow-up Tasks
### How to test
---------
Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com>
---
README.md | 18 +
samples/AspNetCore/Program.cs | 7 +-
src/OpenFeature/Hooks/MetricsHook.cs | 45 ++-
src/OpenFeature/Hooks/MetricsHookOptions.cs | 91 +++++
.../Hooks/MetricsHookOptionsTests.cs | 84 +++++
.../Hooks/MetricsHookTests.cs | 327 ++++++++++++++----
6 files changed, 503 insertions(+), 69 deletions(-)
create mode 100644 src/OpenFeature/Hooks/MetricsHookOptions.cs
create mode 100644 test/OpenFeature.Tests/Hooks/MetricsHookOptionsTests.cs
diff --git a/README.md b/README.md
index 1484f7310..e79fd544a 100644
--- a/README.md
+++ b/README.md
@@ -664,6 +664,24 @@ namespace OpenFeatureTestApp
After running this example, you should be able to see some metrics being generated into the console.
+You can specify custom dimensions on all instruments by the `MetricsHook` by providing `MetricsHookOptions` when adding the hook:
+
+```csharp
+var options = MetricsHookOptions.CreateBuilder()
+ .WithCustomDimension("custom_dimension_key", "custom_dimension_value")
+ .Build();
+
+OpenFeature.Api.Instance.AddHooks(new MetricsHook(options));
+```
+
+You can also write your own extraction logic against the Flag metadata by providing a callback to `WithFlagEvaluationMetadata`.
+
+```csharp
+var options = MetricsHookOptions.CreateBuilder()
+ .WithFlagEvaluationMetadata("boolean", s => s.GetBool("boolean"))
+ .Build();
+```
+
## βοΈ Support the project
diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs
index 5f4f01461..e09213076 100644
--- a/samples/AspNetCore/Program.cs
+++ b/samples/AspNetCore/Program.cs
@@ -26,9 +26,14 @@
builder.Services.AddOpenFeature(featureBuilder =>
{
+ var metricsHookOptions = MetricsHookOptions.CreateBuilder()
+ .WithCustomDimension("custom_dimension_key", "custom_dimension_value")
+ .WithFlagEvaluationMetadata("boolean", s => s.GetBool("boolean"))
+ .Build();
+
featureBuilder.AddHostedFeatureLifecycle()
.AddHook(sp => new LoggingHook(sp.GetRequiredService>()))
- .AddHook()
+ .AddHook(_ => new MetricsHook(metricsHookOptions))
.AddHook()
.AddInMemoryProvider("InMemory", _ => new Dictionary()
{
diff --git a/src/OpenFeature/Hooks/MetricsHook.cs b/src/OpenFeature/Hooks/MetricsHook.cs
index 2f2314f0d..6852b47c6 100644
--- a/src/OpenFeature/Hooks/MetricsHook.cs
+++ b/src/OpenFeature/Hooks/MetricsHook.cs
@@ -19,20 +19,24 @@ public class MetricsHook : Hook
private static readonly string InstrumentationVersion = AssemblyName.Version?.ToString() ?? "1.0.0";
private static readonly Meter Meter = new(InstrumentationName, InstrumentationVersion);
- private readonly UpDownCounter _evaluationActiveUpDownCounter;
- private readonly Counter _evaluationRequestCounter;
- private readonly Counter _evaluationSuccessCounter;
- private readonly Counter _evaluationErrorCounter;
+ internal readonly UpDownCounter _evaluationActiveUpDownCounter;
+ internal readonly Counter _evaluationRequestCounter;
+ internal readonly Counter _evaluationSuccessCounter;
+ internal readonly Counter _evaluationErrorCounter;
+
+ private readonly MetricsHookOptions _options;
///
/// Initializes a new instance of the class.
///
- public MetricsHook()
+ /// Optional configuration for the metrics hook.
+ public MetricsHook(MetricsHookOptions? options = null)
{
this._evaluationActiveUpDownCounter = Meter.CreateUpDownCounter(MetricsConstants.ActiveCountName, description: MetricsConstants.ActiveDescription);
this._evaluationRequestCounter = Meter.CreateCounter(MetricsConstants.RequestsTotalName, "{request}", MetricsConstants.RequestsDescription);
this._evaluationSuccessCounter = Meter.CreateCounter(MetricsConstants.SuccessTotalName, "{impression}", MetricsConstants.SuccessDescription);
this._evaluationErrorCounter = Meter.CreateCounter(MetricsConstants.ErrorTotalName, description: MetricsConstants.ErrorDescription);
+ this._options = options ?? MetricsHookOptions.Default;
}
///
@@ -44,6 +48,8 @@ public override ValueTask BeforeAsync(HookContext conte
{ TelemetryConstants.Provider, context.ProviderMetadata.Name }
};
+ this.AddCustomDimensions(ref tagList);
+
this._evaluationActiveUpDownCounter.Add(1, tagList);
this._evaluationRequestCounter.Add(1, tagList);
@@ -61,6 +67,9 @@ public override ValueTask AfterAsync(HookContext context, FlagEvaluationDe
{ TelemetryConstants.Reason, details.Reason ?? Reason.Unknown.ToString() }
};
+ this.AddCustomDimensions(ref tagList);
+ this.AddFlagMetadataDimensions(details.FlagMetadata, ref tagList);
+
this._evaluationSuccessCounter.Add(1, tagList);
return base.AfterAsync(context, details, hints, cancellationToken);
@@ -76,6 +85,8 @@ public override ValueTask ErrorAsync(HookContext context, Exception error,
{ MetricsConstants.ExceptionAttr, error.Message }
};
+ this.AddCustomDimensions(ref tagList);
+
this._evaluationErrorCounter.Add(1, tagList);
return base.ErrorAsync(context, error, hints, cancellationToken);
@@ -93,8 +104,32 @@ public override ValueTask FinallyAsync(HookContext context,
{ TelemetryConstants.Provider, context.ProviderMetadata.Name }
};
+ this.AddCustomDimensions(ref tagList);
+ this.AddFlagMetadataDimensions(evaluationDetails.FlagMetadata, ref tagList);
+
this._evaluationActiveUpDownCounter.Add(-1, tagList);
return base.FinallyAsync(context, evaluationDetails, hints, cancellationToken);
}
+
+ private void AddCustomDimensions(ref TagList tagList)
+ {
+ foreach (var customDimension in this._options.CustomDimensions)
+ {
+ tagList.Add(customDimension.Key, customDimension.Value);
+ }
+ }
+
+ private void AddFlagMetadataDimensions(ImmutableMetadata? flagMetadata, ref TagList tagList)
+ {
+ flagMetadata ??= new ImmutableMetadata();
+
+ foreach (var item in this._options.FlagMetadataCallbacks)
+ {
+ var flagMetadataCallback = item.Value;
+ var value = flagMetadataCallback(flagMetadata);
+
+ tagList.Add(item.Key, value);
+ }
+ }
}
diff --git a/src/OpenFeature/Hooks/MetricsHookOptions.cs b/src/OpenFeature/Hooks/MetricsHookOptions.cs
new file mode 100644
index 000000000..553431496
--- /dev/null
+++ b/src/OpenFeature/Hooks/MetricsHookOptions.cs
@@ -0,0 +1,91 @@
+using OpenFeature.Model;
+
+namespace OpenFeature.Hooks;
+
+///
+/// Configuration options for the .
+///
+public sealed class MetricsHookOptions
+{
+ ///
+ /// The default options for the .
+ ///
+ public static MetricsHookOptions Default { get; } = new MetricsHookOptions();
+
+ ///
+ /// Custom dimensions or tags to be associated with Meters in .
+ ///
+ public IReadOnlyCollection> CustomDimensions { get; }
+
+ ///
+ ///
+ ///
+ internal IReadOnlyCollection>> FlagMetadataCallbacks { get; }
+
+ ///
+ /// Initializes a new instance of the class with default values.
+ ///
+ private MetricsHookOptions() : this(null, null)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Optional custom dimensions to tag Counter increments with.
+ ///
+ internal MetricsHookOptions(IReadOnlyCollection>? customDimensions = null,
+ IReadOnlyCollection>>? flagMetadataSelectors = null)
+ {
+ this.CustomDimensions = customDimensions ?? [];
+ this.FlagMetadataCallbacks = flagMetadataSelectors ?? [];
+ }
+
+ ///
+ /// Creates a new builder for .
+ ///
+ public static MetricsHookOptionsBuilder CreateBuilder() => new MetricsHookOptionsBuilder();
+
+ ///
+ /// A builder for constructing instances.
+ ///
+ public sealed class MetricsHookOptionsBuilder
+ {
+ private readonly List> _customDimensions = new List>();
+ private readonly List>> _flagMetadataExpressions = new List>>();
+
+ ///
+ /// Adds a custom dimension.
+ ///
+ /// The key for the custom dimension.
+ /// The value for the custom dimension.
+ public MetricsHookOptionsBuilder WithCustomDimension(string key, object? value)
+ {
+ this._customDimensions.Add(new KeyValuePair(key, value));
+ return this;
+ }
+
+ ///
+ /// Provide a callback to evaluate flag metadata for a specific flag key.
+ ///
+ /// The key for the custom dimension.
+ /// The callback to retrieve the value to tag successful flag evaluations.
+ ///
+ public MetricsHookOptionsBuilder WithFlagEvaluationMetadata(string key, Func flagMetadataCallback)
+ {
+ var kvp = new KeyValuePair>(key, flagMetadataCallback);
+
+ this._flagMetadataExpressions.Add(kvp);
+
+ return this;
+ }
+
+ ///
+ /// Builds the instance.
+ ///
+ public MetricsHookOptions Build()
+ {
+ return new MetricsHookOptions(this._customDimensions.AsReadOnly(), this._flagMetadataExpressions.AsReadOnly());
+ }
+ }
+}
diff --git a/test/OpenFeature.Tests/Hooks/MetricsHookOptionsTests.cs b/test/OpenFeature.Tests/Hooks/MetricsHookOptionsTests.cs
new file mode 100644
index 000000000..89f0f56d7
--- /dev/null
+++ b/test/OpenFeature.Tests/Hooks/MetricsHookOptionsTests.cs
@@ -0,0 +1,84 @@
+using OpenFeature.Hooks;
+using OpenFeature.Model;
+
+namespace OpenFeature.Tests.Hooks;
+
+public class MetricsHookOptionsTests
+{
+ [Fact]
+ public void Default_Options_Should_Be_Initialized_Correctly()
+ {
+ // Arrange & Act
+ var options = MetricsHookOptions.Default;
+
+ // Assert
+ Assert.NotNull(options);
+ Assert.Empty(options.CustomDimensions);
+ Assert.Empty(options.FlagMetadataCallbacks);
+ }
+
+ [Fact]
+ public void CreateBuilder_Should_Return_New_Builder_Instance()
+ {
+ // Arrange & Act
+ var builder = MetricsHookOptions.CreateBuilder();
+
+ // Assert
+ Assert.NotNull(builder);
+ Assert.IsType(builder);
+ }
+
+ [Fact]
+ public void Build_Should_Return_Options()
+ {
+ // Arrange
+ var builder = MetricsHookOptions.CreateBuilder();
+
+ // Act
+ var options = builder.Build();
+
+ // Assert
+ Assert.NotNull(options);
+ Assert.IsType(options);
+ }
+
+ [Theory]
+ [InlineData("custom_dimension_value")]
+ [InlineData(1.0)]
+ [InlineData(2025)]
+ [InlineData(null)]
+ [InlineData(true)]
+ public void Builder_Should_Allow_Adding_Custom_Dimensions(object? value)
+ {
+ // Arrange
+ var builder = MetricsHookOptions.CreateBuilder();
+ var key = "custom_dimension_key";
+
+ // Act
+ builder.WithCustomDimension(key, value);
+ var options = builder.Build();
+
+ // Assert
+ Assert.Single(options.CustomDimensions);
+ Assert.Equal(key, options.CustomDimensions.First().Key);
+ Assert.Equal(value, options.CustomDimensions.First().Value);
+ }
+
+ [Fact]
+ public void Builder_Should_Allow_Adding_Flag_Metadata_Expressions()
+ {
+ // Arrange
+ var builder = MetricsHookOptions.CreateBuilder();
+ var key = "flag_metadata_key";
+ static object? expression(ImmutableMetadata m) => m.GetString("flag_metadata_key");
+
+ // Act
+ builder.WithFlagEvaluationMetadata(key, expression);
+ var options = builder.Build();
+
+ // Assert
+ Assert.Single(options.FlagMetadataCallbacks);
+ Assert.Equal(key, options.FlagMetadataCallbacks.First().Key);
+ Assert.Equal(expression, options.FlagMetadataCallbacks.First().Value);
+ }
+}
diff --git a/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs b/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs
index 54f6e19cc..f1c3be3ad 100644
--- a/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs
+++ b/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs
@@ -1,43 +1,78 @@
+using Microsoft.Extensions.Diagnostics.Metrics.Testing;
using OpenFeature.Hooks;
using OpenFeature.Model;
-using OpenTelemetry;
-using OpenTelemetry.Metrics;
-using OpenTelemetry.Resources;
namespace OpenFeature.Tests.Hooks;
[CollectionDefinition(nameof(MetricsHookTest), DisableParallelization = true)]
-public class MetricsHookTest : IDisposable
+public class MetricsHookTest
{
- private readonly List _exportedItems;
- private readonly MeterProvider _meterProvider;
-
- public MetricsHookTest()
+ [Fact]
+ public async Task After_Test()
{
- // Arrange metrics collector
- this._exportedItems = [];
- this._meterProvider = Sdk.CreateMeterProviderBuilder()
- .AddMeter("OpenFeature")
- .ConfigureResource(r => r.AddService("open-feature"))
- .AddInMemoryExporter(this._exportedItems,
- option => option.PeriodicExportingMetricReaderOptions =
- new PeriodicExportingMetricReaderOptions { ExportIntervalMilliseconds = 100 })
- .Build();
+ // Arrange
+ var metricsHook = new MetricsHook();
+
+ using var collector = new MetricCollector(metricsHook._evaluationSuccessCounter);
+
+ var evaluationContext = EvaluationContext.Empty;
+ var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String,
+ new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
+
+ // Act
+ await metricsHook.AfterAsync(ctx,
+ new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"),
+ new Dictionary()).ConfigureAwait(true);
+
+ var measurements = collector.LastMeasurement;
+
+ // Assert
+ Assert.NotNull(measurements);
+
+ Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]);
+ Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]);
+ Assert.Equal("STATIC", measurements.Tags["feature_flag.result.reason"]);
}
-#pragma warning disable CA1816
- public void Dispose()
+ [Fact]
+ public async Task Without_Reason_After_Test_Defaults_To_Unknown()
{
- this._meterProvider.Shutdown();
+ // Arrange
+ var metricsHook = new MetricsHook();
+
+ using var collector = new MetricCollector(metricsHook._evaluationSuccessCounter);
+
+ var evaluationContext = EvaluationContext.Empty;
+ var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String,
+ new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
+
+ // Act
+ await metricsHook.AfterAsync(ctx,
+ new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, reason: null, "default"),
+ new Dictionary()).ConfigureAwait(true);
+
+ var measurements = collector.LastMeasurement;
+
+ // Assert
+ Assert.NotNull(measurements);
+
+ Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]);
+ Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]);
+ Assert.Equal("UNKNOWN", measurements.Tags["feature_flag.result.reason"]);
}
-#pragma warning restore CA1816
[Fact]
- public async Task After_Test()
+ public async Task With_CustomDimensions_After_Test()
{
// Arrange
- const string metricName = "feature_flag.evaluation_success_total";
- var metricsHook = new MetricsHook();
+ var metricHookOptions = MetricsHookOptions.CreateBuilder()
+ .WithCustomDimension("custom_dimension_key", "custom_dimension_value")
+ .Build();
+
+ var metricsHook = new MetricsHook(metricHookOptions);
+
+ using var collector = new MetricCollector(metricsHook._evaluationSuccessCounter);
+
var evaluationContext = EvaluationContext.Empty;
var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String,
new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
@@ -46,50 +81,123 @@ public async Task After_Test()
await metricsHook.AfterAsync(ctx,
new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"),
new Dictionary()).ConfigureAwait(true);
- this._meterProvider.ForceFlush();
- // Assert metrics
- Assert.NotEmpty(this._exportedItems);
+ var measurements = collector.LastMeasurement;
- // check if the metric is present in the exported items
- var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName);
- Assert.NotNull(metric);
+ // Assert
+ Assert.NotNull(measurements);
- var noOtherMetric = this._exportedItems.All(m => m.Name == metricName);
- Assert.True(noOtherMetric);
+ Assert.Equal(1, measurements.Value);
+ Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]);
+ Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]);
+ Assert.Equal("STATIC", measurements.Tags["feature_flag.result.reason"]);
+ Assert.Equal("custom_dimension_value", measurements.Tags["custom_dimension_key"]);
+ }
+
+ [Fact]
+ public async Task With_FlagMetadataCallback_After_Test()
+ {
+ // Arrange
+ var metricHookOptions = MetricsHookOptions.CreateBuilder()
+ .WithFlagEvaluationMetadata("bool", m => m.GetBool("bool"))
+ .Build();
+
+ var metricsHook = new MetricsHook(metricHookOptions);
+
+ using var collector = new MetricCollector(metricsHook._evaluationSuccessCounter);
+
+ var evaluationContext = EvaluationContext.Empty;
+ var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String,
+ new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
+
+ var flagMetadata = new ImmutableMetadata(new Dictionary
+ {
+ { "bool", true }
+ });
+
+ // Act
+ await metricsHook.AfterAsync(ctx,
+ new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default", errorMessage: null, flagMetadata),
+ new Dictionary()).ConfigureAwait(true);
+
+ var measurements = collector.LastMeasurement;
+
+ // Assert
+ Assert.NotNull(measurements);
+
+ Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]);
+ Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]);
+ Assert.Equal("STATIC", measurements.Tags["feature_flag.result.reason"]);
+ Assert.Equal(true, measurements.Tags["bool"]);
}
[Fact]
public async Task Error_Test()
{
// Arrange
- const string metricName = "feature_flag.evaluation_error_total";
var metricsHook = new MetricsHook();
+
+ using var collector = new MetricCollector(metricsHook._evaluationErrorCounter);
+
+ var evaluationContext = EvaluationContext.Empty;
+ var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String,
+ new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
+
+ var errorMessage = "An error occurred during evaluation";
+
+ // Act
+ await metricsHook.ErrorAsync(ctx, new Exception(errorMessage), new Dictionary()).ConfigureAwait(true);
+
+ var measurements = collector.LastMeasurement;
+
+ // Assert
+ Assert.NotNull(measurements);
+
+ Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]);
+ Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]);
+ Assert.Equal(errorMessage, measurements.Tags["exception"]);
+ }
+
+ [Fact]
+ public async Task With_CustomDimensions_Error_Test()
+ {
+ // Arrange
+ var metricHookOptions = MetricsHookOptions.CreateBuilder()
+ .WithCustomDimension("custom_dimension_key", "custom_dimension_value")
+ .Build();
+
+ var metricsHook = new MetricsHook(metricHookOptions);
+
+ using var collector = new MetricCollector(metricsHook._evaluationErrorCounter);
+
var evaluationContext = EvaluationContext.Empty;
var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String,
new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
+ var errorMessage = "An error occurred during evaluation";
+
// Act
- await metricsHook.ErrorAsync(ctx, new Exception(), new Dictionary()).ConfigureAwait(true);
- this._meterProvider.ForceFlush();
+ await metricsHook.ErrorAsync(ctx, new Exception(errorMessage), new Dictionary()).ConfigureAwait(true);
- // Assert metrics
- Assert.NotEmpty(this._exportedItems);
+ var measurements = collector.LastMeasurement;
- // check if the metric is present in the exported items
- var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName);
- Assert.NotNull(metric);
+ // Assert
+ Assert.NotNull(measurements);
- var noOtherMetric = this._exportedItems.All(m => m.Name == metricName);
- Assert.True(noOtherMetric);
+ Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]);
+ Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]);
+ Assert.Equal(errorMessage, measurements.Tags["exception"]);
+ Assert.Equal("custom_dimension_value", measurements.Tags["custom_dimension_key"]);
}
[Fact]
public async Task Finally_Test()
{
// Arrange
- const string metricName = "feature_flag.evaluation_active_count";
var metricsHook = new MetricsHook();
+
+ using var collector = new MetricCollector(metricsHook._evaluationActiveUpDownCounter);
+
var evaluationContext = EvaluationContext.Empty;
var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String,
new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
@@ -97,45 +205,138 @@ public async Task Finally_Test()
// Act
await metricsHook.FinallyAsync(ctx, evaluationDetails, new Dictionary()).ConfigureAwait(true);
- this._meterProvider.ForceFlush();
- // Assert metrics
- Assert.NotEmpty(this._exportedItems);
+ var measurements = collector.LastMeasurement;
+
+ // Assert
+ Assert.NotNull(measurements);
+
+ Assert.Equal(-1, measurements.Value);
+ Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]);
+ Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]);
+ }
+
+ [Fact]
+ public async Task With_CustomDimensions_Finally_Test()
+ {
+ // Arrange
+ var metricHookOptions = MetricsHookOptions.CreateBuilder()
+ .WithCustomDimension("custom_dimension_key", "custom_dimension_value")
+ .Build();
+
+ var metricsHook = new MetricsHook(metricHookOptions);
+
+ using var collector = new MetricCollector(metricsHook._evaluationActiveUpDownCounter);
+
+ var evaluationContext = EvaluationContext.Empty;
+ var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String,
+ new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
+ var evaluationDetails = new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default");
+
+ // Act
+ await metricsHook.FinallyAsync(ctx, evaluationDetails, new Dictionary()).ConfigureAwait(true);
+
+ var measurements = collector.LastMeasurement;
+
+ // Assert
+ Assert.NotNull(measurements);
+
+ Assert.Equal(-1, measurements.Value);
+ Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]);
+ Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]);
+ Assert.Equal("custom_dimension_value", measurements.Tags["custom_dimension_key"]);
+ }
+
+ [Fact]
+ public async Task With_FlagMetadataCallback_Finally_Test()
+ {
+ // Arrange
+ var metricHookOptions = MetricsHookOptions.CreateBuilder()
+ .WithFlagEvaluationMetadata("status_code", m => m.GetInt("status_code"))
+ .Build();
+
+ var metricsHook = new MetricsHook(metricHookOptions);
+
+ using var collector = new MetricCollector(metricsHook._evaluationActiveUpDownCounter);
+
+ var flagMetadata = new ImmutableMetadata(new Dictionary
+ {
+ { "status_code", 1521 }
+ });
+
+ var evaluationContext = EvaluationContext.Empty;
+ var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String,
+ new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
+ var evaluationDetails = new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default", flagMetadata: flagMetadata);
+
+ // Act
+ await metricsHook.FinallyAsync(ctx, evaluationDetails, new Dictionary()).ConfigureAwait(true);
+
+ var measurements = collector.LastMeasurement;
- // check if the metric feature_flag.evaluation_success_total is present in the exported items
- var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName);
- Assert.NotNull(metric);
+ // Assert
+ Assert.NotNull(measurements);
- var noOtherMetric = this._exportedItems.All(m => m.Name == metricName);
- Assert.True(noOtherMetric);
+ Assert.Equal(-1, measurements.Value);
+ Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]);
+ Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]);
+ Assert.Equal(1521, measurements.Tags["status_code"]);
}
[Fact]
public async Task Before_Test()
{
// Arrange
- const string metricName1 = "feature_flag.evaluation_active_count";
- const string metricName2 = "feature_flag.evaluation_requests_total";
var metricsHook = new MetricsHook();
+
+ using var collector1 = new MetricCollector(metricsHook._evaluationActiveUpDownCounter);
+ using var collector2 = new MetricCollector(metricsHook._evaluationRequestCounter);
+
var evaluationContext = EvaluationContext.Empty;
var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String,
new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
// Act
await metricsHook.BeforeAsync(ctx, new Dictionary()).ConfigureAwait(true);
- this._meterProvider.ForceFlush();
- // Assert metrics
- Assert.NotEmpty(this._exportedItems);
+ var measurements = collector1.LastMeasurement;
+
+ // Assert
+ Assert.NotNull(measurements);
+
+ Assert.Equal(1, measurements.Value);
+ Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]);
+ Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]);
+ }
+
+ [Fact]
+ public async Task With_CustomDimensions_Before_Test()
+ {
+ // Arrange
+ var metricHookOptions = MetricsHookOptions.CreateBuilder()
+ .WithCustomDimension("custom_dimension_key", "custom_dimension_value")
+ .Build();
+
+ var metricsHook = new MetricsHook(metricHookOptions);
+
+ using var collector1 = new MetricCollector(metricsHook._evaluationActiveUpDownCounter);
+ using var collector2 = new MetricCollector(metricsHook._evaluationRequestCounter);
+
+ var evaluationContext = EvaluationContext.Empty;
+ var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String,
+ new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext);
+
+ // Act
+ await metricsHook.BeforeAsync(ctx, new Dictionary()).ConfigureAwait(true);
- // check if the metric is present in the exported items
- var metric1 = this._exportedItems.FirstOrDefault(m => m.Name == metricName1);
- Assert.NotNull(metric1);
+ var measurements = collector1.LastMeasurement;
- var metric2 = this._exportedItems.FirstOrDefault(m => m.Name == metricName2);
- Assert.NotNull(metric2);
+ // Assert
+ Assert.NotNull(measurements);
- var noOtherMetric = this._exportedItems.All(m => m.Name == metricName1 || m.Name == metricName2);
- Assert.True(noOtherMetric);
+ Assert.Equal(1, measurements.Value);
+ Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]);
+ Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]);
+ Assert.Equal("custom_dimension_value", measurements.Tags["custom_dimension_key"]);
}
}
From 03d3b9e5d6ff1706faffc25afeba80a0e2bb37ec Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 21 Jul 2025 20:35:50 +0000
Subject: [PATCH 36/90] chore(deps): update github/codeql-action digest to
d6bbdef (#527)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/workflows/codeql-analysis.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 9ad67c477..9f3893836 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -42,7 +42,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3
+ uses: github/codeql-action/init@d6bbdef45e766d081b84a2def353b0055f728d3e # v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -56,7 +56,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3
+ uses: github/codeql-action/autobuild@d6bbdef45e766d081b84a2def353b0055f728d3e # v3
# βΉοΈ Command-line programs to run using the OS shell.
# π See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -69,4 +69,4 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3
+ uses: github/codeql-action/analyze@d6bbdef45e766d081b84a2def353b0055f728d3e # v3
From a0ae014d3194fcf6e5e5e4a17a2f92b1df3dc7c7 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 21 Jul 2025 20:36:01 +0000
Subject: [PATCH 37/90] chore(deps): update spec digest to baec39b (#528)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
spec | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/spec b/spec
index 224b26e44..baec39b3f 160000
--- a/spec
+++ b/spec
@@ -1 +1 @@
-Subproject commit 224b26e44ebfe21d1110d5b64d740c8a3055d398
+Subproject commit baec39b3fe886667a0e94a902c22ca7b8486a36d
From 6e521d25c3dd53c45f2fd30f5319cae5cd2ff46d Mon Sep 17 00:00:00 2001
From: Weihan Li
Date: Fri, 25 Jul 2025 00:16:05 +0800
Subject: [PATCH 38/90] feat: Support JSON Serialize for Value (#529)
* feat: add ValueJsonConverter to support STJ JSON Serialize
Signed-off-by: Weihan Li
* test: add test case
Signed-off-by: Weihan Li
* style: apply dotnet-format
Signed-off-by: Weihan Li
* test: update test cases
Signed-off-by: Weihan Li
* test: update deserialize for int/Datetime
Signed-off-by: Weihan Li
* style: apply dotnet format
Signed-off-by: Weihan Li
* refactor: update DateTime handling
Signed-off-by: Weihan Li
* fix: fix build
Signed-off-by: Weihan Li
* test: update test case to cover nested Structure and beautify JSON test data
Signed-off-by: Weihan Li
* refactor: update double/int value serialize
Signed-off-by: Weihan Li
* include boundaries
Signed-off-by: Weihan Li
* update double test case data
Signed-off-by: Weihan Li
* test: Update StructureTests double test case
Signed-off-by: Weihan Li
* refactor: simplify write number value for ValueJsonConverter
Signed-off-by: Weihan Li
---------
Signed-off-by: Weihan Li
---
Directory.Packages.props | 4 +-
src/OpenFeature/Model/Value.cs | 2 +
src/OpenFeature/Model/ValueJsonConverter.cs | 123 ++++++++++++++++++++
src/OpenFeature/OpenFeature.csproj | 1 +
test/OpenFeature.Tests/StructureTests.cs | 64 ++++++++++
5 files changed, 193 insertions(+), 1 deletion(-)
create mode 100644 src/OpenFeature/Model/ValueJsonConverter.cs
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 194441b93..fe88537d8 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -23,6 +23,8 @@
+
@@ -48,4 +50,4 @@
-
\ No newline at end of file
+
diff --git a/src/OpenFeature/Model/Value.cs b/src/OpenFeature/Model/Value.cs
index 41b15246b..524ac4c4c 100644
--- a/src/OpenFeature/Model/Value.cs
+++ b/src/OpenFeature/Model/Value.cs
@@ -1,4 +1,5 @@
using System.Collections.Immutable;
+using System.Text.Json.Serialization;
namespace OpenFeature.Model;
@@ -6,6 +7,7 @@ namespace OpenFeature.Model;
/// Values serve as a return type for provider objects. Providers may deal in JSON, protobuf, XML or some other data-interchange format.
/// This intermediate representation provides a good medium of exchange.
///
+[JsonConverter(typeof(ValueJsonConverter))]
public sealed class Value : IEquatable
{
private readonly object? _innerValue;
diff --git a/src/OpenFeature/Model/ValueJsonConverter.cs b/src/OpenFeature/Model/ValueJsonConverter.cs
new file mode 100644
index 000000000..1551106cc
--- /dev/null
+++ b/src/OpenFeature/Model/ValueJsonConverter.cs
@@ -0,0 +1,123 @@
+using System.Diagnostics;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace OpenFeature.Model;
+
+internal sealed class ValueJsonConverter : JsonConverter
+{
+ public override void Write(Utf8JsonWriter writer, Value value, JsonSerializerOptions options) =>
+ WriteJsonValue(value, writer);
+
+ public override Value Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ ReadJsonValue(ref reader);
+
+ private static void WriteJsonValue(Value value, Utf8JsonWriter writer)
+ {
+ if (value.IsNull)
+ {
+ writer.WriteNullValue();
+ return;
+ }
+
+ if (value.IsBoolean)
+ {
+ writer.WriteBooleanValue(value.AsBoolean.GetValueOrDefault());
+ return;
+ }
+
+ if (value.IsNumber)
+ {
+ writer.WriteNumberValue(value.AsDouble!.Value);
+ return;
+ }
+
+ if (value.IsString)
+ {
+ writer.WriteStringValue(value.AsString);
+ return;
+ }
+
+ if (value.IsDateTime)
+ {
+ writer.WriteStringValue(value.AsDateTime!.Value);
+ return;
+ }
+
+ if (value.IsList)
+ {
+ writer.WriteStartArray();
+
+ foreach (var item in value.AsList ?? [])
+ {
+ WriteJsonValue(item, writer);
+ }
+
+ writer.WriteEndArray();
+ return;
+ }
+
+ if (value.IsStructure)
+ {
+ writer.WriteStartObject();
+
+ var dic = value.AsStructure?.AsDictionary();
+ if (dic is { Count: > 0 })
+ {
+ foreach (var pair in dic)
+ {
+ writer.WritePropertyName(pair.Key);
+ WriteJsonValue(pair.Value, writer);
+ }
+ }
+
+ writer.WriteEndObject();
+ }
+ }
+
+ private static Value ReadJsonValue(ref Utf8JsonReader reader)
+ {
+ switch (reader.TokenType)
+ {
+ case JsonTokenType.True:
+ return new(true);
+ case JsonTokenType.False:
+ return new(false);
+ case JsonTokenType.Number:
+ if (reader.TryGetInt32(out var intVal))
+ return new(intVal);
+
+ return new(reader.GetDouble());
+ case JsonTokenType.String:
+ if (reader.TryGetDateTime(out var dateTime))
+ return new(dateTime);
+
+ return new(reader.GetString()!);
+ case JsonTokenType.StartArray:
+ var list = new List();
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndArray)
+ {
+ break;
+ }
+ list.Add(ReadJsonValue(ref reader));
+ }
+ return new(list);
+ case JsonTokenType.StartObject:
+ var objectBuilder = Structure.Builder();
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
+ {
+ var name = reader.GetString();
+ Debug.Assert(name is not null);
+ reader.Read();
+ objectBuilder.Set(name!, ReadJsonValue(ref reader));
+ }
+ return new(objectBuilder.Build());
+
+ default:
+ return new();
+ }
+ }
+}
+
diff --git a/src/OpenFeature/OpenFeature.csproj b/src/OpenFeature/OpenFeature.csproj
index 732c92f3b..2a733157d 100644
--- a/src/OpenFeature/OpenFeature.csproj
+++ b/src/OpenFeature/OpenFeature.csproj
@@ -11,6 +11,7 @@
+
diff --git a/test/OpenFeature.Tests/StructureTests.cs b/test/OpenFeature.Tests/StructureTests.cs
index b2b4e1c0f..c7b6b8786 100644
--- a/test/OpenFeature.Tests/StructureTests.cs
+++ b/test/OpenFeature.Tests/StructureTests.cs
@@ -1,4 +1,6 @@
using System.Collections.Immutable;
+using System.Text.Json;
+using System.Text.Json.Nodes;
using OpenFeature.Model;
namespace OpenFeature.Tests;
@@ -113,4 +115,66 @@ public void GetEnumerator_Should_Return_Enumerator()
enumerator.MoveNext();
Assert.Equal(VAL, enumerator.Current.Value.AsString);
}
+
+ [Theory]
+ [MemberData(nameof(JsonSerializeTestData))]
+ public void JsonSerializeTest(Value value, string expectedJson)
+ {
+ var serializedJsonNode = JsonSerializer.SerializeToNode(value);
+ var expectJsonNode = JsonNode.Parse(expectedJson);
+ Assert.True(JsonNode.DeepEquals(expectJsonNode, serializedJsonNode));
+ }
+
+ [Theory]
+ [MemberData(nameof(JsonSerializeTestData))]
+ public void JsonDeserializeTest(Value value, string expectedJson)
+ {
+ var serializedJsonNode = JsonSerializer.SerializeToNode(value);
+ var expectValue = JsonSerializer.Deserialize(expectedJson);
+ var expectJsonNode = JsonSerializer.SerializeToNode(expectValue);
+ Assert.True(JsonNode.DeepEquals(expectJsonNode, serializedJsonNode));
+ }
+
+ public static IEnumerable
-
+
\ No newline at end of file
diff --git a/src/OpenFeature/OpenFeatureClient.cs b/src/OpenFeature/OpenFeatureClient.cs
index c99f4f5c9..1f47d2d24 100644
--- a/src/OpenFeature/OpenFeatureClient.cs
+++ b/src/OpenFeature/OpenFeatureClient.cs
@@ -18,6 +18,7 @@ public sealed partial class FeatureClient : IFeatureClient
private readonly ConcurrentStack _hooks = new ConcurrentStack();
private readonly ILogger _logger;
private readonly Func _providerAccessor;
+ private readonly Api _api;
private EvaluationContext _evaluationContext;
private readonly object _evaluationContextLock = new object();
@@ -40,7 +41,7 @@ public sealed partial class FeatureClient : IFeatureClient
{
// Alias the provider reference so getting the method and returning the provider are
// guaranteed to be the same object.
- var provider = Api.Instance.GetProvider(this._metadata.Name!);
+ var provider = this._api.GetProvider(this._metadata.Name!);
return (method(provider), provider);
}
@@ -69,18 +70,20 @@ public void SetContext(EvaluationContext? context)
///
/// Initializes a new instance of the class.
///
+ /// The API instance for accessing global state and providers
/// Function to retrieve current provider
/// Name of client
/// Version of client
/// Logger used by client
/// Context given to this client
/// Throws if any of the required parameters are null
- internal FeatureClient(Func providerAccessor, string? name, string? version, ILogger? logger = null, EvaluationContext? context = null)
+ internal FeatureClient(Api api, Func providerAccessor, string? name, string? version, ILogger? logger = null, EvaluationContext? context = null)
{
+ this._api = api;
+ this._providerAccessor = providerAccessor;
this._metadata = new ClientMetadata(name, version);
this._logger = logger ?? NullLogger.Instance;
this._evaluationContext = context ?? EvaluationContext.Empty;
- this._providerAccessor = providerAccessor;
}
///
@@ -99,13 +102,13 @@ internal FeatureClient(Func providerAccessor, string? name, str
///
public void AddHandler(ProviderEventTypes eventType, EventHandlerDelegate handler)
{
- Api.Instance.AddClientHandler(this._metadata.Name!, eventType, handler);
+ this._api.AddClientHandler(this._metadata.Name!, eventType, handler);
}
///
public void RemoveHandler(ProviderEventTypes type, EventHandlerDelegate handler)
{
- Api.Instance.RemoveClientHandler(this._metadata.Name!, type, handler);
+ this._api.RemoveClientHandler(this._metadata.Name!, type, handler);
}
///
@@ -213,13 +216,13 @@ private async Task> EvaluateFlagAsync(
// merge api, client, transaction and invocation context
var evaluationContextBuilder = EvaluationContext.Builder();
- evaluationContextBuilder.Merge(Api.Instance.GetContext()); // API context
+ evaluationContextBuilder.Merge(this._api.GetContext()); // API context
evaluationContextBuilder.Merge(this.GetContext()); // Client context
- evaluationContextBuilder.Merge(Api.Instance.GetTransactionContext()); // Transaction context
+ evaluationContextBuilder.Merge(this._api.GetTransactionContext()); // Transaction context
evaluationContextBuilder.Merge(context); // Invocation context
var allHooks = ImmutableList.CreateBuilder()
- .Concat(Api.Instance.GetHooks())
+ .Concat(this._api.GetHooks())
.Concat(this.GetHooks())
.Concat(options?.Hooks ?? Enumerable.Empty())
.Concat(provider.GetProviderHooks())
@@ -310,7 +313,7 @@ public void Track(string trackingEventName, EvaluationContext? evaluationContext
throw new ArgumentException("Tracking event cannot be null or empty.", nameof(trackingEventName));
}
- var globalContext = Api.Instance.GetContext();
+ var globalContext = this._api.GetContext();
var clientContext = this.GetContext();
var evaluationContextBuilder = EvaluationContext.Builder()
From c0eb12aaa6b2dbdc474d88e242d0e7659a3cc122 Mon Sep 17 00:00:00 2001
From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>
Date: Wed, 30 Jul 2025 12:52:15 -0400
Subject: [PATCH 43/90] chore(main): release 2.8.0 (#511)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* chore(main): release 2.8.0
Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>
* Add missing release
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
---------
Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
Co-authored-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
---
.release-please-manifest.json | 2 +-
CHANGELOG.md | 38 +++++++++++++++++++++++++++++++++++
README.md | 4 ++--
build/Common.prod.props | 2 +-
version.txt | 2 +-
5 files changed, 43 insertions(+), 5 deletions(-)
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 6ed9c8012..7a5647237 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "2.7.0"
+ ".": "2.8.0"
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7a176c613..3561fbd74 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,43 @@
# Changelog
+## [2.8.0](https://github.com/open-feature/dotnet-sdk/compare/v2.7.0...v2.8.0) (2025-07-30)
+
+
+### π Bug Fixes
+
+* update DI lifecycle to use container instead of static instance ([#534](https://github.com/open-feature/dotnet-sdk/issues/534)) ([1a3846d](https://github.com/open-feature/dotnet-sdk/commit/1a3846d7575e75b5d7d05ec2a7db0b0f82c7b274))
+
+
+### β¨ New Features
+
+* Add Hook Dependency Injection extension method with Hook instance ([#513](https://github.com/open-feature/dotnet-sdk/issues/513)) ([12396b7](https://github.com/open-feature/dotnet-sdk/commit/12396b7872a2db6533b33267cf9c299248c41472))
+* Add TraceEnricherHookOptions Custom Attributes ([#526](https://github.com/open-feature/dotnet-sdk/issues/526)) ([5a91005](https://github.com/open-feature/dotnet-sdk/commit/5a91005c888c8966145eae7745cc40b2b066f343))
+* Add Track method to IFeatureClient ([#519](https://github.com/open-feature/dotnet-sdk/issues/519)) ([2e70072](https://github.com/open-feature/dotnet-sdk/commit/2e7007277e19a0fbc4c4c3944d24eea1608712e6))
+* Support JSON Serialize for Value ([#529](https://github.com/open-feature/dotnet-sdk/issues/529)) ([6e521d2](https://github.com/open-feature/dotnet-sdk/commit/6e521d25c3dd53c45f2fd30f5319cae5cd2ff46d))
+* Add Metric Hook Custom Attributes ([#512](https://github.com/open-feature/dotnet-sdk/issues/512)) ([8c05d1d](https://github.com/open-feature/dotnet-sdk/commit/8c05d1d7363db89b8379e1a4e46e455f210888e2))
+
+
+### π§Ή Chore
+
+* Add comparison to Value ([#523](https://github.com/open-feature/dotnet-sdk/issues/523)) ([883f4f3](https://github.com/open-feature/dotnet-sdk/commit/883f4f3c8b553dc01b5accdbae2782ca7805e8ed))
+* **deps:** update github/codeql-action digest to 181d5ee ([#520](https://github.com/open-feature/dotnet-sdk/issues/520)) ([40bec0d](https://github.com/open-feature/dotnet-sdk/commit/40bec0d51b6fa782a8b6d90a3d84463f9fb73c1b))
+* **deps:** update github/codeql-action digest to 4e828ff ([#532](https://github.com/open-feature/dotnet-sdk/issues/532)) ([20d1f37](https://github.com/open-feature/dotnet-sdk/commit/20d1f37a4f8991419bb14dae7eec9a08c2b32bc6))
+* **deps:** update github/codeql-action digest to d6bbdef ([#527](https://github.com/open-feature/dotnet-sdk/issues/527)) ([03d3b9e](https://github.com/open-feature/dotnet-sdk/commit/03d3b9e5d6ff1706faffc25afeba80a0e2bb37ec))
+* **deps:** update spec digest to 224b26e ([#521](https://github.com/open-feature/dotnet-sdk/issues/521)) ([fbc2645](https://github.com/open-feature/dotnet-sdk/commit/fbc2645efd649c0c37bd1a1cf473fbd98d920948))
+* **deps:** update spec digest to baec39b ([#528](https://github.com/open-feature/dotnet-sdk/issues/528)) ([a0ae014](https://github.com/open-feature/dotnet-sdk/commit/a0ae014d3194fcf6e5e5e4a17a2f92b1df3dc7c7))
+* remove redundant rule (now in parent) ([929fa74](https://github.com/open-feature/dotnet-sdk/commit/929fa7497197214d385eeaa40aba008932d00896))
+
+
+### π Documentation
+
+* fix anchor link in readme ([#525](https://github.com/open-feature/dotnet-sdk/issues/525)) ([18705c7](https://github.com/open-feature/dotnet-sdk/commit/18705c7338a0c89f163f808c81e513a029c95239))
+* remove curly brace from readme ([8c92524](https://github.com/open-feature/dotnet-sdk/commit/8c92524edbf4579d4ad62c699b338b9811a783fd))
+
+
+### π Refactoring
+
+* Simplify Provider Repository ([#515](https://github.com/open-feature/dotnet-sdk/issues/515)) ([2547a57](https://github.com/open-feature/dotnet-sdk/commit/2547a574e0d0328f909b7e69f3775d07492de3dd))
+
## [2.7.0](https://github.com/open-feature/dotnet-sdk/compare/v2.6.0...v2.7.0) (2025-07-03)
diff --git a/README.md b/README.md
index 4caa736d3..511fa4503 100644
--- a/README.md
+++ b/README.md
@@ -10,8 +10,8 @@
[](https://github.com/open-feature/spec/releases/tag/v0.8.0)
[
-
-](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.7.0)
+
+](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.8.0)
[](https://cloud-native.slack.com/archives/C0344AANLA1)
[](https://codecov.io/gh/open-feature/dotnet-sdk)
diff --git a/build/Common.prod.props b/build/Common.prod.props
index 0a05126f5..047997257 100644
--- a/build/Common.prod.props
+++ b/build/Common.prod.props
@@ -9,7 +9,7 @@
- 2.7.0
+ 2.8.0
git
https://github.com/open-feature/dotnet-sdk
OpenFeature is an open standard for feature flag management, created to support a robust feature flag ecosystem using cloud native technologies. OpenFeature will provide a unified API and SDK, and a developer-first, cloud-native implementation, with extensibility for open source and commercial offerings.
diff --git a/version.txt b/version.txt
index 24ba9a38d..834f26295 100644
--- a/version.txt
+++ b/version.txt
@@ -1 +1 @@
-2.7.0
+2.8.0
From e03aeba0f515f668afaba0a3c6f0ea01b44d6ee4 Mon Sep 17 00:00:00 2001
From: Weihan Li
Date: Fri, 1 Aug 2025 02:06:58 +0800
Subject: [PATCH 44/90] fix: expose ValueJsonConverter for generator support
and add JsonSourceGenerator test cases (#537)
* feat: expose ValueJsonConverter and add JsonSourceGenerator test cases
Signed-off-by: Weihan Li
* style: apply dotnet-format
Signed-off-by: Weihan Li
* feat: let the sample aot safe
Signed-off-by: Weihan Li
* feat: enable aot analyzer and add necessary annotation
Signed-off-by: Weihan Li
* feat: update aot support for sample project
Signed-off-by: Weihan Li
* build: fix aot publish error
Signed-off-by: Weihan Li
* build: simplify the PublishAot error workaround
Signed-off-by: Weihan Li
* build: fix format action error
Signed-off-by: Weihan Li
* sample: update sample usage
Signed-off-by: Weihan Li
---------
Signed-off-by: Weihan Li
---
.github/workflows/ci.yml | 4 ++
.github/workflows/dotnet-format.yml | 4 +-
samples/AspNetCore/Program.cs | 38 ++++++++++++++++++-
samples/AspNetCore/Samples.AspNetCore.csproj | 2 +
src/Directory.Build.props | 4 ++
.../OpenFeatureBuilderExtensions.cs | 25 ++++++++++--
src/OpenFeature/Model/ValueJsonConverter.cs | 7 +++-
test/OpenFeature.Tests/StructureTests.cs | 26 +++++++++++++
8 files changed, 102 insertions(+), 8 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c5b5edb77..1f72a15f3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -53,6 +53,10 @@ jobs:
- name: Test
run: dotnet test -c Release --no-build --logger GitHubActions
+ - name: aot-publish test
+ run: |
+ dotnet publish ./samples/AspNetCore/Samples.AspNetCore.csproj
+
packaging:
needs: build
diff --git a/.github/workflows/dotnet-format.yml b/.github/workflows/dotnet-format.yml
index e35e37756..75f603750 100644
--- a/.github/workflows/dotnet-format.yml
+++ b/.github/workflows/dotnet-format.yml
@@ -23,4 +23,6 @@ jobs:
global-json-file: global.json
- name: dotnet format
- run: dotnet format --verify-no-changes OpenFeature.slnx
+ run: |
+ # Exclude diagnostics to work around dotnet-format issue, see https://github.com/dotnet/sdk/issues/50012
+ dotnet format --verify-no-changes OpenFeature.slnx --exclude-diagnostics IL2026 --exclude-diagnostics IL3050
diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs
index e09213076..e8faf5a5e 100644
--- a/samples/AspNetCore/Program.cs
+++ b/samples/AspNetCore/Program.cs
@@ -1,16 +1,23 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;
using OpenFeature;
using OpenFeature.DependencyInjection.Providers.Memory;
using OpenFeature.Hooks;
+using OpenFeature.Model;
using OpenFeature.Providers.Memory;
-using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
-var builder = WebApplication.CreateBuilder(args);
+var builder = WebApplication.CreateSlimBuilder(args);
// Add services to the container.
+builder.Services.ConfigureHttpJsonOptions(options =>
+{
+ options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
+});
+
builder.Services.AddProblemDetails();
// Configure OpenTelemetry
@@ -40,6 +47,14 @@
{
"welcome-message", new Flag(
new Dictionary { { "show", true }, { "hide", false } }, "show")
+ },
+ {
+ "test-config", new Flag(new Dictionary()
+ {
+ { "enable", new Value(Structure.Builder().Set(nameof(TestConfig.Threshold), 100).Build()) },
+ { "half", new Value(Structure.Builder().Set(nameof(TestConfig.Threshold), 50).Build()) },
+ { "disable", new Value(Structure.Builder().Set(nameof(TestConfig.Threshold), 0).Build()) }
+ }, "disable")
}
});
});
@@ -60,5 +75,24 @@
return TypedResults.Ok("Hello world!");
});
+app.MapGet("/test-config", async ([FromServices] IFeatureClient featureClient) =>
+{
+ var testConfigValue = await featureClient.GetObjectValueAsync("test-config",
+ new Value(Structure.Builder().Set("Threshold", 50).Build())
+ );
+ var json = JsonSerializer.Serialize(testConfigValue, AppJsonSerializerContext.Default.Value);
+ var config = JsonSerializer.Deserialize(json, AppJsonSerializerContext.Default.TestConfig);
+ return Results.Ok(config);
+});
app.Run();
+
+
+public class TestConfig
+{
+ public int Threshold { get; set; } = 10;
+}
+
+[JsonSerializable(typeof(TestConfig))]
+[JsonSerializable(typeof(Value))]
+public partial class AppJsonSerializerContext : JsonSerializerContext;
diff --git a/samples/AspNetCore/Samples.AspNetCore.csproj b/samples/AspNetCore/Samples.AspNetCore.csproj
index cd249ab3e..b6223bd04 100644
--- a/samples/AspNetCore/Samples.AspNetCore.csproj
+++ b/samples/AspNetCore/Samples.AspNetCore.csproj
@@ -2,6 +2,8 @@
false
+ true
+ true
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 992a61958..3b7879044 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -1,3 +1,7 @@
+
+
+ $([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))
+
diff --git a/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs
index 01a535e04..d676dc5e9 100644
--- a/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs
+++ b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs
@@ -272,7 +272,11 @@ public static OpenFeatureBuilder AddPolicyName(this OpenFeatureBuilder builder,
/// The instance.
/// Optional factory for controlling how will be created in the DI container.
/// The instance.
- public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, Func? implementationFactory = null)
+ public static OpenFeatureBuilder AddHook<
+#if NET
+ [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
+#endif
+ THook>(this OpenFeatureBuilder builder, Func? implementationFactory = null)
where THook : Hook
{
return builder.AddHook(typeof(THook).Name, implementationFactory);
@@ -285,7 +289,11 @@ public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder,
/// The instance.
/// Instance of Hook to inject into the OpenFeature context.
/// The instance.
- public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, THook hook)
+ public static OpenFeatureBuilder AddHook<
+#if NET
+ [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
+#endif
+ THook>(this OpenFeatureBuilder builder, THook hook)
where THook : Hook
{
return builder.AddHook(typeof(THook).Name, hook);
@@ -299,7 +307,11 @@ public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder,
/// The name of the that is being added.
/// Instance of Hook to inject into the OpenFeature context.
/// The instance.
- public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, string hookName, THook hook)
+ public static OpenFeatureBuilder AddHook<
+#if NET
+ [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
+#endif
+ THook>(this OpenFeatureBuilder builder, string hookName, THook hook)
where THook : Hook
{
return builder.AddHook(hookName, _ => hook);
@@ -313,7 +325,12 @@ public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder,
/// The name of the that is being added.
/// Optional factory for controlling how will be created in the DI container.
/// The instance.
- public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, string hookName, Func? implementationFactory = null)
+ public static OpenFeatureBuilder AddHook<
+#if NET
+ [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
+#endif
+ THook>
+ (this OpenFeatureBuilder builder, string hookName, Func? implementationFactory = null)
where THook : Hook
{
builder.Services.PostConfigure(options => options.AddHookName(hookName));
diff --git a/src/OpenFeature/Model/ValueJsonConverter.cs b/src/OpenFeature/Model/ValueJsonConverter.cs
index 1551106cc..911cc45fd 100644
--- a/src/OpenFeature/Model/ValueJsonConverter.cs
+++ b/src/OpenFeature/Model/ValueJsonConverter.cs
@@ -4,11 +4,16 @@
namespace OpenFeature.Model;
-internal sealed class ValueJsonConverter : JsonConverter
+///
+/// A for for Json serialization
+///
+public sealed class ValueJsonConverter : JsonConverter
{
+ ///
public override void Write(Utf8JsonWriter writer, Value value, JsonSerializerOptions options) =>
WriteJsonValue(value, writer);
+ ///
public override Value Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
ReadJsonValue(ref reader);
diff --git a/test/OpenFeature.Tests/StructureTests.cs b/test/OpenFeature.Tests/StructureTests.cs
index c7b6b8786..9412e5d33 100644
--- a/test/OpenFeature.Tests/StructureTests.cs
+++ b/test/OpenFeature.Tests/StructureTests.cs
@@ -1,6 +1,7 @@
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
using OpenFeature.Model;
namespace OpenFeature.Tests;
@@ -125,6 +126,15 @@ public void JsonSerializeTest(Value value, string expectedJson)
Assert.True(JsonNode.DeepEquals(expectJsonNode, serializedJsonNode));
}
+ [Theory]
+ [MemberData(nameof(JsonSerializeTestData))]
+ public void JsonSerializeWithGeneratorTest(Value value, string expectedJson)
+ {
+ var serializedJsonNode = JsonSerializer.SerializeToNode(value, ValueJsonSerializerContext.Default.Value);
+ var expectJsonNode = JsonNode.Parse(expectedJson);
+ Assert.True(JsonNode.DeepEquals(expectJsonNode, serializedJsonNode));
+ }
+
[Theory]
[MemberData(nameof(JsonSerializeTestData))]
public void JsonDeserializeTest(Value value, string expectedJson)
@@ -135,6 +145,17 @@ public void JsonDeserializeTest(Value value, string expectedJson)
Assert.True(JsonNode.DeepEquals(expectJsonNode, serializedJsonNode));
}
+ [Theory]
+ [MemberData(nameof(JsonSerializeTestData))]
+ public void JsonDeserializeWithGeneratorTest(Value value, string expectedJson)
+ {
+ var serializedJsonNode = JsonSerializer.SerializeToNode(value, ValueJsonSerializerContext.Default.Value);
+ var expectValue = JsonSerializer.Deserialize(expectedJson, ValueJsonSerializerContext.Default.Value);
+ Assert.NotNull(expectValue);
+ var expectJsonNode = JsonSerializer.SerializeToNode(expectValue, ValueJsonSerializerContext.Default.Value);
+ Assert.True(JsonNode.DeepEquals(expectJsonNode, serializedJsonNode));
+ }
+
public static IEnumerable
- 2.8.0
+ 2.8.1
git
https://github.com/open-feature/dotnet-sdk
OpenFeature is an open standard for feature flag management, created to support a robust feature flag ecosystem using cloud native technologies. OpenFeature will provide a unified API and SDK, and a developer-first, cloud-native implementation, with extensibility for open source and commercial offerings.
diff --git a/version.txt b/version.txt
index 834f26295..dbe590065 100644
--- a/version.txt
+++ b/version.txt
@@ -1 +1 @@
-2.8.0
+2.8.1
From 7237053561d9c36194197169734522f0b978f6e5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Silva?=
<2493377+askpt@users.noreply.github.com>
Date: Tue, 12 Aug 2025 17:09:25 +0100
Subject: [PATCH 46/90] feat: Add multi-provider support (#488)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: implement MultiProvider class with placeholder methods for feature resolution
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* feat: add BaseEvaluationStrategy and MultiProvider classes for multi-provider feature flag evaluation
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* feat: implement MultiProvider methods for feature flag resolution using evaluation strategy
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* feat: add ComparisonStrategy, FirstMatchStrategy, and FirstSuccessfulStrategy classes for feature flag evaluation
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* feat: implement EvaluateAsync method in FirstMatchStrategy for type-specific feature resolution
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* feat: enhance error handling in FirstMatchStrategy for feature flag resolution
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* feat: implement EvaluateAsync method in FirstSuccessfulStrategy for multi-type feature resolution
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* feat: refactor feature resolution strategies to use EvaluateAsync method for improved multi-provider support
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* Removed ComparisonStrategy.cs
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* feat: add unit tests for FirstMatchStrategy and FirstSuccessfulStrategy to enhance multi-provider support
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* feat: add unit tests for FirstSuccessfulStrategy to validate multi-provider evaluation logic
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* feat: add unit tests for MultiProvider and ProviderExtensions to validate multi-provider functionality
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* feat: add unit tests for MultiProvider to validate functionality and strategy delegation
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* fix: update GetMetadata method to return non-nullable Metadata type
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* feat: implement ShutdownAsync method to gracefully shut down all providers
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* feat: implement InitializeAsync method to initialize all providers
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* Move to Extensions folder
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* test: add initialization and shutdown tests for MultiProvider
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* fix: enhance ShutdownAsync to handle exceptions from multiple providers
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* feat: implement ComparisonStrategy for evaluating provider values with fallback and mismatch handling
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* feat: add constructor to MultiProvider for default evaluation strategy
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* refactor: update ComparisonStrategy and MultiProviderTests for improved clarity and consistency
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* refactor: rename namespaces from OpenFeature.Extensions.MultiProvider to OpenFeature.Providers.MultiProvider
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* Removed old files
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* feat: add multi-provider support with evaluation strategies
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* Revert "Move to Extensions folder"
This reverts commit 9ffd149205199783f0b4cfbb6cbbfcbf7c502960.
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* refactor: use 'this' keyword for clarity in constructors across multiple models
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* fix: add missing space in ProviderStatus exception handling for consistency
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* feat: enhance ProviderResolutionResult to include exception details in resolution results
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* feat: add error collection method and refine ProviderError to use Exception type
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* refactor: simplify error handling in FirstMatchStrategy and FirstSuccessfulStrategy
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* test: add unit tests for FirstSuccessfulStrategy behavior and result determination
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* test: add unit tests for FirstMatchStrategy behavior and result determination
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* test: simplify tests
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* test: add unit tests for ComparisonStrategy RunMode behavior
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* test: add unit tests for ComparisonStrategy functionality
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* test: add unit tests for ProviderEntry, ProviderStatus, and RegisteredProvider classes
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* test: add unit tests for FinalResult and ProviderError classes
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* test: add unit tests for ProviderExtensions functionality
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* test: add unit tests for MultiProvider functionality
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* test: add unit tests for BaseEvaluationStrategy functionality
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* test: add multi-provider endpoint and evaluation logic
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* refactor: update RegisteredProvider to use internal access modifiers and enhance status management; add unit test for SetStatus method
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* docs: add Multi-Provider section to README with usage examples and evaluation strategies
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* refactor: change properties in StrategyPerProviderContext to use read-only accessors
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* docs: update summary comments in FirstMatchStrategy and FirstSuccessfulStrategy to clarify provider evaluation order
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* Refactor StrategyEvaluationContext to use generic types
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* refactor: simplify flag resolution logic in EvaluateAsync method
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* refactor: replace hardcoded provider name with constant in MultiProvider strategies
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* refactor: Rename Exception property to Error in ProviderStatus class
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* feat: Improved the thread safety for Multiprovider.
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* fix: Update exception object name in MultiProvider tests
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* fix: Update ObjectDisposedException object name in MultiProvider tests
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* fix: Remove volatile modifier from status fields in RegisteredProvider and MultiProvider
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* docs: Clarify evaluation strategy parameter description in MultiProvider constructor
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* fix: Update shutdown logic to allow shutdown in Ready or Fatal status and add corresponding tests
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* refactor: Move fallback provider resolution logic to a more appropriate location in ComparisonStrategy
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* fix: Simplify multi-provider endpoint response and improve error handling
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* fix: Improve dispose pattern handling in MultiProvider to ensure correct async initialization and shutdown
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* fix: Mark _disposed as volatile to ensure thread-safe access in async methods
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
* fix: Update MultiProvider to implement IAsyncDisposable and improve dispose pattern handling
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
---------
Signed-off-by: AndrΓ© Silva <2493377+askpt@users.noreply.github.com>
---
README.md | 93 +-
samples/AspNetCore/Program.cs | 54 ++
.../Models/ChildProviderStatus.cs | 7 +
.../MultiProvider/Models/ProviderEntry.cs | 29 +
.../Models/RegisteredProvider.cs | 41 +
.../Providers/MultiProvider/MultiProvider.cs | 340 +++++++
.../MultiProvider/MultiProviderConstants.cs | 12 +
.../MultiProvider/ProviderExtensions.cs | 46 +
.../Strategies/BaseEvaluationStrategy.cs | 129 +++
.../Strategies/ComparisonStrategy.cs | 80 ++
.../Strategies/FirstMatchStrategy.cs | 36 +
.../Strategies/FirstSuccessfulStrategy.cs | 47 +
.../Strategies/Models/FinalResult.cs | 45 +
.../Strategies/Models/ProviderError.cs | 29 +
.../Models/ProviderResolutionResult.cs | 45 +
.../Strategies/Models/RunMode.cs | 17 +
.../Models/StrategyEvaluationContext.cs | 22 +
.../Models/StrategyPerProviderContext.cs | 40 +
.../Models/ChildProviderEntryTests.cs | 93 ++
.../Models/ProviderStatusTests.cs | 123 +++
.../Models/RegisteredProviderTests.cs | 116 +++
.../MultiProvider/MultiProviderTests.cs | 851 ++++++++++++++++++
.../MultiProvider/ProviderExtensionsTests.cs | 334 +++++++
.../Strategies/BaseEvaluationStrategyTests.cs | 500 ++++++++++
.../Strategies/ComparisonStrategyTests.cs | 475 ++++++++++
.../Strategies/FirstMatchStrategyTests.cs | 323 +++++++
.../FirstSuccessfulStrategyTests.cs | 240 +++++
.../Strategies/Models/FinalResultTests.cs | 260 ++++++
.../Strategies/Models/ProviderErrorTests.cs | 146 +++
29 files changed, 4572 insertions(+), 1 deletion(-)
create mode 100644 src/OpenFeature/Providers/MultiProvider/Models/ChildProviderStatus.cs
create mode 100644 src/OpenFeature/Providers/MultiProvider/Models/ProviderEntry.cs
create mode 100644 src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs
create mode 100644 src/OpenFeature/Providers/MultiProvider/MultiProvider.cs
create mode 100644 src/OpenFeature/Providers/MultiProvider/MultiProviderConstants.cs
create mode 100644 src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs
create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs
create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs
create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs
create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs
create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/Models/FinalResult.cs
create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs
create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs
create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/Models/RunMode.cs
create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs
create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs
create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Models/ChildProviderEntryTests.cs
create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs
create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs
create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs
create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs
create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Strategies/BaseEvaluationStrategyTests.cs
create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Strategies/ComparisonStrategyTests.cs
create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs
create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs
create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/FinalResultTests.cs
create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/ProviderErrorTests.cs
diff --git a/README.md b/README.md
index 8349bc19a..2da256cd8 100644
--- a/README.md
+++ b/README.md
@@ -113,7 +113,8 @@ Want to contribute a new sample? See our [CONTRIBUTING](CONTRIBUTING.md) guide!
| β
| [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
| β
| [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread). |
| β
| [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
-| π¬ | [DependencyInjection](#dependency-injection) | Integrate OpenFeature with .NET's dependency injection for streamlined provider setup. |
+| π¬ | [Multi-Provider](#multi-provider) | Use multiple feature flag providers simultaneously with configurable evaluation strategies. |
+| π¬ | [DependencyInjection](#dependency-injection) | Integrate OpenFeature with .NET's dependency injection for streamlined provider setup. |
> Implemented: β
| In-progress: β οΈ | Not implemented yet: β | Experimental: π¬
@@ -433,6 +434,96 @@ Hooks support passing per-evaluation data between that stages using `hook data`.
Built a new hook? [Let us know](https://github.com/open-feature/openfeature.dev/issues/new?assignees=&labels=hook&projects=&template=document-hook.yaml&title=%5BHook%5D%3A+) so we can add it to the docs!
+### Multi-Provider
+
+> [!NOTE]
+> The Multi-Provider feature is currently experimental. Hooks and events are not supported at the moment.
+
+The Multi-Provider enables the use of multiple underlying feature flag providers simultaneously, allowing different providers to be used for different flag keys or based on specific evaluation strategies.
+
+#### Basic Usage
+
+```csharp
+using OpenFeature.Providers.MultiProvider;
+using OpenFeature.Providers.MultiProvider.Models;
+using OpenFeature.Providers.MultiProvider.Strategies;
+
+// Create provider entries
+var providerEntries = new List
+{
+ new(new InMemoryProvider(provider1Flags), "Provider1"),
+ new(new InMemoryProvider(provider2Flags), "Provider2")
+};
+
+// Create multi-provider with FirstMatchStrategy (default)
+var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy());
+
+// Set as the default provider
+await Api.Instance.SetProviderAsync(multiProvider);
+
+// Use normally - the multi-provider will handle delegation
+var client = Api.Instance.GetClient();
+var flagValue = await client.GetBooleanValueAsync("my-flag", false);
+```
+
+#### Evaluation Strategies
+
+The Multi-Provider supports different evaluation strategies that determine how multiple providers are used:
+
+##### FirstMatchStrategy (Default)
+
+Evaluates providers sequentially and returns the first result that is not "flag not found". If any provider returns an error, that error is returned immediately.
+
+```csharp
+var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy());
+```
+
+##### FirstSuccessfulStrategy
+
+Evaluates providers sequentially and returns the first successful result, ignoring errors. Only if all providers fail will errors be returned.
+
+```csharp
+var multiProvider = new MultiProvider(providerEntries, new FirstSuccessfulStrategy());
+```
+
+##### ComparisonStrategy
+
+Evaluates all providers in parallel and compares results. If values agree, returns the agreed value. If they disagree, returns the fallback provider's value (or first provider if no fallback is specified) and optionally calls a mismatch callback.
+
+```csharp
+// Basic comparison
+var multiProvider = new MultiProvider(providerEntries, new ComparisonStrategy());
+
+// With fallback provider
+var multiProvider = new MultiProvider(providerEntries,
+ new ComparisonStrategy(fallbackProvider: provider1));
+
+// With mismatch callback
+var multiProvider = new MultiProvider(providerEntries,
+ new ComparisonStrategy(onMismatch: (mismatchDetails) => {
+ // Log or handle mismatches between providers
+ foreach (var kvp in mismatchDetails)
+ {
+ Console.WriteLine($"Provider {kvp.Key}: {kvp.Value}");
+ }
+ }));
+```
+
+#### Evaluation Modes
+
+The Multi-Provider supports two evaluation modes:
+
+- **Sequential**: Providers are evaluated one after another (used by `FirstMatchStrategy` and `FirstSuccessfulStrategy`)
+- **Parallel**: All providers are evaluated simultaneously (used by `ComparisonStrategy`)
+
+#### Limitations
+
+- **Hooks are not supported**: Multi-Provider does not currently support hook registration or execution
+- **Events are not supported**: Provider events are not propagated from underlying providers
+- **Experimental status**: The API may change in future releases
+
+For a complete example, see the [AspNetCore sample](./samples/AspNetCore/README.md) which demonstrates Multi-Provider usage.
+
### Dependency Injection
> [!NOTE]
diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs
index e8faf5a5e..90d1888c6 100644
--- a/samples/AspNetCore/Program.cs
+++ b/samples/AspNetCore/Program.cs
@@ -6,6 +6,9 @@
using OpenFeature.Hooks;
using OpenFeature.Model;
using OpenFeature.Providers.Memory;
+using OpenFeature.Providers.MultiProvider;
+using OpenFeature.Providers.MultiProvider.Models;
+using OpenFeature.Providers.MultiProvider.Strategies;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
@@ -75,6 +78,7 @@
return TypedResults.Ok("Hello world!");
});
+
app.MapGet("/test-config", async ([FromServices] IFeatureClient featureClient) =>
{
var testConfigValue = await featureClient.GetObjectValueAsync("test-config",
@@ -85,6 +89,56 @@
return Results.Ok(config);
});
+app.MapGet("/multi-provider", async () =>
+{
+ // Create first in-memory provider with some flags
+ var provider1Flags = new Dictionary
+ {
+ { "providername", new Flag(new Dictionary { { "enabled", "enabled-provider1" }, { "disabled", "disabled-provider1" } }, "enabled") },
+ { "max-items", new Flag(new Dictionary { { "low", 10 }, { "high", 100 } }, "high") },
+ };
+ var provider1 = new InMemoryProvider(provider1Flags);
+
+ // Create second in-memory provider with different flags
+ var provider2Flags = new Dictionary
+ {
+ { "providername", new Flag(new Dictionary { { "enabled", "enabled-provider2" }, { "disabled", "disabled-provider2" } }, "enabled") },
+ };
+ var provider2 = new InMemoryProvider(provider2Flags);
+
+ // Create provider entries
+ var providerEntries = new List
+ {
+ new(provider1, "Provider1"),
+ new(provider2, "Provider2")
+ };
+
+ // Create multi-provider with FirstMatchStrategy (default)
+ var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy());
+
+ // Set the multi-provider as the default provider using OpenFeature API
+ await Api.Instance.SetProviderAsync(multiProvider);
+
+ // Create a client directly using the API
+ var client = Api.Instance.GetClient();
+
+ try
+ {
+ // Test flag evaluation from different providers
+ var maxItemsFlag = await client.GetIntegerDetailsAsync("max-items", 0);
+ var providerNameFlag = await client.GetStringDetailsAsync("providername", "default");
+
+ // Test a flag that doesn't exist in any provider
+ var unknownFlag = await client.GetBooleanDetailsAsync("unknown-flag", false);
+
+ return Results.Ok();
+ }
+ catch (Exception)
+ {
+ return Results.InternalServerError();
+ }
+});
+
app.Run();
diff --git a/src/OpenFeature/Providers/MultiProvider/Models/ChildProviderStatus.cs b/src/OpenFeature/Providers/MultiProvider/Models/ChildProviderStatus.cs
new file mode 100644
index 000000000..f66f8fae7
--- /dev/null
+++ b/src/OpenFeature/Providers/MultiProvider/Models/ChildProviderStatus.cs
@@ -0,0 +1,7 @@
+namespace OpenFeature.Providers.MultiProvider.Models;
+
+internal class ChildProviderStatus
+{
+ public string ProviderName { get; set; } = string.Empty;
+ public Exception? Error { get; set; }
+}
diff --git a/src/OpenFeature/Providers/MultiProvider/Models/ProviderEntry.cs b/src/OpenFeature/Providers/MultiProvider/Models/ProviderEntry.cs
new file mode 100644
index 000000000..da720da6c
--- /dev/null
+++ b/src/OpenFeature/Providers/MultiProvider/Models/ProviderEntry.cs
@@ -0,0 +1,29 @@
+namespace OpenFeature.Providers.MultiProvider.Models;
+
+///
+/// Represents an entry for a provider in the multi-provider configuration.
+///
+public class ProviderEntry
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The feature provider instance.
+ /// Optional custom name for the provider. If not provided, the provider's metadata name will be used.
+ public ProviderEntry(FeatureProvider provider, string? name = null)
+ {
+ this.Provider = provider ?? throw new ArgumentNullException(nameof(provider));
+ this.Name = name;
+ }
+
+ ///
+ /// Gets the feature provider instance.
+ ///
+ public FeatureProvider Provider { get; }
+
+ ///
+ /// Gets the optional custom name for the provider.
+ /// If null, the provider's metadata name should be used.
+ ///
+ public string? Name { get; }
+}
diff --git a/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs b/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs
new file mode 100644
index 000000000..ee62fd006
--- /dev/null
+++ b/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs
@@ -0,0 +1,41 @@
+namespace OpenFeature.Providers.MultiProvider.Models;
+
+internal class RegisteredProvider
+{
+#if NET9_0_OR_GREATER
+ private readonly Lock _statusLock = new();
+#else
+ private readonly object _statusLock = new object();
+#endif
+
+ private Constant.ProviderStatus _status = Constant.ProviderStatus.NotReady;
+
+ internal RegisteredProvider(FeatureProvider provider, string name)
+ {
+ this.Provider = provider ?? throw new ArgumentNullException(nameof(provider));
+ this.Name = name ?? throw new ArgumentNullException(nameof(name));
+ }
+
+ internal FeatureProvider Provider { get; }
+
+ internal string Name { get; }
+
+ internal Constant.ProviderStatus Status
+ {
+ get
+ {
+ lock (this._statusLock)
+ {
+ return this._status;
+ }
+ }
+ }
+
+ internal void SetStatus(Constant.ProviderStatus status)
+ {
+ lock (this._statusLock)
+ {
+ this._status = status;
+ }
+ }
+}
diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs
new file mode 100644
index 000000000..73ce72eba
--- /dev/null
+++ b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs
@@ -0,0 +1,340 @@
+using System.Collections.ObjectModel;
+using OpenFeature.Constant;
+using OpenFeature.Model;
+using OpenFeature.Providers.MultiProvider.Models;
+using OpenFeature.Providers.MultiProvider.Strategies;
+using OpenFeature.Providers.MultiProvider.Strategies.Models;
+
+namespace OpenFeature.Providers.MultiProvider;
+
+///
+/// A feature provider that enables the use of multiple underlying providers, allowing different providers
+/// to be used for different flag keys or based on specific routing logic.
+///
+///
+/// The MultiProvider acts as a composite provider that can delegate flag resolution to different
+/// underlying providers based on configuration or routing rules. This enables scenarios where
+/// different feature flags may be served by different sources or providers within the same application.
+///
+/// Multi Provider specification
+public sealed class MultiProvider : FeatureProvider, IAsyncDisposable
+{
+ private readonly BaseEvaluationStrategy _evaluationStrategy;
+ private readonly IReadOnlyList _registeredProviders;
+ private readonly Metadata _metadata;
+
+ private readonly SemaphoreSlim _initializationSemaphore = new(1, 1);
+ private readonly SemaphoreSlim _shutdownSemaphore = new(1, 1);
+ private ProviderStatus _providerStatus = ProviderStatus.NotReady;
+ // 0 = Not disposed, 1 = Disposed
+ // This is to handle the dispose pattern correctly with the async initialization and shutdown methods
+ private volatile int _disposed = 0;
+
+ ///
+ /// Initializes a new instance of the class with the specified provider entries and evaluation strategy.
+ ///
+ /// A collection of provider entries containing the feature providers and their optional names.
+ /// The base evaluation strategy to use for determining how to evaluate features across multiple providers. If not specified, the first matching strategy will be used.
+ public MultiProvider(IEnumerable providerEntries, BaseEvaluationStrategy? evaluationStrategy = null)
+ {
+ if (providerEntries == null)
+ {
+ throw new ArgumentNullException(nameof(providerEntries));
+ }
+
+ var entries = providerEntries.ToList();
+ if (entries.Count == 0)
+ {
+ throw new ArgumentException("At least one provider entry must be provided.", nameof(providerEntries));
+ }
+
+ this._evaluationStrategy = evaluationStrategy ?? new FirstMatchStrategy();
+ this._registeredProviders = RegisterProviders(entries);
+
+ // Create aggregate metadata
+ this._metadata = new Metadata(MultiProviderConstants.ProviderName);
+ }
+
+ ///
+ public override Metadata GetMetadata() => this._metadata;
+
+ ///
+ public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) =>
+ this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken);
+
+ ///
+ public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) =>
+ this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken);
+
+ ///
+ public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) =>
+ this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken);
+
+ ///
+ public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) =>
+ this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken);
+
+ ///
+ public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) =>
+ this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken);
+
+
+ ///
+ public override async Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default)
+ {
+ if (this._disposed == 1)
+ {
+ throw new ObjectDisposedException(nameof(MultiProvider));
+ }
+
+ await this._initializationSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (this._providerStatus != ProviderStatus.NotReady || this._disposed == 1)
+ {
+ return;
+ }
+
+ var initializationTasks = this._registeredProviders.Select(async rp =>
+ {
+ try
+ {
+ await rp.Provider.InitializeAsync(context, cancellationToken).ConfigureAwait(false);
+ rp.SetStatus(ProviderStatus.Ready);
+ return new ChildProviderStatus { ProviderName = rp.Name };
+ }
+ catch (Exception ex)
+ {
+ rp.SetStatus(ProviderStatus.Fatal);
+ return new ChildProviderStatus { ProviderName = rp.Name, Error = ex };
+ }
+ });
+
+ var results = await Task.WhenAll(initializationTasks).ConfigureAwait(false);
+ var failures = results.Where(r => r.Error != null).ToList();
+
+ if (failures.Count != 0)
+ {
+ var exceptions = failures.Select(f => f.Error!).ToList();
+ var failedProviders = failures.Select(f => f.ProviderName).ToList();
+ this._providerStatus = ProviderStatus.Fatal;
+ throw new AggregateException(
+ $"Failed to initialize providers: {string.Join(", ", failedProviders)}",
+ exceptions);
+ }
+ else
+ {
+ this._providerStatus = ProviderStatus.Ready;
+ }
+ }
+ finally
+ {
+ this._initializationSemaphore.Release();
+ }
+ }
+
+ ///
+ public override async Task ShutdownAsync(CancellationToken cancellationToken = default)
+ {
+ if (this._disposed == 1)
+ {
+ throw new ObjectDisposedException(nameof(MultiProvider));
+ }
+
+ await this.InternalShutdownAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task> EvaluateAsync(string key, T defaultValue, EvaluationContext? evaluationContext = null, CancellationToken cancellationToken = default)
+ {
+ // Check if the provider has been disposed
+ // This is to handle the dispose pattern correctly with the async initialization and shutdown methods
+ // It is checked here to avoid the check in every public EvaluateAsync method
+ if (this._disposed == 1)
+ {
+ throw new ObjectDisposedException(nameof(MultiProvider));
+ }
+
+ var strategyContext = new StrategyEvaluationContext(key);
+ var resolutions = this._evaluationStrategy.RunMode switch
+ {
+ RunMode.Parallel => await this.ParallelEvaluationAsync(key, defaultValue, evaluationContext, cancellationToken).ConfigureAwait(false),
+ RunMode.Sequential => await this.SequentialEvaluationAsync(key, defaultValue, evaluationContext, cancellationToken).ConfigureAwait(false),
+ _ => throw new NotSupportedException($"Unsupported run mode: {this._evaluationStrategy.RunMode}")
+ };
+
+ var finalResult = this._evaluationStrategy.DetermineFinalResult(strategyContext, key, defaultValue, evaluationContext, resolutions);
+ return finalResult.Details;
+ }
+
+ private async Task>> SequentialEvaluationAsync(string key, T defaultValue, EvaluationContext? evaluationContext, CancellationToken cancellationToken)
+ {
+ var resolutions = new List>();
+
+ foreach (var registeredProvider in this._registeredProviders)
+ {
+ var providerContext = new StrategyPerProviderContext(
+ registeredProvider.Provider,
+ registeredProvider.Name,
+ registeredProvider.Status,
+ key);
+
+ if (!this._evaluationStrategy.ShouldEvaluateThisProvider(providerContext, evaluationContext))
+ {
+ continue;
+ }
+
+ var result = await registeredProvider.Provider.EvaluateAsync(providerContext, evaluationContext, defaultValue, cancellationToken).ConfigureAwait(false);
+ resolutions.Add(result);
+
+ if (!this._evaluationStrategy.ShouldEvaluateNextProvider(providerContext, evaluationContext, result))
+ {
+ break;
+ }
+ }
+
+ return resolutions;
+ }
+
+ private async Task>> ParallelEvaluationAsync(string key, T defaultValue, EvaluationContext? evaluationContext, CancellationToken cancellationToken)
+ {
+ var resolutions = new List>();
+ var tasks = new List>>();
+
+ foreach (var registeredProvider in this._registeredProviders)
+ {
+ var providerContext = new StrategyPerProviderContext(
+ registeredProvider.Provider,
+ registeredProvider.Name,
+ registeredProvider.Status,
+ key);
+
+ if (this._evaluationStrategy.ShouldEvaluateThisProvider(providerContext, evaluationContext))
+ {
+ tasks.Add(registeredProvider.Provider.EvaluateAsync(providerContext, evaluationContext, defaultValue, cancellationToken));
+ }
+ }
+
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+ resolutions.AddRange(results);
+
+ return resolutions;
+ }
+
+ private static ReadOnlyCollection RegisterProviders(IEnumerable providerEntries)
+ {
+ var entries = providerEntries.ToList();
+ var registeredProviders = new List();
+ var nameGroups = entries.GroupBy(e => e.Name ?? e.Provider.GetMetadata()?.Name ?? "UnknownProvider").ToList();
+
+ // Check for duplicate explicit names
+ var duplicateExplicitNames = nameGroups
+ .FirstOrDefault(g => g.Count(e => e.Name != null) > 1)?.Key;
+
+ if (duplicateExplicitNames != null)
+ {
+ throw new ArgumentException($"Multiple providers cannot have the same explicit name: '{duplicateExplicitNames}'");
+ }
+
+ // Assign unique names
+ foreach (var group in nameGroups)
+ {
+ var baseName = group.Key;
+ var groupEntries = group.ToList();
+
+ if (groupEntries.Count == 1)
+ {
+ var entry = groupEntries[0];
+ registeredProviders.Add(new RegisteredProvider(entry.Provider, entry.Name ?? baseName));
+ }
+ else
+ {
+ // Multiple providers with same metadata name - add indices
+ var index = 1;
+ foreach (var entry in groupEntries)
+ {
+ var finalName = entry.Name ?? $"{baseName}-{index++}";
+ registeredProviders.Add(new RegisteredProvider(entry.Provider, finalName));
+ }
+ }
+ }
+
+ return registeredProviders.AsReadOnly();
+ }
+
+ ///
+ /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (Interlocked.Exchange(ref this._disposed, 1) == 1)
+ {
+ // Already disposed
+ return;
+ }
+
+ try
+ {
+ await this.InternalShutdownAsync(CancellationToken.None).ConfigureAwait(false);
+ }
+ finally
+ {
+ this._initializationSemaphore.Dispose();
+ this._shutdownSemaphore.Dispose();
+ }
+ }
+
+ private async Task InternalShutdownAsync(CancellationToken cancellationToken)
+ {
+ await this._shutdownSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ // We should be able to shutdown the provider when it is in Ready or Fatal status.
+ if ((this._providerStatus != ProviderStatus.Ready && this._providerStatus != ProviderStatus.Fatal) || this._disposed == 1)
+ {
+ return;
+ }
+
+ var shutdownTasks = this._registeredProviders.Select(async rp =>
+ {
+ try
+ {
+ await rp.Provider.ShutdownAsync(cancellationToken).ConfigureAwait(false);
+ rp.SetStatus(ProviderStatus.NotReady);
+ return new ChildProviderStatus { ProviderName = rp.Name };
+ }
+ catch (Exception ex)
+ {
+ rp.SetStatus(ProviderStatus.Fatal);
+ return new ChildProviderStatus { ProviderName = rp.Name, Error = ex };
+ }
+ });
+
+ var results = await Task.WhenAll(shutdownTasks).ConfigureAwait(false);
+ var failures = results.Where(r => r.Error != null).ToList();
+
+ if (failures.Count != 0)
+ {
+ var exceptions = failures.Select(f => f.Error!).ToList();
+ var failedProviders = failures.Select(f => f.ProviderName).ToList();
+ throw new AggregateException(
+ $"Failed to shutdown providers: {string.Join(", ", failedProviders)}",
+ exceptions);
+ }
+
+ this._providerStatus = ProviderStatus.NotReady;
+ }
+ finally
+ {
+ this._shutdownSemaphore.Release();
+ }
+ }
+
+ ///
+ /// This should only be used for testing purposes.
+ ///
+ /// The status to set.
+ internal void SetStatus(ProviderStatus providerStatus)
+ {
+ this._providerStatus = providerStatus;
+ }
+}
diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProviderConstants.cs b/src/OpenFeature/Providers/MultiProvider/MultiProviderConstants.cs
new file mode 100644
index 000000000..76df24448
--- /dev/null
+++ b/src/OpenFeature/Providers/MultiProvider/MultiProviderConstants.cs
@@ -0,0 +1,12 @@
+namespace OpenFeature.Providers.MultiProvider;
+
+///
+/// Constants used by the MultiProvider.
+///
+internal static class MultiProviderConstants
+{
+ ///
+ /// The provider name for MultiProvider.
+ ///
+ public const string ProviderName = "MultiProvider";
+}
diff --git a/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs b/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs
new file mode 100644
index 000000000..d8f70dfbf
--- /dev/null
+++ b/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs
@@ -0,0 +1,46 @@
+using OpenFeature.Constant;
+using OpenFeature.Model;
+using OpenFeature.Providers.MultiProvider.Strategies.Models;
+
+namespace OpenFeature.Providers.MultiProvider;
+
+internal static class ProviderExtensions
+{
+ internal static async Task> EvaluateAsync(
+ this FeatureProvider provider,
+ StrategyPerProviderContext providerContext,
+ EvaluationContext? evaluationContext,
+ T defaultValue,
+ CancellationToken cancellationToken)
+ {
+ var key = providerContext.FlagKey;
+
+ try
+ {
+ var result = defaultValue switch
+ {
+ bool boolDefaultValue => (ResolutionDetails)(object)await provider.ResolveBooleanValueAsync(key, boolDefaultValue, evaluationContext, cancellationToken).ConfigureAwait(false),
+ string stringDefaultValue => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, stringDefaultValue, evaluationContext, cancellationToken).ConfigureAwait(false),
+ int intDefaultValue => (ResolutionDetails)(object)await provider.ResolveIntegerValueAsync(key, intDefaultValue, evaluationContext, cancellationToken).ConfigureAwait(false),
+ double doubleDefaultValue => (ResolutionDetails)(object)await provider.ResolveDoubleValueAsync(key, doubleDefaultValue, evaluationContext, cancellationToken).ConfigureAwait(false),
+ Value valueDefaultValue => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, valueDefaultValue, evaluationContext, cancellationToken).ConfigureAwait(false),
+ null when typeof(T) == typeof(string) => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, (string)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false),
+ null when typeof(T) == typeof(Value) => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, (Value)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false),
+ _ => throw new ArgumentException($"Unsupported flag type: {typeof(T)}")
+ };
+ return new ProviderResolutionResult(provider, providerContext.ProviderName, result);
+ }
+ catch (Exception ex)
+ {
+ // Create an error result
+ var errorResult = new ResolutionDetails(
+ key,
+ defaultValue,
+ ErrorType.General,
+ Reason.Error,
+ errorMessage: ex.Message);
+
+ return new ProviderResolutionResult(provider, providerContext.ProviderName, errorResult, ex);
+ }
+ }
+}
diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs
new file mode 100644
index 000000000..f31b2c4ab
--- /dev/null
+++ b/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs
@@ -0,0 +1,129 @@
+using OpenFeature.Constant;
+using OpenFeature.Model;
+using OpenFeature.Providers.MultiProvider.Strategies.Models;
+
+namespace OpenFeature.Providers.MultiProvider.Strategies;
+
+///
+/// Provides a base class for implementing evaluation strategies that determine how feature flags are evaluated across multiple feature providers.
+///
+///
+/// This abstract class serves as the foundation for creating custom evaluation strategies that can handle feature flag resolution
+/// across multiple providers. Implementations define the specific logic for how providers are selected, prioritized, or combined
+/// when evaluating feature flags.
+///
+public abstract class BaseEvaluationStrategy
+{
+ ///
+ /// Determines whether providers should be evaluated in parallel or sequentially.
+ ///
+ public virtual RunMode RunMode => RunMode.Sequential;
+
+ ///
+ /// Determines whether a specific provider should be evaluated.
+ ///
+ /// The type of the flag value.
+ /// Context information about the provider and evaluation.
+ /// The evaluation context for the flag resolution.
+ /// True if the provider should be evaluated, false otherwise.
+ public virtual bool ShouldEvaluateThisProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext)
+ {
+ // Skip providers that are not ready or have fatal errors
+ return strategyContext.ProviderStatus is not (ProviderStatus.NotReady or ProviderStatus.Fatal);
+ }
+
+ ///
+ /// Determines whether the next provider should be evaluated after the current one.
+ /// This method is only called in sequential mode.
+ ///
+ /// The type of the flag value.
+ /// Context information about the provider and evaluation.
+ /// The evaluation context for the flag resolution.
+ /// The result from the current provider evaluation.
+ /// True if the next provider should be evaluated, false otherwise.
+ public virtual bool ShouldEvaluateNextProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext, ProviderResolutionResult result)
+ {
+ return true;
+ }
+
+ ///
+ /// Determines the final result from all provider evaluation results.
+ ///
+ /// The type of the flag value.
+ /// Context information about the evaluation.
+ /// The feature flag key to evaluate.
+ /// The default value to return if evaluation fails or the flag is not found.
+ /// The evaluation context for the flag resolution.
+ /// All resolution results from provider evaluations.
+ /// The final evaluation result.
+ public abstract FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions);
+
+ ///
+ /// Checks if a resolution result represents an error.
+ ///
+ /// The type of the resolved value.
+ /// The resolution result to check.
+ /// True if the result represents an error, false otherwise.
+ protected static bool HasError(ProviderResolutionResult resolution)
+ {
+ return resolution.ThrownError is not null || resolution.ResolutionDetails switch
+ {
+ { } success => success.ErrorType != ErrorType.None,
+ _ => false
+ };
+ }
+
+ ///
+ /// Collects errors from provider resolution results.
+ ///
+ /// The type of the flag value.
+ /// The provider resolution results to collect errors from.
+ /// A list of provider errors.
+ protected static List CollectProviderErrors(List> resolutions)
+ {
+ var errors = new List();
+
+ foreach (var resolution in resolutions)
+ {
+ if (resolution.ThrownError is not null)
+ {
+ errors.Add(new ProviderError(resolution.ProviderName, resolution.ThrownError));
+ }
+ else if (resolution.ResolutionDetails?.ErrorType != ErrorType.None)
+ {
+ var errorMessage = resolution.ResolutionDetails?.ErrorMessage ?? "unknown error";
+ var error = new Exception(errorMessage); // Adjust based on your ErrorWithCode implementation
+ errors.Add(new ProviderError(resolution.ProviderName, error));
+ }
+ }
+
+ return errors;
+ }
+
+ ///
+ /// Checks if a resolution result has a specific error code.
+ ///
+ /// The type of the resolved value.
+ /// The resolution result to check.
+ /// The error type to check for.
+ /// True if the result has the specified error type, false otherwise.
+ protected static bool HasErrorWithCode(ProviderResolutionResult resolution, ErrorType errorType)
+ {
+ return resolution.ResolutionDetails switch
+ {
+ { } success => success.ErrorType == errorType,
+ _ => false
+ };
+ }
+
+ ///
+ /// Converts a resolution result to a final result.
+ ///
+ /// The type of the resolved value.
+ /// The resolution result to convert.
+ /// The converted final result.
+ protected static FinalResult ToFinalResult(ProviderResolutionResult resolution)
+ {
+ return new FinalResult(resolution.ResolutionDetails, resolution.Provider, resolution.ProviderName, null);
+ }
+}
diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs
new file mode 100644
index 000000000..b004b6d32
--- /dev/null
+++ b/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs
@@ -0,0 +1,80 @@
+using OpenFeature.Constant;
+using OpenFeature.Model;
+using OpenFeature.Providers.MultiProvider.Strategies.Models;
+
+namespace OpenFeature.Providers.MultiProvider.Strategies;
+
+///
+/// Evaluate all providers in parallel and compare the results.
+/// If the values agree, return the value.
+/// If the values disagree, return the value from the configured "fallback provider" and execute the "onMismatch"
+/// callback if defined.
+///
+public sealed class ComparisonStrategy : BaseEvaluationStrategy
+{
+ private readonly FeatureProvider? _fallbackProvider;
+ private readonly Action>? _onMismatch;
+
+ ///
+ public override RunMode RunMode => RunMode.Parallel;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The provider to use as fallback when values don't match.
+ /// Optional callback that is called when providers return different values.
+ public ComparisonStrategy(FeatureProvider? fallbackProvider = null, Action>? onMismatch = null)
+ {
+ this._fallbackProvider = fallbackProvider;
+ this._onMismatch = onMismatch;
+ }
+
+ ///
+ public override FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions)
+ {
+ var successfulResolutions = resolutions.Where(r => !HasError(r)).ToList();
+
+ if (successfulResolutions.Count == 0)
+ {
+ var errorDetails = new ResolutionDetails(key, defaultValue, ErrorType.ProviderNotReady, Reason.Error, errorMessage: "No providers available or all providers failed");
+ var errors = resolutions.Select(r => new ProviderError(r.ProviderName, new InvalidOperationException($"Provider {r.ProviderName} failed"))).ToList();
+ return new FinalResult(errorDetails, null!, MultiProviderConstants.ProviderName, errors);
+ }
+
+ var firstResult = successfulResolutions.First();
+
+ // Check if all successful results agree on the value
+ var allAgree = successfulResolutions.All(r => EqualityComparer.Default.Equals(r.ResolutionDetails.Value, firstResult.ResolutionDetails.Value));
+
+ if (allAgree)
+ {
+ return ToFinalResult(firstResult);
+ }
+
+ ProviderResolutionResult? fallbackResolution = null;
+
+ // Find fallback provider if specified
+ if (this._fallbackProvider != null)
+ {
+ fallbackResolution = successfulResolutions.FirstOrDefault(r => ReferenceEquals(r.Provider, this._fallbackProvider));
+ }
+
+ // Values don't agree, trigger mismatch callback if provided
+ if (this._onMismatch != null)
+ {
+ // Create a dictionary with provider names and their values for the callback
+ var mismatchDetails = successfulResolutions.ToDictionary(
+ r => r.ProviderName,
+ r => (object)r.ResolutionDetails.Value!
+ );
+ this._onMismatch(mismatchDetails);
+ }
+
+ // Return fallback provider result if available
+ return fallbackResolution != null
+ ? ToFinalResult(fallbackResolution)
+ :
+ // Default to first provider's result
+ ToFinalResult(firstResult);
+ }
+}
diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs
new file mode 100644
index 000000000..88eba5509
--- /dev/null
+++ b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs
@@ -0,0 +1,36 @@
+using OpenFeature.Constant;
+using OpenFeature.Model;
+using OpenFeature.Providers.MultiProvider.Strategies.Models;
+
+namespace OpenFeature.Providers.MultiProvider.Strategies;
+
+///
+/// Return the first result that did not indicate "flag not found".
+/// Providers are evaluated sequentially in the order they were configured.
+/// If any provider in the course of evaluation returns or throws an error, throw that error
+///
+public sealed class FirstMatchStrategy : BaseEvaluationStrategy
+{
+ ///
+ public override bool ShouldEvaluateNextProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext, ProviderResolutionResult result)
+ {
+ return HasErrorWithCode(result, ErrorType.FlagNotFound);
+ }
+
+ ///
+ public override FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions)
+ {
+ var lastResult = resolutions.LastOrDefault();
+ if (lastResult != null)
+ {
+ return ToFinalResult(lastResult);
+ }
+
+ var errorDetails = new ResolutionDetails(key, defaultValue, ErrorType.ProviderNotReady, Reason.Error, errorMessage: "No providers available or all providers failed");
+ var errors = new List
+ {
+ new(MultiProviderConstants.ProviderName, new InvalidOperationException("No providers available or all providers failed"))
+ };
+ return new FinalResult(errorDetails, null!, MultiProviderConstants.ProviderName, errors);
+ }
+}
diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs
new file mode 100644
index 000000000..7caef6a51
--- /dev/null
+++ b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs
@@ -0,0 +1,47 @@
+using OpenFeature.Constant;
+using OpenFeature.Model;
+using OpenFeature.Providers.MultiProvider.Strategies.Models;
+
+namespace OpenFeature.Providers.MultiProvider.Strategies;
+
+///
+/// Return the first result that did not result in an error.
+/// Providers are evaluated sequentially in the order they were configured.
+/// If any provider in the course of evaluation returns or throws an error, ignore it as long as there is a successful result.
+/// If there is no successful result, throw all errors.
+///
+public sealed class FirstSuccessfulStrategy : BaseEvaluationStrategy
+{
+ ///
+ public override bool ShouldEvaluateNextProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext, ProviderResolutionResult result)
+ {
+ // evaluate next only if there was an error
+ return HasError(result);
+ }
+
+ ///
+ public override FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions)
+ {
+ if (resolutions.Count == 0)
+ {
+ var noProvidersDetails = new ResolutionDetails(key, defaultValue, ErrorType.ProviderNotReady, Reason.Error, errorMessage: "No providers available or all providers failed");
+ var noProvidersErrors = new List
+ {
+ new(MultiProviderConstants.ProviderName, new InvalidOperationException("No providers available or all providers failed"))
+ };
+ return new FinalResult(noProvidersDetails, null!, MultiProviderConstants.ProviderName, noProvidersErrors);
+ }
+
+ // Find the first successful result
+ var successfulResult = resolutions.FirstOrDefault(r => !HasError(r));
+ if (successfulResult != null)
+ {
+ return ToFinalResult(successfulResult);
+ }
+
+ // All results had errors - collect them and throw
+ var collectedErrors = CollectProviderErrors(resolutions);
+ var allFailedDetails = new ResolutionDetails(key, defaultValue, ErrorType.General, Reason.Error, errorMessage: "All providers failed");
+ return new FinalResult(allFailedDetails, null!, "MultiProvider", collectedErrors);
+ }
+}
diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/FinalResult.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/FinalResult.cs
new file mode 100644
index 000000000..0bcc0bd7d
--- /dev/null
+++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/FinalResult.cs
@@ -0,0 +1,45 @@
+using OpenFeature.Model;
+
+namespace OpenFeature.Providers.MultiProvider.Strategies.Models;
+
+///
+/// Represents the final result of a feature flag resolution operation from a multi-provider strategy.
+/// Contains the resolved details, the provider that successfully resolved the flag, and any errors encountered during the resolution process.
+///
+public class FinalResult
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The resolution details containing the resolved value and associated metadata.
+ /// The provider that successfully resolved the feature flag.
+ /// The name of the provider that successfully resolved the feature flag.
+ /// The list of errors encountered during the resolution process.
+ public FinalResult(ResolutionDetails details, FeatureProvider provider, string providerName, List? errors)
+ {
+ this.Details = details;
+ this.Provider = provider;
+ this.ProviderName = providerName;
+ this.Errors = errors ?? [];
+ }
+
+ ///
+ /// Gets or sets the resolution details containing the resolved value and associated metadata.
+ ///
+ public ResolutionDetails Details { get; private set; }
+
+ ///
+ /// Gets or sets the provider that successfully resolved the feature flag.
+ ///
+ public FeatureProvider Provider { get; private set; }
+
+ ///
+ /// Gets or sets the name of the provider that successfully resolved the feature flag.
+ ///
+ public string ProviderName { get; private set; }
+
+ ///
+ /// Gets or sets the list of errors encountered during the resolution process.
+ ///
+ public List Errors { get; private set; }
+}
diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs
new file mode 100644
index 000000000..52204ce5a
--- /dev/null
+++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs
@@ -0,0 +1,29 @@
+namespace OpenFeature.Providers.MultiProvider.Strategies.Models;
+
+///
+/// Represents an error encountered during the resolution process.
+/// Contains the name of the provider that encountered the error and the error details.
+///
+public class ProviderError
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The name of the provider that encountered the error.
+ /// The error details.
+ public ProviderError(string providerName, Exception? error)
+ {
+ this.ProviderName = providerName;
+ this.Error = error;
+ }
+
+ ///
+ /// Gets or sets the name of the provider that encountered the error.
+ ///
+ public string ProviderName { get; private set; }
+
+ ///
+ /// Gets or sets the error details.
+ ///
+ public Exception? Error { get; private set; }
+}
diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs
new file mode 100644
index 000000000..20eddbe44
--- /dev/null
+++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs
@@ -0,0 +1,45 @@
+using OpenFeature.Model;
+
+namespace OpenFeature.Providers.MultiProvider.Strategies.Models;
+
+///
+/// Base class for provider resolution results.
+///
+public class ProviderResolutionResult
+{
+ ///
+ /// Initializes a new instance of the class
+ /// with the specified provider and resolution details.
+ ///
+ /// The feature provider that produced this result.
+ /// The name of the provider that produced this result.
+ /// The resolution details.
+ /// The exception that occurred during resolution, if any.
+ public ProviderResolutionResult(FeatureProvider provider, string providerName, ResolutionDetails resolutionDetails, Exception? thrownError = null)
+ {
+ this.Provider = provider;
+ this.ProviderName = providerName;
+ this.ResolutionDetails = resolutionDetails;
+ this.ThrownError = thrownError;
+ }
+
+ ///
+ /// The feature provider that produced this result.
+ ///
+ public FeatureProvider Provider { get; private set; }
+
+ ///
+ /// The resolution details.
+ ///
+ public ResolutionDetails ResolutionDetails { get; private set; }
+
+ ///
+ /// The name of the provider that produced this result.
+ ///
+ public string ProviderName { get; private set; }
+
+ ///
+ /// The exception that occurred during resolution, if any.
+ ///
+ public Exception? ThrownError { get; private set; }
+}
diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/RunMode.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/RunMode.cs
new file mode 100644
index 000000000..754cb5a9e
--- /dev/null
+++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/RunMode.cs
@@ -0,0 +1,17 @@
+namespace OpenFeature.Providers.MultiProvider.Strategies.Models;
+
+///
+/// Specifies how providers should be evaluated.
+///
+public enum RunMode
+{
+ ///
+ /// Providers are evaluated one after another in sequence.
+ ///
+ Sequential,
+
+ ///
+ /// Providers are evaluated concurrently in parallel.
+ ///
+ Parallel
+}
diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs
new file mode 100644
index 000000000..215c85e4d
--- /dev/null
+++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs
@@ -0,0 +1,22 @@
+namespace OpenFeature.Providers.MultiProvider.Strategies.Models;
+
+///
+/// Evaluation context specific to strategy evaluation containing flag-related information.
+///
+/// The type of the flag value being evaluated.
+public class StrategyEvaluationContext
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The feature flag key being evaluated.
+ public StrategyEvaluationContext(string flagKey)
+ {
+ this.FlagKey = flagKey;
+ }
+
+ ///
+ /// The feature flag key being evaluated.
+ ///
+ public string FlagKey { get; private set; }
+}
diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs
new file mode 100644
index 000000000..4abc434a3
--- /dev/null
+++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs
@@ -0,0 +1,40 @@
+using OpenFeature.Constant;
+
+namespace OpenFeature.Providers.MultiProvider.Strategies.Models;
+
+///
+/// Per-provider context containing provider-specific information for strategy evaluation.
+///
+/// The type of the flag value being evaluated.
+public class StrategyPerProviderContext : StrategyEvaluationContext
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The feature provider instance.
+ /// The name/identifier of the provider.
+ /// The current status of the provider.
+ /// The feature flag key being evaluated.
+ public StrategyPerProviderContext(FeatureProvider provider, string providerName, ProviderStatus providerStatus, string key)
+ : base(key)
+ {
+ this.Provider = provider;
+ this.ProviderName = providerName;
+ this.ProviderStatus = providerStatus;
+ }
+
+ ///
+ /// The feature provider instance.
+ ///
+ public FeatureProvider Provider { get; }
+
+ ///
+ /// The name/identifier of the provider.
+ ///
+ public string ProviderName { get; }
+
+ ///
+ /// The current status of the provider.
+ ///
+ public ProviderStatus ProviderStatus { get; }
+}
diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Models/ChildProviderEntryTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Models/ChildProviderEntryTests.cs
new file mode 100644
index 000000000..69bb62322
--- /dev/null
+++ b/test/OpenFeature.Tests/Providers/MultiProvider/Models/ChildProviderEntryTests.cs
@@ -0,0 +1,93 @@
+using NSubstitute;
+using OpenFeature.Providers.MultiProvider.Models;
+
+namespace OpenFeature.Tests.Providers.MultiProvider.Models;
+
+public class ChildProviderEntryTests
+{
+ private readonly FeatureProvider _mockProvider = Substitute.For();
+
+ [Fact]
+ public void Constructor_WithProvider_CreatesProviderEntry()
+ {
+ // Act
+ var providerEntry = new ProviderEntry(this._mockProvider);
+
+ // Assert
+ Assert.Equal(this._mockProvider, providerEntry.Provider);
+ Assert.Null(providerEntry.Name);
+ }
+
+ [Fact]
+ public void Constructor_WithProviderAndName_CreatesProviderEntry()
+ {
+ // Arrange
+ const string customName = "custom-provider-name";
+
+ // Act
+ var providerEntry = new ProviderEntry(this._mockProvider, customName);
+
+ // Assert
+ Assert.Equal(this._mockProvider, providerEntry.Provider);
+ Assert.Equal(customName, providerEntry.Name);
+ }
+
+ [Fact]
+ public void Constructor_WithNullProvider_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ var exception = Assert.Throws(() => new ProviderEntry(null!));
+ Assert.Equal("provider", exception.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_WithNullName_CreatesProviderEntryWithNullName()
+ {
+ // Act
+ var providerEntry = new ProviderEntry(this._mockProvider, null);
+
+ // Assert
+ Assert.Equal(this._mockProvider, providerEntry.Provider);
+ Assert.Null(providerEntry.Name);
+ }
+
+ [Fact]
+ public void Constructor_WithEmptyName_CreatesProviderEntryWithEmptyName()
+ {
+ // Act
+ var providerEntry = new ProviderEntry(this._mockProvider, string.Empty);
+
+ // Assert
+ Assert.Equal(this._mockProvider, providerEntry.Provider);
+ Assert.Equal(string.Empty, providerEntry.Name);
+ }
+
+ [Fact]
+ public void Provider_Property_IsReadOnly()
+ {
+ // Arrange
+ var providerEntry = new ProviderEntry(this._mockProvider);
+
+ // Act & Assert
+ // Verify that Provider property is read-only by checking it has no setter
+ var providerProperty = typeof(ProviderEntry).GetProperty(nameof(ProviderEntry.Provider));
+ Assert.NotNull(providerProperty);
+ Assert.True(providerProperty.CanRead);
+ Assert.False(providerProperty.CanWrite);
+ }
+
+ [Fact]
+ public void Name_Property_IsReadOnly()
+ {
+ // Arrange
+ const string customName = "test-name";
+ var providerEntry = new ProviderEntry(this._mockProvider, customName);
+
+ // Act & Assert
+ // Verify that Name property is read-only by checking it has no setter
+ var nameProperty = typeof(ProviderEntry).GetProperty(nameof(ProviderEntry.Name));
+ Assert.NotNull(nameProperty);
+ Assert.True(nameProperty.CanRead);
+ Assert.False(nameProperty.CanWrite);
+ }
+}
diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs
new file mode 100644
index 000000000..ad3990aaa
--- /dev/null
+++ b/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs
@@ -0,0 +1,123 @@
+using OpenFeature.Providers.MultiProvider.Models;
+
+namespace OpenFeature.Tests.Providers.MultiProvider.Models;
+
+public class ProviderStatusTests
+{
+ [Fact]
+ public void Constructor_CreatesProviderStatusWithDefaultValues()
+ {
+ // Act
+ var providerStatus = new ChildProviderStatus();
+
+ // Assert
+ Assert.Equal(string.Empty, providerStatus.ProviderName);
+ Assert.Null(providerStatus.Error);
+ }
+
+ [Fact]
+ public void ProviderName_CanBeSet()
+ {
+ // Arrange
+ const string providerName = "test-provider";
+ var providerStatus = new ChildProviderStatus();
+
+ // Act
+ providerStatus.ProviderName = providerName;
+
+ // Assert
+ Assert.Equal(providerName, providerStatus.ProviderName);
+ }
+
+ [Fact]
+ public void ProviderName_CanBeSetToNull()
+ {
+ // Arrange
+ var providerStatus = new ChildProviderStatus { ProviderName = "initial-name" };
+
+ // Act
+ providerStatus.ProviderName = null!;
+
+ // Assert
+ Assert.Null(providerStatus.ProviderName);
+ }
+
+ [Fact]
+ public void ProviderName_CanBeSetToEmptyString()
+ {
+ // Arrange
+ var providerStatus = new ChildProviderStatus { ProviderName = "initial-name" };
+
+ // Act
+ providerStatus.ProviderName = string.Empty;
+
+ // Assert
+ Assert.Equal(string.Empty, providerStatus.ProviderName);
+ }
+
+ [Fact]
+ public void Exception_CanBeSet()
+ {
+ // Arrange
+ var exception = new InvalidOperationException("Test exception");
+ var providerStatus = new ChildProviderStatus();
+
+ // Act
+ providerStatus.Error = exception;
+
+ // Assert
+ Assert.Equal(exception, providerStatus.Error);
+ }
+
+ [Fact]
+ public void Exception_CanBeSetToNull()
+ {
+ // Arrange
+ var providerStatus = new ChildProviderStatus { Error = new Exception("initial exception") };
+
+ // Act
+ providerStatus.Error = null;
+
+ // Assert
+ Assert.Null(providerStatus.Error);
+ }
+
+ [Fact]
+ public void ProviderStatus_CanBeInitializedWithObjectInitializer()
+ {
+ // Arrange
+ const string providerName = "test-provider";
+ var exception = new ArgumentException("Test exception");
+
+ // Act
+ var providerStatus = new ChildProviderStatus
+ {
+ ProviderName = providerName,
+ Error = exception
+ };
+
+ // Assert
+ Assert.Equal(providerName, providerStatus.ProviderName);
+ Assert.Equal(exception, providerStatus.Error);
+ }
+
+ [Fact]
+ public void ProviderName_Property_HasGetterAndSetter()
+ {
+ // Act & Assert
+ var providerNameProperty = typeof(ChildProviderStatus).GetProperty(nameof(ChildProviderStatus.ProviderName));
+ Assert.NotNull(providerNameProperty);
+ Assert.True(providerNameProperty.CanRead);
+ Assert.True(providerNameProperty.CanWrite);
+ }
+
+ [Fact]
+ public void Exception_Property_HasGetterAndSetter()
+ {
+ // Act & Assert
+ var exceptionProperty = typeof(ChildProviderStatus).GetProperty(nameof(ChildProviderStatus.Error));
+ Assert.NotNull(exceptionProperty);
+ Assert.True(exceptionProperty.CanRead);
+ Assert.True(exceptionProperty.CanWrite);
+ }
+}
diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs
new file mode 100644
index 000000000..8734775a7
--- /dev/null
+++ b/test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs
@@ -0,0 +1,116 @@
+using NSubstitute;
+using OpenFeature.Providers.MultiProvider.Models;
+
+namespace OpenFeature.Tests.Providers.MultiProvider.Models;
+
+public class RegisteredProviderTests
+{
+ private readonly FeatureProvider _mockProvider = Substitute.For();
+ private const string TestProviderName = "test-provider";
+
+ [Fact]
+ public void Constructor_WithValidParameters_CreatesRegisteredProvider()
+ {
+ // Act
+ var registeredProvider = new RegisteredProvider(this._mockProvider, TestProviderName);
+
+ // Assert
+ Assert.Equal(this._mockProvider, registeredProvider.Provider);
+ Assert.Equal(TestProviderName, registeredProvider.Name);
+ }
+
+ [Fact]
+ public void Constructor_WithNullProvider_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ var exception = Assert.Throws(() => new RegisteredProvider(null!, TestProviderName));
+ Assert.Equal("provider", exception.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_WithNullName_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ var exception = Assert.Throws(() => new RegisteredProvider(this._mockProvider, null!));
+ Assert.Equal("name", exception.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_WithEmptyName_CreatesRegisteredProviderWithEmptyName()
+ {
+ // Act
+ var registeredProvider = new RegisteredProvider(this._mockProvider, string.Empty);
+
+ // Assert
+ Assert.Equal(this._mockProvider, registeredProvider.Provider);
+ Assert.Equal(string.Empty, registeredProvider.Name);
+ }
+
+ [Fact]
+ public void Constructor_WithWhitespaceName_CreatesRegisteredProviderWithWhitespaceName()
+ {
+ // Arrange
+ const string whitespaceName = " ";
+
+ // Act
+ var registeredProvider = new RegisteredProvider(this._mockProvider, whitespaceName);
+
+ // Assert
+ Assert.Equal(this._mockProvider, registeredProvider.Provider);
+ Assert.Equal(whitespaceName, registeredProvider.Name);
+ }
+
+ [Fact]
+ public void Constructor_WithSameProviderAndDifferentNames_CreatesDistinctInstances()
+ {
+ // Arrange
+ const string name1 = "provider-1";
+ const string name2 = "provider-2";
+
+ // Act
+ var registeredProvider1 = new RegisteredProvider(this._mockProvider, name1);
+ var registeredProvider2 = new RegisteredProvider(this._mockProvider, name2);
+
+ // Assert
+ Assert.Equal(this._mockProvider, registeredProvider1.Provider);
+ Assert.Equal(this._mockProvider, registeredProvider2.Provider);
+ Assert.Equal(name1, registeredProvider1.Name);
+ Assert.Equal(name2, registeredProvider2.Name);
+ Assert.NotEqual(registeredProvider1.Name, registeredProvider2.Name);
+ }
+
+ [Fact]
+ public void Constructor_WithDifferentProvidersAndSameName_CreatesDistinctInstances()
+ {
+ // Arrange
+ var mockProvider2 = Substitute.For();
+
+ // Act
+ var registeredProvider1 = new RegisteredProvider(this._mockProvider, TestProviderName);
+ var registeredProvider2 = new RegisteredProvider(mockProvider2, TestProviderName);
+
+ // Assert
+ Assert.Equal(this._mockProvider, registeredProvider1.Provider);
+ Assert.Equal(mockProvider2, registeredProvider2.Provider);
+ Assert.Equal(TestProviderName, registeredProvider1.Name);
+ Assert.Equal(TestProviderName, registeredProvider2.Name);
+ Assert.NotEqual(registeredProvider1.Provider, registeredProvider2.Provider);
+ }
+
+ [Theory]
+ [InlineData(Constant.ProviderStatus.Ready)]
+ [InlineData(Constant.ProviderStatus.Error)]
+ [InlineData(Constant.ProviderStatus.Fatal)]
+ [InlineData(Constant.ProviderStatus.NotReady)]
+ public void SetStatus_WithDifferentStatuses_UpdatesCorrectly(Constant.ProviderStatus status)
+ {
+ // Arrange
+ var registeredProvider = new RegisteredProvider(new TestProvider(), "test");
+
+ // Act
+ registeredProvider.SetStatus(status);
+
+ // Assert
+ Assert.Equal(status, registeredProvider.Status);
+ }
+}
diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs
new file mode 100644
index 000000000..bf1dfb4e6
--- /dev/null
+++ b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs
@@ -0,0 +1,851 @@
+using System.Reflection;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using OpenFeature.Constant;
+using OpenFeature.Model;
+using OpenFeature.Providers.MultiProvider.Models;
+using OpenFeature.Providers.MultiProvider.Strategies;
+using OpenFeature.Providers.MultiProvider.Strategies.Models;
+using MultiProviderImplementation = OpenFeature.Providers.MultiProvider;
+
+namespace OpenFeature.Tests.Providers.MultiProvider;
+
+public class MultiProviderClassTests
+{
+ private const string TestFlagKey = "test-flag";
+ private const string TestVariant = "test-variant";
+ private const string Provider1Name = "provider1";
+ private const string Provider2Name = "provider2";
+ private const string Provider3Name = "provider3";
+
+ private readonly FeatureProvider _mockProvider1 = Substitute.For();
+ private readonly FeatureProvider _mockProvider2 = Substitute.For();
+ private readonly FeatureProvider _mockProvider3 = Substitute.For();
+ private readonly BaseEvaluationStrategy _mockStrategy = Substitute.For();
+ private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build();
+
+ public MultiProviderClassTests()
+ {
+ // Setup default metadata for providers
+ this._mockProvider1.GetMetadata().Returns(new Metadata(Provider1Name));
+ this._mockProvider2.GetMetadata().Returns(new Metadata(Provider2Name));
+ this._mockProvider3.GetMetadata().Returns(new Metadata(Provider3Name));
+
+ // Setup default strategy behavior
+ this._mockStrategy.RunMode.Returns(RunMode.Sequential);
+ this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), Arg.Any()).Returns(true);
+ this._mockStrategy.ShouldEvaluateNextProvider(Arg.Any>(), Arg.Any(), Arg.Any>()).Returns(false);
+ }
+
+ [Fact]
+ public void Constructor_WithValidProviderEntries_CreatesMultiProvider()
+ {
+ // Arrange
+ var providerEntries = new List
+ {
+ new(this._mockProvider1, Provider1Name),
+ new(this._mockProvider2, Provider2Name)
+ };
+
+ // Act
+ var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy);
+
+ // Assert
+ Assert.NotNull(multiProvider);
+ var metadata = multiProvider.GetMetadata();
+ Assert.Equal("MultiProvider", metadata.Name);
+ }
+
+ [Fact]
+ public void Constructor_WithNullProviderEntries_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ var exception = Assert.Throws(() => new MultiProviderImplementation.MultiProvider(null!, this._mockStrategy));
+ Assert.Equal("providerEntries", exception.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_WithEmptyProviderEntries_ThrowsArgumentException()
+ {
+ // Arrange
+ var emptyProviderEntries = new List();
+
+ // Act & Assert
+ var exception = Assert.Throws(() => new MultiProviderImplementation.MultiProvider(emptyProviderEntries, this._mockStrategy));
+ Assert.Contains("At least one provider entry must be provided", exception.Message);
+ Assert.Equal("providerEntries", exception.ParamName);
+ }
+
+ [Fact]
+ public void Constructor_WithNullStrategy_UsesDefaultFirstMatchStrategy()
+ {
+ // Arrange
+ var providerEntries = new List { new(this._mockProvider1, Provider1Name) };
+
+ // Act
+ var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, null);
+
+ // Assert
+ Assert.NotNull(multiProvider);
+ var metadata = multiProvider.GetMetadata();
+ Assert.Equal("MultiProvider", metadata.Name);
+ }
+
+ [Fact]
+ public void Constructor_WithDuplicateExplicitNames_ThrowsArgumentException()
+ {
+ // Arrange
+ var providerEntries = new List
+ {
+ new(this._mockProvider1, "duplicate-name"),
+ new(this._mockProvider2, "duplicate-name")
+ };
+
+ // Act & Assert
+ var exception = Assert.Throws(() => new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy));
+ Assert.Contains("Multiple providers cannot have the same explicit name: 'duplicate-name'", exception.Message);
+ }
+
+ [Fact]
+ public async Task ResolveBooleanValueAsync_CallsEvaluateAsync()
+ {
+ // Arrange
+ const bool defaultValue = false;
+ const bool resolvedValue = true;
+ var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant);
+ var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null);
+
+ var providerEntries = new List { new(this._mockProvider1, Provider1Name) };
+ var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy);
+
+ this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>())
+ .Returns(finalResult);
+
+ // Act
+ var result = await multiProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext);
+
+ // Assert
+ Assert.Equal(expectedDetails, result);
+ this._mockStrategy.Received(1).DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>());
+ }
+
+ [Fact]
+ public async Task ResolveStringValueAsync_CallsEvaluateAsync()
+ {
+ // Arrange
+ const string defaultValue = "default";
+ const string resolvedValue = "resolved";
+ var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant);
+ var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null);
+
+ var providerEntries = new List { new(this._mockProvider1, Provider1Name) };
+ var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy);
+
+ this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>())
+ .Returns(finalResult);
+
+ // Act
+ var result = await multiProvider.ResolveStringValueAsync(TestFlagKey, defaultValue, this._evaluationContext);
+
+ // Assert
+ Assert.Equal(expectedDetails, result);
+ }
+
+ [Fact]
+ public async Task InitializeAsync_WithAllSuccessfulProviders_InitializesAllProviders()
+ {
+ // Arrange
+ var providerEntries = new List
+ {
+ new(this._mockProvider1, Provider1Name),
+ new(this._mockProvider2, Provider2Name)
+ };
+ var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy);
+
+ this._mockProvider1.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask);
+ this._mockProvider2.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask);
+
+ // Act
+ await multiProvider.InitializeAsync(this._evaluationContext);
+
+ // Assert
+ await this._mockProvider1.Received(1).InitializeAsync(this._evaluationContext, Arg.Any());
+ await this._mockProvider2.Received(1).InitializeAsync(this._evaluationContext, Arg.Any());
+ }
+
+ [Fact]
+ public async Task InitializeAsync_WithSomeFailingProviders_ThrowsAggregateException()
+ {
+ // Arrange
+ var expectedException = new InvalidOperationException("Initialization failed");
+ var providerEntries = new List
+ {
+ new(this._mockProvider1, Provider1Name),
+ new(this._mockProvider2, Provider2Name)
+ };
+ var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy);
+
+ this._mockProvider1.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask);
+ this._mockProvider2.InitializeAsync(this._evaluationContext, Arg.Any()).ThrowsAsync(expectedException);
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() => multiProvider.InitializeAsync(this._evaluationContext));
+ Assert.Contains("Failed to initialize providers", exception.Message);
+ Assert.Contains(Provider2Name, exception.Message);
+ Assert.Contains(expectedException, exception.InnerExceptions);
+ }
+
+ [Fact]
+ public async Task ShutdownAsync_WithAllSuccessfulProviders_ShutsDownAllProviders()
+ {
+ // Arrange
+ var providerEntries = new List
+ {
+ new(this._mockProvider1, Provider1Name),
+ new(this._mockProvider2, Provider2Name)
+ };
+ var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy);
+ multiProvider.SetStatus(ProviderStatus.Ready);
+
+ this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask);
+ this._mockProvider2.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask);
+
+ // Act
+ await multiProvider.ShutdownAsync();
+
+ // Assert
+ await this._mockProvider1.Received(1).ShutdownAsync(Arg.Any());
+ await this._mockProvider2.Received(1).ShutdownAsync(Arg.Any());
+ }
+
+ [Fact]
+ public async Task ShutdownAsync_WithFatalProvider_ShutsDownAllProviders()
+ {
+ // Arrange
+ var providerEntries = new List
+ {
+ new(this._mockProvider1, Provider1Name),
+ new(this._mockProvider2, Provider2Name)
+ };
+ var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy);
+ multiProvider.SetStatus(ProviderStatus.Fatal);
+
+ this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask);
+ this._mockProvider2.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask);
+
+ // Act
+ await multiProvider.ShutdownAsync();
+
+ // Assert
+ await this._mockProvider1.Received(1).ShutdownAsync(Arg.Any());
+ await this._mockProvider2.Received(1).ShutdownAsync(Arg.Any());
+ }
+
+ [Fact]
+ public async Task ShutdownAsync_WithSomeFailingProviders_ThrowsAggregateException()
+ {
+ // Arrange
+ var expectedException = new InvalidOperationException("Shutdown failed");
+ var providerEntries = new List
+ {
+ new(this._mockProvider1, Provider1Name),
+ new(this._mockProvider2, Provider2Name)
+ };
+ var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy);
+ multiProvider.SetStatus(ProviderStatus.Ready);
+
+ this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask);
+ this._mockProvider2.ShutdownAsync(Arg.Any()).ThrowsAsync(expectedException);
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() => multiProvider.ShutdownAsync());
+ Assert.Contains("Failed to shutdown providers", exception.Message);
+ Assert.Contains(Provider2Name, exception.Message);
+ Assert.Contains(expectedException, exception.InnerExceptions);
+ }
+
+ [Fact]
+ public void GetMetadata_ReturnsMultiProviderMetadata()
+ {
+ // Arrange
+ var providerEntries = new List { new(this._mockProvider1, Provider1Name) };
+ var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy);
+
+ // Act
+ var metadata = multiProvider.GetMetadata();
+
+ // Assert
+ Assert.NotNull(metadata);
+ Assert.Equal("MultiProvider", metadata.Name);
+ }
+
+ [Fact]
+ public async Task ResolveDoubleValueAsync_CallsEvaluateAsync()
+ {
+ // Arrange
+ const double defaultValue = 1.0;
+ const double resolvedValue = 2.5;
+ var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant);
+ var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null);
+
+ var providerEntries = new List { new(this._mockProvider1, Provider1Name) };
+ var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy);
+
+ this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>())
+ .Returns(finalResult);
+
+ // Act
+ var result = await multiProvider.ResolveDoubleValueAsync(TestFlagKey, defaultValue, this._evaluationContext);
+
+ // Assert
+ Assert.Equal(expectedDetails, result);
+ this._mockStrategy.Received(1).DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>());
+ }
+
+ [Fact]
+ public async Task ResolveIntegerValueAsync_CallsEvaluateAsync()
+ {
+ // Arrange
+ const int defaultValue = 10;
+ const int resolvedValue = 42;
+ var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant);
+ var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null);
+
+ var providerEntries = new List { new(this._mockProvider1, Provider1Name) };
+ var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy);
+
+ this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>())
+ .Returns(finalResult);
+
+ // Act
+ var result = await multiProvider.ResolveIntegerValueAsync(TestFlagKey, defaultValue, this._evaluationContext);
+
+ // Assert
+ Assert.Equal(expectedDetails, result);
+ }
+
+ [Fact]
+ public async Task ResolveStructureValueAsync_CallsEvaluateAsync()
+ {
+ // Arrange
+ var defaultValue = new Value("default");
+ var resolvedValue = new Value("resolved");
+ var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant);
+ var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null);
+
+ var providerEntries = new List { new(this._mockProvider1, Provider1Name) };
+ var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy);
+
+ this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>())
+ .Returns(finalResult);
+
+ // Act
+ var result = await multiProvider.ResolveStructureValueAsync(TestFlagKey, defaultValue, this._evaluationContext);
+
+ // Assert
+ Assert.Equal(expectedDetails, result);
+ }
+
+ [Fact]
+ public async Task EvaluateAsync_WithSequentialMode_EvaluatesProvidersSequentially()
+ {
+ // Arrange
+ const bool defaultValue = false;
+ const bool resolvedValue = true;
+ var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant);
+ var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null);
+
+ var providerEntries = new List
+ {
+ new(this._mockProvider1, Provider1Name),
+ new(this._mockProvider2, Provider2Name)
+ };
+ var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy);
+
+ this._mockStrategy.RunMode.Returns(RunMode.Sequential);
+ this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), this._evaluationContext).Returns(true);
+ this._mockStrategy.ShouldEvaluateNextProvider(Arg.Any>(), this._evaluationContext, Arg.Any>()).Returns(false);
+ this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>())
+ .Returns(finalResult);
+
+ this._mockProvider1.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any())
+ .Returns(expectedDetails);
+
+ // Act
+ var result = await multiProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext);
+
+ // Assert
+ Assert.Equal(expectedDetails, result);
+ await this._mockProvider1.Received(1).ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any());
+ await this._mockProvider2.DidNotReceive().ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task EvaluateAsync_WithParallelMode_EvaluatesProvidersInParallel()
+ {
+ // Arrange
+ const bool defaultValue = false;
+ const bool resolvedValue = true;
+ var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant);
+ var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null);
+
+ var providerEntries = new List
+ {
+ new(this._mockProvider1, Provider1Name),
+ new(this._mockProvider2, Provider2Name)
+ };
+ var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy);
+
+ this._mockStrategy.RunMode.Returns(RunMode.Parallel);
+ this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), this._evaluationContext).Returns(true);
+ this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>())
+ .Returns(finalResult);
+
+ this._mockProvider1.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any())
+ .Returns(expectedDetails);
+ this._mockProvider2.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any())
+ .Returns(expectedDetails);
+
+ // Act
+ var result = await multiProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext);
+
+ // Assert
+ Assert.Equal(expectedDetails, result);
+ await this._mockProvider1.Received(1).ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any());
+ await this._mockProvider2.Received(1).ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any());
+ }
+
+ [Fact]
+ public async Task EvaluateAsync_WithUnsupportedRunMode_ThrowsNotSupportedException()
+ {
+ // Arrange
+ const bool defaultValue = false;
+ var providerEntries = new List { new(this._mockProvider1, Provider1Name) };
+ var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy);
+
+ this._mockStrategy.RunMode.Returns((RunMode)999); // Invalid enum value
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() =>
+ multiProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext));
+ Assert.Contains("Unsupported run mode", exception.Message);
+ }
+
+ [Fact]
+ public async Task EvaluateAsync_WithStrategySkippingProvider_DoesNotCallSkippedProvider()
+ {
+ // Arrange
+ const bool defaultValue = false;
+ const bool resolvedValue = true;
+ var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant);
+ var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null);
+
+ var providerEntries = new List
+ {
+ new(this._mockProvider1, Provider1Name),
+ new(this._mockProvider2, Provider2Name)
+ };
+ var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy);
+
+ this._mockStrategy.RunMode.Returns(RunMode.Sequential);
+ this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), this._evaluationContext)
+ .Returns(callInfo =>
+ {
+ var context = callInfo.Arg>();
+ return context.ProviderName == Provider1Name; // Only evaluate provider1
+ });
+ this._mockStrategy.DetermineFinalResult(Arg.Any